From 2a0407d1ba8e78310779ce367da951d18f030e80 Mon Sep 17 00:00:00 2001 From: Emmanuel Itighise <77761768+EmeditWeb@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:42:05 +0000 Subject: [PATCH] feat: add CampaignApplicants index and campaign_applicants view function Replace O(1) ApplicantCount counter with CampaignApplicants Vec
to support listing all applicants for a campaign. - Add DataKey::CampaignApplicants(u64) storing Vec
- Update add_campaign_applicant to push_back creator to the ordered list - Add campaign_applicants(campaign_id) view function returning Vec
- Add TTL bump on both read and write for CampaignApplicants key - Guard against double-application via existing get_application check - Update has_campaign_applicants to check non-empty Vec - Add tests: 3 creators returned in order, empty list, double-apply guard Close #8 --- contracts/campaign-escrow/src/lib.rs | 11 +++- contracts/campaign-escrow/src/storage.rs | 55 ++++++++++++----- contracts/campaign-escrow/src/test.rs | 77 ++++++++++++++---------- 3 files changed, 95 insertions(+), 48 deletions(-) diff --git a/contracts/campaign-escrow/src/lib.rs b/contracts/campaign-escrow/src/lib.rs index d5fa716..466b693 100644 --- a/contracts/campaign-escrow/src/lib.rs +++ b/contracts/campaign-escrow/src/lib.rs @@ -21,7 +21,7 @@ pub use error::Error; pub use types::{Application, Campaign, DisputeResolution, ProtocolConfig}; use ads_bazaar_shared::{ApplicationStatus, CampaignId, CampaignStatus, PayoutAsset}; -use soroban_sdk::{contract, contractimpl, token, Address, BytesN, Env, String}; +use soroban_sdk::{contract, contractimpl, token, Address, BytesN, Env, String, Vec}; /// Version string stored at `initialize` time. `upgrade` swaps the WASM /// binary but does not bump this on its own — see the TODO on `upgrade` @@ -963,6 +963,15 @@ impl CampaignEscrowContract { Ok(()) } + /// Read-only lookup of the ordered list of creator addresses that have + /// applied to a campaign (not yet filtered by status). Returns an empty + /// `Vec` when no one has applied yet. The caller can then fetch + /// individual `get_application(campaign_id, creator)` to read each + /// applicant's status. + pub fn campaign_applicants(env: Env, campaign_id: CampaignId) -> Vec
{ + storage::get_campaign_applicants(&env, campaign_id) + } + /// Read-only lookup of a campaign's current state. pub fn get_campaign(env: Env, campaign_id: CampaignId) -> Result { storage::get_campaign(&env, campaign_id) diff --git a/contracts/campaign-escrow/src/storage.rs b/contracts/campaign-escrow/src/storage.rs index 89f9fa5..1d09356 100644 --- a/contracts/campaign-escrow/src/storage.rs +++ b/contracts/campaign-escrow/src/storage.rs @@ -1,7 +1,7 @@ #![allow(dead_code)] use ads_bazaar_shared::CampaignId; -use soroban_sdk::{contracttype, Address, Env, String}; +use soroban_sdk::{contracttype, Address, Env, String, Vec}; use crate::error::Error; use crate::types::{Application, Campaign}; @@ -35,10 +35,12 @@ pub enum DataKey { /// Whether the contract is currently paused. See `require_not_paused` /// and `pause`/`unpause` in `lib.rs`. Paused, - /// Count of creators that have applied to a campaign. Used by + /// Ordered list of creator addresses that have applied to a campaign. + /// Used by `campaign_applicants` to enumerate all applicants so clients + /// can list them without relying on event logs alone. Also used by /// `update_campaign_metadata` to enforce that the brief is locked once /// any creator has applied. - ApplicantCount(CampaignId), + CampaignApplicants(CampaignId), } pub fn is_initialized(env: &Env) -> bool { @@ -188,14 +190,20 @@ pub fn set_paused(env: &Env, paused: bool) { env.storage().instance().set(&DataKey::Paused, &paused); } -/// Increment the applicant count for `campaign_id`. Called from -/// `apply_to_campaign` so `update_campaign_metadata` can lock the brief -/// once at least one creator has applied. O(1) regardless of how many -/// creators have applied, unlike an ever-growing list of applicants. -pub fn add_campaign_applicant(env: &Env, campaign_id: CampaignId, _creator: &Address) { - let key = DataKey::ApplicantCount(campaign_id); - let count: u32 = env.storage().persistent().get(&key).unwrap_or(0); - env.storage().persistent().set(&key, &(count + 1)); +/// Append the creator's address to the ordered applicant list for +/// `campaign_id`. Called from `apply_to_campaign` so clients can enumerate +/// every applicant via `campaign_applicants` without relying on event +/// logs alone, and so `update_campaign_metadata` can lock the brief once +/// at least one creator has applied. +pub fn add_campaign_applicant(env: &Env, campaign_id: CampaignId, creator: &Address) { + let key = DataKey::CampaignApplicants(campaign_id); + let mut applicants: Vec
= env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| Vec::new(env)); + applicants.push_back(creator.clone()); + env.storage().persistent().set(&key, &applicants); env.storage().persistent().extend_ttl( &key, PERSISTENT_LIFETIME_THRESHOLD, @@ -205,9 +213,28 @@ pub fn add_campaign_applicant(env: &Env, campaign_id: CampaignId, _creator: &Add /// Return whether any creator has applied to `campaign_id`. pub fn has_campaign_applicants(env: &Env, campaign_id: CampaignId) -> bool { - let count: Option = env + let applicants: Option> = env .storage() .persistent() - .get(&DataKey::ApplicantCount(campaign_id)); - count.is_some_and(|c| c > 0) + .get(&DataKey::CampaignApplicants(campaign_id)); + applicants.is_some_and(|v| !v.is_empty()) +} + +/// Return the ordered list of creator addresses that have applied to +/// `campaign_id`. Returns an empty `Vec` when no one has applied yet. +/// Bumps the TTL of the `CampaignApplicants` persistent entry on every +/// read so it doesn't expire from ledger storage (only when the entry +/// already exists — an empty list with no stored key is harmless to +/// skip). +pub fn get_campaign_applicants(env: &Env, campaign_id: CampaignId) -> Vec
{ + let key = DataKey::CampaignApplicants(campaign_id); + let applicants: Option> = env.storage().persistent().get(&key); + if let Some(ref _applicants) = applicants { + env.storage().persistent().extend_ttl( + &key, + PERSISTENT_LIFETIME_THRESHOLD, + PERSISTENT_BUMP_LEDGERS, + ); + } + applicants.unwrap_or_else(|| Vec::new(env)) } diff --git a/contracts/campaign-escrow/src/test.rs b/contracts/campaign-escrow/src/test.rs index 4a4ac5e..3c1b096 100644 --- a/contracts/campaign-escrow/src/test.rs +++ b/contracts/campaign-escrow/src/test.rs @@ -1496,48 +1496,59 @@ mod test_update_metadata { assert_eq!(result, Err(Ok(Error::InvalidStatus))); } - /// Applying to a campaign must stay O(1) in storage-write cost - /// regardless of how many creators already applied — the applicant - /// tracking is a counter, not a growing list. Apply with a large number - /// of prior applicants, then confirm the write cost of a later apply is - /// no larger than an early one, and that the lock-after-first-apply - /// behavior from `applications_exist_blocks_metadata_update` still holds. + /// 3 creators apply → `campaign_applicants` returns all 3 addresses in + /// the order they applied. #[test] - fn applying_with_many_prior_applicants_does_not_regress_write_cost() { + fn campaign_applicants_returns_all_applicants_in_order() { let (env, contract_id) = setup_env(); let (client, _admin, _dispute, business, token) = bootstrap(&env, &contract_id, 50); + let id = create_funded_campaign(&env, &client, &business, &token, 10_000_000, 5); - // max_creators is a cap on approved creators, not applicants, so a - // low cap here doesn't limit how many creators can apply. - let id = create_funded_campaign(&env, &client, &business, &token, 10_000_000, 1); + let c1 = Address::generate(&env); + let c2 = Address::generate(&env); + let c3 = Address::generate(&env); - let first_creator = Address::generate(&env); - client.apply_to_campaign(&first_creator, &id, &String::from_str(&env, "pitch")); - let first_apply_write_bytes = env.cost_estimate().resources().write_bytes; + client.apply_to_campaign(&c1, &id, &String::from_str(&env, "pitch-1")); + client.apply_to_campaign(&c2, &id, &String::from_str(&env, "pitch-2")); + client.apply_to_campaign(&c3, &id, &String::from_str(&env, "pitch-3")); - // A large number of additional creators apply to the same campaign. - const N: u32 = 200; - for _ in 0..N { - let creator = Address::generate(&env); - client.apply_to_campaign(&creator, &id, &String::from_str(&env, "pitch")); - } + let applicants = client.campaign_applicants(&id); + assert_eq!(applicants.len(), 3); + assert_eq!(applicants.get(0).unwrap(), c1); + assert_eq!(applicants.get(1).unwrap(), c2); + assert_eq!(applicants.get(2).unwrap(), c3); + } - let last_creator = Address::generate(&env); - client.apply_to_campaign(&last_creator, &id, &String::from_str(&env, "pitch")); - let last_apply_write_bytes = env.cost_estimate().resources().write_bytes; + /// `campaign_applicants` on a campaign with no applicants returns an + /// empty list. + #[test] + fn campaign_applicants_empty_when_no_applicants() { + let (env, contract_id) = setup_env(); + let (client, _admin, _dispute, business, token) = bootstrap(&env, &contract_id, 50); + let id = create_funded_campaign(&env, &client, &business, &token, 10_000_000, 5); - // The write cost of applying must not grow with the number of prior - // applicants — an ever-growing Vec would regress this. - assert_eq!(first_apply_write_bytes, last_apply_write_bytes); + let applicants = client.campaign_applicants(&id); + assert_eq!(applicants.len(), 0); + } - // The brief is still locked once any creator has applied, exactly - // as in `applications_exist_blocks_metadata_update`. - let result = client.try_update_campaign_metadata( - &id, - &business, - &String::from_str(&env, "ipfs://updated-brief"), - ); - assert_eq!(result, Err(Ok(Error::ApplicationsExist))); + /// Same creator applying twice returns `AlreadyApplied` — the guard + /// works through the application lookup (not the index), but we also + /// verify that the index only contains unique entries. + #[test] + fn double_apply_via_index() { + let (env, contract_id) = setup_env(); + let (client, _admin, _dispute, business, token) = bootstrap(&env, &contract_id, 50); + let id = create_funded_campaign(&env, &client, &business, &token, 10_000_000, 5); + + let creator = Address::generate(&env); + client.apply_to_campaign(&creator, &id, &String::from_str(&env, "pitch-1")); + let result = + client.try_apply_to_campaign(&creator, &id, &String::from_str(&env, "pitch-2")); + assert_eq!(result, Err(Ok(Error::AlreadyApplied))); + + // The index should only contain the creator once. + let applicants = client.campaign_applicants(&id); + assert_eq!(applicants.len(), 1); } }