diff --git a/contracts/commitment_nft/src/lib.rs b/contracts/commitment_nft/src/lib.rs index cdafc97..7f71e2e 100644 --- a/contracts/commitment_nft/src/lib.rs +++ b/contracts/commitment_nft/src/lib.rs @@ -54,6 +54,8 @@ pub const CURRENT_VERSION: u32 = 2; // Issue #139: String parameter constraints #[allow(dead_code)] const MAX_COMMITMENT_ID_LENGTH: u32 = 256; +const MAX_ROYALTY_BPS: u32 = 1_000; +const BPS_DENOMINATOR: i128 = 10_000; // ============================================================================ // Error Types @@ -108,6 +110,10 @@ pub enum ContractError { InvalidCommitmentId = 21, /// Given address is a zero/invalid address InvalidAddress = 22, + /// Royalty bps exceeds the configured cap + RoyaltyTooHigh = 23, + /// Royalty calculation overflowed + ArithmeticOverflow = 24, } // ============================================================================ @@ -200,6 +206,10 @@ pub enum DataKey { ReentrancyGuard, /// Contract version Version, + /// Royalty rate in basis points + RoyaltyBps, + /// Royalty recipient address + RoyaltyRecipient, /// Mapping from commitment_id to token_id for reverse lookup (commitment_id -> token_id) CommitmentIdIndex(String), } @@ -274,6 +284,10 @@ impl CommitmentNFTContract { e.storage().instance().set(&DataKey::Admin, &admin); e.storage().instance().set(&DataKey::TokenCounter, &0u32); + e.storage().instance().set(&DataKey::RoyaltyBps, &0u32); + e.storage() + .instance() + .set(&DataKey::RoyaltyRecipient, &admin); // Initialize empty token IDs vector (persistent storage for scalability) let token_ids: Vec = Vec::new(&e); @@ -587,6 +601,67 @@ impl CommitmentNFTContract { Ok(()) } + /// Configure secondary-sale royalty information (admin-only). + /// + /// `royalty_bps` is capped at 1,000 bps (10%). A zero rate is allowed and + /// makes `royalty_info` return amount `0` while preserving the recipient. + pub fn set_royalty( + e: Env, + caller: Address, + royalty_recipient: Address, + royalty_bps: u32, + ) -> Result<(), ContractError> { + require_admin(&e, &caller)?; + if is_zero_address(&e, &royalty_recipient) { + return Err(ContractError::InvalidAddress); + } + if royalty_bps > MAX_ROYALTY_BPS { + return Err(ContractError::RoyaltyTooHigh); + } + + e.storage() + .instance() + .set(&DataKey::RoyaltyRecipient, &royalty_recipient); + e.storage().instance().set(&DataKey::RoyaltyBps, &royalty_bps); + Ok(()) + } + + /// Return the EIP-2981-style royalty recipient and amount for a sale. + /// + /// The token must exist. The amount is `floor(sale_price * royalty_bps / 10_000)`. + pub fn royalty_info( + e: Env, + token_id: u32, + sale_price: i128, + ) -> Result<(Address, i128), ContractError> { + if sale_price < 0 { + return Err(ContractError::InvalidAmount); + } + + let _: CommitmentNFT = e + .storage() + .persistent() + .get(&DataKey::NFT(token_id)) + .ok_or(ContractError::TokenNotFound)?; + + let recipient: Address = e + .storage() + .instance() + .get(&DataKey::RoyaltyRecipient) + .ok_or(ContractError::NotInitialized)?; + let royalty_bps: u32 = e + .storage() + .instance() + .get(&DataKey::RoyaltyBps) + .unwrap_or(0); + + let amount = sale_price + .checked_mul(royalty_bps as i128) + .and_then(|v| v.checked_div(BPS_DENOMINATOR)) + .ok_or(ContractError::ArithmeticOverflow)?; + Ok((recipient, amount)) + } + /// Upgrade contract WASM (admin-only). pub fn upgrade( e: Env, @@ -624,6 +699,19 @@ impl CommitmentNFTContract { .instance() .set(&DataKey::ReentrancyGuard, &false); } + if !e.storage().instance().has(&DataKey::RoyaltyBps) { + e.storage().instance().set(&DataKey::RoyaltyBps, &0u32); + } + if !e.storage().instance().has(&DataKey::RoyaltyRecipient) { + let admin: Address = e + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(ContractError::NotInitialized)?; + e.storage() + .instance() + .set(&DataKey::RoyaltyRecipient, &admin); + } e.storage() .instance() diff --git a/contracts/commitment_nft/src/smoke_tests.rs b/contracts/commitment_nft/src/smoke_tests.rs index db52bdd..4d65253 100644 --- a/contracts/commitment_nft/src/smoke_tests.rs +++ b/contracts/commitment_nft/src/smoke_tests.rs @@ -15,6 +15,27 @@ fn setup_contract(e: &Env) -> (Address, CommitmentNFTContractClient<'_>) { (admin, client) } +fn mint_test_nft( + e: &Env, + client: &CommitmentNFTContractClient, + caller: &Address, + owner: &Address, + asset_address: &Address, + label: &str, +) -> u32 { + client.mint( + caller, + owner, + &String::from_str(e, label), + &1, + &10, + &String::from_str(e, "safe"), + &1_000, + asset_address, + &5, + ) +} + #[test] fn test_initialize_sets_admin_and_zero_supply() { let e = Env::default(); @@ -58,3 +79,82 @@ fn test_mint_and_settle_as_core_updates_supply_and_activity() { assert!(!client.is_active(&token_id)); assert_eq!(client.total_supply(), 1); } + +#[test] +fn test_royalty_info_default_zero_rate() { + let e = Env::default(); + let (admin, client) = setup_contract(&e); + let owner = Address::generate(&e); + let asset_address = Address::generate(&e); + let token_id = mint_test_nft( + &e, + &client, + &admin, + &owner, + &asset_address, + "royalty_default", + ); + + let (recipient, amount) = client.royalty_info(&token_id, &10_000); + + assert_eq!(recipient, admin); + assert_eq!(amount, 0); +} + +#[test] +fn test_set_royalty_at_cap_and_royalty_info_rounds_down() { + let e = Env::default(); + let (admin, client) = setup_contract(&e); + let owner = Address::generate(&e); + let recipient = Address::generate(&e); + let asset_address = Address::generate(&e); + + client.set_royalty(&admin, &recipient, &1_000); + let token_id = mint_test_nft(&e, &client, &admin, &owner, &asset_address, "royalty_cap"); + + let (royalty_recipient, amount) = client.royalty_info(&token_id, &12_345); + + assert_eq!(royalty_recipient, recipient); + assert_eq!(amount, 1_234); +} + +#[test] +fn test_set_royalty_above_cap_rejected() { + let e = Env::default(); + let (admin, client) = setup_contract(&e); + let recipient = Address::generate(&e); + + let result = client.try_set_royalty(&admin, &recipient, &1_001); + + assert!(result.is_err()); +} + +#[test] +fn test_royalty_info_nonexistent_token_rejected() { + let e = Env::default(); + let (_admin, client) = setup_contract(&e); + + let result = client.try_royalty_info(&999, &10_000); + + assert!(result.is_err()); +} + +#[test] +fn test_royalty_info_negative_sale_price_rejected() { + let e = Env::default(); + let (admin, client) = setup_contract(&e); + let owner = Address::generate(&e); + let asset_address = Address::generate(&e); + let token_id = mint_test_nft( + &e, + &client, + &admin, + &owner, + &asset_address, + "royalty_negative", + ); + + let result = client.try_royalty_info(&token_id, &-1); + + assert!(result.is_err()); +} diff --git a/docs/COMMITMENT_NFT.md b/docs/COMMITMENT_NFT.md index 5800b62..410269f 100644 --- a/docs/COMMITMENT_NFT.md +++ b/docs/COMMITMENT_NFT.md @@ -18,10 +18,18 @@ The `commitment_nft` contract mints and manages commitment NFTs that represent l - `add_authorized_contract(caller: Address, contract_address: Address) -> Result<(), ContractError>` - Admin-only. Adds a minter to the whitelist so it may call `mint`. +- `set_royalty(caller: Address, royalty_recipient: Address, royalty_bps: u32) -> Result<(), ContractError>` + - Admin-only. Sets the secondary-sale royalty recipient and rate. + - Notes: `royalty_bps` is capped at 1,000 bps (10%). A zero rate is allowed. + - `mint(caller: Address, owner: Address, _commitment_id: String, duration_days: u32, max_loss_percent: u32, commitment_type: String, initial_amount: i128, asset_address: Address, early_exit_penalty: u32) -> Result` - Creates a new `CommitmentNFT` and returns the minted `token_id`. - Notes: user-supplied `commitment_id` is ignored; the contract auto-generates `COMMIT_{token_id}` and indexes it for reverse lookup. +- `royalty_info(token_id: u32, sale_price: i128) -> Result<(Address, i128), ContractError>` + - Returns the royalty recipient and `floor(sale_price * royalty_bps / 10_000)`. + - Errors: `TokenNotFound` if the token does not exist; `InvalidAmount` if `sale_price` is negative. + - `get_metadata(token_id: u32) -> Result` - Returns the stored `CommitmentNFT`. @@ -35,6 +43,8 @@ The `commitment_nft` contract mints and manages commitment NFTs that represent l - `commitment_type` must be one of: `"safe"`, `"balanced"`, `"aggressive"`. Otherwise `InvalidCommitmentType`. - `initial_amount` must be > 0 (signed i128). Otherwise `InvalidAmount`. - `owner` must not be the "zero" Stellar address (contract uses canonical zero address string). Otherwise `TransferToZeroAddress`. +- `royalty_bps` must be <= 1,000. Otherwise `RoyaltyTooHigh`. +- `sale_price` passed to `royalty_info` must be >= 0. Otherwise `InvalidAmount`. - Expiration timestamp calculation checks for overflow and returns `ExpirationOverflow` if duration math overflows u64. Note: There is no global supply cap implemented in the contract. If a supply or per-owner cap is required, the integrator should request an enhancement; tests will be added to reflect the chosen policy once implemented. @@ -69,6 +79,8 @@ See `ContractError` enum in `contracts/commitment_nft/src/lib.rs` for full list. - `ReentrancyDetected` = 14 - `ExpirationOverflow` = 20 - `TransferToZeroAddress` = 18 +- `RoyaltyTooHigh` = 23 +- `ArithmeticOverflow` = 24 ## Testing notes @@ -82,6 +94,7 @@ cargo test -p commitment_nft --target wasm32v1-none --release - If the wasm target is not installed, tests will fail to compile (error: can't find crate for `core`). - The test suite includes checks for parameter validation, events, reverse lookup, and reentrancy guard behavior. +- Royalty tests cover the default zero-rate configuration, the 10% cap, floor rounding, missing tokens, and negative sale prices. ## Integration recommendation diff --git a/docs/commitment_marketplace.md b/docs/commitment_marketplace.md index 7cbb25d..4a57dff 100644 --- a/docs/commitment_marketplace.md +++ b/docs/commitment_marketplace.md @@ -38,6 +38,7 @@ This page documents the public entry points, access control, and security notes ## Integrator Notes - Integrators should expect deterministic errors for all invalid operations. - All public APIs are documented with Rustdoc/NatSpec comments in the contract source. +- Marketplace settlement can call the NFT contract's `royalty_info(token_id, sale_price)` before fee and seller proceeds are finalized. The returned amount should be paid to the royalty recipient, with the remaining proceeds split according to the marketplace fee rules. - See also: `docs/CONTRACT_FUNCTIONS.md` for cross-contract summary. ## Changelog