Skip to content
Closed
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
88 changes: 88 additions & 0 deletions contracts/commitment_nft/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}

// ============================================================================
Expand Down Expand Up @@ -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),
}
Expand Down Expand Up @@ -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<u32> = Vec::new(&e);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
100 changes: 100 additions & 0 deletions contracts/commitment_nft/src/smoke_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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());
}
13 changes: 13 additions & 0 deletions docs/COMMITMENT_NFT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32, ContractError>`
- 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<CommitmentNFT, ContractError>`
- Returns the stored `CommitmentNFT`.

Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/commitment_marketplace.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading