From 823518c7d5735558c6250c6deef47c33e150da8b Mon Sep 17 00:00:00 2001 From: jburnt Date: Thu, 12 Feb 2026 08:59:36 -0600 Subject: [PATCH 1/7] allow reclaiming expired pending sales and disallow manager to still approve them --- contracts/marketplace/src/error.rs | 6 + contracts/marketplace/src/events.rs | 2 + contracts/marketplace/src/execute.rs | 57 +- contracts/marketplace/src/msg.rs | 3 + .../tests/tests/test_approval_queue.rs | 501 ++++++++++++++++++ 5 files changed, 561 insertions(+), 8 deletions(-) diff --git a/contracts/marketplace/src/error.rs b/contracts/marketplace/src/error.rs index feeba37..9fe0fdc 100644 --- a/contracts/marketplace/src/error.rs +++ b/contracts/marketplace/src/error.rs @@ -63,4 +63,10 @@ pub enum ContractError { collection: String, token_id: String, }, + + #[error("Pending sale expired: {id}")] + PendingSaleExpired { id: String }, + + #[error("Pending sale not yet expired: {id}")] + PendingSaleNotExpired { id: String }, } diff --git a/contracts/marketplace/src/events.rs b/contracts/marketplace/src/events.rs index ac0a2e8..1fc81de 100644 --- a/contracts/marketplace/src/events.rs +++ b/contracts/marketplace/src/events.rs @@ -155,6 +155,7 @@ pub fn sale_rejected_event( buyer: Addr, seller: Addr, price: Coin, + reason: &str, ) -> Event { Event::new(format!("{}/sale-rejected", env!("CARGO_PKG_NAME"))) .add_attribute("pending_sale_id", pending_sale_id) @@ -163,4 +164,5 @@ pub fn sale_rejected_event( .add_attribute("buyer", buyer.to_string()) .add_attribute("seller", seller.to_string()) .add_attribute("price", price.to_string()) + .add_attribute("reason", reason) } diff --git a/contracts/marketplace/src/execute.rs b/contracts/marketplace/src/execute.rs index 217a438..0fefa43 100644 --- a/contracts/marketplace/src/execute.rs +++ b/contracts/marketplace/src/execute.rs @@ -105,8 +105,9 @@ pub fn execute( ), ExecuteMsg::CancelCollectionOffer { id } => execute_cancel_collection_offer(deps, info, id), - ExecuteMsg::ApproveSale { id } => execute_approve_sale(deps, info, id), + ExecuteMsg::ApproveSale { id } => execute_approve_sale(deps, env, info, id), ExecuteMsg::RejectSale { id } => execute_reject_sale(deps, info, id), + ExecuteMsg::ReclaimExpiredSale { id } => execute_reclaim_expired_sale(deps, env, info, id), ExecuteMsg::UpdateConfig { config } => execute_update_config(deps, info, config), } } @@ -400,6 +401,7 @@ fn execute_create_pending_sale( pub fn execute_approve_sale( deps: DepsMut, + env: Env, info: MessageInfo, pending_sale_id: String, ) -> Result { @@ -409,6 +411,12 @@ pub fn execute_approve_sale( let config = CONFIG.load(deps.storage)?; let pending_sale = pending_sales().load(deps.storage, pending_sale_id.clone())?; + if env.block.time.seconds() >= pending_sale.expiration { + return Err(ContractError::PendingSaleExpired { + id: pending_sale_id, + }); + } + // Generate listing_id to find the listing let listing_id = generate_id(vec![ pending_sale.collection.as_bytes(), @@ -478,16 +486,12 @@ pub fn execute_approve_sale( Ok(response) } -pub fn execute_reject_sale( +fn remove_pending_sale( deps: DepsMut, - info: MessageInfo, pending_sale_id: String, + pending_sale: PendingSale, + reason: &str, ) -> Result { - // Only manager can reject - only_manager(&info, &deps)?; - - let pending_sale = pending_sales().load(deps.storage, pending_sale_id.clone())?; - let listing_id = generate_id(vec![ pending_sale.collection.as_bytes(), pending_sale.token_id.as_bytes(), @@ -514,6 +518,7 @@ pub fn execute_reject_sale( funds: vec![], }); } + // refund buyer let refund_msg = BankMsg::Send { to_address: pending_sale.buyer.to_string(), @@ -531,7 +536,43 @@ pub fn execute_reject_sale( pending_sale.buyer.clone(), pending_sale.seller, pending_sale.price, + reason, )) .add_message(refund_msg) .add_messages(sub_msgs)) } + +pub fn execute_reject_sale( + deps: DepsMut, + info: MessageInfo, + pending_sale_id: String, +) -> Result { + only_manager(&info, &deps)?; + + let pending_sale = pending_sales().load(deps.storage, pending_sale_id.clone())?; + + remove_pending_sale(deps, pending_sale_id, pending_sale, "rejected_by_manager") +} + +pub fn execute_reclaim_expired_sale( + deps: DepsMut, + env: Env, + info: MessageInfo, + pending_sale_id: String, +) -> Result { + let pending_sale = pending_sales().load(deps.storage, pending_sale_id.clone())?; + + if env.block.time.seconds() < pending_sale.expiration { + return Err(ContractError::PendingSaleNotExpired { + id: pending_sale_id, + }); + } + + if info.sender != pending_sale.buyer { + return Err(ContractError::Unauthorized { + message: "only the buyer can reclaim an expired sale".to_string(), + }); + } + + remove_pending_sale(deps, pending_sale_id, pending_sale, "expired") +} diff --git a/contracts/marketplace/src/msg.rs b/contracts/marketplace/src/msg.rs index f849f09..1783b4d 100644 --- a/contracts/marketplace/src/msg.rs +++ b/contracts/marketplace/src/msg.rs @@ -60,6 +60,9 @@ pub enum ExecuteMsg { RejectSale { id: String, }, + ReclaimExpiredSale { + id: String, + }, UpdateConfig { config: Config, }, diff --git a/contracts/marketplace/tests/tests/test_approval_queue.rs b/contracts/marketplace/tests/tests/test_approval_queue.rs index b58c4eb..1ae9b6a 100644 --- a/contracts/marketplace/tests/tests/test_approval_queue.rs +++ b/contracts/marketplace/tests/tests/test_approval_queue.rs @@ -1404,3 +1404,504 @@ fn test_approve_sale_with_existing_pending_sale() { .unwrap(); assert_eq!(owner_resp.owner, buyer1.to_string()); } + +#[test] +fn test_approve_expired_pending_sale_fails() { + 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"); + + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_with_approvals(&mut app, &manager); + + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + let price = coin(100, "uxion"); + let listing_id = create_listing_helper( + &mut app, + &marketplace_contract, + &asset_contract, + &seller, + "token1", + price.clone(), + ); + + // Buyer creates pending sale + let buy_msg = ExecuteMsg::BuyItem { + listing_id: listing_id.clone(), + price: price.clone(), + }; + + let buy_result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &buy_msg, + std::slice::from_ref(&price), + ); + assert!(buy_result.is_ok()); + + let pending_sale_id = buy_result + .unwrap() + .events + .iter() + .find(|e| e.ty == "wasm-xion-nft-marketplace/pending-sale-created") + .unwrap() + .attributes + .iter() + .find(|a| a.key == "id") + .unwrap() + .value + .clone(); + + // Advance block time past the 24-hour expiration + app.update_block(|block| { + block.time = block.time.plus_seconds(86401); + }); + + // Manager tries to approve the expired sale + let approve_msg = ExecuteMsg::ApproveSale { + id: pending_sale_id.clone(), + }; + + let result = app.execute_contract( + manager.clone(), + marketplace_contract.clone(), + &approve_msg, + &[], + ); + + assert!(result.is_err()); + assert_error( + result, + xion_nft_marketplace::error::ContractError::PendingSaleExpired { + id: pending_sale_id, + } + .to_string(), + ); + + // Verify NFT is still owned by seller (sale was not executed) + let owner_query = OwnerQueryMsg::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, seller.to_string()); +} + +#[test] +fn test_reclaim_expired_sale_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"); + + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_with_approvals(&mut app, &manager); + + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + let price = coin(100, "uxion"); + let listing_id = create_listing_helper( + &mut app, + &marketplace_contract, + &asset_contract, + &seller, + "token1", + price.clone(), + ); + + let buyer_balance_before = app.wrap().query_balance(&buyer, "uxion").unwrap().amount; + + let buy_msg = ExecuteMsg::BuyItem { + listing_id: listing_id.clone(), + price: price.clone(), + }; + + let buy_result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &buy_msg, + std::slice::from_ref(&price), + ); + assert!(buy_result.is_ok()); + + let pending_sale_id = buy_result + .unwrap() + .events + .iter() + .find(|e| e.ty == "wasm-xion-nft-marketplace/pending-sale-created") + .unwrap() + .attributes + .iter() + .find(|a| a.key == "id") + .unwrap() + .value + .clone(); + + // Advance block time past the 24-hour expiration + app.update_block(|block| { + block.time = block.time.plus_seconds(86401); + }); + + // Buyer reclaims the expired sale + let reclaim_msg = ExecuteMsg::ReclaimExpiredSale { + id: pending_sale_id.clone(), + }; + + let result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &reclaim_msg, + &[], + ); + assert!(result.is_ok()); + + // Verify the event has reason "expired" + let events = result.unwrap().events; + let rejected_event = events + .iter() + .find(|e| e.ty == "wasm-xion-nft-marketplace/sale-rejected") + .expect("sale-rejected event should be emitted"); + let reason = rejected_event + .attributes + .iter() + .find(|a| a.key == "reason") + .unwrap(); + assert_eq!(reason.value, "expired"); + + // Verify buyer is refunded + let buyer_balance_after = app.wrap().query_balance(&buyer, "uxion").unwrap().amount; + assert_eq!(buyer_balance_before, buyer_balance_after); + + // Verify pending sale is removed + let pending_sale_query = app.wrap().query_wasm_smart::( + marketplace_contract.clone(), + &QueryMsg::PendingSale { + id: pending_sale_id, + }, + ); + assert!(pending_sale_query.is_err()); + + // Verify NFT still owned by seller + let owner_query = OwnerQueryMsg::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, seller.to_string()); +} + +#[test] +fn test_reclaim_expired_sale_not_yet_expired() { + 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"); + + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_with_approvals(&mut app, &manager); + + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + let price = coin(100, "uxion"); + let listing_id = create_listing_helper( + &mut app, + &marketplace_contract, + &asset_contract, + &seller, + "token1", + price.clone(), + ); + + let buy_msg = ExecuteMsg::BuyItem { + listing_id: listing_id.clone(), + price: price.clone(), + }; + + let buy_result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &buy_msg, + std::slice::from_ref(&price), + ); + assert!(buy_result.is_ok()); + + let pending_sale_id = buy_result + .unwrap() + .events + .iter() + .find(|e| e.ty == "wasm-xion-nft-marketplace/pending-sale-created") + .unwrap() + .attributes + .iter() + .find(|a| a.key == "id") + .unwrap() + .value + .clone(); + + // Do NOT advance time — sale is still active + + let reclaim_msg = ExecuteMsg::ReclaimExpiredSale { + id: pending_sale_id.clone(), + }; + + let result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &reclaim_msg, + &[], + ); + + assert!(result.is_err()); + assert_error( + result, + xion_nft_marketplace::error::ContractError::PendingSaleNotExpired { + id: pending_sale_id, + } + .to_string(), + ); +} + +#[test] +fn test_reclaim_expired_sale_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 manager = app.api().addr_make("manager"); + let random = app.api().addr_make("random"); + + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_with_approvals(&mut app, &manager); + + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + let price = coin(100, "uxion"); + let listing_id = create_listing_helper( + &mut app, + &marketplace_contract, + &asset_contract, + &seller, + "token1", + price.clone(), + ); + + let buy_msg = ExecuteMsg::BuyItem { + listing_id: listing_id.clone(), + price: price.clone(), + }; + + let buy_result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &buy_msg, + std::slice::from_ref(&price), + ); + assert!(buy_result.is_ok()); + + let pending_sale_id = buy_result + .unwrap() + .events + .iter() + .find(|e| e.ty == "wasm-xion-nft-marketplace/pending-sale-created") + .unwrap() + .attributes + .iter() + .find(|a| a.key == "id") + .unwrap() + .value + .clone(); + + // Advance past expiry + app.update_block(|block| { + block.time = block.time.plus_seconds(86401); + }); + + // Random user tries to reclaim — should fail + let reclaim_msg = ExecuteMsg::ReclaimExpiredSale { + id: pending_sale_id, + }; + + let result = app.execute_contract( + random.clone(), + marketplace_contract.clone(), + &reclaim_msg, + &[], + ); + + assert!(result.is_err()); + assert_error( + result, + xion_nft_marketplace::error::ContractError::Unauthorized { + message: "only the buyer can reclaim an expired sale".to_string(), + } + .to_string(), + ); +} + +#[test] +fn test_reject_sale_emits_reason() { + 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"); + + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_with_approvals(&mut app, &manager); + + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + let price = coin(100, "uxion"); + let listing_id = create_listing_helper( + &mut app, + &marketplace_contract, + &asset_contract, + &seller, + "token1", + price.clone(), + ); + + let buy_msg = ExecuteMsg::BuyItem { + listing_id: listing_id.clone(), + price: price.clone(), + }; + + let buy_result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &buy_msg, + std::slice::from_ref(&price), + ); + assert!(buy_result.is_ok()); + + let pending_sale_id = buy_result + .unwrap() + .events + .iter() + .find(|e| e.ty == "wasm-xion-nft-marketplace/pending-sale-created") + .unwrap() + .attributes + .iter() + .find(|a| a.key == "id") + .unwrap() + .value + .clone(); + + let reject_msg = ExecuteMsg::RejectSale { + id: pending_sale_id, + }; + + let result = app.execute_contract( + manager.clone(), + marketplace_contract.clone(), + &reject_msg, + &[], + ); + assert!(result.is_ok()); + + let events = result.unwrap().events; + let rejected_event = events + .iter() + .find(|e| e.ty == "wasm-xion-nft-marketplace/sale-rejected") + .expect("sale-rejected event should be emitted"); + let reason = rejected_event + .attributes + .iter() + .find(|a| a.key == "reason") + .unwrap(); + assert_eq!(reason.value, "rejected_by_manager"); +} + +#[test] +fn test_manager_can_reject_expired_sale() { + 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"); + + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_with_approvals(&mut app, &manager); + + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + let price = coin(100, "uxion"); + let listing_id = create_listing_helper( + &mut app, + &marketplace_contract, + &asset_contract, + &seller, + "token1", + price.clone(), + ); + + let buyer_balance_before = app.wrap().query_balance(&buyer, "uxion").unwrap().amount; + + let buy_msg = ExecuteMsg::BuyItem { + listing_id: listing_id.clone(), + price: price.clone(), + }; + + let buy_result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &buy_msg, + std::slice::from_ref(&price), + ); + assert!(buy_result.is_ok()); + + let pending_sale_id = buy_result + .unwrap() + .events + .iter() + .find(|e| e.ty == "wasm-xion-nft-marketplace/pending-sale-created") + .unwrap() + .attributes + .iter() + .find(|a| a.key == "id") + .unwrap() + .value + .clone(); + + // Advance past expiry + app.update_block(|block| { + block.time = block.time.plus_seconds(86401); + }); + + // Manager can still reject even after expiry + let reject_msg = ExecuteMsg::RejectSale { + id: pending_sale_id.clone(), + }; + + let result = app.execute_contract( + manager.clone(), + marketplace_contract.clone(), + &reject_msg, + &[], + ); + assert!(result.is_ok()); + + // Verify buyer is refunded + let buyer_balance_after = app.wrap().query_balance(&buyer, "uxion").unwrap().amount; + assert_eq!(buyer_balance_before, buyer_balance_after); + + // Verify pending sale is removed + let pending_sale_query = app.wrap().query_wasm_smart::( + marketplace_contract.clone(), + &QueryMsg::PendingSale { + id: pending_sale_id, + }, + ); + assert!(pending_sale_query.is_err()); +} From df2ed3c15fa91bcef869bd8b8e05c5eab3775262 Mon Sep 17 00:00:00 2001 From: jburnt Date: Thu, 12 Feb 2026 14:41:51 -0600 Subject: [PATCH 2/7] add zero check for listings --- contracts/marketplace/src/execute.rs | 20 +++- .../marketplace/tests/tests/test_buy_item.rs | 93 ++++++++++++++++++- 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/contracts/marketplace/src/execute.rs b/contracts/marketplace/src/execute.rs index 0fefa43..708e247 100644 --- a/contracts/marketplace/src/execute.rs +++ b/contracts/marketplace/src/execute.rs @@ -136,6 +136,13 @@ pub fn execute_create_listing( only_owner(&deps.querier, &info, &collection, &token_id)?; not_listed(&deps.querier, &collection, &token_id)?; let config = CONFIG.load(deps.storage)?; + if price.amount.is_zero() { + return Err(ContractError::InvalidPrice { + expected: price.clone(), + actual: price, + }); + } + ensure_eq!( price.denom, CONFIG.load(deps.storage)?.listing_denom, @@ -281,7 +288,7 @@ pub fn execute_buy_item( .amount .checked_sub(asset_price.amount) .map_err(|_| ContractError::InsuficientFunds {})?; - Ok(Response::new() + let mut response = Response::new() .add_event(item_sold_event( listing.id, listing.collection.clone(), @@ -296,14 +303,19 @@ pub fn execute_buy_item( contract_addr: listing.collection.clone().to_string(), msg: to_json_binary(&buy_msg)?, funds: vec![asset_price], - }) - .add_message(BankMsg::Send { + }); + + if !marketplace_fee.is_zero() { + response = response.add_message(BankMsg::Send { to_address: config.fee_recipient.to_string(), amount: vec![Coin { denom: payment.denom, amount: marketplace_fee, }], - })) + }); + } + + Ok(response) } fn execute_create_pending_sale( diff --git a/contracts/marketplace/tests/tests/test_buy_item.rs b/contracts/marketplace/tests/tests/test_buy_item.rs index 1d1b287..9d43fce 100644 --- a/contracts/marketplace/tests/tests/test_buy_item.rs +++ b/contracts/marketplace/tests/tests/test_buy_item.rs @@ -3,7 +3,7 @@ use cosmwasm_std::{coin, Uint128}; use cw721_base::msg::ExecuteMsg as Cw721ExecuteMsg; use cw_multi_test::Executor; use xion_nft_marketplace::helpers::query_listing; -use xion_nft_marketplace::msg::ExecuteMsg; +use xion_nft_marketplace::msg::{ExecuteMsg, InstantiateMsg}; #[test] fn test_buy_item_success() { @@ -650,3 +650,94 @@ fn test_buy_reserved_for() { .unwrap(); assert_eq!(owner_resp.owner, reserved_buyer.to_string()); } + +#[test] +fn test_buy_item_with_zero_marketplace_fee() { + 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"); + + let asset_contract = setup_asset_contract(&mut app, &minter); + + // Create marketplace with zero fee and approvals disabled (direct buy path) + let marketplace_code_id = app.store_code(marketplace_contract()); + let config_json = serde_json::json!({ + "manager": manager.to_string(), + "fee_recipient": manager.to_string(), + "sale_approvals": false, + "fee_bps": 0, + "listing_denom": "uxion" + }); + let instantiate_msg = InstantiateMsg { + config: serde_json::from_value(config_json).unwrap(), + }; + let marketplace_addr = app + .instantiate_contract( + marketplace_code_id, + manager.clone(), + &instantiate_msg, + &[], + "test-marketplace-zero-fee", + None, + ) + .unwrap(); + + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + let price = coin(1000, "uxion"); + + let seller_balance_before = app.wrap().query_balance(&seller, "uxion").unwrap().amount; + let manager_balance_before = app.wrap().query_balance(&manager, "uxion").unwrap().amount; + + let listing_id = create_listing_helper( + &mut app, + &marketplace_addr, + &asset_contract, + &seller, + "token1", + price.clone(), + ); + + // Direct buy (no approval flow) + let buy_msg = ExecuteMsg::BuyItem { + listing_id, + price: price.clone(), + }; + + let result = app.execute_contract( + buyer.clone(), + marketplace_addr.clone(), + &buy_msg, + std::slice::from_ref(&price), + ); + + assert!(result.is_ok(), "Direct buy with zero fee should succeed: {:?}", result.err()); + + // Seller receives full price (no fee deducted) + let seller_balance_after = app.wrap().query_balance(&seller, "uxion").unwrap().amount; + assert_eq!( + seller_balance_after, + seller_balance_before + price.amount, + "Seller should receive full price with zero fee" + ); + + // Manager receives nothing + let manager_balance_after = app.wrap().query_balance(&manager, "uxion").unwrap().amount; + assert_eq!( + manager_balance_after, manager_balance_before, + "Manager should not receive any fee" + ); + + // NFT transferred to 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, buyer.to_string()); +} From 3a70b89dddea51f9a71f30f8e43d8dbdc374b43b Mon Sep 17 00:00:00 2001 From: jburnt Date: Thu, 12 Feb 2026 14:46:25 -0600 Subject: [PATCH 3/7] expand doc on next_auto_increment --- contracts/marketplace/src/state.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/contracts/marketplace/src/state.rs b/contracts/marketplace/src/state.rs index e49f200..8c0cf78 100644 --- a/contracts/marketplace/src/state.rs +++ b/contracts/marketplace/src/state.rs @@ -195,7 +195,8 @@ pub fn collection_offers<'a>( 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. +// with other sources of entropy to generate unique ids, and never to be used as source of unique ids +// as it wraps around effectily resetting the counter. 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)?; From f7f1f8fd9e27bb4070dee716ed1107470bef9d2c Mon Sep 17 00:00:00 2001 From: jburnt Date: Thu, 12 Feb 2026 14:48:53 -0600 Subject: [PATCH 4/7] format code --- contracts/marketplace/tests/tests/test_buy_item.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/contracts/marketplace/tests/tests/test_buy_item.rs b/contracts/marketplace/tests/tests/test_buy_item.rs index 9d43fce..09bcdb0 100644 --- a/contracts/marketplace/tests/tests/test_buy_item.rs +++ b/contracts/marketplace/tests/tests/test_buy_item.rs @@ -713,7 +713,11 @@ fn test_buy_item_with_zero_marketplace_fee() { std::slice::from_ref(&price), ); - assert!(result.is_ok(), "Direct buy with zero fee should succeed: {:?}", result.err()); + assert!( + result.is_ok(), + "Direct buy with zero fee should succeed: {:?}", + result.err() + ); // Seller receives full price (no fee deducted) let seller_balance_after = app.wrap().query_balance(&seller, "uxion").unwrap().amount; From 7c85dfa480fe8b676a89c4fbb7bb6f373084ec39 Mon Sep 17 00:00:00 2001 From: jburnt Date: Mon, 16 Feb 2026 10:18:17 -0600 Subject: [PATCH 5/7] add direct buy failure test --- .../tests/tests/test_approval_queue.rs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/contracts/marketplace/tests/tests/test_approval_queue.rs b/contracts/marketplace/tests/tests/test_approval_queue.rs index 1ae9b6a..a91fcce 100644 --- a/contracts/marketplace/tests/tests/test_approval_queue.rs +++ b/contracts/marketplace/tests/tests/test_approval_queue.rs @@ -2,6 +2,7 @@ use crate::tests::test_helpers::*; use cosmwasm_std::coin; use cw721_base::msg::QueryMsg as OwnerQueryMsg; use cw_multi_test::Executor; +use xion_nft_marketplace::helpers::query_listing; use xion_nft_marketplace::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; use xion_nft_marketplace::state::{Listing, ListingStatus, PendingSale}; @@ -145,6 +146,70 @@ fn test_buy_with_approvals_enabled_creates_pending_sale() { assert_eq!(owner_resp.owner, seller.to_string()); } +#[test] +fn test_pending_sale_reservation_blocks_direct_asset_buy() { + 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"); + + let asset_contract = setup_asset_contract(&mut app, &minter); + let marketplace_contract = setup_marketplace_with_approvals(&mut app, &manager); + + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + let price = coin(100, "uxion"); + let listing_id = create_listing_helper( + &mut app, + &marketplace_contract, + &asset_contract, + &seller, + "token1", + price.clone(), + ); + + let buy_msg = ExecuteMsg::BuyItem { + listing_id, + price: price.clone(), + }; + + let result = app.execute_contract( + buyer.clone(), + marketplace_contract.clone(), + &buy_msg, + std::slice::from_ref(&price), + ); + assert!(result.is_ok()); + + let asset_listing = query_listing(&app.wrap(), &asset_contract, "token1").unwrap(); + let reserved = asset_listing.reserved.expect("asset listing should be reserved"); + assert_eq!(reserved.reserver, marketplace_contract); + + let direct_buy_msg = asset::msg::ExecuteMsg::< + cw721::DefaultOptionalNftExtensionMsg, + cw721::DefaultOptionalCollectionExtensionMsg, + asset::msg::AssetExtensionExecuteMsg, + >::UpdateExtension { + msg: asset::msg::AssetExtensionExecuteMsg::Buy { + token_id: "token1".to_string(), + recipient: None, + }, + }; + + let direct_buy_result = app.execute_contract( + buyer.clone(), + asset_contract.clone(), + &direct_buy_msg, + std::slice::from_ref(&price), + ); + assert!(direct_buy_result.is_err()); + assert_error( + direct_buy_result, + "Generic error: Generic error: Unauthorized".to_string(), + ); +} + #[test] fn test_approve_sale_success() { let mut app = setup_app_with_balances(); From c1087282a8f5c310a1c6650224c8bc18cc97974b Mon Sep 17 00:00:00 2001 From: jburnt Date: Wed, 25 Feb 2026 09:04:16 -0600 Subject: [PATCH 6/7] add listing status check for edge case and test --- contracts/marketplace/src/execute.rs | 7 + contracts/marketplace/tests/tests/mod.rs | 1 + .../tests/tests/test_approval_queue.rs | 6 +- .../tests/tests/test_reserved_listing_buy.rs | 285 ++++++++++++++++++ 4 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 contracts/marketplace/tests/tests/test_reserved_listing_buy.rs diff --git a/contracts/marketplace/src/execute.rs b/contracts/marketplace/src/execute.rs index 708e247..473e7e8 100644 --- a/contracts/marketplace/src/execute.rs +++ b/contracts/marketplace/src/execute.rs @@ -254,6 +254,13 @@ pub fn execute_buy_item( let config = CONFIG.load(deps.storage)?; let listing = listings().load(deps.storage, listing_id.clone())?; + if listing.status != ListingStatus::Active { + return Err(ContractError::InvalidListingStatus { + expected: ListingStatus::Active.to_string(), + actual: listing.status.to_string(), + }); + } + if let Some(reserved_for) = listing.reserved_for.clone() { ensure_eq!( reserved_for, diff --git a/contracts/marketplace/tests/tests/mod.rs b/contracts/marketplace/tests/tests/mod.rs index 1f6b39c..8986a80 100644 --- a/contracts/marketplace/tests/tests/mod.rs +++ b/contracts/marketplace/tests/tests/mod.rs @@ -8,3 +8,4 @@ mod test_create_collection_offer; mod test_create_listing; mod test_create_offer; mod test_helpers; +mod test_reserved_listing_buy; diff --git a/contracts/marketplace/tests/tests/test_approval_queue.rs b/contracts/marketplace/tests/tests/test_approval_queue.rs index a91fcce..c390aea 100644 --- a/contracts/marketplace/tests/tests/test_approval_queue.rs +++ b/contracts/marketplace/tests/tests/test_approval_queue.rs @@ -1403,9 +1403,9 @@ fn test_approve_sale_with_existing_pending_sale() { assert!(buy_result2.is_err()); assert_error( buy_result2, - xion_nft_marketplace::error::ContractError::PendingSaleAlreadyExists { - collection: asset_contract.to_string(), - token_id: token_id.clone(), + xion_nft_marketplace::error::ContractError::InvalidListingStatus { + expected: "Active".to_string(), + actual: "Reserved".to_string(), } .to_string(), ); diff --git a/contracts/marketplace/tests/tests/test_reserved_listing_buy.rs b/contracts/marketplace/tests/tests/test_reserved_listing_buy.rs new file mode 100644 index 0000000..fd2b042 --- /dev/null +++ b/contracts/marketplace/tests/tests/test_reserved_listing_buy.rs @@ -0,0 +1,285 @@ +use crate::tests::test_helpers::*; +use cosmwasm_std::coin; +use cw_multi_test::{BankSudo, Executor, SudoMsg}; +use xion_nft_marketplace::msg::{ExecuteMsg, QueryMsg}; +use xion_nft_marketplace::state::{Listing, ListingStatus}; + + +#[test] +fn test_double_buy_blocked_when_approvals_stay_enabled() { + let mut app = setup_app(); + let minter = app.api().addr_make("minter"); + let seller = app.api().addr_make("seller"); + let buyer_a = app.api().addr_make("buyer_a"); + let buyer_b = app.api().addr_make("buyer_b"); + let manager = app.api().addr_make("manager"); + + // Fund accounts + for addr in [&buyer_a, &buyer_b, &seller, &minter, &manager] { + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: addr.to_string(), + amount: vec![coin(100_000, "uxion")], + })) + .unwrap(); + } + + let asset_contract = setup_asset_contract(&mut app, &minter); + + // Marketplace with sale_approvals ENABLED — stays enabled throughout + let marketplace_contract = setup_marketplace_with_approvals(&mut app, &manager); + + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + let price = coin(1000, "uxion"); + let listing_id = create_listing_helper( + &mut app, + &marketplace_contract, + &asset_contract, + &seller, + "token1", + price.clone(), + ); + + // Buyer A buys -> pending sale created, listing status becomes Reserved + let buy_msg = ExecuteMsg::BuyItem { + listing_id: listing_id.clone(), + price: price.clone(), + }; + let result = app.execute_contract( + buyer_a.clone(), + marketplace_contract.clone(), + &buy_msg, + &[price.clone()], + ); + assert!( + result.is_ok(), + "Buyer A's purchase should create a pending sale: {:?}", + result.err() + ); + + // Verify listing is Reserved + let listing: Listing = app + .wrap() + .query_wasm_smart( + marketplace_contract.clone(), + &QueryMsg::Listing { + listing_id: listing_id.clone(), + }, + ) + .unwrap(); + assert_eq!( + listing.status, + ListingStatus::Reserved, + "Listing should be Reserved after Buyer A's pending sale" + ); + + // Verify Buyer A's funds are escrowed in the marketplace + let buyer_a_balance = app.wrap().query_balance(&buyer_a, "uxion").unwrap().amount; + assert_eq!(buyer_a_balance.u128(), 100_000 - 1000); + + let marketplace_balance = app + .wrap() + .query_balance(&marketplace_contract, "uxion") + .unwrap() + .amount; + assert_eq!( + marketplace_balance.u128(), + 1000, + "Marketplace should hold Buyer A's escrowed 1000 uxion" + ); + + // Buyer B attempts the same purchase — should be REJECTED + let buy_msg = ExecuteMsg::BuyItem { + listing_id: listing_id.clone(), + price: price.clone(), + }; + let result = app.execute_contract( + buyer_b.clone(), + marketplace_contract.clone(), + &buy_msg, + &[price.clone()], + ); + + assert!( + result.is_err(), + "Double-buy should be blocked when sale_approvals is enabled" + ); + assert_error( + result, + xion_nft_marketplace::error::ContractError::InvalidListingStatus { + expected: ListingStatus::Active.to_string(), + actual: ListingStatus::Reserved.to_string(), + } + .to_string(), + ); + + // Verify state is unchanged after Buyer B's failed attempt: + + // 1. Listing still Reserved + let listing: Listing = app + .wrap() + .query_wasm_smart( + marketplace_contract.clone(), + &QueryMsg::Listing { + listing_id: listing_id.clone(), + }, + ) + .unwrap(); + assert_eq!( + listing.status, + ListingStatus::Reserved, + "Listing should still be Reserved" + ); + + // 2. NFT still owned by seller (not transferred to anyone) + let owner_resp: cw721::msg::OwnerOfResponse = app + .wrap() + .query_wasm_smart( + asset_contract.clone(), + &cw721_base::msg::QueryMsg::OwnerOf { + token_id: "token1".to_string(), + include_expired: Some(false), + }, + ) + .unwrap(); + assert_eq!( + owner_resp.owner, + seller.to_string(), + "NFT should still be owned by the seller" + ); + + // 3. Buyer A's escrow untouched in marketplace + let marketplace_balance = app + .wrap() + .query_balance(&marketplace_contract, "uxion") + .unwrap() + .amount; + assert_eq!( + marketplace_balance.u128(), + 1000, + "Buyer A's escrowed funds should still be in the marketplace" + ); + + // 4. Buyer B's funds returned (tx reverted, they still have 100k) + let buyer_b_balance = app.wrap().query_balance(&buyer_b, "uxion").unwrap().amount; + assert_eq!( + buyer_b_balance.u128(), + 100_000, + "Buyer B should have all funds back after rejected purchase" + ); +} + + +#[test] +fn test_double_buy_blocked_when_approvals_toggled_off() { + let mut app = setup_app(); + let minter = app.api().addr_make("minter"); + let seller = app.api().addr_make("seller"); + let buyer_a = app.api().addr_make("buyer_a"); + let buyer_b = app.api().addr_make("buyer_b"); + let manager = app.api().addr_make("manager"); + + // Fund accounts + for addr in [&buyer_a, &buyer_b, &seller, &minter, &manager] { + app.sudo(SudoMsg::Bank(BankSudo::Mint { + to_address: addr.to_string(), + amount: vec![coin(100_000, "uxion")], + })) + .unwrap(); + } + + let asset_contract = setup_asset_contract(&mut app, &minter); + + // Step 1: Instantiate marketplace with sale_approvals ENABLED + let marketplace_contract = setup_marketplace_with_approvals(&mut app, &manager); + + // Step 2: Mint and list an NFT + mint_nft(&mut app, &asset_contract, &minter, &seller, "token1"); + + let price = coin(1000, "uxion"); + let listing_id = create_listing_helper( + &mut app, + &marketplace_contract, + &asset_contract, + &seller, + "token1", + price.clone(), + ); + + // Step 3: Buyer A buys -> creates a pending sale, listing becomes Reserved + let buy_msg = ExecuteMsg::BuyItem { + listing_id: listing_id.clone(), + price: price.clone(), + }; + let result = app.execute_contract( + buyer_a.clone(), + marketplace_contract.clone(), + &buy_msg, + &[price.clone()], + ); + assert!( + result.is_ok(), + "Buyer A's purchase should create a pending sale: {:?}", + result.err() + ); + + // Verify listing is Reserved and Buyer A's funds are escrowed + let listing: Listing = app + .wrap() + .query_wasm_smart( + marketplace_contract.clone(), + &QueryMsg::Listing { + listing_id: listing_id.clone(), + }, + ) + .unwrap(); + assert_eq!(listing.status, ListingStatus::Reserved); + + let marketplace_balance = app + .wrap() + .query_balance(&marketplace_contract, "uxion") + .unwrap() + .amount; + assert_eq!(marketplace_balance.u128(), 1000); + + // Step 4: Manager flips sale_approvals to false + let update_config_msg = ExecuteMsg::UpdateConfig { + config: serde_json::from_value(serde_json::json!({ + "manager": manager.to_string(), + "fee_recipient": manager.to_string(), + "sale_approvals": false, + "fee_bps": 250, + "listing_denom": "uxion" + })) + .unwrap(), + }; + app.execute_contract( + manager.clone(), + marketplace_contract.clone(), + &update_config_msg, + &[], + ) + .unwrap(); + + // Step 5: Buyer B buys the same Reserved listing + // This SHOULD fail because the listing is Reserved (Buyer A's pending sale exists) + // but execute_buy_item never checks listing.status + let buy_msg = ExecuteMsg::BuyItem { + listing_id: listing_id.clone(), + price: price.clone(), + }; + let result = app.execute_contract( + buyer_b.clone(), + marketplace_contract.clone(), + &buy_msg, + &[price.clone()], + ); + + assert!( + result.is_err(), + "BUG: Buyer B was able to buy a Reserved listing after manager toggled \ + sale_approvals off. This is a double-spend — Buyer A's escrowed 1000 uxion \ + is locked in the contract while the NFT was sold to Buyer B. \ + Fix: add `listing.status != Active` check in execute_buy_item." + ); +} From 4558c82f4b835771b74998d6c2565ebcd167abdc Mon Sep 17 00:00:00 2001 From: jburnt Date: Wed, 25 Feb 2026 09:05:43 -0600 Subject: [PATCH 7/7] format --- contracts/marketplace/tests/tests/test_approval_queue.rs | 4 +++- .../marketplace/tests/tests/test_reserved_listing_buy.rs | 2 -- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/marketplace/tests/tests/test_approval_queue.rs b/contracts/marketplace/tests/tests/test_approval_queue.rs index c390aea..0ef64a1 100644 --- a/contracts/marketplace/tests/tests/test_approval_queue.rs +++ b/contracts/marketplace/tests/tests/test_approval_queue.rs @@ -183,7 +183,9 @@ fn test_pending_sale_reservation_blocks_direct_asset_buy() { assert!(result.is_ok()); let asset_listing = query_listing(&app.wrap(), &asset_contract, "token1").unwrap(); - let reserved = asset_listing.reserved.expect("asset listing should be reserved"); + let reserved = asset_listing + .reserved + .expect("asset listing should be reserved"); assert_eq!(reserved.reserver, marketplace_contract); let direct_buy_msg = asset::msg::ExecuteMsg::< diff --git a/contracts/marketplace/tests/tests/test_reserved_listing_buy.rs b/contracts/marketplace/tests/tests/test_reserved_listing_buy.rs index fd2b042..8b45694 100644 --- a/contracts/marketplace/tests/tests/test_reserved_listing_buy.rs +++ b/contracts/marketplace/tests/tests/test_reserved_listing_buy.rs @@ -4,7 +4,6 @@ use cw_multi_test::{BankSudo, Executor, SudoMsg}; use xion_nft_marketplace::msg::{ExecuteMsg, QueryMsg}; use xion_nft_marketplace::state::{Listing, ListingStatus}; - #[test] fn test_double_buy_blocked_when_approvals_stay_enabled() { let mut app = setup_app(); @@ -169,7 +168,6 @@ fn test_double_buy_blocked_when_approvals_stay_enabled() { ); } - #[test] fn test_double_buy_blocked_when_approvals_toggled_off() { let mut app = setup_app();