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.