diff --git a/contracts/commitment_marketplace/src/lib.rs b/contracts/commitment_marketplace/src/lib.rs index 71a602c..418062c 100644 --- a/contracts/commitment_marketplace/src/lib.rs +++ b/contracts/commitment_marketplace/src/lib.rs @@ -16,14 +16,14 @@ //! - See [`DataKey`] for all storage keys mutated by each entry point. //! //! ## Audit Notes -//! - No cross-contract NFT ownership checks are performed in this implementation (see comments in code). +//! - Listing and auction creation verify NFT ownership and active status against the configured NFT contract. //! - All token transfers use Soroban token interface. #![no_std] use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, Env, Symbol, - Vec, + contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, Env, + IntoVal, Symbol, TryIntoVal, Val, Vec, }; use shared_utils::math::SafeMath; @@ -200,6 +200,56 @@ fn require_allowed_payment_token( Ok(()) } +fn require_nft_owner_and_active( + e: &Env, + token_id: u32, + seller: &Address, +) -> Result<(), MarketplaceError> { + let nft_contract: Address = e + .storage() + .instance() + .get(&DataKey::NFTContract) + .ok_or(MarketplaceError::NotInitialized)?; + + let mut owner_args = Vec::new(e); + owner_args.push_back(token_id.into_val(e)); + let owner_val: Val = match e.try_invoke_contract::( + &nft_contract, + &Symbol::new(e, "owner_of"), + owner_args, + ) { + Ok(Ok(val)) => val, + _ => return Err(MarketplaceError::NFTContractError), + }; + let owner: Address = owner_val + .try_into_val(e) + .map_err(|_| MarketplaceError::NFTContractError)?; + + if owner != *seller { + return Err(MarketplaceError::NotSeller); + } + + let mut active_args = Vec::new(e); + active_args.push_back(token_id.into_val(e)); + let active_val: Val = match e.try_invoke_contract::( + &nft_contract, + &Symbol::new(e, "is_active"), + active_args, + ) { + Ok(Ok(val)) => val, + _ => return Err(MarketplaceError::NFTContractError), + }; + let is_active: bool = active_val + .try_into_val(e) + .map_err(|_| MarketplaceError::NFTContractError)?; + + if !is_active { + return Err(MarketplaceError::NFTNotActive); + } + + Ok(()) +} + #[contractimpl] impl CommitmentMarketplace { // ======================================================================== @@ -263,6 +313,16 @@ impl CommitmentMarketplace { read_admin(&e) } + /// @notice Get the configured CommitmentNFT contract address. + /// @return NFT contract address. + /// @error MarketplaceError::NotInitialized if not initialized. + pub fn get_nft_contract(e: Env) -> Result { + e.storage() + .instance() + .get(&DataKey::NFTContract) + .ok_or(MarketplaceError::NotInitialized) + } + /// @notice Update the marketplace fee (basis points). /// @param fee_basis_points New fee in basis points. /// @dev Only callable by admin. @@ -362,7 +422,7 @@ impl CommitmentMarketplace { /// @param token_id NFT token ID to list. /// @param price Sale price (must be > 0). /// @param payment_token Token contract address for payment. - /// @dev Reentrancy guard enforced. No cross-contract NFT ownership check in this implementation. + /// @dev Reentrancy guard enforced. Verifies NFT ownership and active status. /// @error MarketplaceError::InvalidPrice if price <= 0. /// @error MarketplaceError::ListingExists if listing already exists. /// @error MarketplaceError::NotInitialized if contract not initialized. @@ -410,21 +470,12 @@ impl CommitmentMarketplace { return Err(MarketplaceError::ListingExists); } - // Verify seller owns the NFT (external call - after checks) - let _nft_contract: Address = e - .storage() - .instance() - .get(&DataKey::NFTContract) - .ok_or_else(|| { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - MarketplaceError::NotInitialized - })?; - - // Note: This would require the NFT contract client - // For now, we assume the caller has verified ownership - // In production, you'd call: nft_contract.owner_of(&token_id) + if let Err(err) = require_nft_owner_and_active(&e, token_id, &seller) { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(err); + } // EFFECTS let listing = Listing { @@ -756,6 +807,12 @@ impl CommitmentMarketplace { .set(&DataKey::ReentrancyGuard, &false); return Err(MarketplaceError::CannotBuyOwnListing); } + if let Err(err) = require_nft_owner_and_active(&e, token_id, &listing.seller) { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(err); + } } if let Some(auction) = e @@ -769,6 +826,12 @@ impl CommitmentMarketplace { .set(&DataKey::ReentrancyGuard, &false); return Err(MarketplaceError::CannotBuyOwnListing); } + if let Err(err) = require_nft_owner_and_active(&e, token_id, &auction.seller) { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(err); + } } // EFFECTS @@ -1059,6 +1122,13 @@ impl CommitmentMarketplace { return Err(MarketplaceError::ListingExists); } + if let Err(err) = require_nft_owner_and_active(&e, token_id, &seller) { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(err); + } + // EFFECTS let started_at = e.ledger().timestamp(); let ends_at = started_at.checked_add(duration_seconds).ok_or_else(|| { @@ -1396,4 +1466,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..8564133 100644 --- a/contracts/commitment_marketplace/src/tests.rs +++ b/contracts/commitment_marketplace/src/tests.rs @@ -20,7 +20,7 @@ extern crate std; use crate::*; use soroban_sdk::{ - symbol_short, + contract, contractimpl, contracttype, symbol_short, testutils::{Address as _, Events, Ledger}, vec, Address, Env, IntoVal, }; @@ -29,12 +29,48 @@ use soroban_sdk::{ // Test Setup Helpers // ============================================================================ +#[contracttype] +#[derive(Clone)] +enum MockNftKey { + Owner(u32), + Active(u32), +} + +#[contract] +struct MockNftContract; + +#[contractimpl] +impl MockNftContract { + pub fn set_token(e: Env, token_id: u32, owner: Address, active: bool) { + e.storage() + .persistent() + .set(&MockNftKey::Owner(token_id), &owner); + e.storage() + .persistent() + .set(&MockNftKey::Active(token_id), &active); + } + + pub fn owner_of(e: Env, token_id: u32) -> Result { + e.storage() + .persistent() + .get(&MockNftKey::Owner(token_id)) + .ok_or(MarketplaceError::NFTContractError) + } + + pub fn is_active(e: Env, token_id: u32) -> Result { + e.storage() + .persistent() + .get(&MockNftKey::Active(token_id)) + .ok_or(MarketplaceError::NFTContractError) + } +} + /// @notice Helper to deploy and initialize the marketplace contract for tests. /// @param e Test environment. /// @return (admin, fee_recipient, client) fn setup_marketplace(e: &Env) -> (Address, Address, CommitmentMarketplaceClient<'_>) { let admin = Address::generate(e); - let nft_contract = Address::generate(e); + let nft_contract = e.register_contract(None, MockNftContract); let fee_recipient = Address::generate(e); // Use register_contract for Soroban SDK @@ -46,6 +82,18 @@ fn setup_marketplace(e: &Env) -> (Address, Address, CommitmentMarketplaceClient< (admin, fee_recipient, client) } +fn set_mock_nft( + e: &Env, + client: &CommitmentMarketplaceClient<'_>, + token_id: u32, + owner: &Address, + active: bool, +) { + let nft_contract = client.get_nft_contract(); + let nft_client = MockNftContractClient::new(e, &nft_contract); + nft_client.set_token(&token_id, owner, &active); +} + /// @notice Helper to generate a test token address and allowlist it. fn setup_test_token(e: &Env, client: &CommitmentMarketplaceClient<'_>) -> Address { setup_allowed_payment_token(e, client) @@ -145,6 +193,7 @@ fn test_list_nft_twice_fails() { let seller = Address::generate(&e); let payment_token = setup_allowed_payment_token(&e, &client); + set_mock_nft(&e, &client, 1, &seller, true); client.list_nft(&seller, &1, &1000, &payment_token); client.list_nft(&seller, &1, &2000, &payment_token); // Should fail } @@ -160,6 +209,7 @@ fn test_cancel_listing() { let payment_token = setup_allowed_payment_token(&e, &client); let token_id = 1u32; + set_mock_nft(&e, &client, token_id, &seller, true); client.list_nft(&seller, &token_id, &1000, &payment_token); client.cancel_listing(&seller, &token_id); @@ -189,6 +239,7 @@ fn test_get_listing_after_cancel_panics() { let token_id = 1u32; let payment_token = setup_allowed_payment_token(&e, &client); + set_mock_nft(&e, &client, token_id, &seller, true); client.list_nft(&seller, &token_id, &1000, &payment_token); client.cancel_listing(&seller, &token_id); @@ -220,6 +271,7 @@ fn test_cancel_listing_not_seller_fails() { let not_seller = Address::generate(&e); let payment_token = setup_allowed_payment_token(&e, &client); + set_mock_nft(&e, &client, 1, &seller, true); client.list_nft(&seller, &1, &1000, &payment_token); client.cancel_listing(¬_seller, &1); // Should fail } @@ -235,6 +287,9 @@ fn test_get_all_listings() { let payment_token = setup_allowed_payment_token(&e, &client); // List 3 NFTs + set_mock_nft(&e, &client, 1, &seller, true); + set_mock_nft(&e, &client, 2, &seller, true); + set_mock_nft(&e, &client, 3, &seller, true); client.list_nft(&seller, &1, &1000, &payment_token); client.list_nft(&seller, &2, &2000, &payment_token); client.list_nft(&seller, &3, &3000, &payment_token); @@ -243,6 +298,35 @@ fn test_get_all_listings() { assert_eq!(listings.len(), 3); } +#[test] +#[should_panic(expected = "Error(Contract, #4)")] // NotSeller +fn test_list_nft_rejects_unowned_token() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let actual_owner = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + + set_mock_nft(&e, &client, 1, &actual_owner, true); + client.list_nft(&seller, &1, &1000, &payment_token); +} + +#[test] +#[should_panic(expected = "Error(Contract, #5)")] // NFTNotActive +fn test_list_nft_rejects_inactive_token() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + + set_mock_nft(&e, &client, 1, &seller, false); + client.list_nft(&seller, &1, &1000, &payment_token); +} + // ============================================================================ // Buy Tests (Note: These are simplified - real tests need token contract) // ============================================================================ @@ -261,6 +345,7 @@ fn test_buy_nft_flow() { let price = 1000_0000000i128; // List NFT + set_mock_nft(&e, &client, token_id, &seller, true); client.list_nft(&seller, &token_id, &price, &payment_token); // Note: In a real test, you'd need to: @@ -290,6 +375,7 @@ fn test_buy_own_listing_fails() { let seller = Address::generate(&e); let payment_token = setup_allowed_payment_token(&e, &client); + set_mock_nft(&e, &client, 1, &seller, true); client.list_nft(&seller, &1, &1000, &payment_token); client.buy_nft(&seller, &1); // Seller trying to buy their own listing } @@ -338,6 +424,7 @@ fn test_make_offer_own_listing_fails() { let seller = Address::generate(&e); let payment_token = setup_test_token(&e, &client); + set_mock_nft(&e, &client, 1, &seller, true); client.list_nft(&seller, &1, &1000, &payment_token); client.make_offer(&seller, &1, &800, &payment_token); // Seller making offer on own listing } @@ -353,10 +440,30 @@ fn test_make_offer_own_auction_fails() { let seller = Address::generate(&e); let payment_token = setup_test_token(&e, &client); + set_mock_nft(&e, &client, 1, &seller, true); client.start_auction(&seller, &1, &1000, &86400, &payment_token); client.make_offer(&seller, &1, &1100, &payment_token); // Seller making offer on own auction } +#[test] +#[should_panic(expected = "Error(Contract, #4)")] // NotSeller +fn test_make_offer_on_listing_revalidates_nft_owner() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let new_owner = Address::generate(&e); + let offerer = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + + set_mock_nft(&e, &client, 1, &seller, true); + client.list_nft(&seller, &1, &1000, &payment_token); + set_mock_nft(&e, &client, 1, &new_owner, true); + + client.make_offer(&offerer, &1, &1100, &payment_token); +} + #[test] #[should_panic(expected = "Error(Contract, #8)")] // CannotBuyOwnListing fn test_accept_offer_own_listing_fails() { @@ -469,6 +576,7 @@ fn test_place_bid() { let starting_price = 1000_0000000i128; let _bid_amount = 1200_0000000i128; + set_mock_nft(&e, &client, token_id, &seller, true); client.start_auction(&seller, &token_id, &starting_price, &86400, &payment_token); // Note: In real test, setup token contract and balances @@ -491,6 +599,7 @@ fn test_place_bid_too_low_fails() { let payment_token = setup_allowed_payment_token(&e, &client); let token_id = 1u32; + set_mock_nft(&e, &client, token_id, &seller, true); client.start_auction(&seller, &token_id, &1000, &86400, &payment_token); client.place_bid(&bidder, &token_id, &500); // Lower than starting price } @@ -509,6 +618,7 @@ fn test_place_bid_not_high_enough_fails() { let token_id = 1u32; let starting_price = 1000i128; + set_mock_nft(&e, &client, token_id, &seller, true); client.start_auction(&seller, &token_id, &starting_price, &86400, &payment_token); // current_bid starts at starting_price; bidding the exact same amount is <= current_bid, @@ -530,6 +640,7 @@ fn test_place_bid_after_auction_ends_fails() { let token_id = 1u32; let duration = 86400u64; // 1 day + set_mock_nft(&e, &client, token_id, &seller, true); client.start_auction(&seller, &token_id, &1000, &duration, &payment_token); // Fast forward time past auction end @@ -555,6 +666,7 @@ fn test_auction_duration_boundary() { let starting_price = 1000i128; // Auction starts at timestamp 0, ends_at = 0 + duration = 86400 + set_mock_nft(&e, &client, token_id, &seller, true); client.start_auction(&seller, &token_id, &starting_price, &duration, &payment_token); // At timestamp 0 (start), bidding equal-to-current is rejected with BidTooLow, not AuctionEnded. @@ -601,6 +713,7 @@ fn test_end_auction_before_time_fails() { let seller = Address::generate(&e); let payment_token = setup_allowed_payment_token(&e, &client); + set_mock_nft(&e, &client, 1, &seller, true); client.start_auction(&seller, &1, &1000, &86400, &payment_token); client.end_auction(&1); // Try to end immediately } @@ -616,6 +729,7 @@ fn test_end_auction_twice_fails() { let seller = Address::generate(&e); let payment_token = setup_allowed_payment_token(&e, &client); + set_mock_nft(&e, &client, 1, &seller, true); client.start_auction(&seller, &1, &1000, &86400, &payment_token); e.ledger().with_mut(|li| { @@ -637,6 +751,7 @@ fn test_auction_active_vs_ended() { let payment_token = setup_test_token(&e, &client); let token_id = 1u32; + set_mock_nft(&e, &client, token_id, &seller, true); client.start_auction(&seller, &token_id, &1000, &86400, &payment_token); // Should be in active auctions @@ -670,6 +785,9 @@ fn test_get_all_auctions() { let payment_token = setup_allowed_payment_token(&e, &client); // Start 3 auctions + set_mock_nft(&e, &client, 1, &seller, true); + set_mock_nft(&e, &client, 2, &seller, true); + set_mock_nft(&e, &client, 3, &seller, true); client.start_auction(&seller, &1, &1000, &86400, &payment_token); client.start_auction(&seller, &2, &2000, &86400, &payment_token); client.start_auction(&seller, &3, &3000, &86400, &payment_token); @@ -678,6 +796,35 @@ fn test_get_all_auctions() { assert_eq!(auctions.len(), 3); } +#[test] +#[should_panic(expected = "Error(Contract, #4)")] // NotSeller +fn test_start_auction_rejects_unowned_token() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let actual_owner = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + + set_mock_nft(&e, &client, 1, &actual_owner, true); + client.start_auction(&seller, &1, &1000, &86400, &payment_token); +} + +#[test] +#[should_panic(expected = "Error(Contract, #5)")] // NFTNotActive +fn test_start_auction_rejects_inactive_token() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + + set_mock_nft(&e, &client, 1, &seller, false); + client.start_auction(&seller, &1, &1000, &86400, &payment_token); +} + // ============================================================================ // Issue #267: Unit tests for offers - duplicate offer, cancel, not maker // ============================================================================ @@ -988,6 +1135,7 @@ fn test_list_then_start_auction_same_token() { let token_id = 1u32; // List NFT + set_mock_nft(&e, &client, token_id, &seller, true); client.list_nft(&seller, &token_id, &1000, &payment_token); // Cancel listing @@ -1061,6 +1209,7 @@ fn test_cancel_listing_reentrancy_guard() { let seller = Address::generate(&e); let payment_token = setup_test_token(&e, &client); let token_id = 1u32; + set_mock_nft(&e, &client, token_id, &seller, true); client.list_nft(&seller, &token_id, &1000, &payment_token); e.as_contract(&client.address, || { e.storage().instance().set(&DataKey::ReentrancyGuard, &true); @@ -1079,6 +1228,7 @@ fn test_buy_nft_reentrancy_guard() { let buyer = Address::generate(&e); let payment_token = setup_test_token(&e, &client); let token_id = 1u32; + set_mock_nft(&e, &client, token_id, &seller, true); client.list_nft(&seller, &token_id, &1000, &payment_token); e.as_contract(&client.address, || { e.storage().instance().set(&DataKey::ReentrancyGuard, &true); @@ -1112,6 +1262,7 @@ fn test_accept_offer_reentrancy_guard() { let offerer = Address::generate(&e); let payment_token = setup_test_token(&e, &client); let token_id = 1u32; + set_mock_nft(&e, &client, token_id, &seller, true); client.list_nft(&seller, &token_id, &1000, &payment_token); client.make_offer(&offerer, &token_id, &500, &payment_token); e.as_contract(&client.address, || { @@ -1146,6 +1297,7 @@ fn test_place_bid_reentrancy_guard() { let bidder = Address::generate(&e); let payment_token = setup_test_token(&e, &client); let token_id = 1u32; + set_mock_nft(&e, &client, token_id, &seller, true); client.start_auction(&seller, &token_id, &1000, &86400, &payment_token); e.as_contract(&client.address, || { e.storage().instance().set(&DataKey::ReentrancyGuard, &true); @@ -1163,6 +1315,7 @@ fn test_end_auction_reentrancy_guard() { let seller = Address::generate(&e); let payment_token = setup_test_token(&e, &client); let token_id = 1u32; + set_mock_nft(&e, &client, token_id, &seller, true); client.start_auction(&seller, &token_id, &1000, &1, &payment_token); e.ledger().with_mut(|li| { li.timestamp = 2; @@ -1187,6 +1340,7 @@ fn test_gas_listing_operations() { let start = e.ledger().sequence(); for i in 0..10 { + set_mock_nft(&e, &client, i, &seller, true); client.list_nft(&seller, &i, &1000, &payment_token); } @@ -1269,6 +1423,7 @@ fn test_buy_nft_after_payment_token_is_removed_fails() { client.add_payment_token(&payment_token); soroban_sdk::token::StellarAssetClient::new(&e, &payment_token).mint(&buyer, &10_000); + set_mock_nft(&e, &client, 1, &seller, true); client.list_nft(&seller, &1, &1000, &payment_token); client.remove_payment_token(&payment_token); client.buy_nft(&buyer, &1); diff --git a/docs/MARKETPLACE_LISTING_LIFECYCLE.md b/docs/MARKETPLACE_LISTING_LIFECYCLE.md index ae51ce6..c6f3dca 100644 --- a/docs/MARKETPLACE_LISTING_LIFECYCLE.md +++ b/docs/MARKETPLACE_LISTING_LIFECYCLE.md @@ -46,7 +46,7 @@ Unlisted ──────────▶ Listed ────────── - **Auth**: `seller.require_auth()` - **Reentrancy guard**: yes -- **Preconditions**: `price > 0`, no existing listing for `token_id` +- **Preconditions**: `price > 0`, no existing listing for `token_id`, `seller` owns an active token in the configured `commitment_nft` contract - **Effects**: stores `Listing`, appends `token_id` to `ActiveListings` - **Event**: `("ListNFT", token_id) → (seller, price, payment_token)` @@ -94,7 +94,7 @@ Any address ──────────────▶ Offer stored (per toke - **Auth**: `offerer.require_auth()` - **Reentrancy guard**: yes -- **Preconditions**: `amount > 0`, no existing offer from `offerer` for this token +- **Preconditions**: `amount > 0`, no existing offer from `offerer` for this token, existing listing/auction seller still owns an active token when one exists - **Effect**: appends `Offer` to `DataKey::Offers(token_id)` - **Event**: `("OfferMade", token_id) → (offerer, amount, payment_token)` @@ -161,7 +161,7 @@ Seller ──────────────────────── - **Auth**: `seller.require_auth()` - **Reentrancy guard**: yes -- **Preconditions**: `starting_price > 0`, `duration_seconds > 0`, no existing auction +- **Preconditions**: `starting_price > 0`, `duration_seconds > 0`, no existing auction, `seller` owns an active token in the configured `commitment_nft` contract - **Event**: `("AucStart", token_id) → (seller, starting_price, ends_at)` #### `place_bid(bidder, token_id, bid_amount)` @@ -201,13 +201,13 @@ Seller ──────────────────────── | 1 | `NotInitialized` | Any call before `initialize` | | 2 | `AlreadyInitialized` | Second call to `initialize` | | 3 | `ListingNotFound` | `cancel_listing`, `buy_nft`, `get_listing` with unknown token | -| 4 | `NotSeller` | `cancel_listing` by non-owner | -| 5 | `NFTNotActive` | Reserved | +| 4 | `NotSeller` | `cancel_listing` by non-lister; listing/auction creation when seller does not own the NFT | +| 5 | `NFTNotActive` | Listing/auction creation when NFT is inactive | | 6 | `InvalidPrice` | `list_nft` / `start_auction` with `price ≤ 0` | | 7 | `ListingExists` | `list_nft` twice; `start_auction` twice | | 8 | `CannotBuyOwnListing` | `buy_nft` / `place_bid` by seller | | 9 | `InsufficientPayment` | Reserved | -| 10 | `NFTContractError` | Reserved | +| 10 | `NFTContractError` | Configured NFT contract ownership/activity lookup failed | | 11 | `OfferNotFound` | `cancel_offer`, `accept_offer` with unknown offerer | | 12 | `InvalidOfferAmount` | `make_offer` with `amount ≤ 0` | | 13 | `OfferExists` | `make_offer` when offerer already has an open offer | diff --git a/docs/SECURITY_CONSIDERATIONS.md b/docs/SECURITY_CONSIDERATIONS.md index 2fdea3d..6ed9a61 100644 --- a/docs/SECURITY_CONSIDERATIONS.md +++ b/docs/SECURITY_CONSIDERATIONS.md @@ -36,6 +36,7 @@ ## Cross-contract calls - commitment_core calls token contracts for transfers and commitment_nft for mint/settle. +- commitment_marketplace validates listing and auction sellers with `commitment_nft::owner_of` and `is_active` before storing listings, auctions, or offers against existing marketplace positions. - attestation_engine invokes commitment_core to read commitments. - The commitment_core mint call does not include the `early_exit_penalty` parameter expected by commitment_nft::mint. This must be reconciled before audit. - The core/NFT/attestation call graph review is documented in `docs/CORE_NFT_ATTESTATION_THREAT_REVIEW.md`.