From c9f7453dcda6a60fb8223deee0c0e3ab889cdca1 Mon Sep 17 00:00:00 2001 From: jburnt Date: Thu, 20 Nov 2025 13:28:29 -0600 Subject: [PATCH 1/4] reserve for option --- contracts/marketplace/src/events.rs | 5 +++ contracts/marketplace/src/execute.rs | 42 +++++++++++++++++-- contracts/marketplace/src/helpers.rs | 4 +- contracts/marketplace/src/msg.rs | 1 + contracts/marketplace/src/offers.rs | 4 +- contracts/marketplace/src/state.rs | 1 + .../marketplace/tests/tests/test_buy_item.rs | 3 ++ .../tests/tests/test_create_listing.rs | 6 +++ .../marketplace/tests/tests/test_helpers.rs | 3 ++ 9 files changed, 62 insertions(+), 7 deletions(-) diff --git a/contracts/marketplace/src/events.rs b/contracts/marketplace/src/events.rs index b095ddcb..deb3bad1 100644 --- a/contracts/marketplace/src/events.rs +++ b/contracts/marketplace/src/events.rs @@ -8,11 +8,16 @@ pub fn create_listing_event( collection: Addr, token_id: String, price: Coin, + reserved_for: Option, ) -> Event { + let reserved_for = reserved_for + .map(|addr| addr.to_string()) + .unwrap_or("".to_string()); 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("reserved_for", reserved_for.to_string()) .add_attribute("token_id", token_id) .add_attribute("price", price.to_string()) } diff --git a/contracts/marketplace/src/execute.rs b/contracts/marketplace/src/execute.rs index 2feb9bd0..a05172d9 100644 --- a/contracts/marketplace/src/execute.rs +++ b/contracts/marketplace/src/execute.rs @@ -16,10 +16,12 @@ use crate::offers::{ use crate::helpers::calculate_asset_price; use crate::state::{listings, pending_sales, Listing, ListingStatus, PendingSale, SaleType}; use crate::state::{Config, CONFIG}; +use asset::msg::ReserveMsg; use cosmwasm_std::{ - ensure_eq, to_json_binary, Addr, BankMsg, Coin, DepsMut, Env, MessageInfo, Response, WasmMsg, + ensure_eq, to_json_binary, Addr, BankMsg, Coin, DepsMut, Env, MessageInfo, Response, Timestamp, + WasmMsg, }; - +use cw_utils::maybe_addr; #[cfg_attr(not(feature = "library"), cosmwasm_std::entry_point)] pub fn execute( deps: DepsMut, @@ -33,7 +35,16 @@ pub fn execute( collection, price, token_id, - } => execute_create_listing(deps, info, api.addr_validate(&collection)?, price, token_id), + reserved_for, + } => execute_create_listing( + deps, + env, + info, + api.addr_validate(&collection)?, + price, + token_id, + maybe_addr(api, reserved_for)?, + ), ExecuteMsg::CancelListing { listing_id } => execute_cancel_listing(deps, info, listing_id), ExecuteMsg::BuyItem { listing_id, price } => { execute_buy_item(deps, env, info.clone(), listing_id, price, info.sender) @@ -114,10 +125,12 @@ pub fn execute_update_config( pub fn execute_create_listing( deps: DepsMut, + env: Env, info: MessageInfo, collection: Addr, price: Coin, token_id: String, + reserved_for: Option, ) -> Result { only_owner(&deps.querier, &info, &collection, &token_id)?; not_listed(&deps.querier, &collection, &token_id)?; @@ -135,6 +148,16 @@ pub fn execute_create_listing( // generate consistent id even across relisting helps single lookup let id = generate_id(vec![&collection.as_bytes(), &token_id.as_bytes()]); let asset_price = calculate_asset_price(price.clone(), config.fee_bps)?; + // if reserved is provided, use the contract address (the scrower) used to reserve in the asset contract + + let reservation = if let Some(_) = reserved_for.clone() { + Some(ReserveMsg { + reserver: Some(env.contract.address.to_string()), + reserved_until: Timestamp::from_seconds(env.block.time.seconds() + 365 * 24 * 60 * 60), + }) + } else { + None + }; let listing = Listing { id: id.clone(), seller: info.sender.clone(), @@ -142,6 +165,7 @@ pub fn execute_create_listing( token_id: token_id.clone(), price: price.clone(), asset_price: asset_price.clone(), + reserved_for: reserved_for.clone(), status: ListingStatus::Active, }; // reject if listing already exists @@ -149,7 +173,7 @@ pub fn execute_create_listing( Some(_) => Err(ContractError::AlreadyListed {}), None => Ok(listing), })?; - let list_msg = asset_list_msg(token_id.clone(), asset_price); + let list_msg = asset_list_msg(token_id.clone(), asset_price, reservation); Ok(Response::new() .add_event(create_listing_event( id, @@ -157,6 +181,7 @@ pub fn execute_create_listing( collection.clone(), token_id, price, + reserved_for.clone(), )) .add_message(WasmMsg::Execute { contract_addr: collection.to_string(), @@ -231,6 +256,15 @@ pub fn execute_buy_item( let config = CONFIG.load(deps.storage)?; let listing = listings().load(deps.storage, listing_id.clone())?; + if let Some(reserved_for) = listing.reserved_for.clone() { + ensure_eq!( + reserved_for, + info.sender, + ContractError::Unauthorized { + message: "item is reserved for another address".to_string(), + } + ); + } // Prevent price mismatch due to possible frontrunning if listing.price != price { return Err(ContractError::InvalidPrice { diff --git a/contracts/marketplace/src/helpers.rs b/contracts/marketplace/src/helpers.rs index 74326c9c..eab9ed8d 100644 --- a/contracts/marketplace/src/helpers.rs +++ b/contracts/marketplace/src/helpers.rs @@ -3,6 +3,7 @@ use crate::state::CONFIG; use asset::msg::AssetExtensionExecuteMsg as AssetExecuteMsg; use asset::msg::AssetExtensionQueryMsg; use asset::msg::QueryMsg as AssetQueryMsg; +use asset::msg::ReserveMsg; use asset::state::ListingInfo; use blake2::{Blake2s256, Digest}; use cosmwasm_std::StdError; @@ -129,6 +130,7 @@ pub fn valid_payment( pub fn asset_list_msg( token_id: String, asset_price: Coin, + reservation: Option, ) -> asset::msg::ExecuteMsg< cw721::DefaultOptionalNftExtensionMsg, cw721::DefaultOptionalCollectionExtensionMsg, @@ -142,7 +144,7 @@ pub fn asset_list_msg( msg: AssetExecuteMsg::List { token_id: token_id.clone(), price: asset_price.clone(), - reservation: None, + reservation, }, } } diff --git a/contracts/marketplace/src/msg.rs b/contracts/marketplace/src/msg.rs index 1ea89578..f849f09f 100644 --- a/contracts/marketplace/src/msg.rs +++ b/contracts/marketplace/src/msg.rs @@ -13,6 +13,7 @@ pub enum ExecuteMsg { price: Coin, collection: String, token_id: String, + reserved_for: Option, }, CancelListing { listing_id: String, diff --git a/contracts/marketplace/src/offers.rs b/contracts/marketplace/src/offers.rs index 37ae553f..ee749417 100644 --- a/contracts/marketplace/src/offers.rs +++ b/contracts/marketplace/src/offers.rs @@ -114,7 +114,7 @@ pub fn execute_accept_offer( .map_err(|_| ContractError::InsuficientFunds {})?; // list the item on the asset contract with asset_price (not full price) - let list_msg = asset_list_msg(token_id.clone(), asset_price.clone()); + let list_msg = asset_list_msg(token_id.clone(), asset_price.clone(), None); // do a buy on the asset contract for the specific price and buyer let buy_msg = asset_buy_msg(offer.buyer.clone(), token_id.clone()); @@ -274,7 +274,7 @@ pub fn execute_accept_collection_offer( .map_err(|_| ContractError::InsuficientFunds {})?; // list the item on the asset contract with asset_price (not full price) - let list_msg = asset_list_msg(token_id.clone(), asset_price.clone()); + let list_msg = asset_list_msg(token_id.clone(), asset_price.clone(), None); // do a buy on the asset contract for the specific price and buyer let buy_msg = asset_buy_msg(offer.buyer.clone(), token_id.clone()); diff --git a/contracts/marketplace/src/state.rs b/contracts/marketplace/src/state.rs index 4678f2ff..6d7764f4 100644 --- a/contracts/marketplace/src/state.rs +++ b/contracts/marketplace/src/state.rs @@ -102,6 +102,7 @@ pub struct Listing { pub price: Coin, pub asset_price: Coin, pub seller: Addr, + pub reserved_for: Option, pub status: ListingStatus, } diff --git a/contracts/marketplace/tests/tests/test_buy_item.rs b/contracts/marketplace/tests/tests/test_buy_item.rs index b03422c9..11293650 100644 --- a/contracts/marketplace/tests/tests/test_buy_item.rs +++ b/contracts/marketplace/tests/tests/test_buy_item.rs @@ -31,6 +31,7 @@ fn test_buy_item_success() { collection: asset_contract.to_string(), price: price.clone(), token_id: "token1".to_string(), + reserved_for: None, }; let result = app.execute_contract(seller.clone(), marketplace_contract.clone(), &list_msg, &[]); @@ -212,6 +213,7 @@ fn test_buy_item_wrong_denomination() { collection: asset_contract.to_string(), price: price.clone(), token_id: "token1".to_string(), + reserved_for: None, }; let result = app.execute_contract(seller.clone(), marketplace_contract.clone(), &list_msg, &[]); @@ -442,6 +444,7 @@ fn test_buy_item_success_with_royalties() { collection: asset_contract.to_string(), price: price.clone(), token_id: "token1".to_string(), + reserved_for: None, }; let result = app.execute_contract(seller.clone(), marketplace_contract.clone(), &list_msg, &[]); diff --git a/contracts/marketplace/tests/tests/test_create_listing.rs b/contracts/marketplace/tests/tests/test_create_listing.rs index d1ffba46..f14ef527 100644 --- a/contracts/marketplace/tests/tests/test_create_listing.rs +++ b/contracts/marketplace/tests/tests/test_create_listing.rs @@ -46,6 +46,7 @@ fn test_create_listing_success() { collection: asset_contract.to_string(), price: price.clone(), token_id: "token1".to_string(), + reserved_for: None, }; let result = app.execute_contract(seller.clone(), marketplace_contract.clone(), &list_msg, &[]); @@ -103,6 +104,7 @@ fn test_create_listing_unauthorized() { collection: asset_contract.to_string(), price, token_id: "token1".to_string(), + reserved_for: None, }; let result = app.execute_contract( @@ -142,6 +144,7 @@ fn test_create_listing_invalid_denom() { collection: asset_contract.to_string(), price, token_id: "token1".to_string(), + reserved_for: None, }; let result = app.execute_contract(seller.clone(), marketplace_contract.clone(), &list_msg, &[]); @@ -186,6 +189,7 @@ fn test_create_listing_already_listed() { collection: asset_contract.to_string(), price: price.clone(), token_id: "token1".to_string(), + reserved_for: None, }; let result = app.execute_contract(seller.clone(), marketplace_contract.clone(), &list_msg, &[]); @@ -195,6 +199,7 @@ fn test_create_listing_already_listed() { collection: asset_contract.to_string(), price, token_id: "token1".to_string(), + reserved_for: None, }; let result2 = app.execute_contract( @@ -228,6 +233,7 @@ fn test_create_listing_nonexistent_token() { collection: asset_contract.to_string(), price, token_id: "nonexistent".to_string(), + reserved_for: None, }; let result = app.execute_contract(seller.clone(), marketplace_contract.clone(), &list_msg, &[]); diff --git a/contracts/marketplace/tests/tests/test_helpers.rs b/contracts/marketplace/tests/tests/test_helpers.rs index 9f0ca0e5..fdc7703c 100644 --- a/contracts/marketplace/tests/tests/test_helpers.rs +++ b/contracts/marketplace/tests/tests/test_helpers.rs @@ -156,6 +156,7 @@ pub fn create_listing( collection: asset_contract.to_string(), price, token_id: token_id.to_string(), + reserved_for: None, }; let result = app.execute_contract(seller.clone(), marketplace_contract.clone(), &list_msg, &[]); @@ -211,6 +212,7 @@ pub fn create_listing_for_buy_test( collection: asset_contract.to_string(), price, token_id: token_id.to_string(), + reserved_for: None, }; let result = app.execute_contract(seller.clone(), marketplace_contract.clone(), &list_msg, &[]); @@ -349,6 +351,7 @@ pub fn create_listing_helper( collection: asset_contract.to_string(), price, token_id: token_id.to_string(), + reserved_for: None, }; let result = app.execute_contract(seller.clone(), marketplace_contract.clone(), &list_msg, &[]); From 16cebba6164acbfc3d8980fe25d21111f7070162 Mon Sep 17 00:00:00 2001 From: jburnt Date: Thu, 20 Nov 2025 13:30:28 -0600 Subject: [PATCH 2/4] add test --- .../marketplace/tests/tests/test_buy_item.rs | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/contracts/marketplace/tests/tests/test_buy_item.rs b/contracts/marketplace/tests/tests/test_buy_item.rs index 11293650..1d1b2877 100644 --- a/contracts/marketplace/tests/tests/test_buy_item.rs +++ b/contracts/marketplace/tests/tests/test_buy_item.rs @@ -535,3 +535,118 @@ fn test_buy_item_success_with_royalties() { .unwrap(); assert_eq!(owner_resp.owner, buyer.to_string()); } +#[test] +fn test_buy_reserved_for() { + let mut app = setup_app(); + let minter = app.api().addr_make("minter"); + let seller = app.api().addr_make("seller"); + let reserved_buyer = app.api().addr_make("reserved_buyer"); + let uninterested_buyer = app.api().addr_make("uninterested_buyer"); + let manager = app.api().addr_make("manager"); + + 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 + 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(); + + // List with reserved_for for reserved_buyer + let price = coin(100, "uxion"); + let list_msg = ExecuteMsg::ListItem { + collection: asset_contract.to_string(), + price: price.clone(), + token_id: "token1".to_string(), + reserved_for: Some(reserved_buyer.to_string()), + }; + + let result = app.execute_contract(seller.clone(), marketplace_contract.clone(), &list_msg, &[]); + assert!( + result.is_ok(), + "Listing with reserved_for should succeed: {:?}", + result.err() + ); + + let events = result.unwrap().events; + let listing_id = extract_listing_id_from_events(&events); + + // Fund both buyers + use cw_multi_test::{BankSudo, SudoMsg}; + let funds = vec![coin(10000, "uxion")]; + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: reserved_buyer.to_string(), + amount: funds.clone(), + })) + .unwrap(); + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: uninterested_buyer.to_string(), + amount: funds, + })) + .unwrap(); + + // Buyer C (uninterested_buyer) tries to buy and should get error + let buy_msg = ExecuteMsg::BuyItem { + listing_id: listing_id.clone(), + price: price.clone(), + }; + let result = app.execute_contract( + uninterested_buyer.clone(), + marketplace_contract.clone(), + &buy_msg, + std::slice::from_ref(&price), + ); + assert!( + result.is_err(), + "Unreserved buyer should not be able to purchase, but got Ok" + ); + // Optionally check specific error message + assert_error( + result, + xion_nft_marketplace::error::ContractError::Unauthorized { + message: "item is reserved for another address".to_string(), + } + .to_string(), + ); + + // Buyer B (reserved_buyer) buys and should succeed + let buy_msg = ExecuteMsg::BuyItem { + listing_id, + price: price.clone(), + }; + let result = app.execute_contract( + reserved_buyer.clone(), + marketplace_contract.clone(), + &buy_msg, + std::slice::from_ref(&price), + ); + assert!( + result.is_ok(), + "Reserved buyer should be able to purchase: {:?}", + result.err() + ); + + 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()); + + // Confirm ownership transferred to reserved_buyer + 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, reserved_buyer.to_string()); +} From 6f18bd6be5fc1a11212a6ed8413dcdc6a93f4d11 Mon Sep 17 00:00:00 2001 From: jburnt Date: Thu, 20 Nov 2025 13:34:47 -0600 Subject: [PATCH 3/4] fix lint issues --- contracts/marketplace/src/execute.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/marketplace/src/execute.rs b/contracts/marketplace/src/execute.rs index a05172d9..2c6aab4d 100644 --- a/contracts/marketplace/src/execute.rs +++ b/contracts/marketplace/src/execute.rs @@ -150,7 +150,7 @@ pub fn execute_create_listing( let asset_price = calculate_asset_price(price.clone(), config.fee_bps)?; // if reserved is provided, use the contract address (the scrower) used to reserve in the asset contract - let reservation = if let Some(_) = reserved_for.clone() { + let reservation = if reserved_for.clone().is_some() { Some(ReserveMsg { reserver: Some(env.contract.address.to_string()), reserved_until: Timestamp::from_seconds(env.block.time.seconds() + 365 * 24 * 60 * 60), From f82d213e8ad2b6797a37103113850a206b9bde81 Mon Sep 17 00:00:00 2001 From: jburnt Date: Thu, 20 Nov 2025 13:37:11 -0600 Subject: [PATCH 4/4] Update contracts/marketplace/src/execute.rs Co-authored-by: Justin <328965+justinbarry@users.noreply.github.com> Signed-off-by: jburnt --- contracts/marketplace/src/execute.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/marketplace/src/execute.rs b/contracts/marketplace/src/execute.rs index 2c6aab4d..774eaa13 100644 --- a/contracts/marketplace/src/execute.rs +++ b/contracts/marketplace/src/execute.rs @@ -148,7 +148,7 @@ pub fn execute_create_listing( // generate consistent id even across relisting helps single lookup let id = generate_id(vec![&collection.as_bytes(), &token_id.as_bytes()]); let asset_price = calculate_asset_price(price.clone(), config.fee_bps)?; - // if reserved is provided, use the contract address (the scrower) used to reserve in the asset contract + // if reserved is provided, use the contract address (the escrower) used to reserve in the asset contract let reservation = if reserved_for.clone().is_some() { Some(ReserveMsg {