diff --git a/contracts/shade/src/components/mod.rs b/contracts/shade/src/components/mod.rs index ae0c216..a5b1c93 100644 --- a/contracts/shade/src/components/mod.rs +++ b/contracts/shade/src/components/mod.rs @@ -1,4 +1,4 @@ -pub mod access_control; +pub mod access_control; pub mod account_factory; pub mod admin; pub mod auto_withdrawal; @@ -14,3 +14,4 @@ pub mod history; pub mod upgrade; pub mod backer_rewards; pub mod event; +pub mod nft; diff --git a/contracts/shade/src/components/nft.rs b/contracts/shade/src/components/nft.rs new file mode 100644 index 0000000..f12bc1e --- /dev/null +++ b/contracts/shade/src/components/nft.rs @@ -0,0 +1,135 @@ +use crate::components::merchant; +use crate::errors::ContractError; +use crate::events; +use crate::types::{DataKey, Merchant, Nft, NftCollection, NftStatus}; +use soroban_sdk::{panic_with_error, Address, Env, String, Vec}; + +const MAX_BPS: u32 = 10_000; + +pub fn create_nft_collection(env: &Env, merchant_addr: &Address, name: &String, base_uri: &String, max_supply: u64, royalty_bps: u32) -> u64 { + merchant_addr.require_auth(); + if royalty_bps > MAX_BPS { panic_with_error!(env, ContractError::NftError); } + if base_uri.len() == 0 { panic_with_error!(env, ContractError::NftError); } + let merchant_id = merchant::get_merchant_id(env, merchant_addr); + let merchant_record: Merchant = env.storage().persistent().get(&DataKey::Merchant(merchant_id)) + .unwrap_or_else(|| panic_with_error!(env, ContractError::MerchantNotFound)); + if !merchant_record.active { panic_with_error!(env, ContractError::MerchantNotActive); } + let id: u64 = env.storage().persistent().get(&DataKey::NftCollectionCount).unwrap_or(0u64) + 1; + let collection = NftCollection { id, merchant_id, merchant: merchant_addr.clone(), name: name.clone(), base_uri: base_uri.clone(), max_supply, minted: 0, royalty_bps, active: true, created_at: env.ledger().timestamp() }; + env.storage().persistent().set(&DataKey::NftCollection(id), &collection); + env.storage().persistent().set(&DataKey::NftCollectionCount, &id); + env.storage().persistent().set(&DataKey::CollectionNfts(id), &Vec::::new(env)); + events::publish_nft_collection_created_event(env, id, merchant_id, merchant_addr.clone(), name.clone(), base_uri.clone(), max_supply, royalty_bps, env.ledger().timestamp()); + id +} + +pub fn mint_nft(env: &Env, merchant_addr: &Address, collection_id: u64, recipient: &Address, token_uri: &String) -> u64 { + merchant_addr.require_auth(); + if token_uri.len() == 0 { panic_with_error!(env, ContractError::NftError); } + let mut collection: NftCollection = env.storage().persistent().get::<_, NftCollection>(&DataKey::NftCollection(collection_id)) + .unwrap_or_else(|| panic_with_error!(env, ContractError::NftError)); + let merchant_id = merchant::get_merchant_id(env, merchant_addr); + if collection.merchant_id != merchant_id { panic_with_error!(env, ContractError::NotAuthorized); } + if !collection.active { panic_with_error!(env, ContractError::NftError); } + if collection.max_supply > 0 && collection.minted >= collection.max_supply { panic_with_error!(env, ContractError::NftError); } + let nft_id: u64 = env.storage().persistent().get(&DataKey::NftCount).unwrap_or(0u64) + 1; + let nft = Nft { id: nft_id, collection_id, owner: recipient.clone(), uri: token_uri.clone(), status: NftStatus::Active, minted_at: env.ledger().timestamp(), recipient: recipient.clone() }; + collection.minted += 1; + env.storage().persistent().set(&DataKey::Nft(nft_id), &nft); + env.storage().persistent().set(&DataKey::NftCount, &nft_id); + env.storage().persistent().set(&DataKey::NftCollection(collection_id), &collection); + let mut col_nfts: Vec = env.storage().persistent().get(&DataKey::CollectionNfts(collection_id)).unwrap_or_else(|| Vec::new(env)); + col_nfts.push_back(nft_id); + env.storage().persistent().set(&DataKey::CollectionNfts(collection_id), &col_nfts); + let mut user_nfts: Vec = env.storage().persistent().get(&DataKey::UserNfts(recipient.clone())).unwrap_or_else(|| Vec::new(env)); + user_nfts.push_back(nft_id); + env.storage().persistent().set(&DataKey::UserNfts(recipient.clone()), &user_nfts); + events::publish_nft_minted_event(env, nft_id, collection_id, merchant_id, recipient.clone(), token_uri.clone(), env.ledger().timestamp()); + nft_id +} + +pub fn batch_mint_nfts(env: &Env, merchant_addr: &Address, collection_id: u64, recipients: &Vec
, token_uris: &Vec) -> Vec { + merchant_addr.require_auth(); + if recipients.len() != token_uris.len() { panic_with_error!(env, ContractError::InvalidAmount); } + let mut minted_ids: Vec = Vec::new(env); + let count = recipients.len() as u32; + for i in 0..count { + let id = mint_nft(env, merchant_addr, collection_id, &recipients.get(i).unwrap(), &token_uris.get(i).unwrap()); + minted_ids.push_back(id); + } + let merchant_id = merchant::get_merchant_id(env, merchant_addr); + events::publish_nft_batch_minted_event(env, collection_id, merchant_id, count, env.ledger().timestamp()); + minted_ids +} + +pub fn transfer_nft(env: &Env, from: &Address, to: &Address, nft_id: u64) { + from.require_auth(); + let mut nft: Nft = env.storage().persistent().get::<_, Nft>(&DataKey::Nft(nft_id)) + .unwrap_or_else(|| panic_with_error!(env, ContractError::NftError)); + if nft.owner != *from { panic_with_error!(env, ContractError::NftError); } + if nft.status == NftStatus::Burned { panic_with_error!(env, ContractError::NftError); } + let collection_id = nft.collection_id; + nft.owner = to.clone(); + env.storage().persistent().set(&DataKey::Nft(nft_id), &nft); + let mut from_nfts: Vec = env.storage().persistent().get(&DataKey::UserNfts(from.clone())).unwrap_or_else(|| Vec::new(env)); + let mut new_from: Vec = Vec::new(env); + for i in 0..from_nfts.len() { if from_nfts.get(i).unwrap() != nft_id { new_from.push_back(from_nfts.get(i).unwrap()); } } + env.storage().persistent().set(&DataKey::UserNfts(from.clone()), &new_from); + let mut to_nfts: Vec = env.storage().persistent().get(&DataKey::UserNfts(to.clone())).unwrap_or_else(|| Vec::new(env)); + to_nfts.push_back(nft_id); + env.storage().persistent().set(&DataKey::UserNfts(to.clone()), &to_nfts); + events::publish_nft_transferred_event(env, nft_id, collection_id, from.clone(), to.clone(), env.ledger().timestamp()); +} + +pub fn burn_nft(env: &Env, owner: &Address, nft_id: u64) { + owner.require_auth(); + let mut nft: Nft = env.storage().persistent().get::<_, Nft>(&DataKey::Nft(nft_id)) + .unwrap_or_else(|| panic_with_error!(env, ContractError::NftError)); + if nft.owner != *owner { panic_with_error!(env, ContractError::NftError); } + if nft.status == NftStatus::Burned { panic_with_error!(env, ContractError::NftError); } + let collection_id = nft.collection_id; + nft.status = NftStatus::Burned; + env.storage().persistent().set(&DataKey::Nft(nft_id), &nft); + events::publish_nft_burned_event(env, nft_id, collection_id, owner.clone(), env.ledger().timestamp()); +} + +pub fn claim_nft_reward(env: &Env, claimer: &Address, nft_id: u64) { + claimer.require_auth(); + let claimed_key = DataKey::NftClaimed(nft_id, claimer.clone()); + if env.storage().persistent().has(&claimed_key) { panic_with_error!(env, ContractError::NftError); } + let nft: Nft = env.storage().persistent().get::<_, Nft>(&DataKey::Nft(nft_id)) + .unwrap_or_else(|| panic_with_error!(env, ContractError::NftError)); + if nft.recipient != *claimer { panic_with_error!(env, ContractError::NftError); } + if nft.status == NftStatus::Burned { panic_with_error!(env, ContractError::NftError); } + env.storage().persistent().set(&claimed_key, &true); + events::publish_nft_reward_claimed_event(env, nft_id, nft.collection_id, claimer.clone(), env.ledger().timestamp()); +} + +pub fn deactivate_nft_collection(env: &Env, merchant_addr: &Address, collection_id: u64) { + merchant_addr.require_auth(); + let mut collection: NftCollection = env.storage().persistent().get::<_, NftCollection>(&DataKey::NftCollection(collection_id)) + .unwrap_or_else(|| panic_with_error!(env, ContractError::NftError)); + let merchant_id = merchant::get_merchant_id(env, merchant_addr); + if collection.merchant_id != merchant_id { panic_with_error!(env, ContractError::NotAuthorized); } + collection.active = false; + env.storage().persistent().set(&DataKey::NftCollection(collection_id), &collection); + events::publish_nft_collection_deactivated_event(env, collection_id, merchant_addr.clone(), env.ledger().timestamp()); +} + +pub fn get_nft_collection(env: &Env, collection_id: u64) -> NftCollection { + env.storage().persistent().get::<_, NftCollection>(&DataKey::NftCollection(collection_id)) + .unwrap_or_else(|| panic_with_error!(env, ContractError::NftError)) +} + +pub fn get_nft(env: &Env, nft_id: u64) -> Nft { + env.storage().persistent().get::<_, Nft>(&DataKey::Nft(nft_id)) + .unwrap_or_else(|| panic_with_error!(env, ContractError::NftError)) +} + +pub fn get_collection_nfts(env: &Env, collection_id: u64) -> Vec { + env.storage().persistent().get(&DataKey::CollectionNfts(collection_id)).unwrap_or_else(|| Vec::new(env)) +} + +pub fn get_user_nfts(env: &Env, user: &Address) -> Vec { + env.storage().persistent().get(&DataKey::UserNfts(user.clone())).unwrap_or_else(|| Vec::new(env)) +} \ No newline at end of file diff --git a/contracts/shade/src/errors.rs b/contracts/shade/src/errors.rs index c71b280..a06c1cb 100644 --- a/contracts/shade/src/errors.rs +++ b/contracts/shade/src/errors.rs @@ -53,6 +53,7 @@ pub enum ContractError { NotTicketOwner = 52, TicketEventMismatch = 53, InvalidResalePrice = 54, + NftError = 55, CampaignNotFound = 55, InvalidRewardTier = 56, PledgeBelowTierMinimum = 57, diff --git a/contracts/shade/src/events.rs b/contracts/shade/src/events.rs index ad9ccfd..20e9bec 100644 --- a/contracts/shade/src/events.rs +++ b/contracts/shade/src/events.rs @@ -1005,6 +1005,86 @@ pub fn publish_ticket_resold_event( .publish(env); } +// NFT reward system events +pub struct NftCollectionCreatedEvent { + pub collection_id: u64, + pub merchant_id: u64, + pub merchant: Address, + pub name: String, + pub base_uri: String, + pub max_supply: u64, + pub royalty_bps: u32, + pub timestamp: u64, +} +#[allow(clippy::too_many_arguments)] +pub fn publish_nft_collection_created_event( + env: &Env, collection_id: u64, merchant_id: u64, merchant: Address, + name: String, base_uri: String, max_supply: u64, royalty_bps: u32, timestamp: u64, +) { + env.events().publish((soroban_sdk::symbol_short!("nft_col_c"),), (collection_id, merchant_id, merchant, name, base_uri, max_supply, royalty_bps, timestamp)); +} + +pub struct NftMintedEvent { + pub nft_id: u64, + pub collection_id: u64, + pub merchant_id: u64, + pub recipient: Address, + pub uri: String, + pub timestamp: u64, +} +pub fn publish_nft_minted_event(env: &Env, nft_id: u64, collection_id: u64, merchant_id: u64, recipient: Address, uri: String, timestamp: u64) { + env.events().publish((soroban_sdk::symbol_short!("nft_mint"),), (nft_id, collection_id, merchant_id, recipient, uri, timestamp)); +} + +pub struct NftBatchMintedEvent { + pub collection_id: u64, + pub merchant_id: u64, + pub count: u32, + pub timestamp: u64, +} +pub fn publish_nft_batch_minted_event(env: &Env, collection_id: u64, merchant_id: u64, count: u32, timestamp: u64) { + env.events().publish((soroban_sdk::symbol_short!("nft_batch"),), (collection_id, merchant_id, count, timestamp)); +} + +pub struct NftTransferredEvent { + pub nft_id: u64, + pub collection_id: u64, + pub from: Address, + pub to: Address, + pub timestamp: u64, +} +pub fn publish_nft_transferred_event(env: &Env, nft_id: u64, collection_id: u64, from: Address, to: Address, timestamp: u64) { + env.events().publish((soroban_sdk::symbol_short!("nft_xfer"),), (nft_id, collection_id, from, to, timestamp)); +} + +pub struct NftBurnedEvent { + pub nft_id: u64, + pub collection_id: u64, + pub owner: Address, + pub timestamp: u64, +} +pub fn publish_nft_burned_event(env: &Env, nft_id: u64, collection_id: u64, owner: Address, timestamp: u64) { + env.events().publish((soroban_sdk::symbol_short!("nft_burn"),), (nft_id, collection_id, owner, timestamp)); +} + +pub struct NftCollectionDeactivatedEvent { + pub collection_id: u64, + pub merchant: Address, + pub timestamp: u64, +} +pub fn publish_nft_collection_deactivated_event(env: &Env, collection_id: u64, merchant: Address, timestamp: u64) { + env.events().publish((soroban_sdk::symbol_short!("nft_col_d"),), (collection_id, merchant, timestamp)); +} + +pub struct NftRewardClaimedEvent { + pub nft_id: u64, + pub collection_id: u64, + pub claimer: Address, + pub timestamp: u64, +} +pub fn publish_nft_reward_claimed_event(env: &Env, nft_id: u64, collection_id: u64, claimer: Address, timestamp: u64) { + env.events().publish((soroban_sdk::symbol_short!("nft_claim"),), (nft_id, collection_id, claimer, timestamp)); +} // ── Backer rewards (crowdfunding tiers & perks) ─────────────────────────────── #[contractevent] diff --git a/contracts/shade/src/interface.rs b/contracts/shade/src/interface.rs index 31a0718..2c8e9cf 100644 --- a/contracts/shade/src/interface.rs +++ b/contracts/shade/src/interface.rs @@ -1,6 +1,6 @@ use crate::types::{ BackerCampaign, BackerPerk, BackerRewardTier, CrossChainBridgePayload, Event, Invoice, InvoiceFilter, Merchant, - MerchantAnalytics, MerchantAnalyticsSummary, MerchantFilter, OracleConfig, PaymentPayload, + MerchantAnalytics, MerchantAnalyticsSummary, MerchantFilter, Nft, NftCollection, OracleConfig, PaymentPayload, PendingFee, Role, Subscription, SubscriptionPlan, Ticket, TokenAnalytics, Transaction }; use soroban_sdk::{contracttrait, Address, BytesN, Env, String, Vec}; @@ -235,6 +235,60 @@ pub trait ShadeTrait { /// Get market share of a token as basis points (10000 = 100%) fn get_token_market_share(env: Env, token: Address) -> i128; + // ── NFT minting & distribution ──────────────────────────────────────────── + + /// Create a new NFT collection for crowdfunding rewards. Only the merchant can call this. + fn create_nft_collection( + env: Env, + merchant: Address, + name: String, + base_uri: String, + max_supply: u64, + royalty_bps: u32, + ) -> u64; + + /// Mint a single NFT from a collection to a recipient (backer reward). + fn mint_nft( + env: Env, + merchant: Address, + collection_id: u64, + recipient: Address, + token_uri: String, + ) -> u64; + + /// Mint NFTs to multiple backers in one call. + fn batch_mint_nfts( + env: Env, + merchant: Address, + collection_id: u64, + recipients: Vec
, + token_uris: Vec, + ) -> Vec; + + /// Transfer an NFT from one address to another. + fn transfer_nft(env: Env, from: Address, to: Address, nft_id: u64); + + /// Burn (permanently destroy) an NFT. Only the owner can do this. + fn burn_nft(env: Env, owner: Address, nft_id: u64); + + /// Claim a reward NFT assigned to the caller. + fn claim_nft_reward(env: Env, claimer: Address, nft_id: u64); + + /// Deactivate a collection so no further minting is possible. + fn deactivate_nft_collection(env: Env, merchant: Address, collection_id: u64); + + /// Fetch a collection by ID. + fn get_nft_collection(env: Env, collection_id: u64) -> NftCollection; + + /// Fetch a single NFT by its global token ID. + fn get_nft(env: Env, nft_id: u64) -> Nft; + + /// List all token IDs belonging to a collection. + fn get_collection_nfts(env: Env, collection_id: u64) -> Vec; + + /// List all NFT IDs owned by a user. + fn get_user_nfts(env: Env, user: Address) -> Vec; +} // ── Backer rewards (crowdfunding tiers & perks) ─────────────────────────── /// Create a crowdfunding campaign for tiered backer rewards. diff --git a/contracts/shade/src/shade.rs b/contracts/shade/src/shade.rs index fea005b..184541a 100644 --- a/contracts/shade/src/shade.rs +++ b/contracts/shade/src/shade.rs @@ -1,4 +1,5 @@ -use crate::components::{ +use crate::components::{ + nft as nft_component, access_control as access_control_component, admin as admin_component, core as core_component, invoice as invoice_component, merchant as merchant_component, pausable as pausable_component, subscription as subscription_component, upgrade as upgrade_component, @@ -9,7 +10,7 @@ use crate::events; use crate::interface::ShadeTrait; use crate::types::{ BackerCampaign, BackerRewardTier, ContractInfo, CrossChainBridgePayload, DataKey, Event, Invoice, - InvoiceFilter, Merchant, MerchantAnalytics, MerchantAnalyticsSummary, MerchantFilter, + InvoiceFilter, Merchant, Nft, NftCollection, MerchantAnalytics, MerchantAnalyticsSummary, MerchantFilter, OracleConfig, PaymentPayload, PendingFee, Role, Subscription, SubscriptionPlan, Ticket, TokenAnalytics, Transaction, }; @@ -574,6 +575,77 @@ impl ShadeTrait for Shade { admin_component::get_token_market_share(&env, &token) } + // ── NFT minting & distribution ──────────────────────────────────────────── + + fn create_nft_collection( + env: Env, + merchant: Address, + name: String, + base_uri: String, + max_supply: u64, + royalty_bps: u32, + ) -> u64 { + pausable_component::assert_not_paused(&env); + nft_component::create_nft_collection(&env, &merchant, &name, &base_uri, max_supply, royalty_bps) + } + + fn mint_nft( + env: Env, + merchant: Address, + collection_id: u64, + recipient: Address, + token_uri: String, + ) -> u64 { + pausable_component::assert_not_paused(&env); + nft_component::mint_nft(&env, &merchant, collection_id, &recipient, &token_uri) + } + + fn batch_mint_nfts( + env: Env, + merchant: Address, + collection_id: u64, + recipients: Vec
, + token_uris: Vec, + ) -> Vec { + pausable_component::assert_not_paused(&env); + nft_component::batch_mint_nfts(&env, &merchant, collection_id, &recipients, &token_uris) + } + + fn transfer_nft(env: Env, from: Address, to: Address, nft_id: u64) { + pausable_component::assert_not_paused(&env); + nft_component::transfer_nft(&env, &from, &to, nft_id) + } + + fn burn_nft(env: Env, owner: Address, nft_id: u64) { + pausable_component::assert_not_paused(&env); + nft_component::burn_nft(&env, &owner, nft_id) + } + + fn claim_nft_reward(env: Env, claimer: Address, nft_id: u64) { + pausable_component::assert_not_paused(&env); + nft_component::claim_nft_reward(&env, &claimer, nft_id) + } + + fn deactivate_nft_collection(env: Env, merchant: Address, collection_id: u64) { + pausable_component::assert_not_paused(&env); + nft_component::deactivate_nft_collection(&env, &merchant, collection_id) + } + + fn get_nft_collection(env: Env, collection_id: u64) -> NftCollection { + nft_component::get_nft_collection(&env, collection_id) + } + + fn get_nft(env: Env, nft_id: u64) -> Nft { + nft_component::get_nft(&env, nft_id) + } + + fn get_collection_nfts(env: Env, collection_id: u64) -> Vec { + nft_component::get_collection_nfts(&env, collection_id) + } + + fn get_user_nfts(env: Env, user: Address) -> Vec { + nft_component::get_user_nfts(&env, &user) + } fn create_backer_campaign( env: Env, merchant: Address, diff --git a/contracts/shade/src/tests/mod.rs b/contracts/shade/src/tests/mod.rs index f52ba44..7f3ac3d 100644 --- a/contracts/shade/src/tests/mod.rs +++ b/contracts/shade/src/tests/mod.rs @@ -1,4 +1,4 @@ -pub mod test; +pub mod test; pub mod test_accepted_tokens; pub mod test_access_control; pub mod test_account_factory; @@ -42,4 +42,6 @@ pub mod test_upgrade; pub mod test_fiat_pricing; pub mod test_event_tickets; pub mod test_analytics_aggregation; + +pub mod test_nft_rewards; pub mod test_backer_rewards; diff --git a/contracts/shade/src/tests/test_nft_rewards.rs b/contracts/shade/src/tests/test_nft_rewards.rs new file mode 100644 index 0000000..ec12521 --- /dev/null +++ b/contracts/shade/src/tests/test_nft_rewards.rs @@ -0,0 +1,150 @@ +#![cfg(test)] + +use soroban_sdk::{testutils::Address as _, Address, Env, String, Vec}; +use crate::shade::Shade; +use crate::interface::ShadeTrait; + +fn setup() -> (Env, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, Shade); + let admin = Address::generate(&env); + let client = crate::shade::ShadeClient::new(&env, &contract_id); + client.initialize(&admin); + client.add_accepted_token(&admin, &Address::generate(&env)); + // Register a merchant + let merchant = Address::generate(&env); + client.register_merchant(&merchant); + (env, contract_id, merchant) +} + +#[test] +fn test_create_collection_and_mint() { + let (env, contract_id, merchant) = setup(); + let client = crate::shade::ShadeClient::new(&env, &contract_id); + + let col_id = client.create_nft_collection( + &merchant, + &String::from_str(&env, "Genesis"), + &String::from_str(&env, "ipfs://bafybeig/"), + &100u64, + &500u32, + ); + assert_eq!(col_id, 1); + + let backer = Address::generate(&env); + let nft_id = client.mint_nft( + &merchant, + &col_id, + &backer, + &String::from_str(&env, "ipfs://bafybeig/1.json"), + ); + assert_eq!(nft_id, 1); + + let nft = client.get_nft(&nft_id); + assert_eq!(nft.owner, backer); + assert_eq!(nft.collection_id, col_id); + + let user_nfts = client.get_user_nfts(&backer); + assert_eq!(user_nfts.len(), 1); +} + +#[test] +fn test_batch_mint() { + let (env, contract_id, merchant) = setup(); + let client = crate::shade::ShadeClient::new(&env, &contract_id); + + let col_id = client.create_nft_collection( + &merchant, + &String::from_str(&env, "Batch"), + &String::from_str(&env, "ipfs://abc/"), + &0u64, + &0u32, + ); + + let b1 = Address::generate(&env); + let b2 = Address::generate(&env); + let mut recipients: Vec
= Vec::new(&env); + recipients.push_back(b1.clone()); + recipients.push_back(b2.clone()); + let mut uris: Vec = Vec::new(&env); + uris.push_back(String::from_str(&env, "ipfs://abc/1.json")); + uris.push_back(String::from_str(&env, "ipfs://abc/2.json")); + + let ids = client.batch_mint_nfts(&merchant, &col_id, &recipients, &uris); + assert_eq!(ids.len(), 2); + + let col = client.get_nft_collection(&col_id); + assert_eq!(col.minted, 2); +} + +#[test] +fn test_transfer_nft() { + let (env, contract_id, merchant) = setup(); + let client = crate::shade::ShadeClient::new(&env, &contract_id); + + let col_id = client.create_nft_collection( + &merchant, + &String::from_str(&env, "Transfer"), + &String::from_str(&env, "ipfs://xyz/"), + &10u64, + &250u32, + ); + let owner = Address::generate(&env); + let new_owner = Address::generate(&env); + let nft_id = client.mint_nft( + &merchant, + &col_id, + &owner, + &String::from_str(&env, "ipfs://xyz/1.json"), + ); + + client.transfer_nft(&owner, &new_owner, &nft_id); + + let nft = client.get_nft(&nft_id); + assert_eq!(nft.owner, new_owner); + assert_eq!(client.get_user_nfts(&owner).len(), 0); + assert_eq!(client.get_user_nfts(&new_owner).len(), 1); +} + +#[test] +fn test_burn_nft() { + let (env, contract_id, merchant) = setup(); + let client = crate::shade::ShadeClient::new(&env, &contract_id); + + let col_id = client.create_nft_collection( + &merchant, + &String::from_str(&env, "Burn"), + &String::from_str(&env, "ipfs://burn/"), + &5u64, + &0u32, + ); + let owner = Address::generate(&env); + let nft_id = client.mint_nft( + &merchant, + &col_id, + &owner, + &String::from_str(&env, "ipfs://burn/1.json"), + ); + client.burn_nft(&owner, &nft_id); + + let nft = client.get_nft(&nft_id); + assert_eq!(nft.status, crate::types::NftStatus::Burned); +} + +#[test] +fn test_deactivate_collection() { + let (env, contract_id, merchant) = setup(); + let client = crate::shade::ShadeClient::new(&env, &contract_id); + + let col_id = client.create_nft_collection( + &merchant, + &String::from_str(&env, "Deactivate"), + &String::from_str(&env, "ipfs://deact/"), + &0u64, + &0u32, + ); + client.deactivate_nft_collection(&merchant, &col_id); + let col = client.get_nft_collection(&col_id); + assert!(!col.active); +} \ No newline at end of file diff --git a/contracts/shade/src/types.rs b/contracts/shade/src/types.rs index e8f7219..e874534 100644 --- a/contracts/shade/src/types.rs +++ b/contracts/shade/src/types.rs @@ -1,10 +1,8 @@ -use soroban_sdk::{contracttype, Address, BytesN, String, Vec}; +use soroban_sdk::{contracttype, Address, String, Vec}; #[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] pub enum DataKey { - Admin, - PendingAdmin, - Paused, FeeInBasisPoints(Address), FeeAmount(Address), ContractInfo, @@ -47,6 +45,17 @@ pub enum DataKey { // --- Global token analytics --- TokenAnalytics(Address), TokenVolume(Address), + // --- NFT reward system --- + NftCollection(u64), + NftCollectionCount, + Nft(u64), + NftCount, + CollectionNfts(u64), + UserNfts(Address), + NftClaimed(u64, Address), + // --- Auto-withdrawal --- + MerchantAutoWithdrawalThreshold(u64, Address), + MerchantAutoWithdrawalRecipient(u64), // --- Backer rewards (crowdfunding tiers & perks) --- BackerCampaign(u64), BackerCampaignCount, @@ -368,6 +377,30 @@ pub struct PaymentPayload { pub max_slippage_bps: Option, } +// ── NFT reward system ───────────────────────────────────────────────────────── + +#[contracttype] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum NftStatus { + Active = 0, + Burned = 1, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NftCollection { + pub id: u64, + pub merchant_id: u64, + pub merchant: Address, + pub name: String, + pub base_uri: String, + pub max_supply: u64, + pub minted: u64, + pub royalty_bps: u32, + pub active: bool, + pub created_at: u64, +} // --- Backer rewards (crowdfunding tiers & perks) --- #[contracttype] @@ -384,6 +417,15 @@ pub struct BackerCampaign { #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub struct Nft { + pub id: u64, + pub collection_id: u64, + pub owner: Address, + pub uri: String, + pub status: NftStatus, + pub minted_at: u64, + pub recipient: Address, +} pub struct BackerPerk { pub name: String, pub description: String, @@ -391,6 +433,12 @@ pub struct BackerPerk { #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub struct AutoWithdrawalThreshold { + pub merchant_id: u64, + pub token: Address, + pub threshold: i128, + pub recipient: Address, +} pub struct BackerRewardTier { pub min_pledge: i128, pub name: String,