Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions contracts/marketplace/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,16 @@ pub fn create_listing_event(
collection: Addr,
token_id: String,
price: Coin,
reserved_for: Option<Addr>,
) -> 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())
}
Expand Down
42 changes: 38 additions & 4 deletions contracts/marketplace/src/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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<Addr>,
) -> Result<Response, ContractError> {
only_owner(&deps.querier, &info, &collection, &token_id)?;
not_listed(&deps.querier, &collection, &token_id)?;
Expand All @@ -135,28 +148,40 @@ 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 escrower) used to reserve in the asset contract

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),
})
} else {
None
};
let listing = Listing {
id: id.clone(),
seller: info.sender.clone(),
collection: collection.clone(),
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
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(), asset_price);
let list_msg = asset_list_msg(token_id.clone(), asset_price, reservation);
Ok(Response::new()
.add_event(create_listing_event(
id,
info.sender,
collection.clone(),
token_id,
price,
reserved_for.clone(),
))
.add_message(WasmMsg::Execute {
contract_addr: collection.to_string(),
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion contracts/marketplace/src/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -129,6 +130,7 @@ pub fn valid_payment(
pub fn asset_list_msg(
token_id: String,
asset_price: Coin,
reservation: Option<ReserveMsg>,
) -> asset::msg::ExecuteMsg<
cw721::DefaultOptionalNftExtensionMsg,
cw721::DefaultOptionalCollectionExtensionMsg,
Expand All @@ -142,7 +144,7 @@ pub fn asset_list_msg(
msg: AssetExecuteMsg::List {
token_id: token_id.clone(),
price: asset_price.clone(),
reservation: None,
reservation,
},
}
}
Expand Down
1 change: 1 addition & 0 deletions contracts/marketplace/src/msg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub enum ExecuteMsg {
price: Coin,
collection: String,
token_id: String,
reserved_for: Option<String>,
},
CancelListing {
listing_id: String,
Expand Down
4 changes: 2 additions & 2 deletions contracts/marketplace/src/offers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down Expand Up @@ -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());

Expand Down
1 change: 1 addition & 0 deletions contracts/marketplace/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ pub struct Listing {
pub price: Coin,
pub asset_price: Coin,
pub seller: Addr,
pub reserved_for: Option<Addr>,
pub status: ListingStatus,
}

Expand Down
118 changes: 118 additions & 0 deletions contracts/marketplace/tests/tests/test_buy_item.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, &[]);
Expand Down Expand Up @@ -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, &[]);
Expand Down Expand Up @@ -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, &[]);
Expand Down Expand Up @@ -532,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());
}
6 changes: 6 additions & 0 deletions contracts/marketplace/tests/tests/test_create_listing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, &[]);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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, &[]);
Expand Down Expand Up @@ -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, &[]);
Expand All @@ -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(
Expand Down Expand Up @@ -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, &[]);
Expand Down
3 changes: 3 additions & 0 deletions contracts/marketplace/tests/tests/test_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, &[]);
Expand Down Expand Up @@ -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, &[]);
Expand Down Expand Up @@ -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, &[]);
Expand Down
Loading