diff --git a/contracts/events/src/bounty.rs b/contracts/events/src/bounty.rs index 26c61a1..428a58b 100644 --- a/contracts/events/src/bounty.rs +++ b/contracts/events/src/bounty.rs @@ -2,7 +2,6 @@ use soroban_sdk::{Address, BytesN, Env}; use crate::admin; use crate::errors::Error; -use crate::event_ops::MAX_APPLICANTS_PER_EVENT; use crate::events as evt; use crate::idempotency::{self, tag}; use crate::profile_client; @@ -33,7 +32,7 @@ pub fn apply( applicant.require_auth(); idempotency::require_unseen(env, &applicant, &op_id)?; - storage::append_applicant(env, bounty_id, &applicant, MAX_APPLICANTS_PER_EVENT)?; + storage::append_applicant(env, bounty_id, &applicant)?; let profile = profile_client::client(env); let bootstrap_op = idempotency::derive_child(env, &op_id, tag::BOOTSTRAP); diff --git a/contracts/events/src/errors.rs b/contracts/events/src/errors.rs index 40eb478..a6eca13 100644 --- a/contracts/events/src/errors.rs +++ b/contracts/events/src/errors.rs @@ -50,12 +50,15 @@ pub enum Error { BelowMinimumContribution = 57, InvalidContributionAmount = 58, + // Per-event participant caps were removed (participant sets are + // unbounded; entries are per-participant and self-funded). 59 and 61 are + // kept for ABI stability and now only signal u32 counter overflow. TooManyApplicants = 59, OpAlreadySeen = 60, - // Also returned by append_submission's cap check: the hackathon submission - // cap reuses this rather than adding a near-duplicate "TooManySubmissions". + // Also returned by append_submission's overflow guard — reused rather + // than adding a near-duplicate "TooManySubmissions". TooManyContributors = 61, CancellationNotStarted = 62, diff --git a/contracts/events/src/event_ops.rs b/contracts/events/src/event_ops.rs index 8e002cb..54ab7d6 100644 --- a/contracts/events/src/event_ops.rs +++ b/contracts/events/src/event_ops.rs @@ -27,9 +27,13 @@ const PENDING_MANAGER_TTL_LEDGERS: u32 = 17_280; // before winners are selected). A per-event override needs a migration. pub const PRIZE_CLAIM_WINDOW_SECS: u64 = 90 * 24 * 60 * 60; -pub const MAX_APPLICANTS_PER_EVENT: u32 = 5_000; -pub const MAX_CONTRIBUTORS_PER_EVENT: u32 = 5_000; -pub const MAX_SUBMISSIONS_PER_EVENT: u32 = 5_000; +// Participant sets (applicants, contributors, submissions) are unbounded: +// each entry is its own persistent ledger entry paid for by the participant's +// own transaction, and no state-changing path iterates the full set in one +// transaction (refunds are cranked in batches, winner selection takes an +// explicit bounded list). Full-list reads page through VIEW_PAGE_LIMIT +// entries per call so simulation stays inside per-tx read-entry limits. +pub const VIEW_PAGE_LIMIT: u32 = 100; pub const MAX_CONTENT_URI_LEN: u32 = 256; pub const MAX_REFUNDS_PER_BATCH: u32 = 25; @@ -298,7 +302,7 @@ pub fn add_funds( if is_non_owner { let prior = prior_contribution; if prior == 0 { - storage::append_contributor(env, event_id, &from, MAX_CONTRIBUTORS_PER_EVENT)?; + storage::append_contributor(env, event_id, &from)?; } } @@ -557,11 +561,11 @@ pub fn submit( } } - // Reserve the slot before writing — Hackathon events have - // needs_application == false, so any address can call submit() with no - // prior gate. Without this cap, an attacker spamming fresh addresses - // grows persistent storage / rent burden without bound. - storage::append_submission(env, event_id, &applicant, MAX_SUBMISSIONS_PER_EVENT)?; + // Count the submission before writing. There is no cap: each submission + // is its own ledger entry whose write and rent are paid by the + // submitter's transaction, so spam addresses fund their own storage and + // cannot lock real participants out of a full event. + storage::append_submission(env, event_id, &applicant)?; let submitted_at = existing .as_ref() @@ -906,12 +910,25 @@ pub fn get_submission(env: &Env, event_id: u64, applicant: Address) -> Result Result, Error> { + get_applicants_page(env, event_id, 0, VIEW_PAGE_LIMIT) +} + +pub fn get_applicants_page( + env: &Env, + event_id: u64, + start: u32, + limit: u32, +) -> Result, Error> { storage::get_event(env, event_id).ok_or(Error::EventNotFound)?; Ok(storage::applicants_snapshot( env, event_id, - MAX_APPLICANTS_PER_EVENT, + start, + limit.min(VIEW_PAGE_LIMIT), )) } @@ -926,11 +943,21 @@ pub fn get_applicant_at(env: &Env, event_id: u64, idx: u32) -> Result Result, Error> { + get_winners_page(env, event_id, 0, VIEW_PAGE_LIMIT) +} + +pub fn get_winners_page( + env: &Env, + event_id: u64, + start: u32, + limit: u32, +) -> Result, Error> { storage::get_event(env, event_id).ok_or(Error::EventNotFound)?; Ok(storage::winners_snapshot( env, event_id, - MAX_WINNERS_PER_SELECT.saturating_mul(20), + start, + limit.min(VIEW_PAGE_LIMIT), )) } @@ -945,11 +972,21 @@ pub fn get_winner_at(env: &Env, event_id: u64, idx: u32) -> Result Result, Error> { + get_contributors_page(env, event_id, 0, VIEW_PAGE_LIMIT) +} + +pub fn get_contributors_page( + env: &Env, + event_id: u64, + start: u32, + limit: u32, +) -> Result, Error> { storage::get_event(env, event_id).ok_or(Error::EventNotFound)?; Ok(storage::contributors_snapshot( env, event_id, - MAX_CONTRIBUTORS_PER_EVENT, + start, + limit.min(VIEW_PAGE_LIMIT), )) } diff --git a/contracts/events/src/lib.rs b/contracts/events/src/lib.rs index 6d455b7..8b5f287 100644 --- a/contracts/events/src/lib.rs +++ b/contracts/events/src/lib.rs @@ -274,10 +274,21 @@ impl EventsContract { event_ops::get_submission(&env, event_id, applicant) } + // Full-list getters return the first page (VIEW_PAGE_LIMIT entries); + // page through the _page variants or the per-index getters for more. pub fn get_applicants(env: Env, event_id: u64) -> Result, Error> { event_ops::get_applicants(&env, event_id) } + pub fn get_applicants_page( + env: Env, + event_id: u64, + start: u32, + limit: u32, + ) -> Result, Error> { + event_ops::get_applicants_page(&env, event_id, start, limit) + } + pub fn get_applicant_count(env: Env, event_id: u64) -> Result { event_ops::get_applicant_count(&env, event_id) } @@ -290,6 +301,15 @@ impl EventsContract { event_ops::get_winners(&env, event_id) } + pub fn get_winners_page( + env: Env, + event_id: u64, + start: u32, + limit: u32, + ) -> Result, Error> { + event_ops::get_winners_page(&env, event_id, start, limit) + } + pub fn get_winner_count(env: Env, event_id: u64) -> Result { event_ops::get_winner_count(&env, event_id) } @@ -302,6 +322,15 @@ impl EventsContract { event_ops::get_contributors(&env, event_id) } + pub fn get_contributors_page( + env: Env, + event_id: u64, + start: u32, + limit: u32, + ) -> Result, Error> { + event_ops::get_contributors_page(&env, event_id, start, limit) + } + pub fn get_contributor_count(env: Env, event_id: u64) -> Result { event_ops::get_contributor_count(&env, event_id) } diff --git a/contracts/events/src/storage.rs b/contracts/events/src/storage.rs index b922567..f03bcb5 100644 --- a/contracts/events/src/storage.rs +++ b/contracts/events/src/storage.rs @@ -338,20 +338,20 @@ pub fn applicant_slot(env: &Env, id: u64, addr: &Address) -> u32 { slot.unwrap_or(0) } -pub fn append_applicant(env: &Env, id: u64, addr: &Address, cap: u32) -> Result { +pub fn append_applicant(env: &Env, id: u64, addr: &Address) -> Result { if applicant_slot(env, id, addr) != 0 { return Err(Error::ApplicantAlreadyApplied); } let cur = applicant_count(env, id); - if cur >= cap { - return Err(Error::TooManyApplicants); - } + // No product cap: each applicant is its own ledger entry paid for by the + // applicant's own transaction, so growth is O(1) per append. Only guard + // the u32 counter itself. + let slot = cur.checked_add(1).ok_or(Error::TooManyApplicants)?; let at_key = DataKey::EventApplicantAt(id, cur); env.storage().persistent().set(&at_key, addr); touch_event_persistent(env, &at_key); let slot_key = DataKey::EventApplicantSlot(id, addr.clone()); - let slot = cur.saturating_add(1); env.storage().persistent().set(&slot_key, &slot); touch_event_persistent(env, &slot_key); @@ -399,11 +399,11 @@ pub fn remove_applicant(env: &Env, id: u64, addr: &Address) -> Result<(), Error> Ok(()) } -pub fn applicants_snapshot(env: &Env, id: u64, max: u32) -> Vec
{ +pub fn applicants_snapshot(env: &Env, id: u64, start: u32, limit: u32) -> Vec
{ let count = applicant_count(env, id); - let upper = if count < max { count } else { max }; + let end = start.saturating_add(limit).min(count); let mut out: Vec
= Vec::new(env); - for idx in 0..upper { + for idx in start..end { if let Some(addr) = applicant_at(env, id, idx) { out.push_back(addr); } @@ -460,23 +460,21 @@ pub fn submission_count(env: &Env, id: u64) -> u32 { n.unwrap_or(0) } -/// Reserve a submission slot against the per-event cap before writing the -/// entry (mirrors `append_contributor`/`append_applicant`). A no-op when the -/// applicant already has a submission — re-submission updates the existing -/// entry in place and must not recount against the cap. +/// Count a new submission before writing the entry (mirrors +/// `append_contributor`/`append_applicant`). A no-op when the applicant +/// already has a submission — re-submission updates the existing entry in +/// place and must not recount. /// -/// Returns `Error::TooManyContributors` on cap-exceed — reused rather than -/// a new variant since the errors enum is at the 50-case XDR cap. -pub fn append_submission(env: &Env, id: u64, addr: &Address, cap: u32) -> Result<(), Error> { +/// Returns `Error::TooManyContributors` only on u32 counter overflow — +/// reused rather than a new variant since the errors enum is at the +/// 50-case XDR cap. +pub fn append_submission(env: &Env, id: u64, addr: &Address) -> Result<(), Error> { if get_submission(env, id, addr).is_some() { return Ok(()); } let cur = submission_count(env, id); - if cur >= cap { - return Err(Error::TooManyContributors); - } + let next = cur.checked_add(1).ok_or(Error::TooManyContributors)?; let count_key = DataKey::EventSubmissionCount(id); - let next = cur.saturating_add(1); env.storage().persistent().set(&count_key, &next); touch_event_persistent(env, &count_key); Ok(()) @@ -584,11 +582,11 @@ pub fn set_prize_claim_expiry(env: &Env, id: u64, expires_at: u64) { touch_event_persistent(env, &key); } -pub fn winners_snapshot(env: &Env, id: u64, max: u32) -> Vec { +pub fn winners_snapshot(env: &Env, id: u64, start: u32, limit: u32) -> Vec { let count = winner_count(env, id); - let upper = if count < max { count } else { max }; + let end = start.saturating_add(limit).min(count); let mut out: Vec = Vec::new(env); - for idx in 0..upper { + for idx in start..end { if let Some(w) = winner_at(env, id, idx) { out.push_back(w); } @@ -656,20 +654,18 @@ pub fn contributor_slot(env: &Env, id: u64, addr: &Address) -> u32 { slot.unwrap_or(0) } -pub fn append_contributor(env: &Env, id: u64, addr: &Address, cap: u32) -> Result { +pub fn append_contributor(env: &Env, id: u64, addr: &Address) -> Result { if contributor_slot(env, id, addr) != 0 { return Ok(0); } let cur = contributor_count(env, id); - if cur >= cap { - return Err(Error::TooManyContributors); - } + // No product cap (see append_applicant); guard only the u32 counter. + let slot = cur.checked_add(1).ok_or(Error::TooManyContributors)?; let at_key = DataKey::ContributorAt(id, cur); env.storage().persistent().set(&at_key, addr); touch_event_persistent(env, &at_key); let slot_key = DataKey::ContributorSlot(id, addr.clone()); - let slot = cur.saturating_add(1); env.storage().persistent().set(&slot_key, &slot); touch_event_persistent(env, &slot_key); @@ -679,11 +675,11 @@ pub fn append_contributor(env: &Env, id: u64, addr: &Address, cap: u32) -> Resul Ok(slot) } -pub fn contributors_snapshot(env: &Env, id: u64, max: u32) -> Vec
{ +pub fn contributors_snapshot(env: &Env, id: u64, start: u32, limit: u32) -> Vec
{ let count = contributor_count(env, id); - let upper = if count < max { count } else { max }; + let end = start.saturating_add(limit).min(count); let mut out: Vec
= Vec::new(env); - for idx in 0..upper { + for idx in start..end { if let Some(addr) = contributor_at(env, id, idx) { out.push_back(addr); } diff --git a/contracts/events/src/tests/bounty_pillar.rs b/contracts/events/src/tests/bounty_pillar.rs index f2cf850..49d725f 100644 --- a/contracts/events/src/tests/bounty_pillar.rs +++ b/contracts/events/src/tests/bounty_pillar.rs @@ -7,7 +7,7 @@ use soroban_sdk::{ use super::common::drive_cancel; use crate::errors::Error; -use crate::types::{CreateEventParams, EventStatus, Pillar, ReleaseKind, WinnerSpec}; +use crate::types::{CreateEventParams, DataKey, EventStatus, Pillar, ReleaseKind, WinnerSpec}; use crate::{EventsContract, EventsContractClient}; use boundless_profile::{ProfileContract, ProfileContractClient}; @@ -294,6 +294,94 @@ fn apply_when_paused_reverts() { assert_eq!(err, Error::Paused); } +#[test] +fn apply_beyond_former_cap_succeeds() { + let ctx = setup(); + let bounty_id = create_bounty(&ctx); + + // Fast-forward the per-event counter past the former 5,000 cap instead + // of performing that many real applications from distinct addresses. + ctx.env.as_contract(&ctx.events.address, || { + ctx.env + .storage() + .persistent() + .set(&DataKey::EventApplicantCount(bounty_id), &5_000_u32); + }); + + let op_id = BytesN::random(&ctx.env); + ctx.events + .apply_to_bounty(&bounty_id, &ctx.applicant, &op_id); + + assert_eq!( + ctx.events.get_applicant_count(&bounty_id), + 5_001, + "applications are unbounded; the counter must keep advancing past the former cap" + ); + assert_eq!( + ctx.events.get_applicant_at(&bounty_id, &5_000), + Some(ctx.applicant.clone()), + "the new applicant must land in the next slot" + ); +} + +#[test] +fn apply_at_counter_overflow_reverts() { + let ctx = setup(); + let bounty_id = create_bounty(&ctx); + + ctx.env.as_contract(&ctx.events.address, || { + ctx.env + .storage() + .persistent() + .set(&DataKey::EventApplicantCount(bounty_id), &u32::MAX); + }); + + let op_id = BytesN::random(&ctx.env); + let err = expect_op_err( + ctx.events + .try_apply_to_bounty(&bounty_id, &ctx.applicant, &op_id), + ); + assert_eq!( + err, + Error::TooManyApplicants, + "an application that would overflow the u32 counter must revert" + ); +} + +#[test] +fn applicants_page_respects_start_and_limit() { + let ctx = setup(); + let bounty_id = create_bounty(&ctx); + + let second = Address::generate(&ctx.env); + let third = Address::generate(&ctx.env); + for applicant in [&ctx.applicant, &second, &third] { + ctx.events + .apply_to_bounty(&bounty_id, applicant, &BytesN::random(&ctx.env)); + } + + let tail = ctx.events.get_applicants_page(&bounty_id, &1, &10); + assert_eq!(tail.len(), 2); + assert_eq!(tail.get(0).unwrap(), second); + assert_eq!(tail.get(1).unwrap(), third); + + assert_eq!( + ctx.events.get_applicants_page(&bounty_id, &0, &0).len(), + 0, + "limit 0 must return an empty page" + ); + assert_eq!( + ctx.events.get_applicants_page(&bounty_id, &10, &5).len(), + 0, + "a start past the end must return an empty page" + ); + assert_eq!( + ctx.events.get_applicants_page(&bounty_id, &0, &1_000).len(), + 3, + "an oversized limit is clamped, not an error" + ); +} + #[test] fn apply_requires_applicant_auth() { let ctx = setup(); diff --git a/contracts/events/src/tests/hackathon_pillar.rs b/contracts/events/src/tests/hackathon_pillar.rs index a56733d..d265abe 100644 --- a/contracts/events/src/tests/hackathon_pillar.rs +++ b/contracts/events/src/tests/hackathon_pillar.rs @@ -6,7 +6,7 @@ use soroban_sdk::{ }; use crate::errors::Error; -use crate::event_ops::{MAX_CONTENT_URI_LEN, MAX_SUBMISSIONS_PER_EVENT}; +use crate::event_ops::MAX_CONTENT_URI_LEN; use crate::storage; use crate::types::{CreateEventParams, DataKey, EventStatus, Pillar, ReleaseKind, WinnerSpec}; use crate::{EventsContract, EventsContractClient}; @@ -284,22 +284,47 @@ fn withdraw_submission_frees_the_slot_for_future_submitters() { .as_contract(&ctx.events_id, || storage::submission_count(&ctx.env, id)); assert_eq!( count, 0, - "withdrawing a submission must free its slot against the cap" + "withdrawing a submission must decrement the submission count" ); } #[test] -fn submit_beyond_cap_reverts() { +fn submit_beyond_former_cap_succeeds() { let ctx = setup(); let id = create_hackathon(&ctx); - // Fast-forward the per-event counter directly instead of performing - // MAX_SUBMISSIONS_PER_EVENT real submissions from distinct addresses. + // Fast-forward the per-event counter past the former 5,000 cap instead + // of performing that many real submissions from distinct addresses. ctx.env.as_contract(&ctx.events_id, || { - ctx.env.storage().persistent().set( - &DataKey::EventSubmissionCount(id), - &MAX_SUBMISSIONS_PER_EVENT, - ); + ctx.env + .storage() + .persistent() + .set(&DataKey::EventSubmissionCount(id), &5_000_u32); + }); + + let uri = String::from_str(&ctx.env, "ipfs://Qm.../v5001.json"); + ctx.events + .submit(&id, &ctx.applicant, &uri, &BytesN::random(&ctx.env)); + + let count = ctx + .env + .as_contract(&ctx.events_id, || storage::submission_count(&ctx.env, id)); + assert_eq!( + count, 5_001, + "submissions are unbounded; the counter must keep advancing past the former cap" + ); +} + +#[test] +fn submit_at_counter_overflow_reverts() { + let ctx = setup(); + let id = create_hackathon(&ctx); + + ctx.env.as_contract(&ctx.events_id, || { + ctx.env + .storage() + .persistent() + .set(&DataKey::EventSubmissionCount(id), &u32::MAX); }); let uri = String::from_str(&ctx.env, "ipfs://Qm.../overflow.json"); @@ -308,7 +333,7 @@ fn submit_beyond_cap_reverts() { assert_eq!( err, Error::TooManyContributors, - "a submission at cap + 1 must revert" + "a submission that would overflow the u32 counter must revert" ); }