diff --git a/contracts/commitment_marketplace/README.md b/contracts/commitment_marketplace/README.md index 3538ed3..37f72c2 100644 --- a/contracts/commitment_marketplace/README.md +++ b/contracts/commitment_marketplace/README.md @@ -149,7 +149,8 @@ marketplace.make_offer( offerer_address, token_id, amount, - payment_token_address + payment_token_address, + expires_at ) ``` @@ -281,10 +282,11 @@ fn make_offer( token_id: u32, amount: i128, payment_token: Address, + expires_at: u64, ) -> Result<(), MarketplaceError> ``` -Make an offer on an NFT. +Make an offer on an NFT. `expires_at` must be greater than the current ledger timestamp. #### `accept_offer` diff --git a/contracts/commitment_marketplace/src/lib.rs b/contracts/commitment_marketplace/src/lib.rs index 71a602c..d369d0c 100644 --- a/contracts/commitment_marketplace/src/lib.rs +++ b/contracts/commitment_marketplace/src/lib.rs @@ -64,6 +64,8 @@ pub enum MarketplaceError { OfferExists = 13, /// Not offer maker NotOfferMaker = 14, + /// Offer has expired or expiry is not in the future + OfferExpired = 23, /// Auction not found AuctionNotFound = 15, /// Auction already ended @@ -106,6 +108,7 @@ pub struct Offer { pub amount: i128, pub payment_token: Address, pub created_at: u64, + pub expires_at: u64, } /// Auction information @@ -709,6 +712,7 @@ impl CommitmentMarketplace { /// @dev Reentrancy guard enforced. /// @error MarketplaceError::InvalidOfferAmount if amount <= 0. /// @error MarketplaceError::OfferExists if offerer already has an offer. + /// @error MarketplaceError::OfferExpired if expires_at is not in the future. /// @security Only callable by `offerer` (require_auth). pub fn make_offer( e: Env, @@ -716,6 +720,7 @@ impl CommitmentMarketplace { token_id: u32, amount: i128, payment_token: Address, + expires_at: u64, ) -> Result<(), MarketplaceError> { // Reentrancy protection let guard: bool = e @@ -738,6 +743,14 @@ impl CommitmentMarketplace { return Err(MarketplaceError::InvalidOfferAmount); } + let now = e.ledger().timestamp(); + if expires_at <= now { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(MarketplaceError::OfferExpired); + } + if let Err(err) = require_allowed_payment_token(&e, &payment_token) { e.storage() .instance() @@ -777,7 +790,8 @@ impl CommitmentMarketplace { offerer: offerer.clone(), amount, payment_token: payment_token.clone(), - created_at: e.ledger().timestamp(), + created_at: now, + expires_at, }; let mut offers: Vec = e @@ -809,7 +823,7 @@ impl CommitmentMarketplace { // Emit event e.events().publish( (symbol_short!("OfferMade"), token_id), - (offerer, amount, payment_token), + (offerer, amount, payment_token, expires_at), ); Ok(()) @@ -873,6 +887,12 @@ impl CommitmentMarketplace { })?; let offer = offers.get(offer_index as u32).unwrap(); + if offer.expires_at <= e.ledger().timestamp() { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(MarketplaceError::OfferExpired); + } let fee_basis_points: u32 = e .storage() @@ -984,6 +1004,42 @@ impl CommitmentMarketplace { Ok(()) } + /// @notice Remove expired offers for a token. + /// @param token_id NFT token ID whose expired offers should be pruned. + /// @return Number of expired offers removed. + /// @dev Permissionless; only offers with `expires_at <= ledger.timestamp()` are removed. + pub fn prune_expired_offers(e: Env, token_id: u32) -> u32 { + let offers: Vec = e + .storage() + .persistent() + .get(&DataKey::Offers(token_id)) + .unwrap_or(Vec::new(&e)); + + let now = e.ledger().timestamp(); + let mut active = Vec::new(&e); + let mut removed = 0u32; + + for offer in offers.iter() { + if offer.expires_at <= now { + removed += 1; + } else { + active.push_back(offer); + } + } + + if active.is_empty() { + e.storage().persistent().remove(&DataKey::Offers(token_id)); + } else { + e.storage() + .persistent() + .set(&DataKey::Offers(token_id), &active); + } + + e.events() + .publish((symbol_short!("OfferPrun"), token_id), removed); + removed + } + /// @notice Get all offers for a specific NFT token. /// @param token_id NFT token ID. /// @return Vec of all offers for the token. @@ -1396,4 +1452,4 @@ impl CommitmentMarketplace { auctions } -} \ No newline at end of file +} diff --git a/contracts/commitment_marketplace/src/tests.rs b/contracts/commitment_marketplace/src/tests.rs index 4163b6a..5b5fca9 100644 --- a/contracts/commitment_marketplace/src/tests.rs +++ b/contracts/commitment_marketplace/src/tests.rs @@ -65,6 +65,10 @@ fn setup_allowed_payment_token(e: &Env, client: &CommitmentMarketplaceClient<'_> payment_token } +fn offer_expiry(e: &Env) -> u64 { + e.ledger().timestamp() + 86_400 +} + // ============================================================================ // Initialization Tests // ============================================================================ @@ -309,7 +313,7 @@ fn test_make_offer_zero_amount_fails() { let offerer = Address::generate(&e); let payment_token = setup_allowed_payment_token(&e, &client); - client.make_offer(&offerer, &1, &0, &payment_token); + client.make_offer(&offerer, &1, &0, &payment_token, &offer_expiry(&e)); } #[test] @@ -323,8 +327,8 @@ fn test_make_duplicate_offer_fails() { let offerer = Address::generate(&e); let payment_token = setup_allowed_payment_token(&e, &client); - client.make_offer(&offerer, &1, &500, &payment_token); - client.make_offer(&offerer, &1, &600, &payment_token); // Should fail + client.make_offer(&offerer, &1, &500, &payment_token, &offer_expiry(&e)); + client.make_offer(&offerer, &1, &600, &payment_token, &offer_expiry(&e)); // Should fail } #[test] @@ -339,7 +343,7 @@ fn test_make_offer_own_listing_fails() { let payment_token = setup_test_token(&e, &client); client.list_nft(&seller, &1, &1000, &payment_token); - client.make_offer(&seller, &1, &800, &payment_token); // Seller making offer on own listing + client.make_offer(&seller, &1, &800, &payment_token, &offer_expiry(&e)); // Seller making offer on own listing } #[test] @@ -354,7 +358,7 @@ fn test_make_offer_own_auction_fails() { let payment_token = setup_test_token(&e, &client); client.start_auction(&seller, &1, &1000, &86400, &payment_token); - client.make_offer(&seller, &1, &1100, &payment_token); // Seller making offer on own auction + client.make_offer(&seller, &1, &1100, &payment_token, &offer_expiry(&e)); // Seller making offer on own auction } #[test] @@ -368,7 +372,7 @@ fn test_accept_offer_own_listing_fails() { let seller = Address::generate(&e); let payment_token = setup_test_token(&e, &client); - client.make_offer(&seller, &1, &1000, &payment_token); + client.make_offer(&seller, &1, &1000, &payment_token, &offer_expiry(&e)); client.accept_offer(&seller, &1, &seller); // Seller accepting own offer } @@ -386,8 +390,8 @@ fn test_multiple_offers_same_token() { let payment_token = setup_allowed_payment_token(&e, &client); let token_id = 1u32; - client.make_offer(&offerer1, &token_id, &500, &payment_token); - client.make_offer(&offerer2, &token_id, &600, &payment_token); + client.make_offer(&offerer1, &token_id, &500, &payment_token, &offer_expiry(&e)); + client.make_offer(&offerer2, &token_id, &600, &payment_token, &offer_expiry(&e)); let offers = client.get_offers(&token_id); assert_eq!(offers.len(), 2); @@ -404,7 +408,7 @@ fn test_cancel_offer() { let payment_token = setup_allowed_payment_token(&e, &client); let token_id = 1u32; - client.make_offer(&offerer, &token_id, &500, &payment_token); + client.make_offer(&offerer, &token_id, &500, &payment_token, &offer_expiry(&e)); client.cancel_offer(&offerer, &token_id); let offers = client.get_offers(&token_id); @@ -423,6 +427,108 @@ fn test_cancel_nonexistent_offer_fails() { client.cancel_offer(&offerer, &999); } +#[test] +#[should_panic(expected = "Error(Contract, #23)")] // OfferExpired +fn test_make_offer_with_past_expiry_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + let offerer = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + let token_id = 1u32; + + e.ledger().with_mut(|ledger| { + ledger.timestamp = 1_000; + }); + + client.make_offer(&offerer, &token_id, &500, &payment_token, &1_000); +} + +#[test] +fn test_accept_offer_before_expiry_succeeds() { + let e = Env::default(); + e.mock_all_auths_allowing_non_root_auth(); + + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let offerer = Address::generate(&e); + let token_admin = Address::generate(&e); + let token = e.register_stellar_asset_contract_v2(token_admin); + let payment_token = token.address(); + let token_id = 1u32; + client.add_payment_token(&payment_token); + soroban_sdk::token::StellarAssetClient::new(&e, &payment_token).mint(&offerer, &10_000); + + e.ledger().with_mut(|ledger| { + ledger.timestamp = 1_000; + }); + + let expires_at = 2_000; + client.make_offer(&offerer, &token_id, &500, &payment_token, &expires_at); + e.ledger().with_mut(|ledger| { + ledger.timestamp = expires_at - 1; + }); + + client.accept_offer(&seller, &token_id, &offerer); + assert_eq!(client.get_offers(&token_id).len(), 0); +} + +#[test] +#[should_panic(expected = "Error(Contract, #23)")] // OfferExpired +fn test_accept_offer_after_expiry_rejected() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let offerer = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + let token_id = 1u32; + + e.ledger().with_mut(|ledger| { + ledger.timestamp = 1_000; + }); + + let expires_at = 2_000; + client.make_offer(&offerer, &token_id, &500, &payment_token, &expires_at); + e.ledger().with_mut(|ledger| { + ledger.timestamp = expires_at; + }); + + client.accept_offer(&seller, &token_id, &offerer); +} + +#[test] +fn test_prune_expired_offers_removes_only_expired_entries() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + let expired_offerer = Address::generate(&e); + let active_offerer = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + let token_id = 1u32; + + e.ledger().with_mut(|ledger| { + ledger.timestamp = 1_000; + }); + + client.make_offer(&expired_offerer, &token_id, &500, &payment_token, &1_500); + client.make_offer(&active_offerer, &token_id, &600, &payment_token, &3_000); + + e.ledger().with_mut(|ledger| { + ledger.timestamp = 2_000; + }); + + assert_eq!(client.prune_expired_offers(&token_id), 1); + let offers = client.get_offers(&token_id); + assert_eq!(offers.len(), 1); + let remaining = offers.get(0).unwrap(); + assert_eq!(remaining.offerer, active_offerer); + assert_eq!(remaining.expires_at, 3_000); +} + // ============================================================================ // Auction System Tests // ============================================================================ @@ -696,10 +802,10 @@ fn test_make_duplicate_offer_same_token_different_amount_fails() { let token_id = 1u32; // Make first offer - client.make_offer(&offerer, &token_id, &500, &payment_token); + client.make_offer(&offerer, &token_id, &500, &payment_token, &offer_expiry(&e)); // Try to make another offer with different amount - should fail - client.make_offer(&offerer, &token_id, &1000, &payment_token); + client.make_offer(&offerer, &token_id, &1000, &payment_token, &offer_expiry(&e)); } #[test] @@ -715,13 +821,13 @@ fn test_make_duplicate_offer_different_tokens_same_user_fails() { let payment_token2 = setup_test_token(&e, &client); // Make offer on token 1 - client.make_offer(&offerer, &1, &500, &payment_token1); + client.make_offer(&offerer, &1, &500, &payment_token1, &offer_expiry(&e)); // Make offer on token 2 - should work (different token) - client.make_offer(&offerer, &2, &600, &payment_token2); + client.make_offer(&offerer, &2, &600, &payment_token2, &offer_expiry(&e)); // Try to make another offer on token 1 - should fail - client.make_offer(&offerer, &1, &700, &payment_token1); + client.make_offer(&offerer, &1, &700, &payment_token1, &offer_expiry(&e)); } #[test] @@ -738,9 +844,9 @@ fn test_different_users_can_offer_same_token() { let token_id = 1u32; // Multiple users can offer on the same token - client.make_offer(&offerer1, &token_id, &500, &payment_token); - client.make_offer(&offerer2, &token_id, &600, &payment_token); - client.make_offer(&offerer3, &token_id, &700, &payment_token); + client.make_offer(&offerer1, &token_id, &500, &payment_token, &offer_expiry(&e)); + client.make_offer(&offerer2, &token_id, &600, &payment_token, &offer_expiry(&e)); + client.make_offer(&offerer3, &token_id, &700, &payment_token, &offer_expiry(&e)); let offers = client.get_offers(&token_id); assert_eq!(offers.len(), 3); @@ -761,9 +867,9 @@ fn test_cancel_offer_removes_correct_offer_only() { let token_id = 1u32; // Make multiple offers - client.make_offer(&offerer1, &token_id, &500, &payment_token); - client.make_offer(&offerer2, &token_id, &600, &payment_token); - client.make_offer(&offerer3, &token_id, &700, &payment_token); + client.make_offer(&offerer1, &token_id, &500, &payment_token, &offer_expiry(&e)); + client.make_offer(&offerer2, &token_id, &600, &payment_token, &offer_expiry(&e)); + client.make_offer(&offerer3, &token_id, &700, &payment_token, &offer_expiry(&e)); // Cancel middle offer client.cancel_offer(&offerer2, &token_id); @@ -799,7 +905,7 @@ fn test_cancel_last_offer_removes_storage() { let token_id = 1u32; // Make offer - client.make_offer(&offerer, &token_id, &500, &payment_token); + client.make_offer(&offerer, &token_id, &500, &payment_token, &offer_expiry(&e)); // Verify offer exists let offers = client.get_offers(&token_id); @@ -827,7 +933,7 @@ fn test_cancel_offer_after_accept_fails() { let token_id = 1u32; // Make offer - client.make_offer(&offerer, &token_id, &500, &payment_token); + client.make_offer(&offerer, &token_id, &500, &payment_token, &offer_expiry(&e)); client.cancel_offer(&offerer, &token_id); client.cancel_offer(&offerer, &token_id); } @@ -843,9 +949,9 @@ fn test_cancel_multiple_offers_same_user_different_tokens() { let payment_token = setup_test_token(&e, &client); // Make offers on different tokens - client.make_offer(&offerer, &1, &500, &payment_token); - client.make_offer(&offerer, &2, &600, &payment_token); - client.make_offer(&offerer, &3, &700, &payment_token); + client.make_offer(&offerer, &1, &500, &payment_token, &offer_expiry(&e)); + client.make_offer(&offerer, &2, &600, &payment_token, &offer_expiry(&e)); + client.make_offer(&offerer, &3, &700, &payment_token, &offer_expiry(&e)); // Cancel one offer client.cancel_offer(&offerer, &2); @@ -871,7 +977,7 @@ fn test_non_maker_cannot_cancel_offer() { let token_id = 1u32; // Make offer - client.make_offer(&offerer, &token_id, &500, &payment_token); + client.make_offer(&offerer, &token_id, &500, &payment_token, &offer_expiry(&e)); // Try to cancel with different address - should fail client.cancel_offer(&non_maker, &token_id); @@ -891,8 +997,8 @@ fn test_different_offerer_cannot_cancel_other_offer() { let token_id = 1u32; // Make offers from different users - client.make_offer(&offerer1, &token_id, &500, &payment_token); - client.make_offer(&offerer2, &token_id, &600, &payment_token); + client.make_offer(&offerer1, &token_id, &500, &payment_token, &offer_expiry(&e)); + client.make_offer(&offerer2, &token_id, &600, &payment_token, &offer_expiry(&e)); let non_maker = Address::generate(&e); client.cancel_offer(&non_maker, &token_id); @@ -911,8 +1017,8 @@ fn test_maker_can_cancel_own_offer_multiple_exist() { let token_id = 1u32; // Make offers from different users - client.make_offer(&offerer1, &token_id, &500, &payment_token); - client.make_offer(&offerer2, &token_id, &600, &payment_token); + client.make_offer(&offerer1, &token_id, &500, &payment_token, &offer_expiry(&e)); + client.make_offer(&offerer2, &token_id, &600, &payment_token, &offer_expiry(&e)); // offerer1 should be able to cancel their own offer client.cancel_offer(&offerer1, &token_id); @@ -951,10 +1057,10 @@ fn test_authorization_scenarios_comprehensive() { let payment_token = setup_test_token(&e, &client); // Create offers on multiple tokens - client.make_offer(&offerer1, &1, &100, &payment_token); - client.make_offer(&offerer2, &1, &200, &payment_token); - client.make_offer(&offerer1, &2, &300, &payment_token); - client.make_offer(&offerer3, &3, &400, &payment_token); + client.make_offer(&offerer1, &1, &100, &payment_token, &offer_expiry(&e)); + client.make_offer(&offerer2, &1, &200, &payment_token, &offer_expiry(&e)); + client.make_offer(&offerer1, &2, &300, &payment_token, &offer_expiry(&e)); + client.make_offer(&offerer3, &3, &400, &payment_token, &offer_expiry(&e)); // Each offerer can cancel their own offers client.cancel_offer(&offerer1, &1); // Cancels offerer1's offer on token 1 @@ -1098,7 +1204,7 @@ fn test_make_offer_reentrancy_guard() { e.as_contract(&client.address, || { e.storage().instance().set(&DataKey::ReentrancyGuard, &true); }); - client.make_offer(&offerer, &1, &500, &payment_token); + client.make_offer(&offerer, &1, &500, &payment_token, &offer_expiry(&e)); } /// @notice Test: accept_offer fails if reentrancy guard is set. @@ -1113,7 +1219,7 @@ fn test_accept_offer_reentrancy_guard() { let payment_token = setup_test_token(&e, &client); let token_id = 1u32; client.list_nft(&seller, &token_id, &1000, &payment_token); - client.make_offer(&offerer, &token_id, &500, &payment_token); + client.make_offer(&offerer, &token_id, &500, &payment_token, &offer_expiry(&e)); e.as_contract(&client.address, || { e.storage().instance().set(&DataKey::ReentrancyGuard, &true); }); @@ -1238,7 +1344,7 @@ fn test_make_offer_with_unallowlisted_token_fails() { let offerer = Address::generate(&e); let payment_token = Address::generate(&e); - client.make_offer(&offerer, &1, &1000, &payment_token); + client.make_offer(&offerer, &1, &1000, &payment_token, &offer_expiry(&e)); } #[test] diff --git a/docs/MARKETPLACE_LISTING_LIFECYCLE.md b/docs/MARKETPLACE_LISTING_LIFECYCLE.md index ae51ce6..9fabf44 100644 --- a/docs/MARKETPLACE_LISTING_LIFECYCLE.md +++ b/docs/MARKETPLACE_LISTING_LIFECYCLE.md @@ -90,13 +90,13 @@ Any address ──────────────▶ Offer stored (per toke ### 3.1 Function Reference -#### `make_offer(offerer, token_id, amount, payment_token)` +#### `make_offer(offerer, token_id, amount, payment_token, expires_at)` - **Auth**: `offerer.require_auth()` - **Reentrancy guard**: yes -- **Preconditions**: `amount > 0`, no existing offer from `offerer` for this token +- **Preconditions**: `amount > 0`, `expires_at > ledger.timestamp()`, no existing offer from `offerer` for this token - **Effect**: appends `Offer` to `DataKey::Offers(token_id)` -- **Event**: `("OfferMade", token_id) → (offerer, amount, payment_token)` +- **Event**: `("OfferMade", token_id) → (offerer, amount, payment_token, expires_at)` #### `cancel_offer(offerer, token_id)` diff --git a/docs/commitment_marketplace.md b/docs/commitment_marketplace.md index 7cbb25d..4147d9f 100644 --- a/docs/commitment_marketplace.md +++ b/docs/commitment_marketplace.md @@ -11,9 +11,10 @@ This page documents the public entry points, access control, and security notes | list_nft | List NFT for sale | Seller require_auth | Fails if price <= 0, listing exists, or not initialized | | cancel_listing | Cancel NFT listing | Seller require_auth | Fails if not found or not seller | | buy_nft | Buy NFT from listing | Buyer require_auth | Fails if not found, self-buy, or not initialized | -| make_offer | Make offer on NFT | Offerer require_auth | Fails if amount <= 0 or duplicate offer | -| accept_offer | Accept offer on NFT | Seller require_auth | Fails if offer not found or not initialized | +| make_offer | Make offer on NFT with expiry timestamp | Offerer require_auth | Fails if amount <= 0, duplicate offer, or expiry is not future | +| accept_offer | Accept offer on NFT | Seller require_auth | Fails if offer not found, expired, or not initialized | | cancel_offer | Cancel offer | Offerer require_auth | Fails if offer not found | +| prune_expired_offers | Remove expired offers for an NFT | Permissionless | Removes only offers with `expires_at <= ledger.timestamp()` | | start_auction | Start auction for NFT | Seller require_auth | Fails if price/duration invalid or auction exists | | place_bid | Place bid on auction | Bidder require_auth | Fails if bid too low, ended, or self-bid | | end_auction | End auction and settle | Anyone (time-gated) | Fails if not ended, already ended, or not found | @@ -30,6 +31,12 @@ This page documents the public entry points, access control, and security notes - No cross-contract NFT ownership checks or transfers are performed in this implementation (see contract comments). - All token transfers use Soroban token interface. +## Offer Expiry +- `make_offer` requires an `expires_at` ledger timestamp strictly greater than the current ledger timestamp. +- `accept_offer` rejects an expired offer with `MarketplaceError::OfferExpired`; expiry is inclusive, so an offer is expired when `ledger.timestamp() >= expires_at`. +- `get_offers` returns stored offers as-is so indexers can show both active and stale offers before pruning. +- `prune_expired_offers(token_id)` is permissionless and removes only expired offers for that token, bounding `Offers(token_id)` storage growth without letting third parties remove still-active offers. + ## Reentrancy Guard - All mutating entry points set/check/clear a `ReentrancyGuard` storage key. - If the guard is set, the contract returns `MarketplaceError::ReentrancyDetected`.