From 87c5aae31474f9f4b26f45570c7c4c16e7df196d Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Mon, 17 Aug 2026 23:51:21 +0100 Subject: [PATCH 1/8] feat(events): open pool with committed prize floors Allocation moves out of create_event and into select_winners. An event now holds a pool; each selection names the amount it spends from it. The prize table published at create is stored as per-position minimums, so an award may exceed what was advertised but never fall below it. Percentages are gone, and with them the 1% minimum prize, the whole-percent constraint, and the rounding residual that was dumped on position 1. A top-up now has exactly one consequence: the pool is larger. It no longer silently reprices every prize before the first selection, nor becomes inert after it. EventOwedTotal reserves awarded-but-unclaimed prizes. remaining_escrow only drops at claim time, so without the reservation a second selection would see funds an earlier winner is still entitled to and could promise them twice, leaving the second winner unable to claim. Cancelling releases the reservation rather than withholding it, since both claim paths require Active and withheld funds would otherwise strand with no path out. Grants derive each milestone payment from the awarded amount instead of a share of total_budget, so a grant top-up now reaches its milestones. Crowdfunding drops its {1: 100} check; its milestone math never read the distribution. Closes #107, #108, #109, #110, #111, #112, #113, #114 --- contracts/events/src/crowdfunding.rs | 13 +- contracts/events/src/event_ops.rs | 117 ++++++--- contracts/events/src/grant.rs | 19 +- contracts/events/src/storage.rs | 15 ++ contracts/events/src/tests/bounty_pillar.rs | 11 +- contracts/events/src/tests/cancel_refund.rs | 19 +- contracts/events/src/tests/contributions.rs | 9 +- contracts/events/src/tests/cross_contract.rs | 81 +++--- contracts/events/src/tests/crowdfunding.rs | 17 +- contracts/events/src/tests/escrow_fee_math.rs | 235 +++++++++++++++--- contracts/events/src/tests/grant_pillar.rs | 21 +- .../events/src/tests/hackathon_pillar.rs | 45 +++- contracts/events/src/tests/op_id_security.rs | 10 +- contracts/events/src/tests/prize_claim.rs | 49 ++-- contracts/events/src/tests/token_whitelist.rs | 8 +- contracts/events/src/types.rs | 17 +- 16 files changed, 483 insertions(+), 203 deletions(-) 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..ed3dc10 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)?; @@ -650,13 +664,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 +697,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 +704,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 +777,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 +810,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 +889,11 @@ 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. + let owed = storage::owed_total(env, event_id); + storage::set_owed_total(env, event_id, (owed - amount).max(0)); + event.remaining_escrow = event.remaining_escrow.saturating_sub(amount); if event.remaining_escrow == 0 { event.status = EventStatus::Completed; diff --git a/contracts/events/src/grant.rs b/contracts/events/src/grant.rs index 5d02524..8067346 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,11 @@ 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); + storage::set_owed_total(env, event_id, (owed - amount).max(0)); + } 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/storage.rs b/contracts/events/src/storage.rs index 7eeae1b..b7fb4b5 100644 --- a/contracts/events/src/storage.rs +++ b/contracts/events/src/storage.rs @@ -557,6 +557,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/bounty_pillar.rs b/contracts/events/src/tests/bounty_pillar.rs index 923c1b0..0e852d3 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, }, ]; 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..c000bc8 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, }; @@ -690,7 +705,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 +738,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 +784,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 +810,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 +837,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 +866,7 @@ fn select_winners_rejects_second_call_winners_already_selected() { WinnerSpec { recipient: r2.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; @@ -919,7 +935,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 +956,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 +991,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 +1005,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..1f3776f 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); 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..093ce6a 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, }; @@ -401,6 +401,7 @@ fn select_winners_single_recipient_sweeps_escrow() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 50, }, ]; @@ -454,16 +455,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 +520,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 +553,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 +579,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 +595,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 +613,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 +650,7 @@ fn select_winners_replayed_op_reverts() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; @@ -654,6 +669,7 @@ fn select_winners_on_missing_event_reverts() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; @@ -672,6 +688,7 @@ fn select_winners_on_completed_event_reverts() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; @@ -689,6 +706,7 @@ fn select_winners_on_completed_event_reverts() { WinnerSpec { recipient: again, position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; @@ -710,6 +728,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..8a5d6a3 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, } @@ -209,6 +217,11 @@ 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), } // ============================================================ From 8cd7d2d5581ce57286f17894daba4914e92fbc2f Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Mon, 17 Aug 2026 23:55:25 +0100 Subject: [PATCH 2/8] feat(events): migrate legacy records to prize floors and bump to 1.7.0 The record layout change is not backward compatible: winner_distribution and prize_floors differ in both field name and value type, so a row written before 1.7.0 cannot be decoded by the current struct at all. migrate() reads each stored event through the legacy shape and rewrites it, converting percentages with total_budget * percent / 100, which is exactly what each position would have been paid. This is only affordable because mainnet holds two events, both Completed with zero remaining escrow, so neither can re-enter select_winners. The same rewrite after a real campaign funds escrow would be a production migration on the money path. Bounded by the id counter rather than scanning blindly; the row cap is a backstop against a corrupt counter, not an expected limit. A fresh deployment has no rows and the pass is a no-op. Closes #115, #116 --- contracts/events/src/admin.rs | 90 +++++++++++++++++++++++++- contracts/events/src/lib.rs | 2 +- contracts/events/src/tests/admin.rs | 97 +++++++++++++++++++++++++++-- 3 files changed, 180 insertions(+), 9 deletions(-) diff --git a/contracts/events/src/admin.rs b/contracts/events/src/admin.rs index ff672b5..6140731 100644 --- a/contracts/events/src/admin.rs +++ b/contracts/events/src/admin.rs @@ -1,9 +1,34 @@ -use soroban_sdk::{panic_with_error, Address, BytesN, Env, String}; +use soroban_sdk::{contracttype, panic_with_error, Address, BytesN, Env, Map, String}; 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, +}; + +/// 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; @@ -15,7 +40,7 @@ const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; 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 +275,9 @@ pub fn migrate(env: &Env) -> Result<(), Error> { // ============================================================ // PER-(from -> to) MIGRATION DISPATCH // ============================================================ + if current == String::from_str(env, INITIAL_VERSION) { + migrate_prize_floors(env); + } storage::set_migrated_to_version(env, ¤t); storage::touch_instance(env); @@ -261,6 +289,62 @@ pub fn migrate(env: &Env) -> Result<(), Error> { Ok(()) } +/// Rewrites every stored 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 +/// and has to be read through the legacy shape first. +/// +/// Percentages were always taken against the escrow balance, so `total_budget * +/// percent / 100` reproduces exactly what each position would have been paid. +/// +/// Bounded by the id counter: ids run from `id_base + 1` up to the next id to +/// be issued, and the cap is a backstop against a corrupt counter rather than +/// an expected limit. +fn migrate_prize_floors(env: &Env) { + const MAX_ROWS: u64 = 256; + + let base = idempotency::id_base(env); + let next = storage::get_next_event_id(env, base.saturating_add(1)); + let mut id = base.saturating_add(1); + let mut scanned: u64 = 0; + + while id < next && scanned < MAX_ROWS { + let key = DataKey::Event(id); + let legacy: Option = env.storage().persistent().get(&key); + if let Some(old) = legacy { + 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); + } + id = id.saturating_add(1); + scanned = scanned.saturating_add(1); + } +} + // ============================================================ // READS // ============================================================ diff --git a/contracts/events/src/lib.rs b/contracts/events/src/lib.rs index 2011462..2061f07 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" diff --git a/contracts/events/src/tests/admin.rs b/contracts/events/src/tests/admin.rs index 4199fd8..9b76d63 100644 --- a/contracts/events/src/tests/admin.rs +++ b/contracts/events/src/tests/admin.rs @@ -2,11 +2,12 @@ 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::types::{DataKey, EventStatus, Pillar, ReleaseKind}; const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; @@ -19,7 +20,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); } @@ -96,7 +97,7 @@ fn apply_upgrade_before_timelock_reverts() { .expect("timelock blocks") .unwrap(); assert_eq!(err, Error::UpgradeTimelockNotElapsed); - 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] @@ -130,7 +131,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,6 +146,92 @@ 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(); + + // 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_is_a_no_op_on_a_fresh_deployment() { + let ctx = setup(250); + 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); @@ -152,7 +239,7 @@ fn migrate_marks_current_version_and_blocks_replay() { ctx.client.migrate(); assert_eq!( ctx.client.get_migrated_to_version(), - Some(String::from_str(&ctx.env, "1.6.0")) + Some(String::from_str(&ctx.env, "1.7.0")) ); let err = ctx From f6e13803be2fdccad1c15d8d9bbb61895bb27c66 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Tue, 18 Aug 2026 00:14:16 +0100 Subject: [PATCH 3/8] feat(events): give submissions a caller-chosen slot One wallet could hold exactly one submission per event, because the storage key was (event, applicant). That is fine for a bounty asking for one piece of work and wrong for any event that wants two different things from the same person. Submissions now key on (event, applicant, slot). The contract assigns the slot no meaning. It is a caller-chosen u32, so whatever separates entries off chain (a track, a category, a round) needs no further contract change to express. Re-submitting to an occupied slot still updates in place and keeps the original timestamp; a fresh slot appends. A per-applicant counter keeps "has this wallet submitted at all" O(1), which is what gates application withdrawal. Without it that gate would have to scan slots, and it would have silently regressed to checking slot 0 only. The old key is kept in DataKey as read-only so migrate can move each historical row into slot 0 and drop it. Skipping that would leave the two settled mainnet events' submissions occupying storage with no read path able to reach them: the applicant index bounds the scan, and mainnet holds six applicants across both events. Closes #117 --- contracts/events/src/admin.rs | 23 +++ contracts/events/src/bounty.rs | 2 +- contracts/events/src/event_ops.rs | 31 ++-- contracts/events/src/lib.rs | 17 +- contracts/events/src/storage.rs | 92 +++++++--- contracts/events/src/tests/admin.rs | 73 +++++++- contracts/events/src/tests/bounty_pillar.rs | 164 +++++++++++++++++- contracts/events/src/tests/cross_contract.rs | 39 +++-- contracts/events/src/tests/crowdfunding.rs | 2 +- .../events/src/tests/hackathon_pillar.rs | 67 ++++--- contracts/events/src/types.rs | 8 + 11 files changed, 443 insertions(+), 75 deletions(-) diff --git a/contracts/events/src/admin.rs b/contracts/events/src/admin.rs index 6140731..2835e95 100644 --- a/contracts/events/src/admin.rs +++ b/contracts/events/src/admin.rs @@ -340,11 +340,34 @@ fn migrate_prize_floors(env: &Env) { }; env.storage().persistent().set(&key, &migrated); } + migrate_submissions_to_slots(env, id); id = id.saturating_add(1); scanned = scanned.saturating_add(1); } } +/// Moves each pre-1.7.0 submission to slot 0 of the slotted key and seeds the +/// per-applicant counter. Without this the re-key would orphan every historical +/// submission: the old rows would still occupy storage but no read path could +/// reach them. +/// +/// The applicant index bounds the work, so this only touches wallets the event +/// already knows about. +fn migrate_submissions_to_slots(env: &Env, event_id: u64) { + let applicants = storage::applicant_count(env, event_id); + for idx in 0..applicants { + let applicant = match storage::applicant_at(env, event_id, idx) { + Some(a) => a, + None => continue, + }; + if let Some(legacy) = storage::get_legacy_submission(env, event_id, &applicant) { + storage::set_submission(env, event_id, &applicant, 0, &legacy); + storage::seed_applicant_submission_count(env, event_id, &applicant, 1); + storage::remove_legacy_submission(env, event_id, &applicant); + } + } +} + // ============================================================ // 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/event_ops.rs b/contracts/events/src/event_ops.rs index ed3dc10..b0ee84b 100644 --- a/contracts/events/src/event_ops.rs +++ b/contracts/events/src/event_ops.rs @@ -544,6 +544,7 @@ pub fn submit( env: &Env, event_id: u64, applicant: Address, + slot: u32, content_uri: String, op_id: BytesN<32>, ) -> Result<(), Error> { @@ -566,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() @@ -591,7 +592,7 @@ 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, @@ -611,6 +612,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)?; @@ -623,11 +625,11 @@ 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, @@ -941,8 +943,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/lib.rs b/contracts/events/src/lib.rs index 2061f07..9b48659 100644 --- a/contracts/events/src/lib.rs +++ b/contracts/events/src/lib.rs @@ -185,23 +185,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 +275,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 b7fb4b5..00deb3d 100644 --- a/contracts/events/src/storage.rs +++ b/contracts/events/src/storage.rs @@ -419,8 +419,8 @@ 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); @@ -428,24 +428,51 @@ pub fn get_submission(env: &Env, id: u64, applicant: &Address) -> Option 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 +} + +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); +} - let key = DataKey::EventSubmission(id, applicant.clone()); +pub fn remove_submission(env: &Env, id: u64, applicant: &Address, slot: u32) { + // Idempotent: a no-op when the slot is empty, so a caller that skips its + // own existence check cannot corrupt either counter. + if get_submission(env, id, applicant, slot).is_none() { + return; + } + + let key = DataKey::EventSubmissionEntry(id, applicant.clone(), slot); env.storage().persistent().remove(&key); + let per_applicant = applicant_submission_count(env, id, applicant).saturating_sub(1); + set_applicant_submission_count(env, id, applicant, per_applicant); + let count_key = DataKey::EventSubmissionCount(id); let next = submission_count(env, id).saturating_sub(1); if next == 0 { @@ -465,16 +492,15 @@ 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> { + if get_submission(env, id, addr, slot).is_some() { return Ok(()); } let cur = submission_count(env, id); @@ -482,9 +508,35 @@ 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 { + env.storage() + .persistent() + .get(&DataKey::EventSubmission(id, applicant.clone())) +} + +/// 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())); +} + +/// Sets the per-applicant slot count directly. Only `migrate` calls this, to +/// seed the counter for rows that predate it. +pub fn seed_applicant_submission_count(env: &Env, id: u64, applicant: &Address, count: u32) { + set_applicant_submission_count(env, id, applicant, count); +} + // ============================================================ // WINNERS (paged, persistent) // ============================================================ diff --git a/contracts/events/src/tests/admin.rs b/contracts/events/src/tests/admin.rs index 9b76d63..2c4eaf3 100644 --- a/contracts/events/src/tests/admin.rs +++ b/contracts/events/src/tests/admin.rs @@ -7,7 +7,8 @@ use soroban_sdk::{ use super::common::setup; use crate::errors::Error; -use crate::types::{DataKey, EventStatus, Pillar, ReleaseKind}; +use crate::storage; +use crate::types::{DataKey, EventStatus, Pillar, ReleaseKind, Submission}; const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; @@ -222,6 +223,76 @@ fn migrate_rewrites_legacy_percentages_as_prize_floors() { ); } +#[test] +fn migrate_moves_legacy_submissions_into_slot_zero() { + let ctx = setup(250); + let applicant = Address::generate(&ctx.env); + let event_id = ctx.client.id_base() + 1; + + 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: 1_000_0000000_i128, + 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, + }; + let legacy_submission = Submission { + applicant: applicant.clone(), + content_uri: String::from_str(&ctx.env, "ipfs://historical"), + submitted_at: 42, + }; + + 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)); + // The applicant index is what bounds the migration scan. + storage::append_applicant(&ctx.env, event_id, &applicant).unwrap(); + ctx.env.storage().persistent().set( + &DataKey::EventSubmission(event_id, applicant.clone()), + &legacy_submission, + ); + }); + + ctx.client.migrate(); + + let moved = ctx.client.get_submission(&event_id, &applicant, &0_u32); + assert_eq!( + moved.content_uri, + String::from_str(&ctx.env, "ipfs://historical") + ); + assert_eq!(moved.submitted_at, 42, "the original timestamp survives"); + assert_eq!( + ctx.client + .get_applicant_submission_count(&event_id, &applicant), + 1 + ); + + // The old row is gone rather than left as an unreachable duplicate. + ctx.env.as_contract(&ctx.client.address, || { + assert!( + storage::get_legacy_submission(&ctx.env, event_id, &applicant).is_none(), + "legacy row should be removed once copied" + ); + }); +} + #[test] fn migrate_is_a_no_op_on_a_fresh_deployment() { let ctx = setup(250); diff --git a/contracts/events/src/tests/bounty_pillar.rs b/contracts/events/src/tests/bounty_pillar.rs index 0e852d3..2ea3a68 100644 --- a/contracts/events/src/tests/bounty_pillar.rs +++ b/contracts/events/src/tests/bounty_pillar.rs @@ -447,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( @@ -527,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/cross_contract.rs b/contracts/events/src/tests/cross_contract.rs index c000bc8..8b527ac 100644 --- a/contracts/events/src/tests/cross_contract.rs +++ b/contracts/events/src/tests/cross_contract.rs @@ -579,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()); @@ -594,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"); } @@ -608,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); } @@ -621,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, @@ -645,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"); } @@ -658,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"); } @@ -675,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" diff --git a/contracts/events/src/tests/crowdfunding.rs b/contracts/events/src/tests/crowdfunding.rs index 1f3776f..47280fa 100644 --- a/contracts/events/src/tests/crowdfunding.rs +++ b/contracts/events/src/tests/crowdfunding.rs @@ -410,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/hackathon_pillar.rs b/contracts/events/src/tests/hackathon_pillar.rs index 093ce6a..5aa5a4b 100644 --- a/contracts/events/src/tests/hackathon_pillar.rs +++ b/contracts/events/src/tests/hackathon_pillar.rs @@ -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 diff --git a/contracts/events/src/types.rs b/contracts/events/src/types.rs index 8a5d6a3..b421fed 100644 --- a/contracts/events/src/types.rs +++ b/contracts/events/src/types.rs @@ -175,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), @@ -222,6 +224,12 @@ pub enum DataKey { // 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), } // ============================================================ From 21274ca86c51991dde6feb8089601fd78020d460 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Tue, 18 Aug 2026 00:30:53 +0100 Subject: [PATCH 4/8] fix(events): close review findings on the 1.7.0 migration and slots Five fixes, four of them in the migration, which had no test exercising a pre-upgrade state and so passed while being wrong. Grants selected before the upgrade stored amount 0 on the anchor winner row, because the payout used to come from the percentage distribution at claim time. claim_milestone now reads that amount, so those grants would have reverted on every milestone claim with no way to re-select and no exit but cancelling. migrate rewrites the anchor to the position's floor, which is what the old formula would have produced. Submission migration was bounded by the applicant index, which only apply_to_bounty populates and only for the bounty pillar. Hackathon submitters never appear there, so their rows would have been left unreachable behind the re-key, and has_any_submission would have stopped blocking application withdrawal for someone still holding one. Since those submitters cannot be enumerated on chain, reads now fall back to the legacy key as slot 0 and the first write folds the row into the slotted layout. The migration row cap now fails closed. It stamped the version even when the id range exceeded it, and migrate is one-shot, so the remainder would have been stranded in a layout the current struct cannot decode. Submitted and SubmissionWithdrawn carry the slot. Without it a withdrawal of slot 1 is indistinguishable from slot 0 on the wire, and the subscribers resolve anchors by (event, applicant). claim_prize and claim_milestone subtract from the owed total with a checked op instead of clamping at zero, so a drifted reservation fails loudly rather than silently under-reserving the next selection. --- contracts/events/src/admin.rs | 66 ++++++++++++++-- contracts/events/src/event_ops.rs | 12 ++- contracts/events/src/events.rs | 2 + contracts/events/src/grant.rs | 6 +- contracts/events/src/storage.rs | 26 +++++- contracts/events/src/tests/admin.rs | 118 +++++++++++++++++++++++++++- 6 files changed, 217 insertions(+), 13 deletions(-) diff --git a/contracts/events/src/admin.rs b/contracts/events/src/admin.rs index 2835e95..d42b41a 100644 --- a/contracts/events/src/admin.rs +++ b/contracts/events/src/admin.rs @@ -5,7 +5,7 @@ use crate::events as evt; use crate::idempotency; use crate::storage; use crate::types::{ - DataKey, EventRecord, EventStatus, PendingAdmin, PendingUpgrade, Pillar, ReleaseKind, + DataKey, EventRecord, EventStatus, PendingAdmin, PendingUpgrade, Pillar, ReleaseKind, Winner, }; /// The pre-1.7.0 `EventRecord`, kept only so `migrate` can decode rows written @@ -276,7 +276,7 @@ pub fn migrate(env: &Env) -> Result<(), Error> { // PER-(from -> to) MIGRATION DISPATCH // ============================================================ if current == String::from_str(env, INITIAL_VERSION) { - migrate_prize_floors(env); + migrate_prize_floors(env)?; } storage::set_migrated_to_version(env, ¤t); @@ -298,17 +298,22 @@ pub fn migrate(env: &Env) -> Result<(), Error> { /// percent / 100` reproduces exactly what each position would have been paid. /// /// Bounded by the id counter: ids run from `id_base + 1` up to the next id to -/// be issued, and the cap is a backstop against a corrupt counter rather than -/// an expected limit. -fn migrate_prize_floors(env: &Env) { +/// be issued. The cap is a backstop against a corrupt counter, and it fails +/// closed: exceeding it aborts before anything is stamped, because `migrate` +/// is one-shot and a half-finished pass would leave the remaining events in a +/// layout the current struct cannot decode, with no way to resume. +fn migrate_prize_floors(env: &Env) -> Result<(), Error> { const MAX_ROWS: u64 = 256; let base = idempotency::id_base(env); let next = storage::get_next_event_id(env, base.saturating_add(1)); let mut id = base.saturating_add(1); - let mut scanned: u64 = 0; - while id < next && scanned < MAX_ROWS { + if next.saturating_sub(id) > MAX_ROWS { + return Err(Error::EventIdOverflow); + } + + while id < next { let key = DataKey::Event(id); let legacy: Option = env.storage().persistent().get(&key); if let Some(old) = legacy { @@ -341,8 +346,53 @@ fn migrate_prize_floors(env: &Env) { env.storage().persistent().set(&key, &migrated); } migrate_submissions_to_slots(env, id); + migrate_winner_amounts(env, id); id = id.saturating_add(1); - scanned = scanned.saturating_add(1); + } + Ok(()) +} + +/// 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, + }, + ); + } } } diff --git a/contracts/events/src/event_ops.rs b/contracts/events/src/event_ops.rs index b0ee84b..949330d 100644 --- a/contracts/events/src/event_ops.rs +++ b/contracts/events/src/event_ops.rs @@ -597,6 +597,7 @@ pub fn submit( evt::Submitted { event_id, applicant: applicant.clone(), + slot, content_uri, } .publish(env); @@ -634,6 +635,7 @@ pub fn withdraw_submission( evt::SubmissionWithdrawn { event_id, applicant: applicant.clone(), + slot, } .publish(env); @@ -892,9 +894,15 @@ pub fn claim_prize( 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. + // 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); - storage::set_owed_total(env, event_id, (owed - amount).max(0)); + 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 { 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 8067346..d14b8e9 100644 --- a/contracts/events/src/grant.rs +++ b/contracts/events/src/grant.rs @@ -125,7 +125,11 @@ pub fn claim_milestone( if !is_crowdfunding { // Crowdfunding never reserves, since it has no winner selection. let owed = storage::owed_total(env, event_id); - storage::set_owed_total(env, event_id, (owed - amount).max(0)); + 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 { diff --git a/contracts/events/src/storage.rs b/contracts/events/src/storage.rs index 00deb3d..2c8d028 100644 --- a/contracts/events/src/storage.rs +++ b/contracts/events/src/storage.rs @@ -424,8 +424,16 @@ pub fn get_submission(env: &Env, id: u64, applicant: &Address, slot: u32) -> Opt 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, slot: u32, submission: &Submission) { @@ -448,6 +456,7 @@ pub fn applicant_submission_count(env: &Env, id: u64, applicant: &Address) -> u3 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) { @@ -469,6 +478,11 @@ pub fn remove_submission(env: &Env, id: u64, applicant: &Address, slot: u32) { 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); @@ -500,6 +514,16 @@ pub fn submission_count(env: &Env, id: u64) -> u32 { /// 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); + if applicant_submission_count(env, id, addr) == 0 { + set_applicant_submission_count(env, id, addr, 1); + } + return Ok(()); + } if get_submission(env, id, addr, slot).is_some() { return Ok(()); } diff --git a/contracts/events/src/tests/admin.rs b/contracts/events/src/tests/admin.rs index 2c4eaf3..c619e53 100644 --- a/contracts/events/src/tests/admin.rs +++ b/contracts/events/src/tests/admin.rs @@ -8,7 +8,7 @@ use soroban_sdk::{ use super::common::setup; use crate::errors::Error; use crate::storage; -use crate::types::{DataKey, EventStatus, Pillar, ReleaseKind, Submission}; +use crate::types::{DataKey, EventStatus, Pillar, ReleaseKind, Submission, Winner}; const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; @@ -293,6 +293,122 @@ fn migrate_moves_legacy_submissions_into_slot_zero() { }); } +#[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(); + + 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_when_the_id_range_exceeds_the_cap() { + 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 + 1_000)); + }); + + assert!( + ctx.client.try_migrate().is_err(), + "a range beyond the cap must abort rather than half-migrate" + ); + assert_eq!( + ctx.client.get_migrated_to_version(), + None, + "nothing may be stamped when work would be left undone" + ); +} + #[test] fn migrate_is_a_no_op_on_a_fresh_deployment() { let ctx = setup(250); From 213b8c7d09d50519f0156d683151f5c27a47aca1 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Tue, 18 Aug 2026 00:40:43 +0100 Subject: [PATCH 5/8] fix(events): make the 1.7.0 migration safe to run on any deployment state The migration assumed it would only ever meet pre-1.7.0 rows. Three ways that failed. Decoding every stored event as the legacy struct aborted the whole invocation when it met a row already in the current layout, because a missing field escalates to a host error rather than a catchable one. That is the documented testnet sequence: redeploy fresh, run the smoke script, then migrate. A contracttype struct is stored as a map keyed by field name, so the pass now identifies the old layout by the field only it carries and leaves current rows alone. The rewrite was gated on the stored version matching INITIAL_VERSION exactly, while propose_upgrade accepts any non-empty string. Proposing as "v1.7.0" would have skipped the rewrite, stamped the marker anyway, and left every legacy event undecodable with migrate refused thereafter. The pass is idempotent now, so it runs unconditionally and the string is irrelevant. Folding an unmigrated submission into slot 0 set the per-applicant counter to 1 only when it was zero, which undercounts an applicant who already holds other slots. After one withdrawal has_any_submission would read false while a slot was still occupied, unlocking application withdrawal for someone holding a live submission. It increments instead. --- contracts/events/src/admin.rs | 26 ++++-- contracts/events/src/storage.rs | 10 ++- contracts/events/src/tests/admin.rs | 131 ++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 8 deletions(-) diff --git a/contracts/events/src/admin.rs b/contracts/events/src/admin.rs index d42b41a..0d02197 100644 --- a/contracts/events/src/admin.rs +++ b/contracts/events/src/admin.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracttype, panic_with_error, Address, BytesN, Env, Map, String}; +use soroban_sdk::{contracttype, panic_with_error, Address, BytesN, Env, Map, String, Symbol, Val}; use crate::errors::Error; use crate::events as evt; @@ -275,9 +275,12 @@ pub fn migrate(env: &Env) -> Result<(), Error> { // ============================================================ // PER-(from -> to) MIGRATION DISPATCH // ============================================================ - if current == String::from_str(env, INITIAL_VERSION) { - migrate_prize_floors(env)?; - } + // Run unconditionally rather than gating on an exact version string: + // propose_upgrade accepts any non-empty version, so a differently-spelled + // one would silently skip the rewrite and still stamp the marker, leaving + // every legacy event undecodable with no way to re-run. The pass skips + // rows already in the current layout, so running it always is safe. + migrate_prize_floors(env)?; storage::set_migrated_to_version(env, ¤t); storage::touch_instance(env); @@ -315,7 +318,20 @@ fn migrate_prize_floors(env: &Env) -> Result<(), Error> { while id < next { let key = DataKey::Event(id); - let legacy: Option = env.storage().persistent().get(&key); + // 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"))); + let legacy: Option = if is_legacy { + env.storage().persistent().get(&key) + } else { + None + }; if let Some(old) = legacy { let mut floors: Map = Map::new(env); for (position, percent) in old.winner_distribution.iter() { diff --git a/contracts/events/src/storage.rs b/contracts/events/src/storage.rs index 2c8d028..b2fc4f9 100644 --- a/contracts/events/src/storage.rs +++ b/contracts/events/src/storage.rs @@ -519,9 +519,13 @@ pub fn append_submission(env: &Env, id: u64, addr: &Address, slot: u32) -> Resul // 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); - if applicant_submission_count(env, id, addr) == 0 { - set_applicant_submission_count(env, id, addr, 1); - } + // 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() { diff --git a/contracts/events/src/tests/admin.rs b/contracts/events/src/tests/admin.rs index c619e53..d283c4c 100644 --- a/contracts/events/src/tests/admin.rs +++ b/contracts/events/src/tests/admin.rs @@ -409,6 +409,137 @@ fn migrate_refuses_to_stamp_when_the_id_range_exceeds_the_cap() { ); } +#[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(); + + 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(); + + 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); From bd9e1778c8dafd3f0e7059bde25558f81a983aac Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Tue, 18 Aug 2026 00:56:32 +0100 Subject: [PATCH 6/8] fix(events): fit the 1.7.0 migration inside one invocation's footprint Measured against the host rather than assumed: an invocation may touch 100 ledger entries and write 50. The migration was built as though it had the whole ledger. migrate walked every applicant of every event looking for legacy submission rows. Per-event applicant caps were removed deliberately in #104, so one popular bounty is enough to push the pass past the footprint limit, and because the call reverts nothing is stamped, so it can be retried but never succeed. Every pre-1.7.0 event would then stay undecodable. The scan is removed rather than budgeted. Since reads already fall back to the legacy key as slot 0 and the first write folds the row in, moving rows eagerly was only a storage tidy-up, and it was the one unbounded part of the pass. What remains is load-bearing and bounded: the record rewrite, and the winner-amount rewrite for Multi events. MAX_ROWS drops from 256 to 16 for the same reason. 256 events could never have fit in one transaction, so the old cap described an impossible run. A deployment that trips the new one aborts before stamping and needs a paged entrypoint rather than a one-shot pass. get_legacy_submission now extends the entry's TTL like every other accessor in that file. It stopped being a one-shot migration source and became the only path reaching a hackathon submission, so without the touch those rows archive and the submission silently disappears. --- contracts/events/src/admin.rs | 31 +++++--------------- contracts/events/src/storage.rs | 19 +++++++------ contracts/events/src/tests/admin.rs | 44 ++++++++++++++--------------- 3 files changed, 39 insertions(+), 55 deletions(-) diff --git a/contracts/events/src/admin.rs b/contracts/events/src/admin.rs index 0d02197..db1e017 100644 --- a/contracts/events/src/admin.rs +++ b/contracts/events/src/admin.rs @@ -306,7 +306,13 @@ pub fn migrate(env: &Env) -> Result<(), Error> { /// is one-shot and a half-finished pass would leave the remaining events in a /// layout the current struct cannot decode, with no way to resume. fn migrate_prize_floors(env: &Env) -> Result<(), Error> { - const MAX_ROWS: u64 = 256; + // An invocation may touch at most 100 ledger entries and write 50, so the + // whole pass has to fit in one transaction's footprint. Each event costs a + // record read plus a record write, and a Multi event adds a read and a + // write per winner. Sixteen leaves headroom for the winner rewrites; above + // that this aborts rather than half-migrating, and a deployment that ever + // trips it needs a paged entrypoint instead of a one-shot pass. + const MAX_ROWS: u64 = 16; let base = idempotency::id_base(env); let next = storage::get_next_event_id(env, base.saturating_add(1)); @@ -361,7 +367,6 @@ fn migrate_prize_floors(env: &Env) -> Result<(), Error> { }; env.storage().persistent().set(&key, &migrated); } - migrate_submissions_to_slots(env, id); migrate_winner_amounts(env, id); id = id.saturating_add(1); } @@ -412,28 +417,6 @@ fn migrate_winner_amounts(env: &Env, event_id: u64) { } } -/// Moves each pre-1.7.0 submission to slot 0 of the slotted key and seeds the -/// per-applicant counter. Without this the re-key would orphan every historical -/// submission: the old rows would still occupy storage but no read path could -/// reach them. -/// -/// The applicant index bounds the work, so this only touches wallets the event -/// already knows about. -fn migrate_submissions_to_slots(env: &Env, event_id: u64) { - let applicants = storage::applicant_count(env, event_id); - for idx in 0..applicants { - let applicant = match storage::applicant_at(env, event_id, idx) { - Some(a) => a, - None => continue, - }; - if let Some(legacy) = storage::get_legacy_submission(env, event_id, &applicant) { - storage::set_submission(env, event_id, &applicant, 0, &legacy); - storage::seed_applicant_submission_count(env, event_id, &applicant, 1); - storage::remove_legacy_submission(env, event_id, &applicant); - } - } -} - // ============================================================ // READS // ============================================================ diff --git a/contracts/events/src/storage.rs b/contracts/events/src/storage.rs index b2fc4f9..03dda31 100644 --- a/contracts/events/src/storage.rs +++ b/contracts/events/src/storage.rs @@ -547,9 +547,16 @@ pub fn append_submission(env: &Env, id: u64, addr: &Address, slot: u32) -> Resul /// 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 { - env.storage() - .persistent() - .get(&DataKey::EventSubmission(id, applicant.clone())) + 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. @@ -559,12 +566,6 @@ pub fn remove_legacy_submission(env: &Env, id: u64, applicant: &Address) { .remove(&DataKey::EventSubmission(id, applicant.clone())); } -/// Sets the per-applicant slot count directly. Only `migrate` calls this, to -/// seed the counter for rows that predate it. -pub fn seed_applicant_submission_count(env: &Env, id: u64, applicant: &Address, count: u32) { - set_applicant_submission_count(env, id, applicant, count); -} - // ============================================================ // WINNERS (paged, persistent) // ============================================================ diff --git a/contracts/events/src/tests/admin.rs b/contracts/events/src/tests/admin.rs index d283c4c..048f866 100644 --- a/contracts/events/src/tests/admin.rs +++ b/contracts/events/src/tests/admin.rs @@ -224,10 +224,15 @@ fn migrate_rewrites_legacy_percentages_as_prize_floors() { } #[test] -fn migrate_moves_legacy_submissions_into_slot_zero() { +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); @@ -236,7 +241,7 @@ fn migrate_moves_legacy_submissions_into_slot_zero() { pillar: Pillar::Bounty, owner: Address::generate(&ctx.env), token: Address::generate(&ctx.env), - total_budget: 1_000_0000000_i128, + total_budget: budget, remaining_escrow: 0, release_kind: ReleaseKind::Single, status: EventStatus::Completed, @@ -247,11 +252,6 @@ fn migrate_moves_legacy_submissions_into_slot_zero() { winner_distribution: dist, fee_bps_override: None, }; - let legacy_submission = Submission { - applicant: applicant.clone(), - content_uri: String::from_str(&ctx.env, "ipfs://historical"), - submitted_at: 42, - }; ctx.env.as_contract(&ctx.client.address, || { ctx.env @@ -262,34 +262,34 @@ fn migrate_moves_legacy_submissions_into_slot_zero() { .storage() .instance() .set(&DataKey::NextEventId, &(event_id + 1)); - // The applicant index is what bounds the migration scan. storage::append_applicant(&ctx.env, event_id, &applicant).unwrap(); ctx.env.storage().persistent().set( &DataKey::EventSubmission(event_id, applicant.clone()), - &legacy_submission, + &Submission { + applicant: applicant.clone(), + content_uri: String::from_str(&ctx.env, "ipfs://historical"), + submitted_at: 42, + }, ); }); ctx.client.migrate(); - let moved = ctx.client.get_submission(&event_id, &applicant, &0_u32); + // The record rewrite is the load-bearing part and must have happened. assert_eq!( - moved.content_uri, - String::from_str(&ctx.env, "ipfs://historical") + ctx.client.get_event(&event_id).prize_floors.get(1), + Some(budget) ); - assert_eq!(moved.submitted_at, 42, "the original timestamp survives"); + + // The submission is still addressable, with its timestamp intact. + let found = ctx.client.get_submission(&event_id, &applicant, &0_u32); assert_eq!( - ctx.client - .get_applicant_submission_count(&event_id, &applicant), - 1 + found.content_uri, + String::from_str(&ctx.env, "ipfs://historical") ); - - // The old row is gone rather than left as an unreachable duplicate. + assert_eq!(found.submitted_at, 42); ctx.env.as_contract(&ctx.client.address, || { - assert!( - storage::get_legacy_submission(&ctx.env, event_id, &applicant).is_none(), - "legacy row should be removed once copied" - ); + assert!(storage::has_any_submission(&ctx.env, event_id, &applicant)); }); } From f139c5bc369d432448b3a0906a51bef686bb60a4 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Tue, 18 Aug 2026 03:00:30 +0100 Subject: [PATCH 7/8] feat(events): page the 1.7.0 migration through a cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proven necessary rather than theorised. Testnet was upgraded to 1.7.0 at ledger 4199077 and migrate() aborted with EventIdOverflow: the deployment holds over a hundred events against a one-shot pass capped at 16, so every event created before the upgrade is undecodable and there is no way to finish. Mainnet's two events only postpone the same wall. The cap was never the real constraint. An invocation may touch 100 ledger entries and write 50, so no single transaction can convert a deployment with history, whatever the cap says. migrate_events(max_events) now converts a bounded slice and advances a stored cursor, returning how many remain. An operator loops until it reports zero. migrate() refuses to stamp until the cursor reaches the end, which is what stops a partial pass being sealed in — it is one-shot, so an early stamp would strand the remainder permanently. Per-call work is capped at 8 events regardless of the argument, since each costs a record read and write and a Multi event adds a read and write per winner. Asking for 0 means "use the ceiling" rather than "do nothing". Reuses EventIdOverflow for "events remain": contracterror is at the 50-case cap. MAX_ROWS is gone; the cursor is the gate now. --- contracts/events/src/admin.rs | 128 +++++++++++++++++----------- contracts/events/src/lib.rs | 7 ++ contracts/events/src/storage.rs | 8 ++ contracts/events/src/tests/admin.rs | 49 +++++++++-- contracts/events/src/types.rs | 5 ++ 5 files changed, 141 insertions(+), 56 deletions(-) diff --git a/contracts/events/src/admin.rs b/contracts/events/src/admin.rs index db1e017..7b43d37 100644 --- a/contracts/events/src/admin.rs +++ b/contracts/events/src/admin.rs @@ -275,12 +275,16 @@ pub fn migrate(env: &Env) -> Result<(), Error> { // ============================================================ // PER-(from -> to) MIGRATION DISPATCH // ============================================================ - // Run unconditionally rather than gating on an exact version string: - // propose_upgrade accepts any non-empty version, so a differently-spelled - // one would silently skip the rewrite and still stamp the marker, leaving - // every legacy event undecodable with no way to re-run. The pass skips - // rows already in the current layout, so running it always is safe. - migrate_prize_floors(env)?; + // 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); @@ -292,53 +296,77 @@ pub fn migrate(env: &Env) -> Result<(), Error> { Ok(()) } -/// Rewrites every stored 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 -/// and has to be read through the legacy shape first. -/// -/// Percentages were always taken against the escrow balance, so `total_budget * -/// percent / 100` reproduces exactly what each position would have been paid. +/// 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`. /// -/// Bounded by the id counter: ids run from `id_base + 1` up to the next id to -/// be issued. The cap is a backstop against a corrupt counter, and it fails -/// closed: exceeding it aborts before anything is stamped, because `migrate` -/// is one-shot and a half-finished pass would leave the remaining events in a -/// layout the current struct cannot decode, with no way to resume. -fn migrate_prize_floors(env: &Env) -> Result<(), Error> { - // An invocation may touch at most 100 ledger entries and write 50, so the - // whole pass has to fit in one transaction's footprint. Each event costs a - // record read plus a record write, and a Multi event adds a read and a - // write per winner. Sixteen leaves headroom for the winner rewrites; above - // that this aborts rather than half-migrating, and a deployment that ever - // trips it needs a paged entrypoint instead of a one-shot pass. - const MAX_ROWS: u64 = 16; +/// 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)?; - let base = idempotency::id_base(env); - let next = storage::get_next_event_id(env, base.saturating_add(1)); - let mut id = base.saturating_add(1); + // 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 + }; - if next.saturating_sub(id) > MAX_ROWS { - return Err(Error::EventIdOverflow); + 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); } - while id < next { - 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"))); - let legacy: Option = if is_legacy { - env.storage().persistent().get(&key) - } else { - None - }; - if let Some(old) = legacy { + 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 @@ -367,10 +395,8 @@ fn migrate_prize_floors(env: &Env) -> Result<(), Error> { }; env.storage().persistent().set(&key, &migrated); } - migrate_winner_amounts(env, id); - id = id.saturating_add(1); } - Ok(()) + migrate_winner_amounts(env, id); } /// Pre-1.7.0 `Multi` selections stored `amount: 0` on the anchor winner row, diff --git a/contracts/events/src/lib.rs b/contracts/events/src/lib.rs index 9b48659..33978a1 100644 --- a/contracts/events/src/lib.rs +++ b/contracts/events/src/lib.rs @@ -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) } diff --git a/contracts/events/src/storage.rs b/contracts/events/src/storage.rs index 03dda31..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) } diff --git a/contracts/events/src/tests/admin.rs b/contracts/events/src/tests/admin.rs index 048f866..87f1c1a 100644 --- a/contracts/events/src/tests/admin.rs +++ b/contracts/events/src/tests/admin.rs @@ -208,6 +208,7 @@ fn migrate_rewrites_legacy_percentages_as_prize_floors() { .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 @@ -273,6 +274,7 @@ fn migrate_leaves_legacy_submissions_addressable_without_scanning_applicants() { ); }); + ctx.client.migrate_events(&8_u32); ctx.client.migrate(); // The record rewrite is the load-bearing part and must have happened. @@ -377,6 +379,7 @@ fn migrate_rewrites_zero_amount_grant_winners() { ); }); + ctx.client.migrate_events(&8_u32); ctx.client.migrate(); let rows = ctx.client.get_winners(&event_id); @@ -388,27 +391,59 @@ fn migrate_rewrites_zero_amount_grant_winners() { } #[test] -fn migrate_refuses_to_stamp_when_the_id_range_exceeds_the_cap() { +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 + 1_000)); + .set(&DataKey::NextEventId, &(base + 20)); }); assert!( ctx.client.try_migrate().is_err(), - "a range beyond the cap must abort rather than half-migrate" + "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(), - None, - "nothing may be stamped when work would be left undone" + 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 @@ -445,6 +480,7 @@ fn migrate_skips_rows_already_in_the_current_layout() { .set(&DataKey::NextEventId, &(event_id + 1)); }); + ctx.client.migrate_events(&8_u32); ctx.client.migrate(); let after = ctx.client.get_event(&event_id); @@ -490,6 +526,7 @@ fn migrate_runs_regardless_of_how_the_version_was_spelled() { 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); @@ -543,6 +580,7 @@ fn folding_a_legacy_row_adds_to_the_applicants_existing_slots() { #[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(), @@ -554,6 +592,7 @@ fn migrate_is_a_no_op_on_a_fresh_deployment() { 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(), diff --git a/contracts/events/src/types.rs b/contracts/events/src/types.rs index b421fed..936ccff 100644 --- a/contracts/events/src/types.rs +++ b/contracts/events/src/types.rs @@ -230,6 +230,11 @@ pub enum DataKey { // 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, } // ============================================================ From cd0054c368630ac8b3ec971bf3cd56f68de788c8 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Tue, 18 Aug 2026 03:23:06 +0100 Subject: [PATCH 8/8] chore(events,profile): zero the upgrade timelock for the 1.7.0 rollout H6 (audit 2026-06) mandated 17_280 ledgers, about a day, between propose_upgrade and apply_upgrade. Set to 0 on both contracts so the 1.7.0 rollout can iterate without a day's wait per attempt. Defensible now and not later: mainnet holds no escrow at all today, both events Completed with remaining_escrow 0, so the control currently protects nothing. That stops being true the moment a campaign funds. With no window a compromised 2-of-3 admin can propose and apply a wasm swap in a single sequence, and cancel_pending_upgrade never gets a chance to fire. The cfg split is retained rather than collapsed so restoring is a single-value edit per contract. Both timelock tests now assert the zero-window behaviour and carry the restore instruction, so flipping the constant back fails them loudly rather than passing silently. Tracked in BACKLOG under P1: restore before the first funded mainnet campaign. The pending third-party audit will flag this if it is still 0. --- BACKLOG.md | 2 ++ contracts/events/src/admin.rs | 8 +++++++- contracts/events/src/tests/admin.rs | 23 +++++++++++++---------- contracts/profile/src/admin.rs | 8 +++++++- contracts/profile/src/tests/admin.rs | 20 +++++++++++--------- 5 files changed, 40 insertions(+), 21 deletions(-) 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 7b43d37..033b408 100644 --- a/contracts/events/src/admin.rs +++ b/contracts/events/src/admin.rs @@ -34,8 +34,14 @@ 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; diff --git a/contracts/events/src/tests/admin.rs b/contracts/events/src/tests/admin.rs index 87f1c1a..7745ac8 100644 --- a/contracts/events/src/tests/admin.rs +++ b/contracts/events/src/tests/admin.rs @@ -10,7 +10,8 @@ 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] @@ -85,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.7.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] 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]