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
// ---------------------------------------------------------------------------