From e16c1066cb61b2347b215a5b610e7b26d15ce92b Mon Sep 17 00:00:00 2001 From: OluwapelumiElisha Date: Tue, 30 Jun 2026 10:22:35 +0100 Subject: [PATCH] Add Leaderboard Tracking Logic for Top Donors #370 --- contracts/shade/src/components/leaderboard.rs | 102 ++++++++++++++++++ contracts/shade/src/components/mod.rs | 1 + contracts/shade/src/errors.rs | 1 + contracts/shade/src/events.rs | 29 +++++ contracts/shade/src/interface.rs | 19 +++- contracts/shade/src/shade.rs | 22 +++- contracts/shade/src/types.rs | 11 ++ 7 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 contracts/shade/src/components/leaderboard.rs 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 aea912f..96b9b44 100644 --- a/contracts/shade/src/components/mod.rs +++ b/contracts/shade/src/components/mod.rs @@ -13,3 +13,4 @@ pub mod subscription; pub mod history; pub mod upgrade; pub mod event; +pub mod leaderboard; diff --git a/contracts/shade/src/errors.rs b/contracts/shade/src/errors.rs index 20d761a..5ffb594 100644 --- a/contracts/shade/src/errors.rs +++ b/contracts/shade/src/errors.rs @@ -53,4 +53,5 @@ pub enum ContractError { NotTicketOwner = 52, TicketEventMismatch = 53, InvalidResalePrice = 54, + CampaignNotFound = 55, } diff --git a/contracts/shade/src/events.rs b/contracts/shade/src/events.rs index f2af789..7b8647a 100644 --- a/contracts/shade/src/events.rs +++ b/contracts/shade/src/events.rs @@ -1004,3 +1004,32 @@ 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, + timestamp, + } + .publish(env); +} diff --git a/contracts/shade/src/interface.rs b/contracts/shade/src/interface.rs index 8432329..f8d1935 100644 --- a/contracts/shade/src/interface.rs +++ b/contracts/shade/src/interface.rs @@ -1,7 +1,7 @@ use crate::types::{ CrossChainBridgePayload, Event, Invoice, InvoiceFilter, Merchant, MerchantAnalytics, MerchantAnalyticsSummary, MerchantFilter, OracleConfig, PaymentPayload, PendingFee, Role, - Subscription, SubscriptionPlan, Ticket, TokenAnalytics, Transaction + Subscription, SubscriptionPlan, Ticket, TokenAnalytics, Transaction, DonorInfo }; use soroban_sdk::{contracttrait, Address, BytesN, Env, String, Vec}; @@ -234,4 +234,21 @@ pub trait ShadeTrait { /// Get market share of a token as basis points (10000 = 100%) fn get_token_market_share(env: Env, token: Address) -> i128; + + // ── Crowdfund Leaderboard ─────────────────────────────────────────────────── + + /// Initialize a campaign for leaderboard tracking + fn init_campaign(env: Env, merchant: Address, campaign_id: u64); + + /// Track a donation and update the leaderboard + fn track_donation( + env: Env, + merchant: Address, + campaign_id: u64, + donor: Address, + amount: i128, + ); + + /// Get the top donors for a campaign + fn get_top_donors(env: Env, campaign_id: u64) -> Vec; } diff --git a/contracts/shade/src/shade.rs b/contracts/shade/src/shade.rs index 4945b64..a889216 100644 --- a/contracts/shade/src/shade.rs +++ b/contracts/shade/src/shade.rs @@ -2,7 +2,7 @@ use crate::components::{ 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, - history as history_component, + history as history_component, leaderboard as leaderboard_component, }; use crate::errors::ContractError; use crate::events; @@ -10,7 +10,7 @@ use crate::interface::ShadeTrait; use crate::types::{ ContractInfo, CrossChainBridgePayload, DataKey, Event, Invoice, InvoiceFilter, Merchant, MerchantAnalytics, MerchantAnalyticsSummary, MerchantFilter, OracleConfig, PaymentPayload, - PendingFee, Role, Subscription, SubscriptionPlan, Ticket, TokenAnalytics, Transaction, + PendingFee, Role, Subscription, SubscriptionPlan, Ticket, TokenAnalytics, Transaction, DonorInfo, }; use soroban_sdk::{contract, contractimpl, panic_with_error, Address, BytesN, Env, String, Vec}; @@ -572,4 +572,22 @@ impl ShadeTrait for Shade { fn get_token_market_share(env: Env, token: Address) -> i128 { admin_component::get_token_market_share(&env, &token) } + + 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 a6bb93d..69839d2 100644 --- a/contracts/shade/src/types.rs +++ b/contracts/shade/src/types.rs @@ -47,6 +47,10 @@ pub enum DataKey { // --- Global token analytics --- TokenAnalytics(Address), TokenVolume(Address), + // --- Crowdfund Leaderboard --- + CampaignOwner(u64), + CampaignTopDonors(u64), + CampaignDonorAmount(u64, Address), } #[contracttype] @@ -358,3 +362,10 @@ pub struct PaymentPayload { pub route: PaymentRoute, pub max_slippage_bps: Option, } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DonorInfo { + pub donor: Address, + pub total_donated: i128, +}