diff --git a/contracts/events/src/errors.rs b/contracts/events/src/errors.rs index 69b81d6..3346be7 100644 --- a/contracts/events/src/errors.rs +++ b/contracts/events/src/errors.rs @@ -66,4 +66,7 @@ pub enum Error { Paused = 70, ProfileCallFailed = 80, + + // Prize claims (pull-model for Single-release events) + PrizeAlreadyClaimed = 91, } diff --git a/contracts/events/src/event_ops.rs b/contracts/events/src/event_ops.rs index 5047a17..9ecc989 100644 --- a/contracts/events/src/event_ops.rs +++ b/contracts/events/src/event_ops.rs @@ -18,7 +18,15 @@ use crate::types::{ }; const MAX_TITLE_LEN: u32 = 120; -const MAX_WINNERS_PER_SELECT: u32 = 50; + +// Max winners per select_winners call. With the pull-model (2026-07), +// select_winners only records winners (1 storage write each) and defers +// all token transfers + cross-contract calls to per-winner claim_prize +// transactions. However, claim_prize still performs a linear scan of the +// winner list to locate the anchor row, and get_winners loads all rows +// into a Vec. Without keyed winner lookup or batched recording, 500 is a +// safe ceiling compatible with the current table-backed implementation. +pub const MAX_WINNERS_PER_SELECT: u32 = 500; pub const MAX_APPLICANTS_PER_EVENT: u32 = 5_000; pub const MAX_CONTRIBUTORS_PER_EVENT: u32 = 5_000; @@ -144,8 +152,10 @@ pub fn create_event(env: &Env, params: CreateEventParams, op_id: BytesN<32>) -> amount: 0, milestone: None, paid_at: None, + reputation_bump: None, }, ); + storage::set_grant_recipient_idx(env, id, ¶ms.owner, 0); } evt::EventCreated { @@ -254,6 +264,21 @@ pub fn add_funds( Ok(()) } +/// True if the Single-release event has at least one winner anchor whose +/// prize has not yet been claimed (`paid_at` is `None`). The manager must +/// not be able to cancel and drain the escrow before those winners claim. +fn has_unclaimed_single_winner(env: &Env, event_id: u64) -> bool { + let count = storage::winner_count(env, event_id); + for idx in 0..count { + if let Some(w) = storage::winner_at(env, event_id, idx) { + if w.milestone.is_none() && w.paid_at.is_none() { + return true; + } + } + } + false +} + // ============================================================ // PAGED CANCEL // ============================================================ @@ -269,6 +294,21 @@ pub fn start_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<(), E return Err(Error::CancellationAlreadyStarted); } + // Prevent cancel after winners selected for Single-release (pull-model). + // The manager must not be able to drain escrow before winners claim, + // unless the event deadline has passed (liveness escape hatch for + // winners who cannot authenticate). + if matches!(event.release_kind, ReleaseKind::Single) + && has_unclaimed_single_winner(env, event_id) + { + let deadline_passed = event + .deadline + .map_or(false, |d| env.ledger().timestamp() > d); + if !deadline_passed { + return Err(Error::WinnersAlreadySelected); + } + } + resolve_manager(env, event_id, &event.owner).require_auth(); let remaining = event.remaining_escrow; @@ -535,7 +575,7 @@ pub fn select_winners( admin::require_not_paused(env)?; idempotency::require_unseen(env, &op_id)?; - let mut event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?; + let event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?; if !matches!(event.status, EventStatus::Active) { return Err(Error::EventNotActive); } @@ -579,12 +619,10 @@ pub fn select_winners( seen_positions.push_back(spec.position); } - let profile = profile_client::client(env); - let now = env.ledger().timestamp(); - let reason_win = Symbol::new(env, "win"); - match event.release_kind { ReleaseKind::Single => { + // Pull-model (2026-07): record winners with pre-computed amounts + // but defer all token releases + profile calls to claim_prize. let escrow_at_select = event.remaining_escrow; let mut total_owed: i128 = 0; @@ -603,33 +641,14 @@ pub fn select_winners( return Err(Error::InsufficientEscrow); } - for (idx, spec) in winners.iter().enumerate() { - let sub_idx = idx as u8; + for (i, spec) in winners.iter().enumerate() { let percent = event .winner_distribution .get(spec.position) .ok_or(Error::InvalidDistribution)? as i128; let amount = escrow_at_select.saturating_mul(percent) / 100_i128; - escrow::release(env, &event.token, &spec.recipient, amount); - event.remaining_escrow = event.remaining_escrow.saturating_sub(amount); - - let bootstrap_op = - idempotency::derive_child_indexed(env, &op_id, tag::BOOTSTRAP, sub_idx); - profile.bootstrap(&spec.recipient, &bootstrap_op); - - let rep_op = idempotency::derive_child_indexed(env, &op_id, tag::BUMP_REP, sub_idx); - profile.bump_reputation( - &spec.recipient, - &spec.reputation_bump, - &reason_win, - &rep_op, - ); - - let earnings_op = - idempotency::derive_child_indexed(env, &op_id, tag::REGISTER_EARNINGS, sub_idx); - profile.register_earnings(&spec.recipient, &event.token, &amount, &earnings_op); - + let anchor_idx = existing_count + (i as u32); storage::append_winner( env, event_id, @@ -638,26 +657,22 @@ pub fn select_winners( position: spec.position, amount, milestone: None, - paid_at: Some(now), + paid_at: None, + reputation_bump: Some(spec.reputation_bump), }, ); - - evt::WinnerPaid { + storage::set_winner_index( + env, event_id, - recipient: spec.recipient.clone(), - position: spec.position, - amount, - milestone: None, - } - .publish(env); - } - - if event.remaining_escrow == 0 { - event.status = EventStatus::Completed; + &spec.recipient, + spec.position, + anchor_idx, + ); } } ReleaseKind::Multi(_) => { - for spec in winners.iter() { + for (i, spec) in winners.iter().enumerate() { + let anchor_idx = existing_count + (i as u32); storage::append_winner( env, event_id, @@ -667,8 +682,10 @@ pub fn select_winners( amount: 0, milestone: None, paid_at: None, + reputation_bump: Some(spec.reputation_bump), }, ); + storage::set_grant_recipient_idx(env, event_id, &spec.recipient, anchor_idx); } } } @@ -686,6 +703,113 @@ pub fn select_winners( Ok(()) } +// ============================================================ +// CLAIM PRIZE (pull-model for Single-release events) +// +// Each winner claims their individual prize in their own transaction, +// so there is no per-call winner ceiling. select_winners records winners +// with pre-computed amounts; this function releases the token, bumps +// reputation, and registers earnings for one winner per call. +// +// This mirrors claim_milestone but for ReleaseKind::Single (bounties, +// hackathons) instead of ReleaseKind::Multi (grants, crowdfunding). +// ============================================================ +pub fn claim_prize( + env: &Env, + event_id: u64, + recipient: Address, + position: u32, + op_id: BytesN<32>, +) -> Result<(), Error> { + admin::require_not_paused(env)?; + idempotency::require_unseen(env, &op_id)?; + + let mut event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?; + if !matches!(event.status, EventStatus::Active) { + return Err(Error::EventNotActive); + } + if !matches!(event.release_kind, ReleaseKind::Single) { + return Err(Error::InvalidReleaseKind); + } + if matches!(event.pillar, Pillar::Crowdfunding) { + return Err(Error::InvalidPillar); + } + + recipient.require_auth(); + + // Look up the anchor index stored at selection time (O(1) instead of + // a linear scan). Returns NoSubmissions if no winner matches or the + // prize has already been claimed (canonical guard: paid_at). + let anchor_idx = storage::get_winner_index(env, event_id, &recipient, position) + .ok_or(Error::NoSubmissions)?; + let w = storage::winner_at(env, event_id, anchor_idx).ok_or(Error::NoSubmissions)?; + if w.recipient != recipient || w.position != position || w.milestone.is_some() { + return Err(Error::NoSubmissions); + } + if w.paid_at.is_some() { + return Err(Error::PrizeAlreadyClaimed); + } + let amount = w.amount; + let reputation_bump = w.reputation_bump.unwrap_or(0); + + if amount <= 0 { + return Err(Error::InvalidDistribution); + } + if amount > event.remaining_escrow { + return Err(Error::InsufficientEscrow); + } + + // Release token from escrow — critical path, must succeed. + escrow::release(env, &event.token, &recipient, amount); + event.remaining_escrow = event.remaining_escrow.saturating_sub(amount); + + // Update the anchor row in-place with the paid timestamp instead of + // appending a duplicate row. This keeps winner_count == selected count. + storage::set_winner_at( + env, + event_id, + anchor_idx, + &Winner { + recipient: recipient.clone(), + position, + amount, + milestone: None, + paid_at: Some(env.ledger().timestamp()), + reputation_bump: Some(reputation_bump), + }, + ); + + if event.remaining_escrow == 0 { + event.status = EventStatus::Completed; + } + storage::set_event(env, event_id, &event); + + evt::PrizeClaimed { + event_id, + recipient: recipient.clone(), + position, + amount, + } + .publish(env); + + // Best-effort profile side effects — payout is already finalised so a + // profile-contract failure must not block the claim. + let profile = profile_client::client(env); + let reason_win = Symbol::new(env, "win"); + + let bootstrap_op = idempotency::derive_child(env, &op_id, tag::BOOTSTRAP); + let _ = profile.try_bootstrap(&recipient, &bootstrap_op); + + let rep_op = idempotency::derive_child(env, &op_id, tag::BUMP_REP); + let _ = profile.try_bump_reputation(&recipient, &reputation_bump, &reason_win, &rep_op); + + let earnings_op = idempotency::derive_child(env, &op_id, tag::REGISTER_EARNINGS); + let _ = profile.try_register_earnings(&recipient, &event.token, &amount, &earnings_op); + + idempotency::mark_seen(env, &op_id); + Ok(()) +} + // ============================================================ // READS // ============================================================ diff --git a/contracts/events/src/events.rs b/contracts/events/src/events.rs index 4c675dc..24c2248 100644 --- a/contracts/events/src/events.rs +++ b/contracts/events/src/events.rs @@ -82,6 +82,14 @@ pub struct WinnerPaid { pub milestone: Option, } +#[contractevent] +pub struct PrizeClaimed { + pub event_id: u64, + pub recipient: Address, + pub position: u32, + pub amount: i128, +} + #[contractevent] pub struct MilestoneClaimed { pub event_id: u64, diff --git a/contracts/events/src/grant.rs b/contracts/events/src/grant.rs index e33b893..7caed92 100644 --- a/contracts/events/src/grant.rs +++ b/contracts/events/src/grant.rs @@ -24,7 +24,6 @@ pub fn claim_milestone( event_id: u64, recipient: Address, milestone: u32, - reputation_bump: u32, op_id: BytesN<32>, ) -> Result<(), Error> { admin::require_not_paused(env)?; @@ -55,29 +54,19 @@ pub fn claim_milestone( return Err(Error::MilestoneAlreadyClaimed); } - let count = storage::winner_count(env, event_id); - let mut winner_position: Option = None; - let mut already_claimed_for_recipient: u32 = 0; - let mut already_paid_to_recipient: i128 = 0; - for idx in 0..count { - let w = match storage::winner_at(env, event_id, idx) { - Some(w) => w, - None => continue, - }; - if w.recipient != recipient { - continue; - } - match w.milestone { - None => winner_position = Some(w.position), - Some(_) => { - already_claimed_for_recipient = already_claimed_for_recipient.saturating_add(1); - already_paid_to_recipient = already_paid_to_recipient.saturating_add(w.amount); - } - } - } - let position = winner_position.ok_or(Error::NoSubmissions)?; - let is_crowdfunding = matches!(event.pillar, Pillar::Crowdfunding); + + let (position, reputation_bump, already_claimed_for_recipient) = + resolve_recipient(env, event_id, &recipient)?; + let already_paid_to_recipient = if is_crowdfunding { + 0 + } else { + let percent = event.winner_distribution.get(position).unwrap_or(0) as i128; + let total_share = event.total_budget.saturating_mul(percent) / 100_i128; + let per_milestone_floored = total_share / (total_milestones as i128); + (already_claimed_for_recipient as i128).saturating_mul(per_milestone_floored) + }; + let amount: i128 = if is_crowdfunding { let claimed_count = storage::get_crowdfunding_milestones_claimed(env, event_id); let remaining_milestones = total_milestones.saturating_sub(claimed_count); @@ -121,23 +110,12 @@ pub fn claim_milestone( } event.remaining_escrow = event.remaining_escrow.saturating_sub(amount); storage::mark_milestone_claimed(env, event_id, &recipient, milestone); + storage::increment_grant_recipient_claim_count(env, event_id, &recipient); if is_crowdfunding { let claimed = storage::get_crowdfunding_milestones_claimed(env, event_id); storage::set_crowdfunding_milestones_claimed(env, event_id, claimed.saturating_add(1)); } - let profile = profile_client::client(env); - let reason = Symbol::new(env, "milestone"); - - let bootstrap_op = idempotency::derive_child(env, &op_id, tag::BOOTSTRAP); - profile.bootstrap(&recipient, &bootstrap_op); - - let rep_op = idempotency::derive_child(env, &op_id, tag::BUMP_REP); - profile.bump_reputation(&recipient, &reputation_bump, &reason, &rep_op); - - let earnings_op = idempotency::derive_child(env, &op_id, tag::REGISTER_EARNINGS); - profile.register_earnings(&recipient, &event.token, &amount, &earnings_op); - storage::append_winner( env, event_id, @@ -147,6 +125,7 @@ pub fn claim_milestone( amount, milestone: Some(milestone), paid_at: Some(env.ledger().timestamp()), + reputation_bump: Some(reputation_bump), }, ); @@ -157,12 +136,85 @@ pub fn claim_milestone( evt::MilestoneClaimed { event_id, - recipient, + recipient: recipient.clone(), milestone, amount, } .publish(env); + // Best-effort profile side effects — payout is already finalised so a + // profile-contract failure must not block the claim. + let profile = profile_client::client(env); + let reason = Symbol::new(env, "milestone"); + + let bootstrap_op = idempotency::derive_child(env, &op_id, tag::BOOTSTRAP); + let _ = profile.try_bootstrap(&recipient, &bootstrap_op); + + let rep_op = idempotency::derive_child(env, &op_id, tag::BUMP_REP); + let _ = profile.try_bump_reputation(&recipient, &reputation_bump, &reason, &rep_op); + + let earnings_op = idempotency::derive_child(env, &op_id, tag::REGISTER_EARNINGS); + let _ = profile.try_register_earnings(&recipient, &event.token, &amount, &earnings_op); + idempotency::mark_seen(env, &op_id); Ok(()) } + +/// Resolve recipient anchor (position + reputation_bump) and milestone claim count. +/// +/// Tries the O(1) index path first. If the indexes don't exist (pre-upgrade events), +/// falls back to a linear scan over winner rows and persists the indexes so future +/// calls use the fast path. +fn resolve_recipient( + env: &Env, + event_id: u64, + recipient: &Address, +) -> Result<(u32, u32, u32), Error> { + // Fast path: indexes already exist (post-upgrade events). + if let Some(anchor_idx) = storage::get_grant_recipient_idx(env, event_id, recipient) { + let anchor = storage::winner_at(env, event_id, anchor_idx).ok_or(Error::NoSubmissions)?; + if anchor.recipient != *recipient || anchor.milestone.is_some() { + return Err(Error::NoSubmissions); + } + let claim_count = storage::get_grant_recipient_claim_count(env, event_id, recipient); + return Ok(( + anchor.position, + anchor.reputation_bump.unwrap_or(0), + claim_count, + )); + } + + // Legacy fallback: scan winner rows, which works before the indexes existed. + let count = storage::winner_count(env, event_id); + let mut anchor_idx = None; + let mut position = None; + let mut reputation_bump = 0; + let mut claim_count = 0u32; + for idx in 0..count { + let w = match storage::winner_at(env, event_id, idx) { + Some(w) => w, + None => continue, + }; + if w.recipient != *recipient { + continue; + } + match w.milestone { + None => { + anchor_idx = Some(idx); + position = Some(w.position); + reputation_bump = w.reputation_bump.unwrap_or(0); + } + Some(_) => { + claim_count = claim_count.saturating_add(1); + } + } + } + let idx = anchor_idx.ok_or(Error::NoSubmissions)?; + let pos = position.ok_or(Error::NoSubmissions)?; + + // Persist indexes for future O(1) lookups. + storage::set_grant_recipient_idx(env, event_id, recipient, idx); + storage::set_grant_recipient_claim_count(env, event_id, recipient, claim_count); + + Ok((pos, reputation_bump, claim_count)) +} diff --git a/contracts/events/src/lib.rs b/contracts/events/src/lib.rs index 8f04f9a..7099eb4 100644 --- a/contracts/events/src/lib.rs +++ b/contracts/events/src/lib.rs @@ -227,15 +227,24 @@ impl EventsContract { event_ops::get_manager(&env, event_id) } + pub fn claim_prize( + env: Env, + event_id: u64, + recipient: Address, + position: u32, + op_id: BytesN<32>, + ) -> Result<(), Error> { + event_ops::claim_prize(&env, event_id, recipient, position, op_id) + } + pub fn claim_milestone( env: Env, event_id: u64, recipient: Address, milestone: u32, - reputation_bump: u32, op_id: BytesN<32>, ) -> Result<(), Error> { - grant::claim_milestone(&env, event_id, recipient, milestone, reputation_bump, op_id) + grant::claim_milestone(&env, event_id, recipient, milestone, op_id) } // ============================================================ diff --git a/contracts/events/src/storage.rs b/contracts/events/src/storage.rs index 385a25d..41770bc 100644 --- a/contracts/events/src/storage.rs +++ b/contracts/events/src/storage.rs @@ -433,6 +433,12 @@ pub fn winner_at(env: &Env, id: u64, idx: u32) -> Option { w } +pub fn set_winner_at(env: &Env, id: u64, idx: u32, w: &Winner) { + let key = DataKey::EventWinnerAt(id, idx); + env.storage().persistent().set(&key, w); + touch_event_persistent(env, &key); +} + pub fn append_winner(env: &Env, id: u64, w: &Winner) { let cur = winner_count(env, id); let at_key = DataKey::EventWinnerAt(id, cur); @@ -445,6 +451,60 @@ pub fn append_winner(env: &Env, id: u64, w: &Winner) { touch_event_persistent(env, &count_key); } +pub fn set_winner_index(env: &Env, id: u64, recipient: &Address, position: u32, idx: u32) { + let key = DataKey::WinnerIndex(id, recipient.clone(), position); + env.storage().persistent().set(&key, &idx); + touch_event_persistent(env, &key); +} + +pub fn get_winner_index(env: &Env, id: u64, recipient: &Address, position: u32) -> Option { + let key = DataKey::WinnerIndex(id, recipient.clone(), position); + let idx: Option = env.storage().persistent().get(&key); + if idx.is_some() { + touch_event_persistent(env, &key); + } + idx +} + +pub fn get_grant_recipient_idx(env: &Env, id: u64, recipient: &Address) -> Option { + let key = DataKey::GrantRecipientIdx(id, recipient.clone()); + let idx: Option = env.storage().persistent().get(&key); + if idx.is_some() { + touch_event_persistent(env, &key); + } + idx +} + +pub fn set_grant_recipient_idx(env: &Env, id: u64, recipient: &Address, idx: u32) { + let key = DataKey::GrantRecipientIdx(id, recipient.clone()); + env.storage().persistent().set(&key, &idx); + touch_event_persistent(env, &key); +} + +pub fn get_grant_recipient_claim_count(env: &Env, id: u64, recipient: &Address) -> u32 { + let key = DataKey::GrantRecipientClaimCount(id, recipient.clone()); + let count: Option = env.storage().persistent().get(&key); + if count.is_some() { + touch_event_persistent(env, &key); + } + count.unwrap_or(0) +} + +pub fn set_grant_recipient_claim_count(env: &Env, id: u64, recipient: &Address, count: u32) { + let key = DataKey::GrantRecipientClaimCount(id, recipient.clone()); + env.storage().persistent().set(&key, &count); + touch_event_persistent(env, &key); +} + +pub fn increment_grant_recipient_claim_count(env: &Env, id: u64, recipient: &Address) { + let key = DataKey::GrantRecipientClaimCount(id, recipient.clone()); + let count: u32 = env.storage().persistent().get(&key).unwrap_or(0); + env.storage() + .persistent() + .set(&key, &count.saturating_add(1)); + touch_event_persistent(env, &key); +} + pub fn winners_snapshot(env: &Env, id: u64, max: u32) -> Vec { let count = winner_count(env, id); let upper = if count < max { count } else { max }; diff --git a/contracts/events/src/tests/bounty_pillar.rs b/contracts/events/src/tests/bounty_pillar.rs index a29332e..ab3e9e4 100644 --- a/contracts/events/src/tests/bounty_pillar.rs +++ b/contracts/events/src/tests/bounty_pillar.rs @@ -265,6 +265,11 @@ fn apply_on_completed_event_reverts() { let op_select = BytesN::random(&ctx.env); ctx.events.select_winners(&bounty_id, &winners, &op_select); + // Pull-model: claim prize to drain escrow and complete the event. + let claim_op = BytesN::random(&ctx.env); + ctx.events + .claim_prize(&bounty_id, &ctx.applicant, &1_u32, &claim_op); + let event = ctx.events.get_event(&bounty_id); assert_eq!(event.status, EventStatus::Completed); diff --git a/contracts/events/src/tests/cancel_refund.rs b/contracts/events/src/tests/cancel_refund.rs index e827260..d66fa38 100644 --- a/contracts/events/src/tests/cancel_refund.rs +++ b/contracts/events/src/tests/cancel_refund.rs @@ -438,6 +438,10 @@ fn cancel_prorata_splits_remaining_across_partners_no_owner_residual() { ctx.events .select_winners(&id, &winners, &BytesN::random(&ctx.env)); + // Pull-model: claim prize to drain escrow (60% of 2000 = 1200). + ctx.events + .claim_prize(&id, &w, &1_u32, &BytesN::random(&ctx.env)); + let p1_before = token.balance(&p1); let p2_before = token.balance(&p2); let owner_before = token.balance(&ctx.owner); diff --git a/contracts/events/src/tests/contributions.rs b/contracts/events/src/tests/contributions.rs index cb46fe8..4d93013 100644 --- a/contracts/events/src/tests/contributions.rs +++ b/contracts/events/src/tests/contributions.rs @@ -368,6 +368,9 @@ fn cancel_at_boundary_pays_partners_full_no_owner_residual() { let op_select = BytesN::random(&ctx.env); ctx.events.select_winners(&id, &winners, &op_select); + ctx.events + .claim_prize(&id, &winner_a, &1_u32, &BytesN::random(&ctx.env)); + let after_select = ctx.events.get_event(&id); assert_eq!(after_select.status, EventStatus::Active); assert_eq!(after_select.remaining_escrow, 1_000_0000000_i128); diff --git a/contracts/events/src/tests/cross_contract.rs b/contracts/events/src/tests/cross_contract.rs index 6ada2a5..91abc05 100644 --- a/contracts/events/src/tests/cross_contract.rs +++ b/contracts/events/src/tests/cross_contract.rs @@ -122,6 +122,10 @@ fn select_winners_pays_recipient_and_bumps_profile() { let op_select = BytesN::random(&ctx.env); ctx.events.select_winners(&bounty_id, &winners, &op_select); + let claim_op = BytesN::random(&ctx.env); + ctx.events + .claim_prize(&bounty_id, &ctx.applicant, &1_u32, &claim_op); + let token = token::Client::new(&ctx.env, &ctx.token_addr); assert_eq!(token.balance(&ctx.applicant), TOTAL_BUDGET); assert_eq!(token.balance(&ctx.fee_account), FEE_AMOUNT); @@ -138,12 +142,12 @@ fn select_winners_pays_recipient_and_bumps_profile() { let winner_list = ctx.events.get_winners(&bounty_id); assert_eq!(winner_list.len(), 1); - let recorded = winner_list.get(0).unwrap(); - assert_eq!(recorded.recipient, ctx.applicant); - assert_eq!(recorded.position, 1); - assert_eq!(recorded.amount, TOTAL_BUDGET); - assert_eq!(recorded.milestone, None); - assert!(recorded.paid_at.is_some()); + let row = winner_list.get(0).unwrap(); + assert_eq!(row.recipient, ctx.applicant); + assert_eq!(row.position, 1); + assert_eq!(row.amount, TOTAL_BUDGET); + assert_eq!(row.milestone, None); + assert!(row.paid_at.is_some()); } #[test] @@ -251,6 +255,12 @@ fn select_winners_handles_multi_recipient_distribution() { let op_select = BytesN::random(&ctx.env); ctx.events.select_winners(&bounty_id, &winners, &op_select); + // Pull-model: each winner claims their prize. + ctx.events + .claim_prize(&bounty_id, &winner_a, &1_u32, &BytesN::random(&ctx.env)); + ctx.events + .claim_prize(&bounty_id, &winner_b, &2_u32, &BytesN::random(&ctx.env)); + let token = token::Client::new(&ctx.env, &ctx.token_addr); let amount_a = TOTAL_BUDGET * 60 / 100; let amount_b = TOTAL_BUDGET * 40 / 100; @@ -357,6 +367,10 @@ fn cancel_after_select_winners_refunds_only_remaining() { let op_select = BytesN::random(&ctx.env); ctx.events.select_winners(&bounty_id, &winners, &op_select); + let claim_op = BytesN::random(&ctx.env); + ctx.events + .claim_prize(&bounty_id, &winner_a, &1_u32, &claim_op); + let token = token::Client::new(&ctx.env, &ctx.token_addr); let owner_before = token.balance(&ctx.owner); @@ -400,7 +414,7 @@ fn select_grant_winner(ctx: &Ctx, grant_id: u64, recipient: &Address) { WinnerSpec { recipient: recipient.clone(), position: 1, - reputation_bump: 0, + reputation_bump: 5, }, ]; let op_select = BytesN::random(&ctx.env); @@ -419,7 +433,7 @@ fn claim_milestone_pays_per_milestone_amount() { let op_claim = BytesN::random(&ctx.env); ctx.events - .claim_milestone(&grant_id, &recipient, &0_u32, &5_u32, &op_claim); + .claim_milestone(&grant_id, &recipient, &0_u32, &op_claim); let per_milestone = TOTAL_BUDGET / 4; assert_eq!(token.balance(&recipient) - recipient_before, per_milestone); @@ -444,17 +458,17 @@ fn claim_milestone_idempotent_per_recipient_and_milestone() { let op1 = BytesN::random(&ctx.env); ctx.events - .claim_milestone(&grant_id, &recipient, &0_u32, &5_u32, &op1); + .claim_milestone(&grant_id, &recipient, &0_u32, &op1); let op2 = BytesN::random(&ctx.env); let res = ctx .events - .try_claim_milestone(&grant_id, &recipient, &0_u32, &5_u32, &op2); + .try_claim_milestone(&grant_id, &recipient, &0_u32, &op2); assert!(res.is_err(), "same milestone twice should revert"); let op3 = BytesN::random(&ctx.env); ctx.events - .claim_milestone(&grant_id, &recipient, &1_u32, &5_u32, &op3); + .claim_milestone(&grant_id, &recipient, &1_u32, &op3); } #[test] @@ -467,7 +481,7 @@ fn claim_milestone_invalid_milestone_index_reverts() { let op = BytesN::random(&ctx.env); let res = ctx .events - .try_claim_milestone(&grant_id, &recipient, &4_u32, &5_u32, &op); + .try_claim_milestone(&grant_id, &recipient, &4_u32, &op); assert!(res.is_err(), "out-of-range milestone should revert"); } @@ -491,7 +505,7 @@ fn claim_milestone_rejects_non_grant_events() { let op = BytesN::random(&ctx.env); let res = ctx .events - .try_claim_milestone(&bounty_id2, &ctx.applicant, &0_u32, &5_u32, &op); + .try_claim_milestone(&bounty_id2, &ctx.applicant, &0_u32, &op); assert!(res.is_err(), "claim on Single-release event should revert"); } @@ -504,14 +518,14 @@ fn claim_milestone_final_milestone_marks_event_completed() { let op_a = BytesN::random(&ctx.env); ctx.events - .claim_milestone(&grant_id, &recipient, &0_u32, &5_u32, &op_a); + .claim_milestone(&grant_id, &recipient, &0_u32, &op_a); let mid = ctx.events.get_event(&grant_id); assert_eq!(mid.status, EventStatus::Active); let op_b = BytesN::random(&ctx.env); ctx.events - .claim_milestone(&grant_id, &recipient, &1_u32, &5_u32, &op_b); + .claim_milestone(&grant_id, &recipient, &1_u32, &op_b); let after = ctx.events.get_event(&grant_id); assert_eq!(after.status, EventStatus::Completed); @@ -863,30 +877,15 @@ fn grant_last_milestone_sweeps_rounding_residue() { let before = token.balance(&recipient); let floored = TOTAL_BUDGET / 3; - ctx.events.claim_milestone( - &grant_id, - &recipient, - &0_u32, - &5_u32, - &BytesN::random(&ctx.env), - ); - ctx.events.claim_milestone( - &grant_id, - &recipient, - &1_u32, - &5_u32, - &BytesN::random(&ctx.env), - ); + ctx.events + .claim_milestone(&grant_id, &recipient, &0_u32, &BytesN::random(&ctx.env)); + ctx.events + .claim_milestone(&grant_id, &recipient, &1_u32, &BytesN::random(&ctx.env)); let after_two = token.balance(&recipient); assert_eq!(after_two - before, floored * 2); - ctx.events.claim_milestone( - &grant_id, - &recipient, - &2_u32, - &5_u32, - &BytesN::random(&ctx.env), - ); + ctx.events + .claim_milestone(&grant_id, &recipient, &2_u32, &BytesN::random(&ctx.env)); let after_all = token.balance(&recipient); assert_eq!( after_all - before, @@ -931,6 +930,14 @@ fn select_winners_pays_against_remaining_escrow_including_top_ups() { let op_select = BytesN::random(&ctx.env); ctx.events.select_winners(&bounty_id, &winners, &op_select); + // Pull-model: claim prize. + ctx.events.claim_prize( + &bounty_id, + &ctx.applicant, + &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); @@ -1006,6 +1013,13 @@ fn manager_override_can_select_winners() { let op_select = BytesN::random(&ctx.env); ctx.events.select_winners(&bounty_id, &winners, &op_select); + ctx.events.claim_prize( + &bounty_id, + &ctx.applicant, + &1_u32, + &BytesN::random(&ctx.env), + ); + let event = ctx.events.get_event(&bounty_id); assert_eq!(event.status, EventStatus::Completed); } diff --git a/contracts/events/src/tests/crowdfunding.rs b/contracts/events/src/tests/crowdfunding.rs index 7c281a3..d276029 100644 --- a/contracts/events/src/tests/crowdfunding.rs +++ b/contracts/events/src/tests/crowdfunding.rs @@ -257,14 +257,14 @@ fn claim_milestone_splits_evenly_and_charges_fee_at_release() { let op_m0 = BytesN::random(&ctx.env); ctx.events - .claim_milestone(&id, &ctx.builder, &0_u32, &0_u32, &op_m0); + .claim_milestone(&id, &ctx.builder, &0_u32, &op_m0); assert_eq!(token.balance(&ctx.builder), 292_5000000_i128); assert_eq!(token.balance(&ctx.fee_account) - fee_before, 7_5000000_i128); assert_eq!(ctx.events.get_event(&id).remaining_escrow, 600_0000000_i128); let op_m1 = BytesN::random(&ctx.env); ctx.events - .claim_milestone(&id, &ctx.builder, &1_u32, &0_u32, &op_m1); + .claim_milestone(&id, &ctx.builder, &1_u32, &op_m1); assert_eq!(token.balance(&ctx.builder), 585_0000000_i128); assert_eq!( token.balance(&ctx.fee_account) - fee_before, @@ -274,7 +274,7 @@ fn claim_milestone_splits_evenly_and_charges_fee_at_release() { let op_m2 = BytesN::random(&ctx.env); ctx.events - .claim_milestone(&id, &ctx.builder, &2_u32, &0_u32, &op_m2); + .claim_milestone(&id, &ctx.builder, &2_u32, &op_m2); assert_eq!(token.balance(&ctx.builder), 877_5000000_i128); assert_eq!( token.balance(&ctx.fee_account) - fee_before, @@ -298,7 +298,7 @@ fn claim_milestone_last_drains_dust_with_fee() { for m in 0u32..3 { let op = BytesN::random(&ctx.env); - ctx.events.claim_milestone(&id, &ctx.builder, &m, &0, &op); + ctx.events.claim_milestone(&id, &ctx.builder, &m, &op); } let builder_delta = token.balance(&ctx.builder) - builder_before; @@ -322,12 +322,11 @@ fn claim_milestone_replay_reverts() { back(&ctx, id, &backer, 600_0000000_i128); let op = BytesN::random(&ctx.env); - ctx.events - .claim_milestone(&id, &ctx.builder, &0_u32, &0, &op); + ctx.events.claim_milestone(&id, &ctx.builder, &0_u32, &op); let res = ctx .events - .try_claim_milestone(&id, &ctx.builder, &0_u32, &0, &op); + .try_claim_milestone(&id, &ctx.builder, &0_u32, &op); assert!(res.is_err()); } @@ -341,7 +340,7 @@ fn claim_milestone_out_of_range_reverts() { let op = BytesN::random(&ctx.env); let res = ctx .events - .try_claim_milestone(&id, &ctx.builder, &2_u32, &0, &op); + .try_claim_milestone(&id, &ctx.builder, &2_u32, &op); assert!(res.is_err()); } @@ -352,7 +351,7 @@ fn claim_milestone_with_empty_escrow_reverts() { let op = BytesN::random(&ctx.env); let res = ctx .events - .try_claim_milestone(&id, &ctx.builder, &0_u32, &0, &op); + .try_claim_milestone(&id, &ctx.builder, &0_u32, &op); assert!(res.is_err()); } @@ -385,7 +384,7 @@ fn backer_pays_exactly_pledge_and_creator_bears_fee() { let claim = BytesN::random(&ctx.env); ctx.events - .claim_milestone(&id, &ctx.builder, &0_u32, &0, &claim); + .claim_milestone(&id, &ctx.builder, &0_u32, &claim); let fee = pledge * FEE_BPS as i128 / 10_000_i128; // 2.5 USDC assert_eq!( @@ -492,7 +491,7 @@ fn cancel_after_partial_claim_pro_rates_remaining() { let op_m0 = BytesN::random(&ctx.env); ctx.events - .claim_milestone(&id, &ctx.builder, &0_u32, &0, &op_m0); + .claim_milestone(&id, &ctx.builder, &0_u32, &op_m0); let token = token::Client::new(&ctx.env, &ctx.token_addr); let p1_before = token.balance(&p1); @@ -520,8 +519,7 @@ fn crowdfunding_claim_milestone_requires_admin_auth() { back(&ctx, id, &p, 200_0000000_i128); let op = BytesN::random(&ctx.env); - ctx.events - .claim_milestone(&id, &ctx.builder, &0_u32, &0, &op); + ctx.events.claim_milestone(&id, &ctx.builder, &0_u32, &op); let auths = ctx.env.auths(); let admin_required = auths.iter().any(|(addr, _)| *addr == ctx.events_admin); diff --git a/contracts/events/src/tests/escrow_fee_math.rs b/contracts/events/src/tests/escrow_fee_math.rs index 750c022..e163e1d 100644 --- a/contracts/events/src/tests/escrow_fee_math.rs +++ b/contracts/events/src/tests/escrow_fee_math.rs @@ -380,6 +380,8 @@ fn single_release_pays_full_escrow_for_100_percent() { ]; let op = BytesN::random(&ctx.env); ctx.events.select_winners(&id, &winners, &op); + ctx.events + .claim_prize(&id, &winner, &1_u32, &BytesN::random(&ctx.env)); let token = token::Client::new(&ctx.env, &ctx.token_addr); assert_eq!(token.balance(&winner), TOTAL_BUDGET); @@ -421,6 +423,12 @@ fn multi_position_split_pays_correct_amounts() { ]; let op = BytesN::random(&ctx.env); ctx.events.select_winners(&id, &winners, &op); + ctx.events + .claim_prize(&id, &w1, &1_u32, &BytesN::random(&ctx.env)); + ctx.events + .claim_prize(&id, &w2, &2_u32, &BytesN::random(&ctx.env)); + ctx.events + .claim_prize(&id, &w3, &3_u32, &BytesN::random(&ctx.env)); let token = token::Client::new(&ctx.env, &ctx.token_addr); let escrow = TOTAL_BUDGET; // all positions filled at create time @@ -465,6 +473,12 @@ fn three_way_33_33_34_split_rounding() { ]; let op = BytesN::random(&ctx.env); ctx.events.select_winners(&id, &winners, &op); + ctx.events + .claim_prize(&id, &w1, &1_u32, &BytesN::random(&ctx.env)); + ctx.events + .claim_prize(&id, &w2, &2_u32, &BytesN::random(&ctx.env)); + ctx.events + .claim_prize(&id, &w3, &3_u32, &BytesN::random(&ctx.env)); let token = token::Client::new(&ctx.env, &ctx.token_addr); let escrow = TOTAL_BUDGET; @@ -496,6 +510,8 @@ fn partial_position_fill_leaves_residual_escrow() { ]; let op = BytesN::random(&ctx.env); ctx.events.select_winners(&id, &winners, &op); + ctx.events + .claim_prize(&id, &w1, &1_u32, &BytesN::random(&ctx.env)); let token = token::Client::new(&ctx.env, &ctx.token_addr); assert_eq!(token.balance(&w1), TOTAL_BUDGET * 60 / 100); @@ -538,6 +554,8 @@ fn partner_funds_grow_winner_payout() { ]; let op = BytesN::random(&ctx.env); ctx.events.select_winners(&id, &winners, &op); + ctx.events + .claim_prize(&id, &winner, &1_u32, &BytesN::random(&ctx.env)); let token = token::Client::new(&ctx.env, &ctx.token_addr); assert_eq!(token.balance(&winner), escrow_at_select); @@ -570,15 +588,15 @@ fn grant_milestone_pays_floored_per_milestone() { let per_milestone = TOTAL_BUDGET / milestones as i128; let op_m0 = BytesN::random(&ctx.env); - ctx.events.claim_milestone(&id, &recipient, &0, &50, &op_m0); + ctx.events.claim_milestone(&id, &recipient, &0, &op_m0); assert_eq!(token.balance(&recipient), per_milestone); let op_m1 = BytesN::random(&ctx.env); - ctx.events.claim_milestone(&id, &recipient, &1, &50, &op_m1); + ctx.events.claim_milestone(&id, &recipient, &1, &op_m1); assert_eq!(token.balance(&recipient), per_milestone * 2); let op_m2 = BytesN::random(&ctx.env); - ctx.events.claim_milestone(&id, &recipient, &2, &50, &op_m2); + ctx.events.claim_milestone(&id, &recipient, &2, &op_m2); assert_eq!( token.balance(&recipient), TOTAL_BUDGET, @@ -607,12 +625,12 @@ fn grant_milestone_double_claim_rejected() { ctx.events.select_winners(&id, &winners, &op_sel); let op_m0 = BytesN::random(&ctx.env); - ctx.events.claim_milestone(&id, &recipient, &0, &50, &op_m0); + ctx.events.claim_milestone(&id, &recipient, &0, &op_m0); let op_m0_dup = BytesN::random(&ctx.env); let res = ctx .events - .try_claim_milestone(&id, &recipient, &0, &50, &op_m0_dup); + .try_claim_milestone(&id, &recipient, &0, &op_m0_dup); assert!(res.is_err(), "double claim_milestone must revert"); } @@ -635,9 +653,7 @@ fn grant_milestone_out_of_range_rejected() { ctx.events.select_winners(&id, &winners, &op_sel); let op = BytesN::random(&ctx.env); - let res = ctx - .events - .try_claim_milestone(&id, &recipient, &2, &50, &op); + let res = ctx.events.try_claim_milestone(&id, &recipient, &2, &op); assert!(res.is_err(), "milestone >= total_milestones must revert"); } @@ -682,18 +698,18 @@ fn crowdfunding_dynamic_milestone_split() { let net_per_milestone = milestone_amount - milestone_fee; let op_m0 = BytesN::random(&ctx.env); - ctx.events.claim_milestone(&id, &ctx.owner, &0, &50, &op_m0); + ctx.events.claim_milestone(&id, &ctx.owner, &0, &op_m0); assert_eq!(token.balance(&ctx.owner) - owner_before, net_per_milestone); let op_m1 = BytesN::random(&ctx.env); - ctx.events.claim_milestone(&id, &ctx.owner, &1, &50, &op_m1); + ctx.events.claim_milestone(&id, &ctx.owner, &1, &op_m1); assert_eq!( token.balance(&ctx.owner) - owner_before, net_per_milestone * 2 ); let op_m2 = BytesN::random(&ctx.env); - ctx.events.claim_milestone(&id, &ctx.owner, &2, &50, &op_m2); + ctx.events.claim_milestone(&id, &ctx.owner, &2, &op_m2); assert_eq!( token.balance(&ctx.owner) - owner_before, net_per_milestone * 3 @@ -733,17 +749,17 @@ fn crowdfunding_dynamic_rounding_no_dust() { ctx.events.add_funds(&id, &backer, &raised, &op_fund); let op_m0 = BytesN::random(&ctx.env); - ctx.events.claim_milestone(&id, &ctx.owner, &0, &50, &op_m0); + ctx.events.claim_milestone(&id, &ctx.owner, &0, &op_m0); let event = ctx.events.get_event(&id); assert_eq!(event.remaining_escrow, 666_6666668); let op_m1 = BytesN::random(&ctx.env); - ctx.events.claim_milestone(&id, &ctx.owner, &1, &50, &op_m1); + ctx.events.claim_milestone(&id, &ctx.owner, &1, &op_m1); let event = ctx.events.get_event(&id); assert_eq!(event.remaining_escrow, 333_3333334); let op_m2 = BytesN::random(&ctx.env); - ctx.events.claim_milestone(&id, &ctx.owner, &2, &50, &op_m2); + ctx.events.claim_milestone(&id, &ctx.owner, &2, &op_m2); let event = ctx.events.get_event(&id); assert_eq!(event.remaining_escrow, 0); } @@ -1046,6 +1062,8 @@ fn fee_and_winner_balances_consistent() { ]; let op_sel = BytesN::random(&ctx.env); ctx.events.select_winners(&id, &winners, &op_sel); + ctx.events + .claim_prize(&id, &winner, &1_u32, &BytesN::random(&ctx.env)); assert_eq!(token.balance(&winner), escrow); assert_eq!(token.balance(&ctx.fee_account), create_fee + contrib_fee); diff --git a/contracts/events/src/tests/grant_pillar.rs b/contracts/events/src/tests/grant_pillar.rs index 40db2a0..89bea1e 100644 --- a/contracts/events/src/tests/grant_pillar.rs +++ b/contracts/events/src/tests/grant_pillar.rs @@ -95,7 +95,7 @@ fn select_winner(ctx: &Ctx, id: u64, recipient: &Address) { WinnerSpec { recipient: recipient.clone(), position: 1, - reputation_bump: 0 + reputation_bump: 10 }, ]; ctx.events @@ -176,7 +176,7 @@ fn claim_milestone_pays_fixed_per_milestone_amount() { let fee_before = token.balance(&ctx.fee_account); ctx.events - .claim_milestone(&id, &recipient, &0_u32, &5_u32, &BytesN::random(&ctx.env)); + .claim_milestone(&id, &recipient, &0_u32, &BytesN::random(&ctx.env)); let per_milestone = TOTAL_BUDGET / 4; assert_eq!(token.balance(&recipient) - before, per_milestone); @@ -201,13 +201,13 @@ fn claim_milestone_last_sweeps_rounding_residue() { let floored = TOTAL_BUDGET / 3; ctx.events - .claim_milestone(&id, &recipient, &0_u32, &0, &BytesN::random(&ctx.env)); + .claim_milestone(&id, &recipient, &0_u32, &BytesN::random(&ctx.env)); ctx.events - .claim_milestone(&id, &recipient, &1_u32, &0, &BytesN::random(&ctx.env)); + .claim_milestone(&id, &recipient, &1_u32, &BytesN::random(&ctx.env)); assert_eq!(token.balance(&recipient) - before, floored * 2); ctx.events - .claim_milestone(&id, &recipient, &2_u32, &0, &BytesN::random(&ctx.env)); + .claim_milestone(&id, &recipient, &2_u32, &BytesN::random(&ctx.env)); assert_eq!(token.balance(&recipient) - before, TOTAL_BUDGET); assert_eq!(token.balance(&ctx.fee_account) - fee_before, 0); @@ -224,11 +224,11 @@ fn claim_milestone_marks_completed_on_last() { select_winner(&ctx, id, &recipient); ctx.events - .claim_milestone(&id, &recipient, &0_u32, &0, &BytesN::random(&ctx.env)); + .claim_milestone(&id, &recipient, &0_u32, &BytesN::random(&ctx.env)); assert_eq!(ctx.events.get_event(&id).status, EventStatus::Active); ctx.events - .claim_milestone(&id, &recipient, &1_u32, &0, &BytesN::random(&ctx.env)); + .claim_milestone(&id, &recipient, &1_u32, &BytesN::random(&ctx.env)); assert_eq!(ctx.events.get_event(&id).status, EventStatus::Completed); } @@ -244,7 +244,7 @@ fn claim_milestone_earns_credits_and_bumps_reputation() { select_winner(&ctx, id, &recipient); ctx.events - .claim_milestone(&id, &recipient, &0_u32, &10_u32, &BytesN::random(&ctx.env)); + .claim_milestone(&id, &recipient, &0_u32, &BytesN::random(&ctx.env)); let profile = ctx.profile.get_profile(&recipient).unwrap(); assert_eq!(profile.reputation, 10); @@ -268,10 +268,10 @@ fn claim_milestone_already_claimed_reverts() { select_winner(&ctx, id, &recipient); ctx.events - .claim_milestone(&id, &recipient, &0_u32, &0, &BytesN::random(&ctx.env)); + .claim_milestone(&id, &recipient, &0_u32, &BytesN::random(&ctx.env)); assert!(ctx .events - .try_claim_milestone(&id, &recipient, &0_u32, &0, &BytesN::random(&ctx.env)) + .try_claim_milestone(&id, &recipient, &0_u32, &BytesN::random(&ctx.env)) .is_err()); } @@ -283,7 +283,7 @@ fn claim_milestone_out_of_range_reverts() { select_winner(&ctx, id, &recipient); assert!(ctx .events - .try_claim_milestone(&id, &recipient, &3_u32, &0, &BytesN::random(&ctx.env)) + .try_claim_milestone(&id, &recipient, &3_u32, &BytesN::random(&ctx.env)) .is_err()); } @@ -294,7 +294,7 @@ fn claim_milestone_without_being_winner_reverts() { let non_winner = Address::generate(&ctx.env); assert!(ctx .events - .try_claim_milestone(&id, &non_winner, &0_u32, &0, &BytesN::random(&ctx.env)) + .try_claim_milestone(&id, &non_winner, &0_u32, &BytesN::random(&ctx.env)) .is_err()); } @@ -318,7 +318,7 @@ fn claim_milestone_on_single_release_reverts() { let r = Address::generate(&ctx.env); assert!(ctx .events - .try_claim_milestone(&id, &r, &0_u32, &0, &BytesN::random(&ctx.env)) + .try_claim_milestone(&id, &r, &0_u32, &BytesN::random(&ctx.env)) .is_err()); } @@ -330,10 +330,10 @@ fn claim_milestone_op_replay_reverts() { select_winner(&ctx, id, &recipient); let op = BytesN::random(&ctx.env); - ctx.events.claim_milestone(&id, &recipient, &0_u32, &0, &op); + ctx.events.claim_milestone(&id, &recipient, &0_u32, &op); assert!(ctx .events - .try_claim_milestone(&id, &recipient, &0_u32, &0, &op) + .try_claim_milestone(&id, &recipient, &0_u32, &op) .is_err()); } @@ -386,13 +386,13 @@ fn two_winner_grant_each_claims_their_share() { let fee_before = token.balance(&ctx.fee_account); ctx.events - .claim_milestone(&id, &w1, &0_u32, &0, &BytesN::random(&ctx.env)); + .claim_milestone(&id, &w1, &0_u32, &BytesN::random(&ctx.env)); ctx.events - .claim_milestone(&id, &w1, &1_u32, &0, &BytesN::random(&ctx.env)); + .claim_milestone(&id, &w1, &1_u32, &BytesN::random(&ctx.env)); ctx.events - .claim_milestone(&id, &w2, &0_u32, &0, &BytesN::random(&ctx.env)); + .claim_milestone(&id, &w2, &0_u32, &BytesN::random(&ctx.env)); ctx.events - .claim_milestone(&id, &w2, &1_u32, &0, &BytesN::random(&ctx.env)); + .claim_milestone(&id, &w2, &1_u32, &BytesN::random(&ctx.env)); assert_eq!(token.balance(&w1) - w1_before, TOTAL_BUDGET * 60 / 100); assert_eq!(token.balance(&w2) - w2_before, TOTAL_BUDGET * 40 / 100); diff --git a/contracts/events/src/tests/hackathon_pillar.rs b/contracts/events/src/tests/hackathon_pillar.rs index 4935cc5..685108a 100644 --- a/contracts/events/src/tests/hackathon_pillar.rs +++ b/contracts/events/src/tests/hackathon_pillar.rs @@ -303,8 +303,13 @@ fn select_winners_single_recipient_sweeps_escrow() { reputation_bump: 50, }, ]; - let op = BytesN::random(&ctx.env); - ctx.events.select_winners(&id, &winners, &op); + let op_select = BytesN::random(&ctx.env); + ctx.events.select_winners(&id, &winners, &op_select); + + // Pull-model: claim prize in winner's own transaction. + let claim_op = BytesN::random(&ctx.env); + ctx.events + .claim_prize(&id, &ctx.applicant, &1_u32, &claim_op); assert_eq!(token.balance(&ctx.applicant) - winner_before, TOTAL_BUDGET); assert_eq!(token.balance(&ctx.fee_account) - fee_before, 0); @@ -320,14 +325,15 @@ fn select_winners_single_recipient_sweeps_escrow() { assert_eq!(event.status, EventStatus::Completed); assert_eq!(event.remaining_escrow, 0); + // Single winner row: anchor updated in-place by claim_prize with paid_at. let winner_list = ctx.events.get_winners(&id); assert_eq!(winner_list.len(), 1); - let w = winner_list.get(0).unwrap(); - assert_eq!(w.recipient, ctx.applicant); - assert_eq!(w.position, 1); - assert_eq!(w.amount, TOTAL_BUDGET); - assert!(w.milestone.is_none()); - assert!(w.paid_at.is_some()); + let row = winner_list.get(0).unwrap(); + assert_eq!(row.recipient, ctx.applicant); + assert_eq!(row.position, 1); + assert_eq!(row.amount, TOTAL_BUDGET); + assert!(row.milestone.is_none()); + assert!(row.paid_at.is_some()); } #[test] @@ -361,13 +367,20 @@ fn select_winners_multi_position_splits_by_distribution() { reputation_bump: 20, }, ]; - let op = BytesN::random(&ctx.env); - ctx.events.select_winners(&id, &winners, &op); + let op_select = BytesN::random(&ctx.env); + ctx.events.select_winners(&id, &winners, &op_select); let amt_1 = TOTAL_BUDGET * 50 / 100; let amt_2 = TOTAL_BUDGET * 30 / 100; let amt_3 = TOTAL_BUDGET * 20 / 100; + ctx.events + .claim_prize(&id, &first, &1_u32, &BytesN::random(&ctx.env)); + ctx.events + .claim_prize(&id, &second, &2_u32, &BytesN::random(&ctx.env)); + ctx.events + .claim_prize(&id, &third, &3_u32, &BytesN::random(&ctx.env)); + assert_eq!(token.balance(&first), amt_1); assert_eq!(token.balance(&second), amt_2); assert_eq!(token.balance(&third), amt_3); @@ -383,6 +396,7 @@ fn select_winners_multi_position_splits_by_distribution() { let event = ctx.events.get_event(&id); assert_eq!(event.status, EventStatus::Completed); assert_eq!(event.remaining_escrow, 0); + // 3 anchors updated in-place by claim_prize with paid_at. assert_eq!(ctx.events.get_winners(&id).len(), 3); } @@ -526,8 +540,15 @@ fn select_winners_on_completed_event_reverts() { reputation_bump: 0, }, ]; - let op = BytesN::random(&ctx.env); - ctx.events.select_winners(&id, &winners, &op); + let op_select = BytesN::random(&ctx.env); + ctx.events.select_winners(&id, &winners, &op_select); + // Pull-model: event stays Active until claim. + assert_eq!(ctx.events.get_event(&id).status, EventStatus::Active); + + // Claim to drain escrow and complete the event. + let claim_op = BytesN::random(&ctx.env); + ctx.events + .claim_prize(&id, &ctx.applicant, &1_u32, &claim_op); assert_eq!(ctx.events.get_event(&id).status, EventStatus::Completed); let again = Address::generate(&ctx.env); @@ -587,7 +608,7 @@ fn claim_milestone_on_single_release_hackathon_reverts() { let op = BytesN::random(&ctx.env); let res = ctx .events - .try_claim_milestone(&id, &ctx.applicant, &0_u32, &0_u32, &op); + .try_claim_milestone(&id, &ctx.applicant, &0_u32, &op); assert!( res.is_err(), "claim_milestone must reject a Single-release hackathon" diff --git a/contracts/events/src/types.rs b/contracts/events/src/types.rs index f1909dc..33215bc 100644 --- a/contracts/events/src/types.rs +++ b/contracts/events/src/types.rs @@ -129,6 +129,7 @@ pub struct Winner { pub amount: i128, pub milestone: Option, pub paid_at: Option, + pub reputation_bump: Option, } // ============================================================ @@ -195,6 +196,15 @@ pub enum DataKey { // Appended in 1.2.0 to preserve existing key discriminants. NonOwnerContributionTotal(u64), + + // Winner anchor index for O(1) claim_prize lookup (added 2026-07). + WinnerIndex(u64, Address, u32), + + // Grant anchor index for O(1) claim_milestone recipient lookup. + GrantRecipientIdx(u64, Address), + + // Per-recipient milestone claim counter for claim_milestone. + GrantRecipientClaimCount(u64, Address), } // ============================================================