From 7abbf5c5253b5ececd95d0413417e621095a8981 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Mon, 28 Sep 2026 12:43:05 +0100 Subject: [PATCH] refactor(events)!: drop the on-chain participation records BREAKING: removes apply_to_bounty, withdraw_application, submit, withdraw_submission and the applicant/submission read accessors. Nothing on chain ever read them. select_winners and claim_prize check the award record and the caller's signature, never the applicant index or a submission anchor, so the only consumer of an application was submit, and the only consumer of a submission was withdraw_submission. A closed loop, and a permissionless one: anyone could write an entry for any active event without going through Boundless, which is what made the record worthless as evidence. Applying and submitting are product facts and now live in Postgres. Two properties improve on the way out. No permissionless entrypoint calls the profile contract any more, so the derived child op_ids of claim_prize and claim_milestone can no longer be burned by a stranger (boundlessfi/boundless-contract#129 keeps its fix, but loses its cheap exploit path). And grant submissions stop being a dead branch: submit demanded an application that apply_to_bounty refused to create for a grant, so the call could only ever fail (#132). Storage keys and error discriminants are retired in place rather than deleted, so the remaining variants keep their positions and rows left on live events stay identifiable until they archive. Version goes to 2.0.0 on both the contractmeta and Cargo (which also closes the #142 mismatch): removing entrypoints is breaking, and the upgrade is proposed with that version string. docs/threat-model.md, BACKLOG.md and the Scout report are not touched here; they are checked out dirty in a stash and their STRIDE rows need the same pass. --- Cargo.lock | 2 +- contracts/events/Cargo.toml | 2 +- contracts/events/src/admin.rs | 2 +- contracts/events/src/bounty.rs | 88 +-- contracts/events/src/errors.rs | 20 +- contracts/events/src/event_ops.rs | 164 +---- contracts/events/src/events.rs | 27 - contracts/events/src/lib.rs | 87 +-- contracts/events/src/storage.rs | 255 +------- contracts/events/src/tests/admin.rs | 167 +---- contracts/events/src/tests/bounty_pillar.rs | 590 +----------------- contracts/events/src/tests/cross_contract.rs | 151 ----- contracts/events/src/tests/crowdfunding.rs | 11 - .../events/src/tests/hackathon_pillar.rs | 252 +------- contracts/events/src/tests/op_id_security.rs | 17 +- contracts/events/src/types.rs | 26 +- docs/mainnet-deploy-runbook.md | 4 +- 17 files changed, 51 insertions(+), 1814 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 29468dd..a4f4899 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -215,7 +215,7 @@ dependencies = [ [[package]] name = "boundless-events" -version = "1.6.0" +version = "2.0.0" dependencies = [ "boundless-profile", "soroban-sdk", diff --git a/contracts/events/Cargo.toml b/contracts/events/Cargo.toml index d2899f7..28a97f5 100644 --- a/contracts/events/Cargo.toml +++ b/contracts/events/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "boundless-events" -version = "1.6.0" +version = "2.0.0" edition = "2021" publish = false diff --git a/contracts/events/src/admin.rs b/contracts/events/src/admin.rs index 033b408..7e7a721 100644 --- a/contracts/events/src/admin.rs +++ b/contracts/events/src/admin.rs @@ -46,7 +46,7 @@ const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; -pub const INITIAL_VERSION: &str = "1.7.0"; +pub const INITIAL_VERSION: &str = "2.0.0"; // ============================================================ // INITIALIZATION diff --git a/contracts/events/src/bounty.rs b/contracts/events/src/bounty.rs index da3d6a5..1029741 100644 --- a/contracts/events/src/bounty.rs +++ b/contracts/events/src/bounty.rs @@ -1,12 +1,7 @@ -use soroban_sdk::{Address, BytesN, Env}; +use soroban_sdk::{Address, Env}; -use crate::admin; use crate::errors::Error; -use crate::events as evt; -use crate::idempotency::{self, tag}; -use crate::profile_client; -use crate::storage; -use crate::types::{EventRecord, EventStatus, Pillar, ReleaseKind}; +use crate::types::{EventRecord, ReleaseKind}; pub fn validate_create(_env: &Env, record: &EventRecord, _owner: &Address) -> Result<(), Error> { if !matches!(record.release_kind, ReleaseKind::Single) { @@ -14,82 +9,3 @@ pub fn validate_create(_env: &Env, record: &EventRecord, _owner: &Address) -> Re } Ok(()) } - -// ============================================================ -// APPLY -// ============================================================ -pub fn apply( - env: &Env, - bounty_id: u64, - applicant: Address, - op_id: BytesN<32>, -) -> Result<(), Error> { - admin::require_not_paused(env)?; - - let event = storage::get_event(env, bounty_id).ok_or(Error::EventNotFound)?; - require_active_bounty(&event)?; - - applicant.require_auth(); - idempotency::require_unseen(env, &applicant, &op_id)?; - - storage::append_applicant(env, bounty_id, &applicant)?; - - let profile = profile_client::client(env); - let bootstrap_op = idempotency::derive_child(env, &op_id, tag::BOOTSTRAP); - profile.bootstrap(&applicant, &bootstrap_op); - - evt::Applied { - event_id: bounty_id, - applicant: applicant.clone(), - } - .publish(env); - - idempotency::mark_seen(env, &applicant, &op_id); - Ok(()) -} - -// ============================================================ -// WITHDRAW APPLICATION -// ============================================================ -pub fn withdraw_application( - env: &Env, - bounty_id: u64, - applicant: Address, - op_id: BytesN<32>, -) -> Result<(), Error> { - admin::require_not_paused(env)?; - - let event = storage::get_event(env, bounty_id).ok_or(Error::EventNotFound)?; - require_active_bounty(&event)?; - - applicant.require_auth(); - idempotency::require_unseen(env, &applicant, &op_id)?; - - if storage::has_any_submission(env, bounty_id, &applicant) { - return Err(Error::SubmissionAlreadyExists); - } - - storage::remove_applicant(env, bounty_id, &applicant)?; - - evt::ApplicationWithdrawn { - event_id: bounty_id, - applicant: applicant.clone(), - } - .publish(env); - - idempotency::mark_seen(env, &applicant, &op_id); - Ok(()) -} - -// ============================================================ -// HELPERS -// ============================================================ -fn require_active_bounty(event: &EventRecord) -> Result<(), Error> { - if !matches!(event.pillar, Pillar::Bounty) { - return Err(Error::InvalidPillar); - } - if !matches!(event.status, EventStatus::Active) { - return Err(Error::EventNotActive); - } - Ok(()) -} diff --git a/contracts/events/src/errors.rs b/contracts/events/src/errors.rs index a6eca13..918f2a0 100644 --- a/contracts/events/src/errors.rs +++ b/contracts/events/src/errors.rs @@ -33,11 +33,10 @@ pub enum Error { // off-chain/backend concern; the contract no longer gates on deadline). TitleTooLong = 39, - ApplicantAlreadyApplied = 40, - ApplicantNotApplied = 41, - SubmissionNotFound = 42, - SubmissionAlreadyExists = 43, - + // 40-43 retired in 2.0.0 with the on-chain participation records + // (ApplicantAlreadyApplied, ApplicantNotApplied, SubmissionNotFound, + // SubmissionAlreadyExists). Left as a gap rather than reused so an old + // client decoding a stored error code never reads the wrong case. NoSubmissions = 50, InvalidWinnerPosition = 51, DuplicateWinnerPosition = 52, @@ -50,15 +49,12 @@ 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, - + // 59 retired in 2.0.0 with the applicant index (TooManyApplicants). OpAlreadySeen = 60, - // Also returned by append_submission's overflow guard — reused rather - // than adding a near-duplicate "TooManySubmissions". + // Signals a u32 counter overflow on the contributor index. Per-event + // participant caps were removed (contributor sets are unbounded; entries + // are per-contributor and self-funded). TooManyContributors = 61, CancellationNotStarted = 62, diff --git a/contracts/events/src/event_ops.rs b/contracts/events/src/event_ops.rs index 949330d..040114b 100644 --- a/contracts/events/src/event_ops.rs +++ b/contracts/events/src/event_ops.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{Address, BytesN, Env, String, Symbol, Vec}; +use soroban_sdk::{Address, BytesN, Env, Symbol, Vec}; use crate::admin::{self, MAX_FEE_BPS}; use crate::bounty; @@ -14,7 +14,7 @@ use crate::storage; use crate::token_whitelist; use crate::types::{ CancellationBranch, CancellationState, CreateEventParams, EventRecord, EventStatus, - PendingManager, Pillar, PrizeAward, ReleaseKind, Submission, Winner, WinnerSpec, + PendingManager, Pillar, PrizeAward, ReleaseKind, Winner, WinnerSpec, }; const MAX_TITLE_LEN: u32 = 120; @@ -27,14 +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; -// 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. +// Contributor sets are unbounded: each entry is its own persistent ledger +// entry paid for by the contributor'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; @@ -537,112 +536,6 @@ pub fn finalize_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<() Ok(()) } -// ============================================================ -// SUBMIT -// ============================================================ -pub fn submit( - env: &Env, - event_id: u64, - applicant: Address, - slot: u32, - content_uri: String, - op_id: BytesN<32>, -) -> Result<(), Error> { - admin::require_not_paused(env)?; - - let event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?; - if !matches!(event.status, EventStatus::Active) { - return Err(Error::EventNotActive); - } - if matches!(event.pillar, Pillar::Crowdfunding) { - return Err(Error::InvalidPillar); - } - - applicant.require_auth(); - idempotency::require_unseen(env, &applicant, &op_id)?; - - // Reused rather than adding a new variant — stays inside the - // contracterror 50-variant cap (see BACKLOG.md L7 for precedent). - if content_uri.len() > MAX_CONTENT_URI_LEN { - return Err(Error::TitleTooLong); - } - - let existing = storage::get_submission(env, event_id, &applicant, slot); - - if !storage::has_any_submission(env, event_id, &applicant) { - let needs_application = matches!(event.pillar, Pillar::Bounty | Pillar::Grant); - if needs_application && storage::applicant_slot(env, event_id, &applicant) == 0 { - return Err(Error::ApplicantNotApplied); - } - } - - // Count the slot before writing. There is no cap: each entry 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, slot)?; - - let submitted_at = existing - .as_ref() - .map(|s| s.submitted_at) - .unwrap_or_else(|| env.ledger().timestamp()); - - let submission = Submission { - applicant: applicant.clone(), - content_uri: content_uri.clone(), - submitted_at, - }; - storage::set_submission(env, event_id, &applicant, slot, &submission); - - evt::Submitted { - event_id, - applicant: applicant.clone(), - slot, - content_uri, - } - .publish(env); - - idempotency::mark_seen(env, &applicant, &op_id); - Ok(()) -} - -// ============================================================ -// WITHDRAW SUBMISSION -// ============================================================ -pub fn withdraw_submission( - env: &Env, - event_id: u64, - applicant: Address, - slot: u32, - op_id: BytesN<32>, -) -> Result<(), Error> { - admin::require_not_paused(env)?; - - let event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?; - if !matches!(event.status, EventStatus::Active) { - return Err(Error::EventNotActive); - } - - applicant.require_auth(); - idempotency::require_unseen(env, &applicant, &op_id)?; - - if storage::get_submission(env, event_id, &applicant, slot).is_none() { - return Err(Error::SubmissionNotFound); - } - - storage::remove_submission(env, event_id, &applicant, slot); - - evt::SubmissionWithdrawn { - event_id, - applicant: applicant.clone(), - slot, - } - .publish(env); - - idempotency::mark_seen(env, &applicant, &op_id); - Ok(()) -} - // ============================================================ // SELECT WINNERS // ============================================================ @@ -951,47 +844,6 @@ pub fn get_event(env: &Env, event_id: u64) -> Result { storage::get_event(env, event_id).ok_or(Error::EventNotFound) } -pub fn get_submission( - env: &Env, - event_id: u64, - applicant: Address, - slot: u32, -) -> Result { - storage::get_submission(env, event_id, &applicant, slot).ok_or(Error::SubmissionNotFound) -} - -// Full-list getters return the first VIEW_PAGE_LIMIT entries; use the -// _page variants (or the per-index getters / the off-chain indexer) to -// read beyond that. -pub fn get_applicants(env: &Env, event_id: u64) -> 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, - start, - limit.min(VIEW_PAGE_LIMIT), - )) -} - -pub fn get_applicant_count(env: &Env, event_id: u64) -> Result { - storage::get_event(env, event_id).ok_or(Error::EventNotFound)?; - Ok(storage::applicant_count(env, event_id)) -} - -pub fn get_applicant_at(env: &Env, event_id: u64, idx: u32) -> Result, Error> { - storage::get_event(env, event_id).ok_or(Error::EventNotFound)?; - Ok(storage::applicant_at(env, event_id, idx)) -} - pub fn get_winners(env: &Env, event_id: u64) -> Result, Error> { get_winners_page(env, event_id, 0, VIEW_PAGE_LIMIT) } diff --git a/contracts/events/src/events.rs b/contracts/events/src/events.rs index aaabadb..1253ee1 100644 --- a/contracts/events/src/events.rs +++ b/contracts/events/src/events.rs @@ -60,33 +60,6 @@ pub struct OwnerResidualRefunded { pub amount: i128, } -#[contractevent] -pub struct Applied { - pub event_id: u64, - pub applicant: Address, -} - -#[contractevent] -pub struct ApplicationWithdrawn { - pub event_id: u64, - pub applicant: Address, -} - -#[contractevent] -pub struct Submitted { - pub event_id: u64, - pub applicant: Address, - pub slot: u32, - pub content_uri: String, -} - -#[contractevent] -pub struct SubmissionWithdrawn { - pub event_id: u64, - pub applicant: Address, - pub slot: u32, -} - #[contractevent] pub struct WinnersSelected { pub event_id: u64, diff --git a/contracts/events/src/lib.rs b/contracts/events/src/lib.rs index 33978a1..ddbdd0a 100644 --- a/contracts/events/src/lib.rs +++ b/contracts/events/src/lib.rs @@ -24,7 +24,7 @@ mod tests; use crate::errors::Error; use crate::types::*; -contractmeta!(key = "version", val = "1.7.0"); +contractmeta!(key = "version", val = "2.0.0"); contractmeta!( key = "description", val = "Boundless events contract: hackathon, bounty, grant + escrow" @@ -168,54 +168,6 @@ impl EventsContract { event_ops::add_funds(&env, event_id, from, amount, op_id) } - // ============================================================ - // BOUNTY PARTICIPATION - // ============================================================ - pub fn apply_to_bounty( - env: Env, - bounty_id: u64, - applicant: Address, - op_id: BytesN<32>, - ) -> Result<(), Error> { - bounty::apply(&env, bounty_id, applicant, op_id) - } - - pub fn withdraw_application( - env: Env, - bounty_id: u64, - applicant: Address, - op_id: BytesN<32>, - ) -> Result<(), Error> { - bounty::withdraw_application(&env, bounty_id, applicant, op_id) - } - - // ============================================================ - // SUBMISSION - // ============================================================ - /// `slot` separates several entries by the same wallet in one event. The - /// contract assigns it no meaning; re-submitting to an occupied slot - /// updates it in place. Callers with a single entry use slot 0. - pub fn submit( - env: Env, - event_id: u64, - applicant: Address, - slot: u32, - content_uri: String, - op_id: BytesN<32>, - ) -> Result<(), Error> { - event_ops::submit(&env, event_id, applicant, slot, content_uri, op_id) - } - - pub fn withdraw_submission( - env: Env, - event_id: u64, - applicant: Address, - slot: u32, - op_id: BytesN<32>, - ) -> Result<(), Error> { - event_ops::withdraw_submission(&env, event_id, applicant, slot, op_id) - } - // ============================================================ // WINNERS // ============================================================ @@ -278,43 +230,6 @@ impl EventsContract { event_ops::get_event(&env, event_id) } - pub fn get_submission( - env: Env, - event_id: u64, - applicant: Address, - slot: u32, - ) -> Result { - event_ops::get_submission(&env, event_id, applicant, slot) - } - - /// How many slots this applicant occupies in this event. - pub fn get_applicant_submission_count(env: Env, event_id: u64, applicant: Address) -> u32 { - storage::applicant_submission_count(&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) - } - - pub fn get_applicant_at(env: Env, event_id: u64, idx: u32) -> Result, Error> { - event_ops::get_applicant_at(&env, event_id, idx) - } - pub fn get_winners(env: Env, event_id: u64) -> Result, Error> { event_ops::get_winners(&env, event_id) } diff --git a/contracts/events/src/storage.rs b/contracts/events/src/storage.rs index 9973154..577ed12 100644 --- a/contracts/events/src/storage.rs +++ b/contracts/events/src/storage.rs @@ -7,7 +7,7 @@ use soroban_sdk::String; use crate::errors::Error; use crate::types::{ CancellationState, DataKey, EventRecord, PendingAdmin, PendingManager, PendingUpgrade, - PrizeAward, Submission, Winner, + PrizeAward, Winner, }; // ============================================================ @@ -321,259 +321,6 @@ pub fn clear_pending_manager(env: &Env, id: u64) { .remove(&DataKey::PendingManager(id)); } -// ============================================================ -// APPLICANTS (paged, persistent) -// ============================================================ -pub fn applicant_count(env: &Env, id: u64) -> u32 { - let key = DataKey::EventApplicantCount(id); - let n: Option = env.storage().persistent().get(&key); - if n.is_some() { - touch_event_persistent(env, &key); - } - n.unwrap_or(0) -} - -pub fn applicant_at(env: &Env, id: u64, idx: u32) -> Option
{ - let key = DataKey::EventApplicantAt(id, idx); - let addr: Option
= env.storage().persistent().get(&key); - if addr.is_some() { - touch_event_persistent(env, &key); - } - addr -} - -pub fn applicant_slot(env: &Env, id: u64, addr: &Address) -> u32 { - let key = DataKey::EventApplicantSlot(id, addr.clone()); - let slot: Option = env.storage().persistent().get(&key); - if slot.is_some() { - touch_event_persistent(env, &key); - } - slot.unwrap_or(0) -} - -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); - // 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()); - env.storage().persistent().set(&slot_key, &slot); - touch_event_persistent(env, &slot_key); - - let count_key = DataKey::EventApplicantCount(id); - env.storage().persistent().set(&count_key, &slot); - touch_event_persistent(env, &count_key); - Ok(slot) -} - -pub fn remove_applicant(env: &Env, id: u64, addr: &Address) -> Result<(), Error> { - let slot = applicant_slot(env, id, addr); - if slot == 0 { - return Err(Error::ApplicantNotApplied); - } - let idx = slot.checked_sub(1).ok_or(Error::ApplicantNotApplied)?; - let count = applicant_count(env, id); - let last_idx = count.checked_sub(1).ok_or(Error::EventNotFound)?; - - if idx != last_idx { - let last_addr = applicant_at(env, id, last_idx).ok_or(Error::EventNotFound)?; - let at_key = DataKey::EventApplicantAt(id, idx); - env.storage().persistent().set(&at_key, &last_addr); - touch_event_persistent(env, &at_key); - - let last_slot_key = DataKey::EventApplicantSlot(id, last_addr.clone()); - env.storage().persistent().set(&last_slot_key, &slot); - touch_event_persistent(env, &last_slot_key); - } - - env.storage() - .persistent() - .remove(&DataKey::EventApplicantAt(id, last_idx)); - env.storage() - .persistent() - .remove(&DataKey::EventApplicantSlot(id, addr.clone())); - - let count_key = DataKey::EventApplicantCount(id); - let new_count = count.checked_sub(1).ok_or(Error::EventNotFound)?; - if new_count == 0 { - env.storage().persistent().remove(&count_key); - } else { - env.storage().persistent().set(&count_key, &new_count); - touch_event_persistent(env, &count_key); - } - Ok(()) -} - -pub fn applicants_snapshot(env: &Env, id: u64, start: u32, limit: u32) -> Vec
{ - let count = applicant_count(env, id); - let end = start.saturating_add(limit).min(count); - let mut out: Vec
= Vec::new(env); - for idx in start..end { - if let Some(addr) = applicant_at(env, id, idx) { - out.push_back(addr); - } - } - out -} - -// ============================================================ -// SUBMISSIONS (per-entry, persistent) -// ============================================================ -pub fn get_submission(env: &Env, id: u64, applicant: &Address, slot: u32) -> Option { - let key = DataKey::EventSubmissionEntry(id, applicant.clone(), slot); - let s: Option = env.storage().persistent().get(&key); - if s.is_some() { - touch_event_persistent(env, &key); - return s; - } - // Pre-1.7.0 rows have no slot and `migrate` can only reach the ones whose - // applicant is in the applicant index, which hackathons never populate. - // Reading them as slot 0 keeps every historical submission addressable - // regardless of pillar; the first write folds them into the slotted layout. - if slot == 0 { - return get_legacy_submission(env, id, applicant); - } - None -} - -pub fn set_submission(env: &Env, id: u64, applicant: &Address, slot: u32, submission: &Submission) { - let key = DataKey::EventSubmissionEntry(id, applicant.clone(), slot); - env.storage().persistent().set(&key, submission); - touch_event_persistent(env, &key); -} - -/// How many slots this applicant currently occupies in this event. Drives the -/// O(1) "has this applicant submitted at all" check that gates application -/// withdrawal, which would otherwise have to scan slots. -pub fn applicant_submission_count(env: &Env, id: u64, applicant: &Address) -> u32 { - let key = DataKey::EventApplicantSubmissionCount(id, applicant.clone()); - let n: Option = env.storage().persistent().get(&key); - if n.is_some() { - touch_event_persistent(env, &key); - } - n.unwrap_or(0) -} - -pub fn has_any_submission(env: &Env, id: u64, applicant: &Address) -> bool { - applicant_submission_count(env, id, applicant) > 0 - || get_legacy_submission(env, id, applicant).is_some() -} - -fn set_applicant_submission_count(env: &Env, id: u64, applicant: &Address, count: u32) { - let key = DataKey::EventApplicantSubmissionCount(id, applicant.clone()); - if count == 0 { - env.storage().persistent().remove(&key); - return; - } - env.storage().persistent().set(&key, &count); - touch_event_persistent(env, &key); -} - -pub fn remove_submission(env: &Env, id: u64, applicant: &Address, slot: u32) { - // Idempotent: a no-op when the slot is empty, so a caller that skips its - // own existence check cannot corrupt either counter. - if get_submission(env, id, applicant, slot).is_none() { - return; - } - - let key = DataKey::EventSubmissionEntry(id, applicant.clone(), slot); - env.storage().persistent().remove(&key); - // An unmigrated row is addressed as slot 0; drop it too or it would keep - // answering reads after the withdrawal. - if slot == 0 { - remove_legacy_submission(env, id, applicant); - } - - let per_applicant = applicant_submission_count(env, id, applicant).saturating_sub(1); - set_applicant_submission_count(env, id, applicant, per_applicant); - - let count_key = DataKey::EventSubmissionCount(id); - let next = submission_count(env, id).saturating_sub(1); - if next == 0 { - env.storage().persistent().remove(&count_key); - } else { - env.storage().persistent().set(&count_key, &next); - touch_event_persistent(env, &count_key); - } -} - -pub fn submission_count(env: &Env, id: u64) -> u32 { - let key = DataKey::EventSubmissionCount(id); - let n: Option = env.storage().persistent().get(&key); - if n.is_some() { - touch_event_persistent(env, &key); - } - n.unwrap_or(0) -} - -/// Count a newly occupied slot before writing it (mirrors -/// `append_contributor`/`append_applicant`). A no-op when the slot already -/// holds an entry — re-submitting to the same slot updates it in place and -/// must not recount. -/// -/// 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, slot: u32) -> Result<(), Error> { - // Fold an unmigrated pre-1.7.0 row into the slotted layout on first write, - // so the legacy key cannot linger and disagree with the counters. It was - // already counted in the per-event total before the upgrade. - if slot == 0 && get_legacy_submission(env, id, addr).is_some() { - remove_legacy_submission(env, id, addr); - // Increment rather than set: the applicant may already hold slotted - // entries, and folding adds one more. Setting it to 1 would undercount - // and let has_any_submission go false while a slot is still occupied. - let per_applicant = applicant_submission_count(env, id, addr) - .checked_add(1) - .ok_or(Error::TooManyContributors)?; - set_applicant_submission_count(env, id, addr, per_applicant); - return Ok(()); - } - if get_submission(env, id, addr, slot).is_some() { - return Ok(()); - } - let cur = submission_count(env, id); - let next = cur.checked_add(1).ok_or(Error::TooManyContributors)?; - let count_key = DataKey::EventSubmissionCount(id); - env.storage().persistent().set(&count_key, &next); - touch_event_persistent(env, &count_key); - - let per_applicant = applicant_submission_count(env, id, addr) - .checked_add(1) - .ok_or(Error::TooManyContributors)?; - set_applicant_submission_count(env, id, addr, per_applicant); - Ok(()) -} - -/// Reads a pre-1.7.0 submission row, which lived under a key with no slot. -/// Only `migrate` calls this. -pub fn get_legacy_submission(env: &Env, id: u64, applicant: &Address) -> Option { - let key = DataKey::EventSubmission(id, applicant.clone()); - let s: Option = env.storage().persistent().get(&key); - // This key is a permanent read path, not just a migration source: hackathon - // submitters never enter the applicant index, so their rows are only ever - // reachable here. Without the touch they archive and the submission - // silently disappears. - if s.is_some() { - touch_event_persistent(env, &key); - } - s -} - -/// Drops a pre-1.7.0 submission row once it has been copied to a slot. -pub fn remove_legacy_submission(env: &Env, id: u64, applicant: &Address) { - env.storage() - .persistent() - .remove(&DataKey::EventSubmission(id, applicant.clone())); -} - // ============================================================ // WINNERS (paged, persistent) // ============================================================ diff --git a/contracts/events/src/tests/admin.rs b/contracts/events/src/tests/admin.rs index 7745ac8..9d0d3e0 100644 --- a/contracts/events/src/tests/admin.rs +++ b/contracts/events/src/tests/admin.rs @@ -8,7 +8,7 @@ use soroban_sdk::{ use super::common::setup; use crate::errors::Error; use crate::storage; -use crate::types::{DataKey, EventStatus, Pillar, ReleaseKind, Submission, Winner}; +use crate::types::{DataKey, EventStatus, Pillar, ReleaseKind, Winner}; // Zeroed with the contract constant; restore both together. const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; @@ -22,7 +22,7 @@ fn initializes_with_expected_config() { assert_eq!(ctx.client.get_fee_bps(), 250); assert_eq!(ctx.client.get_profile_contract(), ctx.profile_contract); assert_eq!(ctx.client.is_paused(), false); - assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.7.0")); + assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "2.0.0")); assert_eq!(ctx.client.get_pending_upgrade(), None); assert_eq!(ctx.client.get_migrated_to_version(), None); } @@ -135,7 +135,7 @@ fn cancel_pending_upgrade_clears_proposal() { ctx.client.cancel_pending_upgrade(); assert_eq!(ctx.client.get_pending_upgrade(), None); - assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.7.0")); + assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "2.0.0")); } #[test] @@ -227,111 +227,6 @@ fn migrate_rewrites_legacy_percentages_as_prize_floors() { ); } -#[test] -fn migrate_leaves_legacy_submissions_addressable_without_scanning_applicants() { - // migrate deliberately does not walk the applicant index: that loop is - // unbounded (per-event caps were removed) and would blow the invocation's - // 100-entry footprint on a popular event. Legacy rows stay reachable - // through the slot-0 fallback instead, and fold in on their next write. - let ctx = setup(250); - let applicant = Address::generate(&ctx.env); - let event_id = ctx.client.id_base() + 1; - let budget = 1_000_0000000_i128; - - let mut dist = Map::new(&ctx.env); - dist.set(1, 100_u32); - let legacy_event = LegacyEventRecord { - id: event_id, - pillar: Pillar::Bounty, - owner: Address::generate(&ctx.env), - token: Address::generate(&ctx.env), - total_budget: budget, - remaining_escrow: 0, - release_kind: ReleaseKind::Single, - status: EventStatus::Completed, - content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/legacy"), - title: String::from_str(&ctx.env, "Legacy"), - created_at: 1, - deadline: None, - winner_distribution: dist, - fee_bps_override: None, - }; - - ctx.env.as_contract(&ctx.client.address, || { - ctx.env - .storage() - .persistent() - .set(&DataKey::Event(event_id), &legacy_event); - ctx.env - .storage() - .instance() - .set(&DataKey::NextEventId, &(event_id + 1)); - storage::append_applicant(&ctx.env, event_id, &applicant).unwrap(); - ctx.env.storage().persistent().set( - &DataKey::EventSubmission(event_id, applicant.clone()), - &Submission { - applicant: applicant.clone(), - content_uri: String::from_str(&ctx.env, "ipfs://historical"), - submitted_at: 42, - }, - ); - }); - - ctx.client.migrate_events(&8_u32); - ctx.client.migrate(); - - // The record rewrite is the load-bearing part and must have happened. - assert_eq!( - ctx.client.get_event(&event_id).prize_floors.get(1), - Some(budget) - ); - - // The submission is still addressable, with its timestamp intact. - let found = ctx.client.get_submission(&event_id, &applicant, &0_u32); - assert_eq!( - found.content_uri, - String::from_str(&ctx.env, "ipfs://historical") - ); - assert_eq!(found.submitted_at, 42); - ctx.env.as_contract(&ctx.client.address, || { - assert!(storage::has_any_submission(&ctx.env, event_id, &applicant)); - }); -} - -#[test] -fn legacy_submission_is_readable_when_the_applicant_index_never_saw_it() { - // Hackathon submitters never enter the applicant index, so migrate cannot - // enumerate them. Their rows must still be reachable as slot 0. - let ctx = setup(250); - let submitter = Address::generate(&ctx.env); - let event_id = ctx.client.id_base() + 1; - - let legacy_submission = Submission { - applicant: submitter.clone(), - content_uri: String::from_str(&ctx.env, "ipfs://hackathon-entry"), - submitted_at: 7, - }; - ctx.env.as_contract(&ctx.client.address, || { - ctx.env.storage().persistent().set( - &DataKey::EventSubmission(event_id, submitter.clone()), - &legacy_submission, - ); - }); - - let found = ctx.client.get_submission(&event_id, &submitter, &0_u32); - assert_eq!( - found.content_uri, - String::from_str(&ctx.env, "ipfs://hackathon-entry") - ); - - ctx.env.as_contract(&ctx.client.address, || { - assert!( - storage::has_any_submission(&ctx.env, event_id, &submitter), - "an unmigrated row must still block application withdrawal" - ); - }); -} - #[test] fn migrate_rewrites_zero_amount_grant_winners() { // Pre-1.7.0 Multi selections stored amount 0 on the anchor row; leaving @@ -426,7 +321,7 @@ fn migrate_refuses_to_stamp_while_events_remain_unconverted() { ctx.client.migrate(); assert_eq!( ctx.client.get_migrated_to_version(), - Some(String::from_str(&ctx.env, "1.7.0")) + Some(String::from_str(&ctx.env, "2.0.0")) ); } @@ -449,7 +344,7 @@ fn migrate_events_caps_each_call_regardless_of_the_argument() { #[test] fn migrate_skips_rows_already_in_the_current_layout() { - // A fresh 1.7.0 deployment writes new-layout events, and the runbook still + // A fresh 2.0.0 deployment writes new-layout events, and the runbook still // calls migrate() after apply. Decoding those as the legacy shape used to // abort the whole invocation. let ctx = setup(250); @@ -525,8 +420,8 @@ fn migrate_runs_regardless_of_how_the_version_was_spelled() { .storage() .instance() .set(&DataKey::NextEventId, &(event_id + 1)); - // The operator proposed the upgrade as "v1.7.0" rather than "1.7.0". - storage::set_version(&ctx.env, &String::from_str(&ctx.env, "v1.7.0")); + // The operator proposed the upgrade as "v2.0.0" rather than "2.0.0". + storage::set_version(&ctx.env, &String::from_str(&ctx.env, "v2.0.0")); }); ctx.client.migrate_events(&8_u32); @@ -536,50 +431,6 @@ fn migrate_runs_regardless_of_how_the_version_was_spelled() { assert_eq!(migrated.prize_floors.get(1), Some(budget)); } -#[test] -fn folding_a_legacy_row_adds_to_the_applicants_existing_slots() { - let ctx = setup(250); - let applicant = Address::generate(&ctx.env); - let event_id = ctx.client.id_base() + 1; - - ctx.env.as_contract(&ctx.client.address, || { - // An unmigrated row, plus a slotted entry the applicant already holds. - ctx.env.storage().persistent().set( - &DataKey::EventSubmission(event_id, applicant.clone()), - &Submission { - applicant: applicant.clone(), - content_uri: String::from_str(&ctx.env, "ipfs://legacy"), - submitted_at: 1, - }, - ); - storage::append_submission(&ctx.env, event_id, &applicant, 1).unwrap(); - storage::set_submission( - &ctx.env, - event_id, - &applicant, - 1, - &Submission { - applicant: applicant.clone(), - content_uri: String::from_str(&ctx.env, "ipfs://slot-one"), - submitted_at: 2, - }, - ); - - // Folding the legacy row into slot 0 must count it, not overwrite. - storage::append_submission(&ctx.env, event_id, &applicant, 0).unwrap(); - assert_eq!( - storage::applicant_submission_count(&ctx.env, event_id, &applicant), - 2 - ); - - storage::remove_submission(&ctx.env, event_id, &applicant, 0); - assert!( - storage::has_any_submission(&ctx.env, event_id, &applicant), - "slot 1 is still occupied, so the application must stay locked" - ); - }); -} - #[test] fn migrate_is_a_no_op_on_a_fresh_deployment() { let ctx = setup(250); @@ -587,7 +438,7 @@ fn migrate_is_a_no_op_on_a_fresh_deployment() { ctx.client.migrate(); assert_eq!( ctx.client.get_migrated_to_version(), - Some(String::from_str(&ctx.env, "1.7.0")) + Some(String::from_str(&ctx.env, "2.0.0")) ); } @@ -599,7 +450,7 @@ fn migrate_marks_current_version_and_blocks_replay() { ctx.client.migrate(); assert_eq!( ctx.client.get_migrated_to_version(), - Some(String::from_str(&ctx.env, "1.7.0")) + Some(String::from_str(&ctx.env, "2.0.0")) ); let err = ctx diff --git a/contracts/events/src/tests/bounty_pillar.rs b/contracts/events/src/tests/bounty_pillar.rs index 2ea3a68..3a6b98c 100644 --- a/contracts/events/src/tests/bounty_pillar.rs +++ b/contracts/events/src/tests/bounty_pillar.rs @@ -5,9 +5,8 @@ use soroban_sdk::{ token, Address, BytesN, Env, Map, String, }; -use super::common::drive_cancel; use crate::errors::Error; -use crate::types::{CreateEventParams, DataKey, EventStatus, Pillar, ReleaseKind, WinnerSpec}; +use crate::types::{CreateEventParams, Pillar, ReleaseKind}; use crate::{EventsContract, EventsContractClient}; use boundless_profile::{ProfileContract, ProfileContractClient}; @@ -18,9 +17,7 @@ const TOTAL_BUDGET: i128 = 10_000_0000000_i128; struct Ctx<'a> { env: Env, events: EventsContractClient<'a>, - profile: ProfileContractClient<'a>, owner: Address, - applicant: Address, token_addr: Address, } @@ -57,14 +54,10 @@ fn setup<'a>() -> Ctx<'a> { events.register_supported_token(&token_addr); - let applicant = Address::generate(&env); - Ctx { env, events, - profile, owner, - applicant, token_addr, } } @@ -75,46 +68,6 @@ fn one_winner_distribution(env: &Env) -> Map { m } -fn create_bounty(ctx: &Ctx) -> u64 { - create_bounty_with_deadline(ctx, ctx.env.ledger().timestamp() + 86_400) -} - -fn create_bounty_with_deadline(ctx: &Ctx, deadline: u64) -> u64 { - let params = CreateEventParams { - pillar: Pillar::Bounty, - owner: ctx.owner.clone(), - token: ctx.token_addr.clone(), - total_budget: TOTAL_BUDGET, - release_kind: ReleaseKind::Single, - content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/events/draft/x"), - title: String::from_str(&ctx.env, "Test Bounty"), - deadline: Some(deadline), - prize_floors: one_winner_distribution(&ctx.env), - fee_bps_override: None, - manager: None, - }; - let op_id = BytesN::random(&ctx.env); - ctx.events.create_event(¶ms, &op_id) -} - -fn create_hackathon(ctx: &Ctx) -> u64 { - let params = CreateEventParams { - pillar: Pillar::Hackathon, - owner: ctx.owner.clone(), - token: ctx.token_addr.clone(), - total_budget: TOTAL_BUDGET, - release_kind: ReleaseKind::Single, - content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), - title: String::from_str(&ctx.env, "Test Hackathon"), - deadline: Some(ctx.env.ledger().timestamp() + 86_400), - prize_floors: one_winner_distribution(&ctx.env), - fee_bps_override: None, - manager: None, - }; - let op = BytesN::random(&ctx.env); - ctx.events.create_event(¶ms, &op) -} - fn expect_op_err( result: Result, Result>, ) -> Error { @@ -148,544 +101,3 @@ fn create_rejects_multi_release_kind() { let err = expect_op_err(ctx.events.try_create_event(¶ms, &op)); assert_eq!(err, Error::InvalidReleaseKind); } - -// ============================================================ -// apply_to_bounty — happy path -// ============================================================ - -#[test] -fn apply_bootstraps_profile_and_records_applicant() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - - assert!(ctx.profile.get_profile(&ctx.applicant).is_none()); - - let op_id = BytesN::random(&ctx.env); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &op_id); - - assert!(ctx.profile.get_profile(&ctx.applicant).is_some()); - - let applicants = ctx.events.get_applicants(&bounty_id); - assert_eq!(applicants.len(), 1); - assert_eq!(applicants.get(0).unwrap(), ctx.applicant); -} - -// ============================================================ -// apply_to_bounty — errors + idempotency -// ============================================================ - -#[test] -fn duplicate_apply_reverts() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - - let op_a = BytesN::random(&ctx.env); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &op_a); - - let op_b = BytesN::random(&ctx.env); - let err = expect_op_err( - ctx.events - .try_apply_to_bounty(&bounty_id, &ctx.applicant, &op_b), - ); - assert_eq!(err, Error::ApplicantAlreadyApplied); -} - -#[test] -fn replayed_apply_reverts_idempotently() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - - let op_id = BytesN::random(&ctx.env); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &op_id); - - let err = expect_op_err( - ctx.events - .try_apply_to_bounty(&bounty_id, &ctx.applicant, &op_id), - ); - assert_eq!(err, Error::OpAlreadySeen); -} - -#[test] -fn apply_on_nonexistent_event_reverts() { - let ctx = setup(); - let op_id = BytesN::random(&ctx.env); - let err = expect_op_err( - ctx.events - .try_apply_to_bounty(&9999, &ctx.applicant, &op_id), - ); - assert_eq!(err, Error::EventNotFound); -} - -#[test] -fn apply_on_wrong_pillar_reverts() { - let ctx = setup(); - let hackathon_id = create_hackathon(&ctx); - let op_id = BytesN::random(&ctx.env); - let err = expect_op_err( - ctx.events - .try_apply_to_bounty(&hackathon_id, &ctx.applicant, &op_id), - ); - assert_eq!(err, Error::InvalidPillar); -} - -#[test] -fn apply_on_cancelled_event_reverts() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - drive_cancel(&ctx.env, &ctx.events, bounty_id); - - 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::EventNotActive); -} - -#[test] -fn apply_on_completed_event_reverts() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - - let op_apply = BytesN::random(&ctx.env); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &op_apply); - - let winners = soroban_sdk::vec![ - &ctx.env, - WinnerSpec { - recipient: ctx.applicant.clone(), - position: 1, - amount: 10_000_0000000_i128, - reputation_bump: 0, - }, - ]; - let op_select = BytesN::random(&ctx.env); - ctx.events.select_winners(&bounty_id, &winners, &op_select); - - // Pull model: claim the sole prize to drain escrow and complete. - ctx.events - .claim_prize(&bounty_id, &1_u32, &BytesN::random(&ctx.env)); - - let event = ctx.events.get_event(&bounty_id); - assert_eq!(event.status, EventStatus::Completed); - - let op_retry = BytesN::random(&ctx.env); - let err = expect_op_err( - ctx.events - .try_apply_to_bounty(&bounty_id, &ctx.applicant, &op_retry), - ); - assert_eq!(err, Error::EventNotActive); -} - -#[test] -fn apply_when_paused_reverts() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - ctx.events.pause(); - - 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::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(); - let bounty_id = create_bounty(&ctx); - let op_id = BytesN::random(&ctx.env); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &op_id); - - let auths = ctx.env.auths(); - let applicant_required = auths.iter().any(|(addr, _)| *addr == ctx.applicant); - assert!(applicant_required, "apply must demand applicant auth"); -} - -// ============================================================ -// withdraw_application — happy path -// ============================================================ - -#[test] -fn withdraw_removes_the_applicant() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - - let op_apply = BytesN::random(&ctx.env); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &op_apply); - assert_eq!(ctx.events.get_applicants(&bounty_id).len(), 1); - - let op_wd = BytesN::random(&ctx.env); - ctx.events - .withdraw_application(&bounty_id, &ctx.applicant, &op_wd); - - let applicants = ctx.events.get_applicants(&bounty_id); - assert_eq!(applicants.len(), 0); -} - -// ============================================================ -// withdraw_application — errors + idempotency -// ============================================================ - -#[test] -fn withdraw_without_apply_reverts() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - let op_id = BytesN::random(&ctx.env); - let err = expect_op_err(ctx.events.try_withdraw_application( - &bounty_id, - &ctx.applicant, - &op_id, - )); - assert_eq!(err, Error::ApplicantNotApplied); -} - -#[test] -fn withdraw_after_submit_reverts() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - - let op_apply = BytesN::random(&ctx.env); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &op_apply); - - let uri = String::from_str(&ctx.env, "ipfs://Qm.../bounty.json"); - let op_submit = BytesN::random(&ctx.env); - ctx.events - .submit(&bounty_id, &ctx.applicant, &0_u32, &uri, &op_submit); - - let op_wd = BytesN::random(&ctx.env); - let err = expect_op_err(ctx.events.try_withdraw_application( - &bounty_id, - &ctx.applicant, - &op_wd, - )); - assert_eq!(err, Error::SubmissionAlreadyExists); -} - -#[test] -fn replayed_withdraw_reverts_idempotently() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - - let op_apply = BytesN::random(&ctx.env); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &op_apply); - - let op_wd = BytesN::random(&ctx.env); - ctx.events - .withdraw_application(&bounty_id, &ctx.applicant, &op_wd); - - let err = expect_op_err(ctx.events.try_withdraw_application( - &bounty_id, - &ctx.applicant, - &op_wd, - )); - assert_eq!(err, Error::OpAlreadySeen); -} - -#[test] -fn withdraw_on_nonexistent_event_reverts() { - let ctx = setup(); - let op_id = BytesN::random(&ctx.env); - let err = expect_op_err( - ctx.events - .try_withdraw_application(&9999, &ctx.applicant, &op_id), - ); - assert_eq!(err, Error::EventNotFound); -} - -#[test] -fn withdraw_when_paused_reverts() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - - let op_apply = BytesN::random(&ctx.env); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &op_apply); - - ctx.events.pause(); - - let op_wd = BytesN::random(&ctx.env); - let err = expect_op_err(ctx.events.try_withdraw_application( - &bounty_id, - &ctx.applicant, - &op_wd, - )); - assert_eq!(err, Error::Paused); -} - -#[test] -fn withdraw_requires_applicant_auth() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - - let op_apply = BytesN::random(&ctx.env); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &op_apply); - - let op_wd = BytesN::random(&ctx.env); - ctx.events - .withdraw_application(&bounty_id, &ctx.applicant, &op_wd); - - let auths = ctx.env.auths(); - let applicant_required = auths.iter().any(|(addr, _)| *addr == ctx.applicant); - assert!(applicant_required, "withdraw must demand applicant auth"); -} - -// ============================================================ -// Submission slots: several distinct entries per wallet -// ============================================================ - -#[test] -fn one_wallet_holds_several_submissions_in_distinct_slots() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &BytesN::random(&ctx.env)); - - ctx.events.submit( - &bounty_id, - &ctx.applicant, - &0_u32, - &String::from_str(&ctx.env, "ipfs://design"), - &BytesN::random(&ctx.env), - ); - ctx.events.submit( - &bounty_id, - &ctx.applicant, - &1_u32, - &String::from_str(&ctx.env, "ipfs://article"), - &BytesN::random(&ctx.env), - ); - - // Neither entry overwrites the other; the slot is what tells them apart. - assert_eq!( - ctx.events - .get_submission(&bounty_id, &ctx.applicant, &0_u32) - .content_uri, - String::from_str(&ctx.env, "ipfs://design") - ); - assert_eq!( - ctx.events - .get_submission(&bounty_id, &ctx.applicant, &1_u32) - .content_uri, - String::from_str(&ctx.env, "ipfs://article") - ); - assert_eq!( - ctx.events - .get_applicant_submission_count(&bounty_id, &ctx.applicant), - 2 - ); -} - -#[test] -fn resubmitting_to_an_occupied_slot_updates_it_without_recounting() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &BytesN::random(&ctx.env)); - - ctx.events.submit( - &bounty_id, - &ctx.applicant, - &0_u32, - &String::from_str(&ctx.env, "ipfs://v1"), - &BytesN::random(&ctx.env), - ); - let first = ctx - .events - .get_submission(&bounty_id, &ctx.applicant, &0_u32); - - ctx.events.submit( - &bounty_id, - &ctx.applicant, - &0_u32, - &String::from_str(&ctx.env, "ipfs://v2"), - &BytesN::random(&ctx.env), - ); - let second = ctx - .events - .get_submission(&bounty_id, &ctx.applicant, &0_u32); - - assert_eq!(second.content_uri, String::from_str(&ctx.env, "ipfs://v2")); - assert_eq!( - second.submitted_at, first.submitted_at, - "an update keeps the original submission time" - ); - assert_eq!( - ctx.events - .get_applicant_submission_count(&bounty_id, &ctx.applicant), - 1 - ); -} - -#[test] -fn withdrawing_one_slot_leaves_the_others_intact() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &BytesN::random(&ctx.env)); - - for slot in 0..3_u32 { - ctx.events.submit( - &bounty_id, - &ctx.applicant, - &slot, - &String::from_str(&ctx.env, "ipfs://entry"), - &BytesN::random(&ctx.env), - ); - } - - ctx.events.withdraw_submission( - &bounty_id, - &ctx.applicant, - &1_u32, - &BytesN::random(&ctx.env), - ); - - assert_eq!( - ctx.events - .get_applicant_submission_count(&bounty_id, &ctx.applicant), - 2 - ); - assert!(ctx - .events - .try_get_submission(&bounty_id, &ctx.applicant, &1_u32) - .is_err()); - assert!(ctx - .events - .try_get_submission(&bounty_id, &ctx.applicant, &0_u32) - .is_ok()); - assert!(ctx - .events - .try_get_submission(&bounty_id, &ctx.applicant, &2_u32) - .is_ok()); -} - -#[test] -fn application_withdrawal_stays_blocked_while_any_slot_is_filled() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &BytesN::random(&ctx.env)); - - // Occupying a slot other than 0 must still block application withdrawal; - // the gate asks whether any entry exists, not whether slot 0 does. - ctx.events.submit( - &bounty_id, - &ctx.applicant, - &7_u32, - &String::from_str(&ctx.env, "ipfs://entry"), - &BytesN::random(&ctx.env), - ); - - assert!(ctx - .events - .try_withdraw_application(&bounty_id, &ctx.applicant, &BytesN::random(&ctx.env)) - .is_err()); - - ctx.events.withdraw_submission( - &bounty_id, - &ctx.applicant, - &7_u32, - &BytesN::random(&ctx.env), - ); - ctx.events - .withdraw_application(&bounty_id, &ctx.applicant, &BytesN::random(&ctx.env)); -} diff --git a/contracts/events/src/tests/cross_contract.rs b/contracts/events/src/tests/cross_contract.rs index 8b527ac..e6ff2ce 100644 --- a/contracts/events/src/tests/cross_contract.rs +++ b/contracts/events/src/tests/cross_contract.rs @@ -110,10 +110,6 @@ fn select_winners_pays_recipient_and_bumps_profile() { let ctx = setup(); let bounty_id = create_bounty(&ctx); - let op_apply = BytesN::random(&ctx.env); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &op_apply); - let winners = soroban_sdk::vec![ &ctx.env, WinnerSpec { @@ -548,149 +544,6 @@ fn claim_milestone_final_milestone_marks_event_completed() { assert_eq!(after.remaining_escrow, 0); } -// ============================================================ -// submit / withdraw_submission -// ============================================================ - -fn create_hackathon(ctx: &Ctx) -> u64 { - let mut dist = Map::new(&ctx.env); - dist.set(1, TOTAL_BUDGET * 100 / 100); - let params = CreateEventParams { - pillar: Pillar::Hackathon, - owner: ctx.owner.clone(), - token: ctx.token_addr.clone(), - total_budget: TOTAL_BUDGET, - release_kind: ReleaseKind::Single, - content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), - title: String::from_str(&ctx.env, "Test Hackathon"), - deadline: Some(ctx.env.ledger().timestamp() + 86_400), - prize_floors: dist, - fee_bps_override: None, - manager: None, - }; - let op_create = BytesN::random(&ctx.env); - ctx.events.create_event(¶ms, &op_create) -} - -#[test] -fn hackathon_submit_creates_anchor_without_prior_apply() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - let uri = String::from_str(&ctx.env, "ipfs://Qm.../project.json"); - let op = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &0_u32, &uri, &op); - - let sub = ctx.events.get_submission(&id, &ctx.applicant, &0_u32); - assert_eq!(sub.applicant, ctx.applicant); - assert_eq!(sub.content_uri, uri); - assert_eq!(sub.submitted_at, ctx.env.ledger().timestamp()); -} - -#[test] -fn bounty_submit_requires_prior_application() { - let ctx = setup(); - let id = create_bounty(&ctx); - - let uri = String::from_str(&ctx.env, "ipfs://Qm.../bounty.json"); - let op = BytesN::random(&ctx.env); - let res = ctx - .events - .try_submit(&id, &ctx.applicant, &0_u32, &uri, &op); - assert!(res.is_err(), "submit before apply on bounty should revert"); -} - -#[test] -fn bounty_submit_succeeds_after_apply() { - let ctx = setup(); - let id = create_bounty(&ctx); - - let op_apply = BytesN::random(&ctx.env); - ctx.events.apply_to_bounty(&id, &ctx.applicant, &op_apply); - - let uri = String::from_str(&ctx.env, "ipfs://Qm.../bounty.json"); - let op_submit = BytesN::random(&ctx.env); - ctx.events - .submit(&id, &ctx.applicant, &0_u32, &uri, &op_submit); - - let sub = ctx.events.get_submission(&id, &ctx.applicant, &0_u32); - assert_eq!(sub.content_uri, uri); -} - -#[test] -fn resubmit_preserves_original_submitted_at_and_updates_uri() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - let uri_a = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); - let op_a = BytesN::random(&ctx.env); - ctx.events - .submit(&id, &ctx.applicant, &0_u32, &uri_a, &op_a); - - let first = ctx.events.get_submission(&id, &ctx.applicant, &0_u32); - let first_time = first.submitted_at; - - let uri_b = String::from_str(&ctx.env, "ipfs://Qm.../v2.json"); - let op_b = BytesN::random(&ctx.env); - ctx.events - .submit(&id, &ctx.applicant, &0_u32, &uri_b, &op_b); - - let second = ctx.events.get_submission(&id, &ctx.applicant, &0_u32); - assert_eq!(second.content_uri, uri_b); - assert_eq!( - second.submitted_at, first_time, - "submitted_at must be preserved across re-submit" - ); -} - -#[test] -fn submit_replayed_reverts() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - let uri = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); - let op = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &0_u32, &uri, &op); - - let res = ctx - .events - .try_submit(&id, &ctx.applicant, &0_u32, &uri, &op); - assert!(res.is_err(), "replayed submit should revert"); -} - -#[test] -fn withdraw_submission_removes_anchor() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - let uri = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); - let op_submit = BytesN::random(&ctx.env); - ctx.events - .submit(&id, &ctx.applicant, &0_u32, &uri, &op_submit); - - let op_wd = BytesN::random(&ctx.env); - ctx.events - .withdraw_submission(&id, &ctx.applicant, &0_u32, &op_wd); - - let res = ctx.events.try_get_submission(&id, &ctx.applicant, &0_u32); - assert!(res.is_err(), "withdrawn submission should not be readable"); -} - -#[test] -fn withdraw_submission_without_submission_reverts() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - let op_wd = BytesN::random(&ctx.env); - let res = ctx - .events - .try_withdraw_submission(&id, &ctx.applicant, &0_u32, &op_wd); - assert!( - res.is_err(), - "withdraw without prior submission should revert" - ); -} - // ============================================================ // Per-event fee_bps_override // ============================================================ @@ -1057,8 +910,6 @@ fn unaccepted_proposal_leaves_owner_in_authority() { let attacker = Address::generate(&ctx.env); let id = create_bounty_with_manager(&ctx, &attacker); - ctx.events - .apply_to_bounty(&id, &ctx.applicant, &BytesN::random(&ctx.env)); ctx.events .select_winners(&id, &win_one(&ctx), &BytesN::random(&ctx.env)); @@ -1075,8 +926,6 @@ fn accepted_manager_holds_select_winners_authority() { let id = create_bounty_with_manager(&ctx, &manager); ctx.events.accept_manager(&id); - ctx.events - .apply_to_bounty(&id, &ctx.applicant, &BytesN::random(&ctx.env)); ctx.events .select_winners(&id, &win_one(&ctx), &BytesN::random(&ctx.env)); diff --git a/contracts/events/src/tests/crowdfunding.rs b/contracts/events/src/tests/crowdfunding.rs index 47280fa..6eef7f6 100644 --- a/contracts/events/src/tests/crowdfunding.rs +++ b/contracts/events/src/tests/crowdfunding.rs @@ -403,17 +403,6 @@ fn select_winners_on_crowdfunding_reverts() { assert!(res.is_err()); } -#[test] -fn submit_on_crowdfunding_reverts() { - let ctx = setup(); - let id = create_campaign(&ctx, 3); - - let op = BytesN::random(&ctx.env); - let uri = String::from_str(&ctx.env, "ipfs://nope"); - let res = ctx.events.try_submit(&id, &ctx.builder, &0_u32, &uri, &op); - assert!(res.is_err()); -} - // ============================================================ // cancel_event // ============================================================ diff --git a/contracts/events/src/tests/hackathon_pillar.rs b/contracts/events/src/tests/hackathon_pillar.rs index 5aa5a4b..338def6 100644 --- a/contracts/events/src/tests/hackathon_pillar.rs +++ b/contracts/events/src/tests/hackathon_pillar.rs @@ -5,10 +5,7 @@ use soroban_sdk::{ token, Address, BytesN, Env, Map, String, }; -use crate::errors::Error; -use crate::event_ops::MAX_CONTENT_URI_LEN; -use crate::storage; -use crate::types::{CreateEventParams, DataKey, EventStatus, Pillar, ReleaseKind, WinnerSpec}; +use crate::types::{CreateEventParams, EventStatus, Pillar, ReleaseKind, WinnerSpec}; use crate::{EventsContract, EventsContractClient}; use boundless_profile::{ProfileContract, ProfileContractClient}; @@ -21,7 +18,6 @@ const FEE_AMOUNT: i128 = (TOTAL_BUDGET * FEE_BPS as i128) / 10_000_i128; struct Ctx<'a> { env: Env, events: EventsContractClient<'a>, - events_id: Address, profile: ProfileContractClient<'a>, owner: Address, applicant: Address, @@ -68,7 +64,6 @@ fn setup<'a>() -> Ctx<'a> { Ctx { env, events, - events_id, profile, owner, applicant, @@ -78,15 +73,6 @@ fn setup<'a>() -> Ctx<'a> { } } -fn expect_op_err( - result: Result, Result>, -) -> Error { - match result { - Err(Ok(e)) => e, - _ => panic!("expected contract error"), - } -} - fn single_winner_dist(env: &Env) -> Map { let mut m = Map::new(env); m.set(1, 100000000000_i128); @@ -176,242 +162,6 @@ fn create_rejects_multi_release_kind() { // submit (open submission model) // ============================================================ -#[test] -fn submit_open_without_prior_apply_creates_anchor() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - let uri = String::from_str(&ctx.env, "ipfs://Qm.../project.json"); - let op = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &0_u32, &uri, &op); - - let sub = ctx.events.get_submission(&id, &ctx.applicant, &0_u32); - assert_eq!(sub.applicant, ctx.applicant); - assert_eq!(sub.content_uri, uri); - assert_eq!(sub.submitted_at, ctx.env.ledger().timestamp()); -} - -#[test] -fn resubmit_keeps_original_timestamp_and_updates_uri() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - let uri_a = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); - let op_a = BytesN::random(&ctx.env); - ctx.events - .submit(&id, &ctx.applicant, &0_u32, &uri_a, &op_a); - let first_time = ctx - .events - .get_submission(&id, &ctx.applicant, &0_u32) - .submitted_at; - - let uri_b = String::from_str(&ctx.env, "ipfs://Qm.../v2.json"); - let op_b = BytesN::random(&ctx.env); - ctx.events - .submit(&id, &ctx.applicant, &0_u32, &uri_b, &op_b); - - let second = ctx.events.get_submission(&id, &ctx.applicant, &0_u32); - assert_eq!(second.content_uri, uri_b); - assert_eq!(second.submitted_at, first_time); -} - -#[test] -fn submit_replayed_op_reverts() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - let uri = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); - let op = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &0_u32, &uri, &op); - - let res = ctx - .events - .try_submit(&id, &ctx.applicant, &0_u32, &uri, &op); - assert!(res.is_err(), "replayed submit op_id must revert"); -} - -#[test] -fn withdraw_submission_removes_anchor() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - let uri = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); - let op_s = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &0_u32, &uri, &op_s); - - let op_w = BytesN::random(&ctx.env); - ctx.events - .withdraw_submission(&id, &ctx.applicant, &0_u32, &op_w); - - let res = ctx.events.try_get_submission(&id, &ctx.applicant, &0_u32); - assert!(res.is_err(), "withdrawn submission is no longer readable"); -} - -#[test] -fn remove_submission_on_nonexistent_entry_does_not_corrupt_counter() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - let submitter = Address::generate(&ctx.env); - ctx.events.submit( - &id, - &submitter, - &0_u32, - &String::from_str(&ctx.env, "ipfs://Qm.../v1.json"), - &BytesN::random(&ctx.env), - ); - - // ctx.applicant never submitted — calling the low-level storage helper - // directly for it must be a no-op, not decrement the counter that - // `submitter`'s real submission incremented. - ctx.env.as_contract(&ctx.events_id, || { - storage::remove_submission(&ctx.env, id, &ctx.applicant, 0); - }); - - let count = ctx - .env - .as_contract(&ctx.events_id, || storage::submission_count(&ctx.env, id)); - assert_eq!( - count, 1, - "removing a nonexistent submission must not corrupt the counter" - ); -} - -#[test] -fn withdraw_submission_frees_the_slot_for_future_submitters() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - let uri = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); - ctx.events - .submit(&id, &ctx.applicant, &0_u32, &uri, &BytesN::random(&ctx.env)); - ctx.events - .withdraw_submission(&id, &ctx.applicant, &0_u32, &BytesN::random(&ctx.env)); - - let count = ctx - .env - .as_contract(&ctx.events_id, || storage::submission_count(&ctx.env, id)); - assert_eq!( - count, 0, - "withdrawing a submission must decrement the submission count" - ); -} - -#[test] -fn submit_beyond_former_cap_succeeds() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - // 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), &5_000_u32); - }); - - let uri = String::from_str(&ctx.env, "ipfs://Qm.../v5001.json"); - ctx.events - .submit(&id, &ctx.applicant, &0_u32, &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"); - let op = BytesN::random(&ctx.env); - let err = expect_op_err( - ctx.events - .try_submit(&id, &ctx.applicant, &0_u32, &uri, &op), - ); - assert_eq!( - err, - Error::TooManyContributors, - "a submission that would overflow the u32 counter must revert" - ); -} - -#[test] -fn submit_oversized_content_uri_reverts() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - let too_long = "x".repeat((MAX_CONTENT_URI_LEN + 1) as usize); - let uri = String::from_str(&ctx.env, &too_long); - let op = BytesN::random(&ctx.env); - let err = expect_op_err( - ctx.events - .try_submit(&id, &ctx.applicant, &0_u32, &uri, &op), - ); - // Reused rather than a new variant — stays inside the contracterror - // 50-variant cap (see BACKLOG.md L7 for precedent). - assert_eq!( - err, - Error::TitleTooLong, - "content_uri beyond MAX_CONTENT_URI_LEN must revert" - ); -} - -#[test] -fn resubmit_by_existing_applicant_does_not_increment_submission_count() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - let uri_a = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); - ctx.events.submit( - &id, - &ctx.applicant, - &0_u32, - &uri_a, - &BytesN::random(&ctx.env), - ); - - let count_after_first = ctx - .env - .as_contract(&ctx.events_id, || storage::submission_count(&ctx.env, id)); - assert_eq!(count_after_first, 1); - - let uri_b = String::from_str(&ctx.env, "ipfs://Qm.../v2.json"); - ctx.events.submit( - &id, - &ctx.applicant, - &0_u32, - &uri_b, - &BytesN::random(&ctx.env), - ); - - let count_after_second = ctx - .env - .as_contract(&ctx.events_id, || storage::submission_count(&ctx.env, id)); - assert_eq!( - count_after_second, 1, - "re-submission by an existing applicant updates in place and must not \ - recount against the cap" - ); -} - -// ============================================================ -// select_winners — distribution (happy paths) -// ============================================================ - #[test] fn select_winners_single_recipient_sweeps_escrow() { let ctx = setup(); diff --git a/contracts/events/src/tests/op_id_security.rs b/contracts/events/src/tests/op_id_security.rs index 1aaa682..b3a8a92 100644 --- a/contracts/events/src/tests/op_id_security.rs +++ b/contracts/events/src/tests/op_id_security.rs @@ -144,10 +144,6 @@ fn bootstrap_self_cannot_front_run_events_child_op_ids() { let ctx = setup(); let bounty_id = create_bounty(&ctx); - let op_apply = BytesN::random(&ctx.env); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &op_apply); - let winners = soroban_sdk::vec![ &ctx.env, WinnerSpec { @@ -276,19 +272,20 @@ fn event_id_overflow_reverts() { /// (select_winners) that reuses it. Before namespacing, the shared global /// OpSeen made the manager's payout revert with OpAlreadySeen. #[test] -fn permissionless_apply_cannot_squat_select_winners_op_id() { +fn permissionless_add_funds_cannot_squat_select_winners_op_id() { let ctx = setup(); let id = create_bounty(&ctx); - ctx.events - .apply_to_bounty(&id, &ctx.applicant, &BytesN::random(&ctx.env)); - // The op_id the owner will use to select winners. let victim_op = BytesN::random(&ctx.env); - // Attacker front-runs by burning that op_id in their own (apply) domain. + // Attacker front-runs by burning that op_id in their own (add_funds) + // domain. Contributing is the one entrypoint anyone may still call. let attacker = Address::generate(&ctx.env); - ctx.events.apply_to_bounty(&id, &attacker, &victim_op); + // add_funds debits the contribution plus the platform fee on top. + token::StellarAssetClient::new(&ctx.env, &ctx.token_addr).mint(&attacker, &1_000_0000000_i128); + ctx.events + .add_funds(&id, &attacker, &100_0000000_i128, &victim_op); // Owner's select_winners with the same op_id still succeeds (owner domain). let winners = soroban_sdk::vec![ diff --git a/contracts/events/src/types.rs b/contracts/events/src/types.rs index 936ccff..895fdb2 100644 --- a/contracts/events/src/types.rs +++ b/contracts/events/src/types.rs @@ -100,17 +100,6 @@ pub struct CreateEventParams { pub manager: Option
, } -// ============================================================ -// SUBMISSION -// ============================================================ -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Submission { - pub applicant: Address, - pub content_uri: String, - pub submitted_at: u64, -} - // ============================================================ // CONTRIBUTION // ============================================================ @@ -171,12 +160,14 @@ pub enum DataKey { EventManager(u64), + // Retired in 2.0.0 with the on-chain participation records: applying and + // submitting are product facts the contract never read, so nothing writes + // or reads these now. Kept in place rather than deleted so the remaining + // variants keep their positions and any rows left on live events stay + // identifiable until they archive. EventApplicantCount(u64), EventApplicantAt(u64, u32), EventApplicantSlot(u64, Address), - - /// Pre-1.7.0 single-submission key. Read only by `migrate`, which moves - /// each row to slot 0 of `EventSubmissionEntry`. Never write this. EventSubmission(u64, Address), EventWinnerCount(u64), @@ -217,7 +208,7 @@ pub enum DataKey { // Appended for two-step manager rotation to preserve key discriminants. PendingManager(u64), - // Appended to cap per-event submission storage growth (security fix). + // Retired in 2.0.0 (see the applicant keys above). EventSubmissionCount(u64), // Appended in 1.7.0. Sum of awarded-but-unclaimed prizes. `remaining_escrow` @@ -225,9 +216,8 @@ pub enum DataKey { // funds an earlier winner is still entitled to and could promise them twice. EventOwedTotal(u64), - // Appended in 1.7.0. Submissions gain a caller-chosen slot, so one wallet - // may hold several distinct entries in one event. The contract assigns no - // meaning to the slot; callers use it for whatever separates their entries. + // Retired in 2.0.0 (see the applicant keys above). Added in 1.7.0 to give + // one wallet several distinct entries in one event. EventSubmissionEntry(u64, Address, u32), EventApplicantSubmissionCount(u64, Address), diff --git a/docs/mainnet-deploy-runbook.md b/docs/mainnet-deploy-runbook.md index 15c1011..05d930b 100644 --- a/docs/mainnet-deploy-runbook.md +++ b/docs/mainnet-deploy-runbook.md @@ -328,7 +328,7 @@ stellar contract invoke \ Sign `/tmp/events-emergency-pause.xdr` sequentially with two signers, submit with `stellar tx send`, then read `is_paused` back as `true`. -Pause stops event-lifecycle mutations guarded by `require_not_paused`, including `create_event`, `add_funds`, `propose_manager`, `accept_manager`, `cancel_pending_manager`, `start_cancel`, `process_cancel_batch`, `finalize_cancel`, `select_winners`, `claim_milestone`, `apply_to_bounty`, `withdraw_application`, `submit`, and `withdraw_submission`. Reads continue. +Pause stops event-lifecycle mutations guarded by `require_not_paused`, including `create_event`, `add_funds`, `propose_manager`, `accept_manager`, `cancel_pending_manager`, `start_cancel`, `process_cancel_batch`, `finalize_cancel`, `select_winners`, `claim_milestone`, and `claim_prize`. Reads continue. Pause intentionally does **not** block admin recovery and governance entrypoints such as `propose_upgrade`, `apply_upgrade`, `cancel_pending_upgrade`, `migrate`, configuration changes, token-list changes, admin rotation, or `unpause`. This is why the coordinated SDK 27 upgrade can remain paused through its timelock. @@ -653,7 +653,7 @@ This section summarizes everything that changed in the contract surface between - `propose_upgrade(wasm_hash, new_version)`, `apply_upgrade()`, `cancel_pending_upgrade()`, `migrate()` — timelocked upgrade flow. - `start_cancel(event_id, op_id)`, `process_cancel_batch(event_id, max_refunds, op_id) -> u32`, `finalize_cancel(event_id, op_id)` — paged cancellation. - `propose_events_contract(addr)`, `accept_events_contract()`, `cancel_pending_events_contract()` — two-step rotation for the profile contract's events binding. First-set still uses `set_events_contract`. -- Paged read accessors on the events contract: `get_applicant_count`, `get_applicant_at`, `get_winner_count`, `get_winner_at`, `get_contributor_count`, `get_contributor_at`. The aggregated reads (`get_applicants`, `get_winners`, `get_contributors`) cap at the per-event maximum and stay available for backwards compat. +- Paged read accessors on the events contract: `get_winner_count`, `get_winner_at`, `get_contributor_count`, `get_contributor_at`. The aggregated reads (`get_winners`, `get_contributors`) cap at the per-event maximum and stay available for backwards compat. The applicant accessors went with the participation records in 2.0.0. - New `EventStatus::Cancelling` variant. Any switch on EventStatus needs to handle it. - New events: `PendingUpgradeProposed`, `UpgradeApplied`, `Migrated`, `PendingUpgradeCancelled`, `PendingEventsContractSet`, `EventsRotationCancelled`. `EventCreated` adds a `title` field.