diff --git a/Clarinet.toml b/Clarinet.toml new file mode 100644 index 0000000..909e6b6 --- /dev/null +++ b/Clarinet.toml @@ -0,0 +1,36 @@ +[project] +name = "tiny-market" +authors = [] +description = "" +telemetry = true +requirements = [] +analysis = ["check_checker"] +costs_version = 2 + +[contracts.sip009-nft] +path = "contracts/sip009-nft.clar" +depends_on = ["sip009-nft-trait"] + +[contracts.bogus-nft] +path = "contracts/sip009-nft.clar" +depends_on = ["sip009-nft-trait"] + +[contracts.sip009-nft-trait] +path = "contracts/sip009-nft-trait.clar" +depends_on = [] + +[contracts.sip010-ft-trait] +path = "contracts/sip010-ft-trait.clar" +depends_on = [] + +[contracts.sip010-token] +path = "contracts/sip010-token.clar" +depends_on = ["sip010-ft-trait"] + +[contracts.bogus-ft] +path = "contracts/sip010-token.clar" +depends_on = ["sip010-ft-trait"] + +[contracts.tiny-market] +path = "contracts/tiny-market.clar" +depends_on = ["sip009-nft-trait", "sip010-ft-trait"] diff --git a/contracts/sip009-nft-trait.clar b/contracts/sip009-nft-trait.clar new file mode 100644 index 0000000..b2895f5 --- /dev/null +++ b/contracts/sip009-nft-trait.clar @@ -0,0 +1,21 @@ +;; sip009 must conform to specific implementation +;; Clarity does not follow a class hierarchy model, instead it uses traits +;; which define public interface of contract +(define-trait sip009-nft-trait + ( + ;; Last token ID, limited to uint range + (get-last-token-id () (response uint uint)) + + ;; URI for metadata associated with the token + (get-token-uri (uint) (response (optional (string-ascii 256)) uint)) + + ;; Owner of a given token identifier + (get-owner (uint) (response (optional principal) uint)) + + ;; Transfer from the sender to a new principal + (transfer (uint principal principal) (response bool uint)) + ) +) +;; parameters are not explicitly defined by name because only the type is important +;; the defined parameters can be found in sip documentation +;; eg. transfer: uint = token id, principal = sender, principal = recipient \ No newline at end of file diff --git a/contracts/sip009-nft.clar b/contracts/sip009-nft.clar new file mode 100644 index 0000000..95b4603 --- /dev/null +++ b/contracts/sip009-nft.clar @@ -0,0 +1,52 @@ +;; defines trait to be implemented in the contract +;; implement the contract that the trait is in +;; then identify the trait by the name (since there can be more than one in the contract) +(impl-trait .sip009-nft-trait.sip009-nft-trait) + +;; SIP009 NFT trait on mainnet +;; (impl-trait 'SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait.nft-trait) + +;; define nft and identifier type +(define-non-fungible-token sip009-nft uint) + +;; tx-sender is a built in keyword that always refers to whoever sent the transaction +(define-constant contract-owner tx-sender) +(define-constant err-owner-only (err u100)) +(define-constant err-not-token-owner (err u101)) +(define-data-var last-token-id uint u0) + +;; return a response with the id of the last minted token +(define-public (get-last-token-id) + (ok (var-get last-token-id)) +) + +(define-read-only (get-owner (token-id uint)) + (ok (nft-get-owner? sip009-nft token-id)) +) + +(define-read-only (get-token-uri (token-id uint)) + (ok none) +) + +;; begin used to add more than one expression +(define-public (transfer (token-id uint) (sender principal) (recipient principal)) + (begin + (asserts! (is-eq tx-sender sender) err-not-token-owner) + (nft-transfer? sip009-nft token-id sender recipient) + ) +) + +;; mint a token +(define-public (mint (recipient principal)) + (let + ( + ;; increment token id by 1 + (token-id (+ (var-get last-token-id) u1)) + ) + ;; only contract owner can mint (for testing) + (asserts! (is-eq tx-sender contract-owner) err-owner-only) + (try! (nft-mint? sip009-nft token-id recipient)) + (var-set last-token-id token-id) + (ok token-id) + ) +) \ No newline at end of file diff --git a/contracts/sip010-ft-trait.clar b/contracts/sip010-ft-trait.clar new file mode 100644 index 0000000..348bc4b --- /dev/null +++ b/contracts/sip010-ft-trait.clar @@ -0,0 +1,24 @@ +(define-trait sip010-ft-trait + ( + ;; Transfer from the caller to a new principal + (transfer (uint principal principal (optional (buff 34))) (response bool uint)) + + ;; the human readable name of the token + (get-name () (response (string-ascii 32) uint)) + + ;; the ticker symbol, or empty if none + (get-symbol () (response (string-ascii 32) uint)) + + ;; the number of decimals used, e.g. 6 would mean 1_000_000 represents 1 token + (get-decimals () (response uint uint)) + + ;; the balance of the passed principal + (get-balance (principal) (response uint uint)) + + ;; the current total supply (which does not need to be a constant) + (get-total-supply () (response uint uint)) + + ;; an optional URI that represents metadata of this token + (get-token-uri () (response (optional (string-utf8 256)) uint)) + ) +) \ No newline at end of file diff --git a/contracts/sip010-token.clar b/contracts/sip010-token.clar new file mode 100644 index 0000000..ae5fd37 --- /dev/null +++ b/contracts/sip010-token.clar @@ -0,0 +1,51 @@ +(impl-trait .sip010-ft-trait.sip010-ft-trait) + +;; SIP010 trait on mainnet +;; (impl-trait 'SP3FBR2AGK5H9QBDH3EEN6DF8EK8JY7RX8QJ5SVTE.sip-010-trait-ft-standard.sip-010-trait) + +(define-constant contract-owner tx-sender) +(define-constant err-owner-only (err u100)) +(define-constant err-not-token-owner (err u101)) + +;; No maximum supply! +(define-fungible-token sip010-token) + +(define-public (transfer (amount uint) (sender principal) (recipient principal) (memo (optional (buff 34)))) + (begin + (asserts! (is-eq tx-sender sender) err-owner-only) + (try! (ft-transfer? sip010-token amount sender recipient)) + (match memo to-print (print to-print) 0x) + (ok true) + ) +) + +(define-read-only (get-name) + (ok "Clarity Coin") +) + +(define-read-only (get-symbol) + (ok "CC") +) + +(define-read-only (get-decimals) + (ok u0) +) + +(define-read-only (get-balance (who principal)) + (ok (ft-get-balance sip010-token who)) +) + +(define-read-only (get-total-supply) + (ok (ft-get-supply sip010-token)) +) + +(define-read-only (get-token-uri) + (ok none) +) + +(define-public (mint (amount uint) (recipient principal)) + (begin + (asserts! (is-eq tx-sender contract-owner) err-owner-only) + (ft-mint? sip010-token amount recipient) + ) +) \ No newline at end of file diff --git a/contracts/tiny-market.clar b/contracts/tiny-market.clar new file mode 100644 index 0000000..0eab42c --- /dev/null +++ b/contracts/tiny-market.clar @@ -0,0 +1,176 @@ +;; import sip traits +(use-trait nft-trait .sip009-nft-trait.sip009-nft-trait) +(use-trait ft-trait .sip010-ft-trait.sip010-ft-trait) + +;; define contract owner +(define-constant contract-owner tx-sender) + +;; Think about the various error states that exist in our marketplace. +;; Listing an NFT may fail under a number of circumstances; namely, +;; the expiry block height is in the past, or the listing price is zero (we will not allow free listings). +;; There is also the consideration of the using trying to list the NFT not actually owning it, +;; but this will be handled by the NFT contract itself. +;; listing errors +(define-constant err-expiry-in-past (err u1000)) +(define-constant err-price-zero (err u1001)) + +;; When it comes to cancelling and fulfilling, there are a few more error conditions we can identify: +;; The listing the tx-sender wants to cancel or fulfil does not exist. +;; The tx-sender tries to cancel a listing it did not create. +;; The listing the tx-sender tries to fill has expired. +;; The provided NFT asset trait reference does not match the NFT contract of the listing. +;; Since trait references cannot be stored directly in Clarity, they will have to be provided again when +;; the buyer is trying to purchase an NFT. We have to make sure that the trait reference provided by the buyer +;; matches the NFT contract provided by the seller. +;; The provided payment asset trait reference does not match the payment asset contract of the listing. +;; The same as the above but for the SIP010 being used to purchase the NFT. +;; The maker and the taker (seller and the buyer) are equal. We will not permit users to purchase tokens +;; from themselves using the same principal. +;; The buyer is not the intended taker. If the seller defines an intended taker (buyer) for the listing, +;; then only that principal can fulfil the listing. +;; cancelling and fulfilling errors +(define-constant err-unknown-listing (err u2000)) +(define-constant err-unauthorised (err u2001)) +(define-constant err-listing-expired (err u2002)) +(define-constant err-nft-asset-mismatch (err u2003)) +(define-constant err-payment-asset-mismatch (err u2004)) +(define-constant err-maker-taker-equal (err u2005)) +(define-constant err-unintended-taker (err u2006)) + +;; Finally, we will implement a whitelist for NFT and payment asset contracts that the contract deployer controls. +;; It makes for two additional error conditions: +;; The NFT asset the seller is trying to list is not whitelisted. +;; The requested payment asset is not whitelisted. +(define-constant err-asset-contract-not-whitelisted (err u2007)) +(define-constant err-payment-contract-not-whitelisted (err u2008)) + +;; The most efficient way to store the individual listings is by using a data map that uses +;; an unsigned integer as a key. The integer functions as a unique identifier and will increment +;; for each new listing. We will never reuse a value. To track the latest listing ID, we will use a simple data variable. +(define-map listings + uint + { + maker: principal, + taker: (optional principal), + token-id: uint, + nft-asset-contract: principal, + expiry: uint, + price: uint, + ;; It is important to utilise the native types in Clarity to the fullest extent possible. + ;; A listing does not need to have an intended taker, so we make it optional. + payment-asset-contract: (optional principal) + } +) + +(define-data-var listing-nonce uint u0) + +;; The whitelist itself is a simple map that stores a boolean for a given contract principal. +;; A guarded public function set-whitelisted is used to update the whitelist and a read-only function +;; is-whitelisted allows anyone to check if a particular contract is whitelisted or not. +;; We will also use is-whitelisted to guard other public functions later. +(define-map whitelisted-asset-contracts principal bool) + +(define-read-only (is-whitelisted (asset-contract principal)) + (default-to false (map-get? whitelisted-asset-contracts asset-contract)) +) + +(define-public (set-whitelisted (asset-contract principal) (whitelisted bool)) + (begin + (asserts! (is-eq contract-owner tx-sender) err-unauthorised) + (ok (map-set whitelisted-asset-contracts asset-contract whitelisted)) + ) +) +;; take a trait reference (either SIP009 or SIP010) and then do the proper contract-call? to transfer the token. +(define-private (transfer-nft (token-contract ) (token-id uint) (sender principal) (recipient principal)) + (contract-call? token-contract transfer token-id sender recipient) +) + +(define-private (transfer-ft (token-contract ) (amount uint) (sender principal) (recipient principal)) + (contract-call? token-contract transfer amount sender recipient none) +) + +;; Retrieve the current listing ID to use by reading the listing-nonce variable. +;; Assert that the NFT asset is whitelisted. +;; Assert that the provided expiry height is somewhere in the future. +;; Assert that the listing price is larger than zero. +;; If a payment asset is given, assert that it is whitelisted. +;; Transfer the NFT from the tx-sender to the marketplace. +;; Store the listing information in the listings data map. +;; Increment the listing-nonce variable. +;; Return an ok to materialise the changes. +(define-public (list-asset (nft-asset-contract ) (nft-asset {taker: (optional principal), token-id: uint, expiry: uint, price: uint, payment-asset-contract: (optional principal)})) + (let ((listing-id (var-get listing-nonce))) + (asserts! (is-whitelisted (contract-of nft-asset-contract)) err-asset-contract-not-whitelisted) + (asserts! (> (get expiry nft-asset) block-height) err-expiry-in-past) + (asserts! (> (get price nft-asset) u0) err-price-zero) + (asserts! (match (get payment-asset-contract nft-asset) payment-asset (is-whitelisted payment-asset) true) err-payment-contract-not-whitelisted) + (try! (transfer-nft nft-asset-contract (get token-id nft-asset) tx-sender (as-contract tx-sender))) + (map-set listings listing-id (merge {maker: tx-sender, nft-asset-contract: (contract-of nft-asset-contract)} nft-asset)) + (var-set listing-nonce (+ listing-id u1)) + (ok listing-id) + ) +) + +(define-read-only (get-listing (listing-id uint)) + (map-get? listings listing-id) +) + +;; A listing is available until it either expires or is cancelled by the maker. +;; When the maker cancels the listing, all that has to happen is for the marketplace to send +;; the NFT back and delete the listing from the data map. The maker only has to provide the +;; listing ID and the NFT asset contract trait reference. The rest can be read from the data map. +(define-public (cancel-listing (listing-id uint) (nft-asset-contract )) + (let ( + (listing (unwrap! (map-get? listings listing-id) err-unknown-listing)) + (maker (get maker listing)) + ) + (asserts! (is-eq maker tx-sender) err-unauthorised) + (asserts! (is-eq (get nft-asset-contract listing) (contract-of nft-asset-contract)) err-nft-asset-mismatch) + (map-delete listings listing-id) + (as-contract (transfer-nft nft-asset-contract (get token-id listing) tx-sender maker)) + ) +) + +;; Retrieve the listing from the listings data map and abort if it does not exist. +;; Assert that the taker is not equal to the maker. +;; Assert that the expiry block height has not been reached. +;; Assert that the provided NFT trait reference is equal to the principal stored in the listing. +;; Assert that the payment asset trait reference, if any, is equal to the one stored in the listing. +;; Transfer the NFT from the contract to the buyer and the payment asset from the buyer to the seller and revert if either transfer fails. +;; Delete the listing from the listings data map. +(define-private (assert-can-fulfil (nft-asset-contract principal) (payment-asset-contract (optional principal)) (listing {maker: principal, taker: (optional principal), token-id: uint, nft-asset-contract: principal, expiry: uint, price: uint, payment-asset-contract: (optional principal)})) + (begin + (asserts! (not (is-eq (get maker listing) tx-sender)) err-maker-taker-equal) + (asserts! (match (get taker listing) intended-taker (is-eq intended-taker tx-sender) true) err-unintended-taker) + (asserts! (< block-height (get expiry listing)) err-listing-expired) + (asserts! (is-eq (get nft-asset-contract listing) nft-asset-contract) err-nft-asset-mismatch) + (asserts! (is-eq (get payment-asset-contract listing) payment-asset-contract) err-payment-asset-mismatch) + (ok true) + ) +) + +(define-public (fulfil-listing-stx (listing-id uint) (nft-asset-contract )) + (let ( + (listing (unwrap! (map-get? listings listing-id) err-unknown-listing)) + (taker tx-sender) + ) + (try! (assert-can-fulfil (contract-of nft-asset-contract) none listing)) + (try! (as-contract (transfer-nft nft-asset-contract (get token-id listing) tx-sender taker))) + (try! (stx-transfer? (get price listing) taker (get maker listing))) + (map-delete listings listing-id) + (ok listing-id) + ) +) + +(define-public (fulfil-listing-ft (listing-id uint) (nft-asset-contract ) (payment-asset-contract )) + (let ( + (listing (unwrap! (map-get? listings listing-id) err-unknown-listing)) + (taker tx-sender) + ) + (try! (assert-can-fulfil (contract-of nft-asset-contract) (some (contract-of payment-asset-contract)) listing)) + (try! (as-contract (transfer-nft nft-asset-contract (get token-id listing) tx-sender taker))) + (try! (transfer-ft payment-asset-contract (get price listing) taker (get maker listing))) + (map-delete listings listing-id) + (ok listing-id) + ) +) \ No newline at end of file diff --git a/settings/Devnet.toml b/settings/Devnet.toml new file mode 100644 index 0000000..8a5ff75 --- /dev/null +++ b/settings/Devnet.toml @@ -0,0 +1,127 @@ +[network] +name = "devnet" +deployment_fee_rate = 10 + +[accounts.deployer] +mnemonic = "twice kind fence tip hidden tilt action fragile skin nothing glory cousin green tomorrow spring wrist shed math olympic multiply hip blue scout claw" +balance = 100_000_000_000_000 +# secret_key: 753b7cc01a1a2e86221266a154af739463fce51219d97e4f856cd7200c3bd2a601 +# stx_address: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM +# btc_address: mqVnk6NPRdhntvfm4hh9vvjiRkFDUuSYsH + +[accounts.wallet_1] +mnemonic = "sell invite acquire kitten bamboo drastic jelly vivid peace spawn twice guilt pave pen trash pretty park cube fragile unaware remain midnight betray rebuild" +balance = 100_000_000_000_000 +# secret_key: 7287ba251d44a4d3fd9276c88ce34c5c52a038955511cccaf77e61068649c17801 +# stx_address: ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5 +# btc_address: mr1iPkD9N3RJZZxXRk7xF9d36gffa6exNC + +[accounts.wallet_2] +mnemonic = "hold excess usual excess ring elephant install account glad dry fragile donkey gaze humble truck breeze nation gasp vacuum limb head keep delay hospital" +balance = 100_000_000_000_000 +# secret_key: 530d9f61984c888536871c6573073bdfc0058896dc1adfe9a6a10dfacadc209101 +# stx_address: ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG +# btc_address: muYdXKmX9bByAueDe6KFfHd5Ff1gdN9ErG + +[accounts.wallet_3] +mnemonic = "cycle puppy glare enroll cost improve round trend wrist mushroom scorpion tower claim oppose clever elephant dinosaur eight problem before frozen dune wagon high" +balance = 100_000_000_000_000 +# secret_key: d655b2523bcd65e34889725c73064feb17ceb796831c0e111ba1a552b0f31b3901 +# stx_address: ST2JHG361ZXG51QTKY2NQCVBPPRRE2KZB1HR05NNC +# btc_address: mvZtbibDAAA3WLpY7zXXFqRa3T4XSknBX7 + +[accounts.wallet_4] +mnemonic = "board list obtain sugar hour worth raven scout denial thunder horse logic fury scorpion fold genuine phrase wealth news aim below celery when cabin" +balance = 100_000_000_000_000 +# secret_key: f9d7206a47f14d2870c163ebab4bf3e70d18f5d14ce1031f3902fbbc894fe4c701 +# stx_address: ST2NEB84ASENDXKYGJPQW86YXQCEFEX2ZQPG87ND +# btc_address: mg1C76bNTutiCDV3t9nWhZs3Dc8LzUufj8 + +[accounts.wallet_5] +mnemonic = "hurry aunt blame peanut heavy update captain human rice crime juice adult scale device promote vast project quiz unit note reform update climb purchase" +balance = 100_000_000_000_000 +# secret_key: 3eccc5dac8056590432db6a35d52b9896876a3d5cbdea53b72400bc9c2099fe801 +# stx_address: ST2REHHS5J3CERCRBEPMGH7921Q6PYKAADT7JP2VB +# btc_address: mweN5WVqadScHdA81aATSdcVr4B6dNokqx + +[accounts.wallet_6] +mnemonic = "area desk dutch sign gold cricket dawn toward giggle vibrant indoor bench warfare wagon number tiny universe sand talk dilemma pottery bone trap buddy" +balance = 100_000_000_000_000 +# secret_key: 7036b29cb5e235e5fd9b09ae3e8eec4404e44906814d5d01cbca968a60ed4bfb01 +# stx_address: ST3AM1A56AK2C1XAFJ4115ZSV26EB49BVQ10MGCS0 +# btc_address: mzxXgV6e4BZSsz8zVHm3TmqbECt7mbuErt + +[accounts.wallet_7] +mnemonic = "prevent gallery kind limb income control noise together echo rival record wedding sense uncover school version force bleak nuclear include danger skirt enact arrow" +balance = 100_000_000_000_000 +# secret_key: b463f0df6c05d2f156393eee73f8016c5372caa0e9e29a901bb7171d90dc4f1401 +# stx_address: ST3PF13W7Z0RRM42A8VZRVFQ75SV1K26RXEP8YGKJ +# btc_address: n37mwmru2oaVosgfuvzBwgV2ysCQRrLko7 + +[accounts.wallet_8] +mnemonic = "female adjust gallery certain visit token during great side clown fitness like hurt clip knife warm bench start reunion globe detail dream depend fortune" +balance = 100_000_000_000_000 +# secret_key: 6a1a754ba863d7bab14adbbc3f8ebb090af9e871ace621d3e5ab634e1422885e01 +# stx_address: ST3NBRSFKX28FQ2ZJ1MAKX58HKHSDGNV5N7R21XCP +# btc_address: n2v875jbJ4RjBnTjgbfikDfnwsDV5iUByw + +[accounts.wallet_9] +mnemonic = "shadow private easily thought say logic fault paddle word top book during ignore notable orange flight clock image wealth health outside kitten belt reform" +balance = 100_000_000_000_000 +# secret_key: de433bdfa14ec43aa1098d5be594c8ffb20a31485ff9de2923b2689471c401b801 +# stx_address: STNHKEPYEPJ8ET55ZZ0M5A34J0R3N5FM2CMMMAZ6 +# btc_address: mjSrB3wS4xab3kYqFktwBzfTdPg367ZJ2d + +[devnet] +disable_bitcoin_explorer = true +# disable_stacks_explorer = true +# disable_stacks_api = true +# working_dir = "tmp/devnet" +# stacks_node_events_observers = ["host.docker.internal:8002"] +# miner_mnemonic = "twice kind fence tip hidden tilt action fragile skin nothing glory cousin green tomorrow spring wrist shed math olympic multiply hip blue scout claw" +# miner_derivation_path = "m/44'/5757'/0'/0/0" +# orchestrator_port = 20445 +# bitcoin_node_p2p_port = 18444 +# bitcoin_node_rpc_port = 18443 +# bitcoin_node_username = "devnet" +# bitcoin_node_password = "devnet" +# bitcoin_controller_port = 18442 +# bitcoin_controller_block_time = 30_000 +# stacks_node_rpc_port = 20443 +# stacks_node_p2p_port = 20444 +# stacks_api_port = 3999 +# stacks_api_events_port = 3700 +# bitcoin_explorer_port = 8001 +# stacks_explorer_port = 8000 +# postgres_port = 5432 +# postgres_username = "postgres" +# postgres_password = "postgres" +# postgres_database = "postgres" +# bitcoin_node_image_url = "quay.io/hirosystems/bitcoind:devnet" +# stacks_node_image_url = "localhost:5000/stacks-node:devnet" +# stacks_api_image_url = "blockstack/stacks-blockchain-api:latest" +# stacks_explorer_image_url = "blockstack/explorer:latest" +# bitcoin_explorer_image_url = "quay.io/hirosystems/bitcoin-explorer:devnet" +# postgres_image_url = "postgres:alpine" + +# Send some stacking orders +[[devnet.pox_stacking_orders]] +start_at_cycle = 3 +duration = 12 +wallet = "wallet_1" +slots = 2 +btc_address = "mr1iPkD9N3RJZZxXRk7xF9d36gffa6exNC" + +[[devnet.pox_stacking_orders]] +start_at_cycle = 3 +duration = 12 +wallet = "wallet_2" +slots = 1 +btc_address = "muYdXKmX9bByAueDe6KFfHd5Ff1gdN9ErG" + +[[devnet.pox_stacking_orders]] +start_at_cycle = 3 +duration = 12 +wallet = "wallet_3" +slots = 1 +btc_address = "mvZtbibDAAA3WLpY7zXXFqRa3T4XSknBX7" diff --git a/settings/Mainnet.toml b/settings/Mainnet.toml new file mode 100644 index 0000000..bb18cab --- /dev/null +++ b/settings/Mainnet.toml @@ -0,0 +1,7 @@ +[network] +name = "mainnet" +node_rpc_address = "https://stacks-node-api.mainnet.stacks.co" +deployment_fee_rate = 10 + +[accounts.deployer] +mnemonic = "" diff --git a/settings/Testnet.toml b/settings/Testnet.toml new file mode 100644 index 0000000..0aae938 --- /dev/null +++ b/settings/Testnet.toml @@ -0,0 +1,7 @@ +[network] +name = "testnet" +node_rpc_address = "https://stacks-node-api.testnet.stacks.co" +deployment_fee_rate = 10 + +[accounts.deployer] +mnemonic = "" diff --git a/tests/tiny-market_test.ts b/tests/tiny-market_test.ts new file mode 100644 index 0000000..103f94c --- /dev/null +++ b/tests/tiny-market_test.ts @@ -0,0 +1,497 @@ + +import { Clarinet, Tx, Chain, Account, types } from 'https://deno.land/x/clarinet@v0.14.0/index.ts'; +import { assertEquals } from 'https://deno.land/std@0.90.0/testing/asserts.ts'; + +const contractName = 'tiny-market'; + +const defaultNftAssetContract = 'sip009-nft'; +const defaultPaymentAssetContract = 'sip010-token'; + +const contractPrincipal = (deployer: Account) => `${deployer.address}.${contractName}`; + +function mintNft({ chain, deployer, recipient, nftAssetContract = defaultNftAssetContract }: { chain: Chain, deployer: Account, recipient: Account, nftAssetContract?: string }) { + const block = chain.mineBlock([ + Tx.contractCall(nftAssetContract, 'mint', [types.principal(recipient.address)], deployer.address), + ]); + block.receipts[0].result.expectOk(); + const nftMintEvent = block.receipts[0].events[0].nft_mint_event; + const [nftAssetContractPrincipal, nftAssetId] = nftMintEvent.asset_identifier.split('::'); + return { nftAssetContract: nftAssetContractPrincipal, nftAssetId, tokenId: nftMintEvent.value.substr(1), block }; +} + +function mintFt({ chain, deployer, amount, recipient, paymentAssetContract = defaultPaymentAssetContract }: { chain: Chain, deployer: Account, amount: number, recipient: Account, paymentAssetContract?: string }) { + const block = chain.mineBlock([ + Tx.contractCall(paymentAssetContract, 'mint', [types.uint(amount), types.principal(recipient.address)], deployer.address), + ]); + block.receipts[0].result.expectOk(); + const ftMintEvent = block.receipts[0].events[0].ft_mint_event; + const [paymentAssetContractPrincipal, paymentAssetId] = ftMintEvent.asset_identifier.split('::'); + return { paymentAssetContract: paymentAssetContractPrincipal, paymentAssetId, block }; +} + +interface Sip009NftTransferEvent { + type: string, + nft_transfer_event: { + asset_identifier: string, + sender: string, + recipient: string, + value: string + } +} + +function assertNftTransfer(event: Sip009NftTransferEvent, nftAssetContract: string, tokenId: number, sender: string, recipient: string) { + assertEquals(typeof event, 'object'); + assertEquals(event.type, 'nft_transfer_event'); + assertEquals(event.nft_transfer_event.asset_identifier.substr(0, nftAssetContract.length), nftAssetContract); + event.nft_transfer_event.sender.expectPrincipal(sender); + event.nft_transfer_event.recipient.expectPrincipal(recipient); + event.nft_transfer_event.value.expectUint(tokenId); +} + +interface Order { + taker?: string, + tokenId: number, + expiry: number, + price: number, + paymentAssetContract?: string +} + +const makeOrder = (order: Order) => + types.tuple({ + 'taker': order.taker ? types.some(types.principal(order.taker)) : types.none(), + 'token-id': types.uint(order.tokenId), + 'expiry': types.uint(order.expiry), + 'price': types.uint(order.price), + 'payment-asset-contract': order.paymentAssetContract ? types.some(types.principal(order.paymentAssetContract)) : types.none(), + }); + +const whitelistAssetTx = (assetContract: string, whitelisted: boolean, contractOwner: Account) => + Tx.contractCall(contractName, 'set-whitelisted', [types.principal(assetContract), types.bool(whitelisted)], contractOwner.address); + +const listOrderTx = (nftAssetContract: string, maker: Account, order: Order | string) => + Tx.contractCall(contractName, 'list-asset', [types.principal(nftAssetContract), typeof order === 'string' ? order : makeOrder(order)], maker.address); + +Clarinet.test({ + name: "Can list an NFT for sale for STX", + async fn(chain: Chain, accounts: Map) { + const [deployer, maker] = ['deployer', 'wallet_1'].map(name => accounts.get(name)!); + const { nftAssetContract, tokenId } = mintNft({ chain, deployer, recipient: maker }); + const order: Order = { tokenId, expiry: 10, price: 10 }; + const block = chain.mineBlock([ + whitelistAssetTx(nftAssetContract, true, deployer), + listOrderTx(nftAssetContract, maker, order) + ]); + block.receipts[1].result.expectOk().expectUint(0); + assertNftTransfer(block.receipts[1].events[0], nftAssetContract, tokenId, maker.address, contractPrincipal(deployer)); + } +}); + +Clarinet.test({ + name: "Can list an NFT for sale for any SIP010 fungible token", + async fn(chain: Chain, accounts: Map) { + const [deployer, maker] = ['deployer', 'wallet_1'].map(name => accounts.get(name)!); + const { nftAssetContract, tokenId } = mintNft({ chain, deployer, recipient: maker }); + const { paymentAssetContract } = mintFt({ chain, deployer, recipient: maker, amount: 1 }); + const order: Order = { tokenId, expiry: 10, price: 10, paymentAssetContract }; + const block = chain.mineBlock([ + whitelistAssetTx(nftAssetContract, true, deployer), + whitelistAssetTx(paymentAssetContract, true, deployer), + listOrderTx(nftAssetContract, maker, order) + ]); + block.receipts[2].result.expectOk().expectUint(0); + assertNftTransfer(block.receipts[2].events[0], nftAssetContract, tokenId, maker.address, contractPrincipal(deployer)); + } +}); + +Clarinet.test({ + name: "Cannot list an NFT for sale if the expiry is in the past", + async fn(chain: Chain, accounts: Map) { + const [deployer, maker] = ['deployer', 'wallet_1'].map(name => accounts.get(name)!); + const { nftAssetContract, tokenId } = mintNft({ chain, deployer, recipient: maker }); + const expiry = 10; + const order: Order = { tokenId, expiry, price: 10 }; + chain.mineEmptyBlockUntil(expiry + 1); + const block = chain.mineBlock([ + whitelistAssetTx(nftAssetContract, true, deployer), + listOrderTx(nftAssetContract, maker, order) + ]); + block.receipts[1].result.expectErr().expectUint(1000); + assertEquals(block.receipts[1].events.length, 0); + } +}); + +Clarinet.test({ + name: "Cannot list an NFT for sale for nothing", + async fn(chain: Chain, accounts: Map) { + const [deployer, maker] = ['deployer', 'wallet_1'].map(name => accounts.get(name)!); + const { nftAssetContract, tokenId } = mintNft({ chain, deployer, recipient: maker }); + const order: Order = { tokenId, expiry: 10, price: 0 }; + const block = chain.mineBlock([ + whitelistAssetTx(nftAssetContract, true, deployer), + listOrderTx(nftAssetContract, maker, order) + ]); + block.receipts[1].result.expectErr().expectUint(1001); + assertEquals(block.receipts[1].events.length, 0); + } +}); + +Clarinet.test({ + name: "Cannot list an NFT for sale that the sender does not own", + async fn(chain: Chain, accounts: Map) { + const [deployer, maker, taker] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); + const { nftAssetContract, tokenId } = mintNft({ chain, deployer, recipient: taker }); + const order: Order = { tokenId, expiry: 10, price: 10 }; + const block = chain.mineBlock([ + whitelistAssetTx(nftAssetContract, true, deployer), + listOrderTx(nftAssetContract, maker, order) + ]); + block.receipts[1].result.expectErr().expectUint(1); + assertEquals(block.receipts[1].events.length, 0); + } +}); + +Clarinet.test({ + name: "Maker can cancel a listing", + async fn(chain: Chain, accounts: Map) { + const [deployer, maker] = ['deployer', 'wallet_1'].map(name => accounts.get(name)!); + const { nftAssetContract, tokenId } = mintNft({ chain, deployer, recipient: maker }); + const order: Order = { tokenId, expiry: 10, price: 10 }; + const block = chain.mineBlock([ + whitelistAssetTx(nftAssetContract, true, deployer), + listOrderTx(nftAssetContract, maker, order), + Tx.contractCall(contractName, 'cancel-listing', [types.uint(0), types.principal(nftAssetContract)], maker.address) + ]); + block.receipts[2].result.expectOk().expectBool(true); + assertNftTransfer(block.receipts[2].events[0], nftAssetContract, tokenId, contractPrincipal(deployer), maker.address); + } +}); + +Clarinet.test({ + name: "Non-maker cannot cancel listing", + async fn(chain: Chain, accounts: Map) { + const [deployer, maker, otherAccount] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); + const { nftAssetContract, tokenId } = mintNft({ chain, deployer, recipient: maker }); + const order: Order = { tokenId, expiry: 10, price: 10 }; + const block = chain.mineBlock([ + whitelistAssetTx(nftAssetContract, true, deployer), + listOrderTx(nftAssetContract, maker, order), + Tx.contractCall(contractName, 'cancel-listing', [types.uint(0), types.principal(nftAssetContract)], otherAccount.address) + ]); + block.receipts[2].result.expectErr().expectUint(2001); + assertEquals(block.receipts[2].events.length, 0); + } +}); + +Clarinet.test({ + name: "Can get listings that have not been cancelled", + async fn(chain: Chain, accounts: Map) { + const [deployer, maker] = ['deployer', 'wallet_1'].map(name => accounts.get(name)!); + const { nftAssetContract, tokenId } = mintNft({ chain, deployer, recipient: maker }); + const order: Order = { tokenId, expiry: 10, price: 10 }; + const block = chain.mineBlock([ + whitelistAssetTx(nftAssetContract, true, deployer), + listOrderTx(nftAssetContract, maker, order) + ]); + const listingIdUint = block.receipts[1].result.expectOk(); + const receipt = chain.callReadOnlyFn(contractName, 'get-listing', [listingIdUint], deployer.address); + const listing: { [key: string]: string } = receipt.result.expectSome().expectTuple() as any; + + listing['expiry'].expectUint(order.expiry); + listing['maker'].expectPrincipal(maker.address); + listing['payment-asset-contract'].expectNone(); + listing['price'].expectUint(order.price); + listing['taker'].expectNone(); + listing['nft-asset-contract'].expectPrincipal(nftAssetContract); + listing['token-id'].expectUint(tokenId); + } +}); + +Clarinet.test({ + name: "Cannot get listings that have been cancelled or do not exist", + async fn(chain: Chain, accounts: Map) { + const [deployer, maker] = ['deployer', 'wallet_1'].map(name => accounts.get(name)!); + const { nftAssetContract, tokenId } = mintNft({ chain, deployer, recipient: maker }); + const order: Order = { tokenId, expiry: 10, price: 10 }; + chain.mineBlock([ + listOrderTx(nftAssetContract, maker, order), + Tx.contractCall(contractName, 'cancel-listing', [types.uint(0), types.principal(nftAssetContract)], maker.address) + ]); + const receipts = [types.uint(0), types.uint(999)].map(listingId => chain.callReadOnlyFn(contractName, 'get-listing', [listingId], deployer.address)); + receipts.map(receipt => receipt.result.expectNone()); + } +}); + +Clarinet.test({ + name: "Can fulfil an active listing with STX", + async fn(chain: Chain, accounts: Map) { + const [deployer, maker, taker] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); + const { nftAssetContract, tokenId } = mintNft({ chain, deployer, recipient: maker }); + const order: Order = { tokenId, expiry: 10, price: 10 }; + const block = chain.mineBlock([ + whitelistAssetTx(nftAssetContract, true, deployer), + listOrderTx(nftAssetContract, maker, order), + Tx.contractCall(contractName, 'fulfil-listing-stx', [types.uint(0), types.principal(nftAssetContract)], taker.address) + ]); + block.receipts[2].result.expectOk().expectUint(0); + assertNftTransfer(block.receipts[2].events[0], nftAssetContract, tokenId, contractPrincipal(deployer), taker.address); + block.receipts[2].events.expectSTXTransferEvent(order.price, taker.address, maker.address); + } +}); + +Clarinet.test({ + name: "Can fulfil an active listing with SIP010 fungible tokens", + async fn(chain: Chain, accounts: Map) { + const [deployer, maker, taker] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); + const price = 50; + const { nftAssetContract, tokenId } = mintNft({ chain, deployer, recipient: maker }); + const { paymentAssetContract, paymentAssetId } = mintFt({ chain, deployer, recipient: taker, amount: price }); + const order: Order = { tokenId, expiry: 10, price, paymentAssetContract }; + const block = chain.mineBlock([ + whitelistAssetTx(nftAssetContract, true, deployer), + whitelistAssetTx(paymentAssetContract, true, deployer), + listOrderTx(nftAssetContract, maker, order), + Tx.contractCall(contractName, 'fulfil-listing-ft', [types.uint(0), types.principal(nftAssetContract), types.principal(paymentAssetContract)], taker.address) + ]); + block.receipts[3].result.expectOk().expectUint(0); + assertNftTransfer(block.receipts[3].events[0], nftAssetContract, tokenId, contractPrincipal(deployer), taker.address); + block.receipts[3].events.expectFungibleTokenTransferEvent(price, taker.address, maker.address, paymentAssetId); + } +}); + +Clarinet.test({ + name: "Cannot fulfil own listing", + async fn(chain: Chain, accounts: Map) { + const [deployer, maker] = ['deployer', 'wallet_1'].map(name => accounts.get(name)!); + const { nftAssetContract, tokenId } = mintNft({ chain, deployer, recipient: maker }); + const order: Order = { tokenId, expiry: 10, price: 10 }; + const block = chain.mineBlock([ + whitelistAssetTx(nftAssetContract, true, deployer), + listOrderTx(nftAssetContract, maker, order), + Tx.contractCall(contractName, 'fulfil-listing-stx', [types.uint(0), types.principal(nftAssetContract)], maker.address) + ]); + block.receipts[2].result.expectErr().expectUint(2005); + assertEquals(block.receipts[2].events.length, 0); + } +}); + +Clarinet.test({ + name: "Cannot fulfil an unknown listing", + async fn(chain: Chain, accounts: Map) { + const [deployer, maker, taker] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); + const { nftAssetContract } = mintNft({ chain, deployer, recipient: maker }); + const block = chain.mineBlock([ + whitelistAssetTx(nftAssetContract, true, deployer), + Tx.contractCall(contractName, 'fulfil-listing-stx', [types.uint(0), types.principal(nftAssetContract)], taker.address) + ]) + block.receipts[1].result.expectErr().expectUint(2000); + assertEquals(block.receipts[1].events.length, 0); + } +}); + +Clarinet.test({ + name: "Cannot fulfil an expired listing", + async fn(chain: Chain, accounts: Map) { + const [deployer, maker, taker] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); + const expiry = 10; + const { nftAssetContract, tokenId } = mintNft({ chain, deployer, recipient: maker }); + const order: Order = { tokenId, expiry, price: 10 }; + chain.mineBlock([ + whitelistAssetTx(nftAssetContract, true, deployer), + listOrderTx(nftAssetContract, maker, order), + ]); + chain.mineEmptyBlockUntil(expiry + 1); + const block = chain.mineBlock([ + Tx.contractCall(contractName, 'fulfil-listing-stx', [types.uint(0), types.principal(nftAssetContract)], taker.address) + ]) + block.receipts[0].result.expectErr().expectUint(2002); + assertEquals(block.receipts[0].events.length, 0); + } +}); + +Clarinet.test({ + name: "Cannot fulfil a listing with a different NFT contract reference", + async fn(chain: Chain, accounts: Map) { + const [deployer, maker, taker] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); + const expiry = 10; + const { nftAssetContract, tokenId } = mintNft({ chain, deployer, recipient: maker }); + const order: Order = { tokenId, expiry: 10, price: 10 }; + const bogusNftAssetContract = `${deployer.address}.bogus-nft`; + const block = chain.mineBlock([ + whitelistAssetTx(nftAssetContract, true, deployer), + listOrderTx(nftAssetContract, maker, order), + Tx.contractCall(contractName, 'fulfil-listing-stx', [types.uint(0), types.principal(bogusNftAssetContract)], taker.address) + ]); + block.receipts[2].result.expectErr().expectUint(2003); + assertEquals(block.receipts[2].events.length, 0); + } +}); + +Clarinet.test({ + name: "Cannot fulfil an active STX listing with SIP010 fungible tokens", + async fn(chain: Chain, accounts: Map) { + const [deployer, maker, taker] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); + const price = 50; + const { nftAssetContract, tokenId } = mintNft({ chain, deployer, recipient: maker }); + const { paymentAssetContract } = mintFt({ chain, deployer, recipient: taker, amount: price }); + const order: Order = { tokenId, expiry: 10, price }; + const block = chain.mineBlock([ + whitelistAssetTx(nftAssetContract, true, deployer), + whitelistAssetTx(paymentAssetContract, true, deployer), + listOrderTx(nftAssetContract, maker, order), + Tx.contractCall(contractName, 'fulfil-listing-ft', [types.uint(0), types.principal(nftAssetContract), types.principal(paymentAssetContract)], taker.address) + ]); + block.receipts[3].result.expectErr().expectUint(2004); + assertEquals(block.receipts[3].events.length, 0); + } +}); + +Clarinet.test({ + name: "Cannot fulfil an active SIP010 fungible token listing with STX", + async fn(chain: Chain, accounts: Map) { + const [deployer, maker, taker] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); + const price = 50; + const { nftAssetContract, tokenId } = mintNft({ chain, deployer, recipient: maker }); + const { paymentAssetContract } = mintFt({ chain, deployer, recipient: taker, amount: price }); + const order: Order = { tokenId, expiry: 10, price, paymentAssetContract }; + const block = chain.mineBlock([ + whitelistAssetTx(nftAssetContract, true, deployer), + whitelistAssetTx(paymentAssetContract, true, deployer), + listOrderTx(nftAssetContract, maker, order), + Tx.contractCall(contractName, 'fulfil-listing-stx', [types.uint(0), types.principal(nftAssetContract)], taker.address) + ]); + block.receipts[3].result.expectErr().expectUint(2004); + assertEquals(block.receipts[3].events.length, 0); + } +}); + +Clarinet.test({ + name: "Cannot fulfil an active SIP010 fungible token listing with a different SIP010 fungible token contract reference", + async fn(chain: Chain, accounts: Map) { + const [deployer, maker, taker] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); + const price = 50; + const { nftAssetContract, tokenId } = mintNft({ chain, deployer, recipient: maker }); + const { paymentAssetContract } = mintFt({ chain, deployer, recipient: taker, amount: price }); + const bogusPaymentAssetContract = `${deployer.address}.bogus-ft`; + const order: Order = { tokenId, expiry: 10, price, paymentAssetContract }; + const block = chain.mineBlock([ + whitelistAssetTx(nftAssetContract, true, deployer), + whitelistAssetTx(paymentAssetContract, true, deployer), + listOrderTx(nftAssetContract, maker, order), + Tx.contractCall(contractName, 'fulfil-listing-ft', [types.uint(0), types.principal(nftAssetContract), types.principal(bogusPaymentAssetContract)], taker.address) + ]); + block.receipts[3].result.expectErr().expectUint(2004); + assertEquals(block.receipts[3].events.length, 0); + } +}); + +Clarinet.test({ + name: "Cannot fulfil an active STX listing with insufficient balance", + async fn(chain: Chain, accounts: Map) { + const [deployer, maker, taker] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); + const { nftAssetContract, tokenId } = mintNft({ chain, deployer, recipient: maker }); + const order: Order = { tokenId, expiry: 10, price: taker.balance + 10 }; + const block = chain.mineBlock([ + whitelistAssetTx(nftAssetContract, true, deployer), + listOrderTx(nftAssetContract, maker, order), + Tx.contractCall(contractName, 'fulfil-listing-stx', [types.uint(0), types.principal(nftAssetContract)], taker.address) + ]); + block.receipts[2].result.expectErr().expectUint(1); + assertEquals(block.receipts[2].events.length, 0); + } +}); + +Clarinet.test({ + name: "Cannot fulfil an active SIP010 fungible token listing with insufficient balance", + async fn(chain: Chain, accounts: Map) { + const [deployer, maker, taker] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); + const price = 50; + const { nftAssetContract, tokenId } = mintNft({ chain, deployer, recipient: maker }); + const { paymentAssetContract } = mintFt({ chain, deployer, recipient: taker, amount: price }); + const order: Order = { tokenId, expiry: 10, price: taker.balance + 10, paymentAssetContract }; + const block = chain.mineBlock([ + whitelistAssetTx(nftAssetContract, true, deployer), + whitelistAssetTx(paymentAssetContract, true, deployer), + listOrderTx(nftAssetContract, maker, order), + Tx.contractCall(contractName, 'fulfil-listing-ft', [types.uint(0), types.principal(nftAssetContract), types.principal(paymentAssetContract)], taker.address) + ]); + block.receipts[3].result.expectErr().expectUint(1); + assertEquals(block.receipts[3].events.length, 0); + } +}); + +Clarinet.test({ + name: "Intended taker can fulfil active listing", + async fn(chain: Chain, accounts: Map) { + const [deployer, maker, taker] = ['deployer', 'wallet_1', 'wallet_2'].map(name => accounts.get(name)!); + const { nftAssetContract, tokenId } = mintNft({ chain, deployer, recipient: maker }); + const order: Order = { tokenId, expiry: 10, price: 10, taker: taker.address }; + const block = chain.mineBlock([ + whitelistAssetTx(nftAssetContract, true, deployer), + listOrderTx(nftAssetContract, maker, order), + Tx.contractCall(contractName, 'fulfil-listing-stx', [types.uint(0), types.principal(nftAssetContract)], taker.address) + ]); + block.receipts[2].result.expectOk().expectUint(0); + assertNftTransfer(block.receipts[2].events[0], nftAssetContract, tokenId, contractPrincipal(deployer), taker.address); + block.receipts[2].events.expectSTXTransferEvent(order.price, taker.address, maker.address); + } +}); + +Clarinet.test({ + name: "Unintended taker cannot fulfil active listing", + async fn(chain: Chain, accounts: Map) { + const [deployer, maker, taker, unintendedTaker] = ['deployer', 'wallet_1', 'wallet_2', 'wallet_3'].map(name => accounts.get(name)!); + const { nftAssetContract, tokenId } = mintNft({ chain, deployer, recipient: maker }); + const order: Order = { tokenId, expiry: 10, price: 10, taker: taker.address }; + const block = chain.mineBlock([ + whitelistAssetTx(nftAssetContract, true, deployer), + listOrderTx(nftAssetContract, maker, order), + Tx.contractCall(contractName, 'fulfil-listing-stx', [types.uint(0), types.principal(nftAssetContract)], unintendedTaker.address) + ]); + block.receipts[2].result.expectErr().expectUint(2006); + assertEquals(block.receipts[2].events.length, 0); + } +}); + +Clarinet.test({ + name: "Can fulfil multiple active listings in any order", + async fn(chain: Chain, accounts: Map) { + const deployer = accounts.get('deployer')!; + const expiry = 100; + + const randomSorter = () => Math.random() - .5; + + // Take some makers and takers in random order. + const makers = ['wallet_1', 'wallet_2', 'wallet_3', 'wallet_4'].sort(randomSorter).map(name => accounts.get(name)!); + const takers = ['wallet_5', 'wallet_6', 'wallet_7', 'wallet_8'].sort(randomSorter).map(name => accounts.get(name)!); + + // Mint some NFTs so the IDs do not always start at zero. + const mints = [...Array(1 + ~~(Math.random() * 10))].map(() => mintNft({ chain, deployer, recipient: deployer })); + + // Mint an NFT for all makers and generate orders. + const nfts = makers.map(recipient => mintNft({ chain, deployer, recipient })); + const orders: Order[] = makers.map((maker, i) => ({ tokenId: nfts[i].tokenId, expiry, price: 1 + ~~(Math.random() * 10) })); + + // Whitelist asset contract + chain.mineBlock([whitelistAssetTx(mints[0].nftAssetContract, true, deployer)]); + + // List all NFTs. + const block = chain.mineBlock( + makers.map((maker, i) => listOrderTx(nfts[i].nftAssetContract, maker, makeOrder(orders[i]))) + ); + const orderIdUints = block.receipts.map(receipt => receipt.result.expectOk().toString()); + + // Attempt to fulfil all listings. + const block2 = chain.mineBlock( + takers.map((taker, i) => Tx.contractCall(contractName, 'fulfil-listing-stx', [orderIdUints[i], types.principal(nfts[i].nftAssetContract)], taker.address)) + ); + + const contractAddress = contractPrincipal(deployer); + + // Assert that all orders were fulfilled and that the NFTs and STX have been tranferred to the appropriate principals. + block2.receipts.map((receipt, i) => { + assertEquals(receipt.result.expectOk(), orderIdUints[i]); + assertNftTransfer(receipt.events[0], nfts[i].nftAssetContract, nfts[i].tokenId, contractAddress, takers[i].address); + receipt.events.expectSTXTransferEvent(orders[i].price, takers[i].address, makers[i].address); + }); + } +}); \ No newline at end of file