diff --git a/.cargo/config.toml b/.cargo/config.toml index 4d85773..c939037 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,7 +1,6 @@ [alias] -wasm = "build --release --lib --target wasm32-unknown-unknown" schema = "run --example schema" +wasm = "build --release --lib --target wasm32-unknown-unknown" [env] RUSTFLAGS = "-C link-arg=-s" - diff --git a/Cargo.lock b/Cargo.lock index b26eefa..be08c7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,9 +33,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "anyhow" -version = "1.0.99" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "ark-bls12-381" @@ -169,7 +169,7 @@ dependencies = [ "cw721 0.20.0", "schemars", "serde", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -214,6 +214,15 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d965446196e3b7decd44aa7ee49e31d630118f90ef12f97900f262eb915c951d" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block-buffer" version = "0.9.0" @@ -287,7 +296,7 @@ dependencies = [ "ed25519-zebra 3.1.0", "k256", "rand_core 0.6.4", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -311,7 +320,7 @@ dependencies = [ "rand_core 0.6.4", "rayon", "sha2 0.10.9", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -344,7 +353,7 @@ dependencies = [ "schemars", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -357,7 +366,7 @@ dependencies = [ "schemars", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -401,7 +410,7 @@ dependencies = [ "serde-json-wasm 0.5.2", "sha2 0.10.9", "static_assertions", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -425,7 +434,7 @@ dependencies = [ "serde-json-wasm 1.0.1", "sha2 0.10.9", "static_assertions", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -539,6 +548,26 @@ dependencies = [ "cosmwasm-std 2.2.2", ] +[[package]] +name = "cw-multi-test" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683c799ba3a3d01933be5ef693c10cf815855967eb71c3dd730f2987ebb9316e" +dependencies = [ + "anyhow", + "bech32 0.11.0", + "cosmwasm-schema 2.2.2", + "cosmwasm-std 2.2.2", + "cw-storage-plus 2.0.0", + "cw-utils 2.0.0", + "itertools 0.14.0", + "prost 0.14.1", + "schemars", + "serde", + "sha2 0.10.9", + "thiserror 2.0.16", +] + [[package]] name = "cw-ownable" version = "2.1.0" @@ -551,7 +580,7 @@ dependencies = [ "cw-ownable-derive", "cw-storage-plus 2.0.0", "cw-utils 2.0.0", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -565,6 +594,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "cw-storage-macro" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b853a2f7d85f286d099ae5b28ae4025c475d31145bf426cc6d86ec92afd215c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "cw-storage-plus" version = "0.16.0" @@ -583,6 +623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f13360e9007f51998d42b1bc6b7fa0141f74feae61ed5fd1e5b0a89eec7b5de1" dependencies = [ "cosmwasm-std 2.2.2", + "cw-storage-macro", "schemars", "serde", ] @@ -599,7 +640,7 @@ dependencies = [ "schemars", "semver", "serde", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -612,7 +653,7 @@ dependencies = [ "cosmwasm-std 2.2.2", "schemars", "serde", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -640,7 +681,7 @@ dependencies = [ "schemars", "semver", "serde", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -671,10 +712,24 @@ dependencies = [ "cw721 0.16.0", "schemars", "serde", - "thiserror", + "thiserror 1.0.69", "url", ] +[[package]] +name = "cw721-base" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f3671b8e76ab8c22113e8d5b6e4bffbfb5df69a44cae99657a361941b95b6ca" +dependencies = [ + "cosmwasm-schema 2.2.2", + "cosmwasm-std 2.2.2", + "cw-ownable", + "cw2 2.0.0", + "cw721 0.20.0", + "serde", +] + [[package]] name = "der" version = "0.7.10" @@ -1330,7 +1385,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.13.5", +] + +[[package]] +name = "prost" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d" +dependencies = [ + "bytes", + "prost-derive 0.14.1", ] [[package]] @@ -1346,6 +1411,19 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "prost-derive" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "quote" version = "1.0.40" @@ -1747,7 +1825,7 @@ checksum = "d2c40e13d39ca19082d8a7ed22de7595979350319833698f8b1080f29620a094" dependencies = [ "bytes", "flex-error", - "prost", + "prost 0.13.5", "serde", "serde_bytes", "subtle-encoding", @@ -1760,7 +1838,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +dependencies = [ + "thiserror-impl 2.0.16", ] [[package]] @@ -1774,6 +1861,17 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "thiserror-impl" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "time" version = "0.3.43" @@ -1833,7 +1931,7 @@ dependencies = [ "schemars", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", "url", "xion-cosmos-sdk-proto", ] @@ -1877,7 +1975,7 @@ dependencies = [ "cw-storage-plus 2.0.0", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -1923,7 +2021,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "thiserror", + "thiserror 1.0.69", "tiny-keccak", "xion-cosmos-sdk-proto", ] @@ -1935,11 +2033,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5950da92cdb6e0fdebe4513a1defd73b6c4af7d1fa72ae5f14780451c535bc2" dependencies = [ "informalsystems-pbjson", - "prost", + "prost 0.13.5", "serde", "tendermint-proto", ] +[[package]] +name = "xion-nft-marketplace" +version = "0.1.0" +dependencies = [ + "anyhow", + "asset", + "blake2", + "cosmwasm-schema 2.2.2", + "cosmwasm-std 2.2.2", + "cw-address-like", + "cw-multi-test", + "cw-storage-plus 2.0.0", + "cw-utils 2.0.0", + "cw2 2.0.0", + "cw721 0.20.0", + "cw721-base", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "yoke" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index 5a00566..0514b3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,33 +1,39 @@ [workspace] -members = [ - "contracts/*", -] +members = ["contracts/*"] [profile.release] -opt-level = 3 # Use slightly better optimizations. -overflow-checks = true # Disable integer overflow checks. +opt-level = 3 # Use slightly better optimizations. +overflow-checks = true # Disable integer overflow checks. [workspace.dependencies] -cosmwasm-schema = "2.2.2" -cosmwasm-std = { version = "2.2.2", features = ["stargate", "cosmwasm_2_1"] } -cw2 = "2.0.0" -cw-storage-plus = "2.0.0" -cw-utils = "2.0.0" -cw721 = "0.20.0" -hex = "0.4" -sha2 = { version = "0.10.8", features = ["oid"]} -thiserror = "1" -tiny-keccak = { version = "2", features = ["keccak"] } -serde = { version = "1.0.203", default-features = false, features = ["derive"] } -serde_json = "1.0.87" -schemars = "0.8.10" -ripemd = "0.1.3" -bech32 = "0.9.1" +anyhow = "1.0.100" base64 = "0.21.4" +bech32 = "0.9.1" +cosmos-sdk-proto = { package = "xion-cosmos-sdk-proto", version = "0.26.1", default-features = false, features = [ + "std", + "cosmwasm", + "xion", + "serde", +] } +cosmwasm-schema = "2.2.2" +cosmwasm-std = { version = "2.2.2", features = ["stargate", "cosmwasm_2_1"] } +cw-address-like = "2.0.0" +cw-storage-plus = "2.0.0" +cw-utils = "2.0.0" +cw2 = "2.0.0" +cw721 = "0.20.0" +cw721-base = "0.20.0" +getrandom = { version = "0.2.10", features = ["custom"] } +hex = "0.4" +p256 = { version = "0.13.2", features = ["ecdsa-core", "arithmetic", "serde"] } phf = { version = "0.11.2", features = ["macros"] } +ripemd = "0.1.3" rsa = { version = "0.9.2" } -getrandom = { version = "0.2.10", features = ["custom"] } -p256 = {version = "0.13.2", features = ["ecdsa-core", "arithmetic", "serde"]} -cosmos-sdk-proto = {package = "xion-cosmos-sdk-proto", version = "0.26.1", default-features = false, features = ["std", "cosmwasm", "xion", "serde"]} +schemars = "0.8.10" +serde = { version = "1.0.203", default-features = false, features = ["derive"] } +serde_json = "1.0.87" +sha2 = { version = "0.10.8", features = ["oid"] } +thiserror = "1" +tiny-keccak = { version = "2", features = ["keccak"] } url = "2.5.2" diff --git a/contracts/account/Cargo.toml b/contracts/account/Cargo.toml index f1b9a0f..1f421ff 100644 --- a/contracts/account/Cargo.toml +++ b/contracts/account/Cargo.toml @@ -1,9 +1,9 @@ [package] -name = "xion-account" -version = "0.1.1" -edition = "2021" description = "Primary MetaAccount implementation for XION network" -license = "Apache-2.0" +edition = "2021" +license = "Apache-2.0" +name = "xion-account" +version = "0.1.1" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] @@ -14,21 +14,21 @@ crate-type = ["cdylib", "rlib"] library = [] [dependencies] -cosmwasm-schema = { workspace = true } -cosmwasm-std = { workspace = true } -cw2 = { workspace = true } -cw-storage-plus = { workspace = true } -sha2 = { workspace = true } -thiserror = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -tiny-keccak = { workspace = true } -schemars = { workspace = true } -hex = { workspace = true } -ripemd = { workspace = true } -bech32 = { workspace = true } -base64 = { workspace = true } -rsa = { workspace = true } -getrandom = { workspace = true } -p256 = { workspace = true } -cosmos-sdk-proto = { workspace = true } +base64 = { workspace = true } +bech32 = { workspace = true } +cosmos-sdk-proto = { workspace = true } +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +cw-storage-plus = { workspace = true } +cw2 = { workspace = true } +getrandom = { workspace = true } +hex = { workspace = true } +p256 = { workspace = true } +ripemd = { workspace = true } +rsa = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +tiny-keccak = { workspace = true } diff --git a/contracts/asset/Cargo.toml b/contracts/asset/Cargo.toml index b699f5a..c22fc94 100644 --- a/contracts/asset/Cargo.toml +++ b/contracts/asset/Cargo.toml @@ -1,9 +1,9 @@ [package] -name = "asset" -version = "0.1.0" -edition = "2024" description = "Primary Asset Contract implementation for XION network" -license = "Apache-2.0" +edition = "2024" +license = "Apache-2.0" +name = "asset" +version = "0.1.0" [lib] crate-type = ["cdylib", "rlib"] diff --git a/contracts/asset/src/execute.rs b/contracts/asset/src/execute.rs index da489b6..177533e 100644 --- a/contracts/asset/src/execute.rs +++ b/contracts/asset/src/execute.rs @@ -65,7 +65,8 @@ where reserved: reservation.clone(), nft_info, marketplace_fee_bps, - marketplace_fee_recipient: Some(deps.api.addr_validate(&marketplace_fee_recipient.unwrap())?), + marketplace_fee_recipient: marketplace_fee_recipient + .map(|recipient| deps.api.addr_validate(&recipient).unwrap()), }; asset_config.listings.save(deps.storage, &id, &listing)?; Ok(Response::default() diff --git a/contracts/marketplace/Cargo.toml b/contracts/marketplace/Cargo.toml new file mode 100644 index 0000000..f7e8139 --- /dev/null +++ b/contracts/marketplace/Cargo.toml @@ -0,0 +1,32 @@ +[package] +edition = "2021" +name = "xion-nft-marketplace" +version = "0.1.0" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# enable feature if you want to disable entry points +library = [] + +[dependencies] +anyhow.workspace = true + +asset = { path = "../asset", features = ["library", "asset_base"] } +blake2 = "0.10.6" +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +cw-address-like = { workspace = true } +cw-storage-plus = { workspace = true, features = ["macro"] } +cw-utils = { workspace = true } +cw2 = { workspace = true } +cw721 = { workspace = true } +cw721-base = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +cw-multi-test = { version = "2", features = ["staking", "stargate", "cosmwasm_2_2"] } diff --git a/contracts/marketplace/src/contract.rs b/contracts/marketplace/src/contract.rs new file mode 100644 index 0000000..6818c60 --- /dev/null +++ b/contracts/marketplace/src/contract.rs @@ -0,0 +1,272 @@ +use std::env; + +use crate::error::ContractError; +use crate::helpers::{ + asset_buy_msg, asset_list_msg, generate_id, not_listed, only_owner, query_listing, + valid_payment, +}; +use crate::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg}; +use crate::offers::{ + execute_accept_collection_offer, execute_accept_offer, execute_cancel_collection_offer, + execute_cancel_offer, execute_create_collection_offer, execute_create_offer, +}; +use crate::state::init_auto_increment; +use crate::state::{listings, Listing, ListingStatus}; +use crate::state::{Config, CONFIG}; +use asset::msg::AssetExtensionExecuteMsg as AssetExecuteMsg; +use cosmwasm_std::{ + ensure_eq, entry_point, to_json_binary, Addr, Coin, DepsMut, Env, MessageInfo, Response, + WasmMsg, +}; +use cw2::set_contract_version; + +const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); +#[entry_point] +pub fn instantiate( + deps: DepsMut, + _env: Env, + _: MessageInfo, + msg: InstantiateMsg, +) -> Result { + let config = Config::from_str(msg.config, deps.api)?; + config.save(deps.storage)?; + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + init_auto_increment(deps.storage)?; + Ok(Response::new().add_attribute("method", "instantiate")) +} + +use crate::events::{cancel_listing_event, create_listing_event, item_sold_event}; +#[entry_point] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + let api = deps.api; + match msg { + ExecuteMsg::ListItem { + collection, + price, + token_id, + } => execute_create_listing(deps, info, api.addr_validate(&collection)?, price, token_id), + ExecuteMsg::CancelListing { listing_id } => execute_cancel_listing(deps, info, listing_id), + ExecuteMsg::BuyItem { listing_id, price } => { + execute_buy_item(deps, info, listing_id, price) + } + ExecuteMsg::CreateOffer { + collection, + price, + token_id, + } => execute_create_offer( + deps, + env, + info, + api.addr_validate(&collection)?, + price, + token_id, + ), + ExecuteMsg::AcceptOffer { + id, + collection, + token_id, + price, + } => execute_accept_offer( + deps, + info, + id, + api.addr_validate(&collection)?, + token_id, + price, + ), + ExecuteMsg::CancelOffer { id } => execute_cancel_offer(deps, info, id), + ExecuteMsg::CreateCollectionOffer { collection, price } => { + execute_create_collection_offer(deps, env, info, api.addr_validate(&collection)?, price) + } + ExecuteMsg::AcceptCollectionOffer { + id, + collection, + token_id, + price, + } => execute_accept_collection_offer( + deps, + info, + id, + api.addr_validate(&collection)?, + token_id, + price, + ), + + ExecuteMsg::CancelCollectionOffer { id } => execute_cancel_collection_offer(deps, info, id), + ExecuteMsg::ApproveSale { id } => execute_approve_sale(deps, info, id), + ExecuteMsg::RejectSale { id } => execute_reject_sale(deps, info, id), + } +} + +pub fn execute_create_listing( + deps: DepsMut, + info: MessageInfo, + collection: Addr, + price: Coin, + token_id: String, +) -> Result { + only_owner(&deps.querier, &info, &collection, &token_id)?; + not_listed(&deps.querier, &collection, &token_id)?; + let config = CONFIG.load(deps.storage)?; + ensure_eq!( + price.denom, + CONFIG.load(deps.storage)?.listing_denom, + ContractError::InvalidListingDenom { + expected: config.listing_denom, + actual: price.denom, + } + ); + + // generate consistent id even across relisting helps single lookup + let id = generate_id(vec![&collection.as_bytes(), &token_id.as_bytes()]); + let listing = Listing { + id: id.clone(), + seller: info.sender.clone(), + collection: collection.clone(), + token_id: token_id.clone(), + price: price.clone(), + status: ListingStatus::Active, + }; + // reject if listing already exists + listings().update(deps.storage, id.clone(), |prev| match prev { + Some(_) => Err(ContractError::AlreadyListed {}), + None => Ok(listing), + })?; + let list_msg = asset_list_msg(token_id.clone(), price.clone()); + Ok(Response::new() + .add_event(create_listing_event( + id, + info.sender, + collection.clone(), + token_id, + price, + )) + .add_message(WasmMsg::Execute { + contract_addr: collection.to_string(), + msg: to_json_binary(&list_msg)?, + funds: vec![], + })) +} + +pub fn execute_cancel_listing( + deps: DepsMut, + info: MessageInfo, + listing_id: String, +) -> Result { + let listing = listings().load(deps.storage, listing_id.clone())?; + ensure_eq!( + listing.seller, + info.sender, + ContractError::Unauthorized { + message: "sender is not the seller".to_string(), + } + ); + // can't cancel a list that is pending approval if sale approvals are enabled + // listings that are in pending status have already been placed a matching buy order + // but it's not yet been accepted by the manager + if CONFIG.load(deps.storage)?.sale_approvals && listing.status != ListingStatus::Active { + return Err(ContractError::InvalidListingStatus { + expected: ListingStatus::Active.to_string(), + actual: listing.status.to_string(), + }); + } + + listings().remove(deps.storage, listing_id.clone())?; + // query if there is a listing in the asset contract (in case is out of sync) + let asset_listing = query_listing(&deps.querier, &listing.collection, &listing.token_id); + + let mut sub_msgs = vec![]; + + if let Ok(_) = asset_listing { + let cancel_listing = asset::msg::ExecuteMsg::< + cw721::DefaultOptionalNftExtensionMsg, + cw721::DefaultOptionalCollectionExtensionMsg, + asset::msg::AssetExtensionExecuteMsg, + >::UpdateExtension { + msg: asset::msg::AssetExtensionExecuteMsg::Delist { + token_id: listing.token_id.clone(), + }, + }; + sub_msgs.push(WasmMsg::Execute { + contract_addr: listing.collection.to_string(), + msg: to_json_binary(&cancel_listing)?, + funds: vec![], + }); + } + Ok(Response::new() + .add_event(cancel_listing_event( + listing_id, + listing.collection.clone(), + listing.seller, + listing.token_id, + )) + .add_messages(sub_msgs)) +} + +pub fn execute_buy_item( + deps: DepsMut, + info: MessageInfo, + listing_id: String, + price: Coin, +) -> Result { + let listing = listings().load(deps.storage, listing_id.clone())?; + // prevent price mismatch due to possible frontrunning + if listing.price != price { + return Err(ContractError::InvalidPrice { + expected: listing.price, + actual: price, + }); + } + + // check payment and funds are valid + valid_payment(&info, price.clone(), listing.price.denom)?; + + let buy_msg = asset_buy_msg(info.sender.clone(), listing.token_id.clone()); + + Ok(Response::new() + .add_event(item_sold_event( + listing.id, + listing.collection.clone(), + listing.seller, + info.sender, + listing.token_id.clone(), + price, + None, + None, + )) + .add_message(WasmMsg::Execute { + contract_addr: listing.collection.clone().to_string(), + msg: to_json_binary(&buy_msg)?, + // send the payment to the asset contract + funds: info.funds, + })) +} + +pub fn execute_approve_sale( + _deps: DepsMut, + _info: MessageInfo, + _id: String, +) -> Result { + // TODO: Implement approve sale functionality + Ok(Response::default()) +} + +pub fn execute_reject_sale( + _deps: DepsMut, + _info: MessageInfo, + _id: String, +) -> Result { + // TODO: Implement reject sale functionality + Ok(Response::default()) +} + +#[entry_point] +pub fn migrate(_deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + Ok(Response::default()) +} diff --git a/contracts/marketplace/src/error.rs b/contracts/marketplace/src/error.rs new file mode 100644 index 0000000..3656ffe --- /dev/null +++ b/contracts/marketplace/src/error.rs @@ -0,0 +1,51 @@ +use cosmwasm_std::{Coin, StdError}; +use cw_utils::PaymentError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized: {message}")] + Unauthorized { message: String }, + + #[error("Offer already exists: {id}")] + OfferAlreadyExists { id: String }, + + #[error("Invalid fee rate")] + InvalidFeeRate {}, + + #[error("Listing not found: {id}")] + ListingNotFound { id: String }, + + #[error("Not listed")] + NotListed {}, + + #[error("Already listed")] + AlreadyListed {}, + + #[error("Invalid listing denom: expected {expected}, got {actual}")] + InvalidListingDenom { expected: String, actual: String }, + + #[error("Invalid listing status: expected {expected}, got {actual}")] + InvalidListingStatus { expected: String, actual: String }, + + #[error("Invalid price: {expected} != {actual}")] + InvalidPrice { expected: Coin, actual: Coin }, + + #[error("Invalid payment: {expected} != {actual}")] + InvalidPayment { expected: Coin, actual: Coin }, + + #[error("Invalid seller")] + InvalidSeller {}, + + #[error("Invalid token: expected {expected}, got {actual}")] + InvalidTokenId { expected: String, actual: String }, + + #[error("Invalid collection: expected {expected}, got {actual}")] + InvalidCollection { expected: String, actual: String }, + + #[error("{0}")] + PaymentError(#[from] PaymentError), +} diff --git a/contracts/marketplace/src/events.rs b/contracts/marketplace/src/events.rs new file mode 100644 index 0000000..6b6911c --- /dev/null +++ b/contracts/marketplace/src/events.rs @@ -0,0 +1,99 @@ +use cosmwasm_std::{Addr, Coin, Event}; + +pub fn create_listing_event( + id: String, + owner: Addr, + collection: Addr, + token_id: String, + price: Coin, +) -> Event { + Event::new(format!("{}/list-item", env!("CARGO_PKG_NAME"))) + .add_attribute("id", id) + .add_attribute("owner", owner.to_string()) + .add_attribute("collection", collection.to_string()) + .add_attribute("token_id", token_id) + .add_attribute("price", price.to_string()) +} + +pub fn cancel_listing_event(id: String, collection: Addr, owner: Addr, token_id: String) -> Event { + Event::new(format!("{}/cancel-listing", env!("CARGO_PKG_NAME"))) + .add_attribute("id", id) + .add_attribute("owner", owner.to_string()) + .add_attribute("collection", collection.to_string()) + .add_attribute("token_id", token_id) +} + +pub fn item_sold_event( + id: String, + collection: Addr, + seller: Addr, + buyer: Addr, + token_id: String, + price: Coin, + offer_id: Option, + collection_offer_id: Option, +) -> Event { + let mut sold_event = Event::new(format!("{}/item-sold", env!("CARGO_PKG_NAME"))) + .add_attribute("id", id) + .add_attribute("seller", seller.to_string()) + .add_attribute("buyer", buyer.to_string()) + .add_attribute("collection", collection.to_string()) + .add_attribute("token_id", token_id) + .add_attribute("price", price.to_string()); + if let Some(id) = offer_id { + sold_event = sold_event.add_attribute("offer_id", id); + } + if let Some(id) = collection_offer_id { + sold_event = sold_event.add_attribute("collection_offer_id", id); + } + sold_event +} + +pub fn cancel_offer_event(id: String, collection: Addr, owner: Addr, token_id: String) -> Event { + Event::new(format!("{}/cancel-offer", env!("CARGO_PKG_NAME"))) + .add_attribute("id", id) + .add_attribute("buyer", owner.to_string()) + .add_attribute("collection", collection.to_string()) + .add_attribute("token_id", token_id) +} + +pub fn cancel_collection_offer_event(id: String, collection: Addr, owner: Addr) -> Event { + Event::new(format!( + "{}/cancel-collection-offer", + env!("CARGO_PKG_NAME") + )) + .add_attribute("id", id) + .add_attribute("buyer", owner.to_string()) + .add_attribute("collection", collection.to_string()) +} + +pub fn create_offer_event( + id: String, + collection: Addr, + buyer: Addr, + token_id: String, + price: Coin, +) -> Event { + Event::new(format!("{}/create-offer", env!("CARGO_PKG_NAME"))) + .add_attribute("id", id) + .add_attribute("buyer", buyer.to_string()) + .add_attribute("collection", collection.to_string()) + .add_attribute("token_id", token_id) + .add_attribute("price", price.to_string()) +} + +pub fn create_collection_offer_event( + id: String, + collection: Addr, + owner: Addr, + price: Coin, +) -> Event { + Event::new(format!( + "{}/create-collection-offer", + env!("CARGO_PKG_NAME") + )) + .add_attribute("id", id) + .add_attribute("owner", owner.to_string()) + .add_attribute("collection", collection.to_string()) + .add_attribute("price", price.to_string()) +} diff --git a/contracts/marketplace/src/helpers.rs b/contracts/marketplace/src/helpers.rs new file mode 100644 index 0000000..df3e77e --- /dev/null +++ b/contracts/marketplace/src/helpers.rs @@ -0,0 +1,158 @@ +use crate::error::ContractError; +use asset::msg::AssetExtensionExecuteMsg as AssetExecuteMsg; +use asset::msg::AssetExtensionQueryMsg; +use asset::msg::QueryMsg as AssetQueryMsg; +use asset::state::ListingInfo; +use blake2::{Blake2s256, Digest}; +use cosmwasm_std::{ensure, ensure_eq, Coin}; +use cosmwasm_std::{Addr, Empty, MessageInfo, QuerierWrapper}; +use cw721::msg::OwnerOfResponse; +use cw721_base::msg::QueryMsg; +use cw_utils::one_coin; + +pub fn only_owner( + querier: &QuerierWrapper, + info: &MessageInfo, + collection: &Addr, + token_id: &str, +) -> Result<(), ContractError> { + let result = querier.query_wasm_smart::( + collection.clone(), + &QueryMsg::OwnerOf { + token_id: token_id.to_string(), + include_expired: Some(false), + }, + ); + match result { + Ok(owner_resp) => { + if owner_resp.owner != info.sender.to_string() { + return Err(ContractError::Unauthorized { + message: "sender is not owner".to_string(), + }); + } + Ok(()) + } + Err(_) => { + return Err(ContractError::Unauthorized { + message: "sender is not owner".to_string(), + }) + } + } +} +pub fn query_listing( + querier: &QuerierWrapper, + collection: &Addr, + token_id: &str, +) -> Result, ContractError> { + if let Ok(listing) = querier + .query_wasm_smart::>( + collection.clone(), + &AssetQueryMsg::::Extension { + msg: AssetExtensionQueryMsg::GetListing { + token_id: token_id.to_string(), + }, + }, + ) + { + Ok(listing) + } else { + Err(ContractError::NotListed {}) + } +} +pub fn not_listed( + querier: &QuerierWrapper, + collection: &Addr, + token_id: &str, +) -> Result<(), ContractError> { + let listing_response = query_listing(querier, collection, token_id); + match listing_response { + Ok(_) => Err(ContractError::AlreadyListed {}), + Err(_) => Ok(()), + } +} + +pub fn generate_id(parts: Vec<&[u8]>) -> String { + let mut hasher = Blake2s256::new(); + for part in parts { + hasher.update(part); + } + format!("{:x}", hasher.finalize()) +} + +pub fn valid_payment( + info: &MessageInfo, + price: Coin, + valid_denom: String, +) -> Result<(), ContractError> { + let payment = one_coin(&info)?; + // check if the payment is the valid denom + ensure_eq!( + payment.denom, + valid_denom, + ContractError::InvalidListingDenom { + expected: valid_denom, + actual: payment.denom, + } + ); + // check if the payment and listing have the same denom + ensure_eq!( + payment.denom, + price.denom, + ContractError::InvalidListingDenom { + expected: price.denom, + actual: payment.denom, + } + ); + // check if the payment is the same amount as the price + ensure!( + payment.amount == price.amount, + ContractError::InvalidPayment { + expected: price, + actual: payment, + } + ); + Ok(()) +} + +pub fn asset_list_msg( + token_id: String, + price: Coin, +) -> asset::msg::ExecuteMsg< + cw721::DefaultOptionalNftExtensionMsg, + cw721::DefaultOptionalCollectionExtensionMsg, + AssetExecuteMsg, +> { + asset::msg::ExecuteMsg::< + cw721::DefaultOptionalNftExtensionMsg, + cw721::DefaultOptionalCollectionExtensionMsg, + AssetExecuteMsg, + >::UpdateExtension { + msg: AssetExecuteMsg::List { + token_id: token_id.clone(), + price: price.clone(), + reservation: None, + marketplace_fee_bps: None, + marketplace_fee_recipient: None, + }, + } +} + +pub fn asset_buy_msg( + recipient: Addr, + token_id: String, +) -> asset::msg::ExecuteMsg< + cw721::DefaultOptionalNftExtensionMsg, + cw721::DefaultOptionalCollectionExtensionMsg, + AssetExecuteMsg, +> { + asset::msg::ExecuteMsg::< + cw721::DefaultOptionalNftExtensionMsg, + cw721::DefaultOptionalCollectionExtensionMsg, + AssetExecuteMsg, + >::UpdateExtension { + msg: AssetExecuteMsg::Buy { + token_id: token_id.clone(), + recipient: Some(recipient.to_string()), + }, + } +} diff --git a/contracts/marketplace/src/lib.rs b/contracts/marketplace/src/lib.rs new file mode 100644 index 0000000..0a20417 --- /dev/null +++ b/contracts/marketplace/src/lib.rs @@ -0,0 +1,11 @@ +extern crate core; + +#[cfg(not(feature = "library"))] +pub mod contract; +pub mod error; +pub mod events; +pub mod helpers; +pub mod msg; +pub mod offers; +pub mod query; +pub mod state; diff --git a/contracts/marketplace/src/msg.rs b/contracts/marketplace/src/msg.rs new file mode 100644 index 0000000..2af1257 --- /dev/null +++ b/contracts/marketplace/src/msg.rs @@ -0,0 +1,85 @@ +use crate::state::{CollectionOffer, Config, Listing, Offer, PendingSale}; +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::{Addr, Coin}; + +#[cw_serde] +pub struct InstantiateMsg { + pub config: Config, +} + +#[cw_serde] +pub enum ExecuteMsg { + ListItem { + price: Coin, + collection: String, + token_id: String, + }, + CancelListing { + listing_id: String, + }, + BuyItem { + listing_id: String, + price: Coin, + }, + CreateOffer { + collection: String, + token_id: String, + price: Coin, + }, + AcceptOffer { + id: String, + collection: String, + token_id: String, + price: Coin, + }, + CreateCollectionOffer { + collection: String, + price: Coin, + }, + AcceptCollectionOffer { + id: String, + collection: String, + token_id: String, + price: Coin, + }, + CancelOffer { + id: String, + }, + CancelCollectionOffer { + id: String, + }, + ApproveSale { + id: String, + }, + RejectSale { + id: String, + }, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(Config)] + Config {}, + #[returns(Listing)] + Listing { listing_id: String }, + #[returns(Offer)] + Offer { offer_id: String }, + #[returns(CollectionOffer)] + CollectionOffer { collection_offer_id: String }, + #[returns(PendingSale)] + PendingSale { id: String }, + #[returns(Vec)] + PendingSales { + limit: Option, + start_after: Option, + }, + #[returns(Vec)] + PendingSalesByExpiry { + start_after: Option, + limit: Option, + }, +} + +#[cw_serde] +pub struct MigrateMsg {} diff --git a/contracts/marketplace/src/offers.rs b/contracts/marketplace/src/offers.rs new file mode 100644 index 0000000..63aa644 --- /dev/null +++ b/contracts/marketplace/src/offers.rs @@ -0,0 +1,269 @@ +use crate::error::ContractError; +use crate::events::item_sold_event; +use crate::helpers::{asset_buy_msg, asset_list_msg, generate_id, only_owner, valid_payment}; +use crate::state::{collection_offers, CollectionOffer, Offer, CONFIG}; +use cosmwasm_std::{ + ensure_eq, to_json_binary, Addr, BankMsg, Coin, DepsMut, Env, MessageInfo, Response, WasmMsg, +}; + +use crate::events::{ + cancel_collection_offer_event, cancel_offer_event, create_collection_offer_event, + create_offer_event, +}; +use crate::state::{next_auto_increment, offers}; + +pub fn execute_create_offer( + deps: DepsMut, + env: Env, + info: MessageInfo, + collection: Addr, + price: Coin, + token_id: String, +) -> Result { + let config = CONFIG.load(deps.storage)?; + // ensure valid payment is sent for escrow + valid_payment(&info, price.clone(), config.listing_denom)?; + let auto_increment = next_auto_increment(deps.storage)?; + let id = generate_id(vec![ + env.block.height.to_string().as_bytes(), + &auto_increment.to_string().as_bytes(), + &collection.as_bytes(), + &token_id.as_bytes(), + &info.sender.as_bytes(), + ]); + let offer = Offer { + id: id.clone().to_string(), + buyer: info.sender.clone(), + collection: collection.clone(), + token_id: token_id.clone(), + price: price.clone(), + }; + // reject offer for potential collision + offers().update(deps.storage, id.clone().to_string(), |prev| match prev { + Some(_) => Err(ContractError::OfferAlreadyExists { id: id.to_string() }), + None => Ok(offer), + })?; + + Ok(Response::new().add_event(create_offer_event( + id, + collection, + info.sender, + token_id, + price, + ))) +} +pub fn execute_accept_offer( + deps: DepsMut, + info: MessageInfo, + offer_id: String, + collection: Addr, + token_id: String, + price: Coin, +) -> Result { + only_owner(&deps.querier, &info, &collection, &token_id)?; + let offer = offers().load(deps.storage, offer_id.clone())?; + ensure_eq!( + offer.collection, + collection, + ContractError::InvalidCollection { + expected: collection.clone().to_string(), + actual: offer.collection.clone().to_string() + } + ); + ensure_eq!( + token_id, + offer.token_id, + ContractError::InvalidTokenId { + expected: offer.token_id.clone(), + actual: token_id.clone() + } + ); + + if offer.price != price { + return Err(ContractError::InvalidPrice { + expected: offer.price, + actual: price, + }); + } + if offer.buyer == info.sender { + return Err(ContractError::InvalidSeller {}); + } + // list the item on the asset contract for the specific price + let list_msg = asset_list_msg(token_id.clone(), offer.price.clone()); + // do a buy on the asset contract for the specific price and buyer + let buy_msg = asset_buy_msg(info.sender.clone(), token_id.clone()); + + offers().remove(deps.storage, offer_id.clone())?; + + Ok(Response::new() + .add_event(item_sold_event( + "listing_id".to_string(), + offer.collection.clone(), + info.sender.clone(), + offer.buyer, + token_id.clone(), + offer.price.clone(), + Some(offer.id), + None, + )) + .add_message(WasmMsg::Execute { + contract_addr: offer.collection.clone().to_string(), + msg: to_json_binary(&list_msg)?, + funds: vec![], + }) + .add_message(WasmMsg::Execute { + contract_addr: offer.collection.clone().to_string(), + msg: to_json_binary(&buy_msg)?, + funds: vec![price], + })) +} + +pub fn execute_cancel_offer( + deps: DepsMut, + info: MessageInfo, + offer_id: String, +) -> Result { + let offer = offers().load(deps.storage, offer_id.clone())?; + ensure_eq!( + offer.buyer, + info.sender, + ContractError::Unauthorized { + message: "sender is not the buyer".to_string() + } + ); + offers().remove(deps.storage, offer_id)?; + let refund_msg = BankMsg::Send { + to_address: offer.buyer.to_string(), + amount: vec![offer.price], + }; + Ok(Response::new() + .add_event(cancel_offer_event( + offer.id, + offer.collection, + offer.buyer, + offer.token_id, + )) + .add_message(refund_msg)) +} + +pub fn execute_create_collection_offer( + deps: DepsMut, + env: Env, + info: MessageInfo, + collection: Addr, + price: Coin, +) -> Result { + let config = CONFIG.load(deps.storage)?; + // ensure valid payment is sent for escrow + valid_payment(&info, price.clone(), config.listing_denom)?; + let auto_increment = next_auto_increment(deps.storage)?; + let id = generate_id(vec![ + env.block.height.to_string().as_bytes(), + &auto_increment.to_string().as_bytes(), + &collection.as_bytes(), + &info.sender.as_bytes(), + ]); + let collection_offer = CollectionOffer { + id: id.to_string(), + buyer: info.sender.clone(), + collection: collection.clone(), + price: price.clone(), + }; + // reject offer for potential collision + collection_offers().update(deps.storage, id.to_string(), |prev| match prev { + Some(_) => Err(ContractError::OfferAlreadyExists { id: id.to_string() }), + None => Ok(collection_offer), + })?; + Ok(Response::new().add_event(create_collection_offer_event( + id, + collection, + info.sender, + price, + ))) +} + +pub fn execute_accept_collection_offer( + deps: DepsMut, + info: MessageInfo, + offer_id: String, + collection: Addr, + token_id: String, + price: Coin, +) -> Result { + only_owner(&deps.querier, &info, &collection, &token_id)?; + let offer = collection_offers().load(deps.storage, offer_id.clone())?; + ensure_eq!( + offer.collection, + collection, + ContractError::InvalidCollection { + expected: collection.clone().to_string(), + actual: offer.collection.clone().to_string() + } + ); + + if offer.price != price { + return Err(ContractError::InvalidPrice { + expected: offer.price, + actual: price, + }); + } + if offer.buyer == info.sender { + return Err(ContractError::InvalidSeller {}); + } + // list the item on the asset contract for the specific price + let list_msg = asset_list_msg(token_id.clone(), offer.price.clone()); + // do a buy on the asset contract for the specific price and buyer + let buy_msg = asset_buy_msg(info.sender.clone(), token_id.clone()); + + collection_offers().remove(deps.storage, offer_id.clone())?; + + Ok(Response::new() + .add_event(item_sold_event( + "listing_id".to_string(), + offer.collection.clone(), + info.sender.clone(), + offer.buyer, + token_id.clone(), + offer.price.clone(), + Some(offer.id), + None, + )) + .add_message(WasmMsg::Execute { + contract_addr: offer.collection.clone().to_string(), + msg: to_json_binary(&list_msg)?, + funds: vec![], + }) + .add_message(WasmMsg::Execute { + contract_addr: offer.collection.clone().to_string(), + msg: to_json_binary(&buy_msg)?, + funds: vec![price], + })) +} + +pub fn execute_cancel_collection_offer( + deps: DepsMut, + info: MessageInfo, + offer_id: String, +) -> Result { + let collection_offer = collection_offers().load(deps.storage, offer_id.clone())?; + ensure_eq!( + collection_offer.buyer, + info.sender, + ContractError::Unauthorized { + message: "sender is not the buyer".to_string() + } + ); + collection_offers().remove(deps.storage, offer_id)?; + + let refund_msg = BankMsg::Send { + to_address: collection_offer.buyer.to_string(), + amount: vec![collection_offer.price], + }; + Ok(Response::new() + .add_event(cancel_collection_offer_event( + collection_offer.id, + collection_offer.collection, + collection_offer.buyer, + )) + .add_message(refund_msg)) +} diff --git a/contracts/marketplace/src/query.rs b/contracts/marketplace/src/query.rs new file mode 100644 index 0000000..39fa48f --- /dev/null +++ b/contracts/marketplace/src/query.rs @@ -0,0 +1,82 @@ +use cosmwasm_std::{entry_point, to_json_binary, Addr, Binary, Deps, Env, Order, StdResult}; +use cw_storage_plus::Bound; + +use crate::msg::QueryMsg; +use crate::state::{ + collection_offers, listings, offers, pending_sales, CollectionOffer, Config, Listing, Offer, + PendingSale, CONFIG, +}; + +#[entry_point] +pub fn query(_deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Config {} => to_json_binary(&query_config(_deps)?), + QueryMsg::Listing { listing_id } => to_json_binary(&query_listing(_deps, listing_id)?), + QueryMsg::Offer { offer_id } => to_json_binary(&query_offer(_deps, offer_id)?), + QueryMsg::CollectionOffer { + collection_offer_id, + } => to_json_binary(&query_collection_offer(_deps, collection_offer_id)?), + QueryMsg::PendingSale { id } => to_json_binary(&query_pending_sale(_deps, id)?), + QueryMsg::PendingSales { start_after, limit } => { + to_json_binary(&query_pending_sales(_deps, start_after, limit)?) + } + QueryMsg::PendingSalesByExpiry { start_after, limit } => { + to_json_binary(&query_pending_sales_by_expiry(_deps, start_after, limit)?) + } + } +} + +pub fn query_pending_sale(deps: Deps, id: String) -> StdResult { + Ok(pending_sales().load(deps.storage, id)?) +} + +pub fn query_pending_sales( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult> { + let limit = limit.unwrap_or(30).min(30) as usize; + let start = start_after.map(|v| Bound::exclusive(v.to_string())); + + pending_sales() + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|item| item.map(|(_, sale)| sale)) + .collect::>>() +} + +pub fn query_pending_sales_by_expiry( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult> { + let limit = limit.unwrap_or(30).min(30) as usize; + let start = start_after.map(|v| Bound::exclusive((v, "".to_string()))); + + pending_sales() + .idx + .by_expiration + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|item| item.map(|(_, sale)| sale)) + .collect::>>() +} + +pub fn query_config(deps: Deps) -> StdResult> { + Ok(CONFIG.load(deps.storage)?) +} + +pub fn query_listing(deps: Deps, listing_id: String) -> StdResult { + Ok(listings().load(deps.storage, listing_id)?) +} + +pub fn query_offer(deps: Deps, offer_id: String) -> StdResult { + Ok(offers().load(deps.storage, offer_id)?) +} + +pub fn query_collection_offer( + deps: Deps, + collection_offer_id: String, +) -> StdResult { + Ok(collection_offers().load(deps.storage, collection_offer_id)?) +} diff --git a/contracts/marketplace/src/state.rs b/contracts/marketplace/src/state.rs new file mode 100644 index 0000000..96f5769 --- /dev/null +++ b/contracts/marketplace/src/state.rs @@ -0,0 +1,233 @@ +use cosmwasm_schema::cw_serde; + +use crate::error::ContractError; +use cosmwasm_std::{ensure, Addr, Api, Coin, Storage}; +use cw_address_like::AddressLike; +use cw_storage_plus::{index_list, IndexedMap, Item, MultiIndex}; + +#[cw_serde] +pub struct Config { + pub manager: T, + pub sale_approvals: bool, + pub fee_bps: u64, + pub listing_denom: String, +} + +// Maximum fee bps allowed. +const MAX_FEE_BPS: u64 = 10_000; + +impl Config { + pub fn save(&self, storage: &mut dyn Storage) -> Result<(), ContractError> { + ensure!( + self.fee_bps <= MAX_FEE_BPS, + ContractError::InvalidFeeRate {} + ); + CONFIG.save(storage, self)?; + Ok(()) + } +} + +impl Config { + pub fn to_addr(&self, api: &dyn Api) -> Result, ContractError> { + Ok(Config { + manager: api.addr_validate(&self.manager)?, + fee_bps: self.fee_bps, + sale_approvals: self.sale_approvals, + listing_denom: self.listing_denom.clone(), + }) + } +} +impl Config { + pub fn from_str(config: Config, api: &dyn Api) -> Result { + Ok(Config { + manager: api.addr_validate(&config.manager)?, + fee_bps: config.fee_bps, + sale_approvals: config.sale_approvals, + listing_denom: config.listing_denom, + }) + } +} +impl From> for Config { + fn from(config: Config) -> Self { + Config { + manager: config.manager.to_string(), + fee_bps: config.fee_bps, + sale_approvals: config.sale_approvals, + listing_denom: config.listing_denom, + } + } +} + +pub const CONFIG: Item> = Item::new("config"); + +#[cw_serde] +pub enum ListingStatus { + Active, + PendingApproval, +} + +impl std::fmt::Display for ListingStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ListingStatus::Active => write!(f, "Active"), + ListingStatus::PendingApproval => write!(f, "PendingApproval"), + } + } +} + +#[cw_serde] +pub struct Listing { + pub id: String, + pub collection: Addr, + pub token_id: String, + pub price: Coin, + pub seller: Addr, + pub status: ListingStatus, +} + +type ListingId = String; +type OfferId = String; +type CollectionOfferId = String; +#[index_list(Listing)] +pub struct ListingIndices<'a> { + pub by_seller: MultiIndex<'a, Addr, Listing, ListingId>, +} + +pub fn listings<'a>() -> IndexedMap> { + let listing_indices = ListingIndices { + by_seller: MultiIndex::new( + |_pk: &[u8], listing: &Listing| listing.seller.clone(), + "l", + "ls", + ), + }; + IndexedMap::new("l", listing_indices) +} + +#[cw_serde] +pub struct Offer { + pub id: String, + pub buyer: Addr, + pub price: Coin, + pub collection: Addr, + pub token_id: String, +} + +#[index_list(Offer)] +pub struct OfferIndices<'a> { + pub by_collection_and_price: MultiIndex<'a, (Addr, String, u128), Offer, OfferId>, +} + +pub fn offers<'a>() -> IndexedMap> { + let offer_indices = OfferIndices { + by_collection_and_price: MultiIndex::new( + |_pk: &[u8], offer: &Offer| { + ( + offer.collection.clone(), + offer.price.denom.clone(), + offer.price.amount.u128(), + ) + }, + "o", // offers namespace shorter for storage efficiency + "ocp", // offers by collection and price + ), + }; + IndexedMap::new("o", offer_indices) +} + +#[cw_serde] +pub struct CollectionOffer { + pub id: String, + pub buyer: Addr, + pub price: Coin, + pub collection: Addr, +} + +#[index_list(CollectionOffer)] +pub struct CollectionOfferIndices<'a> { + pub by_collection_and_price: + MultiIndex<'a, (Addr, String, u128), CollectionOffer, CollectionOfferId>, +} + +pub fn collection_offers<'a>( +) -> IndexedMap> { + let collection_offer_indices = CollectionOfferIndices { + by_collection_and_price: MultiIndex::new( + |_pk: &[u8], collection_offer: &CollectionOffer| { + ( + collection_offer.collection.clone(), + collection_offer.price.denom.clone(), + collection_offer.price.amount.u128(), + ) + }, + "co", // collection offers namespace shorter for storage efficiency + "cop", // collection offers by collection and price + ), + }; + IndexedMap::new("co", collection_offer_indices) +} + +pub const AUTO_INCREMENT: Item = Item::new("auto_increment"); + +// next_auto_increment is inteded to be used as a generator nonce for unique ids in combination +// with other sources of entropy to generate unique ids. +pub fn next_auto_increment(storage: &mut dyn Storage) -> Result { + let auto_increment = AUTO_INCREMENT.load(storage)?.wrapping_add(1); + AUTO_INCREMENT.save(storage, &auto_increment)?; + Ok(auto_increment) +} + +pub fn init_auto_increment(storage: &mut dyn Storage) -> Result<(), ContractError> { + AUTO_INCREMENT.save(storage, &0)?; + Ok(()) +} + +#[cw_serde] +pub enum SaleType { + BuyNow, + TokenOffer, + CollectionOffer, +} + +type PendingSaleId = String; +#[cw_serde] +pub struct PendingSale { + pub id: String, + pub collection: Addr, + pub token_id: String, + pub price: Coin, + pub seller: Addr, + pub buyer: Addr, + pub sale_type: SaleType, + pub time: u64, + pub expiration: u64, +} + +#[index_list(PendingSale)] +pub struct PendingSaleIndices<'a> { + pub by_seller: MultiIndex<'a, Addr, PendingSale, PendingSaleId>, + pub by_buyer: MultiIndex<'a, Addr, PendingSale, PendingSaleId>, + pub by_expiration: MultiIndex<'a, u64, PendingSale, PendingSaleId>, +} + +const PENDING_SALES_NAMESPACE: &str = "ps"; +pub fn pending_sales<'a>() -> IndexedMap> { + let pending_sale_indices = PendingSaleIndices { + by_seller: MultiIndex::new( + |_id, pending_sale: &PendingSale| pending_sale.seller.clone(), + PENDING_SALES_NAMESPACE, + "pss", // pending sale seller index namespace + ), + by_buyer: MultiIndex::new( + |_id, pending_sale: &PendingSale| pending_sale.buyer.clone(), + PENDING_SALES_NAMESPACE, + "psb", // pending sale buyer index namespace + ), + by_expiration: MultiIndex::new( + |_id, pending_sale: &PendingSale| pending_sale.expiration, + PENDING_SALES_NAMESPACE, + "pse", // pending sale expiration index namespace + ), + }; + IndexedMap::new("ps", pending_sale_indices) +} diff --git a/contracts/marketplace/tests/mod.rs b/contracts/marketplace/tests/mod.rs new file mode 100644 index 0000000..d950d31 --- /dev/null +++ b/contracts/marketplace/tests/mod.rs @@ -0,0 +1,2 @@ +#[cfg(test)] +mod test_marketplace; diff --git a/contracts/marketplace/tests/test_marketplace.rs b/contracts/marketplace/tests/test_marketplace.rs new file mode 100644 index 0000000..cecc33c --- /dev/null +++ b/contracts/marketplace/tests/test_marketplace.rs @@ -0,0 +1,1750 @@ +use anyhow::Error; +use cosmwasm_std::{coin, Addr, Empty}; +use cw_multi_test::AppResponse; +use cw_multi_test::{Contract, ContractWrapper}; +use serde_json::json; +use xion_nft_marketplace::helpers::generate_id; +use xion_nft_marketplace::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use xion_nft_marketplace::state::{Listing, ListingStatus}; + +pub fn assert_error(result: Result, expected: String) { + assert_eq!(result.unwrap_err().source().unwrap().to_string(), expected); +} +fn asset_contract() -> Box> { + Box::new(ContractWrapper::new_with_empty( + asset::contracts::asset_base::execute, + asset::contracts::asset_base::instantiate, + asset::contracts::asset_base::query, + )) +} + +fn marketplace_contract() -> Box> { + Box::new(ContractWrapper::new_with_empty( + xion_nft_marketplace::contract::execute, + xion_nft_marketplace::contract::instantiate, + xion_nft_marketplace::query::query, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use asset::msg::InstantiateMsg as AssetInstantiateMsg; + use cw721::DefaultOptionalCollectionExtensionMsg; + use cw721_base::msg::ExecuteMsg as Cw721ExecuteMsg; + + use cw_multi_test::{App, Executor}; + use xion_nft_marketplace::helpers::query_listing; + + fn setup_app() -> App { + App::default() + } + + fn setup_app_with_balances() -> App { + use cosmwasm_std::coin; + use cw_multi_test::{App, BankSudo, SudoMsg}; + + let mut app = App::default(); + + // Create proper addresses using app.api().addr_make + let buyer = app.api().addr_make("buyer"); + let seller = app.api().addr_make("seller"); + let minter = app.api().addr_make("minter"); + let manager = app.api().addr_make("manager"); + + // Mint tokens to test accounts using sudo + let funds = vec![coin(10000, "uxion")]; + + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: buyer.to_string(), + amount: funds.clone(), + })) + .unwrap(); + + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: seller.to_string(), + amount: funds.clone(), + })) + .unwrap(); + + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: minter.to_string(), + amount: funds.clone(), + })) + .unwrap(); + + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: manager.to_string(), + amount: funds.clone(), + })) + .unwrap(); + + app + } + + fn setup_asset_contract(app: &mut App, minter: &Addr) -> Addr { + let asset_code_id = app.store_code(asset_contract()); + + let instantiate_msg = AssetInstantiateMsg { + name: "Test Asset".to_string(), + symbol: "TEST".to_string(), + minter: Some(minter.to_string()), + collection_info_extension: DefaultOptionalCollectionExtensionMsg::default(), + creator: Some(minter.to_string()), + withdraw_address: None, + }; + + app.instantiate_contract( + asset_code_id, + minter.clone(), + &instantiate_msg, + &[], + "test-asset", + None, + ) + .unwrap() + } + + fn setup_marketplace_contract(app: &mut App, manager: &Addr) -> Addr { + let marketplace_code_id = app.store_code(marketplace_contract()); + + let config_json = json!({ + "manager": manager.to_string(), + "sale_approvals": false, + "fee_bps": 250, + "listing_denom": "uxion" + }); + + let instantiate_msg = InstantiateMsg { + config: serde_json::from_value(config_json).unwrap(), + }; + + app.instantiate_contract( + marketplace_code_id, + manager.clone(), + &instantiate_msg, + &[], + "test-marketplace", + None, + ) + .unwrap() + } + + fn mint_nft(app: &mut App, asset_contract: &Addr, minter: &Addr, owner: &Addr, token_id: &str) { + let mint_msg = asset::msg::ExecuteMsg::< + cw721::DefaultOptionalNftExtensionMsg, + cw721::DefaultOptionalCollectionExtensionMsg, + asset::msg::AssetExtensionExecuteMsg, + >::Mint { + token_id: token_id.to_string(), + owner: owner.to_string(), + token_uri: Some("https://example.com/metadata.json".to_string()), + extension: cw721::DefaultOptionalNftExtensionMsg::default(), + }; + + app.execute_contract(minter.clone(), asset_contract.clone(), &mint_msg, &[]) + .unwrap(); + } + + mod create_listing_tests { + use super::*; + + #[test] + fn test_mint_nft() { + let mut app = setup_app(); + let minter = app.api().addr_make("minter"); + let seller = app.api().addr_make("seller"); + let asset_contract = setup_asset_contract(&mut app, &minter); + + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + } + + #[test] + fn test_create_listing_success() { + let mut app = setup_app(); + let minter = app.api().addr_make("minter"); + let seller = app.api().addr_make("seller"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Mint NFT to seller + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + // Approve marketplace contract to manage the NFT + let approve_msg = Cw721ExecuteMsg::Approve { + spender: marketplace_contract.to_string(), + token_id: "token1".to_string(), + expires: None, + }; + app.execute_contract(seller.clone(), asset_contract.clone(), &approve_msg, &[]) + .unwrap(); + + // Create listing + let price = coin(100, "uxion"); + let list_msg = ExecuteMsg::ListItem { + collection: asset_contract.to_string(), + price: price.clone(), + token_id: "token1".to_string(), + }; + + let result = + app.execute_contract(seller.clone(), marketplace_contract.clone(), &list_msg, &[]); + assert!(result.is_ok()); + let listing_resp = query_listing(&app.wrap(), &asset_contract, "token1"); + assert!(listing_resp.is_ok()); + let listing = listing_resp.unwrap(); + assert_eq!(listing.price, price); + // TODO: seller should not be the marketplace contract + // assert_eq!(listing.seller, seller); + + // verify event is emitted + let events = result.unwrap().events; + let list_event = events + .iter() + .find(|e| e.ty == "wasm-xion-nft-marketplace/list-item"); + assert!(list_event.is_some()); + let listing_id = generate_id(vec![&asset_contract.as_bytes(), &"token1".as_bytes()]); + assert_eq!( + "54f6c0b51fa2fae79401bc3d0f0e5d98f8be4588d312643f7b7dd631e88173cc", + listing_id.clone() + ); + let listing = app + .wrap() + .query_wasm_smart::( + marketplace_contract.clone(), + &QueryMsg::Listing { + listing_id: listing_id, + }, + ) + .unwrap(); + + assert_eq!(listing.price, price); + assert_eq!(listing.seller, seller); + assert_eq!(listing.status, ListingStatus::Active); + } + #[test] + fn test_create_listing_unauthorized() { + let mut app = setup_app(); + let minter = app.api().addr_make("minter"); + let seller = app.api().addr_make("seller"); + let unauthorized_user = app.api().addr_make("unauthorized"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Mint NFT to seller + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + // Try to create listing with unauthorized user + let price = coin(100, "uxion"); + let list_msg = ExecuteMsg::ListItem { + collection: asset_contract.to_string(), + price, + token_id: "token1".to_string(), + }; + + let result = app.execute_contract( + unauthorized_user.clone(), + marketplace_contract.clone(), + &list_msg, + &[], + ); + + assert!(result.is_err()); + assert_error( + result, + xion_nft_marketplace::error::ContractError::Unauthorized { + message: "sender is not owner".to_string(), + } + .to_string(), + ); + } + + #[test] + fn test_create_listing_invalid_denom() { + let mut app = setup_app(); + let minter = app.api().addr_make("minter"); + let seller = app.api().addr_make("seller"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Mint NFT to seller + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + // Try to create listing with invalid denom + let price = coin(100, "fakexion"); // Wrong denom + let list_msg = ExecuteMsg::ListItem { + collection: asset_contract.to_string(), + price, + token_id: "token1".to_string(), + }; + + let result = + app.execute_contract(seller.clone(), marketplace_contract.clone(), &list_msg, &[]); + + assert!(result.is_err()); + assert_error( + result, + xion_nft_marketplace::error::ContractError::InvalidListingDenom { + expected: "uxion".to_string(), + actual: "fakexion".to_string(), + } + .to_string(), + ); + } + + #[test] + fn test_create_listing_already_listed() { + let mut app = setup_app(); + let minter = app.api().addr_make("minter"); + let seller = app.api().addr_make("seller"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Mint NFT to seller + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + // Approve marketplace contract to manage the NFT + let approve_msg = Cw721ExecuteMsg::Approve { + spender: marketplace_contract.to_string(), + token_id: "token1".to_string(), + expires: None, + }; + app.execute_contract(seller.clone(), asset_contract.clone(), &approve_msg, &[]) + .unwrap(); + + // Create listing + let price = coin(100, "uxion"); + let list_msg = ExecuteMsg::ListItem { + collection: asset_contract.to_string(), + price: price.clone(), + token_id: "token1".to_string(), + }; + + let result = + app.execute_contract(seller.clone(), marketplace_contract.clone(), &list_msg, &[]); + assert!(result.is_ok()); + // Try to create second listing for same token + let list_msg2 = ExecuteMsg::ListItem { + collection: asset_contract.to_string(), + price, + token_id: "token1".to_string(), + }; + + let result2 = app.execute_contract( + seller.clone(), + marketplace_contract.clone(), + &list_msg2, + &[], + ); + + assert!(result2.is_err()); + assert_error( + result2, + xion_nft_marketplace::error::ContractError::AlreadyListed {}.to_string(), + ); + } + + #[test] + fn test_create_listing_nonexistent_token() { + let mut app = setup_app(); + let seller = app.api().addr_make("seller"); + let manager = app.api().addr_make("manager"); + let minter = app.api().addr_make("minter"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Try to create listing for non-existent token + let price = coin(100, "uxion"); + let list_msg = ExecuteMsg::ListItem { + collection: asset_contract.to_string(), + price, + token_id: "nonexistent".to_string(), + }; + + let result = + app.execute_contract(seller.clone(), marketplace_contract.clone(), &list_msg, &[]); + + assert!(result.is_err()); + assert_error( + result, + xion_nft_marketplace::error::ContractError::Unauthorized { + message: "sender is not owner".to_string(), + } + .to_string(), + ); + } + } + + mod cancel_listing_tests { + use super::*; + + fn create_listing( + app: &mut App, + marketplace_contract: &Addr, + asset_contract: &Addr, + seller: &Addr, + token_id: &str, + price: cosmwasm_std::Coin, + ) -> String { + // Approve marketplace contract to manage the NFT + let approve_msg = Cw721ExecuteMsg::Approve { + spender: marketplace_contract.to_string(), + token_id: token_id.to_string(), + expires: None, + }; + app.execute_contract(seller.clone(), asset_contract.clone(), &approve_msg, &[]) + .unwrap(); + + let list_msg = ExecuteMsg::ListItem { + collection: asset_contract.to_string(), + price, + token_id: token_id.to_string(), + }; + + let result = + app.execute_contract(seller.clone(), marketplace_contract.clone(), &list_msg, &[]); + assert!(result.is_ok()); + + // Extract listing ID from events + let events = result.unwrap().events; + let list_event = events + .iter() + .find(|e| e.ty == "wasm-xion-nft-marketplace/list-item") + .unwrap(); + // Find the id attribute by key + let id_attr = list_event + .attributes + .iter() + .find(|a| a.key == "id") + .unwrap(); + id_attr.value.clone() + } + #[test] + fn test_cancel_listing_success() { + let mut app = setup_app(); + let minter = app.api().addr_make("minter"); + let seller = app.api().addr_make("seller"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Mint NFT to seller + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + // Create listing + let price = coin(100, "uxion"); + let listing_id = create_listing( + &mut app, + &marketplace_contract, + &asset_contract, + &seller, + "token1", + price, + ); + + // Cancel listing + let cancel_msg = ExecuteMsg::CancelListing { + listing_id: listing_id.clone(), + }; + + let result = app.execute_contract( + seller.clone(), + marketplace_contract.clone(), + &cancel_msg, + &[], + ); + + assert!(result.is_ok()); + + // Verify the listing was cancelled by checking events + let events = result.unwrap().events; + let cancel_event = events + .iter() + .find(|e| e.ty == "wasm-xion-nft-marketplace/cancel-listing"); + assert!(cancel_event.is_some()); + + // listing should not be found on the asset contract + let listing_resp = query_listing(&app.wrap(), &asset_contract, "token1"); + assert!(listing_resp.is_err()); + + // listing should not be found on the marketplace contract + let listing_resp = app.wrap().query_wasm_smart::( + marketplace_contract.clone(), + &QueryMsg::Listing { + listing_id: listing_id.clone(), + }, + ); + assert!(listing_resp.is_err()); + } + + #[test] + fn test_cancel_listing_unauthorized() { + let mut app = setup_app(); + let minter = app.api().addr_make("minter"); + let seller = app.api().addr_make("seller"); + let unauthorized_user = app.api().addr_make("unauthorized"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Mint NFT to seller + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + // Create listing + let price = coin(100, "uxion"); + let listing_id = create_listing( + &mut app, + &marketplace_contract, + &asset_contract, + &seller, + "token1", + price, + ); + + // Try to cancel listing with unauthorized user + let cancel_msg = ExecuteMsg::CancelListing { listing_id }; + + let result = app.execute_contract( + unauthorized_user.clone(), + marketplace_contract.clone(), + &cancel_msg, + &[], + ); + + assert!(result.is_err()); + + assert_error( + result, + xion_nft_marketplace::error::ContractError::Unauthorized { + message: "sender is not the seller".to_string(), + } + .to_string(), + ); + } + + #[test] + fn test_cancel_listing_nonexistent() { + let mut app = setup_app(); + let seller = app.api().addr_make("seller"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Try to cancel non-existent listing + let cancel_msg = ExecuteMsg::CancelListing { + listing_id: "nonexistent".to_string(), + }; + + let result = app.execute_contract( + seller.clone(), + marketplace_contract.clone(), + &cancel_msg, + &[], + ); + + assert!(result.is_err()); + result.unwrap_err().to_string().contains("not found"); + } + } + + mod buy_item_tests { + use super::*; + + fn extract_listing_id_from_events(events: &[cosmwasm_std::Event]) -> String { + let list_event = events + .iter() + .find(|e| e.ty == "wasm-xion-nft-marketplace/list-item") + .unwrap(); + list_event + .attributes + .iter() + .find(|a| a.key == "id") + .unwrap() + .value + .clone() + } + + fn create_listing_for_buy_test( + app: &mut App, + marketplace_contract: &Addr, + asset_contract: &Addr, + seller: &Addr, + token_id: &str, + price: cosmwasm_std::Coin, + ) -> String { + // Approve marketplace contract to manage the NFT + let approve_msg = Cw721ExecuteMsg::Approve { + spender: marketplace_contract.to_string(), + token_id: token_id.to_string(), + expires: None, + }; + app.execute_contract(seller.clone(), asset_contract.clone(), &approve_msg, &[]) + .unwrap(); + + let list_msg = ExecuteMsg::ListItem { + collection: asset_contract.to_string(), + price, + token_id: token_id.to_string(), + }; + + let result = + app.execute_contract(seller.clone(), marketplace_contract.clone(), &list_msg, &[]); + assert!(result.is_ok()); + + // Extract listing ID from events + let events = result.unwrap().events; + let list_event = events + .iter() + .find(|e| e.ty == "wasm-xion-nft-marketplace/list-item") + .unwrap(); + let id_attr = list_event + .attributes + .iter() + .find(|a| a.key == "id") + .unwrap(); + id_attr.value.clone() + } + + #[test] + fn test_buy_item_success() { + // This test follows the exact same pattern as test_create_listing_success + // but adds the buy functionality + let mut app = setup_app(); + let minter = app.api().addr_make("minter"); + let seller = app.api().addr_make("seller"); + let buyer = app.api().addr_make("buyer"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Mint NFT to seller + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + // Approve marketplace contract to manage the NFT + let approve_msg = Cw721ExecuteMsg::Approve { + spender: marketplace_contract.to_string(), + token_id: "token1".to_string(), + expires: None, + }; + app.execute_contract(seller.clone(), asset_contract.clone(), &approve_msg, &[]) + .unwrap(); + + // Create listing (same as test_create_listing_success) + let price = coin(100, "uxion"); + let list_msg = ExecuteMsg::ListItem { + collection: asset_contract.to_string(), + price: price.clone(), + token_id: "token1".to_string(), + }; + + let result = + app.execute_contract(seller.clone(), marketplace_contract.clone(), &list_msg, &[]); + assert!(result.is_ok()); + + // Extract listing ID from events + let events = result.unwrap().events; + let listing_id = extract_listing_id_from_events(&events); + + // Verify listing was created (same as test_create_listing_success) + let listing_resp = query_listing(&app.wrap(), &asset_contract, "token1"); + assert!(listing_resp.is_ok()); + let listing = listing_resp.unwrap(); + assert_eq!(listing.price, price); + + // Add funds for the buyer + use cosmwasm_std::coin; + use cw_multi_test::{BankSudo, SudoMsg}; + let funds = vec![coin(10000, "uxion")]; + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: buyer.to_string(), + amount: funds, + })) + .unwrap(); + + // Buy item using listing_id + let buy_msg = ExecuteMsg::BuyItem { + listing_id: listing_id.clone(), + price: price.clone(), + }; + + let result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &buy_msg, + &[price.clone()], + ); + + match result { + Ok(response) => { + // Verify the item was sold by checking events + let events = response.events; + let sell_event = events + .iter() + .find(|e| e.ty == "wasm-xion-nft-marketplace/item-sold"); + assert!(sell_event.is_some()); + + // Verify NFT ownership changed + let owner_query = cw721_base::msg::QueryMsg::OwnerOf { + token_id: "token1".to_string(), + include_expired: Some(false), + }; + let owner_resp: cw721::msg::OwnerOfResponse = app + .wrap() + .query_wasm_smart(asset_contract.clone(), &owner_query) + .unwrap(); + assert_eq!(owner_resp.owner, buyer.to_string()); + + // Verify listing is no longer active + let listing_resp = query_listing(&app.wrap(), &asset_contract, "token1"); + assert!(listing_resp.is_err()); + } + Err(error) => { + println!("Error: {:?}", error); + panic!("Buy item failed: {:?}", error); + } + } + } + + #[test] + fn test_buy_item_insufficient_funds() { + let mut app = setup_app_with_balances(); + let minter = app.api().addr_make("minter"); + let seller = app.api().addr_make("seller"); + let buyer = app.api().addr_make("buyer"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Mint NFT to seller + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + // Create listing + let price = coin(100, "uxion"); + let listing_id = create_listing_for_buy_test( + &mut app, + &marketplace_contract, + &asset_contract, + &seller, + "token1", + price.clone(), + ); + + // Try to buy item with insufficient funds + let insufficient_price = coin(50, "uxion"); + let buy_msg = ExecuteMsg::BuyItem { + listing_id: listing_id.clone(), + price: price.clone(), + }; + + let result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &buy_msg, + &[insufficient_price], + ); + + assert!(result.is_err()); + assert_error( + result, + xion_nft_marketplace::error::ContractError::InvalidPayment { + expected: price, + actual: coin(50, "uxion"), + } + .to_string(), + ); + } + + #[test] + fn test_buy_item_wrong_price() { + let mut app = setup_app_with_balances(); + let minter = app.api().addr_make("minter"); + let seller = app.api().addr_make("seller"); + let buyer = app.api().addr_make("buyer"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Mint NFT to seller + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + // Create listing + let listing_price = coin(100, "uxion"); + let listing_id = create_listing_for_buy_test( + &mut app, + &marketplace_contract, + &asset_contract, + &seller, + "token1", + listing_price.clone(), + ); + + // Try to buy item with wrong price + let wrong_price = coin(150, "uxion"); + let buy_msg = ExecuteMsg::BuyItem { + listing_id: listing_id.clone(), + price: wrong_price.clone(), + }; + + let result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &buy_msg, + &[wrong_price], + ); + + assert!(result.is_err()); + assert_error( + result, + xion_nft_marketplace::error::ContractError::InvalidPrice { + expected: listing_price, + actual: coin(150, "uxion"), + } + .to_string(), + ); + } + + #[test] + fn test_buy_item_wrong_denomination() { + let mut app = setup_app(); + let minter = app.api().addr_make("minter"); + let seller = app.api().addr_make("seller"); + let buyer = app.api().addr_make("buyer"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Mint NFT to seller + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + // Approve marketplace contract to manage the NFT + let approve_msg = Cw721ExecuteMsg::Approve { + spender: marketplace_contract.to_string(), + token_id: "token1".to_string(), + expires: None, + }; + app.execute_contract(seller.clone(), asset_contract.clone(), &approve_msg, &[]) + .unwrap(); + + // Create listing + let price = coin(100, "uxion"); + let list_msg = ExecuteMsg::ListItem { + collection: asset_contract.to_string(), + price: price.clone(), + token_id: "token1".to_string(), + }; + + let result = + app.execute_contract(seller.clone(), marketplace_contract.clone(), &list_msg, &[]); + assert!(result.is_ok()); + + // Extract listing ID from events + let events = result.unwrap().events; + let listing_id = extract_listing_id_from_events(&events); + + // Add funds for the buyer + use cosmwasm_std::coin; + use cw_multi_test::{BankSudo, SudoMsg}; + let funds = vec![coin(10000, "fakexion")]; // Give buyer fakexion instead of uxion + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: buyer.to_string(), + amount: funds, + })) + .unwrap(); + + // Try to buy item with wrong denomination + let wrong_denom_price = coin(100, "fakexion"); + let buy_msg = ExecuteMsg::BuyItem { + listing_id: listing_id.clone(), + price: wrong_denom_price.clone(), + }; + + let result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &buy_msg, + &[wrong_denom_price], + ); + + assert!(result.is_err()); + assert_error( + result, + xion_nft_marketplace::error::ContractError::InvalidPrice { + expected: coin(100, "uxion"), + actual: coin(100, "fakexion"), + } + .to_string(), + ); + } + + #[test] + fn test_buy_item_nonexistent_listing() { + let mut app = setup_app_with_balances(); + let buyer = app.api().addr_make("buyer"); + let manager = app.api().addr_make("manager"); + let minter = app.api().addr_make("minter"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Try to buy item that's not listed + let price = coin(100, "uxion"); + let buy_msg = ExecuteMsg::BuyItem { + listing_id: "nonexistent-listing-id".to_string(), + price: price.clone(), + }; + + let result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &buy_msg, + &[price], + ); + + assert!(result.is_err()); + // Should fail because the listing doesn't exist + let error_msg = result.unwrap_err().to_string(); + // The error is wrapped by the test framework, so we check for the generic error + assert!(error_msg.contains("Error executing") || error_msg.contains("WasmMsg")); + } + + #[test] + fn test_buy_item_self_purchase() { + let mut app = setup_app_with_balances(); + let minter = app.api().addr_make("minter"); + let seller = app.api().addr_make("seller"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Mint NFT to seller + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + // Create listing + let price = coin(100, "uxion"); + let listing_id = create_listing_for_buy_test( + &mut app, + &marketplace_contract, + &asset_contract, + &seller, + "token1", + price.clone(), + ); + + // Try to buy own item (this should succeed - sellers can buy their own items) + let buy_msg = ExecuteMsg::BuyItem { + listing_id: listing_id.clone(), + price: price.clone(), + }; + + let result = app.execute_contract( + seller.clone(), + marketplace_contract.clone(), + &buy_msg, + &[price], + ); + + // This should succeed - sellers can buy their own items + assert!(result.is_ok()); + + // Verify the item was sold by checking events + let events = result.unwrap().events; + let sell_event = events + .iter() + .find(|e| e.ty == "wasm-xion-nft-marketplace/item-sold"); + assert!(sell_event.is_some()); + } + + #[test] + fn test_buy_item_multiple_coins() { + let mut app = setup_app_with_balances(); + let minter = app.api().addr_make("minter"); + let seller = app.api().addr_make("seller"); + let buyer = app.api().addr_make("buyer"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Mint NFT to seller + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + // Create listing + let price = coin(100, "uxion"); + let listing_id = create_listing_for_buy_test( + &mut app, + &marketplace_contract, + &asset_contract, + &seller, + "token1", + price.clone(), + ); + + // Try to buy item with multiple coins + let extra_coin = coin(50, "fakexion"); + let buy_msg = ExecuteMsg::BuyItem { + listing_id: listing_id.clone(), + price: price.clone(), + }; + + let result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &buy_msg, + &[price, extra_coin], + ); + + assert!(result.is_err()); + // Should fail because only one coin is expected + let error_msg = result.unwrap_err().to_string(); + // The error is wrapped by the test framework, so we check for the generic error + assert!(error_msg.contains("Error executing") || error_msg.contains("WasmMsg")); + } + + #[test] + fn test_buy_item_no_coins() { + let mut app = setup_app_with_balances(); + let minter = app.api().addr_make("minter"); + let seller = app.api().addr_make("seller"); + let buyer = app.api().addr_make("buyer"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Mint NFT to seller + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + // Create listing + let price = coin(100, "uxion"); + let listing_id = create_listing_for_buy_test( + &mut app, + &marketplace_contract, + &asset_contract, + &seller, + "token1", + price.clone(), + ); + + // Try to buy item without sending any coins + let buy_msg = ExecuteMsg::BuyItem { + listing_id: listing_id.clone(), + price: price.clone(), + }; + + let result = + app.execute_contract(buyer.clone(), marketplace_contract.clone(), &buy_msg, &[]); + + assert!(result.is_err()); + // Should fail because no coins were sent + let error_msg = result.unwrap_err().to_string(); + // The error is wrapped by the test framework, so we check for the generic error + assert!(error_msg.contains("Error executing") || error_msg.contains("WasmMsg")); + } + } + + mod create_offer_tests { + use super::*; + + #[test] + fn test_create_offer_success() { + let mut app = setup_app_with_balances(); + let minter = app.api().addr_make("minter"); + let seller = app.api().addr_make("seller"); + let buyer = app.api().addr_make("buyer"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Mint NFT to seller + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + // Create offer + let price = coin(100, "uxion"); + let offer_msg = ExecuteMsg::CreateOffer { + collection: asset_contract.to_string(), + token_id: "token1".to_string(), + price: price.clone(), + }; + + let result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &offer_msg, + &[price.clone()], + ); + + assert!(result.is_ok()); + + // Verify the offer was created by checking event + let response = result.unwrap(); + let events = response.events; + let offer_event = events + .iter() + .find(|e| e.ty == "wasm-xion-nft-marketplace/create-offer"); + assert!(offer_event.is_some()); + + // Verify event has ID attribute + let id_attr = offer_event + .unwrap() + .attributes + .iter() + .find(|a| a.key == "id"); + assert!(id_attr.is_some()); + } + + #[test] + fn test_create_offer_without_funds() { + let mut app = setup_app_with_balances(); + let minter = app.api().addr_make("minter"); + let seller = app.api().addr_make("seller"); + let buyer = app.api().addr_make("buyer"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Mint NFT to seller + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + // Try to create offer without sending funds + let price = coin(100, "uxion"); + let offer_msg = ExecuteMsg::CreateOffer { + collection: asset_contract.to_string(), + token_id: "token1".to_string(), + price: price.clone(), + }; + + let result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &offer_msg, + &[], // No funds sent + ); + + assert!(result.is_err()); + // Should fail because no funds were sent for escrow + } + + #[test] + fn test_create_offer_wrong_denom() { + let mut app = setup_app(); + let minter = app.api().addr_make("minter"); + let seller = app.api().addr_make("seller"); + let buyer = app.api().addr_make("buyer"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Mint NFT to seller + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + // Mint wrong denom to buyer + use cw_multi_test::{BankSudo, SudoMsg}; + let funds = vec![coin(10000, "fakexion")]; + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: buyer.to_string(), + amount: funds, + })) + .unwrap(); + + // Try to create offer with wrong denom + let price = coin(100, "fakexion"); + let offer_msg = ExecuteMsg::CreateOffer { + collection: asset_contract.to_string(), + token_id: "token1".to_string(), + price: price.clone(), + }; + + let result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &offer_msg, + &[price.clone()], + ); + + assert!(result.is_err()); + assert_error( + result, + xion_nft_marketplace::error::ContractError::InvalidListingDenom { + expected: "uxion".to_string(), + actual: "fakexion".to_string(), + } + .to_string(), + ); + } + + #[test] + fn test_create_offer_insufficient_funds() { + let mut app = setup_app_with_balances(); + let minter = app.api().addr_make("minter"); + let seller = app.api().addr_make("seller"); + let buyer = app.api().addr_make("buyer"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Mint NFT to seller + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + // Try to create offer with insufficient funds + let price = coin(100, "uxion"); + let insufficient = coin(50, "uxion"); + let offer_msg = ExecuteMsg::CreateOffer { + collection: asset_contract.to_string(), + token_id: "token1".to_string(), + price: price.clone(), + }; + + let result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &offer_msg, + &[insufficient.clone()], + ); + + assert!(result.is_err()); + assert_error( + result, + xion_nft_marketplace::error::ContractError::InvalidPayment { + expected: price, + actual: insufficient, + } + .to_string(), + ); + } + + #[test] + fn test_create_offer_nonexistent_token() { + let mut app = setup_app_with_balances(); + let minter = app.api().addr_make("minter"); + let buyer = app.api().addr_make("buyer"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Create offer for nonexistent token (should still succeed as offers don't validate existence) + let price = coin(100, "uxion"); + let offer_msg = ExecuteMsg::CreateOffer { + collection: asset_contract.to_string(), + token_id: "nonexistent".to_string(), + price: price.clone(), + }; + + let result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &offer_msg, + &[price.clone()], + ); + + // Offers can be created for nonexistent tokens + assert!(result.is_ok()); + } + } + + mod cancel_offer_tests { + use super::*; + + fn create_offer_helper( + app: &mut App, + marketplace_contract: &Addr, + asset_contract: &Addr, + buyer: &Addr, + token_id: &str, + price: cosmwasm_std::Coin, + ) -> String { + let offer_msg = ExecuteMsg::CreateOffer { + collection: asset_contract.to_string(), + token_id: token_id.to_string(), + price: price.clone(), + }; + + let result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &offer_msg, + &[price], + ); + assert!(result.is_ok()); + + // Extract offer ID from events + let events = result.unwrap().events; + let offer_event = events + .iter() + .find(|e| e.ty == "wasm-xion-nft-marketplace/create-offer") + .unwrap(); + offer_event + .attributes + .iter() + .find(|a| a.key == "id") + .unwrap() + .value + .clone() + } + + #[test] + fn test_cancel_offer_success() { + let mut app = setup_app_with_balances(); + let minter = app.api().addr_make("minter"); + let seller = app.api().addr_make("seller"); + let buyer = app.api().addr_make("buyer"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Mint NFT to seller + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + // Check buyer balance before + let buyer_balance_before = app.wrap().query_balance(buyer.clone(), "uxion").unwrap(); + + // Create offer and get ID from event + let price = coin(100, "uxion"); + let offer_id = create_offer_helper( + &mut app, + &marketplace_contract, + &asset_contract, + &buyer, + "token1", + price.clone(), + ); + + // Cancel offer + let cancel_msg = ExecuteMsg::CancelOffer { id: offer_id }; + + let result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &cancel_msg, + &[], + ); + + assert!(result.is_ok()); + + // Verify refund was sent + let buyer_balance_after = app.wrap().query_balance(buyer.clone(), "uxion").unwrap(); + assert_eq!(buyer_balance_before, buyer_balance_after); + + // Verify event was emitted + let events = result.unwrap().events; + let cancel_event = events + .iter() + .find(|e| e.ty == "wasm-xion-nft-marketplace/cancel-offer"); + assert!(cancel_event.is_some()); + } + + #[test] + fn test_cancel_offer_unauthorized() { + let mut app = setup_app_with_balances(); + let minter = app.api().addr_make("minter"); + let seller = app.api().addr_make("seller"); + let buyer = app.api().addr_make("buyer"); + let unauthorized = app.api().addr_make("unauthorized"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Mint NFT to seller + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + // Create offer and get ID from event + let price = coin(100, "uxion"); + let offer_id = create_offer_helper( + &mut app, + &marketplace_contract, + &asset_contract, + &buyer, + "token1", + price.clone(), + ); + + // Try to cancel offer with unauthorized user + let cancel_msg = ExecuteMsg::CancelOffer { id: offer_id }; + + let result = app.execute_contract( + unauthorized.clone(), + marketplace_contract.clone(), + &cancel_msg, + &[], + ); + + assert!(result.is_err()); + assert_error( + result, + xion_nft_marketplace::error::ContractError::Unauthorized { + message: "sender is not the buyer".to_string(), + } + .to_string(), + ); + } + + #[test] + fn test_cancel_offer_nonexistent() { + let mut app = setup_app_with_balances(); + let buyer = app.api().addr_make("buyer"); + let manager = app.api().addr_make("manager"); + let minter = app.api().addr_make("minter"); + + // Setup contracts + let _asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Try to cancel nonexistent offer + let cancel_msg = ExecuteMsg::CancelOffer { + id: "nonexistent-offer-id".to_string(), + }; + + let result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &cancel_msg, + &[], + ); + + assert!(result.is_err()); + // Should fail because offer doesn't exist + } + } + + mod create_collection_offer_tests { + use super::*; + + #[test] + fn test_create_collection_offer_success() { + let mut app = setup_app_with_balances(); + let minter = app.api().addr_make("minter"); + let buyer = app.api().addr_make("buyer"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Create collection offer + let price = coin(100, "uxion"); + let offer_msg = ExecuteMsg::CreateCollectionOffer { + collection: asset_contract.to_string(), + price: price.clone(), + }; + + let result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &offer_msg, + &[price.clone()], + ); + + assert!(result.is_ok()); + } + + #[test] + fn test_create_collection_offer_without_funds() { + let mut app = setup_app_with_balances(); + let minter = app.api().addr_make("minter"); + let buyer = app.api().addr_make("buyer"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Try to create collection offer without funds + let price = coin(100, "uxion"); + let offer_msg = ExecuteMsg::CreateCollectionOffer { + collection: asset_contract.to_string(), + price: price.clone(), + }; + + let result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &offer_msg, + &[], // No funds + ); + + assert!(result.is_err()); + } + + #[test] + fn test_create_collection_offer_wrong_denom() { + let mut app = setup_app(); + let minter = app.api().addr_make("minter"); + let buyer = app.api().addr_make("buyer"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Mint wrong denom to buyer + use cw_multi_test::{BankSudo, SudoMsg}; + let funds = vec![coin(10000, "fakexion")]; + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: buyer.to_string(), + amount: funds, + })) + .unwrap(); + + // Try to create collection offer with wrong denom + let price = coin(100, "fakexion"); + let offer_msg = ExecuteMsg::CreateCollectionOffer { + collection: asset_contract.to_string(), + price: price.clone(), + }; + + let result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &offer_msg, + &[price.clone()], + ); + + assert!(result.is_err()); + assert_error( + result, + xion_nft_marketplace::error::ContractError::InvalidListingDenom { + expected: "uxion".to_string(), + actual: "fakexion".to_string(), + } + .to_string(), + ); + } + + #[test] + fn test_create_collection_offer_insufficient_funds() { + let mut app = setup_app_with_balances(); + let minter = app.api().addr_make("minter"); + let buyer = app.api().addr_make("buyer"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Try to create collection offer with insufficient funds + let price = coin(100, "uxion"); + let insufficient = coin(50, "uxion"); + let offer_msg = ExecuteMsg::CreateCollectionOffer { + collection: asset_contract.to_string(), + price: price.clone(), + }; + + let result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &offer_msg, + &[insufficient.clone()], + ); + + assert!(result.is_err()); + assert_error( + result, + xion_nft_marketplace::error::ContractError::InvalidPayment { + expected: price, + actual: insufficient, + } + .to_string(), + ); + } + } + + mod cancel_collection_offer_tests { + use super::*; + + fn create_collection_offer_helper( + app: &mut App, + marketplace_contract: &Addr, + asset_contract: &Addr, + buyer: &Addr, + price: cosmwasm_std::Coin, + ) -> String { + let offer_msg = ExecuteMsg::CreateCollectionOffer { + collection: asset_contract.to_string(), + price: price.clone(), + }; + + let result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &offer_msg, + &[price], + ); + assert!(result.is_ok()); + + // Extract collection offer ID from events + let events = result.unwrap().events; + let offer_event = events + .iter() + .find(|e| e.ty == "wasm-xion-nft-marketplace/create-collection-offer") + .unwrap(); + offer_event + .attributes + .iter() + .find(|a| a.key == "id") + .unwrap() + .value + .clone() + } + + #[test] + fn test_cancel_collection_offer_success() { + let mut app = setup_app_with_balances(); + let minter = app.api().addr_make("minter"); + let buyer = app.api().addr_make("buyer"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Check buyer balance before + let buyer_balance_before = app.wrap().query_balance(buyer.clone(), "uxion").unwrap(); + + // Create collection offer and get ID from event + let price = coin(100, "uxion"); + let offer_id = create_collection_offer_helper( + &mut app, + &marketplace_contract, + &asset_contract, + &buyer, + price.clone(), + ); + + // Cancel collection offer + let cancel_msg = ExecuteMsg::CancelCollectionOffer { id: offer_id }; + + let result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &cancel_msg, + &[], + ); + + assert!(result.is_ok()); + + // Verify refund was sent + let buyer_balance_after = app.wrap().query_balance(buyer.clone(), "uxion").unwrap(); + assert_eq!(buyer_balance_before, buyer_balance_after); + + // Verify event was emitted + let events = result.unwrap().events; + let cancel_event = events + .iter() + .find(|e| e.ty == "wasm-xion-nft-marketplace/cancel-collection-offer"); + assert!(cancel_event.is_some()); + } + + #[test] + fn test_cancel_collection_offer_unauthorized() { + let mut app = setup_app_with_balances(); + let minter = app.api().addr_make("minter"); + let buyer = app.api().addr_make("buyer"); + let unauthorized = app.api().addr_make("unauthorized"); + let manager = app.api().addr_make("manager"); + + // Setup contracts + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Create collection offer and get ID from event + let price = coin(100, "uxion"); + let offer_id = create_collection_offer_helper( + &mut app, + &marketplace_contract, + &asset_contract, + &buyer, + price.clone(), + ); + + // Try to cancel with unauthorized user + let cancel_msg = ExecuteMsg::CancelCollectionOffer { id: offer_id }; + + let result = app.execute_contract( + unauthorized.clone(), + marketplace_contract.clone(), + &cancel_msg, + &[], + ); + + assert!(result.is_err()); + assert_error( + result, + xion_nft_marketplace::error::ContractError::Unauthorized { + message: "sender is not the buyer".to_string(), + } + .to_string(), + ); + } + + #[test] + fn test_cancel_collection_offer_nonexistent() { + let mut app = setup_app_with_balances(); + let buyer = app.api().addr_make("buyer"); + let manager = app.api().addr_make("manager"); + let minter = app.api().addr_make("minter"); + + // Setup contracts + let _asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_contract(&mut app, &manager); + + // Try to cancel nonexistent collection offer + let cancel_msg = ExecuteMsg::CancelCollectionOffer { + id: "nonexistent-collection-offer".to_string(), + }; + + let result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &cancel_msg, + &[], + ); + + assert!(result.is_err()); + } + } +} diff --git a/contracts/treasury/Cargo.toml b/contracts/treasury/Cargo.toml index 12bfa79..b570a9e 100644 --- a/contracts/treasury/Cargo.toml +++ b/contracts/treasury/Cargo.toml @@ -1,7 +1,7 @@ [package] -name = "treasury" -version = "0.1.0" edition = "2021" +name = "treasury" +version = "0.1.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [lib] @@ -12,13 +12,13 @@ crate-type = ["cdylib", "rlib"] library = [] [dependencies] -cosmwasm-schema = { workspace = true } -cosmwasm-std = { workspace = true } -cw2 = { workspace = true } -cw-storage-plus = { workspace = true } -thiserror = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -schemars = { workspace = true } -cosmos-sdk-proto = { workspace = true } -url = { workspace = true } \ No newline at end of file +cosmos-sdk-proto = { workspace = true } +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +cw-storage-plus = { workspace = true } +cw2 = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +url = { workspace = true } diff --git a/contracts/user_map/Cargo.toml b/contracts/user_map/Cargo.toml index 2700035..074f946 100644 --- a/contracts/user_map/Cargo.toml +++ b/contracts/user_map/Cargo.toml @@ -1,7 +1,7 @@ [package] -name = "user-map" -version = "0.1.0" edition = "2021" +name = "user-map" +version = "0.1.0" [lib] crate-type = ["cdylib", "rlib"] @@ -11,9 +11,9 @@ crate-type = ["cdylib", "rlib"] library = [] [dependencies] -cosmwasm-schema = { workspace = true } -cosmwasm-std = { workspace = true } -cw-storage-plus = { workspace = true } -thiserror = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } \ No newline at end of file +cosmwasm-schema = { workspace = true } +cosmwasm-std = { workspace = true } +cw-storage-plus = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } diff --git a/taplo.toml b/taplo.toml new file mode 100644 index 0000000..5815bff --- /dev/null +++ b/taplo.toml @@ -0,0 +1,7 @@ +[formatting] +align_comments = true +align_entries = true +allowed_blank_lines = 2 +column_width = 100 +compact_arrays = true +reorder_keys = true