Summary
Remove the hard per-event participant caps (MAX_APPLICANTS_PER_EVENT, MAX_CONTRIBUTORS_PER_EVENT, MAX_SUBMISSIONS_PER_EVENT, all 5_000) from the events contract so events support unlimited participants. The storage layout already makes this safe, and the caps themselves are a griefing vector. As part of the same change, clamp the unbounded snapshot getters to a page size that can actually be simulated.
Background
Participant caps normally exist to protect contracts that keep all participants in a single Vec inside one ledger entry: the entry grows until any transaction touching it exceeds resource limits, and the event bricks. Our contract does not have that shape. Applicants, contributors, and submissions are each stored as one persistent ledger entry per participant, plus a u32 counter:
DataKey::EventApplicantAt(id, idx) + EventApplicantSlot(id, addr) + EventApplicantCount(id) (contracts/events/src/storage.rs:341)
- Contributors mirror the same layout (
contracts/events/src/storage.rs:659)
- Submissions are per-entry keyed by applicant (
contracts/events/src/storage.rs:470)
Because of that, appending participant N+1 costs the same as participant #1, and each participant's own transaction pays for its own entry's write and rent. No state-changing path iterates the full participant set in a single transaction:
apply / submit / contribute: O(1) per-entry writes
remove_applicant: O(1) swap-remove (storage.rs:364)
- Cancellation refunds: resumable crank,
MAX_REFUNDS_PER_BATCH = 25 per tx with a next_idx bookmark (event_ops.rs:412)
select_winners: explicit address list, MAX_WINNERS_PER_SELECT = 50 per call, batchable (event_ops.rs:627)
- Prize claims: per-winner
So the caps do not prevent any structural failure mode.
Problems with the current caps
- Griefing / DoS. Applying is permissionless. An attacker with throwaway wallets can fill all 5,000 applicant slots and lock real participants out with
Error::TooManyApplicants (errors.rs:53, enforced at storage.rs:347). Same applies to contributor slots via Error::TooManyContributors (storage.rs:476, storage.rs:665, reused for submissions). Removing the cap removes the thing being filled.
- False comfort. The only code that assumes a bounded set is the full-list view functions, and they already fail well below the cap:
get_applicants / get_contributors read one ledger entry per participant in a single call (event_ops.rs:909, event_ops.rs:947), which exceeds Soroban per-tx read-entry limits far below 5,000. They are read-only, so nothing on-chain is blocked, but the cap does not make them work.
- Product constraint leaking from the wrong layer. Organizer-facing participant limits are a product decision. Enforcing them in the contract makes them global, migration-gated, and identical for every event.
Proposed changes
All in contracts/events/src:
-
Remove the cap checks on append paths.
- Delete the
cur >= cap checks in append_applicant (storage.rs:346), append_contributor (storage.rs:664), and the submission-count check in append_submission (storage.rs:475).
- Remove the
cap parameter from these functions and update call sites (bounty.rs:36, event_ops.rs:301, event_ops.rs:564).
- Counters are
u32 (~4.29B); add a checked_add overflow guard on the counter bump rather than a product cap.
- Remove
MAX_APPLICANTS_PER_EVENT, MAX_CONTRIBUTORS_PER_EVENT, MAX_SUBMISSIONS_PER_EVENT (event_ops.rs:30-32).
- Decide whether
TooManyApplicants / TooManyContributors error variants stay (reserved codes) or are retired; do not renumber existing variants either way.
-
Keep the real safety valves unchanged.
MAX_WINNERS_PER_SELECT = 50 (event_ops.rs:22)
MAX_REFUNDS_PER_BATCH = 25 (event_ops.rs:35)
- These are what make unbounded participant counts safe; they are not in scope to change.
-
Fix the unbounded view functions.
- Replace or clamp
get_applicants (event_ops.rs:909) and get_contributors (event_ops.rs:947) with paginated variants, e.g. get_applicants_page(event_id, start: u32, limit: u32) with limit clamped to ~100.
get_winners (event_ops.rs:928, currently clamped to 50 * 20 = 1,000) should get the same treatment.
- Per-index getters (
get_applicant_at, get_contributor_count, etc.) already exist and stay as-is; full lists remain the indexer's job off-chain.
Out of scope / follow-ups
- Backend (
boundless-nestjs) and platform (boundless-platform): maxParticipants remains a valid optional, off-chain organizer setting (currently wired through hackathon DTOs/schemas and the generated OpenAPI types). No contract involvement; if we want backend enforcement it caps registrations at the API layer.
- Contract redeploy/upgrade and address rollout are handled per the usual release process.
Acceptance criteria
Test plan
- Unit: append/remove applicant beyond former cap; counter overflow guard; pagination clamp edges (
start past end, limit = 0, limit > clamp).
- Integration: large-event lifecycle — many applicants -> submissions ->
select_winners in batches -> claims; cancellation with many contributors driven by the crank until remaining == 0.
- Simulation check: paginated getters stay within read-entry limits at max page size.
Summary
Remove the hard per-event participant caps (
MAX_APPLICANTS_PER_EVENT,MAX_CONTRIBUTORS_PER_EVENT,MAX_SUBMISSIONS_PER_EVENT, all5_000) from the events contract so events support unlimited participants. The storage layout already makes this safe, and the caps themselves are a griefing vector. As part of the same change, clamp the unbounded snapshot getters to a page size that can actually be simulated.Background
Participant caps normally exist to protect contracts that keep all participants in a single
Vecinside one ledger entry: the entry grows until any transaction touching it exceeds resource limits, and the event bricks. Our contract does not have that shape. Applicants, contributors, and submissions are each stored as one persistent ledger entry per participant, plus au32counter:DataKey::EventApplicantAt(id, idx)+EventApplicantSlot(id, addr)+EventApplicantCount(id)(contracts/events/src/storage.rs:341)contracts/events/src/storage.rs:659)contracts/events/src/storage.rs:470)Because of that, appending participant N+1 costs the same as participant #1, and each participant's own transaction pays for its own entry's write and rent. No state-changing path iterates the full participant set in a single transaction:
apply/submit/contribute: O(1) per-entry writesremove_applicant: O(1) swap-remove (storage.rs:364)MAX_REFUNDS_PER_BATCH = 25per tx with anext_idxbookmark (event_ops.rs:412)select_winners: explicit address list,MAX_WINNERS_PER_SELECT = 50per call, batchable (event_ops.rs:627)So the caps do not prevent any structural failure mode.
Problems with the current caps
Error::TooManyApplicants(errors.rs:53, enforced atstorage.rs:347). Same applies to contributor slots viaError::TooManyContributors(storage.rs:476,storage.rs:665, reused for submissions). Removing the cap removes the thing being filled.get_applicants/get_contributorsread one ledger entry per participant in a single call (event_ops.rs:909,event_ops.rs:947), which exceeds Soroban per-tx read-entry limits far below 5,000. They are read-only, so nothing on-chain is blocked, but the cap does not make them work.Proposed changes
All in
contracts/events/src:Remove the cap checks on append paths.
cur >= capchecks inappend_applicant(storage.rs:346),append_contributor(storage.rs:664), and the submission-count check inappend_submission(storage.rs:475).capparameter from these functions and update call sites (bounty.rs:36,event_ops.rs:301,event_ops.rs:564).u32(~4.29B); add achecked_addoverflow guard on the counter bump rather than a product cap.MAX_APPLICANTS_PER_EVENT,MAX_CONTRIBUTORS_PER_EVENT,MAX_SUBMISSIONS_PER_EVENT(event_ops.rs:30-32).TooManyApplicants/TooManyContributorserror variants stay (reserved codes) or are retired; do not renumber existing variants either way.Keep the real safety valves unchanged.
MAX_WINNERS_PER_SELECT = 50(event_ops.rs:22)MAX_REFUNDS_PER_BATCH = 25(event_ops.rs:35)Fix the unbounded view functions.
get_applicants(event_ops.rs:909) andget_contributors(event_ops.rs:947) with paginated variants, e.g.get_applicants_page(event_id, start: u32, limit: u32)withlimitclamped to ~100.get_winners(event_ops.rs:928, currently clamped to 50 * 20 = 1,000) should get the same treatment.get_applicant_at,get_contributor_count, etc.) already exist and stay as-is; full lists remain the indexer's job off-chain.Out of scope / follow-ups
boundless-nestjs) and platform (boundless-platform):maxParticipantsremains a valid optional, off-chain organizer setting (currently wired through hackathon DTOs/schemas and the generated OpenAPI types). No contract involvement; if we want backend enforcement it caps registrations at the API layer.Acceptance criteria
process_cancel_batchcalls.select_winnersstill rejects > 50 specs per call and supports multiple batches against a large applicant set.Test plan
startpast end,limit = 0,limit > clamp).select_winnersin batches -> claims; cancellation with many contributors driven by the crank untilremaining == 0.