Skip to content

Commit 62fa3dc

Browse files
authored
fix (10): check for pre-existing pending sales (#111)
* fix: check for pre-existing pending sales * fix fmt
1 parent 5c3d8fe commit 62fa3dc

File tree

4 files changed

+202
-1
lines changed

4 files changed

+202
-1
lines changed

contracts/marketplace/src/error.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,10 @@ pub enum ContractError {
5454

5555
#[error("Offers disabled")]
5656
OfferesDisabled {},
57+
58+
#[error("Pending sale already exists: {collection}, {token_id}")]
59+
PendingSaleAlreadyExists {
60+
collection: String,
61+
token_id: String,
62+
},
5763
}

contracts/marketplace/src/execute.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,18 @@ fn execute_create_pending_sale(
315315
listing: Listing,
316316
price: Coin,
317317
) -> Result<Response, ContractError> {
318+
// query if there is a previous pending sale for this item
319+
let existing_pending_sale = pending_sales().idx.by_collection_and_token_id.item(
320+
deps.storage,
321+
(listing.collection.clone(), listing.token_id.clone()),
322+
);
323+
if let Ok(Some(_)) = existing_pending_sale {
324+
return Err(ContractError::PendingSaleAlreadyExists {
325+
collection: listing.collection.clone().to_string(),
326+
token_id: listing.token_id.clone(),
327+
});
328+
}
329+
318330
let pending_sale_id = generate_id(vec![
319331
listing_id.as_bytes(),
320332
info.sender.as_bytes(),

contracts/marketplace/src/state.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use cosmwasm_schema::cw_serde;
33
use crate::error::ContractError;
44
use cosmwasm_std::{ensure, Addr, Api, Coin, Storage};
55
use cw_address_like::AddressLike;
6-
use cw_storage_plus::{index_list, IndexedMap, Item, MultiIndex};
6+
use cw_storage_plus::{index_list, IndexedMap, Item, MultiIndex, UniqueIndex};
77

88
#[cw_serde]
99
pub struct Config<T: AddressLike> {
@@ -228,6 +228,7 @@ pub struct PendingSale {
228228
pub struct PendingSaleIndices<'a> {
229229
pub by_seller: MultiIndex<'a, Addr, PendingSale, PendingSaleId>,
230230
pub by_buyer: MultiIndex<'a, Addr, PendingSale, PendingSaleId>,
231+
pub by_collection_and_token_id: UniqueIndex<'a, (Addr, String), PendingSale, PendingSaleId>,
231232
pub by_expiration: MultiIndex<'a, u64, PendingSale, PendingSaleId>,
232233
}
233234

@@ -244,6 +245,15 @@ pub fn pending_sales<'a>() -> IndexedMap<PendingSaleId, PendingSale, PendingSale
244245
PENDING_SALES_NAMESPACE,
245246
"psb", // pending sale buyer index namespace
246247
),
248+
by_collection_and_token_id: UniqueIndex::new(
249+
|pending_sale: &PendingSale| {
250+
(
251+
pending_sale.collection.clone(),
252+
pending_sale.token_id.clone(),
253+
)
254+
},
255+
"psct", // pending sale collection and token id index namespace
256+
),
247257
by_expiration: MultiIndex::new(
248258
|_id, pending_sale: &PendingSale| pending_sale.expiration,
249259
PENDING_SALES_NAMESPACE,

contracts/marketplace/tests/tests/test_approval_queue.rs

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -996,3 +996,176 @@ fn test_approve_sale_fee_routing_with_royalties() {
996996
"Pending sale should be deleted after approval"
997997
);
998998
}
999+
1000+
#[test]
1001+
fn test_approve_sale_with_existing_pending_sale() {
1002+
let mut app = setup_app_with_balances();
1003+
let minter = app.api().addr_make("minter");
1004+
let seller = app.api().addr_make("seller");
1005+
let buyer1 = app.api().addr_make("buyer1");
1006+
let buyer2 = app.api().addr_make("buyer2");
1007+
let manager = app.api().addr_make("manager");
1008+
1009+
// Give funds to buyer1 and buyer2
1010+
use cw_multi_test::{BankSudo, SudoMsg};
1011+
let funds = vec![coin(10000, "uxion")];
1012+
app.sudo(SudoMsg::Bank(BankSudo::Mint {
1013+
to_address: buyer1.to_string(),
1014+
amount: funds.clone(),
1015+
}))
1016+
.unwrap();
1017+
app.sudo(SudoMsg::Bank(BankSudo::Mint {
1018+
to_address: buyer2.to_string(),
1019+
amount: funds.clone(),
1020+
}))
1021+
.unwrap();
1022+
1023+
let asset_contract = setup_asset_contract(&mut app, &minter);
1024+
let marketplace_contract = setup_marketplace_with_approvals(&mut app, &manager);
1025+
1026+
// Use a unique token_id with timestamp to avoid any potential conflicts
1027+
use std::time::{SystemTime, UNIX_EPOCH};
1028+
let timestamp = SystemTime::now()
1029+
.duration_since(UNIX_EPOCH)
1030+
.unwrap()
1031+
.as_nanos();
1032+
let token_id = format!("test_approve_existing_pending_{}", timestamp);
1033+
mint_nft(&mut app, &asset_contract, &minter, &seller, &token_id);
1034+
1035+
let price = coin(100, "uxion");
1036+
let listing_id = create_listing_helper(
1037+
&mut app,
1038+
&marketplace_contract,
1039+
&asset_contract,
1040+
&seller,
1041+
&token_id,
1042+
price.clone(),
1043+
);
1044+
1045+
// First buyer creates a pending sale
1046+
let buy_msg1 = ExecuteMsg::BuyItem {
1047+
listing_id: listing_id.clone(),
1048+
price: price.clone(),
1049+
};
1050+
1051+
let buy_result1 = app.execute_contract(
1052+
buyer1.clone(),
1053+
marketplace_contract.clone(),
1054+
&buy_msg1,
1055+
std::slice::from_ref(&price),
1056+
);
1057+
1058+
assert!(
1059+
buy_result1.is_ok(),
1060+
"First buy should succeed: {:?}",
1061+
buy_result1.as_ref().unwrap_err()
1062+
);
1063+
1064+
let pending_sale_id1 = buy_result1
1065+
.unwrap()
1066+
.events
1067+
.iter()
1068+
.find(|e| e.ty == "wasm-xion-nft-marketplace/pending-sale-created")
1069+
.unwrap()
1070+
.attributes
1071+
.iter()
1072+
.find(|a| a.key == "id")
1073+
.unwrap()
1074+
.value
1075+
.clone();
1076+
1077+
// Verify first pending sale exists
1078+
let pending_sale1: PendingSale = app
1079+
.wrap()
1080+
.query_wasm_smart(
1081+
marketplace_contract.clone(),
1082+
&QueryMsg::PendingSale {
1083+
id: pending_sale_id1.clone(),
1084+
},
1085+
)
1086+
.unwrap();
1087+
assert_eq!(pending_sale1.buyer, buyer1);
1088+
assert_eq!(pending_sale1.token_id, token_id.clone());
1089+
1090+
// Second buyer tries to create another pending sale for the same item - should fail
1091+
let buy_msg2 = ExecuteMsg::BuyItem {
1092+
listing_id: listing_id.clone(),
1093+
price: price.clone(),
1094+
};
1095+
1096+
let buy_result2 = app.execute_contract(
1097+
buyer2.clone(),
1098+
marketplace_contract.clone(),
1099+
&buy_msg2,
1100+
std::slice::from_ref(&price),
1101+
);
1102+
1103+
assert!(buy_result2.is_err());
1104+
assert_error(
1105+
buy_result2,
1106+
xion_nft_marketplace::error::ContractError::PendingSaleAlreadyExists {
1107+
collection: asset_contract.to_string(),
1108+
token_id: token_id.clone(),
1109+
}
1110+
.to_string(),
1111+
);
1112+
1113+
// Now approve the first pending sale
1114+
let approve_msg = ExecuteMsg::ApproveSale {
1115+
id: pending_sale_id1.clone(),
1116+
};
1117+
1118+
let approve_result = app.execute_contract(
1119+
manager.clone(),
1120+
marketplace_contract.clone(),
1121+
&approve_msg,
1122+
&[],
1123+
);
1124+
1125+
assert!(
1126+
approve_result.is_ok(),
1127+
"Approval should succeed even with existing pending sale"
1128+
);
1129+
1130+
let events = approve_result.unwrap().events;
1131+
let approved_event = events
1132+
.iter()
1133+
.find(|e| e.ty == "wasm-xion-nft-marketplace/sale-approved");
1134+
assert!(
1135+
approved_event.is_some(),
1136+
"Sale approved event should be emitted"
1137+
);
1138+
1139+
// Verify the pending sale is removed after approval
1140+
let pending_sale_query = app.wrap().query_wasm_smart::<PendingSale>(
1141+
marketplace_contract.clone(),
1142+
&QueryMsg::PendingSale {
1143+
id: pending_sale_id1,
1144+
},
1145+
);
1146+
assert!(
1147+
pending_sale_query.is_err(),
1148+
"Pending sale should be removed after approval"
1149+
);
1150+
1151+
// Verify the listing is removed
1152+
let listing_query = app.wrap().query_wasm_smart::<Listing>(
1153+
marketplace_contract.clone(),
1154+
&QueryMsg::Listing { listing_id },
1155+
);
1156+
assert!(
1157+
listing_query.is_err(),
1158+
"Listing should be deleted after approval"
1159+
);
1160+
1161+
// Verify NFT ownership transferred to buyer1
1162+
let owner_query = OwnerQueryMsg::OwnerOf {
1163+
token_id: token_id.clone(),
1164+
include_expired: Some(false),
1165+
};
1166+
let owner_resp: cw721::msg::OwnerOfResponse = app
1167+
.wrap()
1168+
.query_wasm_smart(asset_contract.clone(), &owner_query)
1169+
.unwrap();
1170+
assert_eq!(owner_resp.owner, buyer1.to_string());
1171+
}

0 commit comments

Comments
 (0)