diff --git a/contracts/nft-reward/src/lib.rs b/contracts/nft-reward/src/lib.rs index a2f1d35..9acaa39 100644 --- a/contracts/nft-reward/src/lib.rs +++ b/contracts/nft-reward/src/lib.rs @@ -8,7 +8,7 @@ const MAX_URI_LEN: usize = 512; const MAX_NFT_TITLE_BYTES: u32 = 128; const MAX_NFT_DESCRIPTION_BYTES: u32 = 1024; const MAX_NFT_URI_BYTES: u32 = 512; -const MAX_SCAN_LIMIT: u32 = 1000; +const MAX_TRANSFER_HISTORY: u32 = 20; /// Core display metadata for an NFT (title, description, image URI). /// Supports off-chain storage references to keep gas costs low. @@ -41,6 +41,17 @@ pub struct MintParams { pub metadata: NftMetadata, } +/// A single transfer record in an NFT's transfer history. +/// `from` is `None` for the initial mint (origination), and +/// `Some(previous_owner)` for subsequent transfers. +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TransferRecord { + pub from: Option
, + pub to: Address, + pub timestamp: u64, +} + fn image_uri_is_valid(uri: &String) -> bool { let len = uri.len(); if len == 0 || len > 200 { @@ -597,6 +608,16 @@ impl NftReward { Storage::add_nft_to_hunt(&env, hunt_id, nft_id); Storage::mark_hunt_minted(&env, hunt_id); + Storage::save_transfer_record( + &env, + nft_id, + &TransferRecord { + from: None, + to: player_address.clone(), + timestamp: minted_at, + }, + ); + let event = NftMintedEvent { nft_id, hunt_id, @@ -639,6 +660,37 @@ impl NftReward { }) } + /// Returns the transfer history for an NFT. + /// + /// The history is bounded to the last `MAX_TRANSFER_HISTORY` records. + /// The first record (index 0) represents the mint (origination), with + /// `from = None`. Subsequent records represent transfers. + /// + /// # Arguments + /// * `nft_id` - The NFT to query + /// + /// # Returns + /// A Vec of TransferRecords, oldest first + pub fn get_nft_transfer_history(env: Env, nft_id: u64) -> Vec { + Storage::get_transfer_history(&env, nft_id) + } + + /// Returns the configured admin address, if set. + pub fn get_admin(env: Env) -> Option
{ + Storage::get_admin(&env) + } + + /// Sets the RewardManager contract address. Only the admin can call this. + pub fn set_reward_manager( + env: Env, + admin: Address, + reward_manager: Address, + ) -> Result<(), crate::errors::NftErrorCode> { + Self::require_admin(&env, &admin)?; + Storage::set_reward_manager(&env, &reward_manager); + Ok(()) + } + /// Batch-updates image URIs for all NFTs whose `image_uri` starts with `old_prefix`, /// replacing it with `new_prefix`. Useful for migrating between IPFS gateways or CDNs. /// @@ -970,8 +1022,15 @@ impl NftReward { Storage::save_nft(&env, &nft); Storage::add_nft_to_owner(&env, &to_address, nft_id); - // Clear approval after successful transfer - Storage::clear_approval(&env, nft_id); + Storage::save_transfer_record( + &env, + nft_id, + &TransferRecord { + from: Some(from_address.clone()), + to: to_address.clone(), + timestamp: env.ledger().timestamp(), + }, + ); env.events().publish( (Symbol::new(&env, "NftTransferred"), nft_id), diff --git a/contracts/nft-reward/src/storage.rs b/contracts/nft-reward/src/storage.rs index dd1079f..9740c04 100644 --- a/contracts/nft-reward/src/storage.rs +++ b/contracts/nft-reward/src/storage.rs @@ -1,4 +1,4 @@ -use crate::NftData; +use crate::{NftData, TransferRecord}; use soroban_sdk::{symbol_short, Address, Env, Vec}; /// Storage layer for NFTs. @@ -20,6 +20,7 @@ impl Storage { const TOTAL_OWNERS_KEY: soroban_sdk::Symbol = symbol_short!("TO"); const ALL_NFTS_KEY: soroban_sdk::Symbol = symbol_short!("AN"); const CONTRACT_VERSION_KEY: soroban_sdk::Symbol = symbol_short!("CV"); + const TRANSFER_HISTORY_KEY: soroban_sdk::Symbol = symbol_short!("THST"); fn nft_key(nft_id: u64) -> (soroban_sdk::Symbol, u64) { (Self::NFT_KEY, nft_id) @@ -427,4 +428,38 @@ impl Storage { pub fn get_contract_version(env: &Env) -> Option { env.storage().instance().get(&Self::CONTRACT_VERSION_KEY) } + + fn transfer_history_key(nft_id: u64) -> (soroban_sdk::Symbol, u64) { + (Self::TRANSFER_HISTORY_KEY, nft_id) + } + + pub fn save_transfer_record(env: &Env, nft_id: u64, record: &TransferRecord) { + let key = Self::transfer_history_key(nft_id); + let mut history: Vec = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| Vec::new(env)); + + if history.len() >= crate::MAX_TRANSFER_HISTORY { + let mut trimmed: Vec = Vec::new(env); + let len = history.len(); + for i in 1..len { + if let Some(r) = history.get(i) { + trimmed.push_back(r); + } + } + history = trimmed; + } + history.push_back(record.clone()); + env.storage().persistent().set(&key, &history); + } + + pub fn get_transfer_history(env: &Env, nft_id: u64) -> Vec { + let key = Self::transfer_history_key(nft_id); + env.storage() + .persistent() + .get(&key) + .unwrap_or_else(|| Vec::new(env)) + } } diff --git a/contracts/nft-reward/src/test.rs b/contracts/nft-reward/src/test.rs index fb220cb..bf512b4 100644 --- a/contracts/nft-reward/src/test.rs +++ b/contracts/nft-reward/src/test.rs @@ -1,7 +1,10 @@ #![cfg(test)] extern crate std; -use crate::{MintParams, NftMetadata, NftMintedEvent, NftReward, NftRewardClient, METADATA_SCHEMA_VERSION}; +use crate::{ + MintParams, NftMetadata, NftMintedEvent, NftReward, NftRewardClient, TransferRecord, + METADATA_SCHEMA_VERSION, +}; use soroban_sdk::{ testutils::{Address as _, Events as _, Ledger as _}, Address, Env, IntoVal, Map, String, Symbol, Val, TryFromVal, TryIntoVal, @@ -1887,6 +1890,137 @@ fn test_transfer_emits_nft_transferred_event_with_correct_fields() { assert_eq!(event.to, to); } +// --------------------------------------------------------------------------- +// Transfer history tests +// --------------------------------------------------------------------------- + +#[test] +fn test_mint_records_first_transfer_record() { + let env = setup_env(); + let (client, minter) = setup_nft_reward(&env, None); + + let player = Address::generate(&env); + let metadata = create_metadata(&env, "First", "desc", "ipfs://first"); + + let nft_id = client.mint_reward_nft(&minter, &1, &player, &metadata); + + let history = client.get_nft_transfer_history(&nft_id); + assert_eq!(history.len(), 1); + + let record: TransferRecord = history.get(0).unwrap(); + assert_eq!(record.from, None); + assert_eq!(record.to, player); + assert_eq!(record.timestamp, 1000); +} + +#[test] +fn test_transfer_adds_transfer_record() { + let env = setup_env(); + let (client, _) = setup_nft_reward(&env, None); + let admin = client.get_admin().unwrap(); + let reward_manager = Address::generate(&env); + client.set_reward_manager(&admin, &reward_manager); + + let from = Address::generate(&env); + let to = Address::generate(&env); + let metadata = create_metadata(&env, "Transfer Me", "desc", "ipfs://tfr"); + let nft_id = mint_transferable(&env, &client, 1, &from, &metadata); + + env.ledger().set_timestamp(2000); + client.transfer_nft(&nft_id, &from, &to, &from); + + let history = client.get_nft_transfer_history(&nft_id); + assert_eq!(history.len(), 2); + + // Record 0: mint + let mint_record: TransferRecord = history.get(0).unwrap(); + assert_eq!(mint_record.from, None); + assert_eq!(mint_record.to, from); + assert_eq!(mint_record.timestamp, 1000); + + // Record 1: transfer + let transfer_record: TransferRecord = history.get(1).unwrap(); + assert_eq!(transfer_record.from, Some(from.clone())); + assert_eq!(transfer_record.to, to); + assert_eq!(transfer_record.timestamp, 2000); +} + +#[test] +fn test_chained_transfers_accumulate_history() { + let env = setup_env(); + let (client, _) = setup_nft_reward(&env, None); + let admin = client.get_admin().unwrap(); + let reward_manager = Address::generate(&env); + client.set_reward_manager(&admin, &reward_manager); + + let alice = Address::generate(&env); + let bob = Address::generate(&env); + let carol = Address::generate(&env); + let metadata = create_metadata(&env, "Chain", "desc", "ipfs://chain"); + let nft_id = mint_transferable(&env, &client, 1, &alice, &metadata); + + env.ledger().set_timestamp(2000); + client.transfer_nft(&nft_id, &alice, &bob, &alice); + + env.ledger().set_timestamp(3000); + client.transfer_nft(&nft_id, &bob, &carol, &bob); + + let history = client.get_nft_transfer_history(&nft_id); + assert_eq!(history.len(), 3); + + assert_eq!(history.get(0).unwrap().from, None); + assert_eq!(history.get(0).unwrap().to, alice); + assert_eq!(history.get(0).unwrap().timestamp, 1000); + + assert_eq!(history.get(1).unwrap().from, Some(alice)); + assert_eq!(history.get(1).unwrap().to, bob); + assert_eq!(history.get(1).unwrap().timestamp, 2000); + + assert_eq!(history.get(2).unwrap().from, Some(bob)); + assert_eq!(history.get(2).unwrap().to, carol); + assert_eq!(history.get(2).unwrap().timestamp, 3000); +} + +#[test] +fn test_transfer_history_bounded_storage() { + let env = setup_env(); + let (client, _) = setup_nft_reward(&env, None); + let admin = client.get_admin().unwrap(); + let reward_manager = Address::generate(&env); + client.set_reward_manager(&admin, &reward_manager); + + let owner = Address::generate(&env); + let metadata = create_metadata(&env, "Bounded", "desc", "ipfs://bounded"); + let nft_id = mint_transferable(&env, &client, 1, &owner, &metadata); + + // Mint is record 0. Perform many transfers to exceed MAX_TRANSFER_HISTORY. + for i in 0..25 { + let recipient = Address::generate(&env); + env.ledger().set_timestamp(2000 + i as u64); + client.transfer_nft(&nft_id, &owner, &recipient, &owner); + // Transfer back to owner for next iteration + env.ledger().set_timestamp(3000 + i as u64); + client.transfer_nft(&nft_id, &recipient, &owner, &recipient); + } + + let history = client.get_nft_transfer_history(&nft_id); + // Should be bounded at MAX_TRANSFER_HISTORY (20) — 1 mint + 19 most recent transfers + assert_eq!(history.len(), 20); + + // First record should no longer be the mint + let first_record = history.get(0).unwrap(); + assert!(first_record.from.is_some()); +} + +#[test] +fn test_transfer_history_empty_for_nonexistent_nft() { + let env = setup_env(); + let client = NftRewardClient::new(&env, &env.register(NftReward, ())); + + let history = client.get_nft_transfer_history(&999); + assert_eq!(history.len(), 0); +} + // --------------------------------------------------------------------------- // admin_update_image_uris tests // ---------------------------------------------------------------------------