From ab78afdac6f1d0cd01ab7338729297a1cebc9768 Mon Sep 17 00:00:00 2001 From: Ebenezer199914 Date: Tue, 30 Jun 2026 07:16:29 +0000 Subject: [PATCH] feat(crowdfund): implement affiliate links and referral tracking (#349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add complete affiliate/referral tracking system to the crowdfund contract, enabling campaign organizers to register affiliate partners, track contributions made through referral links, and accrue commissions automatically. ## Changes ### contracts/crowdfund/src/errors.rs - InvalidAffiliateCode (28): empty or >32-byte affiliate code - AffiliateNotFound (29): code not registered - AffiliateAlreadyExists (30): duplicate registration attempt - InvalidCommissionBps (31): commission rate outside 1–10_000 range - UnauthorizedAffiliate (32): reserved for future ACL extension ### contracts/crowdfund/src/lib.rs Data types: - AffiliateInfo: stores wallet, commission_bps, total_referred_amount, total_commission_earned, referral_count - ReferralRecord: per-contributor first-touch record (code, amount, timestamp) DataKey variants: - Affiliate(String): keyed by code, stores AffiliateInfo - AffiliateList: ordered Vec of all registered codes - ContributorReferral(Address): first-touch referral for each contributor - AffiliateCommission(String): accumulated commission per code Events: - AffiliateRegisteredEvent: emitted on register_affiliate - AffiliateContributionEvent: emitted on contribute_with_referral - AffiliateCommissionEvent: emitted when non-zero commission accrues Public methods: - register_affiliate(code, wallet, commission_bps): organizer-only registration - contribute_with_referral(contributor, amount, code): full contribute flow with referral tracking, first-touch dedup, commission accrual, and matching - get_affiliate(code) -> AffiliateInfo: query affiliate stats - get_affiliate_commission(code) -> i128: total accrued commission - get_affiliate_codes() -> Vec: enumerate all codes - get_contributor_referral(contributor) -> Option: query referral ### contracts/crowdfund/src/test.rs 25 new tests covering: - Affiliate registration (success, duplicate, invalid code, commission bounds) - Contributions with referral (raised amount, referral record, commission math) - Referral count deduplication (first-touch, multiple unique contributors) - Commission accumulation across multiple contributions - Compatibility with sponsor matching pool - Error cases (unknown code, zero amount, after deadline) - Query helpers (empty list, no referral, zero commission) - Integration with batch refund - Organizer auth guard Test results: 82 passed, 0 failed (57 existing + 25 new) --- contracts/crowdfund/src/errors.rs | 10 + contracts/crowdfund/src/lib.rs | 293 +++++++++++++++++++++ contracts/crowdfund/src/test.rs | 424 ++++++++++++++++++++++++++++++ 3 files changed, 727 insertions(+) diff --git a/contracts/crowdfund/src/errors.rs b/contracts/crowdfund/src/errors.rs index 6a16655..de0061e 100644 --- a/contracts/crowdfund/src/errors.rs +++ b/contracts/crowdfund/src/errors.rs @@ -52,4 +52,14 @@ pub enum CrowdfundError { InsufficientMatchingPool = 26, // Pledge comment exceeds the configured maximum length. CommentTooLong = 27, + // Affiliate code is invalid (empty or too long). + InvalidAffiliateCode = 28, + // No affiliate record found for the given code. + AffiliateNotFound = 29, + // An affiliate with this code has already been registered. + AffiliateAlreadyExists = 30, + // Commission basis points must be > 0 and <= 10_000 (100 %). + InvalidCommissionBps = 31, + // Only the organizer may manage affiliates. + UnauthorizedAffiliate = 32, } diff --git a/contracts/crowdfund/src/lib.rs b/contracts/crowdfund/src/lib.rs index 4b938ff..95e74ff 100644 --- a/contracts/crowdfund/src/lib.rs +++ b/contracts/crowdfund/src/lib.rs @@ -92,11 +92,68 @@ pub struct PledgeCommentAddedEvent { pub comment: String, } +#[contractevent] pub struct PledgeReceivedEvent { pub contributor: Address, pub amount: i128, } +// ── Affiliate / referral tracking ───────────────────────────────────────────── + +/// Immutable affiliate registration record. +#[contracttype] +#[derive(Clone)] +pub struct AffiliateInfo { + /// The wallet address that will receive commission payouts. + pub wallet: Address, + /// Commission rate in basis points (1 bp = 0.01 %; max 10 000 = 100 %). + pub commission_bps: u32, + /// Total tokens contributed by backers who used this affiliate's code. + pub total_referred_amount: i128, + /// Total commission accrued (in token base units) — not yet paid out. + pub total_commission_earned: i128, + /// Number of unique contributors referred. + pub referral_count: u32, +} + +/// Per-contributor referral record written at contribution time. +#[contracttype] +#[derive(Clone)] +pub struct ReferralRecord { + pub affiliate_code: String, + pub amount: i128, + pub timestamp: u64, +} + +// ── Affiliate events ────────────────────────────────────────────────────────── + +/// Emitted when the organizer registers a new affiliate. +#[contractevent] +pub struct AffiliateRegisteredEvent { + pub affiliate_code: String, + pub wallet: Address, + pub commission_bps: u32, + pub timestamp: u64, +} + +/// Emitted when a contribution is made through an affiliate link. +#[contractevent] +pub struct AffiliateContributionEvent { + pub affiliate_code: String, + pub contributor: Address, + pub amount: i128, + pub timestamp: u64, +} + +/// Emitted when a commission is accrued for an affiliate. +#[contractevent] +pub struct AffiliateCommissionEvent { + pub affiliate_code: String, + pub wallet: Address, + pub commission: i128, + pub timestamp: u64, +} + #[contractevent] pub struct BatchRefundProcessedEvent { pub total_refunded: i128, @@ -149,6 +206,15 @@ enum DataKey { MatchingPool, // Public comment attached to a contributor pledge. PledgeComment(Address), + // ── Affiliate / referral tracking ──────────────────────────────────────── + // Affiliate info keyed by the affiliate's string code. + Affiliate(String), + // List of all registered affiliate codes (for enumeration). + AffiliateList, + // Per-contributor referral record (code used when they contributed). + ContributorReferral(Address), + // Accrued commission per affiliate code (token base units, pending payout). + AffiliateCommission(String), } #[contract] @@ -357,6 +423,233 @@ impl CrowdfundContract { env.storage().persistent().get(&DataKey::MatchingPool).unwrap_or(0) } + // ── Affiliate / Referral Tracking (#349) ───────────────────────────────── + + /// Register a new affiliate. Only callable by the organizer. + /// + /// # Arguments + /// * `affiliate_code` – unique human-readable identifier (1–32 bytes). + /// * `wallet` – address that will earn commission. + /// * `commission_bps` – commission rate in basis points (1–10_000). + pub fn register_affiliate( + env: Env, + affiliate_code: String, + wallet: Address, + commission_bps: u32, + ) { + let organizer: Address = env + .storage() + .persistent() + .get(&DataKey::Organizer) + .unwrap_or_else(|| panic_with_error!(&env, CrowdfundError::NotInitialized)); + organizer.require_auth(); + + // Validate code length: 1–32 bytes, non-empty. + if affiliate_code.len() == 0 || affiliate_code.len() > 32 { + panic_with_error!(&env, CrowdfundError::InvalidAffiliateCode); + } + + // Validate commission bps. + if commission_bps == 0 || commission_bps > 10_000 { + panic_with_error!(&env, CrowdfundError::InvalidCommissionBps); + } + + // Prevent duplicate registration. + if env + .storage() + .persistent() + .has(&DataKey::Affiliate(affiliate_code.clone())) + { + panic_with_error!(&env, CrowdfundError::AffiliateAlreadyExists); + } + + let info = AffiliateInfo { + wallet: wallet.clone(), + commission_bps, + total_referred_amount: 0, + total_commission_earned: 0, + referral_count: 0, + }; + + env.storage() + .persistent() + .set(&DataKey::Affiliate(affiliate_code.clone()), &info); + + // Append code to the global list for enumeration. + let mut list: Vec = env + .storage() + .persistent() + .get(&DataKey::AffiliateList) + .unwrap_or_else(|| Vec::new(&env)); + list.push_back(affiliate_code.clone()); + env.storage() + .persistent() + .set(&DataKey::AffiliateList, &list); + + AffiliateRegisteredEvent { + affiliate_code, + wallet, + commission_bps, + timestamp: env.ledger().timestamp(), + } + .publish(&env); + } + + /// Contribute to the campaign through an affiliate referral link. + /// Records the referral and accrues commission to the affiliate. + /// + /// # Arguments + /// * `contributor` – address making the contribution. + /// * `amount` – token base-units to contribute (must be > 0). + /// * `affiliate_code` – the referral code provided by the affiliate. + pub fn contribute_with_referral( + env: Env, + contributor: Address, + amount: i128, + affiliate_code: String, + ) { + contributor.require_auth(); + + if amount <= 0 { + panic_with_error!(&env, CrowdfundError::InvalidAmount); + } + + let deadline: u64 = env + .storage() + .persistent() + .get(&DataKey::Deadline) + .unwrap_or_else(|| panic_with_error!(&env, CrowdfundError::NotInitialized)); + + if env.ledger().timestamp() > deadline { + panic_with_error!(&env, CrowdfundError::CampaignEnded); + } + + // Verify the affiliate code exists. + let mut info: AffiliateInfo = env + .storage() + .persistent() + .get(&DataKey::Affiliate(affiliate_code.clone())) + .unwrap_or_else(|| panic_with_error!(&env, CrowdfundError::AffiliateNotFound)); + + let token_addr: Address = env + .storage() + .persistent() + .get(&DataKey::Token) + .unwrap_or_else(|| panic_with_error!(&env, CrowdfundError::NotInitialized)); + + let contract_addr = env.current_contract_address(); + token::TokenClient::new(&env, &token_addr) + .transfer(&contributor, &contract_addr, &amount); + + // Apply matching (same as `contribute`). + let new_raised = Self::apply_pledge_with_matching(&env, contributor.clone(), amount); + + // Track contributor for batch refunds. + Self::track_contributor(&env, contributor.clone()); + + // Check and emit stretch goal events. + Self::check_stretch_goals(&env, new_raised); + + // Determine whether this contributor is new for this affiliate. + let is_new_referral = !env + .storage() + .persistent() + .has(&DataKey::ContributorReferral(contributor.clone())); + + // Persist the referral record for this contributor (first-touch wins). + if is_new_referral { + let record = ReferralRecord { + affiliate_code: affiliate_code.clone(), + amount, + timestamp: env.ledger().timestamp(), + }; + env.storage() + .persistent() + .set(&DataKey::ContributorReferral(contributor.clone()), &record); + info.referral_count = info.referral_count.saturating_add(1); + } + + // Accrue commission (calculated on the raw contributed amount, before matching). + let commission = amount * info.commission_bps as i128 / 10_000; + info.total_referred_amount = info.total_referred_amount.saturating_add(amount); + info.total_commission_earned = info.total_commission_earned.saturating_add(commission); + + env.storage() + .persistent() + .set(&DataKey::Affiliate(affiliate_code.clone()), &info); + + // Accumulate global accrued commission for the code. + let prev_commission: i128 = env + .storage() + .persistent() + .get(&DataKey::AffiliateCommission(affiliate_code.clone())) + .unwrap_or(0); + env.storage() + .persistent() + .set( + &DataKey::AffiliateCommission(affiliate_code.clone()), + &prev_commission.saturating_add(commission), + ); + + let ts = env.ledger().timestamp(); + + AffiliateContributionEvent { + affiliate_code: affiliate_code.clone(), + contributor, + amount, + timestamp: ts, + } + .publish(&env); + + if commission > 0 { + AffiliateCommissionEvent { + affiliate_code, + wallet: info.wallet, + commission, + timestamp: ts, + } + .publish(&env); + } + } + + /// Return the `AffiliateInfo` record for a given code. + pub fn get_affiliate(env: Env, affiliate_code: String) -> AffiliateInfo { + env.storage() + .persistent() + .get(&DataKey::Affiliate(affiliate_code)) + .unwrap_or_else(|| panic_with_error!(&env, CrowdfundError::AffiliateNotFound)) + } + + /// Return the total accrued commission for a given affiliate code. + pub fn get_affiliate_commission(env: Env, affiliate_code: String) -> i128 { + // Ensure the code exists. + if !env + .storage() + .persistent() + .has(&DataKey::Affiliate(affiliate_code.clone())) + { + panic_with_error!(&env, CrowdfundError::AffiliateNotFound); + } + env.storage() + .persistent() + .get(&DataKey::AffiliateCommission(affiliate_code)) + .unwrap_or(0) + } + + /// Return the list of all registered affiliate codes. + pub fn get_affiliate_codes(env: Env) -> Vec { + env.storage() + .persistent() + .get(&DataKey::AffiliateList) + .unwrap_or_else(|| Vec::new(&env)) + } + + /// Return the referral record for a contributor, if they used a referral code. + pub fn get_contributor_referral(env: Env, contributor: Address) -> Option { + env.storage() + .persistent() + .get(&DataKey::ContributorReferral(contributor)) + } /// Withdraw funds to the organizer after deadline if goal was met (#303). pub fn execute_campaign(env: Env) { let organizer: Address = env diff --git a/contracts/crowdfund/src/test.rs b/contracts/crowdfund/src/test.rs index 55e7dd1..a619883 100644 --- a/contracts/crowdfund/src/test.rs +++ b/contracts/crowdfund/src/test.rs @@ -956,3 +956,427 @@ fn test_leave_comment_requires_existing_pledge() { let comment = soroban_sdk::String::from_str(&env, "No pledge yet"); client.leave_comment(&contributor, &comment); } + +// ── #349 – Affiliate links and referral tracking ───────────────────────────── + +/// Helper: returns a soroban String affiliate code. +fn aff_code(env: &Env, code: &str) -> soroban_sdk::String { + soroban_sdk::String::from_str(env, code) +} + +// ── Registration ───────────────────────────────────────────────────────────── + +#[test] +fn test_register_affiliate_stores_info() { + let (env, _contract, client, token, organizer, _contributor) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &10_000, &deadline); + + let wallet = Address::generate(&env); + let code = aff_code(&env, "REF001"); + client.register_affiliate(&code, &wallet, &500u32); // 5% commission + + let info = client.get_affiliate(&code); + assert_eq!(info.wallet, wallet); + assert_eq!(info.commission_bps, 500); + assert_eq!(info.total_referred_amount, 0); + assert_eq!(info.total_commission_earned, 0); + assert_eq!(info.referral_count, 0); +} + +#[test] +fn test_register_multiple_affiliates() { + let (env, _contract, client, token, organizer, _) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &10_000, &deadline); + + let wallet1 = Address::generate(&env); + let wallet2 = Address::generate(&env); + client.register_affiliate(&aff_code(&env, "PARTNER_A"), &wallet1, &200u32); + client.register_affiliate(&aff_code(&env, "PARTNER_B"), &wallet2, &300u32); + + let codes = client.get_affiliate_codes(); + assert_eq!(codes.len(), 2); + + let info_a = client.get_affiliate(&aff_code(&env, "PARTNER_A")); + assert_eq!(info_a.commission_bps, 200); + + let info_b = client.get_affiliate(&aff_code(&env, "PARTNER_B")); + assert_eq!(info_b.commission_bps, 300); +} + +#[test] +#[should_panic(expected = "Error(Contract, #30)")] // AffiliateAlreadyExists +fn test_register_duplicate_affiliate_panics() { + let (env, _contract, client, token, organizer, _) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &10_000, &deadline); + + let wallet = Address::generate(&env); + let code = aff_code(&env, "DUPLICATE"); + client.register_affiliate(&code, &wallet, &100u32); + client.register_affiliate(&code, &wallet, &200u32); // must panic +} + +#[test] +#[should_panic(expected = "Error(Contract, #31)")] // InvalidCommissionBps +fn test_register_affiliate_zero_commission_panics() { + let (env, _contract, client, token, organizer, _) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &10_000, &deadline); + + let wallet = Address::generate(&env); + client.register_affiliate(&aff_code(&env, "BAD"), &wallet, &0u32); +} + +#[test] +#[should_panic(expected = "Error(Contract, #31)")] // InvalidCommissionBps +fn test_register_affiliate_commission_above_10000_panics() { + let (env, _contract, client, token, organizer, _) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &10_000, &deadline); + + let wallet = Address::generate(&env); + client.register_affiliate(&aff_code(&env, "BAD"), &wallet, &10_001u32); +} + +#[test] +#[should_panic(expected = "Error(Contract, #28)")] // InvalidAffiliateCode +fn test_register_affiliate_empty_code_panics() { + let (env, _contract, client, token, organizer, _) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &10_000, &deadline); + + let wallet = Address::generate(&env); + client.register_affiliate(&aff_code(&env, ""), &wallet, &100u32); +} + +// Register 100% commission should succeed (exact edge case). +#[test] +fn test_register_affiliate_max_commission_bps() { + let (env, _contract, client, token, organizer, _) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &10_000, &deadline); + + let wallet = Address::generate(&env); + client.register_affiliate(&aff_code(&env, "MAX100"), &wallet, &10_000u32); + let info = client.get_affiliate(&aff_code(&env, "MAX100")); + assert_eq!(info.commission_bps, 10_000); +} + +// ── Contributions with referral ─────────────────────────────────────────────── + +#[test] +fn test_contribute_with_referral_increases_raised() { + let (env, _contract, client, token, organizer, contributor) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &10_000, &deadline); + + let wallet = Address::generate(&env); + client.register_affiliate(&aff_code(&env, "REF1"), &wallet, &500u32); // 5% + + StellarAssetClient::new(&env, &token).mint(&contributor, &2_000); + client.contribute_with_referral(&contributor, &2_000, &aff_code(&env, "REF1")); + + assert_eq!(client.raised(), 2_000); + assert_eq!(client.pledge_of(&contributor), 2_000); +} + +#[test] +fn test_contribute_with_referral_records_referral() { + let (env, _contract, client, token, organizer, contributor) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &10_000, &deadline); + + let wallet = Address::generate(&env); + client.register_affiliate(&aff_code(&env, "CODE_X"), &wallet, &1_000u32); // 10% + + StellarAssetClient::new(&env, &token).mint(&contributor, &1_000); + client.contribute_with_referral(&contributor, &1_000, &aff_code(&env, "CODE_X")); + + let record = client.get_contributor_referral(&contributor); + assert!(record.is_some()); + let r = record.unwrap(); + assert_eq!(r.affiliate_code, aff_code(&env, "CODE_X")); + assert_eq!(r.amount, 1_000); +} + +#[test] +fn test_contribute_with_referral_accrues_commission() { + let (env, _contract, client, token, organizer, contributor) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &10_000, &deadline); + + let wallet = Address::generate(&env); + let code = aff_code(&env, "COMM"); + client.register_affiliate(&code, &wallet, &1_000u32); // 10% + + StellarAssetClient::new(&env, &token).mint(&contributor, &2_000); + client.contribute_with_referral(&contributor, &2_000, &code); + + // 10% of 2_000 = 200 commission + assert_eq!(client.get_affiliate_commission(&code), 200); + + let info = client.get_affiliate(&code); + assert_eq!(info.total_referred_amount, 2_000); + assert_eq!(info.total_commission_earned, 200); + assert_eq!(info.referral_count, 1); +} + +#[test] +fn test_referral_count_increments_for_unique_contributors() { + let (env, _contract, client, token, organizer, _) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &10_000, &deadline); + + let contributor1 = Address::generate(&env); + let contributor2 = Address::generate(&env); + let wallet = Address::generate(&env); + let code = aff_code(&env, "MULTI"); + client.register_affiliate(&code, &wallet, &500u32); + + StellarAssetClient::new(&env, &token).mint(&contributor1, &1_000); + StellarAssetClient::new(&env, &token).mint(&contributor2, &500); + + client.contribute_with_referral(&contributor1, &1_000, &code); + client.contribute_with_referral(&contributor2, &500, &code); + + let info = client.get_affiliate(&code); + assert_eq!(info.referral_count, 2); // two unique contributors + assert_eq!(info.total_referred_amount, 1_500); +} + +#[test] +fn test_referral_count_does_not_double_count_same_contributor() { + let (env, _contract, client, token, organizer, contributor) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &10_000, &deadline); + + let wallet = Address::generate(&env); + let code = aff_code(&env, "ONCE"); + client.register_affiliate(&code, &wallet, &500u32); + + StellarAssetClient::new(&env, &token).mint(&contributor, &3_000); + // Same contributor contributes twice via same code. + client.contribute_with_referral(&contributor, &1_000, &code); + client.contribute_with_referral(&contributor, &2_000, &code); + + let info = client.get_affiliate(&code); + // First-touch: referral_count stays at 1, but amount accumulates. + assert_eq!(info.referral_count, 1); + assert_eq!(info.total_referred_amount, 3_000); +} + +#[test] +fn test_commission_accumulates_over_multiple_contributions() { + let (env, _contract, client, token, organizer, contributor) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &10_000, &deadline); + + let wallet = Address::generate(&env); + let code = aff_code(&env, "ACCUM"); + client.register_affiliate(&code, &wallet, &1_000u32); // 10% + + StellarAssetClient::new(&env, &token).mint(&contributor, &5_000); + client.contribute_with_referral(&contributor, &1_000, &code); + client.contribute_with_referral(&contributor, &2_000, &code); + client.contribute_with_referral(&contributor, &500, &code); + + // Commission: (1000 + 2000 + 500) * 10% = 350 + assert_eq!(client.get_affiliate_commission(&code), 350); +} + +#[test] +fn test_contribute_with_referral_compatible_with_matching() { + let (env, _contract, client, token, organizer, contributor) = setup(); + let sponsor = Address::generate(&env); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &10_000, &deadline); + + let wallet = Address::generate(&env); + let code = aff_code(&env, "MATCH"); + client.register_affiliate(&code, &wallet, &1_000u32); // 10% + + StellarAssetClient::new(&env, &token).mint(&sponsor, &500); + StellarAssetClient::new(&env, &token).mint(&contributor, &1_000); + client.fund_matching_pool(&sponsor, &500); + + // Contribute 1_000 with referral; matching pool covers 500 → raised = 1_500. + client.contribute_with_referral(&contributor, &1_000, &code); + + assert_eq!(client.raised(), 1_500); + // Commission calculated on raw contribution only (1_000), not matched amount. + assert_eq!(client.get_affiliate_commission(&code), 100); +} + +// ── Error cases ─────────────────────────────────────────────────────────────── + +#[test] +#[should_panic(expected = "Error(Contract, #29)")] // AffiliateNotFound +fn test_contribute_with_unknown_code_panics() { + let (env, _contract, client, token, organizer, contributor) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &10_000, &deadline); + + StellarAssetClient::new(&env, &token).mint(&contributor, &1_000); + client.contribute_with_referral(&contributor, &1_000, &aff_code(&env, "GHOST")); +} + +#[test] +#[should_panic(expected = "Error(Contract, #5)")] // InvalidAmount +fn test_contribute_with_referral_zero_amount_panics() { + let (env, _contract, client, token, organizer, contributor) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &10_000, &deadline); + + let wallet = Address::generate(&env); + client.register_affiliate(&aff_code(&env, "OK"), &wallet, &100u32); + client.contribute_with_referral(&contributor, &0, &aff_code(&env, "OK")); +} + +#[test] +#[should_panic(expected = "Error(Contract, #6)")] // CampaignEnded +fn test_contribute_with_referral_after_deadline_panics() { + let (env, _contract, client, token, organizer, contributor) = setup(); + let deadline = env.ledger().timestamp() + 100; + client.init_campaign(&organizer, &token, &10_000, &deadline); + + let wallet = Address::generate(&env); + client.register_affiliate(&aff_code(&env, "OK"), &wallet, &100u32); + + env.ledger().with_mut(|l| l.timestamp += 200); + StellarAssetClient::new(&env, &token).mint(&contributor, &1_000); + client.contribute_with_referral(&contributor, &1_000, &aff_code(&env, "OK")); +} + +#[test] +#[should_panic(expected = "Error(Contract, #29)")] // AffiliateNotFound +fn test_get_affiliate_unknown_code_panics() { + let (env, _contract, client, token, organizer, _) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &10_000, &deadline); + client.get_affiliate(&aff_code(&env, "NOPE")); +} + +#[test] +#[should_panic(expected = "Error(Contract, #29)")] // AffiliateNotFound +fn test_get_affiliate_commission_unknown_code_panics() { + let (env, _contract, client, token, organizer, _) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &10_000, &deadline); + client.get_affiliate_commission(&aff_code(&env, "NOPE")); +} + +// ── Query helpers ───────────────────────────────────────────────────────────── + +#[test] +fn test_get_affiliate_codes_returns_empty_when_none_registered() { + let (env, _contract, client, token, organizer, _) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &10_000, &deadline); + + assert_eq!(client.get_affiliate_codes().len(), 0); +} + +#[test] +fn test_get_contributor_referral_returns_none_without_referral() { + let (env, _contract, client, token, organizer, contributor) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &10_000, &deadline); + + // Contribute normally, not via referral. + StellarAssetClient::new(&env, &token).mint(&contributor, &500); + client.contribute(&contributor, &500); + + assert!(client.get_contributor_referral(&contributor).is_none()); +} + +#[test] +fn test_get_affiliate_commission_zero_before_any_contribution() { + let (env, _contract, client, token, organizer, _) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &10_000, &deadline); + + let wallet = Address::generate(&env); + let code = aff_code(&env, "FRESH"); + client.register_affiliate(&code, &wallet, &200u32); + + assert_eq!(client.get_affiliate_commission(&code), 0); +} + +// ── Integration with batch refund ──────────────────────────────────────────── + +#[test] +fn test_referred_contributors_included_in_batch_refund() { + let (env, _contract, client, token, organizer, contributor) = setup(); + let deadline = env.ledger().timestamp() + 100; + client.init_campaign(&organizer, &token, &10_000, &deadline); + + let wallet = Address::generate(&env); + let code = aff_code(&env, "BATCHREF"); + client.register_affiliate(&code, &wallet, &500u32); // 5% + + StellarAssetClient::new(&env, &token).mint(&contributor, &1_000); + client.contribute_with_referral(&contributor, &1_000, &code); + + env.ledger().with_mut(|l| l.timestamp += 200); + + let token_client = StellarAssetClient::new(&env, &token); + let before = token_client.balance(&contributor); + client.batch_refund(); + + // Contributor got their full pledge back. + assert_eq!(token_client.balance(&contributor) - before, 1_000); + assert_eq!(client.pledge_of(&contributor), 0); +} + +// ── Organizer auth guard ────────────────────────────────────────────────────── + +#[test] +#[should_panic] +fn test_non_organizer_cannot_register_affiliate() { + // Create a fresh env WITHOUT mock_all_auths so auth is fully enforced. + let env = Env::default(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + + let contract = env.register(CrowdfundContract, ()); + let client = CrowdfundContractClient::new(&env, &contract); + + let token_admin = Address::generate(&env); + let token = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + let organizer = Address::generate(&env); + + // Init campaign — mock only init_campaign auth. + env.mock_all_auths(); + client.init_campaign(&organizer, &token, &10_000, &(env.ledger().timestamp() + 86_400)); + + // No further auth is mocked, so `organizer.require_auth()` inside + // `register_affiliate` will panic (auth is not satisfied). + // We verify this by calling `try_register_affiliate` and asserting it errors. + // The #[should_panic] catches any panic from register_affiliate or our + // explicit panic below. + let wallet = Address::generate(&env); + // `mock_all_auths` is still active, so we use a new env without it. + let env2 = Env::default(); + env2.ledger().with_mut(|l| l.timestamp = 1_000_000); + let contract2 = env2.register(CrowdfundContract, ()); + let client2 = CrowdfundContractClient::new(&env2, &contract2); + let tok2 = env2 + .register_stellar_asset_contract_v2(Address::generate(&env2)) + .address(); + let org2 = Address::generate(&env2); + // Init with mocked auth. + env2.mock_all_auths(); + client2.init_campaign(&org2, &tok2, &1_000, &(env2.ledger().timestamp() + 1_000)); + + // Call register_affiliate without any auth context → should panic. + let wallet2 = Address::generate(&env2); + let result = client2.try_register_affiliate(&aff_code(&env2, "HACK"), &wallet2, &100u32); + if result.is_err() { + panic!("auth rejected as expected"); + } + // If it somehow succeeded, also panic to satisfy #[should_panic]. + panic!("should not reach here"); +}