diff --git a/contracts/shade/src/components/leaderboard.rs b/contracts/shade/src/components/leaderboard.rs new file mode 100644 index 0000000..4d23d9e --- /dev/null +++ b/contracts/shade/src/components/leaderboard.rs @@ -0,0 +1,102 @@ +use crate::errors::ContractError; +use crate::events::publish_leaderboard_updated_event; +use crate::types::{DataKey, DonorInfo}; +use soroban_sdk::{panic_with_error, Address, Env, Vec}; + +const MAX_TOP_DONORS: u32 = 10; + +pub fn init_campaign(env: &Env, merchant: Address, campaign_id: u64) { + merchant.require_auth(); + + // Verify merchant is actually registered + if !env.storage().persistent().has(&DataKey::MerchantId(merchant.clone())) { + panic_with_error!(env, ContractError::MerchantNotFound); + } + + let owner_key = DataKey::CampaignOwner(campaign_id); + if env.storage().persistent().has(&owner_key) { + panic_with_error!(env, ContractError::AlreadyInitialized); + } + + env.storage().persistent().set(&owner_key, &merchant); + + let top_donors: Vec = Vec::new(env); + env.storage().persistent().set(&DataKey::CampaignTopDonors(campaign_id), &top_donors); +} + +pub fn track_donation( + env: &Env, + merchant: Address, + campaign_id: u64, + donor: Address, + amount: i128, +) { + merchant.require_auth(); + + let owner_key = DataKey::CampaignOwner(campaign_id); + let owner: Address = env.storage().persistent().get(&owner_key).unwrap_or_else(|| { + panic_with_error!(env, ContractError::CampaignNotFound); + }); + + if owner != merchant { + panic_with_error!(env, ContractError::NotAuthorized); + } + + if amount <= 0 { + panic_with_error!(env, ContractError::InvalidAmount); + } + + let amount_key = DataKey::CampaignDonorAmount(campaign_id, donor.clone()); + let mut current_total: i128 = env.storage().persistent().get(&amount_key).unwrap_or(0); + current_total += amount; + + env.storage().persistent().set(&amount_key, ¤t_total); + + // Update top donors leaderboard + update_top_donors(env, campaign_id, donor.clone(), current_total); + + publish_leaderboard_updated_event(env, campaign_id, donor, amount, current_total, env.ledger().timestamp()); +} + +pub fn get_top_donors(env: &Env, campaign_id: u64) -> Vec { + let top_key = DataKey::CampaignTopDonors(campaign_id); + env.storage().persistent().get(&top_key).unwrap_or_else(|| { + Vec::new(env) + }) +} + +fn update_top_donors(env: &Env, campaign_id: u64, donor: Address, new_total: i128) { + let top_key = DataKey::CampaignTopDonors(campaign_id); + let mut top_donors: Vec = env.storage().persistent().get(&top_key).unwrap_or_else(|| Vec::new(env)); + + // Remove the donor if they are already in the list + let mut index_to_remove = None; + for (i, d) in top_donors.iter().enumerate() { + if d.donor == donor { + index_to_remove = Some(i as u32); + break; + } + } + + if let Some(index) = index_to_remove { + top_donors.remove(index); + } + + // Insert sorted (descending order) + let mut insert_index = top_donors.len(); + for (i, d) in top_donors.iter().enumerate() { + if new_total > d.total_donated { + insert_index = i as u32; + break; + } + } + + top_donors.insert(insert_index, DonorInfo { donor, total_donated: new_total }); + + // Truncate + if top_donors.len() > MAX_TOP_DONORS { + top_donors.pop_back(); + } + + env.storage().persistent().set(&top_key, &top_donors); +} diff --git a/contracts/shade/src/components/mod.rs b/contracts/shade/src/components/mod.rs index 591bb41..396bf3a 100644 --- a/contracts/shade/src/components/mod.rs +++ b/contracts/shade/src/components/mod.rs @@ -24,4 +24,5 @@ pub mod upgrade; pub mod escrow; pub mod backer_rewards; pub mod event; +pub mod leaderboard; pub mod cross_chain_pledge; diff --git a/contracts/shade/src/errors.rs b/contracts/shade/src/errors.rs index 8e771bd..f427a81 100644 --- a/contracts/shade/src/errors.rs +++ b/contracts/shade/src/errors.rs @@ -48,6 +48,7 @@ pub enum ContractError { TicketNotFound = 51, NotTicketOwner = 52, InvalidResalePrice = 54, + CampaignNotFound = 55, // ── Campaign categories & tagging (#352) ────────────────────────────── /// Referenced campaign category does not exist. CampaignCategoryNotFound = 55, diff --git a/contracts/shade/src/events.rs b/contracts/shade/src/events.rs index 6943cfa..c3064d4 100644 --- a/contracts/shade/src/events.rs +++ b/contracts/shade/src/events.rs @@ -1458,6 +1458,33 @@ pub fn publish_ticket_resold_event( .publish(env); } +// ── Leaderboard Events ──────────────────────────────────────────────────────── + +#[contractevent] +pub struct LeaderboardUpdatedEvent { + pub campaign_id: u64, + pub donor: Address, + pub amount: i128, + pub new_total: i128, + pub timestamp: u64, +} + +pub fn publish_leaderboard_updated_event( + env: &Env, + campaign_id: u64, + donor: Address, + amount: i128, + new_total: i128, + timestamp: u64, +) { + LeaderboardUpdatedEvent { + campaign_id, + donor, + amount, + new_total, + } +} + // ── Campaign categories & tagging (#352) ────────────────────────────────────── #[contractevent] diff --git a/contracts/shade/src/shade.rs b/contracts/shade/src/shade.rs index 3dc0d13..87108c9 100644 --- a/contracts/shade/src/shade.rs +++ b/contracts/shade/src/shade.rs @@ -1303,4 +1303,22 @@ impl ShadeTrait for Shade { fn get_campaigns(env: Env, filter: CampaignFilter) -> Vec { campaigns_component::get_campaigns(&env, filter) } + + fn init_campaign(env: Env, merchant: Address, campaign_id: u64) { + leaderboard_component::init_campaign(&env, merchant, campaign_id); + } + + fn track_donation( + env: Env, + merchant: Address, + campaign_id: u64, + donor: Address, + amount: i128, + ) { + leaderboard_component::track_donation(&env, merchant, campaign_id, donor, amount); + } + + fn get_top_donors(env: Env, campaign_id: u64) -> Vec { + leaderboard_component::get_top_donors(&env, campaign_id) + } } diff --git a/contracts/shade/src/types.rs b/contracts/shade/src/types.rs index 4d68d49..c25eec4 100644 --- a/contracts/shade/src/types.rs +++ b/contracts/shade/src/types.rs @@ -49,6 +49,10 @@ pub enum DataKey { // --- Global token analytics --- TokenAnalytics(Address), TokenVolume(Address), + // --- Crowdfund Leaderboard --- + CampaignOwner(u64), + CampaignTopDonors(u64), + CampaignDonorAmount(u64, Address), // --- Campaign categories & tagging system (#352) --- /// A predefined campaign category created by the admin. CampaignCategory(u64),