diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9e2b36e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,8 @@ +Never add "Co-Authored-By" lines to commits + +Comment sparingly. A comment earns its place only by stating something the +code cannot: an invariant, a security-relevant ordering, a compatibility +trap, or a non-obvious "why". Do not narrate what the code does, restate the +function name, tag lines with version or PR numbers, or add banners over +self-evident blocks. When in doubt, delete it. Match the comment density of +the surrounding file. diff --git a/BACKLOG.md b/BACKLOG.md index 72e606c..3ca3188 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -29,7 +29,10 @@ See `docs/audit-2026-06-stellar-skill.md` for full findings. ## P1 (post-launch) -- [ ] `select_winners` re-run semantics: today rejected; revisit if a real customer wants "append-only" behavior. Open question, not blocking. +- [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) +- [ ] Per-event prize claim window: `PRIZE_CLAIM_WINDOW_SECS` (90 days) is a module constant for now because adding a field to `EventRecord` requires a storage migration. Move it onto `EventRecord` at the next real migration window, per the per-event-config rule in CLAUDE.md. +- [ ] `Error` enum is at the 50-case XDR spec cap. The next error needs consolidation of an existing variant; plan this before the audit freeze. +- [ ] Measure real `select_winners` batch headroom with `simulateTransaction` on testnet (entry writes per winner: row + award key). The 50/call cap is conservative; raise it only from measured numbers. - [ ] Grant committee multi-sig primitive (dedicated signer set + quorum at the contract level vs the current address-level multi-sig). - [ ] Bounty Showdown participation badge on the boundless-profile contract for non-winning finalists. - [ ] Multi-token support audit: verify the whitelist mechanism handles tokens with non-Stellar 7-decimal scales (currently assumed uniform). diff --git a/CLAUDE.md b/CLAUDE.md index d29621a..271ee61 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ The Stellar Development Foundation publishes a Claude Code skill that bundles cu After install, the seven sub-skills (`soroban`, `dapp`, `assets`, `data`, `agentic-payments`, `zk-proofs`, `standards`) become available across sessions. Lean on `soroban/` for contract changes and audit prep; lean on `dapp/` and `assets/` only when the work crosses into the frontend wallet or trustline flows. -Source: https://github.com/stellar/stellar-dev-skill +Source: ## Hard rules @@ -22,6 +22,7 @@ Source: https://github.com/stellar/stellar-dev-skill - **Per-event configuration over global constants.** Anything sales might want to vary per program (fees, windows, caps) belongs on `EventRecord` or its variant payload, not in module constants. - **Tests cover the math.** Every payout split (single + multi-position + sweep) has a test that asserts both the recipient and the fee account deltas. - **Snapshots are inspection tooling, not history.** `test_snapshots/` is gitignored: snapshots are derived artifacts that `cargo test` regenerates from any commit, and committing them made PR diffs so large that the security scanners skipped them. When reviewing a storage-layout or auth change, regenerate locally and inspect the snapshot diff — but never commit snapshot files. +- **Comment sparingly.** A comment earns its place only by stating a constraint the code cannot: an invariant, a security ordering, a compatibility trap, a non-obvious "why". Do not narrate what the code does, restate the function name, tag changes with version/PR numbers, or leave banners over self-evident blocks. When in doubt, delete it — dense explanatory comments read as AI-generated and make review harder, not easier. Match the density of the surrounding file. ## Build, test, deploy @@ -49,3 +50,6 @@ cargo build --release --target wasm32-unknown-unknown ``` Update `BACKLOG.md` if your PR closes one of the entries there. + +@AGENTS.md +Never add "Co-Authored-By" lines to commits diff --git a/Cargo.lock b/Cargo.lock index 9898a3f..35414ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -185,7 +185,7 @@ dependencies = [ [[package]] name = "boundless-events" -version = "1.2.0" +version = "1.3.0" dependencies = [ "boundless-profile", "soroban-sdk", diff --git a/contracts/events/Cargo.toml b/contracts/events/Cargo.toml index b4d6964..10e2525 100644 --- a/contracts/events/Cargo.toml +++ b/contracts/events/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "boundless-events" -version = "1.2.0" +version = "1.3.0" edition = "2021" publish = false diff --git a/contracts/events/src/admin.rs b/contracts/events/src/admin.rs index 3631f3e..6f8b874 100644 --- a/contracts/events/src/admin.rs +++ b/contracts/events/src/admin.rs @@ -15,7 +15,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.2.0"; +pub const INITIAL_VERSION: &str = "1.3.0"; // ============================================================ // INITIALIZATION diff --git a/contracts/events/src/errors.rs b/contracts/events/src/errors.rs index 69b81d6..0a1e5e5 100644 --- a/contracts/events/src/errors.rs +++ b/contracts/events/src/errors.rs @@ -66,4 +66,7 @@ pub enum Error { Paused = 70, ProfileCallFailed = 80, + + // Enum is at the 50-case XDR cap; consolidate before adding another. + PrizeAlreadyClaimed = 91, } diff --git a/contracts/events/src/event_ops.rs b/contracts/events/src/event_ops.rs index 5047a17..8fd64af 100644 --- a/contracts/events/src/event_ops.rs +++ b/contracts/events/src/event_ops.rs @@ -14,12 +14,17 @@ use crate::storage; use crate::token_whitelist; use crate::types::{ CancellationBranch, CancellationState, CreateEventParams, EventRecord, EventStatus, Pillar, - ReleaseKind, Submission, Winner, WinnerSpec, + PrizeAward, ReleaseKind, Submission, Winner, WinnerSpec, }; const MAX_TITLE_LEN: u32 = 120; + const MAX_WINNERS_PER_SELECT: u32 = 50; +// Anchored at selection time, not the event deadline (which usually passes +// before winners are selected). A per-event override needs a migration. +pub const PRIZE_CLAIM_WINDOW_SECS: u64 = 90 * 24 * 60 * 60; + pub const MAX_APPLICANTS_PER_EVENT: u32 = 5_000; pub const MAX_CONTRIBUTORS_PER_EVENT: u32 = 5_000; @@ -269,6 +274,17 @@ pub fn start_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<(), E return Err(Error::CancellationAlreadyStarted); } + // Block cancel while prizes are unclaimed and the window is open; + // after it expires, unclaimed amounts sweep out via the refund path. + if matches!(event.release_kind, ReleaseKind::Single) + && storage::unclaimed_prize_count(env, event_id) > 0 + { + let expiry = storage::get_prize_claim_expiry(env, event_id).unwrap_or(0); + if env.ledger().timestamp() <= expiry { + return Err(Error::WinnersAlreadySelected); + } + } + resolve_manager(env, event_id, &event.owner).require_auth(); let remaining = event.remaining_escrow; @@ -535,7 +551,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); } @@ -546,12 +562,23 @@ pub fn select_winners( resolve_manager(env, event_id, &event.owner).require_auth(); let existing_count = storage::winner_count(env, event_id); - for idx in 0..existing_count { - if let Some(w) = storage::winner_at(env, event_id, idx) { - if w.milestone.is_none() { + 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); } } + ReleaseKind::Multi(_) => { + for idx in 0..existing_count { + if let Some(w) = storage::winner_at(env, event_id, idx) { + if w.milestone.is_none() { + return Err(Error::WinnersAlreadySelected); + } + } + } + } } if winners.is_empty() { @@ -579,21 +606,31 @@ 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 => { - let escrow_at_select = event.remaining_escrow; + // 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 + } + }; let mut total_owed: 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 = escrow_at_select.saturating_mul(percent) / 100_i128; + let amount = base_escrow.saturating_mul(percent) / 100_i128; if amount <= 0 { return Err(Error::InvalidDistribution); } @@ -604,32 +641,13 @@ pub fn select_winners( } for (idx, spec) in winners.iter().enumerate() { - let sub_idx = idx as u8; 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 amount = base_escrow.saturating_mul(percent) / 100_i128; + let anchor_idx = existing_count + (idx as u32); storage::append_winner( env, event_id, @@ -638,22 +656,33 @@ pub fn select_winners( position: spec.position, amount, milestone: None, - paid_at: Some(now), + paid_at: None, }, ); - - evt::WinnerPaid { + storage::set_prize_award( + env, event_id, - recipient: spec.recipient.clone(), - position: spec.position, - amount, - milestone: None, - } - .publish(env); + spec.position, + &PrizeAward { + recipient: spec.recipient.clone(), + anchor_idx, + reputation_bump: spec.reputation_bump, + }, + ); } - if event.remaining_escrow == 0 { - event.status = EventStatus::Completed; + let unclaimed = storage::unclaimed_prize_count(env, event_id); + storage::set_unclaimed_prize_count( + env, + event_id, + unclaimed.saturating_add(winners.len()), + ); + + // Extend the window so a later batch's winners get the full term. + let expiry = now.saturating_add(PRIZE_CLAIM_WINDOW_SECS); + let cur = storage::get_prize_claim_expiry(env, event_id).unwrap_or(0); + if expiry > cur { + storage::set_prize_claim_expiry(env, event_id, expiry); } } ReleaseKind::Multi(_) => { @@ -686,6 +715,103 @@ pub fn select_winners( Ok(()) } +// ============================================================ +// CLAIM PRIZE (pull model for Single-release events; #61) +// ============================================================ +pub fn claim_prize( + env: &Env, + event_id: u64, + 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); + } + + let award = + storage::get_prize_award(env, event_id, position).ok_or(Error::InvalidWinnerPosition)?; + award.recipient.require_auth(); + + let anchor = + storage::winner_at(env, event_id, award.anchor_idx).ok_or(Error::InvalidWinnerPosition)?; + if anchor.recipient != award.recipient || anchor.position != position { + return Err(Error::InvalidWinnerPosition); + } + if anchor.paid_at.is_some() { + return Err(Error::PrizeAlreadyClaimed); + } + let amount = anchor.amount; + if amount <= 0 { + return Err(Error::InvalidDistribution); + } + if amount > event.remaining_escrow { + return Err(Error::InsufficientEscrow); + } + + let now = env.ledger().timestamp(); + storage::set_winner_at( + env, + event_id, + award.anchor_idx, + &Winner { + recipient: anchor.recipient.clone(), + position, + amount, + milestone: None, + paid_at: Some(now), + }, + ); + + let unclaimed = storage::unclaimed_prize_count(env, event_id); + storage::set_unclaimed_prize_count(env, event_id, unclaimed.saturating_sub(1)); + + event.remaining_escrow = event.remaining_escrow.saturating_sub(amount); + if event.remaining_escrow == 0 { + event.status = EventStatus::Completed; + } + storage::set_event(env, event_id, &event); + idempotency::mark_seen(env, &op_id); + + // State written above; release last so a reentrant token can't double-claim. + escrow::release(env, &event.token, &award.recipient, amount); + + evt::WinnerPaid { + event_id, + recipient: award.recipient.clone(), + position, + amount, + milestone: None, + } + .publish(env); + + // Best-effort: the payout is final, so a profile failure must not revert it. + 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(&award.recipient, &bootstrap_op); + + let rep_op = idempotency::derive_child(env, &op_id, tag::BUMP_REP); + let _ = profile.try_bump_reputation( + &award.recipient, + &award.reputation_bump, + &reason_win, + &rep_op, + ); + + let earnings_op = idempotency::derive_child(env, &op_id, tag::REGISTER_EARNINGS); + let _ = profile.try_register_earnings(&award.recipient, &event.token, &amount, &earnings_op); + + Ok(()) +} + // ============================================================ // READS // ============================================================ diff --git a/contracts/events/src/lib.rs b/contracts/events/src/lib.rs index 8f04f9a..dfdc3ec 100644 --- a/contracts/events/src/lib.rs +++ b/contracts/events/src/lib.rs @@ -216,6 +216,15 @@ impl EventsContract { event_ops::select_winners(&env, event_id, winners, op_id) } + pub fn claim_prize( + env: Env, + event_id: u64, + position: u32, + op_id: BytesN<32>, + ) -> Result<(), Error> { + event_ops::claim_prize(&env, event_id, position, op_id) + } + // ============================================================ // MANAGEMENT AUTHORITY (manager != funder/owner) // ============================================================ diff --git a/contracts/events/src/storage.rs b/contracts/events/src/storage.rs index 385a25d..7324f7a 100644 --- a/contracts/events/src/storage.rs +++ b/contracts/events/src/storage.rs @@ -6,7 +6,8 @@ use soroban_sdk::String; use crate::errors::Error; use crate::types::{ - CancellationState, DataKey, EventRecord, PendingAdmin, PendingUpgrade, Submission, Winner, + CancellationState, DataKey, EventRecord, PendingAdmin, PendingUpgrade, PrizeAward, Submission, + Winner, }; // ============================================================ @@ -445,6 +446,75 @@ pub fn append_winner(env: &Env, id: u64, w: &Winner) { touch_event_persistent(env, &count_key); } +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); +} + +// ============================================================ +// PRIZE AWARDS (pull-model claims; persistent, keyed by (event, position)) +// ============================================================ +pub fn get_prize_award(env: &Env, id: u64, position: u32) -> Option { + let key = DataKey::EventPrizeAward(id, position); + let a: Option = env.storage().persistent().get(&key); + if a.is_some() { + touch_event_persistent(env, &key); + } + a +} + +pub fn set_prize_award(env: &Env, id: u64, position: u32, award: &PrizeAward) { + let key = DataKey::EventPrizeAward(id, position); + env.storage().persistent().set(&key, award); + touch_event_persistent(env, &key); +} + +pub fn unclaimed_prize_count(env: &Env, id: u64) -> u32 { + let key = DataKey::EventUnclaimedPrizes(id); + let n: Option = env.storage().persistent().get(&key); + if n.is_some() { + touch_event_persistent(env, &key); + } + n.unwrap_or(0) +} + +pub fn set_unclaimed_prize_count(env: &Env, id: u64, count: u32) { + let key = DataKey::EventUnclaimedPrizes(id); + env.storage().persistent().set(&key, &count); + 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); + if b.is_some() { + touch_event_persistent(env, &key); + } + b +} + +pub fn set_prize_base_escrow(env: &Env, id: u64, base: i128) { + let key = DataKey::EventPrizeBaseEscrow(id); + env.storage().persistent().set(&key, &base); + touch_event_persistent(env, &key); +} + +pub fn get_prize_claim_expiry(env: &Env, id: u64) -> Option { + let key = DataKey::EventPrizeClaimExpiry(id); + let t: Option = env.storage().persistent().get(&key); + if t.is_some() { + touch_event_persistent(env, &key); + } + t +} + +pub fn set_prize_claim_expiry(env: &Env, id: u64, expires_at: u64) { + let key = DataKey::EventPrizeClaimExpiry(id); + env.storage().persistent().set(&key, &expires_at); + 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/admin.rs b/contracts/events/src/tests/admin.rs index aa657df..916d6fe 100644 --- a/contracts/events/src/tests/admin.rs +++ b/contracts/events/src/tests/admin.rs @@ -19,7 +19,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.2.0")); + assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.3.0")); assert_eq!(ctx.client.get_pending_upgrade(), None); assert_eq!(ctx.client.get_migrated_to_version(), None); } @@ -96,7 +96,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.2.0")); + assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.3.0")); } #[test] @@ -130,7 +130,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.2.0")); + assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.3.0")); } #[test] @@ -152,7 +152,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.2.0")) + Some(String::from_str(&ctx.env, "1.3.0")) ); let err = ctx diff --git a/contracts/events/src/tests/bounty_pillar.rs b/contracts/events/src/tests/bounty_pillar.rs index a29332e..a21fe59 100644 --- a/contracts/events/src/tests/bounty_pillar.rs +++ b/contracts/events/src/tests/bounty_pillar.rs @@ -265,6 +265,10 @@ 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 the sole prize to drain escrow and complete. + ctx.events + .claim_prize(&bounty_id, &1_u32, &BytesN::random(&ctx.env)); + let event = ctx.events.get_event(&bounty_id); assert_eq!(event.status, EventStatus::Completed); diff --git a/contracts/events/src/tests/cancel_refund.rs b/contracts/events/src/tests/cancel_refund.rs index e827260..ba8eb64 100644 --- a/contracts/events/src/tests/cancel_refund.rs +++ b/contracts/events/src/tests/cancel_refund.rs @@ -438,6 +438,11 @@ fn cancel_prorata_splits_remaining_across_partners_no_owner_residual() { 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. + ctx.events + .claim_prize(&id, &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..2652f84 100644 --- a/contracts/events/src/tests/contributions.rs +++ b/contracts/events/src/tests/contributions.rs @@ -368,6 +368,10 @@ 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); + // Pull model: escrow only moves at claim time. + ctx.events + .claim_prize(&id, &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..b6a408a 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); + // Pull model: winner claims in their own transaction. + 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); assert_eq!(token.balance(&ctx.fee_account), FEE_AMOUNT); @@ -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 own position. + ctx.events + .claim_prize(&bounty_id, &1_u32, &BytesN::random(&ctx.env)); + ctx.events + .claim_prize(&bounty_id, &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); + // Pull model: winner claims their 60% before the manager can cancel. + ctx.events + .claim_prize(&bounty_id, &1_u32, &BytesN::random(&ctx.env)); + let token = token::Client::new(&ctx.env, &ctx.token_addr); let owner_before = token.balance(&ctx.owner); @@ -931,6 +945,10 @@ 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; 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); @@ -1006,6 +1024,10 @@ fn manager_override_can_select_winners() { let op_select = BytesN::random(&ctx.env); ctx.events.select_winners(&bounty_id, &winners, &op_select); + // Pull model: completion happens at claim time. + ctx.events + .claim_prize(&bounty_id, &1_u32, &BytesN::random(&ctx.env)); + let event = ctx.events.get_event(&bounty_id); assert_eq!(event.status, EventStatus::Completed); } diff --git a/contracts/events/src/tests/escrow_fee_math.rs b/contracts/events/src/tests/escrow_fee_math.rs index 750c022..33dadb4 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, &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, &1_u32, &BytesN::random(&ctx.env)); + ctx.events + .claim_prize(&id, &2_u32, &BytesN::random(&ctx.env)); + ctx.events + .claim_prize(&id, &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, &1_u32, &BytesN::random(&ctx.env)); + ctx.events + .claim_prize(&id, &2_u32, &BytesN::random(&ctx.env)); + ctx.events + .claim_prize(&id, &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, &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, &1_u32, &BytesN::random(&ctx.env)); let token = token::Client::new(&ctx.env, &ctx.token_addr); assert_eq!(token.balance(&winner), escrow_at_select); @@ -1046,6 +1064,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, &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/hackathon_pillar.rs b/contracts/events/src/tests/hackathon_pillar.rs index 4935cc5..415b429 100644 --- a/contracts/events/src/tests/hackathon_pillar.rs +++ b/contracts/events/src/tests/hackathon_pillar.rs @@ -306,6 +306,11 @@ fn select_winners_single_recipient_sweeps_escrow() { let op = BytesN::random(&ctx.env); ctx.events.select_winners(&id, &winners, &op); + // Pull model: selection only records; the winner claims in their own tx. + assert_eq!(token.balance(&ctx.applicant) - winner_before, 0); + ctx.events + .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); + assert_eq!(token.balance(&ctx.applicant) - winner_before, TOTAL_BUDGET); assert_eq!(token.balance(&ctx.fee_account) - fee_before, 0); @@ -364,6 +369,14 @@ fn select_winners_multi_position_splits_by_distribution() { let op = BytesN::random(&ctx.env); ctx.events.select_winners(&id, &winners, &op); + // Pull model: each winner claims their own position. + ctx.events + .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); + ctx.events + .claim_prize(&id, &2_u32, &BytesN::random(&ctx.env)); + ctx.events + .claim_prize(&id, &3_u32, &BytesN::random(&ctx.env)); + let amt_1 = TOTAL_BUDGET * 50 / 100; let amt_2 = TOTAL_BUDGET * 30 / 100; let amt_3 = TOTAL_BUDGET * 20 / 100; @@ -445,7 +458,7 @@ fn select_winners_duplicate_position_reverts() { } #[test] -fn select_winners_second_call_reverts_winners_already_selected() { +fn select_winners_batches_append_and_position_replay_reverts() { let ctx = setup(); let dl = Some(ctx.env.ledger().timestamp() + 86_400); let id = create_hackathon_with(&ctx, three_way_dist(&ctx.env), dl); @@ -463,18 +476,53 @@ fn select_winners_second_call_reverts_winners_already_selected() { assert_eq!(ctx.events.get_event(&id).status, EventStatus::Active); + // Re-awarding an already-taken position must revert, even across calls. + let usurper = Address::generate(&ctx.env); + let replay = soroban_sdk::vec![ + &ctx.env, + WinnerSpec { + recipient: usurper, + position: 1, + reputation_bump: 0, + }, + ]; + let res = ctx + .events + .try_select_winners(&id, &replay, &BytesN::random(&ctx.env)); + assert!(res.is_err(), "re-awarding a taken position must revert"); + + // A later batch for untaken positions appends (1.3.0 batching), and + // amounts stay anchored to the baseline captured at the first batch. let second = Address::generate(&ctx.env); - let second_winner = soroban_sdk::vec![ + let third = Address::generate(&ctx.env); + let batch2 = soroban_sdk::vec![ &ctx.env, WinnerSpec { - recipient: second, + recipient: second.clone(), position: 2, reputation_bump: 0, }, + WinnerSpec { + recipient: third.clone(), + position: 3, + reputation_bump: 0, + }, ]; - let op2 = BytesN::random(&ctx.env); - let res = ctx.events.try_select_winners(&id, &second_winner, &op2); - assert!(res.is_err(), "a second select_winners must revert"); + ctx.events + .select_winners(&id, &batch2, &BytesN::random(&ctx.env)); + + let token = token::Client::new(&ctx.env, &ctx.token_addr); + ctx.events + .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); + ctx.events + .claim_prize(&id, &2_u32, &BytesN::random(&ctx.env)); + ctx.events + .claim_prize(&id, &3_u32, &BytesN::random(&ctx.env)); + + assert_eq!(token.balance(&ctx.applicant), TOTAL_BUDGET * 50 / 100); + assert_eq!(token.balance(&second), TOTAL_BUDGET * 30 / 100); + assert_eq!(token.balance(&third), TOTAL_BUDGET * 20 / 100); + assert_eq!(ctx.events.get_event(&id).status, EventStatus::Completed); } #[test] @@ -528,6 +576,10 @@ fn select_winners_on_completed_event_reverts() { ]; let op = BytesN::random(&ctx.env); ctx.events.select_winners(&id, &winners, &op); + // Pull model: the event completes when the last prize is claimed. + assert_eq!(ctx.events.get_event(&id).status, EventStatus::Active); + ctx.events + .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); assert_eq!(ctx.events.get_event(&id).status, EventStatus::Completed); let again = Address::generate(&ctx.env); diff --git a/contracts/events/src/tests/mod.rs b/contracts/events/src/tests/mod.rs index 0064ef6..b2bd14a 100644 --- a/contracts/events/src/tests/mod.rs +++ b/contracts/events/src/tests/mod.rs @@ -10,4 +10,5 @@ mod crowdfunding; mod escrow_fee_math; mod grant_pillar; mod hackathon_pillar; +mod prize_claim; mod token_whitelist; diff --git a/contracts/events/src/tests/prize_claim.rs b/contracts/events/src/tests/prize_claim.rs new file mode 100644 index 0000000..3b757d3 --- /dev/null +++ b/contracts/events/src/tests/prize_claim.rs @@ -0,0 +1,479 @@ +// Pull-model prize claims (#61): claim math, auth, replay/double-claim, +// the cancel gate + window escape hatch, and the pre-1.3.0 row decode guard. + +#![cfg(test)] + +use soroban_sdk::{ + contracttype, + testutils::{Address as _, BytesN as _, Ledger as _}, + token, Address, BytesN, Env, Map, String, +}; + +use super::common::drive_cancel; +use crate::event_ops::PRIZE_CLAIM_WINDOW_SECS; +use crate::types::{CreateEventParams, DataKey, EventStatus, Pillar, ReleaseKind, WinnerSpec}; +use crate::{EventsContract, EventsContractClient}; + +use boundless_profile::{ProfileContract, ProfileContractClient}; + +const FEE_BPS: u32 = 250; +const TOTAL_BUDGET: i128 = 10_000_0000000_i128; + +struct Ctx<'a> { + env: Env, + events: EventsContractClient<'a>, + profile: ProfileContractClient<'a>, + owner: Address, + token_addr: Address, + fee_account: Address, +} + +fn setup<'a>() -> Ctx<'a> { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + let profile_admin = Address::generate(&env); + let profile_id = env.register(ProfileContract, (profile_admin.clone(),)); + let profile = ProfileContractClient::new(&env, &profile_id); + + let events_admin = Address::generate(&env); + let fee_account = Address::generate(&env); + let events_id = env.register( + EventsContract, + ( + events_admin.clone(), + fee_account.clone(), + FEE_BPS, + profile_id.clone(), + ), + ); + let events = EventsContractClient::new(&env, &events_id); + profile.set_events_contract(&events_id); + + let issuer = Address::generate(&env); + let sac = env.register_stellar_asset_contract_v2(issuer); + let token_addr = sac.address(); + let token_admin = token::StellarAssetClient::new(&env, &token_addr); + + token_admin.mint(&fee_account, &0); + let owner = Address::generate(&env); + token_admin.mint(&owner, &1_000_000_0000000_i128); + + events.register_supported_token(&token_addr); + + Ctx { + env, + events, + profile, + owner, + token_addr, + fee_account, + } +} + +fn create_single(ctx: &Ctx, dist: Map) -> u64 { + let params = CreateEventParams { + pillar: Pillar::Hackathon, + owner: ctx.owner.clone(), + token: ctx.token_addr.clone(), + total_budget: TOTAL_BUDGET, + release_kind: ReleaseKind::Single, + content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/prize-claim"), + title: String::from_str(&ctx.env, "Prize Claim Suite"), + deadline: Some(ctx.env.ledger().timestamp() + 86_400), + winner_distribution: dist, + fee_bps_override: None, + manager: None, + }; + ctx.events.create_event(¶ms, &BytesN::random(&ctx.env)) +} + +fn dist_100(env: &Env) -> Map { + let mut m = Map::new(env); + m.set(1, 100); + m +} + +fn dist_60_40(env: &Env) -> Map { + let mut m = Map::new(env); + m.set(1, 60); + m.set(2, 40); + m +} + +fn select_one(ctx: &Ctx, id: u64, recipient: &Address, position: u32, bump: u32) { + let winners = soroban_sdk::vec![ + &ctx.env, + WinnerSpec { + recipient: recipient.clone(), + position, + reputation_bump: bump, + }, + ]; + ctx.events + .select_winners(&id, &winners, &BytesN::random(&ctx.env)); +} + +// ============================================================ +// Claim math: recipient delta, fee delta, profile effects +// ============================================================ + +#[test] +fn claim_pays_recipient_full_prize_and_no_release_fee() { + let ctx = setup(); + let id = create_single(&ctx, dist_100(&ctx.env)); + let token = token::Client::new(&ctx.env, &ctx.token_addr); + + let w = Address::generate(&ctx.env); + select_one(&ctx, id, &w, 1, 50); + + // Selection alone must not move funds or complete the event. + assert_eq!(token.balance(&w), 0); + let mid = ctx.events.get_event(&id); + assert_eq!(mid.status, EventStatus::Active); + assert_eq!(mid.remaining_escrow, TOTAL_BUDGET); + + let fee_before = token.balance(&ctx.fee_account); + ctx.events + .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); + + assert_eq!(token.balance(&w), TOTAL_BUDGET); + assert_eq!( + token.balance(&ctx.fee_account) - fee_before, + 0, + "fee is charged at funding time, not at release" + ); + + let after = ctx.events.get_event(&id); + assert_eq!(after.status, EventStatus::Completed); + assert_eq!(after.remaining_escrow, 0); + + let p = ctx.profile.get_profile(&w).unwrap(); + assert_eq!(p.reputation, 50); + assert_eq!(ctx.profile.get_earnings(&w, &ctx.token_addr), TOTAL_BUDGET); + + let rows = ctx.events.get_winners(&id); + assert_eq!(rows.len(), 1); + let row = rows.get(0).unwrap(); + assert_eq!(row.amount, TOTAL_BUDGET); + assert!(row.paid_at.is_some()); +} + +#[test] +fn split_claims_pay_exact_amounts_each() { + let ctx = setup(); + let id = create_single(&ctx, dist_60_40(&ctx.env)); + let token = token::Client::new(&ctx.env, &ctx.token_addr); + + 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); + + ctx.events + .claim_prize(&id, &2_u32, &BytesN::random(&ctx.env)); + assert_eq!(token.balance(&b), TOTAL_BUDGET * 40 / 100); + assert_eq!( + ctx.events.get_event(&id).status, + EventStatus::Active, + "event stays active until every prize is claimed" + ); + + ctx.events + .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); + assert_eq!(token.balance(&a), TOTAL_BUDGET * 60 / 100); + assert_eq!(ctx.events.get_event(&id).status, EventStatus::Completed); +} + +#[test] +fn topup_after_selection_stays_residual_for_refund() { + let ctx = setup(); + let id = create_single(&ctx, dist_100(&ctx.env)); + let token = token::Client::new(&ctx.env, &ctx.token_addr); + let token_admin = token::StellarAssetClient::new(&ctx.env, &ctx.token_addr); + + let w = Address::generate(&ctx.env); + select_one(&ctx, id, &w, 1, 0); + + // A partner tops up AFTER selection: the prize amount stays anchored + // to the selection-time baseline; the top-up is refundable residual. + let p = Address::generate(&ctx.env); + let c = 500_0000000_i128; + let fee = c * FEE_BPS as i128 / 10_000_i128; + token_admin.mint(&p, &(c + fee)); + ctx.events.add_funds(&id, &p, &c, &BytesN::random(&ctx.env)); + + ctx.events + .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); + assert_eq!(token.balance(&w), TOTAL_BUDGET); + + let after = ctx.events.get_event(&id); + assert_eq!(after.status, EventStatus::Active); + assert_eq!(after.remaining_escrow, c); + + // With every prize claimed, the manager can cancel and the partner + // gets their contribution back. + drive_cancel(&ctx.env, &ctx.events, id); + assert_eq!(token.balance(&p), c); +} + +// ============================================================ +// Auth surface +// ============================================================ + +#[test] +fn claim_requires_recipient_auth() { + let ctx = setup(); + let id = create_single(&ctx, dist_100(&ctx.env)); + + let w = Address::generate(&ctx.env); + select_one(&ctx, id, &w, 1, 0); + + ctx.env.mock_auths(&[]); + let res = ctx + .events + .try_claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); + assert!(res.is_err(), "claim without the winner's auth must revert"); +} + +#[test] +fn claim_demands_the_award_recipients_auth_specifically() { + let ctx = setup(); + let id = create_single(&ctx, dist_100(&ctx.env)); + + let w = Address::generate(&ctx.env); + select_one(&ctx, id, &w, 1, 0); + + ctx.events + .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); + + let auths = ctx.env.auths(); + assert!( + auths.iter().any(|(addr, _)| *addr == w), + "the recorded recipient must be the authorizing address" + ); +} + +// ============================================================ +// Replay and not-found guards +// ============================================================ + +#[test] +fn double_claim_reverts() { + let ctx = setup(); + let id = create_single(&ctx, dist_60_40(&ctx.env)); + + let w = Address::generate(&ctx.env); + select_one(&ctx, id, &w, 1, 0); + + ctx.events + .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); + let res = ctx + .events + .try_claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); + assert!( + res.is_err(), + "second claim of the same position must revert" + ); +} + +#[test] +fn op_id_replay_reverts() { + let ctx = setup(); + let id = create_single(&ctx, dist_60_40(&ctx.env)); + + let a = Address::generate(&ctx.env); + let b = Address::generate(&ctx.env); + select_one(&ctx, id, &a, 1, 0); + select_one(&ctx, id, &b, 2, 0); + + let op = BytesN::random(&ctx.env); + ctx.events.claim_prize(&id, &1_u32, &op); + let res = ctx.events.try_claim_prize(&id, &2_u32, &op); + assert!(res.is_err(), "replaying an op_id must revert"); +} + +#[test] +fn claim_of_unawarded_position_reverts() { + let ctx = setup(); + let id = create_single(&ctx, dist_60_40(&ctx.env)); + + let w = Address::generate(&ctx.env); + select_one(&ctx, id, &w, 1, 0); + + // Position 2 is in the distribution but has no award yet. + let res = ctx + .events + .try_claim_prize(&id, &2_u32, &BytesN::random(&ctx.env)); + assert!(res.is_err(), "claiming an unawarded position must revert"); + + // Position 9 is not even in the distribution. + let res = ctx + .events + .try_claim_prize(&id, &9_u32, &BytesN::random(&ctx.env)); + assert!(res.is_err(), "claiming an unknown position must revert"); +} + +#[test] +fn claim_on_multi_release_event_reverts() { + let ctx = setup(); + let params = CreateEventParams { + pillar: Pillar::Grant, + owner: ctx.owner.clone(), + token: ctx.token_addr.clone(), + total_budget: TOTAL_BUDGET, + release_kind: ReleaseKind::Multi(2), + 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), + 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); + + let res = ctx + .events + .try_claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); + assert!( + res.is_err(), + "claim_prize on a Multi-release event must revert" + ); +} + +// ============================================================ +// Cancel gate and the claim-window escape hatch +// ============================================================ + +#[test] +fn cancel_blocked_while_unclaimed_prizes_within_window() { + let ctx = setup(); + let id = create_single(&ctx, dist_60_40(&ctx.env)); + + let w = Address::generate(&ctx.env); + select_one(&ctx, id, &w, 1, 0); + + let res = ctx.events.try_start_cancel(&id, &BytesN::random(&ctx.env)); + assert!( + res.is_err(), + "cancel must be blocked while prizes are unclaimed in-window" + ); + + // Once the winner claims, cancellation may sweep the residual 40%. + let token = token::Client::new(&ctx.env, &ctx.token_addr); + ctx.events + .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); + let owner_before = token.balance(&ctx.owner); + drive_cancel(&ctx.env, &ctx.events, id); + assert_eq!( + token.balance(&ctx.owner) - owner_before, + TOTAL_BUDGET * 40 / 100 + ); +} + +#[test] +fn cancel_after_window_expiry_sweeps_unclaimed_and_blocks_late_claim() { + let ctx = setup(); + let id = create_single(&ctx, dist_100(&ctx.env)); + let token = token::Client::new(&ctx.env, &ctx.token_addr); + + let w = Address::generate(&ctx.env); + select_one(&ctx, id, &w, 1, 0); + + ctx.env.ledger().with_mut(|li| { + li.timestamp += PRIZE_CLAIM_WINDOW_SECS + 1; + }); + + let owner_before = token.balance(&ctx.owner); + drive_cancel(&ctx.env, &ctx.events, id); + assert_eq!( + token.balance(&ctx.owner) - owner_before, + TOTAL_BUDGET, + "expired unclaimed prize sweeps back through the refund path" + ); + + let res = ctx + .events + .try_claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); + assert!(res.is_err(), "claim after cancellation must revert"); + assert_eq!(token.balance(&w), 0); +} + +#[test] +fn claim_window_refreshes_on_a_later_batch() { + let ctx = setup(); + let id = create_single(&ctx, dist_60_40(&ctx.env)); + + let a = Address::generate(&ctx.env); + select_one(&ctx, id, &a, 1, 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); + + // Past the FIRST batch's expiry, but inside the refreshed window: + // cancel stays blocked, protecting the late-selected winner. + ctx.env.ledger().with_mut(|li| { + li.timestamp += 200; + }); + let res = ctx.events.try_start_cancel(&id, &BytesN::random(&ctx.env)); + assert!( + res.is_err(), + "refreshed window must keep protecting unclaimed winners" + ); +} + +// ============================================================ +// Storage-layout guard for pre-1.3.0 winner rows +// ============================================================ + +// Winner as persisted up to 1.2.0. Adding a field to types::Winner breaks +// this test (UnexpectedSize) because deployed rows would fail to decode on +// upgrade. Persist new per-winner data under new DataKeys instead. +#[contracttype] +#[derive(Clone, Debug)] +pub struct WinnerRowV1 { + pub recipient: Address, + pub position: u32, + pub amount: i128, + pub milestone: Option, + pub paid_at: Option, +} + +#[test] +fn pre_upgrade_winner_rows_still_decode() { + let ctx = setup(); + let recipient = Address::generate(&ctx.env); + + ctx.env.as_contract(&ctx.events.address, || { + ctx.env.storage().persistent().set( + &DataKey::EventWinnerAt(7u64, 0u32), + &WinnerRowV1 { + recipient: recipient.clone(), + position: 1, + amount: 1_000, + milestone: None, + paid_at: Some(12_345), + }, + ); + ctx.env + .storage() + .persistent() + .set(&DataKey::EventWinnerCount(7u64), &1u32); + }); + + let row = ctx.env.as_contract(&ctx.events.address, || { + crate::storage::winner_at(&ctx.env, 7, 0) + }); + assert!( + row.is_some(), + "pre-1.3.0 Winner rows must stay readable after upgrade" + ); + assert_eq!(row.unwrap().recipient, recipient); +} diff --git a/contracts/events/src/types.rs b/contracts/events/src/types.rs index f1909dc..2411594 100644 --- a/contracts/events/src/types.rs +++ b/contracts/events/src/types.rs @@ -195,6 +195,23 @@ pub enum DataKey { // Appended in 1.2.0 to preserve existing key discriminants. NonOwnerContributionTotal(u64), + + // Appended in 1.3.0 to preserve existing key discriminants. + EventPrizeAward(u64, u32), + EventUnclaimedPrizes(u64), + EventPrizeBaseEscrow(u64), + EventPrizeClaimExpiry(u64), +} + +// ============================================================ +// PRIZE AWARD payload (keyed by (event, position); pull-model claims) +// ============================================================ +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PrizeAward { + pub recipient: Address, + pub anchor_idx: u32, + pub reputation_bump: u32, } // ============================================================