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
3 changes: 2 additions & 1 deletion contracts/shade/src/components/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
pub mod access_control;
pub mod access_control;
pub mod account_factory;
pub mod admin;
pub mod auto_withdrawal;
Expand All @@ -14,3 +14,4 @@ pub mod history;
pub mod upgrade;
pub mod backer_rewards;
pub mod event;
pub mod nft;
135 changes: 135 additions & 0 deletions contracts/shade/src/components/nft.rs
Original file line number Diff line number Diff line change
@@ -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::<u64>::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<u64> = 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<u64> = 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<Address>, token_uris: &Vec<String>) -> Vec<u64> {
merchant_addr.require_auth();
if recipients.len() != token_uris.len() { panic_with_error!(env, ContractError::InvalidAmount); }
let mut minted_ids: Vec<u64> = 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<u64> = env.storage().persistent().get(&DataKey::UserNfts(from.clone())).unwrap_or_else(|| Vec::new(env));
let mut new_from: Vec<u64> = 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<u64> = 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<u64> {
env.storage().persistent().get(&DataKey::CollectionNfts(collection_id)).unwrap_or_else(|| Vec::new(env))
}

pub fn get_user_nfts(env: &Env, user: &Address) -> Vec<u64> {
env.storage().persistent().get(&DataKey::UserNfts(user.clone())).unwrap_or_else(|| Vec::new(env))
}
1 change: 1 addition & 0 deletions contracts/shade/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ pub enum ContractError {
NotTicketOwner = 52,
TicketEventMismatch = 53,
InvalidResalePrice = 54,
NftError = 55,
CampaignNotFound = 55,
InvalidRewardTier = 56,
PledgeBelowTierMinimum = 57,
Expand Down
80 changes: 80 additions & 0 deletions contracts/shade/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
56 changes: 55 additions & 1 deletion contracts/shade/src/interface.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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<Address>,
token_uris: Vec<String>,
) -> Vec<u64>;

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

/// List all NFT IDs owned by a user.
fn get_user_nfts(env: Env, user: Address) -> Vec<u64>;
}
// ── Backer rewards (crowdfunding tiers & perks) ───────────────────────────

/// Create a crowdfunding campaign for tiered backer rewards.
Expand Down
Loading