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);
}
}