Skip to content

Remove per-event participant caps; support unlimited applicants/contributors/submissions #103

Description

@0xdevcollins

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

  1. 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.
  2. 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.
  3. 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:

  1. 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.
  2. 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.
  3. 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

  • Applying, contributing, and submitting succeed past 5,000 participants (test with counts > former cap; storage writes remain O(1) per append).
  • No state-changing entrypoint reads or writes a number of ledger entries proportional to total participant count in one transaction.
  • Cancellation crank drains a large contributor set to completion across multiple process_cancel_batch calls.
  • select_winners still rejects > 50 specs per call and supports multiple batches against a large applicant set.
  • View functions are paginated/clamped and simulate successfully on an event with a large participant count.
  • Existing error codes are not renumbered.
  • Full test suite passes; new tests cover the removed-cap paths and pagination bounds.

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.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions