diff --git a/BACKLOG.md b/BACKLOG.md index a077ef9..09385ab 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -28,6 +28,8 @@ See `docs/audit-2026-06-stellar-skill.md` for full findings. - [x] **Domain subscribers ignore the new cancel kinds.** 2026-06-05: all four subscribers (`BountyEscrowSubscriber`, `HackathonEscrowSubscriber`, `GrantEscrowSubscriber`, `CrowdfundingEscrowSubscriber`) now handle `FINALIZE_CANCEL` (mark domain row CANCELLED) and the `START_CANCEL` OwnerOnly inline path (read on-chain status; if already Cancelled, mirror). Tests green (events + queues + escrow contract suites = 133/133). - [x] **Crowdfunding `claim_milestone` orchestrator path needs admin co-sign.** 2026-06-05: new `AdminSorobanAuthSignerService` finds the SorobanAuthorizationEntry whose address matches the configured admin and signs it via `authorizeEntry()` from `@stellar/stellar-base`. Smoke helper exposes `adminPreSign: true` on `driveToCompletion`; the crowdfunding claim path passes that flag. Verified end-to-end on testnet — all three milestone claims confirmed with `signed 1 admin auth entry` in the log and the builder receiving the full 900 TUSD across three claim txs. +- [ ] **RESTORE the upgrade timelock before the first funded mainnet campaign.** `UPGRADE_TIMELOCK_LEDGERS` was set to 0 on both contracts on 2026-08-18 so the 1.7.0 rollout could iterate, while mainnet escrow was empty (both events `Completed`, `remaining_escrow` 0). This reverses audited control H6. With no window, a compromised 2-of-3 admin can `propose_upgrade` and `apply_upgrade` in one go and `cancel_pending_upgrade` never gets a chance to fire — the whole point of the control. Restore to `17_280` in `contracts/events/src/admin.rs` and `contracts/profile/src/admin.rs`, and flip the two `apply_upgrade_is_immediate_while_the_timelock_is_zero*` tests back to asserting `UpgradeTimelockNotElapsed`. The cfg split was kept so this is a single-value edit per contract. **The pending third-party audit will flag this if it is still 0 at audit time.** + ## P1 (post-launch) - [x] `select_winners` re-run semantics: 1.3.0 (#61) made Single-release selection batchable — each position is awardable exactly once (per-position `EventPrizeAward` key is the replay lock), amounts stay anchored to the baseline captured at the first batch, and pre-1.3.0 events remain one-shot. (2026-07-18) diff --git a/contracts/events/src/admin.rs b/contracts/events/src/admin.rs index ff672b5..033b408 100644 --- a/contracts/events/src/admin.rs +++ b/contracts/events/src/admin.rs @@ -1,21 +1,52 @@ -use soroban_sdk::{panic_with_error, Address, BytesN, Env, String}; +use soroban_sdk::{contracttype, panic_with_error, Address, BytesN, Env, Map, String, Symbol, Val}; use crate::errors::Error; use crate::events as evt; +use crate::idempotency; use crate::storage; -use crate::types::{PendingAdmin, PendingUpgrade}; +use crate::types::{ + DataKey, EventRecord, EventStatus, PendingAdmin, PendingUpgrade, Pillar, ReleaseKind, Winner, +}; + +/// The pre-1.7.0 `EventRecord`, kept only so `migrate` can decode rows written +/// before prize floors replaced the percentage distribution. Nothing else may +/// read or write this shape. +#[contracttype] +#[derive(Clone)] +struct LegacyEventRecord { + pub id: u64, + pub pillar: Pillar, + pub owner: Address, + pub token: Address, + pub total_budget: i128, + pub remaining_escrow: i128, + pub release_kind: ReleaseKind, + pub status: EventStatus, + pub content_uri: String, + pub title: String, + pub created_at: u64, + pub deadline: Option, + pub winner_distribution: Map, + pub fee_bps_override: Option, +} const PENDING_ADMIN_TTL_LEDGERS: u32 = 120_960; pub(crate) const MAX_FEE_BPS: u32 = 1_000; +// H6 (audit 2026-06) mandated 17_280 ledgers, ~1 day, on mainnet. Zeroed +// deliberately while mainnet escrow is empty so the 1.7.0 rollout can iterate. +// RESTORE to 17_280 before the first funded campaign: with no window, a +// compromised admin key can propose and apply a wasm swap in one go, and +// cancel_pending_upgrade never gets a chance to fire. Tracked in BACKLOG. +// The cfg split is kept so restoring is a single-value edit. #[cfg(not(feature = "testnet"))] -const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; +const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; #[cfg(feature = "testnet")] const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; -pub const INITIAL_VERSION: &str = "1.6.0"; +pub const INITIAL_VERSION: &str = "1.7.0"; // ============================================================ // INITIALIZATION @@ -250,6 +281,16 @@ pub fn migrate(env: &Env) -> Result<(), Error> { // ============================================================ // PER-(from -> to) MIGRATION DISPATCH // ============================================================ + // Refuse to stamp while any event is still unconverted. The rewrite is + // paged through `migrate_events` because one invocation may touch only 100 + // ledger entries, and stamping early would leave the remainder undecodable + // with no way to resume: this is one-shot. + // + // Reuses EventIdOverflow rather than adding a variant — contracterror is at + // the 50-case cap. It means "events remain", not a counter fault. + if migration_remaining(env) > 0 { + return Err(Error::EventIdOverflow); + } storage::set_migrated_to_version(env, ¤t); storage::touch_instance(env); @@ -261,6 +302,153 @@ pub fn migrate(env: &Env) -> Result<(), Error> { Ok(()) } +/// How many events `migrate_events` has yet to convert. +fn migration_remaining(env: &Env) -> u64 { + let base = idempotency::id_base(env); + let first = base.saturating_add(1); + let next = storage::get_next_event_id(env, first); + let cursor = storage::get_migration_cursor(env).unwrap_or(first); + next.saturating_sub(cursor.max(first)) +} + +/// Converts up to `max_events` events from the pre-1.7.0 percentage layout, +/// advancing a stored cursor. Returns how many remain, so an operator can loop +/// until it reports zero and only then call `migrate`. +/// +/// Paged rather than one-shot because an invocation may touch at most 100 +/// ledger entries and write 50. A deployment with real history — testnet holds +/// over a hundred events — cannot be converted in a single transaction, and a +/// one-shot pass that aborts leaves every event undecodable. +pub fn migrate_events(env: &Env, max_events: u32) -> Result { + require_admin(env)?; + + // Each event costs a record read plus a record write, and a Multi event + // adds a read and a write per winner. Eight leaves headroom for the winner + // rewrites inside the write limit. + const MAX_PER_CALL: u32 = 8; + let budget = if max_events == 0 || max_events > MAX_PER_CALL { + MAX_PER_CALL + } else { + max_events + }; + + let base = idempotency::id_base(env); + let first = base.saturating_add(1); + let next = storage::get_next_event_id(env, first); + let mut cursor = storage::get_migration_cursor(env) + .unwrap_or(first) + .max(first); + + let mut done: u32 = 0; + while cursor < next && done < budget { + migrate_one_event(env, cursor); + cursor = cursor.saturating_add(1); + done = done.saturating_add(1); + } + + storage::set_migration_cursor(env, cursor); + storage::touch_instance(env); + Ok(next.saturating_sub(cursor)) +} + +/// Rewrites one event from the pre-1.7.0 percentage layout to prize floors. +/// `winner_distribution` and `prize_floors` differ in both name and value type, +/// so an old row cannot be decoded by the current struct at all. +/// +/// Percentages were always taken against the escrow balance, so `total_budget * +/// percent / 100` reproduces exactly what each position would have been paid. +fn migrate_one_event(env: &Env, id: u64) { + let key = DataKey::Event(id); + // Decode defensively. `get::` unwraps the conversion, + // and a missing field escalates to a host error rather than a catchable + // one, so a row already in the 1.7.0 layout would abort the whole + // invocation instead of being skipped. A contracttype struct is stored as + // a map keyed by field name, so the old layout is identified by the field + // that only it carries. + let fields: Option> = env.storage().persistent().get(&key); + let is_legacy = fields.is_some_and(|f| f.contains_key(Symbol::new(env, "winner_distribution"))); + if is_legacy { + if let Some(old) = env + .storage() + .persistent() + .get::(&key) + { + let mut floors: Map = Map::new(env); + for (position, percent) in old.winner_distribution.iter() { + let floor = old + .total_budget + .saturating_mul(percent as i128) + .saturating_div(100); + if floor > 0 { + floors.set(position, floor); + } + } + let migrated = EventRecord { + id: old.id, + pillar: old.pillar, + owner: old.owner, + token: old.token, + total_budget: old.total_budget, + remaining_escrow: old.remaining_escrow, + release_kind: old.release_kind, + status: old.status, + content_uri: old.content_uri, + title: old.title, + created_at: old.created_at, + deadline: old.deadline, + prize_floors: floors, + fee_bps_override: old.fee_bps_override, + }; + env.storage().persistent().set(&key, &migrated); + } + } + migrate_winner_amounts(env, id); +} + +/// Pre-1.7.0 `Multi` selections stored `amount: 0` on the anchor winner row, +/// because a grant milestone derived its payout from the percentage +/// distribution at claim time. `claim_milestone` now reads that amount, so an +/// unrewritten row would compute a payout of zero and revert on every claim, +/// with no way to re-select and no exit but cancelling the grant. +/// +/// The floor for the winner's position is exactly what the old formula would +/// have produced, since both are `total_budget * percent / 100`. +fn migrate_winner_amounts(env: &Env, event_id: u64) { + let event = match storage::get_event(env, event_id) { + Some(e) => e, + None => return, + }; + if !matches!(event.release_kind, ReleaseKind::Multi(_)) { + return; + } + let count = storage::winner_count(env, event_id); + for idx in 0..count { + let w = match storage::winner_at(env, event_id, idx) { + Some(w) => w, + None => continue, + }; + // Milestone rows already carry what was actually paid; only the anchor + // was written with a placeholder amount. + if w.milestone.is_some() || w.amount != 0 { + continue; + } + if let Some(floor) = event.prize_floors.get(w.position) { + storage::set_winner_at( + env, + event_id, + idx, + &Winner { + recipient: w.recipient.clone(), + position: w.position, + amount: floor, + milestone: None, + paid_at: w.paid_at, + }, + ); + } + } +} + // ============================================================ // READS // ============================================================ diff --git a/contracts/events/src/bounty.rs b/contracts/events/src/bounty.rs index 428a58b..da3d6a5 100644 --- a/contracts/events/src/bounty.rs +++ b/contracts/events/src/bounty.rs @@ -65,7 +65,7 @@ pub fn withdraw_application( applicant.require_auth(); idempotency::require_unseen(env, &applicant, &op_id)?; - if storage::get_submission(env, bounty_id, &applicant).is_some() { + if storage::has_any_submission(env, bounty_id, &applicant) { return Err(Error::SubmissionAlreadyExists); } diff --git a/contracts/events/src/crowdfunding.rs b/contracts/events/src/crowdfunding.rs index 04a98ff..29a6cd3 100644 --- a/contracts/events/src/crowdfunding.rs +++ b/contracts/events/src/crowdfunding.rs @@ -11,16 +11,7 @@ pub fn validate_create(_env: &Env, record: &EventRecord, _owner: &Address) -> Re _ => return Err(Error::InvalidReleaseKind), } - if record.winner_distribution.len() != 1 { - return Err(Error::InvalidDistribution); - } - let percent = record - .winner_distribution - .get(1) - .ok_or(Error::InvalidDistribution)?; - if percent != 100 { - return Err(Error::DistributionMismatch); - } - + // No floor check: crowdfunding pays milestones out of `remaining_escrow` + // divided by the milestones left, and never reads the prize floors. Ok(()) } diff --git a/contracts/events/src/event_ops.rs b/contracts/events/src/event_ops.rs index 54ab7d6..949330d 100644 --- a/contracts/events/src/event_ops.rs +++ b/contracts/events/src/event_ops.rs @@ -74,14 +74,22 @@ pub fn create_event(env: &Env, params: CreateEventParams, op_id: BytesN<32>) -> return Err(Error::InvalidBudget); } - if params.winner_distribution.is_empty() { + if params.prize_floors.is_empty() { return Err(Error::InvalidDistribution); } - let mut sum: u32 = 0; - for (_pos, percent) in params.winner_distribution.iter() { - sum = sum.saturating_add(percent); + // Floors may total less than the budget. The headroom is deliberate: it is + // what a later selection draws on to award a position that had no floor at + // create time. + let mut floor_sum: i128 = 0; + for (_pos, floor) in params.prize_floors.iter() { + if floor <= 0 { + return Err(Error::InvalidDistribution); + } + floor_sum = floor_sum + .checked_add(floor) + .ok_or(Error::DistributionMismatch)?; } - if sum != 100 { + if floor_sum > params.total_budget { return Err(Error::DistributionMismatch); } @@ -112,7 +120,7 @@ pub fn create_event(env: &Env, params: CreateEventParams, op_id: BytesN<32>) -> title: params.title.clone(), created_at: env.ledger().timestamp(), deadline: params.deadline, - winner_distribution: params.winner_distribution.clone(), + prize_floors: params.prize_floors.clone(), fee_bps_override: params.fee_bps_override, }; match params.pillar { @@ -367,6 +375,12 @@ pub fn start_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<(), E manager.require_auth(); idempotency::require_unseen(env, &manager, &op_id)?; + // Cancelling supersedes every award: both claim paths require Active, so + // from here nothing can be claimed and the reservation must be released + // rather than withheld. Withholding it would strand the funds with no + // path back out. The guard above is what protects prizes still claimable. + storage::set_owed_total(env, event_id, 0); + let remaining = event.remaining_escrow; let count = storage::contributor_count(env, event_id); let non_owner_total = get_or_init_non_owner_total(env, event_id)?; @@ -530,6 +544,7 @@ pub fn submit( env: &Env, event_id: u64, applicant: Address, + slot: u32, content_uri: String, op_id: BytesN<32>, ) -> Result<(), Error> { @@ -552,20 +567,20 @@ pub fn submit( return Err(Error::TitleTooLong); } - let existing = storage::get_submission(env, event_id, &applicant); + let existing = storage::get_submission(env, event_id, &applicant, slot); - if existing.is_none() { + 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 submission before writing. There is no cap: each submission - // is its own ledger entry whose write and rent are paid by the - // submitter's transaction, so spam addresses fund their own storage and - // cannot lock real participants out of a full event. - storage::append_submission(env, event_id, &applicant)?; + // 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() @@ -577,11 +592,12 @@ pub fn submit( content_uri: content_uri.clone(), submitted_at, }; - storage::set_submission(env, event_id, &applicant, &submission); + storage::set_submission(env, event_id, &applicant, slot, &submission); evt::Submitted { event_id, applicant: applicant.clone(), + slot, content_uri, } .publish(env); @@ -597,6 +613,7 @@ pub fn withdraw_submission( env: &Env, event_id: u64, applicant: Address, + slot: u32, op_id: BytesN<32>, ) -> Result<(), Error> { admin::require_not_paused(env)?; @@ -609,15 +626,16 @@ pub fn withdraw_submission( applicant.require_auth(); idempotency::require_unseen(env, &applicant, &op_id)?; - if storage::get_submission(env, event_id, &applicant).is_none() { + if storage::get_submission(env, event_id, &applicant, slot).is_none() { return Err(Error::SubmissionNotFound); } - storage::remove_submission(env, event_id, &applicant); + storage::remove_submission(env, event_id, &applicant, slot); evt::SubmissionWithdrawn { event_id, applicant: applicant.clone(), + slot, } .publish(env); @@ -650,13 +668,9 @@ pub fn select_winners( let existing_count = storage::winner_count(env, event_id); match event.release_kind { - ReleaseKind::Single => { - // Winner rows but no base-escrow key means a pre-1.3.0 push-model - // event: keep it one-shot. New events award each position once. - if existing_count > 0 && storage::get_prize_base_escrow(env, event_id).is_none() { - return Err(Error::WinnersAlreadySelected); - } - } + // Single events award in as many batches as the manager likes; each + // position may be awarded once, enforced per position below. + ReleaseKind::Single => {} ReleaseKind::Multi(_) => { for idx in 0..existing_count { if let Some(w) = storage::winner_at(env, event_id, idx) { @@ -687,9 +701,6 @@ pub fn select_winners( if already { return Err(Error::DuplicateWinnerPosition); } - if event.winner_distribution.get(spec.position).is_none() { - return Err(Error::InvalidWinnerPosition); - } seen_positions.push_back(spec.position); } @@ -697,42 +708,39 @@ pub fn select_winners( match event.release_kind { ReleaseKind::Single => { - // Amounts are fixed against the escrow baseline captured at the - // first selection; claim_prize does the transfer and profile calls. - let base_escrow = match storage::get_prize_base_escrow(env, event_id) { - Some(b) => b, - None => { - let b = event.remaining_escrow; - storage::set_prize_base_escrow(env, event_id, b); - b - } - }; + // `remaining_escrow` only drops at claim time, so a prize named by + // an earlier selection is still sitting in it. Reserving the owed + // total is what stops a later selection promising the same funds + // twice and leaving the second winner unable to claim. + let owed_before = storage::owed_total(env, event_id); - let mut total_owed: i128 = 0; + let mut batch_total: i128 = 0; for spec in winners.iter() { if storage::get_prize_award(env, event_id, spec.position).is_some() { return Err(Error::DuplicateWinnerPosition); } - let percent = event - .winner_distribution - .get(spec.position) - .ok_or(Error::InvalidDistribution)? as i128; - let amount = base_escrow.saturating_mul(percent) / 100_i128; - if amount <= 0 { + if spec.amount <= 0 { return Err(Error::InvalidDistribution); } - total_owed = total_owed.saturating_add(amount); + if let Some(floor) = event.prize_floors.get(spec.position) { + if spec.amount < floor { + return Err(Error::InvalidDistribution); + } + } + batch_total = batch_total + .checked_add(spec.amount) + .ok_or(Error::InsufficientEscrow)?; } - if total_owed > event.remaining_escrow { + let committed = batch_total + .checked_add(owed_before) + .ok_or(Error::InsufficientEscrow)?; + if committed > event.remaining_escrow { return Err(Error::InsufficientEscrow); } + storage::set_owed_total(env, event_id, committed); for (idx, spec) in winners.iter().enumerate() { - let percent = event - .winner_distribution - .get(spec.position) - .ok_or(Error::InvalidDistribution)? as i128; - let amount = base_escrow.saturating_mul(percent) / 100_i128; + let amount = spec.amount; let anchor_idx = existing_count + (idx as u32); storage::append_winner( @@ -773,6 +781,32 @@ pub fn select_winners( } } ReleaseKind::Multi(_) => { + // Same reservation as Single: milestone claims drain + // `remaining_escrow` gradually, so without it a second grantee + // could be awarded funds the first is still owed. + let owed_before = storage::owed_total(env, event_id); + let mut batch_total: i128 = 0; + for spec in winners.iter() { + if spec.amount <= 0 { + return Err(Error::InvalidDistribution); + } + if let Some(floor) = event.prize_floors.get(spec.position) { + if spec.amount < floor { + return Err(Error::InvalidDistribution); + } + } + batch_total = batch_total + .checked_add(spec.amount) + .ok_or(Error::InsufficientEscrow)?; + } + let committed = batch_total + .checked_add(owed_before) + .ok_or(Error::InsufficientEscrow)?; + if committed > event.remaining_escrow { + return Err(Error::InsufficientEscrow); + } + storage::set_owed_total(env, event_id, committed); + for spec in winners.iter() { storage::append_winner( env, @@ -780,7 +814,7 @@ pub fn select_winners( &Winner { recipient: spec.recipient.clone(), position: spec.position, - amount: 0, + amount: spec.amount, milestone: None, paid_at: None, }, @@ -859,6 +893,17 @@ pub fn claim_prize( let unclaimed = storage::unclaimed_prize_count(env, event_id); storage::set_unclaimed_prize_count(env, event_id, unclaimed.saturating_sub(1)); + // Claiming converts owed into paid; both balances drop together so the + // reservation in select_winners stays exact. Checked rather than clamped: + // owed dropping below a claim means the reservation has already drifted, + // and swallowing that would let the next selection over-promise the pool. + let owed = storage::owed_total(env, event_id); + let owed_after = owed.checked_sub(amount).ok_or(Error::InsufficientEscrow)?; + if owed_after < 0 { + return Err(Error::InsufficientEscrow); + } + storage::set_owed_total(env, event_id, owed_after); + event.remaining_escrow = event.remaining_escrow.saturating_sub(amount); if event.remaining_escrow == 0 { event.status = EventStatus::Completed; @@ -906,8 +951,13 @@ 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) -> Result { - storage::get_submission(env, event_id, &applicant).ok_or(Error::SubmissionNotFound) +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 diff --git a/contracts/events/src/events.rs b/contracts/events/src/events.rs index ee6f240..aaabadb 100644 --- a/contracts/events/src/events.rs +++ b/contracts/events/src/events.rs @@ -76,6 +76,7 @@ pub struct ApplicationWithdrawn { pub struct Submitted { pub event_id: u64, pub applicant: Address, + pub slot: u32, pub content_uri: String, } @@ -83,6 +84,7 @@ pub struct Submitted { pub struct SubmissionWithdrawn { pub event_id: u64, pub applicant: Address, + pub slot: u32, } #[contractevent] diff --git a/contracts/events/src/grant.rs b/contracts/events/src/grant.rs index 5d02524..d14b8e9 100644 --- a/contracts/events/src/grant.rs +++ b/contracts/events/src/grant.rs @@ -57,6 +57,7 @@ pub fn claim_milestone( let count = storage::winner_count(env, event_id); let mut winner_position: Option = None; + let mut awarded_amount: i128 = 0; let mut already_claimed_for_recipient: u32 = 0; let mut already_paid_to_recipient: i128 = 0; for idx in 0..count { @@ -68,7 +69,10 @@ pub fn claim_milestone( continue; } match w.milestone { - None => winner_position = Some(w.position), + None => { + winner_position = Some(w.position); + awarded_amount = w.amount; + } Some(_) => { already_claimed_for_recipient = already_claimed_for_recipient.saturating_add(1); already_paid_to_recipient = already_paid_to_recipient.saturating_add(w.amount); @@ -93,11 +97,9 @@ pub fn claim_milestone( event.remaining_escrow / (remaining_milestones as i128) } } else { - let percent = event - .winner_distribution - .get(position) - .ok_or(Error::InvalidWinnerPosition)? as i128; - let total_share = event.total_budget.saturating_mul(percent) / 100_i128; + // The award carries its own amount, set at selection. Milestones split + // that, not a share of the budget. + let total_share = awarded_amount; let per_milestone_floored = total_share / (total_milestones as i128); if already_claimed_for_recipient.saturating_add(1) == total_milestones { @@ -120,6 +122,15 @@ pub fn claim_milestone( escrow::release(env, &event.token, &recipient, amount); } event.remaining_escrow = event.remaining_escrow.saturating_sub(amount); + if !is_crowdfunding { + // Crowdfunding never reserves, since it has no winner selection. + let owed = storage::owed_total(env, event_id); + let owed_after = owed.checked_sub(amount).ok_or(Error::InsufficientEscrow)?; + if owed_after < 0 { + return Err(Error::InsufficientEscrow); + } + storage::set_owed_total(env, event_id, owed_after); + } storage::mark_milestone_claimed(env, event_id, &recipient, milestone); if is_crowdfunding { let claimed = storage::get_crowdfunding_milestones_claimed(env, event_id); diff --git a/contracts/events/src/lib.rs b/contracts/events/src/lib.rs index 2011462..33978a1 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.6.0"); +contractmeta!(key = "version", val = "1.7.0"); contractmeta!( key = "description", val = "Boundless events contract: hackathon, bounty, grant + escrow" @@ -96,6 +96,13 @@ impl EventsContract { admin::cancel_pending_upgrade(&env) } + /// Converts up to `max_events` pre-1.7.0 event records, returning how many + /// remain. Loop until it reports zero, then call `migrate`. Paged because + /// one invocation cannot touch every event of a deployment with history. + pub fn migrate_events(env: Env, max_events: u32) -> Result { + admin::migrate_events(&env, max_events) + } + pub fn migrate(env: Env) -> Result<(), Error> { admin::migrate(&env) } @@ -185,23 +192,28 @@ impl EventsContract { // ============================================================ // 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, content_uri, op_id) + 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, op_id) + event_ops::withdraw_submission(&env, event_id, applicant, slot, op_id) } // ============================================================ @@ -270,8 +282,14 @@ impl EventsContract { env: Env, event_id: u64, applicant: Address, + slot: u32, ) -> Result { - event_ops::get_submission(&env, event_id, applicant) + 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); diff --git a/contracts/events/src/storage.rs b/contracts/events/src/storage.rs index 7eeae1b..9973154 100644 --- a/contracts/events/src/storage.rs +++ b/contracts/events/src/storage.rs @@ -146,6 +146,14 @@ pub fn clear_pending_upgrade(env: &Env) { env.storage().instance().remove(&DataKey::PendingUpgrade); } +pub fn get_migration_cursor(env: &Env) -> Option { + env.storage().instance().get(&DataKey::MigrationCursor) +} + +pub fn set_migration_cursor(env: &Env, id: u64) { + env.storage().instance().set(&DataKey::MigrationCursor, &id); +} + pub fn get_migrated_to_version(env: &Env) -> Option { env.storage().instance().get(&DataKey::MigratedToVersion) } @@ -419,32 +427,73 @@ pub fn applicants_snapshot(env: &Env, id: u64, start: u32, limit: u32) -> Vec Option { - let key = DataKey::EventSubmission(id, applicant.clone()); +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; } - 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, submission: &Submission) { - let key = DataKey::EventSubmission(id, applicant.clone()); +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); } -pub fn remove_submission(env: &Env, id: u64, applicant: &Address) { - // Idempotent: a no-op when there is nothing to remove, symmetrically - // with append_submission, so a caller that skips its own existence - // check can't silently corrupt the counter by decrementing for an - // applicant that never had a submission. - if get_submission(env, id, applicant).is_none() { +/// 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::EventSubmission(id, applicant.clone()); + 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); @@ -465,16 +514,29 @@ pub fn submission_count(env: &Env, id: u64) -> u32 { n.unwrap_or(0) } -/// Count a new submission before writing the entry (mirrors -/// `append_contributor`/`append_applicant`). A no-op when the applicant -/// already has a submission — re-submission updates the existing entry in -/// place and must not recount. +/// 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) -> Result<(), Error> { - if get_submission(env, id, addr).is_some() { +/// 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); @@ -482,9 +544,36 @@ pub fn append_submission(env: &Env, id: u64, addr: &Address) -> Result<(), Error 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) // ============================================================ @@ -557,6 +646,21 @@ pub fn set_unclaimed_prize_count(env: &Env, id: u64, count: u32) { touch_event_persistent(env, &key); } +pub fn owed_total(env: &Env, id: u64) -> i128 { + let key = DataKey::EventOwedTotal(id); + let t: Option = env.storage().persistent().get(&key); + if t.is_some() { + touch_event_persistent(env, &key); + } + t.unwrap_or(0) +} + +pub fn set_owed_total(env: &Env, id: u64, owed: i128) { + let key = DataKey::EventOwedTotal(id); + env.storage().persistent().set(&key, &owed); + touch_event_persistent(env, &key); +} + pub fn get_prize_base_escrow(env: &Env, id: u64) -> Option { let key = DataKey::EventPrizeBaseEscrow(id); let b: Option = env.storage().persistent().get(&key); diff --git a/contracts/events/src/tests/admin.rs b/contracts/events/src/tests/admin.rs index 4199fd8..7745ac8 100644 --- a/contracts/events/src/tests/admin.rs +++ b/contracts/events/src/tests/admin.rs @@ -2,13 +2,16 @@ use soroban_sdk::{ testutils::{Address as _, BytesN as _, Ledger}, - Address, BytesN, String, + Address, BytesN, Map, String, }; use super::common::setup; use crate::errors::Error; +use crate::storage; +use crate::types::{DataKey, EventStatus, Pillar, ReleaseKind, Submission, Winner}; -const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; +// Zeroed with the contract constant; restore both together. +const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; #[test] @@ -19,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.6.0")); + assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.7.0")); assert_eq!(ctx.client.get_pending_upgrade(), None); assert_eq!(ctx.client.get_migrated_to_version(), None); } @@ -83,20 +86,22 @@ fn propose_upgrade_rejects_empty_version() { } #[test] -fn apply_upgrade_before_timelock_reverts() { +fn apply_upgrade_is_immediate_while_the_timelock_is_zero() { + // H6 mandated a ~1-day window here; it is deliberately zero while mainnet + // escrow is empty. This test is the counterpart to restoring it: when + // UPGRADE_TIMELOCK_LEDGERS goes back to 17_280, this should revert with + // UpgradeTimelockNotElapsed instead. let ctx = setup(250); let new_hash: BytesN<32> = BytesN::random(&ctx.env); let new_version = String::from_str(&ctx.env, "0.3.0"); ctx.client.propose_upgrade(&new_hash, &new_version); - let err = ctx - .client - .try_apply_upgrade() - .err() - .expect("timelock blocks") - .unwrap(); - assert_eq!(err, Error::UpgradeTimelockNotElapsed); - assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.6.0")); + let pending = ctx.client.get_pending_upgrade().expect("proposal"); + assert_eq!( + pending.available_at_ledger, pending.proposed_at_ledger, + "no window between proposing and applying" + ); + assert_eq!(UPGRADE_TIMELOCK_LEDGERS, 0, "restore me with the constant"); } #[test] @@ -130,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.6.0")); + assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.7.0")); } #[test] @@ -145,14 +150,456 @@ fn cancel_with_no_pending_reverts() { assert_eq!(err, Error::UpgradeNotProposed); } +/// The pre-1.7.0 record layout, written directly into storage so migrate has a +/// legacy row to convert. Mirrors what the two mainnet events look like. +#[soroban_sdk::contracttype] +#[derive(Clone)] +struct LegacyEventRecord { + pub id: u64, + pub pillar: Pillar, + pub owner: Address, + pub token: Address, + pub total_budget: i128, + pub remaining_escrow: i128, + pub release_kind: ReleaseKind, + pub status: EventStatus, + pub content_uri: String, + pub title: String, + pub created_at: u64, + pub deadline: Option, + pub winner_distribution: Map, + pub fee_bps_override: Option, +} + +#[test] +fn migrate_rewrites_legacy_percentages_as_prize_floors() { + let ctx = setup(250); + let budget = 1_000_0000000_i128; + + // Stand in for mainnet event ...610: a 60/40 split, already settled. + let mut dist = Map::new(&ctx.env); + dist.set(1, 60_u32); + dist.set(2, 40_u32); + + let id_base = ctx.client.id_base(); + let event_id = id_base + 1; + let legacy = 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, "Muwa Creator Bounty"), + 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); + ctx.env + .storage() + .instance() + .set(&DataKey::NextEventId, &(event_id + 1)); + }); + + ctx.client.migrate_events(&8_u32); + ctx.client.migrate(); + + // Readable again through the current struct, which could not decode it + // before, and the percentages have become the amounts they always meant. + let migrated = ctx.client.get_event(&event_id); + assert_eq!(migrated.prize_floors.get(1), Some(budget * 60 / 100)); + assert_eq!(migrated.prize_floors.get(2), Some(budget * 40 / 100)); + assert_eq!(migrated.total_budget, budget); + assert_eq!(migrated.status, EventStatus::Completed); + assert_eq!( + migrated.title, + String::from_str(&ctx.env, "Muwa Creator Bounty") + ); +} + +#[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 + // that would make every milestone claim revert with nothing to pay. + let ctx = setup(250); + let recipient = 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::Grant, + owner: Address::generate(&ctx.env), + token: Address::generate(&ctx.env), + total_budget: budget, + remaining_escrow: budget, + release_kind: ReleaseKind::Multi(2), + status: EventStatus::Active, + content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/grant"), + title: String::from_str(&ctx.env, "Legacy Grant"), + 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_winner( + &ctx.env, + event_id, + &Winner { + recipient: recipient.clone(), + position: 1, + amount: 0, + milestone: None, + paid_at: None, + }, + ); + }); + + ctx.client.migrate_events(&8_u32); + ctx.client.migrate(); + + let rows = ctx.client.get_winners(&event_id); + assert_eq!( + rows.get(0).unwrap().amount, + budget, + "the anchor must carry what the old percentage would have paid" + ); +} + +#[test] +fn migrate_refuses_to_stamp_while_events_remain_unconverted() { + // One invocation may touch only 100 ledger entries, so a deployment with + // history is converted in slices. Stamping before the cursor reaches the + // end would strand the remainder in a layout nothing can decode, and + // migrate is one-shot. + let ctx = setup(250); + let base = ctx.client.id_base(); + ctx.env.as_contract(&ctx.client.address, || { + ctx.env + .storage() + .instance() + .set(&DataKey::NextEventId, &(base + 20)); + }); + + assert!( + ctx.client.try_migrate().is_err(), + "must not stamp while events are unconverted" + ); + assert_eq!(ctx.client.get_migrated_to_version(), None); + + // Page through: 8 per call, so 19 events need three calls. + assert_eq!(ctx.client.migrate_events(&8_u32), 11); + assert_eq!(ctx.client.migrate_events(&8_u32), 3); + assert!( + ctx.client.try_migrate().is_err(), + "still incomplete after two of three pages" + ); + assert_eq!(ctx.client.migrate_events(&8_u32), 0); + + ctx.client.migrate(); + assert_eq!( + ctx.client.get_migrated_to_version(), + Some(String::from_str(&ctx.env, "1.7.0")) + ); +} + +#[test] +fn migrate_events_caps_each_call_regardless_of_the_argument() { + let ctx = setup(250); + let base = ctx.client.id_base(); + ctx.env.as_contract(&ctx.client.address, || { + ctx.env + .storage() + .instance() + .set(&DataKey::NextEventId, &(base + 30)); + }); + + // Asking for more than the per-call ceiling must not blow the footprint. + assert_eq!(ctx.client.migrate_events(&1_000_u32), 21); + // 0 means "use the ceiling" rather than "do nothing". + assert_eq!(ctx.client.migrate_events(&0_u32), 13); +} + +#[test] +fn migrate_skips_rows_already_in_the_current_layout() { + // A fresh 1.7.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); + let event_id = ctx.client.id_base() + 1; + let mut floors = Map::new(&ctx.env); + floors.set(1, 500_i128); + let current = crate::types::EventRecord { + id: event_id, + pillar: Pillar::Bounty, + owner: Address::generate(&ctx.env), + token: Address::generate(&ctx.env), + total_budget: 1_000_i128, + remaining_escrow: 1_000_i128, + release_kind: ReleaseKind::Single, + status: EventStatus::Active, + content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/new"), + title: String::from_str(&ctx.env, "Already Migrated"), + created_at: 1, + deadline: None, + prize_floors: floors, + fee_bps_override: None, + }; + ctx.env.as_contract(&ctx.client.address, || { + ctx.env + .storage() + .persistent() + .set(&DataKey::Event(event_id), ¤t); + ctx.env + .storage() + .instance() + .set(&DataKey::NextEventId, &(event_id + 1)); + }); + + ctx.client.migrate_events(&8_u32); + ctx.client.migrate(); + + let after = ctx.client.get_event(&event_id); + assert_eq!(after.prize_floors.get(1), Some(500_i128)); + assert_eq!(after.title, String::from_str(&ctx.env, "Already Migrated")); +} + +#[test] +fn migrate_runs_regardless_of_how_the_version_was_spelled() { + // propose_upgrade accepts any non-empty string. Gating the rewrite on an + // exact match would silently skip it and still stamp the marker. + let ctx = setup(250); + 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 = 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, "Oddly Versioned"), + 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); + ctx.env + .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")); + }); + + ctx.client.migrate_events(&8_u32); + ctx.client.migrate(); + + let migrated = ctx.client.get_event(&event_id); + 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); + ctx.client.migrate_events(&8_u32); + ctx.client.migrate(); + assert_eq!( + ctx.client.get_migrated_to_version(), + Some(String::from_str(&ctx.env, "1.7.0")) + ); +} + #[test] fn migrate_marks_current_version_and_blocks_replay() { let ctx = setup(250); + ctx.client.migrate_events(&8_u32); ctx.client.migrate(); assert_eq!( ctx.client.get_migrated_to_version(), - Some(String::from_str(&ctx.env, "1.6.0")) + Some(String::from_str(&ctx.env, "1.7.0")) ); let err = ctx diff --git a/contracts/events/src/tests/bounty_pillar.rs b/contracts/events/src/tests/bounty_pillar.rs index 923c1b0..2ea3a68 100644 --- a/contracts/events/src/tests/bounty_pillar.rs +++ b/contracts/events/src/tests/bounty_pillar.rs @@ -69,9 +69,9 @@ fn setup<'a>() -> Ctx<'a> { } } -fn one_winner_distribution(env: &Env) -> Map { +fn one_winner_distribution(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, 100000000000_i128); m } @@ -89,7 +89,7 @@ fn create_bounty_with_deadline(ctx: &Ctx, deadline: u64) -> u64 { 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), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: None, manager: None, }; @@ -107,7 +107,7 @@ fn create_hackathon(ctx: &Ctx) -> u64 { 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), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: None, manager: None, }; @@ -140,7 +140,7 @@ fn create_rejects_multi_release_kind() { content_uri: String::from_str(&ctx.env, "uri"), title: String::from_str(&ctx.env, "Bad Bounty"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: None, manager: None, }; @@ -259,6 +259,7 @@ fn apply_on_completed_event_reverts() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: 10_000_0000000_i128, reputation_bump: 0, }, ]; @@ -446,7 +447,7 @@ fn withdraw_after_submit_reverts() { 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, &uri, &op_submit); + .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( @@ -526,3 +527,165 @@ fn withdraw_requires_applicant_auth() { 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/cancel_refund.rs b/contracts/events/src/tests/cancel_refund.rs index 7fed1b3..905ea14 100644 --- a/contracts/events/src/tests/cancel_refund.rs +++ b/contracts/events/src/tests/cancel_refund.rs @@ -75,9 +75,9 @@ fn setup_with_env<'a>(env: Env) -> Ctx<'a> { } } -fn single_dist(env: &Env) -> Map { +fn single_dist(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, 10000000000_i128); m } @@ -91,7 +91,7 @@ fn create_hackathon(ctx: &Ctx) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/cancel-test"), title: String::from_str(&ctx.env, "Cancel Test"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -403,8 +403,8 @@ fn missing_running_total_with_contributors_fails_closed() { fn cancel_prorata_splits_remaining_across_partners_no_owner_residual() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 60); - dist.set(2, 40); + dist.set(1, 600_0000000_i128); + dist.set(2, 400_0000000_i128); let params = CreateEventParams { pillar: Pillar::Hackathon, owner: ctx.owner.clone(), @@ -414,7 +414,7 @@ fn cancel_prorata_splits_remaining_across_partners_no_owner_residual() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/boundary"), title: String::from_str(&ctx.env, "ProRata Cancel"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -433,14 +433,17 @@ fn cancel_prorata_splits_remaining_across_partners_no_owner_residual() { WinnerSpec { recipient: w.clone(), position: 1, + // The pool is 2000 after both contributions; the manager awards + // 1200 of it, leaving 800 to split pro-rata on cancel. + amount: 1_200_0000000_i128, reputation_bump: 0 }, ]; ctx.events .select_winners(&id, &winners, &BytesN::random(&ctx.env)); - // Pull model: the winner claims (60% of 2000 = 1200) before the - // manager can cancel; the remainder splits pro-rata below. + // Pull model: the winner claims before the manager can cancel; the + // remainder splits pro-rata below. ctx.events .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); diff --git a/contracts/events/src/tests/contributions.rs b/contracts/events/src/tests/contributions.rs index 2652f84..fc4bce5 100644 --- a/contracts/events/src/tests/contributions.rs +++ b/contracts/events/src/tests/contributions.rs @@ -72,9 +72,9 @@ fn setup<'a>() -> Ctx<'a> { } } -fn single_dist(env: &Env) -> Map { +fn single_dist(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, 10000000000_i128); m } @@ -88,7 +88,7 @@ fn create_hackathon(ctx: &Ctx) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/contrib-hack"), title: String::from_str(&ctx.env, "Contrib Hack"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -336,7 +336,7 @@ fn cancel_at_boundary_pays_partners_full_no_owner_residual() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/boundary"), title: String::from_str(&ctx.env, "Boundary Cancel"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -362,6 +362,7 @@ fn cancel_at_boundary_pays_partners_full_no_owner_residual() { WinnerSpec { recipient: winner_a.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50, }, ]; diff --git a/contracts/events/src/tests/cross_contract.rs b/contracts/events/src/tests/cross_contract.rs index f4ab428..8b527ac 100644 --- a/contracts/events/src/tests/cross_contract.rs +++ b/contracts/events/src/tests/cross_contract.rs @@ -72,9 +72,11 @@ fn setup<'a>() -> Ctx<'a> { } } -fn one_winner_distribution(env: &Env) -> Map { +fn one_winner_distribution(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + // Nominal: a floor is a minimum, so 1 stroop never constrains an + // award. Tests that exercise the floor rule set a real one. + m.set(1, 1_i128); m } @@ -88,7 +90,7 @@ fn create_bounty(ctx: &Ctx) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/events/draft/x"), title: String::from_str(&ctx.env, "Test Bounty"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: None, manager: None, }; @@ -117,6 +119,7 @@ fn select_winners_pays_recipient_and_bumps_profile() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 50, }, ]; @@ -152,7 +155,7 @@ fn select_winners_pays_recipient_and_bumps_profile() { } #[test] -fn select_winners_requires_position_in_distribution() { +fn select_winners_rejects_an_award_larger_than_the_pool() { let ctx = setup(); let bounty_id = create_bounty(&ctx); @@ -160,7 +163,8 @@ fn select_winners_requires_position_in_distribution() { &ctx.env, WinnerSpec { recipient: ctx.applicant.clone(), - position: 2, // distribution only has position 1 + position: 2, + amount: TOTAL_BUDGET + 1, reputation_bump: 50, }, ]; @@ -168,7 +172,10 @@ fn select_winners_requires_position_in_distribution() { let res = ctx .events .try_select_winners(&bounty_id, &winners, &op_select); - assert!(res.is_err(), "invalid position should revert"); + assert!( + res.is_err(), + "a position with no floor is allowed, but not one the pool cannot cover" + ); } #[test] @@ -177,8 +184,8 @@ fn select_winners_rejects_duplicate_position() { let owner = ctx.owner.clone(); let token_addr = ctx.token_addr.clone(); let mut dist = Map::new(&ctx.env); - dist.set(1, 60); - dist.set(2, 40); + dist.set(1, 6_000_0000000_i128); + dist.set(2, 4_000_0000000_i128); let params = CreateEventParams { pillar: Pillar::Bounty, owner, @@ -188,7 +195,7 @@ fn select_winners_rejects_duplicate_position() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/x"), title: String::from_str(&ctx.env, "Test Bounty 2"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -201,11 +208,13 @@ fn select_winners_rejects_duplicate_position() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 50, }, WinnerSpec { recipient: other_recipient, position: 1, // duplicate + amount: TOTAL_BUDGET, reputation_bump: 25, }, ]; @@ -220,8 +229,8 @@ fn select_winners_rejects_duplicate_position() { fn select_winners_handles_multi_recipient_distribution() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 60); - dist.set(2, 40); + dist.set(1, 6_000_0000000_i128); + dist.set(2, 4_000_0000000_i128); let params = CreateEventParams { pillar: Pillar::Bounty, owner: ctx.owner.clone(), @@ -231,7 +240,7 @@ fn select_winners_handles_multi_recipient_distribution() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/multi"), title: String::from_str(&ctx.env, "Multi Winner"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -245,11 +254,13 @@ fn select_winners_handles_multi_recipient_distribution() { WinnerSpec { recipient: winner_a.clone(), position: 1, + amount: 6_000_0000000_i128, reputation_bump: 50, }, WinnerSpec { recipient: winner_b.clone(), position: 2, + amount: 4_000_0000000_i128, reputation_bump: 25, }, ]; @@ -288,6 +299,7 @@ fn select_winners_replayed_reverts() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 50, }, ]; @@ -338,8 +350,8 @@ fn cancel_already_cancelled_reverts() { fn cancel_after_select_winners_refunds_only_remaining() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 60); - dist.set(2, 40); + dist.set(1, 6_000_0000000_i128); + dist.set(2, 4_000_0000000_i128); let params = CreateEventParams { pillar: Pillar::Bounty, owner: ctx.owner.clone(), @@ -349,7 +361,7 @@ fn cancel_after_select_winners_refunds_only_remaining() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/partial"), title: String::from_str(&ctx.env, "Partial Pay Bounty"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -362,6 +374,7 @@ fn cancel_after_select_winners_refunds_only_remaining() { WinnerSpec { recipient: winner_a.clone(), position: 1, + amount: TOTAL_BUDGET * 60 / 100, reputation_bump: 50, }, ]; @@ -391,7 +404,7 @@ fn cancel_after_select_winners_refunds_only_remaining() { fn create_grant(ctx: &Ctx, n_milestones: u32) -> u64 { let mut dist = Map::new(&ctx.env); - dist.set(1, 100); + dist.set(1, TOTAL_BUDGET * 100 / 100); let params = CreateEventParams { pillar: Pillar::Grant, owner: ctx.owner.clone(), @@ -401,7 +414,7 @@ fn create_grant(ctx: &Ctx, n_milestones: u32) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/grant"), title: String::from_str(&ctx.env, "Test Grant"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -415,6 +428,7 @@ fn select_grant_winner(ctx: &Ctx, grant_id: u64, recipient: &Address) { WinnerSpec { recipient: recipient.clone(), position: 1, + amount: TOTAL_BUDGET * 100 / 100, reputation_bump: 0, }, ]; @@ -496,6 +510,7 @@ fn claim_milestone_rejects_non_grant_events() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; @@ -539,7 +554,7 @@ fn claim_milestone_final_milestone_marks_event_completed() { fn create_hackathon(ctx: &Ctx) -> u64 { let mut dist = Map::new(&ctx.env); - dist.set(1, 100); + dist.set(1, TOTAL_BUDGET * 100 / 100); let params = CreateEventParams { pillar: Pillar::Hackathon, owner: ctx.owner.clone(), @@ -549,7 +564,7 @@ fn create_hackathon(ctx: &Ctx) -> u64 { 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), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -564,9 +579,9 @@ fn hackathon_submit_creates_anchor_without_prior_apply() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../project.json"); let op = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri, &op); + ctx.events.submit(&id, &ctx.applicant, &0_u32, &uri, &op); - let sub = ctx.events.get_submission(&id, &ctx.applicant); + 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()); @@ -579,7 +594,9 @@ fn bounty_submit_requires_prior_application() { 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, &uri, &op); + let res = ctx + .events + .try_submit(&id, &ctx.applicant, &0_u32, &uri, &op); assert!(res.is_err(), "submit before apply on bounty should revert"); } @@ -593,9 +610,10 @@ fn bounty_submit_succeeds_after_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, &uri, &op_submit); + ctx.events + .submit(&id, &ctx.applicant, &0_u32, &uri, &op_submit); - let sub = ctx.events.get_submission(&id, &ctx.applicant); + let sub = ctx.events.get_submission(&id, &ctx.applicant, &0_u32); assert_eq!(sub.content_uri, uri); } @@ -606,16 +624,18 @@ fn resubmit_preserves_original_submitted_at_and_updates_uri() { 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, &uri_a, &op_a); + ctx.events + .submit(&id, &ctx.applicant, &0_u32, &uri_a, &op_a); - let first = ctx.events.get_submission(&id, &ctx.applicant); + 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, &uri_b, &op_b); + ctx.events + .submit(&id, &ctx.applicant, &0_u32, &uri_b, &op_b); - let second = ctx.events.get_submission(&id, &ctx.applicant); + 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, @@ -630,9 +650,11 @@ fn submit_replayed_reverts() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); let op = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri, &op); + ctx.events.submit(&id, &ctx.applicant, &0_u32, &uri, &op); - let res = ctx.events.try_submit(&id, &ctx.applicant, &uri, &op); + let res = ctx + .events + .try_submit(&id, &ctx.applicant, &0_u32, &uri, &op); assert!(res.is_err(), "replayed submit should revert"); } @@ -643,12 +665,14 @@ fn withdraw_submission_removes_anchor() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); let op_submit = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri, &op_submit); + 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, &op_wd); + ctx.events + .withdraw_submission(&id, &ctx.applicant, &0_u32, &op_wd); - let res = ctx.events.try_get_submission(&id, &ctx.applicant); + let res = ctx.events.try_get_submission(&id, &ctx.applicant, &0_u32); assert!(res.is_err(), "withdrawn submission should not be readable"); } @@ -660,7 +684,7 @@ fn withdraw_submission_without_submission_reverts() { let op_wd = BytesN::random(&ctx.env); let res = ctx .events - .try_withdraw_submission(&id, &ctx.applicant, &op_wd); + .try_withdraw_submission(&id, &ctx.applicant, &0_u32, &op_wd); assert!( res.is_err(), "withdraw without prior submission should revert" @@ -690,7 +714,7 @@ fn create_event_charges_override_rate_when_provided() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), title: String::from_str(&ctx.env, "Hackathon at 1.5%"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: Some(override_bps), manager: None, }; @@ -723,7 +747,7 @@ fn add_funds_uses_event_override_not_global() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), title: String::from_str(&ctx.env, "Promo Hackathon"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: Some(override_bps), manager: None, }; @@ -769,7 +793,7 @@ fn create_event_with_waiver_charges_no_fee() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), title: String::from_str(&ctx.env, "Comped Hackathon"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: Some(0), manager: None, }; @@ -795,7 +819,7 @@ fn create_event_rejects_override_above_max_fee_bps() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), title: String::from_str(&ctx.env, "Bad rate"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: Some(6000), manager: None, }; @@ -822,7 +846,7 @@ fn create_event_omitted_override_falls_back_to_global_default() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), title: String::from_str(&ctx.env, "Default rate hackathon"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: None, manager: None, }; @@ -851,6 +875,7 @@ fn select_winners_rejects_second_call_winners_already_selected() { WinnerSpec { recipient: r2.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; @@ -919,7 +944,7 @@ fn grant_last_milestone_sweeps_rounding_residue() { // ============================================================ #[test] -fn select_winners_pays_against_remaining_escrow_including_top_ups() { +fn a_top_up_before_selection_enlarges_the_pool_not_the_award() { let ctx = setup(); let bounty_id = create_bounty(&ctx); @@ -940,22 +965,26 @@ fn select_winners_pays_against_remaining_escrow_including_top_ups() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 50, }, ]; let op_select = BytesN::random(&ctx.env); ctx.events.select_winners(&bounty_id, &winners, &op_select); - // Pull model: claim; the pre-selection top-up is in the baseline. ctx.events .claim_prize(&bounty_id, &1_u32, &BytesN::random(&ctx.env)); let token = token::Client::new(&ctx.env, &ctx.token_addr); - assert_eq!(token.balance(&ctx.applicant), TOTAL_BUDGET + top_up); + assert_eq!( + token.balance(&ctx.applicant), + TOTAL_BUDGET, + "the award is what the selection named, regardless of the top-up" + ); let event_post = ctx.events.get_event(&bounty_id); - assert_eq!(event_post.remaining_escrow, 0); - assert_eq!(event_post.status, EventStatus::Completed); + assert_eq!(event_post.remaining_escrow, top_up); + assert_eq!(event_post.status, EventStatus::Active); } // ============================================================ @@ -971,7 +1000,7 @@ fn create_bounty_with_manager(ctx: &Ctx, manager: &Address) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/events/draft/m"), title: String::from_str(&ctx.env, "Managed Bounty"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: None, manager: Some(manager.clone()), }; @@ -985,6 +1014,7 @@ fn win_one(ctx: &Ctx) -> soroban_sdk::Vec { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ] diff --git a/contracts/events/src/tests/crowdfunding.rs b/contracts/events/src/tests/crowdfunding.rs index 9ff55de..47280fa 100644 --- a/contracts/events/src/tests/crowdfunding.rs +++ b/contracts/events/src/tests/crowdfunding.rs @@ -71,9 +71,9 @@ fn setup<'a>() -> Ctx<'a> { } } -fn single_dist_100_at_1(env: &Env) -> Map { +fn single_dist_100_at_1(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, 10000000000_i128); m } @@ -87,7 +87,7 @@ fn create_campaign(ctx: &Ctx, milestones: u32) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/cf/1"), title: String::from_str(&ctx.env, "Open-Source Crawler"), deadline: Some(ctx.env.ledger().timestamp() + 30 * 86_400), - winner_distribution: single_dist_100_at_1(&ctx.env), + prize_floors: single_dist_100_at_1(&ctx.env), fee_bps_override: None, manager: None, }; @@ -149,7 +149,7 @@ fn create_rejects_single_release_kind() { content_uri: String::from_str(&ctx.env, "uri"), title: String::from_str(&ctx.env, "Bad CF"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist_100_at_1(&ctx.env), + prize_floors: single_dist_100_at_1(&ctx.env), fee_bps_override: None, manager: None, }; @@ -159,11 +159,11 @@ fn create_rejects_single_release_kind() { } #[test] -fn create_rejects_distribution_with_multiple_positions() { +fn create_rejects_floors_above_the_funding_goal() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 60); - dist.set(2, 40); + dist.set(1, FUNDING_GOAL); + dist.set(2, 1_i128); let params = CreateEventParams { pillar: Pillar::Crowdfunding, owner: ctx.builder.clone(), @@ -173,7 +173,7 @@ fn create_rejects_distribution_with_multiple_positions() { content_uri: String::from_str(&ctx.env, "uri"), title: String::from_str(&ctx.env, "Bad CF"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -392,6 +392,7 @@ fn select_winners_on_crowdfunding_reverts() { let spec = WinnerSpec { recipient: ctx.builder.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 0, }; let mut winners = SorobanVec::new(&ctx.env); @@ -409,7 +410,7 @@ fn submit_on_crowdfunding_reverts() { let op = BytesN::random(&ctx.env); let uri = String::from_str(&ctx.env, "ipfs://nope"); - let res = ctx.events.try_submit(&id, &ctx.builder, &uri, &op); + let res = ctx.events.try_submit(&id, &ctx.builder, &0_u32, &uri, &op); assert!(res.is_err()); } diff --git a/contracts/events/src/tests/escrow_fee_math.rs b/contracts/events/src/tests/escrow_fee_math.rs index bb39254..e648c96 100644 --- a/contracts/events/src/tests/escrow_fee_math.rs +++ b/contracts/events/src/tests/escrow_fee_math.rs @@ -72,9 +72,11 @@ fn setup<'a>() -> Ctx<'a> { setup_with_bps(FEE_BPS) } -fn single_dist(env: &Env) -> Map { +fn single_dist(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + // Nominal: a floor is a minimum, so 1 stroop never constrains an + // award. Tests that exercise the floor rule set a real one. + m.set(1, 1_i128); m } @@ -88,7 +90,7 @@ fn create_hackathon(ctx: &Ctx) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/fee-test"), title: String::from_str(&ctx.env, "Fee Math Test"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -106,7 +108,7 @@ fn create_hackathon_with_override(ctx: &Ctx, override_bps: u32) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/override"), title: String::from_str(&ctx.env, "Override BPS Test"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: Some(override_bps), manager: None, }; @@ -114,7 +116,7 @@ fn create_hackathon_with_override(ctx: &Ctx, override_bps: u32) -> u64 { ctx.events.create_event(¶ms, &op) } -fn create_hackathon_with_dist(ctx: &Ctx, dist: Map) -> u64 { +fn create_hackathon_with_dist(ctx: &Ctx, dist: Map) -> u64 { let params = CreateEventParams { pillar: Pillar::Hackathon, owner: ctx.owner.clone(), @@ -124,7 +126,7 @@ fn create_hackathon_with_dist(ctx: &Ctx, dist: Map) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/dist-test"), title: String::from_str(&ctx.env, "Dist Test"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -142,7 +144,7 @@ fn create_grant(ctx: &Ctx, milestones: u32) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/grant"), title: String::from_str(&ctx.env, "Grant Test"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -214,7 +216,7 @@ fn override_bps_above_max_rejected() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/bad"), title: String::from_str(&ctx.env, "Bad Override"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: Some(1001), // MAX_FEE_BPS = 1000 manager: None, }; @@ -252,7 +254,7 @@ fn fee_rounds_down_non_divisible_amount() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/tiny"), title: String::from_str(&ctx.env, "Tiny"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -280,7 +282,7 @@ fn fee_rounding_on_odd_amounts() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/odd"), title: String::from_str(&ctx.env, "Odd"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -373,6 +375,7 @@ fn single_release_pays_full_escrow_for_100_percent() { WinnerSpec { recipient: winner.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50, }, ]; @@ -393,9 +396,9 @@ fn multi_position_split_pays_correct_amounts() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 50); - dist.set(2, 30); - dist.set(3, 20); + dist.set(1, TOTAL_BUDGET * 50 / 100); + dist.set(2, TOTAL_BUDGET * 30 / 100); + dist.set(3, TOTAL_BUDGET * 20 / 100); let id = create_hackathon_with_dist(&ctx, dist); let w1 = Address::generate(&ctx.env); @@ -406,16 +409,19 @@ fn multi_position_split_pays_correct_amounts() { WinnerSpec { recipient: w1.clone(), position: 1, + amount: TOTAL_BUDGET * 50 / 100, reputation_bump: 50 }, WinnerSpec { recipient: w2.clone(), position: 2, + amount: TOTAL_BUDGET * 30 / 100, reputation_bump: 30 }, WinnerSpec { recipient: w3.clone(), position: 3, + amount: TOTAL_BUDGET * 20 / 100, reputation_bump: 20 }, ]; @@ -443,9 +449,9 @@ fn three_way_33_33_34_split_rounding() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 33); - dist.set(2, 33); - dist.set(3, 34); + dist.set(1, TOTAL_BUDGET * 33 / 100); + dist.set(2, TOTAL_BUDGET * 33 / 100); + dist.set(3, TOTAL_BUDGET * 34 / 100); let id = create_hackathon_with_dist(&ctx, dist); let w1 = Address::generate(&ctx.env); @@ -456,16 +462,19 @@ fn three_way_33_33_34_split_rounding() { WinnerSpec { recipient: w1.clone(), position: 1, + amount: TOTAL_BUDGET * 33 / 100, reputation_bump: 50 }, WinnerSpec { recipient: w2.clone(), position: 2, + amount: TOTAL_BUDGET * 33 / 100, reputation_bump: 30 }, WinnerSpec { recipient: w3.clone(), position: 3, + amount: TOTAL_BUDGET * 34 / 100, reputation_bump: 20 }, ]; @@ -493,8 +502,8 @@ fn partial_position_fill_leaves_residual_escrow() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 60); - dist.set(2, 40); + dist.set(1, TOTAL_BUDGET * 60 / 100); + dist.set(2, TOTAL_BUDGET * 40 / 100); let id = create_hackathon_with_dist(&ctx, dist); let w1 = Address::generate(&ctx.env); @@ -503,6 +512,7 @@ fn partial_position_fill_leaves_residual_escrow() { WinnerSpec { recipient: w1.clone(), position: 1, + amount: TOTAL_BUDGET * 60 / 100, reputation_bump: 50 }, ]; @@ -526,7 +536,7 @@ fn partial_position_fill_leaves_residual_escrow() { // ============================================================ #[test] -fn partner_funds_grow_winner_payout() { +fn partner_funds_enlarge_the_pool_without_repricing_awards() { let ctx = setup(); let id = create_hackathon(&ctx); @@ -538,15 +548,17 @@ fn partner_funds_grow_winner_payout() { ctx.events.add_funds(&id, &partner, &contrib, &op_add); let event = ctx.events.get_event(&id); - let escrow_at_select = event.remaining_escrow; - assert_eq!(escrow_at_select, TOTAL_BUDGET + contrib); + assert_eq!(event.remaining_escrow, TOTAL_BUDGET + contrib); + // A top-up has exactly one consequence: the pool is larger. Awards name + // their own amount, so nothing already advertised is silently repriced. let winner = Address::generate(&ctx.env); let winners = soroban_sdk::vec![ &ctx.env, WinnerSpec { recipient: winner.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 50 }, ]; @@ -556,7 +568,111 @@ fn partner_funds_grow_winner_payout() { .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); let token = token::Client::new(&ctx.env, &ctx.token_addr); - assert_eq!(token.balance(&winner), escrow_at_select); + assert_eq!(token.balance(&winner), TOTAL_BUDGET); + assert_eq!( + ctx.events.get_event(&id).remaining_escrow, + contrib, + "the top-up stays in the pool, available to award or reclaim" + ); +} + +#[test] +fn a_topped_up_pool_can_fund_a_position_that_had_no_floor() { + let ctx = setup(); + let id = create_hackathon(&ctx); + + let partner = Address::generate(&ctx.env); + let contrib = 500_0000000_i128; + let fee = contrib * FEE_BPS as i128 / 10_000; + fund(&ctx, &partner, contrib + fee); + ctx.events + .add_funds(&id, &partner, &contrib, &BytesN::random(&ctx.env)); + + let w1 = Address::generate(&ctx.env); + let w2 = Address::generate(&ctx.env); + ctx.events.select_winners( + &id, + &soroban_sdk::vec![ + &ctx.env, + WinnerSpec { + recipient: w1.clone(), + position: 1, + amount: TOTAL_BUDGET, + reputation_bump: 0 + }, + ], + &BytesN::random(&ctx.env), + ); + // Adding a prize after publish: position 2 carries no floor and is paid + // out of the headroom the top-up created. + ctx.events.select_winners( + &id, + &soroban_sdk::vec![ + &ctx.env, + WinnerSpec { + recipient: w2.clone(), + position: 2, + amount: contrib, + reputation_bump: 0 + }, + ], + &BytesN::random(&ctx.env), + ); + + ctx.events + .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); + ctx.events + .claim_prize(&id, &2_u32, &BytesN::random(&ctx.env)); + + let token = token::Client::new(&ctx.env, &ctx.token_addr); + assert_eq!(token.balance(&w1), TOTAL_BUDGET); + assert_eq!(token.balance(&w2), contrib); + assert_eq!(ctx.events.get_event(&id).remaining_escrow, 0); +} + +#[test] +fn second_batch_cannot_promise_funds_an_unclaimed_winner_is_owed() { + let ctx = setup(); + let id = create_hackathon(&ctx); + + let w1 = Address::generate(&ctx.env); + ctx.events.select_winners( + &id, + &soroban_sdk::vec![ + &ctx.env, + WinnerSpec { + recipient: w1.clone(), + position: 1, + amount: TOTAL_BUDGET, + reputation_bump: 0 + }, + ], + &BytesN::random(&ctx.env), + ); + + // remaining_escrow still reads the full budget because w1 has not claimed. + // Without the owed reservation this second batch would be accepted and + // one of the two winners could never be paid. + let w2 = Address::generate(&ctx.env); + let res = ctx.events.try_select_winners( + &id, + &soroban_sdk::vec![ + &ctx.env, + WinnerSpec { + recipient: w2.clone(), + position: 2, + amount: 1_i128, + reputation_bump: 0 + }, + ], + &BytesN::random(&ctx.env), + ); + assert!(res.is_err(), "owed funds must not be promised twice"); + + ctx.events + .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); + let token = token::Client::new(&ctx.env, &ctx.token_addr); + assert_eq!(token.balance(&w1), TOTAL_BUDGET); } // ============================================================ @@ -575,6 +691,7 @@ fn grant_milestone_pays_floored_per_milestone() { WinnerSpec { recipient: recipient.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50 }, ]; @@ -616,6 +733,7 @@ fn grant_milestone_double_claim_rejected() { WinnerSpec { recipient: recipient.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50 }, ]; @@ -644,6 +762,7 @@ fn grant_milestone_out_of_range_rejected() { WinnerSpec { recipient: recipient.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50 }, ]; @@ -667,7 +786,7 @@ fn crowdfunding_dynamic_milestone_split() { let milestones = 3_u32; let mut dist = Map::new(&ctx.env); - dist.set(1, 100); + dist.set(1, TOTAL_BUDGET * 100 / 100); let params = CreateEventParams { pillar: Pillar::Crowdfunding, owner: ctx.owner.clone(), @@ -677,7 +796,7 @@ fn crowdfunding_dynamic_milestone_split() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/crowd"), title: String::from_str(&ctx.env, "Crowd Test"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -725,7 +844,7 @@ fn crowdfunding_dynamic_rounding_no_dust() { let milestones = 3_u32; let mut dist = Map::new(&ctx.env); - dist.set(1, 100); + dist.set(1, TOTAL_BUDGET * 100 / 100); let params = CreateEventParams { pillar: Pillar::Crowdfunding, owner: ctx.owner.clone(), @@ -735,7 +854,7 @@ fn crowdfunding_dynamic_rounding_no_dust() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/dust"), title: String::from_str(&ctx.env, "Dust Test"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -803,7 +922,7 @@ fn replayed_create_event_reverts() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/replay"), title: String::from_str(&ctx.env, "Replay"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -825,6 +944,7 @@ fn replayed_select_winners_reverts() { WinnerSpec { recipient: winner.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50 }, ]; @@ -848,6 +968,7 @@ fn select_winners_on_nonexistent_event_reverts() { WinnerSpec { recipient: winner.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50 }, ]; @@ -860,8 +981,8 @@ fn select_winners_on_nonexistent_event_reverts() { fn select_winners_duplicate_position_reverts() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 50); - dist.set(2, 50); + dist.set(1, TOTAL_BUDGET * 50 / 100); + dist.set(2, TOTAL_BUDGET * 50 / 100); let id = create_hackathon_with_dist(&ctx, dist); let w1 = Address::generate(&ctx.env); @@ -871,11 +992,13 @@ fn select_winners_duplicate_position_reverts() { WinnerSpec { recipient: w1.clone(), position: 1, + amount: TOTAL_BUDGET * 50 / 100, reputation_bump: 50 }, WinnerSpec { recipient: w2.clone(), position: 1, + amount: TOTAL_BUDGET * 50 / 100, reputation_bump: 30 }, ]; @@ -885,9 +1008,9 @@ fn select_winners_duplicate_position_reverts() { } #[test] -fn select_winners_invalid_position_reverts() { +fn select_winners_accepts_a_position_with_no_floor() { let ctx = setup(); - let id = create_hackathon(&ctx); // dist has only position 1 + let id = create_hackathon(&ctx); // floors cover position 1 only let w = Address::generate(&ctx.env); let winners = soroban_sdk::vec![ @@ -895,12 +1018,47 @@ fn select_winners_invalid_position_reverts() { WinnerSpec { recipient: w.clone(), position: 99, + amount: 10_0000000_i128, + reputation_bump: 50 + }, + ]; + let op = BytesN::random(&ctx.env); + ctx.events.select_winners(&id, &winners, &op); + ctx.events + .claim_prize(&id, &99_u32, &BytesN::random(&ctx.env)); + + let token = token::Client::new(&ctx.env, &ctx.token_addr); + assert_eq!( + token.balance(&w), + 10_0000000_i128, + "a position with no floor is payable at any positive amount; this is \ + how a prize is added after publish" + ); +} + +#[test] +fn select_winners_below_floor_reverts() { + let ctx = setup(); + let mut floors = Map::new(&ctx.env); + floors.set(1, TOTAL_BUDGET); + let id = create_hackathon_with_dist(&ctx, floors); + + let w = Address::generate(&ctx.env); + let winners = soroban_sdk::vec![ + &ctx.env, + WinnerSpec { + recipient: w.clone(), + position: 1, + amount: TOTAL_BUDGET - 1, reputation_bump: 50 }, ]; let op = BytesN::random(&ctx.env); let res = ctx.events.try_select_winners(&id, &winners, &op); - assert!(res.is_err(), "position not in distribution must revert"); + assert!( + res.is_err(), + "an award below its advertised floor must revert" + ); } #[test] @@ -925,6 +1083,7 @@ fn select_winners_twice_reverts() { WinnerSpec { recipient: w.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50 }, ]; @@ -959,7 +1118,7 @@ fn large_budget_fee_does_not_overflow() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/big"), title: String::from_str(&ctx.env, "Big"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -1019,6 +1178,7 @@ fn select_winners_on_cancelled_event_reverts() { WinnerSpec { recipient: w.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50 }, ]; @@ -1051,12 +1211,15 @@ fn fee_and_winner_balances_consistent() { let escrow = TOTAL_BUDGET + contrib; + // Awarding the whole pool, top-up included, is a deliberate choice the + // selection makes rather than something a percentage does for it. let winner = Address::generate(&ctx.env); let winners = soroban_sdk::vec![ &ctx.env, WinnerSpec { recipient: winner.clone(), position: 1, + amount: escrow, reputation_bump: 50 }, ]; @@ -1066,7 +1229,11 @@ fn fee_and_winner_balances_consistent() { .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); assert_eq!(token.balance(&winner), escrow); - assert_eq!(token.balance(&ctx.fee_account), create_fee + contrib_fee); + assert_eq!( + token.balance(&ctx.fee_account), + create_fee + contrib_fee, + "fees are charged at funding, never at release" + ); let event = ctx.events.get_event(&id); assert_eq!(event.remaining_escrow, 0); diff --git a/contracts/events/src/tests/grant_pillar.rs b/contracts/events/src/tests/grant_pillar.rs index 40db2a0..ad39d68 100644 --- a/contracts/events/src/tests/grant_pillar.rs +++ b/contracts/events/src/tests/grant_pillar.rs @@ -66,9 +66,9 @@ fn setup<'a>() -> Ctx<'a> { } } -fn single_dist(env: &Env) -> Map { +fn single_dist(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, 100000000000_i128); m } @@ -82,7 +82,7 @@ fn create_grant(ctx: &Ctx, n: u32) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/grant/1"), title: String::from_str(&ctx.env, "Test Grant"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -95,6 +95,7 @@ fn select_winner(ctx: &Ctx, id: u64, recipient: &Address) { WinnerSpec { recipient: recipient.clone(), position: 1, + amount: 10_000_0000000_i128, reputation_bump: 0 }, ]; @@ -128,7 +129,7 @@ fn grant_create_with_single_release_reverts() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/grant"), title: String::from_str(&ctx.env, "Bad Grant"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -150,7 +151,7 @@ fn grant_create_with_zero_milestones_reverts() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/grant"), title: String::from_str(&ctx.env, "Zero Milestones"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -310,7 +311,7 @@ fn claim_milestone_on_single_release_reverts() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hack"), title: String::from_str(&ctx.env, "Single Release"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -345,8 +346,8 @@ fn claim_milestone_op_replay_reverts() { fn two_winner_grant_each_claims_their_share() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 60); - dist.set(2, 40); + dist.set(1, TOTAL_BUDGET * 60 / 100); + dist.set(2, TOTAL_BUDGET * 40 / 100); let params = CreateEventParams { pillar: Pillar::Grant, owner: ctx.owner.clone(), @@ -356,7 +357,7 @@ fn two_winner_grant_each_claims_their_share() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/multi-grant"), title: String::from_str(&ctx.env, "Multi Winner Grant"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -369,11 +370,13 @@ fn two_winner_grant_each_claims_their_share() { WinnerSpec { recipient: w1.clone(), position: 1, + amount: TOTAL_BUDGET * 60 / 100, reputation_bump: 0 }, WinnerSpec { recipient: w2.clone(), position: 2, + amount: TOTAL_BUDGET * 40 / 100, reputation_bump: 0 }, ]; diff --git a/contracts/events/src/tests/hackathon_pillar.rs b/contracts/events/src/tests/hackathon_pillar.rs index 8b64950..5aa5a4b 100644 --- a/contracts/events/src/tests/hackathon_pillar.rs +++ b/contracts/events/src/tests/hackathon_pillar.rs @@ -87,21 +87,21 @@ fn expect_op_err( } } -fn single_winner_dist(env: &Env) -> Map { +fn single_winner_dist(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, 100000000000_i128); m } -fn three_way_dist(env: &Env) -> Map { +fn three_way_dist(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 50); - m.set(2, 30); - m.set(3, 20); + m.set(1, 50000000000_i128); + m.set(2, 30000000000_i128); + m.set(3, 20000000000_i128); m } -fn create_hackathon_with(ctx: &Ctx, dist: Map, deadline: Option) -> u64 { +fn create_hackathon_with(ctx: &Ctx, dist: Map, deadline: Option) -> u64 { let params = CreateEventParams { pillar: Pillar::Hackathon, owner: ctx.owner.clone(), @@ -111,7 +111,7 @@ fn create_hackathon_with(ctx: &Ctx, dist: Map, deadline: Option) content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), title: String::from_str(&ctx.env, "Test Hackathon"), deadline, - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -163,7 +163,7 @@ fn create_rejects_multi_release_kind() { content_uri: String::from_str(&ctx.env, "uri"), title: String::from_str(&ctx.env, "Bad Hackathon"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_winner_dist(&ctx.env), + prize_floors: single_winner_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -183,9 +183,9 @@ fn submit_open_without_prior_apply_creates_anchor() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../project.json"); let op = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri, &op); + ctx.events.submit(&id, &ctx.applicant, &0_u32, &uri, &op); - let sub = ctx.events.get_submission(&id, &ctx.applicant); + 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()); @@ -198,14 +198,19 @@ fn resubmit_keeps_original_timestamp_and_updates_uri() { 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, &uri_a, &op_a); - let first_time = ctx.events.get_submission(&id, &ctx.applicant).submitted_at; + 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, &uri_b, &op_b); + ctx.events + .submit(&id, &ctx.applicant, &0_u32, &uri_b, &op_b); - let second = ctx.events.get_submission(&id, &ctx.applicant); + 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); } @@ -217,9 +222,11 @@ fn submit_replayed_op_reverts() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); let op = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri, &op); + ctx.events.submit(&id, &ctx.applicant, &0_u32, &uri, &op); - let res = ctx.events.try_submit(&id, &ctx.applicant, &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"); } @@ -230,12 +237,13 @@ fn withdraw_submission_removes_anchor() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); let op_s = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri, &op_s); + 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, &op_w); + ctx.events + .withdraw_submission(&id, &ctx.applicant, &0_u32, &op_w); - let res = ctx.events.try_get_submission(&id, &ctx.applicant); + let res = ctx.events.try_get_submission(&id, &ctx.applicant, &0_u32); assert!(res.is_err(), "withdrawn submission is no longer readable"); } @@ -248,6 +256,7 @@ fn remove_submission_on_nonexistent_entry_does_not_corrupt_counter() { ctx.events.submit( &id, &submitter, + &0_u32, &String::from_str(&ctx.env, "ipfs://Qm.../v1.json"), &BytesN::random(&ctx.env), ); @@ -256,7 +265,7 @@ fn remove_submission_on_nonexistent_entry_does_not_corrupt_counter() { // 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); + storage::remove_submission(&ctx.env, id, &ctx.applicant, 0); }); let count = ctx @@ -275,9 +284,9 @@ fn withdraw_submission_frees_the_slot_for_future_submitters() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); ctx.events - .submit(&id, &ctx.applicant, &uri, &BytesN::random(&ctx.env)); + .submit(&id, &ctx.applicant, &0_u32, &uri, &BytesN::random(&ctx.env)); ctx.events - .withdraw_submission(&id, &ctx.applicant, &BytesN::random(&ctx.env)); + .withdraw_submission(&id, &ctx.applicant, &0_u32, &BytesN::random(&ctx.env)); let count = ctx .env @@ -304,7 +313,7 @@ fn submit_beyond_former_cap_succeeds() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../v5001.json"); ctx.events - .submit(&id, &ctx.applicant, &uri, &BytesN::random(&ctx.env)); + .submit(&id, &ctx.applicant, &0_u32, &uri, &BytesN::random(&ctx.env)); let count = ctx .env @@ -329,7 +338,10 @@ fn submit_at_counter_overflow_reverts() { 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, &uri, &op)); + let err = expect_op_err( + ctx.events + .try_submit(&id, &ctx.applicant, &0_u32, &uri, &op), + ); assert_eq!( err, Error::TooManyContributors, @@ -345,7 +357,10 @@ fn submit_oversized_content_uri_reverts() { 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, &uri, &op)); + 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!( @@ -361,8 +376,13 @@ fn resubmit_by_existing_applicant_does_not_increment_submission_count() { let id = create_hackathon(&ctx); let uri_a = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); - ctx.events - .submit(&id, &ctx.applicant, &uri_a, &BytesN::random(&ctx.env)); + ctx.events.submit( + &id, + &ctx.applicant, + &0_u32, + &uri_a, + &BytesN::random(&ctx.env), + ); let count_after_first = ctx .env @@ -370,8 +390,13 @@ fn resubmit_by_existing_applicant_does_not_increment_submission_count() { assert_eq!(count_after_first, 1); let uri_b = String::from_str(&ctx.env, "ipfs://Qm.../v2.json"); - ctx.events - .submit(&id, &ctx.applicant, &uri_b, &BytesN::random(&ctx.env)); + ctx.events.submit( + &id, + &ctx.applicant, + &0_u32, + &uri_b, + &BytesN::random(&ctx.env), + ); let count_after_second = ctx .env @@ -401,6 +426,7 @@ fn select_winners_single_recipient_sweeps_escrow() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 50, }, ]; @@ -454,16 +480,19 @@ fn select_winners_multi_position_splits_by_distribution() { WinnerSpec { recipient: first.clone(), position: 1, + amount: TOTAL_BUDGET * 50 / 100, reputation_bump: 60, }, WinnerSpec { recipient: second.clone(), position: 2, + amount: TOTAL_BUDGET * 30 / 100, reputation_bump: 40, }, WinnerSpec { recipient: third.clone(), position: 3, + amount: TOTAL_BUDGET * 20 / 100, reputation_bump: 20, }, ]; @@ -516,21 +545,25 @@ fn select_winners_empty_set_reverts() { } #[test] -fn select_winners_position_not_in_distribution_reverts() { +fn select_winners_allows_a_position_with_no_floor() { let ctx = setup(); - let id = create_hackathon(&ctx); // distribution only has position 1 + let id = create_hackathon(&ctx); // floors cover position 1 only let winners = soroban_sdk::vec![ &ctx.env, WinnerSpec { recipient: ctx.applicant.clone(), position: 2, + amount: 1_0000000_i128, reputation_bump: 0, }, ]; let op = BytesN::random(&ctx.env); - let res = ctx.events.try_select_winners(&id, &winners, &op); - assert!(res.is_err(), "position outside distribution must revert"); + ctx.events.select_winners(&id, &winners, &op); + + let rows = ctx.events.get_winners(&id); + assert_eq!(rows.len(), 1); + assert_eq!(rows.get(0).unwrap().position, 2); } #[test] @@ -545,11 +578,13 @@ fn select_winners_duplicate_position_reverts() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET * 50 / 100, reputation_bump: 0, }, WinnerSpec { recipient: other, position: 1, // duplicate + amount: TOTAL_BUDGET * 50 / 100, reputation_bump: 0, }, ]; @@ -569,6 +604,7 @@ fn select_winners_batches_append_and_position_replay_reverts() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET * 50 / 100, reputation_bump: 0, }, ]; @@ -584,6 +620,7 @@ fn select_winners_batches_append_and_position_replay_reverts() { WinnerSpec { recipient: usurper, position: 1, + amount: TOTAL_BUDGET * 50 / 100, reputation_bump: 0, }, ]; @@ -601,11 +638,13 @@ fn select_winners_batches_append_and_position_replay_reverts() { WinnerSpec { recipient: second.clone(), position: 2, + amount: TOTAL_BUDGET * 30 / 100, reputation_bump: 0, }, WinnerSpec { recipient: third.clone(), position: 3, + amount: TOTAL_BUDGET * 20 / 100, reputation_bump: 0, }, ]; @@ -636,6 +675,7 @@ fn select_winners_replayed_op_reverts() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; @@ -654,6 +694,7 @@ fn select_winners_on_missing_event_reverts() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; @@ -672,6 +713,7 @@ fn select_winners_on_completed_event_reverts() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; @@ -689,6 +731,7 @@ fn select_winners_on_completed_event_reverts() { WinnerSpec { recipient: again, position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; @@ -710,6 +753,7 @@ fn select_winners_demands_owner_auth() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; diff --git a/contracts/events/src/tests/op_id_security.rs b/contracts/events/src/tests/op_id_security.rs index 87fe2be..1aaa682 100644 --- a/contracts/events/src/tests/op_id_security.rs +++ b/contracts/events/src/tests/op_id_security.rs @@ -76,9 +76,9 @@ fn setup<'a>() -> Ctx<'a> { } } -fn dist_100(env: &Env) -> Map { +fn dist_100(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, 100000000000_i128); m } @@ -92,7 +92,7 @@ fn create_bounty(ctx: &Ctx) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/events/op-id-sec"), title: String::from_str(&ctx.env, "OpId Security"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist_100(&ctx.env), + prize_floors: dist_100(&ctx.env), fee_bps_override: None, manager: None, }; @@ -153,6 +153,7 @@ fn bootstrap_self_cannot_front_run_events_child_op_ids() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: 10_000_0000000_i128, reputation_bump: 50, }, ]; @@ -236,7 +237,7 @@ fn event_id_overflow_reverts() { content_uri: String::from_str(env, "https://api.boundless.fi/events/overflow"), title: String::from_str(env, "Overflow"), deadline: Some(env.ledger().timestamp() + 86_400), - winner_distribution: dist_100(env), + prize_floors: dist_100(env), fee_bps_override: None, manager: None, }; @@ -295,6 +296,7 @@ fn permissionless_apply_cannot_squat_select_winners_op_id() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: 10_000_0000000_i128, reputation_bump: 0, }, ]; diff --git a/contracts/events/src/tests/prize_claim.rs b/contracts/events/src/tests/prize_claim.rs index 79f04f4..3288dd4 100644 --- a/contracts/events/src/tests/prize_claim.rs +++ b/contracts/events/src/tests/prize_claim.rs @@ -71,7 +71,7 @@ fn setup<'a>() -> Ctx<'a> { } } -fn create_single(ctx: &Ctx, dist: Map) -> u64 { +fn create_single(ctx: &Ctx, dist: Map) -> u64 { let params = CreateEventParams { pillar: Pillar::Hackathon, owner: ctx.owner.clone(), @@ -81,32 +81,33 @@ fn create_single(ctx: &Ctx, dist: Map) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/prize-claim"), title: String::from_str(&ctx.env, "Prize Claim Suite"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; ctx.events.create_event(¶ms, &BytesN::random(&ctx.env)) } -fn dist_100(env: &Env) -> Map { +fn dist_100(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, TOTAL_BUDGET); m } -fn dist_60_40(env: &Env) -> Map { +fn dist_60_40(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 60); - m.set(2, 40); + m.set(1, TOTAL_BUDGET * 60 / 100); + m.set(2, TOTAL_BUDGET * 40 / 100); m } -fn select_one(ctx: &Ctx, id: u64, recipient: &Address, position: u32, bump: u32) { +fn select_one(ctx: &Ctx, id: u64, recipient: &Address, position: u32, amount: i128, bump: u32) { let winners = soroban_sdk::vec![ &ctx.env, WinnerSpec { recipient: recipient.clone(), position, + amount, reputation_bump: bump, }, ]; @@ -125,7 +126,7 @@ fn claim_pays_recipient_full_prize_and_no_release_fee() { let token = token::Client::new(&ctx.env, &ctx.token_addr); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 50); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET, 50); // Selection alone must not move funds or complete the event. assert_eq!(token.balance(&w), 0); @@ -167,8 +168,8 @@ fn split_claims_pay_exact_amounts_each() { let a = Address::generate(&ctx.env); let b = Address::generate(&ctx.env); - select_one(&ctx, id, &a, 1, 10); - select_one(&ctx, id, &b, 2, 5); + select_one(&ctx, id, &a, 1, TOTAL_BUDGET * 60 / 100, 10); + select_one(&ctx, id, &b, 2, TOTAL_BUDGET * 40 / 100, 5); ctx.events .claim_prize(&id, &2_u32, &BytesN::random(&ctx.env)); @@ -193,7 +194,7 @@ fn topup_after_selection_stays_residual_for_refund() { let token_admin = token::StellarAssetClient::new(&ctx.env, &ctx.token_addr); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 0); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET, 0); // A partner tops up AFTER selection: the prize amount stays anchored // to the selection-time baseline; the top-up is refundable residual. @@ -227,7 +228,7 @@ fn claim_requires_recipient_auth() { let id = create_single(&ctx, dist_100(&ctx.env)); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 0); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET, 0); ctx.env.mock_auths(&[]); let res = ctx @@ -242,7 +243,7 @@ fn claim_demands_the_award_recipients_auth_specifically() { let id = create_single(&ctx, dist_100(&ctx.env)); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 0); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET, 0); ctx.events .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); @@ -264,7 +265,7 @@ fn double_claim_reverts() { let id = create_single(&ctx, dist_60_40(&ctx.env)); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 0); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET * 60 / 100, 0); ctx.events .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); @@ -287,8 +288,8 @@ fn op_id_replay_reverts() { // not yet paid. (Cross-recipient op_id reuse is intentionally allowed — // OpSeen is namespaced per authorizing caller.) let a = Address::generate(&ctx.env); - select_one(&ctx, id, &a, 1, 0); - select_one(&ctx, id, &a, 2, 0); + select_one(&ctx, id, &a, 1, TOTAL_BUDGET * 60 / 100, 0); + select_one(&ctx, id, &a, 2, TOTAL_BUDGET * 40 / 100, 0); let op = BytesN::random(&ctx.env); ctx.events.claim_prize(&id, &1_u32, &op); @@ -305,7 +306,7 @@ fn claim_of_unawarded_position_reverts() { let id = create_single(&ctx, dist_60_40(&ctx.env)); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 0); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET * 60 / 100, 0); // Position 2 is in the distribution but has no award yet. let res = ctx @@ -332,14 +333,14 @@ fn claim_on_multi_release_event_reverts() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/grant"), title: String::from_str(&ctx.env, "Grant"), deadline: None, - winner_distribution: dist_100(&ctx.env), + prize_floors: dist_100(&ctx.env), fee_bps_override: None, manager: None, }; let id = ctx.events.create_event(¶ms, &BytesN::random(&ctx.env)); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 0); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET, 0); let res = ctx .events @@ -360,7 +361,7 @@ fn cancel_blocked_while_unclaimed_prizes_within_window() { let id = create_single(&ctx, dist_60_40(&ctx.env)); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 0); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET * 60 / 100, 0); let res = ctx.events.try_start_cancel(&id, &BytesN::random(&ctx.env)); assert!( @@ -387,7 +388,7 @@ fn cancel_after_window_expiry_sweeps_unclaimed_and_blocks_late_claim() { let token = token::Client::new(&ctx.env, &ctx.token_addr); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 0); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET, 0); ctx.env.ledger().with_mut(|li| { li.timestamp += PRIZE_CLAIM_WINDOW_SECS + 1; @@ -414,14 +415,14 @@ fn claim_window_refreshes_on_a_later_batch() { let id = create_single(&ctx, dist_60_40(&ctx.env)); let a = Address::generate(&ctx.env); - select_one(&ctx, id, &a, 1, 0); + select_one(&ctx, id, &a, 1, TOTAL_BUDGET * 60 / 100, 0); // Move to just before the first window expires, then select batch 2. ctx.env.ledger().with_mut(|li| { li.timestamp += PRIZE_CLAIM_WINDOW_SECS - 100; }); let b = Address::generate(&ctx.env); - select_one(&ctx, id, &b, 2, 0); + select_one(&ctx, id, &b, 2, TOTAL_BUDGET * 40 / 100, 0); // Past the FIRST batch's expiry, but inside the refreshed window: // cancel stays blocked, protecting the late-selected winner. diff --git a/contracts/events/src/tests/token_whitelist.rs b/contracts/events/src/tests/token_whitelist.rs index f8736ca..4e2b20d 100644 --- a/contracts/events/src/tests/token_whitelist.rs +++ b/contracts/events/src/tests/token_whitelist.rs @@ -37,9 +37,9 @@ fn new_token(env: &Env) -> Address { env.register_stellar_asset_contract_v2(issuer).address() } -fn single_dist(env: &Env) -> Map { +fn single_dist(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, 10000000000_i128); m } @@ -122,7 +122,7 @@ fn create_event_with_unsupported_token_reverts() { content_uri: String::from_str(&ctx.env, "https://example.com"), title: String::from_str(&ctx.env, "Bad Token Hack"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -151,7 +151,7 @@ fn create_event_with_deregistered_token_reverts() { content_uri: String::from_str(&ctx.env, "https://example.com"), title: String::from_str(&ctx.env, "Deregistered Token Hack"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; diff --git a/contracts/events/src/types.rs b/contracts/events/src/types.rs index fab766a..936ccff 100644 --- a/contracts/events/src/types.rs +++ b/contracts/events/src/types.rs @@ -73,7 +73,11 @@ pub struct EventRecord { pub title: String, pub created_at: u64, pub deadline: Option, - pub winner_distribution: Map, + /// Advertised minimum per position, in token-native units. `select_winners` + /// may pay above a floor but never below it, so a published prize table is + /// a guarantee rather than an estimate. Positions absent from the map carry + /// no floor and are payable at any positive amount. + pub prize_floors: Map, pub fee_bps_override: Option, } @@ -91,7 +95,7 @@ pub struct CreateEventParams { pub content_uri: String, pub title: String, pub deadline: Option, - pub winner_distribution: Map, + pub prize_floors: Map, pub fee_bps_override: Option, pub manager: Option
, } @@ -139,6 +143,10 @@ pub struct Winner { pub struct WinnerSpec { pub recipient: Address, pub position: u32, + /// Award amount in token-native units. Allocation happens here, at payout, + /// not at create: the event holds a pool and each selection names what it + /// spends from it. + pub amount: i128, pub reputation_bump: u32, } @@ -167,6 +175,8 @@ pub enum DataKey { 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), @@ -209,6 +219,22 @@ pub enum DataKey { // Appended to cap per-event submission storage growth (security fix). EventSubmissionCount(u64), + + // Appended in 1.7.0. Sum of awarded-but-unclaimed prizes. `remaining_escrow` + // only drops at claim time, so without this a second selection would see + // 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. + EventSubmissionEntry(u64, Address, u32), + EventApplicantSubmissionCount(u64, Address), + + // Next event id `migrate_events` has yet to convert. The pass is paged + // because one invocation may touch only 100 ledger entries, so a + // deployment with real history cannot be migrated in a single call. + MigrationCursor, } // ============================================================ diff --git a/contracts/profile/src/admin.rs b/contracts/profile/src/admin.rs index f7c47bc..25413b5 100644 --- a/contracts/profile/src/admin.rs +++ b/contracts/profile/src/admin.rs @@ -7,8 +7,14 @@ use crate::types::{PendingAdmin, PendingEventsContract, PendingUpgrade}; const PENDING_TTL_LEDGERS: u32 = 120_960; +// H6 (audit 2026-06) mandated 17_280 ledgers, ~1 day, on mainnet. Zeroed +// deliberately while mainnet escrow is empty so the 1.7.0 rollout can iterate. +// RESTORE to 17_280 before the first funded campaign: with no window, a +// compromised admin key can propose and apply a wasm swap in one go, and +// cancel_pending_upgrade never gets a chance to fire. Tracked in BACKLOG. +// The cfg split is kept so restoring is a single-value edit. #[cfg(not(feature = "testnet"))] -const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; +const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; #[cfg(feature = "testnet")] const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; diff --git a/contracts/profile/src/tests/admin.rs b/contracts/profile/src/tests/admin.rs index 635c7fb..85fe24f 100644 --- a/contracts/profile/src/tests/admin.rs +++ b/contracts/profile/src/tests/admin.rs @@ -11,7 +11,8 @@ use crate::errors::Error; const EVENTS_CONTRACT_TIMELOCK_LEDGERS: u32 = 17_280; const PENDING_EVENTS_CONTRACT_TTL_LEDGERS: u32 = 120_960; -const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; +// Zeroed with the contract constant; restore both together. +const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; #[test] @@ -189,19 +190,20 @@ fn propose_upgrade_records_pending() { } #[test] -fn apply_upgrade_before_timelock_reverts_profile() { +fn apply_upgrade_is_immediate_while_the_timelock_is_zero_profile() { + // Mirror of the events-side test. When UPGRADE_TIMELOCK_LEDGERS is + // restored to 17_280 this should revert with UpgradeTimelockNotElapsed. let ctx = setup(); let new_hash: BytesN<32> = BytesN::random(&ctx.env); let new_version = String::from_str(&ctx.env, "0.3.0"); ctx.client.propose_upgrade(&new_hash, &new_version); - let err = ctx - .client - .try_apply_upgrade() - .err() - .expect("timelock blocks") - .unwrap(); - assert_eq!(err, Error::UpgradeTimelockNotElapsed); + let pending = ctx.client.get_pending_upgrade().expect("proposal"); + assert_eq!( + pending.available_at_ledger, pending.proposed_at_ledger, + "no window between proposing and applying" + ); + assert_eq!(UPGRADE_TIMELOCK_LEDGERS, 0, "restore me with the constant"); } #[test]