Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions contracts/events/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,7 @@ pub enum Error {
Paused = 70,

ProfileCallFailed = 80,

// Prize claims (pull-model for Single-release events)
PrizeAlreadyClaimed = 91,
}
206 changes: 165 additions & 41 deletions contracts/events/src/event_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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, &params.owner, 0);
}

evt::EventCreated {
Expand Down Expand Up @@ -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
// ============================================================
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -638,26 +657,22 @@ pub fn select_winners(
position: spec.position,
amount,
milestone: None,
paid_at: Some(now),
paid_at: None,
Comment thread
Shadow-MMN marked this conversation as resolved.
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,
Expand All @@ -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);
}
}
}
Expand All @@ -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>,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) -> 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
// ============================================================
Expand Down
8 changes: 8 additions & 0 deletions contracts/events/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ pub struct WinnerPaid {
pub milestone: Option<u32>,
}

#[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,
Expand Down
Loading