Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion contracts/campaign-escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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<Address> {
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<Campaign, Error> {
storage::get_campaign(&env, campaign_id)
Expand Down
55 changes: 41 additions & 14 deletions contracts/campaign-escrow/src/storage.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Address> = 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,
Expand All @@ -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<u32> = env
let applicants: Option<Vec<Address>> = 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<Address> {
let key = DataKey::CampaignApplicants(campaign_id);
let applicants: Option<Vec<Address>> = 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))
}
77 changes: 44 additions & 33 deletions contracts/campaign-escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down