Skip to content
Merged
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
65 changes: 62 additions & 3 deletions contracts/nft-reward/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
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.
Expand Down Expand Up @@ -41,6 +41,17 @@
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<Address>,
pub to: Address,
pub timestamp: u64,
}

fn image_uri_is_valid(uri: &String) -> bool {
let len = uri.len();
if len == 0 || len > 200 {
Expand Down Expand Up @@ -169,7 +180,7 @@
/// Contract version constant.
pub const CONTRACT_VERSION: u32 = 2;

#[contractimpl]

Check failure on line 183 in contracts/nft-reward/src/lib.rs

View workflow job for this annotation

GitHub Actions / Clippy

duplicate definitions with name `spec_xdr_get_admin`

Check failure on line 183 in contracts/nft-reward/src/lib.rs

View workflow job for this annotation

GitHub Actions / Clippy

the name `__SPEC_XDR_FN_SET_REWARD_MANAGER` is defined multiple times

Check failure on line 183 in contracts/nft-reward/src/lib.rs

View workflow job for this annotation

GitHub Actions / Clippy

the name `__SPEC_XDR_FN_GET_ADMIN` is defined multiple times

Check failure on line 183 in contracts/nft-reward/src/lib.rs

View workflow job for this annotation

GitHub Actions / Clippy

the name `__set_reward_manager` is defined multiple times

Check failure on line 183 in contracts/nft-reward/src/lib.rs

View workflow job for this annotation

GitHub Actions / Clippy

the name `__get_admin` is defined multiple times

Check failure on line 183 in contracts/nft-reward/src/lib.rs

View workflow job for this annotation

GitHub Actions / Test

duplicate definitions with name `spec_xdr_set_reward_manager`

Check failure on line 183 in contracts/nft-reward/src/lib.rs

View workflow job for this annotation

GitHub Actions / Test

duplicate definitions with name `spec_xdr_get_admin`

Check failure on line 183 in contracts/nft-reward/src/lib.rs

View workflow job for this annotation

GitHub Actions / Test

the name `__SPEC_XDR_FN_SET_REWARD_MANAGER` is defined multiple times

Check failure on line 183 in contracts/nft-reward/src/lib.rs

View workflow job for this annotation

GitHub Actions / Test

the name `__SPEC_XDR_FN_GET_ADMIN` is defined multiple times

Check failure on line 183 in contracts/nft-reward/src/lib.rs

View workflow job for this annotation

GitHub Actions / Test

the name `__set_reward_manager` is defined multiple times

Check failure on line 183 in contracts/nft-reward/src/lib.rs

View workflow job for this annotation

GitHub Actions / Test

the name `__get_admin` is defined multiple times
impl NftReward {
/// Initializes the NFT reward contract with an admin, minter, and optional max supply cap.
pub fn initialize(
Expand Down Expand Up @@ -597,6 +608,16 @@
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,
Expand Down Expand Up @@ -639,6 +660,37 @@
})
}

/// 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<TransferRecord> {
Storage::get_transfer_history(&env, nft_id)
}

/// Returns the configured admin address, if set.
pub fn get_admin(env: Env) -> Option<Address> {

Check failure on line 679 in contracts/nft-reward/src/lib.rs

View workflow job for this annotation

GitHub Actions / Clippy

duplicate definitions with name `get_admin`

Check failure on line 679 in contracts/nft-reward/src/lib.rs

View workflow job for this annotation

GitHub Actions / Test

duplicate definitions with name `get_admin`
Storage::get_admin(&env)
}

/// Sets the RewardManager contract address. Only the admin can call this.
pub fn set_reward_manager(

Check failure on line 684 in contracts/nft-reward/src/lib.rs

View workflow job for this annotation

GitHub Actions / Clippy

duplicate definitions with name `set_reward_manager`

Check failure on line 684 in contracts/nft-reward/src/lib.rs

View workflow job for this annotation

GitHub Actions / Test

duplicate definitions with name `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.
///
Expand Down Expand Up @@ -732,7 +784,7 @@
}

// Apply bounded scan limit to prevent excessive gas consumption
let bounded_limit = limit.min(MAX_SCAN_LIMIT);

Check failure on line 787 in contracts/nft-reward/src/lib.rs

View workflow job for this annotation

GitHub Actions / Clippy

cannot find value `MAX_SCAN_LIMIT` in this scope

Check failure on line 787 in contracts/nft-reward/src/lib.rs

View workflow job for this annotation

GitHub Actions / Test

cannot find value `MAX_SCAN_LIMIT` in this scope
let end = offset.saturating_add(bounded_limit).min(total_count);

let mut result = Vec::new(&env);
Expand Down Expand Up @@ -814,7 +866,7 @@
}

/// Searches NFTs by title (case-insensitive partial match).
/// Returns a vector of NFT IDs whose titles contain the search query.

Check failure on line 869 in contracts/nft-reward/src/lib.rs

View workflow job for this annotation

GitHub Actions / Clippy

empty line after outer attribute

pub fn search_by_title(env: Env, _query: String) -> Vec<u64> {
Vec::new(&env)
Expand Down Expand Up @@ -970,8 +1022,15 @@
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),
Expand Down
37 changes: 36 additions & 1 deletion contracts/nft-reward/src/storage.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::NftData;
use crate::{NftData, TransferRecord};
use soroban_sdk::{symbol_short, Address, Env, Vec};

/// Storage layer for NFTs.
Expand All @@ -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)
Expand Down Expand Up @@ -427,4 +428,38 @@ impl Storage {
pub fn get_contract_version(env: &Env) -> Option<u32> {
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<TransferRecord> = env
.storage()
.persistent()
.get(&key)
.unwrap_or_else(|| Vec::new(env));

if history.len() >= crate::MAX_TRANSFER_HISTORY {
let mut trimmed: Vec<TransferRecord> = 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<TransferRecord> {
let key = Self::transfer_history_key(nft_id);
env.storage()
.persistent()
.get(&key)
.unwrap_or_else(|| Vec::new(env))
}
}
136 changes: 135 additions & 1 deletion contracts/nft-reward/src/test.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
Loading