diff --git a/contracts/shade/src/components/campaigns.rs b/contracts/shade/src/components/campaigns.rs new file mode 100644 index 0000000..34d38d4 --- /dev/null +++ b/contracts/shade/src/components/campaigns.rs @@ -0,0 +1,770 @@ +use crate::components::{admin, merchant, reentrancy}; +use crate::errors::ContractError; +use crate::events; +use crate::types::{ + Campaign, CampaignCategory, CampaignFilter, CampaignTag, DataKey, +}; +use soroban_sdk::{panic_with_error, Address, Env, String, Vec}; + +/// Validation bounds for free-form user strings. Kept conservative to minimise +/// Soroban rent/cpu overhead on event emission and storage. +const MAX_NAME_LEN: u32 = 64; +const MAX_DESCRIPTION_LEN: u32 = 512; +const MAX_TITLE_LEN: u32 = 128; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn get_category_count(env: &Env) -> u64 { + env.storage() + .persistent() + .get(&DataKey::CampaignCategoryCount) + .unwrap_or(0) +} + +fn get_tag_count(env: &Env) -> u64 { + env.storage() + .persistent() + .get(&DataKey::CampaignTagCount) + .unwrap_or(0) +} + +fn get_campaign_count(env: &Env) -> u64 { + env.storage() + .persistent() + .get(&DataKey::CampaignCount) + .unwrap_or(0) +} + +/// Push `value` onto the `Vec` stored under `list_key` if it isn't +/// already present. Returns true iff the value was added. +fn push_unique_u64(env: &Env, list_key: &DataKey, value: u64) -> bool { + let mut list: Vec = env + .storage() + .persistent() + .get(list_key) + .unwrap_or_else(|| Vec::new(env)); + for v in list.iter() { + if v == value { + return false; + } + } + list.push_back(value); + env.storage().persistent().set(list_key, &list); + true +} + +// ── Category management (#352) ──────────────────────────────────────────────── + +pub fn create_category( + env: &Env, + admin: &Address, + name: &String, + description: &String, +) -> u64 { + reentrancy::enter(env); + crate::components::core::assert_admin(env, admin); + + if name.len() == 0 || name.len() > MAX_NAME_LEN { + panic_with_error!(env, ContractError::InvalidDescription); + } + if description.len() > MAX_DESCRIPTION_LEN { + panic_with_error!(env, ContractError::InvalidDescription); + } + + let name_key = DataKey::CampaignCategoryName(name.clone()); + if env.storage().persistent().has(&name_key) { + panic_with_error!(env, ContractError::CampaignCategoryAlreadyExists); + } + + let id = get_category_count(env) + 1; + let category = CampaignCategory { + id, + name: name.clone(), + description: description.clone(), + active: true, + timestamp: env.ledger().timestamp(), + }; + + env.storage() + .persistent() + .set(&DataKey::CampaignCategory(id), &category); + env.storage() + .persistent() + .set(&DataKey::CampaignCategoryCount, &id); + env.storage().persistent().set(&name_key, &id); + env.storage() + .persistent() + .set(&DataKey::CategoryCampaigns(id), &Vec::::new(env)); + + events::publish_campaign_category_created_event( + env, + id, + admin.clone(), + name.clone(), + description.clone(), + env.ledger().timestamp(), + ); + reentrancy::exit(env); + id +} + +#[allow(clippy::too_many_arguments)] +pub fn update_category( + env: &Env, + admin: &Address, + category_id: u64, + name: Option, + description: Option, + active: Option, +) { + reentrancy::enter(env); + crate::components::core::assert_admin(env, admin); + + let key = DataKey::CampaignCategory(category_id); + if !env.storage().persistent().has(&key) { + panic_with_error!(env, ContractError::CampaignCategoryNotFound); + } + let mut category: CampaignCategory = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| panic_with_error!(env, ContractError::CampaignCategoryNotFound)); + + if let Some(new_name) = name.as_ref() { + if new_name.len() == 0 || new_name.len() > MAX_NAME_LEN { + panic_with_error!(env, ContractError::InvalidDescription); + } + if new_name != &category.name { + let name_key = DataKey::CampaignCategoryName(new_name.clone()); + if env.storage().persistent().has(&name_key) { + panic_with_error!(env, ContractError::CampaignCategoryAlreadyExists); + } + env.storage() + .persistent() + .remove(&DataKey::CampaignCategoryName(category.name.clone())); + env.storage().persistent().set(&name_key, &category_id); + category.name = new_name.clone(); + } + } + if let Some(new_desc) = description.as_ref() { + if new_desc.len() > MAX_DESCRIPTION_LEN { + panic_with_error!(env, ContractError::InvalidDescription); + } + category.description = new_desc.clone(); + } + if let Some(active_flag) = active { + category.active = active_flag; + } + + env.storage().persistent().set(&key, &category); + + events::publish_campaign_category_updated_event( + env, + category_id, + admin.clone(), + category.name.clone(), + category.description.clone(), + category.active, + env.ledger().timestamp(), + ); + reentrancy::exit(env); +} + +pub fn get_category(env: &Env, category_id: u64) -> CampaignCategory { + let key = DataKey::CampaignCategory(category_id); + if !env.storage().persistent().has(&key) { + panic_with_error!(env, ContractError::CampaignCategoryNotFound); + } + env.storage() + .persistent() + .get(&key) + .unwrap_or_else(|| panic_with_error!(env, ContractError::CampaignCategoryNotFound)) +} + +pub fn get_categories(env: &Env) -> Vec { + let count = get_category_count(env); + let mut out: Vec = Vec::new(env); + for i in 1..=count { + if let Some(cat) = env + .storage() + .persistent() + .get::<_, CampaignCategory>(&DataKey::CampaignCategory(i)) + { + out.push_back(cat); + } + } + out +} + +// ── Tag management (#352) ───────────────────────────────────────────────────── + +pub fn create_tag(env: &Env, creator: &Address, name: &String) -> u64 { + creator.require_auth(); + + // Check merchant membership first to avoid the storage-cost of resolving + // the admin on the common path. + if !merchant::is_merchant(env, creator) + && crate::components::core::get_admin(env) != *creator + { + panic_with_error!(env, ContractError::NotAuthorized); + } + + if name.len() == 0 || name.len() > MAX_NAME_LEN { + panic_with_error!(env, ContractError::InvalidDescription); + } + + let name_key = DataKey::CampaignTagName(name.clone()); + if env.storage().persistent().has(&name_key) { + panic_with_error!(env, ContractError::CampaignTagAlreadyExists); + } + + let id = get_tag_count(env) + 1; + let tag = CampaignTag { + id, + name: name.clone(), + creator: creator.clone(), + timestamp: env.ledger().timestamp(), + }; + + env.storage() + .persistent() + .set(&DataKey::CampaignTag(id), &tag); + env.storage() + .persistent() + .set(&DataKey::CampaignTagCount, &id); + env.storage().persistent().set(&name_key, &id); + env.storage() + .persistent() + .set(&DataKey::TagCampaigns(id), &Vec::::new(env)); + + events::publish_campaign_tag_created_event( + env, + id, + creator.clone(), + name.clone(), + env.ledger().timestamp(), + ); + id +} + +pub fn get_tag(env: &Env, tag_id: u64) -> CampaignTag { + let key = DataKey::CampaignTag(tag_id); + if !env.storage().persistent().has(&key) { + panic_with_error!(env, ContractError::CampaignTagNotFound); + } + env.storage() + .persistent() + .get(&key) + .unwrap_or_else(|| panic_with_error!(env, ContractError::CampaignTagNotFound)) +} + +pub fn get_tags(env: &Env) -> Vec { + let count = get_tag_count(env); + let mut out: Vec = Vec::new(env); + for i in 1..=count { + if let Some(tag) = env + .storage() + .persistent() + .get::<_, CampaignTag>(&DataKey::CampaignTag(i)) + { + out.push_back(tag); + } + } + out +} + +// ── Campaign management (#352) ──────────────────────────────────────────────── + +#[allow(clippy::too_many_arguments)] +pub fn create_campaign( + env: &Env, + merchant_addr: &Address, + title: &String, + description: &String, + category_id: u64, + tags: &Vec, + goal_amount: i128, + token: &Address, + deadline: u64, +) -> u64 { + merchant_addr.require_auth(); + + if !merchant::is_merchant(env, merchant_addr) { + panic_with_error!(env, ContractError::MerchantNotFound); + } + let merchant_id = merchant::get_merchant_id(env, merchant_addr); + if !merchant::is_merchant_active(env, merchant_id) { + panic_with_error!(env, ContractError::MerchantNotActive); + } + + if title.len() == 0 || title.len() > MAX_TITLE_LEN { + panic_with_error!(env, ContractError::InvalidDescription); + } + if description.len() > MAX_DESCRIPTION_LEN { + panic_with_error!(env, ContractError::InvalidDescription); + } + if *goal_amount <= 0 { + panic_with_error!(env, ContractError::InvalidCampaignGoal); + } + if *deadline <= env.ledger().timestamp() { + panic_with_error!(env, ContractError::InvalidCampaignDeadline); + } + if !admin::is_accepted_token(env, token) { + panic_with_error!(env, ContractError::TokenNotAccepted); + } + + let category_key = DataKey::CampaignCategory(*category_id); + if !env.storage().persistent().has(&category_key) { + panic_with_error!(env, ContractError::CampaignCategoryNotFound); + } + let category: CampaignCategory = env + .storage() + .persistent() + .get(&category_key) + .unwrap_or_else(|| panic_with_error!(env, ContractError::CampaignCategoryNotFound)); + if !category.active { + panic_with_error!(env, ContractError::CampaignCategoryInactive); + } + + // Validate tags + de-dupe per campaign. + let mut deduped_tags: Vec = Vec::new(env); + for tag_id in tags.iter() { + let key = DataKey::CampaignTag(*tag_id); + if !env.storage().persistent().has(&key) { + panic_with_error!(env, ContractError::CampaignTagNotFound); + } + let mut found = false; + for existing in deduped_tags.iter() { + if existing == tag_id { + found = true; + break; + } + } + if !found { + deduped_tags.push_back(*tag_id); + } + } + + let campaign_id = get_campaign_count(env) + 1; + let campaign = Campaign { + id: campaign_id, + merchant_id, + merchant: merchant_addr.clone(), + title: title.clone(), + description: description.clone(), + category_id: *category_id, + tags: deduped_tags.clone(), + goal_amount: *goal_amount, + token: token.clone(), + deadline: *deadline, + raised_amount: 0, + active: true, + created_at: env.ledger().timestamp(), + }; + + env.storage() + .persistent() + .set(&DataKey::Campaign(campaign_id), &campaign); + env.storage() + .persistent() + .set(&DataKey::CampaignCount, &campaign_id); + // Seed the empty merchant campaigns vec before push_unique (avoids `has` + // returning false for ids that aren't yet allocated). + env.storage() + .persistent() + .set(&DataKey::MerchantCampaigns(merchant_id), &Vec::::new(env)); + push_unique_u64( + env, + &DataKey::MerchantCampaigns(merchant_id), + campaign_id, + ); + push_unique_u64(env, &DataKey::CategoryCampaigns(*category_id), campaign_id); + env.storage().persistent().set( + &DataKey::CampaignTagList(campaign_id), + &deduped_tags, + ); + for tag_id in deduped_tags.iter() { + push_unique_u64(env, &DataKey::TagCampaigns(tag_id), campaign_id); + } + + events::publish_campaign_created_event( + env, + campaign_id, + merchant_addr.clone(), + merchant_id, + title.clone(), + description.clone(), + *category_id, + deduped_tags.clone(), + *goal_amount, + token.clone(), + *deadline, + env.ledger().timestamp(), + ); + + campaign_id +} + +/// Update only the mutable text fields of a campaign. Goal/token/deadline are +/// immutable after creation to keep the published fundraising target stable. +#[allow(clippy::too_many_arguments)] +pub fn update_campaign( + env: &Env, + merchant_addr: &Address, + campaign_id: u64, + title: Option, + description: Option, +) { + merchant_addr.require_auth(); + + let key = DataKey::Campaign(campaign_id); + if !env.storage().persistent().has(&key) { + panic_with_error!(env, ContractError::CampaignNotFound); + } + let mut campaign: Campaign = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| panic_with_error!(env, ContractError::CampaignNotFound)); + + if campaign.merchant != *merchant_addr { + panic_with_error!(env, ContractError::NotCampaignMerchant); + } + + if let Some(new_title) = title.as_ref() { + if new_title.len() == 0 || new_title.len() > MAX_TITLE_LEN { + panic_with_error!(env, ContractError::InvalidDescription); + } + campaign.title = new_title.clone(); + } + if let Some(new_desc) = description.as_ref() { + if new_desc.len() > MAX_DESCRIPTION_LEN { + panic_with_error!(env, ContractError::InvalidDescription); + } + campaign.description = new_desc.clone(); + } + + env.storage().persistent().set(&key, &campaign); + events::publish_campaign_updated_event( + env, + campaign_id, + merchant_addr.clone(), + campaign.title.clone(), + campaign.description.clone(), + env.ledger().timestamp(), + ); +} + +/// Toggle campaign active state. Deactivated campaigns cannot accept new tag +/// edits or contributions, but their existing data is preserved. +pub fn set_campaign_active( + env: &Env, + merchant_addr: &Address, + campaign_id: u64, + active: bool, +) { + merchant_addr.require_auth(); + + let key = DataKey::Campaign(campaign_id); + if !env.storage().persistent().has(&key) { + panic_with_error!(env, ContractError::CampaignNotFound); + } + let mut campaign: Campaign = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| panic_with_error!(env, ContractError::CampaignNotFound)); + if campaign.merchant != *merchant_addr { + panic_with_error!(env, ContractError::NotCampaignMerchant); + } + if campaign.active == active { + return; + } + campaign.active = active; + env.storage().persistent().set(&key, &campaign); + events::publish_campaign_status_changed_event( + env, + campaign_id, + merchant_addr.clone(), + active, + env.ledger().timestamp(), + ); +} + +/// Attach an existing tag to a campaign. De-duplicated; reverse index updated. +pub fn add_campaign_tag( + env: &Env, + merchant_addr: &Address, + campaign_id: u64, + tag_id: u64, +) { + merchant_addr.require_auth(); + + let key = DataKey::Campaign(campaign_id); + if !env.storage().persistent().has(&key) { + panic_with_error!(env, ContractError::CampaignNotFound); + } + let mut campaign: Campaign = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| panic_with_error!(env, ContractError::CampaignNotFound)); + if campaign.merchant != *merchant_addr { + panic_with_error!(env, ContractError::NotCampaignMerchant); + } + + let tag_key = DataKey::CampaignTag(tag_id); + if !env.storage().persistent().has(&tag_key) { + panic_with_error!(env, ContractError::CampaignTagNotFound); + } + + let attached = push_unique_u64(env, &DataKey::CampaignTagList(campaign_id), tag_id); + // Only update the inverse index when this tag was newly attached, so the + // two indices stay in lockstep and events fire exactly once per attach. + if attached { + push_unique_u64(env, &DataKey::TagCampaigns(tag_id), campaign_id); + + // Refresh the campaign.tags snapshot from authoritative storage so + // reads see the updated tag list without an extra fetch. + let current_tags: Vec = env + .storage() + .persistent() + .get(&DataKey::CampaignTagList(campaign_id)) + .unwrap_or_else(|| Vec::new(env)); + campaign.tags = current_tags; + env.storage().persistent().set(&key, &campaign); + + events::publish_campaign_tag_added_event( + env, + campaign_id, + merchant_addr.clone(), + tag_id, + env.ledger().timestamp(), + ); + } +} + +/// Detach a tag from a campaign. Reverse index updated to remove the +/// campaign_id from the tag's index list. +pub fn remove_campaign_tag( + env: &Env, + merchant_addr: &Address, + campaign_id: u64, + tag_id: u64, +) { + merchant_addr.require_auth(); + + let key = DataKey::Campaign(campaign_id); + if !env.storage().persistent().has(&key) { + panic_with_error!(env, ContractError::CampaignNotFound); + } + let mut campaign: Campaign = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| panic_with_error!(env, ContractError::CampaignNotFound)); + if campaign.merchant != *merchant_addr { + panic_with_error!(env, ContractError::NotCampaignMerchant); + } + + let current: Vec = env + .storage() + .persistent() + .get(&DataKey::CampaignTagList(campaign_id)) + .unwrap_or_else(|| Vec::new(env)); + let mut remaining: Vec = Vec::new(env); + let mut was_present = false; + for t in current.iter() { + if t == tag_id { + was_present = true; + } else { + remaining.push_back(t); + } + } + if !was_present { + return; + } + env.storage() + .persistent() + .set(&DataKey::CampaignTagList(campaign_id), &remaining); + campaign.tags = remaining.clone(); + env.storage().persistent().set(&key, &campaign); + + let tag_list: Vec = env + .storage() + .persistent() + .get(&DataKey::TagCampaigns(tag_id)) + .unwrap_or_else(|| Vec::new(env)); + let mut new_tag_list: Vec = Vec::new(env); + for tid in tag_list.iter() { + if tid != campaign_id { + new_tag_list.push_back(tid); + } + } + env.storage() + .persistent() + .set(&DataKey::TagCampaigns(tag_id), &new_tag_list); + + events::publish_campaign_tag_removed_event( + env, + campaign_id, + merchant_addr.clone(), + tag_id, + env.ledger().timestamp(), + ); +} + +/// Records a contribution amount against `campaign_id`. This is an accounting +/// helper - it does not move tokens. Campaigns may use any off-chain payment +/// rail while benefiting from the on-chain metadata + indexed totals. +pub fn record_contribution( + env: &Env, + campaign_id: u64, + contributor: &Address, + amount: i128, +) { + if *amount <= 0 { + panic_with_error!(env, ContractError::InvalidAmount); + } + + let key = DataKey::Campaign(campaign_id); + if !env.storage().persistent().has(&key) { + panic_with_error!(env, ContractError::CampaignNotFound); + } + let mut campaign: Campaign = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| panic_with_error!(env, ContractError::CampaignNotFound)); + if !campaign.active { + panic_with_error!(env, ContractError::CampaignInactive); + } + if env.ledger().timestamp() > campaign.deadline { + panic_with_error!(env, ContractError::CampaignExpired); + } + + campaign.raised_amount = campaign.raised_amount.saturating_add(*amount); + env.storage().persistent().set(&key, &campaign); + + events::publish_campaign_contribution_event( + env, + campaign_id, + contributor.clone(), + *amount, + campaign.raised_amount, + campaign.goal_amount, + env.ledger().timestamp(), + ); +} + +// ── Read accessors ──────────────────────────────────────────────────────────── + +pub fn get_campaign(env: &Env, campaign_id: u64) -> Campaign { + let key = DataKey::Campaign(campaign_id); + if !env.storage().persistent().has(&key) { + panic_with_error!(env, ContractError::CampaignNotFound); + } + env.storage() + .persistent() + .get(&key) + .unwrap_or_else(|| panic_with_error!(env, ContractError::CampaignNotFound)) +} + +pub fn get_campaigns_by_category(env: &Env, category_id: u64) -> Vec { + collect_campaigns(env, &DataKey::CategoryCampaigns(category_id)) +} + +pub fn get_campaigns_by_tag(env: &Env, tag_id: u64) -> Vec { + collect_campaigns(env, &DataKey::TagCampaigns(tag_id)) +} + +pub fn get_merchant_campaigns(env: &Env, merchant_id: u64) -> Vec { + collect_campaigns(env, &DataKey::MerchantCampaigns(merchant_id)) +} + +fn collect_campaigns(env: &Env, index_key: &DataKey) -> Vec { + let ids: Vec = env + .storage() + .persistent() + .get(index_key) + .unwrap_or_else(|| Vec::new(env)); + let mut out: Vec = Vec::new(env); + for id in ids.iter() { + if let Some(c) = env + .storage() + .persistent() + .get::<_, Campaign>(&DataKey::Campaign(id)) + { + out.push_back(c); + } + } + out +} + +pub fn get_campaigns(env: &Env, filter: CampaignFilter) -> Vec { + let count = get_campaign_count(env); + + let seeded_ids: Vec = match (filter.category_id, filter.tag_id) { + (Some(cat), Some(tag)) => { + let cat_ids = env + .storage() + .persistent() + .get(&DataKey::CategoryCampaigns(cat)) + .unwrap_or_else(|| Vec::new(env)); + let tag_ids = env + .storage() + .persistent() + .get(&DataKey::TagCampaigns(tag)) + .unwrap_or_else(|| Vec::new(env)); + let mut intersection: Vec = Vec::new(env); + for c_id in cat_ids.iter() { + for t_id in tag_ids.iter() { + if c_id == t_id { + intersection.push_back(c_id); + break; + } + } + } + intersection + } + (Some(cat), None) => env + .storage() + .persistent() + .get(&DataKey::CategoryCampaigns(cat)) + .unwrap_or_else(|| Vec::new(env)), + (None, Some(tag)) => env + .storage() + .persistent() + .get(&DataKey::TagCampaigns(tag)) + .unwrap_or_else(|| Vec::new(env)), + (None, None) => { + let mut all: Vec = Vec::new(env); + for i in 1..=count { + all.push_back(i); + } + all + } + }; + + let mut out: Vec = Vec::new(env); + for id in seeded_ids.iter() { + if let Some(c) = env + .storage() + .persistent() + .get::<_, Campaign>(&DataKey::Campaign(id)) + { + if let Some(active) = filter.is_active { + if c.active != active { + continue; + } + } + if let Some(mid) = filter.merchant_id { + if c.merchant_id != mid { + continue; + } + } + out.push_back(c); + } + } + out +} diff --git a/contracts/shade/src/components/mod.rs b/contracts/shade/src/components/mod.rs index aea912f..c8ff1c5 100644 --- a/contracts/shade/src/components/mod.rs +++ b/contracts/shade/src/components/mod.rs @@ -2,6 +2,7 @@ pub mod access_control; pub mod account_factory; pub mod admin; pub mod auto_withdrawal; +pub mod campaigns; pub mod core; pub mod invoice; pub mod merchant; diff --git a/contracts/shade/src/errors.rs b/contracts/shade/src/errors.rs index 20d761a..a298d99 100644 --- a/contracts/shade/src/errors.rs +++ b/contracts/shade/src/errors.rs @@ -53,4 +53,27 @@ pub enum ContractError { NotTicketOwner = 52, TicketEventMismatch = 53, InvalidResalePrice = 54, + // ── Campaign categories & tagging (#352) ────────────────────────────── + /// Referenced campaign category does not exist. + CampaignCategoryNotFound = 55, + /// A category with the supplied name has already been registered. + CampaignCategoryAlreadyExists = 56, + /// Referenced campaign category exists but is not active. + CampaignCategoryInactive = 57, + /// Referenced campaign tag does not exist. + CampaignTagNotFound = 58, + /// A tag with the supplied name has already been registered. + CampaignTagAlreadyExists = 59, + /// Referenced campaign does not exist. + CampaignNotFound = 60, + /// Campaign goal_amount must be positive. + InvalidCampaignGoal = 61, + /// Campaign deadline must be in the future. + InvalidCampaignDeadline = 62, + /// Operation referred to a campaign that has been deactivated. + CampaignInactive = 63, + /// The caller is not the merchant that owns the campaign. + NotCampaignMerchant = 64, + /// The campaign's deadline has passed and it can no longer accept contributions. + CampaignExpired = 65, } diff --git a/contracts/shade/src/events.rs b/contracts/shade/src/events.rs index f2af789..d4c1cf0 100644 --- a/contracts/shade/src/events.rs +++ b/contracts/shade/src/events.rs @@ -1004,3 +1004,262 @@ pub fn publish_ticket_resold_event( } .publish(env); } + +// ── Campaign categories & tagging (#352) ────────────────────────────────────── + +#[contractevent] +pub struct CampaignCategoryCreatedEvent { + pub category_id: u64, + pub admin: Address, + pub name: String, + pub description: String, + pub timestamp: u64, +} + +pub fn publish_campaign_category_created_event( + env: &Env, + category_id: u64, + admin: Address, + name: String, + description: String, + timestamp: u64, +) { + CampaignCategoryCreatedEvent { + category_id, + admin, + name, + description, + timestamp, + } + .publish(env); +} + +#[contractevent] +pub struct CampaignCategoryUpdatedEvent { + pub category_id: u64, + pub admin: Address, + pub name: String, + pub description: String, + pub active: bool, + pub timestamp: u64, +} + +pub fn publish_campaign_category_updated_event( + env: &Env, + category_id: u64, + admin: Address, + name: String, + description: String, + active: bool, + timestamp: u64, +) { + CampaignCategoryUpdatedEvent { + category_id, + admin, + name, + description, + active, + timestamp, + } + .publish(env); +} + +#[contractevent] +pub struct CampaignTagCreatedEvent { + pub tag_id: u64, + pub creator: Address, + pub name: String, + pub timestamp: u64, +} + +pub fn publish_campaign_tag_created_event( + env: &Env, + tag_id: u64, + creator: Address, + name: String, + timestamp: u64, +) { + CampaignTagCreatedEvent { + tag_id, + creator, + name, + timestamp, + } + .publish(env); +} + +#[allow(clippy::too_many_arguments)] +#[contractevent] +pub struct CampaignCreatedEvent { + pub campaign_id: u64, + pub merchant: Address, + pub merchant_id: u64, + pub title: String, + pub description: String, + pub category_id: u64, + pub tags: Vec, + pub goal_amount: i128, + pub token: Address, + pub deadline: u64, + pub timestamp: u64, +} + +#[allow(clippy::too_many_arguments)] +pub fn publish_campaign_created_event( + env: &Env, + campaign_id: u64, + merchant: Address, + merchant_id: u64, + title: String, + description: String, + category_id: u64, + tags: Vec, + goal_amount: i128, + token: Address, + deadline: u64, + timestamp: u64, +) { + CampaignCreatedEvent { + campaign_id, + merchant, + merchant_id, + title, + description, + category_id, + tags, + goal_amount, + token, + deadline, + timestamp, + } + .publish(env); +} + +#[contractevent] +pub struct CampaignUpdatedEvent { + pub campaign_id: u64, + pub merchant: Address, + pub title: String, + pub description: String, + pub timestamp: u64, +} + +pub fn publish_campaign_updated_event( + env: &Env, + campaign_id: u64, + merchant: Address, + title: String, + description: String, + timestamp: u64, +) { + CampaignUpdatedEvent { + campaign_id, + merchant, + title, + description, + timestamp, + } + .publish(env); +} + +#[contractevent] +pub struct CampaignStatusChangedEvent { + pub campaign_id: u64, + pub merchant: Address, + pub active: bool, + pub timestamp: u64, +} + +pub fn publish_campaign_status_changed_event( + env: &Env, + campaign_id: u64, + merchant: Address, + active: bool, + timestamp: u64, +) { + CampaignStatusChangedEvent { + campaign_id, + merchant, + active, + timestamp, + } + .publish(env); +} + +#[contractevent] +pub struct CampaignTagAddedEvent { + pub campaign_id: u64, + pub merchant: Address, + pub tag_id: u64, + pub timestamp: u64, +} + +pub fn publish_campaign_tag_added_event( + env: &Env, + campaign_id: u64, + merchant: Address, + tag_id: u64, + timestamp: u64, +) { + CampaignTagAddedEvent { + campaign_id, + merchant, + tag_id, + timestamp, + } + .publish(env); +} + +#[contractevent] +pub struct CampaignTagRemovedEvent { + pub campaign_id: u64, + pub merchant: Address, + pub tag_id: u64, + pub timestamp: u64, +} + +pub fn publish_campaign_tag_removed_event( + env: &Env, + campaign_id: u64, + merchant: Address, + tag_id: u64, + timestamp: u64, +) { + CampaignTagRemovedEvent { + campaign_id, + merchant, + tag_id, + timestamp, + } + .publish(env); +} + +#[contractevent] +pub struct CampaignContributionEvent { + pub campaign_id: u64, + pub contributor: Address, + pub amount: i128, + pub raised_amount: i128, + pub goal_amount: i128, + pub timestamp: u64, +} + +pub fn publish_campaign_contribution_event( + env: &Env, + campaign_id: u64, + contributor: Address, + amount: i128, + raised_amount: i128, + goal_amount: i128, + timestamp: u64, +) { + CampaignContributionEvent { + campaign_id, + contributor, + amount, + raised_amount, + goal_amount, + timestamp, + } + .publish(env); +} diff --git a/contracts/shade/src/interface.rs b/contracts/shade/src/interface.rs index 8432329..d505bd9 100644 --- a/contracts/shade/src/interface.rs +++ b/contracts/shade/src/interface.rs @@ -1,7 +1,8 @@ use crate::types::{ - CrossChainBridgePayload, Event, Invoice, InvoiceFilter, Merchant, MerchantAnalytics, - MerchantAnalyticsSummary, MerchantFilter, OracleConfig, PaymentPayload, PendingFee, Role, - Subscription, SubscriptionPlan, Ticket, TokenAnalytics, Transaction + Campaign, CampaignCategory, CampaignFilter, CampaignTag, CrossChainBridgePayload, Event, + Invoice, InvoiceFilter, Merchant, MerchantAnalytics, MerchantAnalyticsSummary, MerchantFilter, + OracleConfig, PaymentPayload, PendingFee, Role, Subscription, SubscriptionPlan, Ticket, + TokenAnalytics, Transaction }; use soroban_sdk::{contracttrait, Address, BytesN, Env, String, Vec}; @@ -234,4 +235,91 @@ pub trait ShadeTrait { /// Get market share of a token as basis points (10000 = 100%) fn get_token_market_share(env: Env, token: Address) -> i128; + + // ── Campaign categories & tagging (#352) ────────────────────────────── + + /// Create a new campaign category. Admin-only. Returns the new category ID. + fn create_campaign_category( + env: Env, + admin: Address, + name: String, + description: String, + ) -> u64; + + /// Update an existing campaign category. Admin-only. + /// All fields are optional; only `Some` values are written. + fn update_campaign_category( + env: Env, + admin: Address, + category_id: u64, + name: Option, + description: Option, + active: Option, + ); + + /// Fetch a campaign category by ID. + fn get_campaign_category(env: Env, category_id: u64) -> CampaignCategory; + + /// List every campaign category ever created. + fn get_campaign_categories(env: Env) -> Vec; + + /// Create a new campaign tag. Callable by the admin or any registered + /// merchant. Returns the new tag ID. + fn create_campaign_tag(env: Env, creator: Address, name: String) -> u64; + + /// Fetch a campaign tag by ID. + fn get_campaign_tag(env: Env, tag_id: u64) -> CampaignTag; + + /// List every campaign tag ever created. + fn get_campaign_tags(env: Env) -> Vec; + + /// Create a new campaign. Merchant-only. The category must exist and be + /// active; every tag ID must reference an existing tag. Returns the new + /// campaign ID. + #[allow(clippy::too_many_arguments)] + fn create_campaign( + env: Env, + merchant: Address, + title: String, + description: String, + category_id: u64, + tags: Vec, + goal_amount: i128, + token: Address, + deadline: u64, + ) -> u64; + + /// Update the title and/or description of an existing campaign. Owner-only. + /// Goal/token/deadline are immutable to preserve the published target. + fn update_campaign( + env: Env, + merchant: Address, + campaign_id: u64, + title: Option, + description: Option, + ); + + /// Toggle a campaign's active flag. Owner-only. + fn set_campaign_active(env: Env, merchant: Address, campaign_id: u64, active: bool); + + /// Attach a tag to a campaign. Owner-only. De-duplicated. + fn add_campaign_tag(env: Env, merchant: Address, campaign_id: u64, tag_id: u64); + + /// Detach a tag from a campaign. Owner-only. + fn remove_campaign_tag(env: Env, merchant: Address, campaign_id: u64, tag_id: u64); + + /// Record a contribution against a campaign. Open to any caller + /// (backers, payment gateways, etc.). + fn record_campaign_contribution( + env: Env, + campaign_id: u64, + contributor: Address, + amount: i128, + ); + + /// Fetch a campaign by ID. + fn get_campaign(env: Env, campaign_id: u64) -> Campaign; + + /// Filtered campaign listing. Any of the filter fields may be `None`. + fn get_campaigns(env: Env, filter: CampaignFilter) -> Vec; } diff --git a/contracts/shade/src/shade.rs b/contracts/shade/src/shade.rs index 4945b64..9fcf121 100644 --- a/contracts/shade/src/shade.rs +++ b/contracts/shade/src/shade.rs @@ -1,6 +1,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, + access_control as access_control_component, admin as admin_component, + campaigns as campaigns_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, }; @@ -8,7 +9,8 @@ use crate::errors::ContractError; use crate::events; use crate::interface::ShadeTrait; use crate::types::{ - ContractInfo, CrossChainBridgePayload, DataKey, Event, Invoice, InvoiceFilter, Merchant, + Campaign, CampaignCategory, CampaignFilter, CampaignTag, ContractInfo, + CrossChainBridgePayload, DataKey, Event, Invoice, InvoiceFilter, Merchant, MerchantAnalytics, MerchantAnalyticsSummary, MerchantFilter, OracleConfig, PaymentPayload, PendingFee, Role, Subscription, SubscriptionPlan, Ticket, TokenAnalytics, Transaction, }; @@ -572,4 +574,127 @@ impl ShadeTrait for Shade { fn get_token_market_share(env: Env, token: Address) -> i128 { admin_component::get_token_market_share(&env, &token) } + + // ── Campaign categories & tagging (#352) ────────────────────────────── + + fn create_campaign_category( + env: Env, + admin: Address, + name: String, + description: String, + ) -> u64 { + pausable_component::assert_not_paused(&env); + campaigns_component::create_category(&env, &admin, &name, &description) + } + + fn update_campaign_category( + env: Env, + admin: Address, + category_id: u64, + name: Option, + description: Option, + active: Option, + ) { + pausable_component::assert_not_paused(&env); + campaigns_component::update_category( + &env, + &admin, + category_id, + name, + description, + active, + ); + } + + fn get_campaign_category(env: Env, category_id: u64) -> CampaignCategory { + campaigns_component::get_category(&env, category_id) + } + + fn get_campaign_categories(env: Env) -> Vec { + campaigns_component::get_categories(&env) + } + + fn create_campaign_tag(env: Env, creator: Address, name: String) -> u64 { + pausable_component::assert_not_paused(&env); + campaigns_component::create_tag(&env, &creator, &name) + } + + fn get_campaign_tag(env: Env, tag_id: u64) -> CampaignTag { + campaigns_component::get_tag(&env, tag_id) + } + + fn get_campaign_tags(env: Env) -> Vec { + campaigns_component::get_tags(&env) + } + + #[allow(clippy::too_many_arguments)] + fn create_campaign( + env: Env, + merchant: Address, + title: String, + description: String, + category_id: u64, + tags: Vec, + goal_amount: i128, + token: Address, + deadline: u64, + ) -> u64 { + pausable_component::assert_not_paused(&env); + campaigns_component::create_campaign( + &env, + &merchant, + &title, + &description, + category_id, + &tags, + goal_amount, + &token, + deadline, + ) + } + + fn update_campaign( + env: Env, + merchant: Address, + campaign_id: u64, + title: Option, + description: Option, + ) { + pausable_component::assert_not_paused(&env); + campaigns_component::update_campaign(&env, &merchant, campaign_id, title, description); + } + + fn set_campaign_active(env: Env, merchant: Address, campaign_id: u64, active: bool) { + pausable_component::assert_not_paused(&env); + campaigns_component::set_campaign_active(&env, &merchant, campaign_id, active); + } + + fn add_campaign_tag(env: Env, merchant: Address, campaign_id: u64, tag_id: u64) { + pausable_component::assert_not_paused(&env); + campaigns_component::add_campaign_tag(&env, &merchant, campaign_id, tag_id); + } + + fn remove_campaign_tag(env: Env, merchant: Address, campaign_id: u64, tag_id: u64) { + pausable_component::assert_not_paused(&env); + campaigns_component::remove_campaign_tag(&env, &merchant, campaign_id, tag_id); + } + + fn record_campaign_contribution( + env: Env, + campaign_id: u64, + contributor: Address, + amount: i128, + ) { + pausable_component::assert_not_paused(&env); + contributor.require_auth(); + campaigns_component::record_contribution(&env, campaign_id, &contributor, amount); + } + + fn get_campaign(env: Env, campaign_id: u64) -> Campaign { + campaigns_component::get_campaign(&env, campaign_id) + } + + fn get_campaigns(env: Env, filter: CampaignFilter) -> Vec { + campaigns_component::get_campaigns(&env, filter) + } } diff --git a/contracts/shade/src/tests/mod.rs b/contracts/shade/src/tests/mod.rs index a313f9f..515efa8 100644 --- a/contracts/shade/src/tests/mod.rs +++ b/contracts/shade/src/tests/mod.rs @@ -42,3 +42,4 @@ pub mod test_upgrade; pub mod test_fiat_pricing; pub mod test_event_tickets; pub mod test_analytics_aggregation; +pub mod test_campaigns; diff --git a/contracts/shade/src/tests/test_campaigns.rs b/contracts/shade/src/tests/test_campaigns.rs new file mode 100644 index 0000000..f1520e7 --- /dev/null +++ b/contracts/shade/src/tests/test_campaigns.rs @@ -0,0 +1,647 @@ +#![cfg(test)] + +use crate::shade::{Shade, ShadeClient}; +use soroban_sdk::testutils::{Address as _, Ledger as _}; +use soroban_sdk::{Address, Env, String, Vec}; + +struct CampaignFixture<'a> { + env: Env, + client: ShadeClient<'a>, + admin: Address, + token: Address, + merchant: Address, +} + +fn setup() -> CampaignFixture<'static> { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(Shade, ()); + let client = ShadeClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + client.initialize(&admin); + + let token_admin = Address::generate(&env); + let token_address = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); + client.add_accepted_token(&admin, &token_address); + + let merchant = Address::generate(&env); + client.register_merchant(&merchant); + + CampaignFixture { + env, + client, + admin, + token: token_address, + merchant, + } +} + +fn future_deadline(env: &Env) -> u64 { + env.ledger().timestamp() + 86_400 +} + +// ── Categories (#352) ───────────────────────────────────────────────────────── + +#[test] +fn create_campaign_category_stores_all_fields() { + let f = setup(); + let id = f.client.create_campaign_category( + &f.admin, + &String::from_str(&f.env, "Technology"), + &String::from_str(&f.env, "Tech campaigns"), + ); + let cat = f.client.get_campaign_category(&id); + assert_eq!(cat.id, id); + assert_eq!(cat.name, String::from_str(&f.env, "Technology")); + assert_eq!(cat.description, String::from_str(&f.env, "Tech campaigns")); + assert!(cat.active); +} + +#[test] +fn get_campaign_categories_returns_all() { + let f = setup(); + f.client.create_campaign_category( + &f.admin, + &String::from_str(&f.env, "Tech"), + &String::from_str(&f.env, "Tech desc"), + ); + f.client.create_campaign_category( + &f.admin, + &String::from_str(&f.env, "Art"), + &String::from_str(&f.env, "Art desc"), + ); + f.client.create_campaign_category( + &f.admin, + &String::from_str(&f.env, "Music"), + &String::from_str(&f.env, "Music desc"), + ); + let cats = f.client.get_campaign_categories(); + assert_eq!(cats.len(), 3); +} + +#[test] +#[should_panic(expected = "Error(Contract, #1)")] // NotAuthorized +fn create_campaign_category_rejects_non_admin() { + let f = setup(); + let imposter = Address::generate(&f.env); + f.client.create_campaign_category( + &imposter, + &String::from_str(&f.env, "Tech"), + &String::from_str(&f.env, "desc"), + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #56)")] // CampaignCategoryAlreadyExists +fn create_campaign_category_rejects_duplicate_name() { + let f = setup(); + f.client.create_campaign_category( + &f.admin, + &String::from_str(&f.env, "Tech"), + &String::from_str(&f.env, "desc"), + ); + f.client.create_campaign_category( + &f.admin, + &String::from_str(&f.env, "Tech"), + &String::from_str(&f.env, "other"), + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #33)")] // InvalidDescription (empty) +fn create_campaign_category_rejects_empty_name() { + let f = setup(); + f.client.create_campaign_category( + &f.admin, + &String::from_str(&f.env, ""), + &String::from_str(&f.env, "desc"), + ); +} + +#[test] +fn update_campaign_category_changes_fields() { + let f = setup(); + let id = f.client.create_campaign_category( + &f.admin, + &String::from_str(&f.env, "Tech"), + &String::from_str(&f.env, "desc"), + ); + f.client.update_campaign_category( + &f.admin, + &id, + &Some(String::from_str(&f.env, "Technology")), + &Some(String::from_str(&f.env, "new desc")), + &Some(false), + ); + let cat = f.client.get_campaign_category(&id); + assert_eq!(cat.name, String::from_str(&f.env, "Technology")); + assert_eq!(cat.description, String::from_str(&f.env, "new desc")); + assert!(!cat.active); +} + +#[test] +#[should_panic(expected = "Error(Contract, #55)")] // CampaignCategoryNotFound +fn get_campaign_category_rejects_missing() { + let f = setup(); + f.client.get_campaign_category(&999); +} + +// ── Tags (#352) ─────────────────────────────────────────────────────────────── + +#[test] +fn create_campaign_tag_by_merchant() { + let f = setup(); + let id = f + .client + .create_campaign_tag(&f.merchant, &String::from_str(&f.env, "rust")); + let tag = f.client.get_campaign_tag(&id); + assert_eq!(tag.name, String::from_str(&f.env, "rust")); + assert_eq!(tag.creator, f.merchant); +} + +#[test] +fn create_campaign_tag_by_admin() { + let f = setup(); + let id = f + .client + .create_campaign_tag(&f.admin, &String::from_str(&f.env, "platform")); + let tag = f.client.get_campaign_tag(&id); + assert_eq!(tag.creator, f.admin); +} + +#[test] +#[should_panic(expected = "Error(Contract, #1)")] // NotAuthorized +fn create_campaign_tag_rejects_unregistered_user() { + let f = setup(); + let imposter = Address::generate(&f.env); + f.client + .create_campaign_tag(&imposter, &String::from_str(&f.env, "x")); +} + +#[test] +#[should_panic(expected = "Error(Contract, #59)")] // CampaignTagAlreadyExists +fn create_campaign_tag_rejects_duplicate_name() { + let f = setup(); + f.client + .create_campaign_tag(&f.merchant, &String::from_str(&f.env, "rust")); + f.client + .create_campaign_tag(&f.merchant, &String::from_str(&f.env, "rust")); +} + +#[test] +fn get_campaign_tags_returns_all() { + let f = setup(); + f.client + .create_campaign_tag(&f.merchant, &String::from_str(&f.env, "a")); + f.client + .create_campaign_tag(&f.merchant, &String::from_str(&f.env, "b")); + let tags = f.client.get_campaign_tags(); + assert_eq!(tags.len(), 2); +} + +// ── Campaigns (#352) ───────────────────────────────────────────────────────── + +#[test] +fn create_campaign_stores_all_fields() { + let f = setup(); + let cat_id = f.client.create_campaign_category( + &f.admin, + &String::from_str(&f.env, "Tech"), + &String::from_str(&f.env, "desc"), + ); + let tag_a = f + .client + .create_campaign_tag(&f.merchant, &String::from_str(&f.env, "rust")); + let tag_b = f + .client + .create_campaign_tag(&f.merchant, &String::from_str(&f.env, "sdk")); + + let id = f.client.create_campaign( + &f.merchant, + &String::from_str(&f.env, "Shade SDK"), + &String::from_str(&f.env, "Reusable Soroban toolkit"), + &cat_id, + &Vec::from_array(&f.env, [tag_a, tag_b]), + &10_000i128, + &f.token, + &future_deadline(&f.env), + ); + + let campaign = f.client.get_campaign(&id); + assert_eq!(campaign.id, id); + assert_eq!(campaign.title, String::from_str(&f.env, "Shade SDK")); + assert_eq!(campaign.category_id, cat_id); + assert_eq!(campaign.tags.len(), 2); + assert_eq!(campaign.goal_amount, 10_000); + assert_eq!(campaign.raised_amount, 0); + assert!(campaign.active); +} + +#[test] +#[should_panic(expected = "Error(Contract, #6)")] // MerchantNotFound +fn create_campaign_rejects_non_merchant() { + let f = setup(); + let imposter = Address::generate(&f.env); + let cat_id = f.client.create_campaign_category( + &f.admin, + &String::from_str(&f.env, "Tech"), + &String::from_str(&f.env, "desc"), + ); + f.client.create_campaign( + &imposter, + &String::from_str(&f.env, "X"), + &String::from_str(&f.env, "x"), + &cat_id, + &Vec::new(&f.env), + &1i128, + &f.token, + &future_deadline(&f.env), + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #61)")] // InvalidCampaignGoal +fn create_campaign_rejects_zero_goal() { + let f = setup(); + let cat_id = f.client.create_campaign_category( + &f.admin, + &String::from_str(&f.env, "Tech"), + &String::from_str(&f.env, "desc"), + ); + f.client.create_campaign( + &f.merchant, + &String::from_str(&f.env, "X"), + &String::from_str(&f.env, "x"), + &cat_id, + &Vec::new(&f.env), + &0i128, + &f.token, + &future_deadline(&f.env), + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #62)")] // InvalidCampaignDeadline +fn create_campaign_rejects_past_deadline() { + let f = setup(); + let cat_id = f.client.create_campaign_category( + &f.admin, + &String::from_str(&f.env, "Tech"), + &String::from_str(&f.env, "desc"), + ); + f.client.create_campaign( + &f.merchant, + &String::from_str(&f.env, "X"), + &String::from_str(&f.env, "x"), + &cat_id, + &Vec::new(&f.env), + &1i128, + &f.token, + &0u64, + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #57)")] // CampaignCategoryInactive +fn create_campaign_rejects_inactive_category() { + let f = setup(); + let cat_id = f.client.create_campaign_category( + &f.admin, + &String::from_str(&f.env, "Tech"), + &String::from_str(&f.env, "desc"), + ); + f.client.update_campaign_category( + &f.admin, + &cat_id, + &None, + &None, + &Some(false), + ); + f.client.create_campaign( + &f.merchant, + &String::from_str(&f.env, "X"), + &String::from_str(&f.env, "x"), + &cat_id, + &Vec::new(&f.env), + &1i128, + &f.token, + &future_deadline(&f.env), + ); +} + +#[test] +fn update_campaign_changes_title_and_description() { + let f = setup(); + let cat_id = f.client.create_campaign_category( + &f.admin, + &String::from_str(&f.env, "Tech"), + &String::from_str(&f.env, "desc"), + ); + let id = f.client.create_campaign( + &f.merchant, + &String::from_str(&f.env, "Old title"), + &String::from_str(&f.env, "Old desc"), + &cat_id, + &Vec::new(&f.env), + &1i128, + &f.token, + &future_deadline(&f.env), + ); + f.client.update_campaign( + &f.merchant, + &id, + &Some(String::from_str(&f.env, "New title")), + &Some(String::from_str(&f.env, "New desc")), + ); + let c = f.client.get_campaign(&id); + assert_eq!(c.title, String::from_str(&f.env, "New title")); + assert_eq!(c.description, String::from_str(&f.env, "New desc")); +} + +#[test] +#[should_panic(expected = "Error(Contract, #64)")] // NotCampaignMerchant +fn update_campaign_rejects_non_owner() { + let f = setup(); + let cat_id = f.client.create_campaign_category( + &f.admin, + &String::from_str(&f.env, "Tech"), + &String::from_str(&f.env, "desc"), + ); + let id = f.client.create_campaign( + &f.merchant, + &String::from_str(&f.env, "X"), + &String::from_str(&f.env, "x"), + &cat_id, + &Vec::new(&f.env), + &1i128, + &f.token, + &future_deadline(&f.env), + ); + let imposter = Address::generate(&f.env); + // Register imposter as a merchant so the MerchantNotFound guard doesn't + // fire first. + f.client.register_merchant(&imposter); + f.client.update_campaign( + &imposter, + &id, + &Some(String::from_str(&f.env, "Hacked")), + &None, + ); +} + +#[test] +fn add_and_remove_campaign_tag_updates_indices() { + let f = setup(); + let cat_id = f.client.create_campaign_category( + &f.admin, + &String::from_str(&f.env, "Tech"), + &String::from_str(&f.env, "desc"), + ); + let tag_one = f + .client + .create_campaign_tag(&f.merchant, &String::from_str(&f.env, "alpha")); + let tag_two = f + .client + .create_campaign_tag(&f.merchant, &String::from_str(&f.env, "beta")); + + let id = f.client.create_campaign( + &f.merchant, + &String::from_str(&f.env, "C"), + &String::from_str(&f.env, "d"), + &cat_id, + &Vec::from_array(&f.env, [tag_one]), + &1i128, + &f.token, + &future_deadline(&f.env), + ); + + // Add a new tag. + f.client.add_campaign_tag(&f.merchant, &id, &tag_two); + let c = f.client.get_campaign(&id); + assert_eq!(c.tags.len(), 2); + + // Adding the same tag again is a no-op. + f.client.add_campaign_tag(&f.merchant, &id, &tag_two); + let c2 = f.client.get_campaign(&id); + assert_eq!(c2.tags.len(), 2); + + // Removing it brings the count back down. + f.client.remove_campaign_tag(&f.merchant, &id, &tag_two); + let c3 = f.client.get_campaign(&id); + assert_eq!(c3.tags.len(), 1); + + // get_campaigns_by_tag should no longer include this campaign. + let by_tag = f.client.get_campaigns(&crate::types::CampaignFilter { + is_active: None, + category_id: None, + tag_id: Some(tag_two), + merchant_id: None, + }); + assert_eq!(by_tag.len(), 0); +} + +#[test] +fn set_campaign_active_toggles_flag() { + let f = setup(); + let cat_id = f.client.create_campaign_category( + &f.admin, + &String::from_str(&f.env, "Tech"), + &String::from_str(&f.env, "desc"), + ); + let id = f.client.create_campaign( + &f.merchant, + &String::from_str(&f.env, "C"), + &String::from_str(&f.env, "d"), + &cat_id, + &Vec::new(&f.env), + &1i128, + &f.token, + &future_deadline(&f.env), + ); + assert!(f.client.get_campaign(&id).active); + f.client.set_campaign_active(&f.merchant, &id, &false); + assert!(!f.client.get_campaign(&id).active); +} + +#[test] +fn record_campaign_contribution_accumulates() { + let f = setup(); + let cat_id = f.client.create_campaign_category( + &f.admin, + &String::from_str(&f.env, "Tech"), + &String::from_str(&f.env, "desc"), + ); + let id = f.client.create_campaign( + &f.merchant, + &String::from_str(&f.env, "C"), + &String::from_str(&f.env, "d"), + &cat_id, + &Vec::new(&f.env), + &1_000i128, + &f.token, + &future_deadline(&f.env), + ); + let contributor = Address::generate(&f.env); + f.client.record_campaign_contribution(&id, &contributor, &250i128); + f.client.record_campaign_contribution(&id, &contributor, &100i128); + let c = f.client.get_campaign(&id); + assert_eq!(c.raised_amount, 350); +} + +#[test] +#[should_panic(expected = "Error(Contract, #63)")] // CampaignInactive +fn record_campaign_contribution_rejects_inactive_campaign() { + let f = setup(); + let cat_id = f.client.create_campaign_category( + &f.admin, + &String::from_str(&f.env, "Tech"), + &String::from_str(&f.env, "desc"), + ); + let id = f.client.create_campaign( + &f.merchant, + &String::from_str(&f.env, "C"), + &String::from_str(&f.env, "d"), + &cat_id, + &Vec::new(&f.env), + &1_000i128, + &f.token, + &future_deadline(&f.env), + ); + f.client.set_campaign_active(&f.merchant, &id, &false); + f.client.record_campaign_contribution(&id, &f.merchant, &10i128); +} + +#[test] +#[should_panic(expected = "Error(Contract, #7)")] // InvalidAmount +fn record_campaign_contribution_rejects_zero_amount() { + let f = setup(); + let cat_id = f.client.create_campaign_category( + &f.admin, + &String::from_str(&f.env, "Tech"), + &String::from_str(&f.env, "desc"), + ); + let id = f.client.create_campaign( + &f.merchant, + &String::from_str(&f.env, "C"), + &String::from_str(&f.env, "d"), + &cat_id, + &Vec::new(&f.env), + &1_000i128, + &f.token, + &future_deadline(&f.env), + ); + f.client.record_campaign_contribution(&id, &f.merchant, &0i128); +} + +#[test] +#[should_panic(expected = "Error(Contract, #65)")] // CampaignExpired +fn record_campaign_contribution_rejects_expired_campaign() { + let f = setup(); + let cat_id = f.client.create_campaign_category( + &f.admin, + &String::from_str(&f.env, "Tech"), + &String::from_str(&f.env, "desc"), + ); + let id = f.client.create_campaign( + &f.merchant, + &String::from_str(&f.env, "C"), + &String::from_str(&f.env, "d"), + &cat_id, + &Vec::new(&f.env), + &1_000i128, + &f.token, + &future_deadline(&f.env), + ); + // Advance the ledger past the deadline. + f.env + .ledger() + .with_mut(|l| l.timestamp = f.env.ledger().timestamp() + 90_000); + f.client + .record_campaign_contribution(&id, &f.merchant, &10i128); +} + +#[test] +fn get_campaigns_filters_by_category_and_tag() { + let f = setup(); + let cat_tech = f.client.create_campaign_category( + &f.admin, + &String::from_str(&f.env, "Tech"), + &String::from_str(&f.env, "Tech desc"), + ); + let cat_art = f.client.create_campaign_category( + &f.admin, + &String::from_str(&f.env, "Art"), + &String::from_str(&f.env, "Art desc"), + ); + let tag_rust = f + .client + .create_campaign_tag(&f.merchant, &String::from_str(&f.env, "rust")); + let tag_soroban = f + .client + .create_campaign_tag(&f.merchant, &String::from_str(&f.env, "soroban")); + + f.client.create_campaign( + &f.merchant, + &String::from_str(&f.env, "TechRustCamp"), + &String::from_str(&f.env, "d"), + &cat_tech, + &Vec::from_array(&f.env, [tag_rust]), + &1i128, + &f.token, + &future_deadline(&f.env), + ); + f.client.create_campaign( + &f.merchant, + &String::from_str(&f.env, "TechSorobanCamp"), + &String::from_str(&f.env, "d"), + &cat_tech, + &Vec::from_array(&f.env, [tag_soroban]), + &1i128, + &f.token, + &future_deadline(&f.env), + ); + f.client.create_campaign( + &f.merchant, + &String::from_str(&f.env, "ArtCamp"), + &String::from_str(&f.env, "d"), + &cat_art, + &Vec::from_array(&f.env, [tag_rust]), + &1i128, + &f.token, + &future_deadline(&f.env), + ); + + // Filter: only Tech category + let only_tech = f.client.get_campaigns(&crate::types::CampaignFilter { + is_active: None, + category_id: Some(cat_tech), + tag_id: None, + merchant_id: None, + }); + assert_eq!(only_tech.len(), 2); + + // Filter: Tech + rust tag intersection + let tech_rust = f.client.get_campaigns(&crate::types::CampaignFilter { + is_active: None, + category_id: Some(cat_tech), + tag_id: Some(tag_rust), + merchant_id: None, + }); + assert_eq!(tech_rust.len(), 1); + assert_eq!( + tech_rust.get(0).unwrap().title, + String::from_str(&f.env, "TechRustCamp") + ); + + // Filter: rust tag across all categories + let all_rust = f.client.get_campaigns(&crate::types::CampaignFilter { + is_active: None, + category_id: None, + tag_id: Some(tag_rust), + merchant_id: None, + }); + assert_eq!(all_rust.len(), 2); +} diff --git a/contracts/shade/src/types.rs b/contracts/shade/src/types.rs index a6bb93d..787fb11 100644 --- a/contracts/shade/src/types.rs +++ b/contracts/shade/src/types.rs @@ -47,6 +47,31 @@ pub enum DataKey { // --- Global token analytics --- TokenAnalytics(Address), TokenVolume(Address), + // --- Campaign categories & tagging system (#352) --- + /// A predefined campaign category created by the admin. + CampaignCategory(u64), + /// Total number of campaign categories ever created (never decreases). + CampaignCategoryCount, + /// Name -> category_id, used to enforce unique category names. + CampaignCategoryName(String), + /// A free-form campaign tag created by a merchant. + CampaignTag(u64), + /// Total number of campaign tags ever created (never decreases). + CampaignTagCount, + /// Name -> tag_id, used to enforce unique tag names. + CampaignTagName(String), + /// A marketing/fundraising campaign registered by a merchant. + Campaign(u64), + /// Total number of campaigns ever created (never decreases). + CampaignCount, + /// Reverse index: category_id -> ordered list of campaign IDs. + CategoryCampaigns(u64), + /// Reverse index: tag_id -> ordered list of campaign IDs. + TagCampaigns(u64), + /// Reverse index: merchant_id -> ordered list of campaign IDs. + MerchantCampaigns(u64), + /// Reverse index: campaign_id -> ordered list of attached tag IDs. + CampaignTagList(u64), } #[contracttype] @@ -358,3 +383,51 @@ pub struct PaymentPayload { pub route: PaymentRoute, pub max_slippage_bps: Option, } + +// ── Campaign categories & tagging (#352) ────────────────────────────────────── + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CampaignCategory { + pub id: u64, + pub name: String, + pub description: String, + pub active: bool, + pub timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CampaignTag { + pub id: u64, + pub name: String, + pub creator: Address, + pub timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Campaign { + pub id: u64, + pub merchant_id: u64, + pub merchant: Address, + pub title: String, + pub description: String, + pub category_id: u64, + pub tags: Vec, + pub goal_amount: i128, + pub token: Address, + pub deadline: u64, + pub raised_amount: i128, + pub active: bool, + pub created_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CampaignFilter { + pub is_active: Option, + pub category_id: Option, + pub tag_id: Option, + pub merchant_id: Option, +}