diff --git a/BACKLOG.md b/BACKLOG.md index a077ef9..20eb482 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -28,6 +28,10 @@ See `docs/audit-2026-06-stellar-skill.md` for full findings. - [x] **Domain subscribers ignore the new cancel kinds.** 2026-06-05: all four subscribers (`BountyEscrowSubscriber`, `HackathonEscrowSubscriber`, `GrantEscrowSubscriber`, `CrowdfundingEscrowSubscriber`) now handle `FINALIZE_CANCEL` (mark domain row CANCELLED) and the `START_CANCEL` OwnerOnly inline path (read on-chain status; if already Cancelled, mirror). Tests green (events + queues + escrow contract suites = 133/133). - [x] **Crowdfunding `claim_milestone` orchestrator path needs admin co-sign.** 2026-06-05: new `AdminSorobanAuthSignerService` finds the SorobanAuthorizationEntry whose address matches the configured admin and signs it via `authorizeEntry()` from `@stellar/stellar-base`. Smoke helper exposes `adminPreSign: true` on `driveToCompletion`; the crowdfunding claim path passes that flag. Verified end-to-end on testnet — all three milestone claims confirmed with `signed 1 admin auth entry` in the log and the builder receiving the full 900 TUSD across three claim txs. +- [ ] **RESTORE the upgrade timelock before the first funded mainnet campaign.** `UPGRADE_TIMELOCK_LEDGERS` was set to 0 on both contracts on 2026-08-18 so the 1.7.0 rollout could iterate, while mainnet escrow was empty (both events `Completed`, `remaining_escrow` 0). This reverses audited control H6. With no window, a compromised 2-of-3 admin can `propose_upgrade` and `apply_upgrade` in one go and `cancel_pending_upgrade` never gets a chance to fire — the whole point of the control. Restore to `17_280` in `contracts/events/src/admin.rs` and `contracts/profile/src/admin.rs`, and flip the two `apply_upgrade_is_immediate_while_the_timelock_is_zero*` tests back to asserting `UpgradeTimelockNotElapsed`. The cfg split was kept so this is a single-value edit per contract. **The pending third-party audit will flag this if it is still 0 at audit time.** 2026-09-11: the premise no longer holds — mainnet `version()` reads 1.7.0 on events / 1.2.0 on profile (wasm hashes match the 2026-08 runbook build) and three Active bounties hold 1,030 USDC (`…611`, `…612`, `…613`) under the zeroed timelock. Restore is now the next admin operation. Disclosed as EoP.10 in `docs/threat-model.md` v1.4. + +- [ ] **Finish the 1.7.0 mainnet migration and record the apply.** 2026-09-11 live read: events `get_migrated_to_version()` = `1.6.0`, profile = `null`, `get_pending_upgrade` = null on both. `migrate_events` was never run, so the two pre-1.7.0 rows (`…609`, `…610`) trap on every read with `Error(Value, InvalidInput)` / `symbol mismatch: release_kind`; new-format events are unaffected and no escrow is at risk (both legacy rows were `Completed`, escrow 0). Steps: `migrate_events(max_events=8)` once (returns 0 remaining) → `migrate()` on events → `migrate()` on profile (stamps 1.2.0) → append the apply + migrate txs to `deployments/mainnet-upgrades.jsonl` and bump `deployments/mainnet.json`. Then add a runbook gate that refuses to sign `apply_upgrade` unless the migration steps are queued in the same signing session. + ## P1 (post-launch) - [x] `select_winners` re-run semantics: 1.3.0 (#61) made Single-release selection batchable — each position is awardable exactly once (per-position `EventPrizeAward` key is the replay lock), amounts stay anchored to the baseline captured at the first batch, and pre-1.3.0 events remain one-shot. (2026-07-18) diff --git a/contracts/events/src/admin.rs b/contracts/events/src/admin.rs index ff672b5..ad5f131 100644 --- a/contracts/events/src/admin.rs +++ b/contracts/events/src/admin.rs @@ -1,21 +1,52 @@ -use soroban_sdk::{panic_with_error, Address, BytesN, Env, String}; +use soroban_sdk::{contracttype, panic_with_error, Address, BytesN, Env, Map, String, Symbol, Val}; use crate::errors::Error; use crate::events as evt; +use crate::idempotency; use crate::storage; -use crate::types::{PendingAdmin, PendingUpgrade}; +use crate::types::{ + DataKey, EventRecord, EventStatus, PendingAdmin, PendingUpgrade, Pillar, ReleaseKind, Winner, +}; + +/// The pre-1.7.0 `EventRecord`, kept only so `migrate` can decode rows written +/// before prize floors replaced the percentage distribution. Nothing else may +/// read or write this shape. +#[contracttype] +#[derive(Clone)] +struct LegacyEventRecord { + pub id: u64, + pub pillar: Pillar, + pub owner: Address, + pub token: Address, + pub total_budget: i128, + pub remaining_escrow: i128, + pub release_kind: ReleaseKind, + pub status: EventStatus, + pub content_uri: String, + pub title: String, + pub created_at: u64, + pub deadline: Option, + pub winner_distribution: Map, + pub fee_bps_override: Option, +} const PENDING_ADMIN_TTL_LEDGERS: u32 = 120_960; pub(crate) const MAX_FEE_BPS: u32 = 1_000; +// H6 (audit 2026-06) mandated 17_280 ledgers, ~1 day, on mainnet. Zeroed +// deliberately while mainnet escrow is empty so the 1.7.0 rollout can iterate. +// RESTORE to 17_280 before the first funded campaign: with no window, a +// compromised admin key can propose and apply a wasm swap in one go, and +// cancel_pending_upgrade never gets a chance to fire. Tracked in BACKLOG. +// The cfg split is kept so restoring is a single-value edit. #[cfg(not(feature = "testnet"))] -const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; +const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; #[cfg(feature = "testnet")] const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; -pub const INITIAL_VERSION: &str = "1.6.0"; +pub const INITIAL_VERSION: &str = "1.7.0"; // ============================================================ // INITIALIZATION @@ -250,6 +281,16 @@ pub fn migrate(env: &Env) -> Result<(), Error> { // ============================================================ // PER-(from -> to) MIGRATION DISPATCH // ============================================================ + // Refuse to stamp while any event is still unconverted. The rewrite is + // paged through `migrate_events` because one invocation may touch only 100 + // ledger entries, and stamping early would leave the remainder undecodable + // with no way to resume: this is one-shot. + // + // Reuses EventIdOverflow rather than adding a variant — contracterror is at + // the 50-case cap. It means "events remain", not a counter fault. + if migration_remaining(env) > 0 { + return Err(Error::EventIdOverflow); + } storage::set_migrated_to_version(env, ¤t); storage::touch_instance(env); @@ -261,6 +302,192 @@ pub fn migrate(env: &Env) -> Result<(), Error> { Ok(()) } +/// How many events `migrate_events` has yet to convert. +fn migration_remaining(env: &Env) -> u64 { + let base = idempotency::id_base(env); + let first = base.saturating_add(1); + let next = storage::get_next_event_id(env, first); + let cursor = storage::get_migration_cursor(env).unwrap_or(first); + next.saturating_sub(cursor.max(first)) +} + +/// Converts up to `max_events` events from the pre-1.7.0 percentage layout, +/// advancing a stored cursor. Returns how many remain, so an operator can loop +/// until it reports zero and only then call `migrate`. +/// +/// Paged rather than one-shot because an invocation may touch at most 100 +/// ledger entries and write 50. A deployment with real history — testnet holds +/// over a hundred events — cannot be converted in a single transaction, and a +/// one-shot pass that aborts leaves every event undecodable. +pub fn migrate_events(env: &Env, max_events: u32) -> Result { + require_admin(env)?; + + // Each event costs a record read plus a record write, and a Multi event + // adds a read and a write per winner. Eight leaves headroom for the winner + // rewrites inside the write limit. + const MAX_PER_CALL: u32 = 8; + let budget = if max_events == 0 || max_events > MAX_PER_CALL { + MAX_PER_CALL + } else { + max_events + }; + + let base = idempotency::id_base(env); + let first = base.saturating_add(1); + let next = storage::get_next_event_id(env, first); + let mut cursor = storage::get_migration_cursor(env) + .unwrap_or(first) + .max(first); + + let mut done: u32 = 0; + while cursor < next && done < budget { + migrate_one_event(env, cursor); + cursor = cursor.saturating_add(1); + done = done.saturating_add(1); + } + + storage::set_migration_cursor(env, cursor); + storage::touch_instance(env); + Ok(next.saturating_sub(cursor)) +} + +/// Rewrites one event from the pre-1.7.0 percentage layout to prize floors. +/// `winner_distribution` and `prize_floors` differ in both name and value type, +/// so an old row cannot be decoded by the current struct at all. +/// +/// Percentages were always taken against the escrow balance, so `total_budget * +/// percent / 100` reproduces exactly what each position would have been paid. +fn migrate_one_event(env: &Env, id: u64) { + let key = DataKey::Event(id); + // Decode defensively. `get::` unwraps the conversion, + // and a missing field escalates to a host error rather than a catchable + // one, so a row already in the 1.7.0 layout would abort the whole + // invocation instead of being skipped. A contracttype struct is stored as + // a map keyed by field name, so the old layout is identified by the field + // that only it carries. + let fields: Option> = env.storage().persistent().get(&key); + let is_legacy = fields.is_some_and(|f| f.contains_key(Symbol::new(env, "winner_distribution"))); + if is_legacy { + if let Some(old) = env + .storage() + .persistent() + .get::(&key) + { + let mut floors: Map = Map::new(env); + for (position, percent) in old.winner_distribution.iter() { + let floor = old + .total_budget + .saturating_mul(percent as i128) + .saturating_div(100); + if floor > 0 { + floors.set(position, floor); + } + } + let migrated = EventRecord { + id: old.id, + pillar: old.pillar, + owner: old.owner, + token: old.token, + total_budget: old.total_budget, + remaining_escrow: old.remaining_escrow, + release_kind: old.release_kind, + status: old.status, + content_uri: old.content_uri, + title: old.title, + created_at: old.created_at, + deadline: old.deadline, + prize_floors: floors, + fee_bps_override: old.fee_bps_override, + }; + env.storage().persistent().set(&key, &migrated); + } + } + migrate_winner_amounts(env, id); + migrate_owed_total(env, id); +} + +/// Rebuild `EventOwedTotal` for a migrated event. +/// +/// 1.7.0 reserves awarded-but-unclaimed prizes, because `remaining_escrow` +/// only drops at claim time and a second selection would otherwise see funds +/// an earlier winner is still entitled to. Pre-1.7.0 state has no such +/// counter, so a migrated event would report nothing reserved while a winner +/// was still owed, and the next batch could promise the same money twice. +/// +/// Outstanding is the same quantity the live path maintains: award amounts +/// that have not been paid out, less whatever milestone rows already paid. +/// A Single award marks its anchor `paid_at` on claim; a grant leaves the +/// anchor open and appends one paid row per milestone. +fn migrate_owed_total(env: &Env, event_id: u64) { + let count = storage::winner_count(env, event_id); + let mut awarded: i128 = 0; + let mut paid: i128 = 0; + for idx in 0..count { + let w = match storage::winner_at(env, event_id, idx) { + Some(w) => w, + None => continue, + }; + match w.milestone { + None => { + if w.paid_at.is_none() { + awarded = awarded.saturating_add(w.amount); + } + } + Some(_) => { + paid = paid.saturating_add(w.amount); + } + } + } + let owed = awarded.saturating_sub(paid); + if owed > 0 { + storage::set_owed_total(env, event_id, owed); + } +} + +/// Pre-1.7.0 `Multi` selections stored `amount: 0` on the anchor winner row, +/// because a grant milestone derived its payout from the percentage +/// distribution at claim time. `claim_milestone` now reads that amount, so an +/// unrewritten row would compute a payout of zero and revert on every claim, +/// with no way to re-select and no exit but cancelling the grant. +/// +/// The floor for the winner's position is exactly what the old formula would +/// have produced, since both are `total_budget * percent / 100`. +fn migrate_winner_amounts(env: &Env, event_id: u64) { + let event = match storage::get_event(env, event_id) { + Some(e) => e, + None => return, + }; + if !matches!(event.release_kind, ReleaseKind::Multi(_)) { + return; + } + let count = storage::winner_count(env, event_id); + for idx in 0..count { + let w = match storage::winner_at(env, event_id, idx) { + Some(w) => w, + None => continue, + }; + // Milestone rows already carry what was actually paid; only the anchor + // was written with a placeholder amount. + if w.milestone.is_some() || w.amount != 0 { + continue; + } + if let Some(floor) = event.prize_floors.get(w.position) { + storage::set_winner_at( + env, + event_id, + idx, + &Winner { + recipient: w.recipient.clone(), + position: w.position, + amount: floor, + milestone: None, + paid_at: w.paid_at, + }, + ); + } + } +} + // ============================================================ // READS // ============================================================ diff --git a/contracts/events/src/bounty.rs b/contracts/events/src/bounty.rs index 428a58b..da3d6a5 100644 --- a/contracts/events/src/bounty.rs +++ b/contracts/events/src/bounty.rs @@ -65,7 +65,7 @@ pub fn withdraw_application( applicant.require_auth(); idempotency::require_unseen(env, &applicant, &op_id)?; - if storage::get_submission(env, bounty_id, &applicant).is_some() { + if storage::has_any_submission(env, bounty_id, &applicant) { return Err(Error::SubmissionAlreadyExists); } diff --git a/contracts/events/src/crowdfunding.rs b/contracts/events/src/crowdfunding.rs index 04a98ff..29a6cd3 100644 --- a/contracts/events/src/crowdfunding.rs +++ b/contracts/events/src/crowdfunding.rs @@ -11,16 +11,7 @@ pub fn validate_create(_env: &Env, record: &EventRecord, _owner: &Address) -> Re _ => return Err(Error::InvalidReleaseKind), } - if record.winner_distribution.len() != 1 { - return Err(Error::InvalidDistribution); - } - let percent = record - .winner_distribution - .get(1) - .ok_or(Error::InvalidDistribution)?; - if percent != 100 { - return Err(Error::DistributionMismatch); - } - + // No floor check: crowdfunding pays milestones out of `remaining_escrow` + // divided by the milestones left, and never reads the prize floors. Ok(()) } diff --git a/contracts/events/src/event_ops.rs b/contracts/events/src/event_ops.rs index 54ab7d6..949330d 100644 --- a/contracts/events/src/event_ops.rs +++ b/contracts/events/src/event_ops.rs @@ -74,14 +74,22 @@ pub fn create_event(env: &Env, params: CreateEventParams, op_id: BytesN<32>) -> return Err(Error::InvalidBudget); } - if params.winner_distribution.is_empty() { + if params.prize_floors.is_empty() { return Err(Error::InvalidDistribution); } - let mut sum: u32 = 0; - for (_pos, percent) in params.winner_distribution.iter() { - sum = sum.saturating_add(percent); + // Floors may total less than the budget. The headroom is deliberate: it is + // what a later selection draws on to award a position that had no floor at + // create time. + let mut floor_sum: i128 = 0; + for (_pos, floor) in params.prize_floors.iter() { + if floor <= 0 { + return Err(Error::InvalidDistribution); + } + floor_sum = floor_sum + .checked_add(floor) + .ok_or(Error::DistributionMismatch)?; } - if sum != 100 { + if floor_sum > params.total_budget { return Err(Error::DistributionMismatch); } @@ -112,7 +120,7 @@ pub fn create_event(env: &Env, params: CreateEventParams, op_id: BytesN<32>) -> title: params.title.clone(), created_at: env.ledger().timestamp(), deadline: params.deadline, - winner_distribution: params.winner_distribution.clone(), + prize_floors: params.prize_floors.clone(), fee_bps_override: params.fee_bps_override, }; match params.pillar { @@ -367,6 +375,12 @@ pub fn start_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<(), E manager.require_auth(); idempotency::require_unseen(env, &manager, &op_id)?; + // Cancelling supersedes every award: both claim paths require Active, so + // from here nothing can be claimed and the reservation must be released + // rather than withheld. Withholding it would strand the funds with no + // path back out. The guard above is what protects prizes still claimable. + storage::set_owed_total(env, event_id, 0); + let remaining = event.remaining_escrow; let count = storage::contributor_count(env, event_id); let non_owner_total = get_or_init_non_owner_total(env, event_id)?; @@ -530,6 +544,7 @@ pub fn submit( env: &Env, event_id: u64, applicant: Address, + slot: u32, content_uri: String, op_id: BytesN<32>, ) -> Result<(), Error> { @@ -552,20 +567,20 @@ pub fn submit( return Err(Error::TitleTooLong); } - let existing = storage::get_submission(env, event_id, &applicant); + let existing = storage::get_submission(env, event_id, &applicant, slot); - if existing.is_none() { + if !storage::has_any_submission(env, event_id, &applicant) { let needs_application = matches!(event.pillar, Pillar::Bounty | Pillar::Grant); if needs_application && storage::applicant_slot(env, event_id, &applicant) == 0 { return Err(Error::ApplicantNotApplied); } } - // Count the submission before writing. There is no cap: each submission - // is its own ledger entry whose write and rent are paid by the - // submitter's transaction, so spam addresses fund their own storage and - // cannot lock real participants out of a full event. - storage::append_submission(env, event_id, &applicant)?; + // Count the slot before writing. There is no cap: each entry is its own + // ledger entry whose write and rent are paid by the submitter's + // transaction, so spam addresses fund their own storage and cannot lock + // real participants out of a full event. + storage::append_submission(env, event_id, &applicant, slot)?; let submitted_at = existing .as_ref() @@ -577,11 +592,12 @@ pub fn submit( content_uri: content_uri.clone(), submitted_at, }; - storage::set_submission(env, event_id, &applicant, &submission); + storage::set_submission(env, event_id, &applicant, slot, &submission); evt::Submitted { event_id, applicant: applicant.clone(), + slot, content_uri, } .publish(env); @@ -597,6 +613,7 @@ pub fn withdraw_submission( env: &Env, event_id: u64, applicant: Address, + slot: u32, op_id: BytesN<32>, ) -> Result<(), Error> { admin::require_not_paused(env)?; @@ -609,15 +626,16 @@ pub fn withdraw_submission( applicant.require_auth(); idempotency::require_unseen(env, &applicant, &op_id)?; - if storage::get_submission(env, event_id, &applicant).is_none() { + if storage::get_submission(env, event_id, &applicant, slot).is_none() { return Err(Error::SubmissionNotFound); } - storage::remove_submission(env, event_id, &applicant); + storage::remove_submission(env, event_id, &applicant, slot); evt::SubmissionWithdrawn { event_id, applicant: applicant.clone(), + slot, } .publish(env); @@ -650,13 +668,9 @@ pub fn select_winners( let existing_count = storage::winner_count(env, event_id); match event.release_kind { - ReleaseKind::Single => { - // Winner rows but no base-escrow key means a pre-1.3.0 push-model - // event: keep it one-shot. New events award each position once. - if existing_count > 0 && storage::get_prize_base_escrow(env, event_id).is_none() { - return Err(Error::WinnersAlreadySelected); - } - } + // Single events award in as many batches as the manager likes; each + // position may be awarded once, enforced per position below. + ReleaseKind::Single => {} ReleaseKind::Multi(_) => { for idx in 0..existing_count { if let Some(w) = storage::winner_at(env, event_id, idx) { @@ -687,9 +701,6 @@ pub fn select_winners( if already { return Err(Error::DuplicateWinnerPosition); } - if event.winner_distribution.get(spec.position).is_none() { - return Err(Error::InvalidWinnerPosition); - } seen_positions.push_back(spec.position); } @@ -697,42 +708,39 @@ pub fn select_winners( match event.release_kind { ReleaseKind::Single => { - // Amounts are fixed against the escrow baseline captured at the - // first selection; claim_prize does the transfer and profile calls. - let base_escrow = match storage::get_prize_base_escrow(env, event_id) { - Some(b) => b, - None => { - let b = event.remaining_escrow; - storage::set_prize_base_escrow(env, event_id, b); - b - } - }; + // `remaining_escrow` only drops at claim time, so a prize named by + // an earlier selection is still sitting in it. Reserving the owed + // total is what stops a later selection promising the same funds + // twice and leaving the second winner unable to claim. + let owed_before = storage::owed_total(env, event_id); - let mut total_owed: i128 = 0; + let mut batch_total: i128 = 0; for spec in winners.iter() { if storage::get_prize_award(env, event_id, spec.position).is_some() { return Err(Error::DuplicateWinnerPosition); } - let percent = event - .winner_distribution - .get(spec.position) - .ok_or(Error::InvalidDistribution)? as i128; - let amount = base_escrow.saturating_mul(percent) / 100_i128; - if amount <= 0 { + if spec.amount <= 0 { return Err(Error::InvalidDistribution); } - total_owed = total_owed.saturating_add(amount); + if let Some(floor) = event.prize_floors.get(spec.position) { + if spec.amount < floor { + return Err(Error::InvalidDistribution); + } + } + batch_total = batch_total + .checked_add(spec.amount) + .ok_or(Error::InsufficientEscrow)?; } - if total_owed > event.remaining_escrow { + let committed = batch_total + .checked_add(owed_before) + .ok_or(Error::InsufficientEscrow)?; + if committed > event.remaining_escrow { return Err(Error::InsufficientEscrow); } + storage::set_owed_total(env, event_id, committed); for (idx, spec) in winners.iter().enumerate() { - let percent = event - .winner_distribution - .get(spec.position) - .ok_or(Error::InvalidDistribution)? as i128; - let amount = base_escrow.saturating_mul(percent) / 100_i128; + let amount = spec.amount; let anchor_idx = existing_count + (idx as u32); storage::append_winner( @@ -773,6 +781,32 @@ pub fn select_winners( } } ReleaseKind::Multi(_) => { + // Same reservation as Single: milestone claims drain + // `remaining_escrow` gradually, so without it a second grantee + // could be awarded funds the first is still owed. + let owed_before = storage::owed_total(env, event_id); + let mut batch_total: i128 = 0; + for spec in winners.iter() { + if spec.amount <= 0 { + return Err(Error::InvalidDistribution); + } + if let Some(floor) = event.prize_floors.get(spec.position) { + if spec.amount < floor { + return Err(Error::InvalidDistribution); + } + } + batch_total = batch_total + .checked_add(spec.amount) + .ok_or(Error::InsufficientEscrow)?; + } + let committed = batch_total + .checked_add(owed_before) + .ok_or(Error::InsufficientEscrow)?; + if committed > event.remaining_escrow { + return Err(Error::InsufficientEscrow); + } + storage::set_owed_total(env, event_id, committed); + for spec in winners.iter() { storage::append_winner( env, @@ -780,7 +814,7 @@ pub fn select_winners( &Winner { recipient: spec.recipient.clone(), position: spec.position, - amount: 0, + amount: spec.amount, milestone: None, paid_at: None, }, @@ -859,6 +893,17 @@ pub fn claim_prize( let unclaimed = storage::unclaimed_prize_count(env, event_id); storage::set_unclaimed_prize_count(env, event_id, unclaimed.saturating_sub(1)); + // Claiming converts owed into paid; both balances drop together so the + // reservation in select_winners stays exact. Checked rather than clamped: + // owed dropping below a claim means the reservation has already drifted, + // and swallowing that would let the next selection over-promise the pool. + let owed = storage::owed_total(env, event_id); + let owed_after = owed.checked_sub(amount).ok_or(Error::InsufficientEscrow)?; + if owed_after < 0 { + return Err(Error::InsufficientEscrow); + } + storage::set_owed_total(env, event_id, owed_after); + event.remaining_escrow = event.remaining_escrow.saturating_sub(amount); if event.remaining_escrow == 0 { event.status = EventStatus::Completed; @@ -906,8 +951,13 @@ pub fn get_event(env: &Env, event_id: u64) -> Result { storage::get_event(env, event_id).ok_or(Error::EventNotFound) } -pub fn get_submission(env: &Env, event_id: u64, applicant: Address) -> Result { - storage::get_submission(env, event_id, &applicant).ok_or(Error::SubmissionNotFound) +pub fn get_submission( + env: &Env, + event_id: u64, + applicant: Address, + slot: u32, +) -> Result { + storage::get_submission(env, event_id, &applicant, slot).ok_or(Error::SubmissionNotFound) } // Full-list getters return the first VIEW_PAGE_LIMIT entries; use the diff --git a/contracts/events/src/events.rs b/contracts/events/src/events.rs index ee6f240..aaabadb 100644 --- a/contracts/events/src/events.rs +++ b/contracts/events/src/events.rs @@ -76,6 +76,7 @@ pub struct ApplicationWithdrawn { pub struct Submitted { pub event_id: u64, pub applicant: Address, + pub slot: u32, pub content_uri: String, } @@ -83,6 +84,7 @@ pub struct Submitted { pub struct SubmissionWithdrawn { pub event_id: u64, pub applicant: Address, + pub slot: u32, } #[contractevent] diff --git a/contracts/events/src/grant.rs b/contracts/events/src/grant.rs index 5d02524..d14b8e9 100644 --- a/contracts/events/src/grant.rs +++ b/contracts/events/src/grant.rs @@ -57,6 +57,7 @@ pub fn claim_milestone( let count = storage::winner_count(env, event_id); let mut winner_position: Option = None; + let mut awarded_amount: i128 = 0; let mut already_claimed_for_recipient: u32 = 0; let mut already_paid_to_recipient: i128 = 0; for idx in 0..count { @@ -68,7 +69,10 @@ pub fn claim_milestone( continue; } match w.milestone { - None => winner_position = Some(w.position), + None => { + winner_position = Some(w.position); + awarded_amount = w.amount; + } Some(_) => { already_claimed_for_recipient = already_claimed_for_recipient.saturating_add(1); already_paid_to_recipient = already_paid_to_recipient.saturating_add(w.amount); @@ -93,11 +97,9 @@ pub fn claim_milestone( event.remaining_escrow / (remaining_milestones as i128) } } else { - let percent = event - .winner_distribution - .get(position) - .ok_or(Error::InvalidWinnerPosition)? as i128; - let total_share = event.total_budget.saturating_mul(percent) / 100_i128; + // The award carries its own amount, set at selection. Milestones split + // that, not a share of the budget. + let total_share = awarded_amount; let per_milestone_floored = total_share / (total_milestones as i128); if already_claimed_for_recipient.saturating_add(1) == total_milestones { @@ -120,6 +122,15 @@ pub fn claim_milestone( escrow::release(env, &event.token, &recipient, amount); } event.remaining_escrow = event.remaining_escrow.saturating_sub(amount); + if !is_crowdfunding { + // Crowdfunding never reserves, since it has no winner selection. + let owed = storage::owed_total(env, event_id); + let owed_after = owed.checked_sub(amount).ok_or(Error::InsufficientEscrow)?; + if owed_after < 0 { + return Err(Error::InsufficientEscrow); + } + storage::set_owed_total(env, event_id, owed_after); + } storage::mark_milestone_claimed(env, event_id, &recipient, milestone); if is_crowdfunding { let claimed = storage::get_crowdfunding_milestones_claimed(env, event_id); diff --git a/contracts/events/src/lib.rs b/contracts/events/src/lib.rs index 2011462..33978a1 100644 --- a/contracts/events/src/lib.rs +++ b/contracts/events/src/lib.rs @@ -24,7 +24,7 @@ mod tests; use crate::errors::Error; use crate::types::*; -contractmeta!(key = "version", val = "1.6.0"); +contractmeta!(key = "version", val = "1.7.0"); contractmeta!( key = "description", val = "Boundless events contract: hackathon, bounty, grant + escrow" @@ -96,6 +96,13 @@ impl EventsContract { admin::cancel_pending_upgrade(&env) } + /// Converts up to `max_events` pre-1.7.0 event records, returning how many + /// remain. Loop until it reports zero, then call `migrate`. Paged because + /// one invocation cannot touch every event of a deployment with history. + pub fn migrate_events(env: Env, max_events: u32) -> Result { + admin::migrate_events(&env, max_events) + } + pub fn migrate(env: Env) -> Result<(), Error> { admin::migrate(&env) } @@ -185,23 +192,28 @@ impl EventsContract { // ============================================================ // SUBMISSION // ============================================================ + /// `slot` separates several entries by the same wallet in one event. The + /// contract assigns it no meaning; re-submitting to an occupied slot + /// updates it in place. Callers with a single entry use slot 0. pub fn submit( env: Env, event_id: u64, applicant: Address, + slot: u32, content_uri: String, op_id: BytesN<32>, ) -> Result<(), Error> { - event_ops::submit(&env, event_id, applicant, content_uri, op_id) + event_ops::submit(&env, event_id, applicant, slot, content_uri, op_id) } pub fn withdraw_submission( env: Env, event_id: u64, applicant: Address, + slot: u32, op_id: BytesN<32>, ) -> Result<(), Error> { - event_ops::withdraw_submission(&env, event_id, applicant, op_id) + event_ops::withdraw_submission(&env, event_id, applicant, slot, op_id) } // ============================================================ @@ -270,8 +282,14 @@ impl EventsContract { env: Env, event_id: u64, applicant: Address, + slot: u32, ) -> Result { - event_ops::get_submission(&env, event_id, applicant) + event_ops::get_submission(&env, event_id, applicant, slot) + } + + /// How many slots this applicant occupies in this event. + pub fn get_applicant_submission_count(env: Env, event_id: u64, applicant: Address) -> u32 { + storage::applicant_submission_count(&env, event_id, &applicant) } // Full-list getters return the first page (VIEW_PAGE_LIMIT entries); diff --git a/contracts/events/src/storage.rs b/contracts/events/src/storage.rs index 7eeae1b..9973154 100644 --- a/contracts/events/src/storage.rs +++ b/contracts/events/src/storage.rs @@ -146,6 +146,14 @@ pub fn clear_pending_upgrade(env: &Env) { env.storage().instance().remove(&DataKey::PendingUpgrade); } +pub fn get_migration_cursor(env: &Env) -> Option { + env.storage().instance().get(&DataKey::MigrationCursor) +} + +pub fn set_migration_cursor(env: &Env, id: u64) { + env.storage().instance().set(&DataKey::MigrationCursor, &id); +} + pub fn get_migrated_to_version(env: &Env) -> Option { env.storage().instance().get(&DataKey::MigratedToVersion) } @@ -419,32 +427,73 @@ pub fn applicants_snapshot(env: &Env, id: u64, start: u32, limit: u32) -> Vec Option { - let key = DataKey::EventSubmission(id, applicant.clone()); +pub fn get_submission(env: &Env, id: u64, applicant: &Address, slot: u32) -> Option { + let key = DataKey::EventSubmissionEntry(id, applicant.clone(), slot); let s: Option = env.storage().persistent().get(&key); if s.is_some() { touch_event_persistent(env, &key); + return s; } - s + // Pre-1.7.0 rows have no slot and `migrate` can only reach the ones whose + // applicant is in the applicant index, which hackathons never populate. + // Reading them as slot 0 keeps every historical submission addressable + // regardless of pillar; the first write folds them into the slotted layout. + if slot == 0 { + return get_legacy_submission(env, id, applicant); + } + None } -pub fn set_submission(env: &Env, id: u64, applicant: &Address, submission: &Submission) { - let key = DataKey::EventSubmission(id, applicant.clone()); +pub fn set_submission(env: &Env, id: u64, applicant: &Address, slot: u32, submission: &Submission) { + let key = DataKey::EventSubmissionEntry(id, applicant.clone(), slot); env.storage().persistent().set(&key, submission); touch_event_persistent(env, &key); } -pub fn remove_submission(env: &Env, id: u64, applicant: &Address) { - // Idempotent: a no-op when there is nothing to remove, symmetrically - // with append_submission, so a caller that skips its own existence - // check can't silently corrupt the counter by decrementing for an - // applicant that never had a submission. - if get_submission(env, id, applicant).is_none() { +/// How many slots this applicant currently occupies in this event. Drives the +/// O(1) "has this applicant submitted at all" check that gates application +/// withdrawal, which would otherwise have to scan slots. +pub fn applicant_submission_count(env: &Env, id: u64, applicant: &Address) -> u32 { + let key = DataKey::EventApplicantSubmissionCount(id, applicant.clone()); + let n: Option = env.storage().persistent().get(&key); + if n.is_some() { + touch_event_persistent(env, &key); + } + n.unwrap_or(0) +} + +pub fn has_any_submission(env: &Env, id: u64, applicant: &Address) -> bool { + applicant_submission_count(env, id, applicant) > 0 + || get_legacy_submission(env, id, applicant).is_some() +} + +fn set_applicant_submission_count(env: &Env, id: u64, applicant: &Address, count: u32) { + let key = DataKey::EventApplicantSubmissionCount(id, applicant.clone()); + if count == 0 { + env.storage().persistent().remove(&key); + return; + } + env.storage().persistent().set(&key, &count); + touch_event_persistent(env, &key); +} + +pub fn remove_submission(env: &Env, id: u64, applicant: &Address, slot: u32) { + // Idempotent: a no-op when the slot is empty, so a caller that skips its + // own existence check cannot corrupt either counter. + if get_submission(env, id, applicant, slot).is_none() { return; } - let key = DataKey::EventSubmission(id, applicant.clone()); + let key = DataKey::EventSubmissionEntry(id, applicant.clone(), slot); env.storage().persistent().remove(&key); + // An unmigrated row is addressed as slot 0; drop it too or it would keep + // answering reads after the withdrawal. + if slot == 0 { + remove_legacy_submission(env, id, applicant); + } + + let per_applicant = applicant_submission_count(env, id, applicant).saturating_sub(1); + set_applicant_submission_count(env, id, applicant, per_applicant); let count_key = DataKey::EventSubmissionCount(id); let next = submission_count(env, id).saturating_sub(1); @@ -465,16 +514,29 @@ pub fn submission_count(env: &Env, id: u64) -> u32 { n.unwrap_or(0) } -/// Count a new submission before writing the entry (mirrors -/// `append_contributor`/`append_applicant`). A no-op when the applicant -/// already has a submission — re-submission updates the existing entry in -/// place and must not recount. +/// Count a newly occupied slot before writing it (mirrors +/// `append_contributor`/`append_applicant`). A no-op when the slot already +/// holds an entry — re-submitting to the same slot updates it in place and +/// must not recount. /// -/// Returns `Error::TooManyContributors` only on u32 counter overflow — -/// reused rather than a new variant since the errors enum is at the -/// 50-case XDR cap. -pub fn append_submission(env: &Env, id: u64, addr: &Address) -> Result<(), Error> { - if get_submission(env, id, addr).is_some() { +/// Returns `Error::TooManyContributors` only on u32 counter overflow — reused +/// rather than a new variant since the errors enum is at the 50-case XDR cap. +pub fn append_submission(env: &Env, id: u64, addr: &Address, slot: u32) -> Result<(), Error> { + // Fold an unmigrated pre-1.7.0 row into the slotted layout on first write, + // so the legacy key cannot linger and disagree with the counters. It was + // already counted in the per-event total before the upgrade. + if slot == 0 && get_legacy_submission(env, id, addr).is_some() { + remove_legacy_submission(env, id, addr); + // Increment rather than set: the applicant may already hold slotted + // entries, and folding adds one more. Setting it to 1 would undercount + // and let has_any_submission go false while a slot is still occupied. + let per_applicant = applicant_submission_count(env, id, addr) + .checked_add(1) + .ok_or(Error::TooManyContributors)?; + set_applicant_submission_count(env, id, addr, per_applicant); + return Ok(()); + } + if get_submission(env, id, addr, slot).is_some() { return Ok(()); } let cur = submission_count(env, id); @@ -482,9 +544,36 @@ pub fn append_submission(env: &Env, id: u64, addr: &Address) -> Result<(), Error let count_key = DataKey::EventSubmissionCount(id); env.storage().persistent().set(&count_key, &next); touch_event_persistent(env, &count_key); + + let per_applicant = applicant_submission_count(env, id, addr) + .checked_add(1) + .ok_or(Error::TooManyContributors)?; + set_applicant_submission_count(env, id, addr, per_applicant); Ok(()) } +/// Reads a pre-1.7.0 submission row, which lived under a key with no slot. +/// Only `migrate` calls this. +pub fn get_legacy_submission(env: &Env, id: u64, applicant: &Address) -> Option { + let key = DataKey::EventSubmission(id, applicant.clone()); + let s: Option = env.storage().persistent().get(&key); + // This key is a permanent read path, not just a migration source: hackathon + // submitters never enter the applicant index, so their rows are only ever + // reachable here. Without the touch they archive and the submission + // silently disappears. + if s.is_some() { + touch_event_persistent(env, &key); + } + s +} + +/// Drops a pre-1.7.0 submission row once it has been copied to a slot. +pub fn remove_legacy_submission(env: &Env, id: u64, applicant: &Address) { + env.storage() + .persistent() + .remove(&DataKey::EventSubmission(id, applicant.clone())); +} + // ============================================================ // WINNERS (paged, persistent) // ============================================================ @@ -557,6 +646,21 @@ pub fn set_unclaimed_prize_count(env: &Env, id: u64, count: u32) { touch_event_persistent(env, &key); } +pub fn owed_total(env: &Env, id: u64) -> i128 { + let key = DataKey::EventOwedTotal(id); + let t: Option = env.storage().persistent().get(&key); + if t.is_some() { + touch_event_persistent(env, &key); + } + t.unwrap_or(0) +} + +pub fn set_owed_total(env: &Env, id: u64, owed: i128) { + let key = DataKey::EventOwedTotal(id); + env.storage().persistent().set(&key, &owed); + touch_event_persistent(env, &key); +} + pub fn get_prize_base_escrow(env: &Env, id: u64) -> Option { let key = DataKey::EventPrizeBaseEscrow(id); let b: Option = env.storage().persistent().get(&key); diff --git a/contracts/events/src/tests/admin.rs b/contracts/events/src/tests/admin.rs index 4199fd8..c0ab031 100644 --- a/contracts/events/src/tests/admin.rs +++ b/contracts/events/src/tests/admin.rs @@ -2,13 +2,16 @@ use soroban_sdk::{ testutils::{Address as _, BytesN as _, Ledger}, - Address, BytesN, String, + Address, BytesN, Map, String, }; use super::common::setup; use crate::errors::Error; +use crate::storage; +use crate::types::{DataKey, EventStatus, Pillar, ReleaseKind, Submission, Winner}; -const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; +// Zeroed with the contract constant; restore both together. +const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; #[test] @@ -19,7 +22,7 @@ fn initializes_with_expected_config() { assert_eq!(ctx.client.get_fee_bps(), 250); assert_eq!(ctx.client.get_profile_contract(), ctx.profile_contract); assert_eq!(ctx.client.is_paused(), false); - assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.6.0")); + assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.7.0")); assert_eq!(ctx.client.get_pending_upgrade(), None); assert_eq!(ctx.client.get_migrated_to_version(), None); } @@ -83,20 +86,22 @@ fn propose_upgrade_rejects_empty_version() { } #[test] -fn apply_upgrade_before_timelock_reverts() { +fn apply_upgrade_is_immediate_while_the_timelock_is_zero() { + // H6 mandated a ~1-day window here; it is deliberately zero while mainnet + // escrow is empty. This test is the counterpart to restoring it: when + // UPGRADE_TIMELOCK_LEDGERS goes back to 17_280, this should revert with + // UpgradeTimelockNotElapsed instead. let ctx = setup(250); let new_hash: BytesN<32> = BytesN::random(&ctx.env); let new_version = String::from_str(&ctx.env, "0.3.0"); ctx.client.propose_upgrade(&new_hash, &new_version); - let err = ctx - .client - .try_apply_upgrade() - .err() - .expect("timelock blocks") - .unwrap(); - assert_eq!(err, Error::UpgradeTimelockNotElapsed); - assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.6.0")); + let pending = ctx.client.get_pending_upgrade().expect("proposal"); + assert_eq!( + pending.available_at_ledger, pending.proposed_at_ledger, + "no window between proposing and applying" + ); + assert_eq!(UPGRADE_TIMELOCK_LEDGERS, 0, "restore me with the constant"); } #[test] @@ -130,7 +135,7 @@ fn cancel_pending_upgrade_clears_proposal() { ctx.client.cancel_pending_upgrade(); assert_eq!(ctx.client.get_pending_upgrade(), None); - assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.6.0")); + assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.7.0")); } #[test] @@ -145,14 +150,456 @@ fn cancel_with_no_pending_reverts() { assert_eq!(err, Error::UpgradeNotProposed); } +/// The pre-1.7.0 record layout, written directly into storage so migrate has a +/// legacy row to convert. Mirrors what the two mainnet events look like. +#[soroban_sdk::contracttype] +#[derive(Clone)] +struct LegacyEventRecord { + pub id: u64, + pub pillar: Pillar, + pub owner: Address, + pub token: Address, + pub total_budget: i128, + pub remaining_escrow: i128, + pub release_kind: ReleaseKind, + pub status: EventStatus, + pub content_uri: String, + pub title: String, + pub created_at: u64, + pub deadline: Option, + pub winner_distribution: Map, + pub fee_bps_override: Option, +} + +#[test] +fn migrate_rewrites_legacy_percentages_as_prize_floors() { + let ctx = setup(250); + let budget = 1_000_0000000_i128; + + // Stand in for mainnet event ...610: a 60/40 split, already settled. + let mut dist = Map::new(&ctx.env); + dist.set(1, 60_u32); + dist.set(2, 40_u32); + + let id_base = ctx.client.id_base(); + let event_id = id_base + 1; + let legacy = LegacyEventRecord { + id: event_id, + pillar: Pillar::Bounty, + owner: Address::generate(&ctx.env), + token: Address::generate(&ctx.env), + total_budget: budget, + remaining_escrow: 0, + release_kind: ReleaseKind::Single, + status: EventStatus::Completed, + content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/legacy"), + title: String::from_str(&ctx.env, "Muwa Creator Bounty"), + created_at: 1, + deadline: None, + winner_distribution: dist, + fee_bps_override: None, + }; + + ctx.env.as_contract(&ctx.client.address, || { + ctx.env + .storage() + .persistent() + .set(&DataKey::Event(event_id), &legacy); + ctx.env + .storage() + .instance() + .set(&DataKey::NextEventId, &(event_id + 1)); + }); + + ctx.client.migrate_events(&8_u32); + ctx.client.migrate(); + + // Readable again through the current struct, which could not decode it + // before, and the percentages have become the amounts they always meant. + let migrated = ctx.client.get_event(&event_id); + assert_eq!(migrated.prize_floors.get(1), Some(budget * 60 / 100)); + assert_eq!(migrated.prize_floors.get(2), Some(budget * 40 / 100)); + assert_eq!(migrated.total_budget, budget); + assert_eq!(migrated.status, EventStatus::Completed); + assert_eq!( + migrated.title, + String::from_str(&ctx.env, "Muwa Creator Bounty") + ); +} + +#[test] +fn migrate_leaves_legacy_submissions_addressable_without_scanning_applicants() { + // migrate deliberately does not walk the applicant index: that loop is + // unbounded (per-event caps were removed) and would blow the invocation's + // 100-entry footprint on a popular event. Legacy rows stay reachable + // through the slot-0 fallback instead, and fold in on their next write. + let ctx = setup(250); + let applicant = Address::generate(&ctx.env); + let event_id = ctx.client.id_base() + 1; + let budget = 1_000_0000000_i128; + + let mut dist = Map::new(&ctx.env); + dist.set(1, 100_u32); + let legacy_event = LegacyEventRecord { + id: event_id, + pillar: Pillar::Bounty, + owner: Address::generate(&ctx.env), + token: Address::generate(&ctx.env), + total_budget: budget, + remaining_escrow: 0, + release_kind: ReleaseKind::Single, + status: EventStatus::Completed, + content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/legacy"), + title: String::from_str(&ctx.env, "Legacy"), + created_at: 1, + deadline: None, + winner_distribution: dist, + fee_bps_override: None, + }; + + ctx.env.as_contract(&ctx.client.address, || { + ctx.env + .storage() + .persistent() + .set(&DataKey::Event(event_id), &legacy_event); + ctx.env + .storage() + .instance() + .set(&DataKey::NextEventId, &(event_id + 1)); + storage::append_applicant(&ctx.env, event_id, &applicant).unwrap(); + ctx.env.storage().persistent().set( + &DataKey::EventSubmission(event_id, applicant.clone()), + &Submission { + applicant: applicant.clone(), + content_uri: String::from_str(&ctx.env, "ipfs://historical"), + submitted_at: 42, + }, + ); + }); + + ctx.client.migrate_events(&8_u32); + ctx.client.migrate(); + + // The record rewrite is the load-bearing part and must have happened. + assert_eq!( + ctx.client.get_event(&event_id).prize_floors.get(1), + Some(budget) + ); + + // The submission is still addressable, with its timestamp intact. + let found = ctx.client.get_submission(&event_id, &applicant, &0_u32); + assert_eq!( + found.content_uri, + String::from_str(&ctx.env, "ipfs://historical") + ); + assert_eq!(found.submitted_at, 42); + ctx.env.as_contract(&ctx.client.address, || { + assert!(storage::has_any_submission(&ctx.env, event_id, &applicant)); + }); +} + +#[test] +fn legacy_submission_is_readable_when_the_applicant_index_never_saw_it() { + // Hackathon submitters never enter the applicant index, so migrate cannot + // enumerate them. Their rows must still be reachable as slot 0. + let ctx = setup(250); + let submitter = Address::generate(&ctx.env); + let event_id = ctx.client.id_base() + 1; + + let legacy_submission = Submission { + applicant: submitter.clone(), + content_uri: String::from_str(&ctx.env, "ipfs://hackathon-entry"), + submitted_at: 7, + }; + ctx.env.as_contract(&ctx.client.address, || { + ctx.env.storage().persistent().set( + &DataKey::EventSubmission(event_id, submitter.clone()), + &legacy_submission, + ); + }); + + let found = ctx.client.get_submission(&event_id, &submitter, &0_u32); + assert_eq!( + found.content_uri, + String::from_str(&ctx.env, "ipfs://hackathon-entry") + ); + + ctx.env.as_contract(&ctx.client.address, || { + assert!( + storage::has_any_submission(&ctx.env, event_id, &submitter), + "an unmigrated row must still block application withdrawal" + ); + }); +} + +#[test] +fn migrate_rewrites_zero_amount_grant_winners() { + // Pre-1.7.0 Multi selections stored amount 0 on the anchor row; leaving + // that would make every milestone claim revert with nothing to pay. + let ctx = setup(250); + let recipient = Address::generate(&ctx.env); + let event_id = ctx.client.id_base() + 1; + let budget = 1_000_0000000_i128; + + let mut dist = Map::new(&ctx.env); + dist.set(1, 100_u32); + let legacy_event = LegacyEventRecord { + id: event_id, + pillar: Pillar::Grant, + owner: Address::generate(&ctx.env), + token: Address::generate(&ctx.env), + total_budget: budget, + remaining_escrow: budget, + release_kind: ReleaseKind::Multi(2), + status: EventStatus::Active, + content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/grant"), + title: String::from_str(&ctx.env, "Legacy Grant"), + created_at: 1, + deadline: None, + winner_distribution: dist, + fee_bps_override: None, + }; + + ctx.env.as_contract(&ctx.client.address, || { + ctx.env + .storage() + .persistent() + .set(&DataKey::Event(event_id), &legacy_event); + ctx.env + .storage() + .instance() + .set(&DataKey::NextEventId, &(event_id + 1)); + storage::append_winner( + &ctx.env, + event_id, + &Winner { + recipient: recipient.clone(), + position: 1, + amount: 0, + milestone: None, + paid_at: None, + }, + ); + }); + + ctx.client.migrate_events(&8_u32); + ctx.client.migrate(); + + let rows = ctx.client.get_winners(&event_id); + assert_eq!( + rows.get(0).unwrap().amount, + budget, + "the anchor must carry what the old percentage would have paid" + ); +} + +#[test] +fn migrate_refuses_to_stamp_while_events_remain_unconverted() { + // One invocation may touch only 100 ledger entries, so a deployment with + // history is converted in slices. Stamping before the cursor reaches the + // end would strand the remainder in a layout nothing can decode, and + // migrate is one-shot. + let ctx = setup(250); + let base = ctx.client.id_base(); + ctx.env.as_contract(&ctx.client.address, || { + ctx.env + .storage() + .instance() + .set(&DataKey::NextEventId, &(base + 20)); + }); + + assert!( + ctx.client.try_migrate().is_err(), + "must not stamp while events are unconverted" + ); + assert_eq!(ctx.client.get_migrated_to_version(), None); + + // Page through: 8 per call, so 19 events need three calls. + assert_eq!(ctx.client.migrate_events(&8_u32), 11); + assert_eq!(ctx.client.migrate_events(&8_u32), 3); + assert!( + ctx.client.try_migrate().is_err(), + "still incomplete after two of three pages" + ); + assert_eq!(ctx.client.migrate_events(&8_u32), 0); + + ctx.client.migrate(); + assert_eq!( + ctx.client.get_migrated_to_version(), + Some(String::from_str(&ctx.env, "1.7.0")) + ); +} + +#[test] +fn migrate_events_caps_each_call_regardless_of_the_argument() { + let ctx = setup(250); + let base = ctx.client.id_base(); + ctx.env.as_contract(&ctx.client.address, || { + ctx.env + .storage() + .instance() + .set(&DataKey::NextEventId, &(base + 30)); + }); + + // Asking for more than the per-call ceiling must not blow the footprint. + assert_eq!(ctx.client.migrate_events(&1_000_u32), 21); + // 0 means "use the ceiling" rather than "do nothing". + assert_eq!(ctx.client.migrate_events(&0_u32), 13); +} + +#[test] +fn migrate_skips_rows_already_in_the_current_layout() { + // A fresh 1.7.0 deployment writes new-layout events, and the runbook still + // calls migrate() after apply. Decoding those as the legacy shape used to + // abort the whole invocation. + let ctx = setup(250); + let event_id = ctx.client.id_base() + 1; + let mut floors = Map::new(&ctx.env); + floors.set(1, 500_i128); + let current = crate::types::EventRecord { + id: event_id, + pillar: Pillar::Bounty, + owner: Address::generate(&ctx.env), + token: Address::generate(&ctx.env), + total_budget: 1_000_i128, + remaining_escrow: 1_000_i128, + release_kind: ReleaseKind::Single, + status: EventStatus::Active, + content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/new"), + title: String::from_str(&ctx.env, "Already Migrated"), + created_at: 1, + deadline: None, + prize_floors: floors, + fee_bps_override: None, + }; + ctx.env.as_contract(&ctx.client.address, || { + ctx.env + .storage() + .persistent() + .set(&DataKey::Event(event_id), ¤t); + ctx.env + .storage() + .instance() + .set(&DataKey::NextEventId, &(event_id + 1)); + }); + + ctx.client.migrate_events(&8_u32); + ctx.client.migrate(); + + let after = ctx.client.get_event(&event_id); + assert_eq!(after.prize_floors.get(1), Some(500_i128)); + assert_eq!(after.title, String::from_str(&ctx.env, "Already Migrated")); +} + +#[test] +fn migrate_runs_regardless_of_how_the_version_was_spelled() { + // propose_upgrade accepts any non-empty string. Gating the rewrite on an + // exact match would silently skip it and still stamp the marker. + let ctx = setup(250); + let event_id = ctx.client.id_base() + 1; + let budget = 1_000_0000000_i128; + let mut dist = Map::new(&ctx.env); + dist.set(1, 100_u32); + let legacy = LegacyEventRecord { + id: event_id, + pillar: Pillar::Bounty, + owner: Address::generate(&ctx.env), + token: Address::generate(&ctx.env), + total_budget: budget, + remaining_escrow: 0, + release_kind: ReleaseKind::Single, + status: EventStatus::Completed, + content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/legacy"), + title: String::from_str(&ctx.env, "Oddly Versioned"), + created_at: 1, + deadline: None, + winner_distribution: dist, + fee_bps_override: None, + }; + ctx.env.as_contract(&ctx.client.address, || { + ctx.env + .storage() + .persistent() + .set(&DataKey::Event(event_id), &legacy); + ctx.env + .storage() + .instance() + .set(&DataKey::NextEventId, &(event_id + 1)); + // The operator proposed the upgrade as "v1.7.0" rather than "1.7.0". + storage::set_version(&ctx.env, &String::from_str(&ctx.env, "v1.7.0")); + }); + + ctx.client.migrate_events(&8_u32); + ctx.client.migrate(); + + let migrated = ctx.client.get_event(&event_id); + assert_eq!(migrated.prize_floors.get(1), Some(budget)); +} + +#[test] +fn folding_a_legacy_row_adds_to_the_applicants_existing_slots() { + let ctx = setup(250); + let applicant = Address::generate(&ctx.env); + let event_id = ctx.client.id_base() + 1; + + ctx.env.as_contract(&ctx.client.address, || { + // An unmigrated row, plus a slotted entry the applicant already holds. + ctx.env.storage().persistent().set( + &DataKey::EventSubmission(event_id, applicant.clone()), + &Submission { + applicant: applicant.clone(), + content_uri: String::from_str(&ctx.env, "ipfs://legacy"), + submitted_at: 1, + }, + ); + storage::append_submission(&ctx.env, event_id, &applicant, 1).unwrap(); + storage::set_submission( + &ctx.env, + event_id, + &applicant, + 1, + &Submission { + applicant: applicant.clone(), + content_uri: String::from_str(&ctx.env, "ipfs://slot-one"), + submitted_at: 2, + }, + ); + + // Folding the legacy row into slot 0 must count it, not overwrite. + storage::append_submission(&ctx.env, event_id, &applicant, 0).unwrap(); + assert_eq!( + storage::applicant_submission_count(&ctx.env, event_id, &applicant), + 2 + ); + + storage::remove_submission(&ctx.env, event_id, &applicant, 0); + assert!( + storage::has_any_submission(&ctx.env, event_id, &applicant), + "slot 1 is still occupied, so the application must stay locked" + ); + }); +} + +#[test] +fn migrate_is_a_no_op_on_a_fresh_deployment() { + let ctx = setup(250); + ctx.client.migrate_events(&8_u32); + ctx.client.migrate(); + assert_eq!( + ctx.client.get_migrated_to_version(), + Some(String::from_str(&ctx.env, "1.7.0")) + ); +} + #[test] fn migrate_marks_current_version_and_blocks_replay() { let ctx = setup(250); + ctx.client.migrate_events(&8_u32); ctx.client.migrate(); assert_eq!( ctx.client.get_migrated_to_version(), - Some(String::from_str(&ctx.env, "1.6.0")) + Some(String::from_str(&ctx.env, "1.7.0")) ); let err = ctx @@ -316,3 +763,88 @@ fn accept_admin_demands_pending_targets_auth_specifically() { "accept_admin must demand the pending target's own auth" ); } + +#[test] +fn migrated_award_reserves_what_its_winner_is_still_owed() { + // A legacy event can be mid-flight: a winner selected, the prize not yet + // claimed. remaining_escrow still reads the full budget in that state, + // because it only drops at claim time, so 1.7.0 keeps EventOwedTotal to + // stop a later batch promising the same funds twice. + // + // The migration rewrites the award but never writes that reservation, so + // a migrated event reports nothing owed while a winner is still entitled + // to be paid. Every existing migration test checks the rewritten record; + // none replays a selection afterwards, which is why the suite stayed + // green. Fresh 1.7.0 events are unaffected: select_winners sets owed + // itself, and that path is covered. + let ctx = setup(250); + let winner = Address::generate(&ctx.env); + let event_id = ctx.client.id_base() + 1; + let budget = 1_000_0000000_i128; + let awarded = 600_0000000_i128; + + let mut dist = Map::new(&ctx.env); + dist.set(1, 100_u32); + let legacy_event = LegacyEventRecord { + id: event_id, + pillar: Pillar::Bounty, + owner: ctx.admin.clone(), + token: Address::generate(&ctx.env), + total_budget: budget, + remaining_escrow: budget, + release_kind: ReleaseKind::Single, + status: EventStatus::Active, + content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/b"), + title: String::from_str(&ctx.env, "Legacy Bounty"), + created_at: 1, + deadline: None, + winner_distribution: dist, + fee_bps_override: None, + }; + + ctx.env.as_contract(&ctx.client.address, || { + ctx.env + .storage() + .persistent() + .set(&DataKey::Event(event_id), &legacy_event); + ctx.env + .storage() + .instance() + .set(&DataKey::NextEventId, &(event_id + 1)); + storage::append_winner( + &ctx.env, + event_id, + &Winner { + recipient: winner.clone(), + position: 1, + amount: awarded, + milestone: None, + paid_at: None, + }, + ); + }); + + ctx.client.migrate_events(&8_u32); + ctx.client.migrate(); + + // 400 of the 1000 is genuinely free; 600 belongs to the unclaimed winner. + let second = Address::generate(&ctx.env); + let res = ctx.client.try_select_winners( + &event_id, + &soroban_sdk::vec![ + &ctx.env, + crate::types::WinnerSpec { + recipient: second, + position: 2, + amount: awarded, + reputation_bump: 0, + }, + ], + &BytesN::random(&ctx.env), + ); + assert!( + res.is_err(), + "a migrated award must still be reserved; otherwise both winners are \ + promised funds only one of them can claim" + ); +} diff --git a/contracts/events/src/tests/bounty_pillar.rs b/contracts/events/src/tests/bounty_pillar.rs index 923c1b0..2ea3a68 100644 --- a/contracts/events/src/tests/bounty_pillar.rs +++ b/contracts/events/src/tests/bounty_pillar.rs @@ -69,9 +69,9 @@ fn setup<'a>() -> Ctx<'a> { } } -fn one_winner_distribution(env: &Env) -> Map { +fn one_winner_distribution(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, 100000000000_i128); m } @@ -89,7 +89,7 @@ fn create_bounty_with_deadline(ctx: &Ctx, deadline: u64) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/events/draft/x"), title: String::from_str(&ctx.env, "Test Bounty"), deadline: Some(deadline), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: None, manager: None, }; @@ -107,7 +107,7 @@ fn create_hackathon(ctx: &Ctx) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), title: String::from_str(&ctx.env, "Test Hackathon"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: None, manager: None, }; @@ -140,7 +140,7 @@ fn create_rejects_multi_release_kind() { content_uri: String::from_str(&ctx.env, "uri"), title: String::from_str(&ctx.env, "Bad Bounty"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: None, manager: None, }; @@ -259,6 +259,7 @@ fn apply_on_completed_event_reverts() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: 10_000_0000000_i128, reputation_bump: 0, }, ]; @@ -446,7 +447,7 @@ fn withdraw_after_submit_reverts() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../bounty.json"); let op_submit = BytesN::random(&ctx.env); ctx.events - .submit(&bounty_id, &ctx.applicant, &uri, &op_submit); + .submit(&bounty_id, &ctx.applicant, &0_u32, &uri, &op_submit); let op_wd = BytesN::random(&ctx.env); let err = expect_op_err(ctx.events.try_withdraw_application( @@ -526,3 +527,165 @@ fn withdraw_requires_applicant_auth() { let applicant_required = auths.iter().any(|(addr, _)| *addr == ctx.applicant); assert!(applicant_required, "withdraw must demand applicant auth"); } + +// ============================================================ +// Submission slots: several distinct entries per wallet +// ============================================================ + +#[test] +fn one_wallet_holds_several_submissions_in_distinct_slots() { + let ctx = setup(); + let bounty_id = create_bounty(&ctx); + ctx.events + .apply_to_bounty(&bounty_id, &ctx.applicant, &BytesN::random(&ctx.env)); + + ctx.events.submit( + &bounty_id, + &ctx.applicant, + &0_u32, + &String::from_str(&ctx.env, "ipfs://design"), + &BytesN::random(&ctx.env), + ); + ctx.events.submit( + &bounty_id, + &ctx.applicant, + &1_u32, + &String::from_str(&ctx.env, "ipfs://article"), + &BytesN::random(&ctx.env), + ); + + // Neither entry overwrites the other; the slot is what tells them apart. + assert_eq!( + ctx.events + .get_submission(&bounty_id, &ctx.applicant, &0_u32) + .content_uri, + String::from_str(&ctx.env, "ipfs://design") + ); + assert_eq!( + ctx.events + .get_submission(&bounty_id, &ctx.applicant, &1_u32) + .content_uri, + String::from_str(&ctx.env, "ipfs://article") + ); + assert_eq!( + ctx.events + .get_applicant_submission_count(&bounty_id, &ctx.applicant), + 2 + ); +} + +#[test] +fn resubmitting_to_an_occupied_slot_updates_it_without_recounting() { + let ctx = setup(); + let bounty_id = create_bounty(&ctx); + ctx.events + .apply_to_bounty(&bounty_id, &ctx.applicant, &BytesN::random(&ctx.env)); + + ctx.events.submit( + &bounty_id, + &ctx.applicant, + &0_u32, + &String::from_str(&ctx.env, "ipfs://v1"), + &BytesN::random(&ctx.env), + ); + let first = ctx + .events + .get_submission(&bounty_id, &ctx.applicant, &0_u32); + + ctx.events.submit( + &bounty_id, + &ctx.applicant, + &0_u32, + &String::from_str(&ctx.env, "ipfs://v2"), + &BytesN::random(&ctx.env), + ); + let second = ctx + .events + .get_submission(&bounty_id, &ctx.applicant, &0_u32); + + assert_eq!(second.content_uri, String::from_str(&ctx.env, "ipfs://v2")); + assert_eq!( + second.submitted_at, first.submitted_at, + "an update keeps the original submission time" + ); + assert_eq!( + ctx.events + .get_applicant_submission_count(&bounty_id, &ctx.applicant), + 1 + ); +} + +#[test] +fn withdrawing_one_slot_leaves_the_others_intact() { + let ctx = setup(); + let bounty_id = create_bounty(&ctx); + ctx.events + .apply_to_bounty(&bounty_id, &ctx.applicant, &BytesN::random(&ctx.env)); + + for slot in 0..3_u32 { + ctx.events.submit( + &bounty_id, + &ctx.applicant, + &slot, + &String::from_str(&ctx.env, "ipfs://entry"), + &BytesN::random(&ctx.env), + ); + } + + ctx.events.withdraw_submission( + &bounty_id, + &ctx.applicant, + &1_u32, + &BytesN::random(&ctx.env), + ); + + assert_eq!( + ctx.events + .get_applicant_submission_count(&bounty_id, &ctx.applicant), + 2 + ); + assert!(ctx + .events + .try_get_submission(&bounty_id, &ctx.applicant, &1_u32) + .is_err()); + assert!(ctx + .events + .try_get_submission(&bounty_id, &ctx.applicant, &0_u32) + .is_ok()); + assert!(ctx + .events + .try_get_submission(&bounty_id, &ctx.applicant, &2_u32) + .is_ok()); +} + +#[test] +fn application_withdrawal_stays_blocked_while_any_slot_is_filled() { + let ctx = setup(); + let bounty_id = create_bounty(&ctx); + ctx.events + .apply_to_bounty(&bounty_id, &ctx.applicant, &BytesN::random(&ctx.env)); + + // Occupying a slot other than 0 must still block application withdrawal; + // the gate asks whether any entry exists, not whether slot 0 does. + ctx.events.submit( + &bounty_id, + &ctx.applicant, + &7_u32, + &String::from_str(&ctx.env, "ipfs://entry"), + &BytesN::random(&ctx.env), + ); + + assert!(ctx + .events + .try_withdraw_application(&bounty_id, &ctx.applicant, &BytesN::random(&ctx.env)) + .is_err()); + + ctx.events.withdraw_submission( + &bounty_id, + &ctx.applicant, + &7_u32, + &BytesN::random(&ctx.env), + ); + ctx.events + .withdraw_application(&bounty_id, &ctx.applicant, &BytesN::random(&ctx.env)); +} diff --git a/contracts/events/src/tests/cancel_refund.rs b/contracts/events/src/tests/cancel_refund.rs index 7fed1b3..905ea14 100644 --- a/contracts/events/src/tests/cancel_refund.rs +++ b/contracts/events/src/tests/cancel_refund.rs @@ -75,9 +75,9 @@ fn setup_with_env<'a>(env: Env) -> Ctx<'a> { } } -fn single_dist(env: &Env) -> Map { +fn single_dist(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, 10000000000_i128); m } @@ -91,7 +91,7 @@ fn create_hackathon(ctx: &Ctx) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/cancel-test"), title: String::from_str(&ctx.env, "Cancel Test"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -403,8 +403,8 @@ fn missing_running_total_with_contributors_fails_closed() { fn cancel_prorata_splits_remaining_across_partners_no_owner_residual() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 60); - dist.set(2, 40); + dist.set(1, 600_0000000_i128); + dist.set(2, 400_0000000_i128); let params = CreateEventParams { pillar: Pillar::Hackathon, owner: ctx.owner.clone(), @@ -414,7 +414,7 @@ fn cancel_prorata_splits_remaining_across_partners_no_owner_residual() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/boundary"), title: String::from_str(&ctx.env, "ProRata Cancel"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -433,14 +433,17 @@ fn cancel_prorata_splits_remaining_across_partners_no_owner_residual() { WinnerSpec { recipient: w.clone(), position: 1, + // The pool is 2000 after both contributions; the manager awards + // 1200 of it, leaving 800 to split pro-rata on cancel. + amount: 1_200_0000000_i128, reputation_bump: 0 }, ]; ctx.events .select_winners(&id, &winners, &BytesN::random(&ctx.env)); - // Pull model: the winner claims (60% of 2000 = 1200) before the - // manager can cancel; the remainder splits pro-rata below. + // Pull model: the winner claims before the manager can cancel; the + // remainder splits pro-rata below. ctx.events .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); diff --git a/contracts/events/src/tests/contributions.rs b/contracts/events/src/tests/contributions.rs index 2652f84..fc4bce5 100644 --- a/contracts/events/src/tests/contributions.rs +++ b/contracts/events/src/tests/contributions.rs @@ -72,9 +72,9 @@ fn setup<'a>() -> Ctx<'a> { } } -fn single_dist(env: &Env) -> Map { +fn single_dist(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, 10000000000_i128); m } @@ -88,7 +88,7 @@ fn create_hackathon(ctx: &Ctx) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/contrib-hack"), title: String::from_str(&ctx.env, "Contrib Hack"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -336,7 +336,7 @@ fn cancel_at_boundary_pays_partners_full_no_owner_residual() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/boundary"), title: String::from_str(&ctx.env, "Boundary Cancel"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -362,6 +362,7 @@ fn cancel_at_boundary_pays_partners_full_no_owner_residual() { WinnerSpec { recipient: winner_a.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50, }, ]; diff --git a/contracts/events/src/tests/cross_contract.rs b/contracts/events/src/tests/cross_contract.rs index f4ab428..8b527ac 100644 --- a/contracts/events/src/tests/cross_contract.rs +++ b/contracts/events/src/tests/cross_contract.rs @@ -72,9 +72,11 @@ fn setup<'a>() -> Ctx<'a> { } } -fn one_winner_distribution(env: &Env) -> Map { +fn one_winner_distribution(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + // Nominal: a floor is a minimum, so 1 stroop never constrains an + // award. Tests that exercise the floor rule set a real one. + m.set(1, 1_i128); m } @@ -88,7 +90,7 @@ fn create_bounty(ctx: &Ctx) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/events/draft/x"), title: String::from_str(&ctx.env, "Test Bounty"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: None, manager: None, }; @@ -117,6 +119,7 @@ fn select_winners_pays_recipient_and_bumps_profile() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 50, }, ]; @@ -152,7 +155,7 @@ fn select_winners_pays_recipient_and_bumps_profile() { } #[test] -fn select_winners_requires_position_in_distribution() { +fn select_winners_rejects_an_award_larger_than_the_pool() { let ctx = setup(); let bounty_id = create_bounty(&ctx); @@ -160,7 +163,8 @@ fn select_winners_requires_position_in_distribution() { &ctx.env, WinnerSpec { recipient: ctx.applicant.clone(), - position: 2, // distribution only has position 1 + position: 2, + amount: TOTAL_BUDGET + 1, reputation_bump: 50, }, ]; @@ -168,7 +172,10 @@ fn select_winners_requires_position_in_distribution() { let res = ctx .events .try_select_winners(&bounty_id, &winners, &op_select); - assert!(res.is_err(), "invalid position should revert"); + assert!( + res.is_err(), + "a position with no floor is allowed, but not one the pool cannot cover" + ); } #[test] @@ -177,8 +184,8 @@ fn select_winners_rejects_duplicate_position() { let owner = ctx.owner.clone(); let token_addr = ctx.token_addr.clone(); let mut dist = Map::new(&ctx.env); - dist.set(1, 60); - dist.set(2, 40); + dist.set(1, 6_000_0000000_i128); + dist.set(2, 4_000_0000000_i128); let params = CreateEventParams { pillar: Pillar::Bounty, owner, @@ -188,7 +195,7 @@ fn select_winners_rejects_duplicate_position() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/x"), title: String::from_str(&ctx.env, "Test Bounty 2"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -201,11 +208,13 @@ fn select_winners_rejects_duplicate_position() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 50, }, WinnerSpec { recipient: other_recipient, position: 1, // duplicate + amount: TOTAL_BUDGET, reputation_bump: 25, }, ]; @@ -220,8 +229,8 @@ fn select_winners_rejects_duplicate_position() { fn select_winners_handles_multi_recipient_distribution() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 60); - dist.set(2, 40); + dist.set(1, 6_000_0000000_i128); + dist.set(2, 4_000_0000000_i128); let params = CreateEventParams { pillar: Pillar::Bounty, owner: ctx.owner.clone(), @@ -231,7 +240,7 @@ fn select_winners_handles_multi_recipient_distribution() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/multi"), title: String::from_str(&ctx.env, "Multi Winner"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -245,11 +254,13 @@ fn select_winners_handles_multi_recipient_distribution() { WinnerSpec { recipient: winner_a.clone(), position: 1, + amount: 6_000_0000000_i128, reputation_bump: 50, }, WinnerSpec { recipient: winner_b.clone(), position: 2, + amount: 4_000_0000000_i128, reputation_bump: 25, }, ]; @@ -288,6 +299,7 @@ fn select_winners_replayed_reverts() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 50, }, ]; @@ -338,8 +350,8 @@ fn cancel_already_cancelled_reverts() { fn cancel_after_select_winners_refunds_only_remaining() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 60); - dist.set(2, 40); + dist.set(1, 6_000_0000000_i128); + dist.set(2, 4_000_0000000_i128); let params = CreateEventParams { pillar: Pillar::Bounty, owner: ctx.owner.clone(), @@ -349,7 +361,7 @@ fn cancel_after_select_winners_refunds_only_remaining() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/partial"), title: String::from_str(&ctx.env, "Partial Pay Bounty"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -362,6 +374,7 @@ fn cancel_after_select_winners_refunds_only_remaining() { WinnerSpec { recipient: winner_a.clone(), position: 1, + amount: TOTAL_BUDGET * 60 / 100, reputation_bump: 50, }, ]; @@ -391,7 +404,7 @@ fn cancel_after_select_winners_refunds_only_remaining() { fn create_grant(ctx: &Ctx, n_milestones: u32) -> u64 { let mut dist = Map::new(&ctx.env); - dist.set(1, 100); + dist.set(1, TOTAL_BUDGET * 100 / 100); let params = CreateEventParams { pillar: Pillar::Grant, owner: ctx.owner.clone(), @@ -401,7 +414,7 @@ fn create_grant(ctx: &Ctx, n_milestones: u32) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/grant"), title: String::from_str(&ctx.env, "Test Grant"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -415,6 +428,7 @@ fn select_grant_winner(ctx: &Ctx, grant_id: u64, recipient: &Address) { WinnerSpec { recipient: recipient.clone(), position: 1, + amount: TOTAL_BUDGET * 100 / 100, reputation_bump: 0, }, ]; @@ -496,6 +510,7 @@ fn claim_milestone_rejects_non_grant_events() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; @@ -539,7 +554,7 @@ fn claim_milestone_final_milestone_marks_event_completed() { fn create_hackathon(ctx: &Ctx) -> u64 { let mut dist = Map::new(&ctx.env); - dist.set(1, 100); + dist.set(1, TOTAL_BUDGET * 100 / 100); let params = CreateEventParams { pillar: Pillar::Hackathon, owner: ctx.owner.clone(), @@ -549,7 +564,7 @@ fn create_hackathon(ctx: &Ctx) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), title: String::from_str(&ctx.env, "Test Hackathon"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -564,9 +579,9 @@ fn hackathon_submit_creates_anchor_without_prior_apply() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../project.json"); let op = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri, &op); + ctx.events.submit(&id, &ctx.applicant, &0_u32, &uri, &op); - let sub = ctx.events.get_submission(&id, &ctx.applicant); + let sub = ctx.events.get_submission(&id, &ctx.applicant, &0_u32); assert_eq!(sub.applicant, ctx.applicant); assert_eq!(sub.content_uri, uri); assert_eq!(sub.submitted_at, ctx.env.ledger().timestamp()); @@ -579,7 +594,9 @@ fn bounty_submit_requires_prior_application() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../bounty.json"); let op = BytesN::random(&ctx.env); - let res = ctx.events.try_submit(&id, &ctx.applicant, &uri, &op); + let res = ctx + .events + .try_submit(&id, &ctx.applicant, &0_u32, &uri, &op); assert!(res.is_err(), "submit before apply on bounty should revert"); } @@ -593,9 +610,10 @@ fn bounty_submit_succeeds_after_apply() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../bounty.json"); let op_submit = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri, &op_submit); + ctx.events + .submit(&id, &ctx.applicant, &0_u32, &uri, &op_submit); - let sub = ctx.events.get_submission(&id, &ctx.applicant); + let sub = ctx.events.get_submission(&id, &ctx.applicant, &0_u32); assert_eq!(sub.content_uri, uri); } @@ -606,16 +624,18 @@ fn resubmit_preserves_original_submitted_at_and_updates_uri() { let uri_a = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); let op_a = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri_a, &op_a); + ctx.events + .submit(&id, &ctx.applicant, &0_u32, &uri_a, &op_a); - let first = ctx.events.get_submission(&id, &ctx.applicant); + let first = ctx.events.get_submission(&id, &ctx.applicant, &0_u32); let first_time = first.submitted_at; let uri_b = String::from_str(&ctx.env, "ipfs://Qm.../v2.json"); let op_b = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri_b, &op_b); + ctx.events + .submit(&id, &ctx.applicant, &0_u32, &uri_b, &op_b); - let second = ctx.events.get_submission(&id, &ctx.applicant); + let second = ctx.events.get_submission(&id, &ctx.applicant, &0_u32); assert_eq!(second.content_uri, uri_b); assert_eq!( second.submitted_at, first_time, @@ -630,9 +650,11 @@ fn submit_replayed_reverts() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); let op = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri, &op); + ctx.events.submit(&id, &ctx.applicant, &0_u32, &uri, &op); - let res = ctx.events.try_submit(&id, &ctx.applicant, &uri, &op); + let res = ctx + .events + .try_submit(&id, &ctx.applicant, &0_u32, &uri, &op); assert!(res.is_err(), "replayed submit should revert"); } @@ -643,12 +665,14 @@ fn withdraw_submission_removes_anchor() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); let op_submit = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri, &op_submit); + ctx.events + .submit(&id, &ctx.applicant, &0_u32, &uri, &op_submit); let op_wd = BytesN::random(&ctx.env); - ctx.events.withdraw_submission(&id, &ctx.applicant, &op_wd); + ctx.events + .withdraw_submission(&id, &ctx.applicant, &0_u32, &op_wd); - let res = ctx.events.try_get_submission(&id, &ctx.applicant); + let res = ctx.events.try_get_submission(&id, &ctx.applicant, &0_u32); assert!(res.is_err(), "withdrawn submission should not be readable"); } @@ -660,7 +684,7 @@ fn withdraw_submission_without_submission_reverts() { let op_wd = BytesN::random(&ctx.env); let res = ctx .events - .try_withdraw_submission(&id, &ctx.applicant, &op_wd); + .try_withdraw_submission(&id, &ctx.applicant, &0_u32, &op_wd); assert!( res.is_err(), "withdraw without prior submission should revert" @@ -690,7 +714,7 @@ fn create_event_charges_override_rate_when_provided() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), title: String::from_str(&ctx.env, "Hackathon at 1.5%"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: Some(override_bps), manager: None, }; @@ -723,7 +747,7 @@ fn add_funds_uses_event_override_not_global() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), title: String::from_str(&ctx.env, "Promo Hackathon"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: Some(override_bps), manager: None, }; @@ -769,7 +793,7 @@ fn create_event_with_waiver_charges_no_fee() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), title: String::from_str(&ctx.env, "Comped Hackathon"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: Some(0), manager: None, }; @@ -795,7 +819,7 @@ fn create_event_rejects_override_above_max_fee_bps() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), title: String::from_str(&ctx.env, "Bad rate"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: Some(6000), manager: None, }; @@ -822,7 +846,7 @@ fn create_event_omitted_override_falls_back_to_global_default() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), title: String::from_str(&ctx.env, "Default rate hackathon"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: None, manager: None, }; @@ -851,6 +875,7 @@ fn select_winners_rejects_second_call_winners_already_selected() { WinnerSpec { recipient: r2.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; @@ -919,7 +944,7 @@ fn grant_last_milestone_sweeps_rounding_residue() { // ============================================================ #[test] -fn select_winners_pays_against_remaining_escrow_including_top_ups() { +fn a_top_up_before_selection_enlarges_the_pool_not_the_award() { let ctx = setup(); let bounty_id = create_bounty(&ctx); @@ -940,22 +965,26 @@ fn select_winners_pays_against_remaining_escrow_including_top_ups() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 50, }, ]; let op_select = BytesN::random(&ctx.env); ctx.events.select_winners(&bounty_id, &winners, &op_select); - // Pull model: claim; the pre-selection top-up is in the baseline. ctx.events .claim_prize(&bounty_id, &1_u32, &BytesN::random(&ctx.env)); let token = token::Client::new(&ctx.env, &ctx.token_addr); - assert_eq!(token.balance(&ctx.applicant), TOTAL_BUDGET + top_up); + assert_eq!( + token.balance(&ctx.applicant), + TOTAL_BUDGET, + "the award is what the selection named, regardless of the top-up" + ); let event_post = ctx.events.get_event(&bounty_id); - assert_eq!(event_post.remaining_escrow, 0); - assert_eq!(event_post.status, EventStatus::Completed); + assert_eq!(event_post.remaining_escrow, top_up); + assert_eq!(event_post.status, EventStatus::Active); } // ============================================================ @@ -971,7 +1000,7 @@ fn create_bounty_with_manager(ctx: &Ctx, manager: &Address) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/events/draft/m"), title: String::from_str(&ctx.env, "Managed Bounty"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: None, manager: Some(manager.clone()), }; @@ -985,6 +1014,7 @@ fn win_one(ctx: &Ctx) -> soroban_sdk::Vec { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ] diff --git a/contracts/events/src/tests/crowdfunding.rs b/contracts/events/src/tests/crowdfunding.rs index 9ff55de..47280fa 100644 --- a/contracts/events/src/tests/crowdfunding.rs +++ b/contracts/events/src/tests/crowdfunding.rs @@ -71,9 +71,9 @@ fn setup<'a>() -> Ctx<'a> { } } -fn single_dist_100_at_1(env: &Env) -> Map { +fn single_dist_100_at_1(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, 10000000000_i128); m } @@ -87,7 +87,7 @@ fn create_campaign(ctx: &Ctx, milestones: u32) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/cf/1"), title: String::from_str(&ctx.env, "Open-Source Crawler"), deadline: Some(ctx.env.ledger().timestamp() + 30 * 86_400), - winner_distribution: single_dist_100_at_1(&ctx.env), + prize_floors: single_dist_100_at_1(&ctx.env), fee_bps_override: None, manager: None, }; @@ -149,7 +149,7 @@ fn create_rejects_single_release_kind() { content_uri: String::from_str(&ctx.env, "uri"), title: String::from_str(&ctx.env, "Bad CF"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist_100_at_1(&ctx.env), + prize_floors: single_dist_100_at_1(&ctx.env), fee_bps_override: None, manager: None, }; @@ -159,11 +159,11 @@ fn create_rejects_single_release_kind() { } #[test] -fn create_rejects_distribution_with_multiple_positions() { +fn create_rejects_floors_above_the_funding_goal() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 60); - dist.set(2, 40); + dist.set(1, FUNDING_GOAL); + dist.set(2, 1_i128); let params = CreateEventParams { pillar: Pillar::Crowdfunding, owner: ctx.builder.clone(), @@ -173,7 +173,7 @@ fn create_rejects_distribution_with_multiple_positions() { content_uri: String::from_str(&ctx.env, "uri"), title: String::from_str(&ctx.env, "Bad CF"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -392,6 +392,7 @@ fn select_winners_on_crowdfunding_reverts() { let spec = WinnerSpec { recipient: ctx.builder.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 0, }; let mut winners = SorobanVec::new(&ctx.env); @@ -409,7 +410,7 @@ fn submit_on_crowdfunding_reverts() { let op = BytesN::random(&ctx.env); let uri = String::from_str(&ctx.env, "ipfs://nope"); - let res = ctx.events.try_submit(&id, &ctx.builder, &uri, &op); + let res = ctx.events.try_submit(&id, &ctx.builder, &0_u32, &uri, &op); assert!(res.is_err()); } diff --git a/contracts/events/src/tests/escrow_fee_math.rs b/contracts/events/src/tests/escrow_fee_math.rs index bb39254..e648c96 100644 --- a/contracts/events/src/tests/escrow_fee_math.rs +++ b/contracts/events/src/tests/escrow_fee_math.rs @@ -72,9 +72,11 @@ fn setup<'a>() -> Ctx<'a> { setup_with_bps(FEE_BPS) } -fn single_dist(env: &Env) -> Map { +fn single_dist(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + // Nominal: a floor is a minimum, so 1 stroop never constrains an + // award. Tests that exercise the floor rule set a real one. + m.set(1, 1_i128); m } @@ -88,7 +90,7 @@ fn create_hackathon(ctx: &Ctx) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/fee-test"), title: String::from_str(&ctx.env, "Fee Math Test"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -106,7 +108,7 @@ fn create_hackathon_with_override(ctx: &Ctx, override_bps: u32) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/override"), title: String::from_str(&ctx.env, "Override BPS Test"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: Some(override_bps), manager: None, }; @@ -114,7 +116,7 @@ fn create_hackathon_with_override(ctx: &Ctx, override_bps: u32) -> u64 { ctx.events.create_event(¶ms, &op) } -fn create_hackathon_with_dist(ctx: &Ctx, dist: Map) -> u64 { +fn create_hackathon_with_dist(ctx: &Ctx, dist: Map) -> u64 { let params = CreateEventParams { pillar: Pillar::Hackathon, owner: ctx.owner.clone(), @@ -124,7 +126,7 @@ fn create_hackathon_with_dist(ctx: &Ctx, dist: Map) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/dist-test"), title: String::from_str(&ctx.env, "Dist Test"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -142,7 +144,7 @@ fn create_grant(ctx: &Ctx, milestones: u32) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/grant"), title: String::from_str(&ctx.env, "Grant Test"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -214,7 +216,7 @@ fn override_bps_above_max_rejected() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/bad"), title: String::from_str(&ctx.env, "Bad Override"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: Some(1001), // MAX_FEE_BPS = 1000 manager: None, }; @@ -252,7 +254,7 @@ fn fee_rounds_down_non_divisible_amount() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/tiny"), title: String::from_str(&ctx.env, "Tiny"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -280,7 +282,7 @@ fn fee_rounding_on_odd_amounts() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/odd"), title: String::from_str(&ctx.env, "Odd"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -373,6 +375,7 @@ fn single_release_pays_full_escrow_for_100_percent() { WinnerSpec { recipient: winner.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50, }, ]; @@ -393,9 +396,9 @@ fn multi_position_split_pays_correct_amounts() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 50); - dist.set(2, 30); - dist.set(3, 20); + dist.set(1, TOTAL_BUDGET * 50 / 100); + dist.set(2, TOTAL_BUDGET * 30 / 100); + dist.set(3, TOTAL_BUDGET * 20 / 100); let id = create_hackathon_with_dist(&ctx, dist); let w1 = Address::generate(&ctx.env); @@ -406,16 +409,19 @@ fn multi_position_split_pays_correct_amounts() { WinnerSpec { recipient: w1.clone(), position: 1, + amount: TOTAL_BUDGET * 50 / 100, reputation_bump: 50 }, WinnerSpec { recipient: w2.clone(), position: 2, + amount: TOTAL_BUDGET * 30 / 100, reputation_bump: 30 }, WinnerSpec { recipient: w3.clone(), position: 3, + amount: TOTAL_BUDGET * 20 / 100, reputation_bump: 20 }, ]; @@ -443,9 +449,9 @@ fn three_way_33_33_34_split_rounding() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 33); - dist.set(2, 33); - dist.set(3, 34); + dist.set(1, TOTAL_BUDGET * 33 / 100); + dist.set(2, TOTAL_BUDGET * 33 / 100); + dist.set(3, TOTAL_BUDGET * 34 / 100); let id = create_hackathon_with_dist(&ctx, dist); let w1 = Address::generate(&ctx.env); @@ -456,16 +462,19 @@ fn three_way_33_33_34_split_rounding() { WinnerSpec { recipient: w1.clone(), position: 1, + amount: TOTAL_BUDGET * 33 / 100, reputation_bump: 50 }, WinnerSpec { recipient: w2.clone(), position: 2, + amount: TOTAL_BUDGET * 33 / 100, reputation_bump: 30 }, WinnerSpec { recipient: w3.clone(), position: 3, + amount: TOTAL_BUDGET * 34 / 100, reputation_bump: 20 }, ]; @@ -493,8 +502,8 @@ fn partial_position_fill_leaves_residual_escrow() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 60); - dist.set(2, 40); + dist.set(1, TOTAL_BUDGET * 60 / 100); + dist.set(2, TOTAL_BUDGET * 40 / 100); let id = create_hackathon_with_dist(&ctx, dist); let w1 = Address::generate(&ctx.env); @@ -503,6 +512,7 @@ fn partial_position_fill_leaves_residual_escrow() { WinnerSpec { recipient: w1.clone(), position: 1, + amount: TOTAL_BUDGET * 60 / 100, reputation_bump: 50 }, ]; @@ -526,7 +536,7 @@ fn partial_position_fill_leaves_residual_escrow() { // ============================================================ #[test] -fn partner_funds_grow_winner_payout() { +fn partner_funds_enlarge_the_pool_without_repricing_awards() { let ctx = setup(); let id = create_hackathon(&ctx); @@ -538,15 +548,17 @@ fn partner_funds_grow_winner_payout() { ctx.events.add_funds(&id, &partner, &contrib, &op_add); let event = ctx.events.get_event(&id); - let escrow_at_select = event.remaining_escrow; - assert_eq!(escrow_at_select, TOTAL_BUDGET + contrib); + assert_eq!(event.remaining_escrow, TOTAL_BUDGET + contrib); + // A top-up has exactly one consequence: the pool is larger. Awards name + // their own amount, so nothing already advertised is silently repriced. let winner = Address::generate(&ctx.env); let winners = soroban_sdk::vec![ &ctx.env, WinnerSpec { recipient: winner.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 50 }, ]; @@ -556,7 +568,111 @@ fn partner_funds_grow_winner_payout() { .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); let token = token::Client::new(&ctx.env, &ctx.token_addr); - assert_eq!(token.balance(&winner), escrow_at_select); + assert_eq!(token.balance(&winner), TOTAL_BUDGET); + assert_eq!( + ctx.events.get_event(&id).remaining_escrow, + contrib, + "the top-up stays in the pool, available to award or reclaim" + ); +} + +#[test] +fn a_topped_up_pool_can_fund_a_position_that_had_no_floor() { + let ctx = setup(); + let id = create_hackathon(&ctx); + + let partner = Address::generate(&ctx.env); + let contrib = 500_0000000_i128; + let fee = contrib * FEE_BPS as i128 / 10_000; + fund(&ctx, &partner, contrib + fee); + ctx.events + .add_funds(&id, &partner, &contrib, &BytesN::random(&ctx.env)); + + let w1 = Address::generate(&ctx.env); + let w2 = Address::generate(&ctx.env); + ctx.events.select_winners( + &id, + &soroban_sdk::vec![ + &ctx.env, + WinnerSpec { + recipient: w1.clone(), + position: 1, + amount: TOTAL_BUDGET, + reputation_bump: 0 + }, + ], + &BytesN::random(&ctx.env), + ); + // Adding a prize after publish: position 2 carries no floor and is paid + // out of the headroom the top-up created. + ctx.events.select_winners( + &id, + &soroban_sdk::vec![ + &ctx.env, + WinnerSpec { + recipient: w2.clone(), + position: 2, + amount: contrib, + reputation_bump: 0 + }, + ], + &BytesN::random(&ctx.env), + ); + + ctx.events + .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); + ctx.events + .claim_prize(&id, &2_u32, &BytesN::random(&ctx.env)); + + let token = token::Client::new(&ctx.env, &ctx.token_addr); + assert_eq!(token.balance(&w1), TOTAL_BUDGET); + assert_eq!(token.balance(&w2), contrib); + assert_eq!(ctx.events.get_event(&id).remaining_escrow, 0); +} + +#[test] +fn second_batch_cannot_promise_funds_an_unclaimed_winner_is_owed() { + let ctx = setup(); + let id = create_hackathon(&ctx); + + let w1 = Address::generate(&ctx.env); + ctx.events.select_winners( + &id, + &soroban_sdk::vec![ + &ctx.env, + WinnerSpec { + recipient: w1.clone(), + position: 1, + amount: TOTAL_BUDGET, + reputation_bump: 0 + }, + ], + &BytesN::random(&ctx.env), + ); + + // remaining_escrow still reads the full budget because w1 has not claimed. + // Without the owed reservation this second batch would be accepted and + // one of the two winners could never be paid. + let w2 = Address::generate(&ctx.env); + let res = ctx.events.try_select_winners( + &id, + &soroban_sdk::vec![ + &ctx.env, + WinnerSpec { + recipient: w2.clone(), + position: 2, + amount: 1_i128, + reputation_bump: 0 + }, + ], + &BytesN::random(&ctx.env), + ); + assert!(res.is_err(), "owed funds must not be promised twice"); + + ctx.events + .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); + let token = token::Client::new(&ctx.env, &ctx.token_addr); + assert_eq!(token.balance(&w1), TOTAL_BUDGET); } // ============================================================ @@ -575,6 +691,7 @@ fn grant_milestone_pays_floored_per_milestone() { WinnerSpec { recipient: recipient.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50 }, ]; @@ -616,6 +733,7 @@ fn grant_milestone_double_claim_rejected() { WinnerSpec { recipient: recipient.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50 }, ]; @@ -644,6 +762,7 @@ fn grant_milestone_out_of_range_rejected() { WinnerSpec { recipient: recipient.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50 }, ]; @@ -667,7 +786,7 @@ fn crowdfunding_dynamic_milestone_split() { let milestones = 3_u32; let mut dist = Map::new(&ctx.env); - dist.set(1, 100); + dist.set(1, TOTAL_BUDGET * 100 / 100); let params = CreateEventParams { pillar: Pillar::Crowdfunding, owner: ctx.owner.clone(), @@ -677,7 +796,7 @@ fn crowdfunding_dynamic_milestone_split() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/crowd"), title: String::from_str(&ctx.env, "Crowd Test"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -725,7 +844,7 @@ fn crowdfunding_dynamic_rounding_no_dust() { let milestones = 3_u32; let mut dist = Map::new(&ctx.env); - dist.set(1, 100); + dist.set(1, TOTAL_BUDGET * 100 / 100); let params = CreateEventParams { pillar: Pillar::Crowdfunding, owner: ctx.owner.clone(), @@ -735,7 +854,7 @@ fn crowdfunding_dynamic_rounding_no_dust() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/dust"), title: String::from_str(&ctx.env, "Dust Test"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -803,7 +922,7 @@ fn replayed_create_event_reverts() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/replay"), title: String::from_str(&ctx.env, "Replay"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -825,6 +944,7 @@ fn replayed_select_winners_reverts() { WinnerSpec { recipient: winner.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50 }, ]; @@ -848,6 +968,7 @@ fn select_winners_on_nonexistent_event_reverts() { WinnerSpec { recipient: winner.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50 }, ]; @@ -860,8 +981,8 @@ fn select_winners_on_nonexistent_event_reverts() { fn select_winners_duplicate_position_reverts() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 50); - dist.set(2, 50); + dist.set(1, TOTAL_BUDGET * 50 / 100); + dist.set(2, TOTAL_BUDGET * 50 / 100); let id = create_hackathon_with_dist(&ctx, dist); let w1 = Address::generate(&ctx.env); @@ -871,11 +992,13 @@ fn select_winners_duplicate_position_reverts() { WinnerSpec { recipient: w1.clone(), position: 1, + amount: TOTAL_BUDGET * 50 / 100, reputation_bump: 50 }, WinnerSpec { recipient: w2.clone(), position: 1, + amount: TOTAL_BUDGET * 50 / 100, reputation_bump: 30 }, ]; @@ -885,9 +1008,9 @@ fn select_winners_duplicate_position_reverts() { } #[test] -fn select_winners_invalid_position_reverts() { +fn select_winners_accepts_a_position_with_no_floor() { let ctx = setup(); - let id = create_hackathon(&ctx); // dist has only position 1 + let id = create_hackathon(&ctx); // floors cover position 1 only let w = Address::generate(&ctx.env); let winners = soroban_sdk::vec![ @@ -895,12 +1018,47 @@ fn select_winners_invalid_position_reverts() { WinnerSpec { recipient: w.clone(), position: 99, + amount: 10_0000000_i128, + reputation_bump: 50 + }, + ]; + let op = BytesN::random(&ctx.env); + ctx.events.select_winners(&id, &winners, &op); + ctx.events + .claim_prize(&id, &99_u32, &BytesN::random(&ctx.env)); + + let token = token::Client::new(&ctx.env, &ctx.token_addr); + assert_eq!( + token.balance(&w), + 10_0000000_i128, + "a position with no floor is payable at any positive amount; this is \ + how a prize is added after publish" + ); +} + +#[test] +fn select_winners_below_floor_reverts() { + let ctx = setup(); + let mut floors = Map::new(&ctx.env); + floors.set(1, TOTAL_BUDGET); + let id = create_hackathon_with_dist(&ctx, floors); + + let w = Address::generate(&ctx.env); + let winners = soroban_sdk::vec![ + &ctx.env, + WinnerSpec { + recipient: w.clone(), + position: 1, + amount: TOTAL_BUDGET - 1, reputation_bump: 50 }, ]; let op = BytesN::random(&ctx.env); let res = ctx.events.try_select_winners(&id, &winners, &op); - assert!(res.is_err(), "position not in distribution must revert"); + assert!( + res.is_err(), + "an award below its advertised floor must revert" + ); } #[test] @@ -925,6 +1083,7 @@ fn select_winners_twice_reverts() { WinnerSpec { recipient: w.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50 }, ]; @@ -959,7 +1118,7 @@ fn large_budget_fee_does_not_overflow() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/big"), title: String::from_str(&ctx.env, "Big"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -1019,6 +1178,7 @@ fn select_winners_on_cancelled_event_reverts() { WinnerSpec { recipient: w.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50 }, ]; @@ -1051,12 +1211,15 @@ fn fee_and_winner_balances_consistent() { let escrow = TOTAL_BUDGET + contrib; + // Awarding the whole pool, top-up included, is a deliberate choice the + // selection makes rather than something a percentage does for it. let winner = Address::generate(&ctx.env); let winners = soroban_sdk::vec![ &ctx.env, WinnerSpec { recipient: winner.clone(), position: 1, + amount: escrow, reputation_bump: 50 }, ]; @@ -1066,7 +1229,11 @@ fn fee_and_winner_balances_consistent() { .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); assert_eq!(token.balance(&winner), escrow); - assert_eq!(token.balance(&ctx.fee_account), create_fee + contrib_fee); + assert_eq!( + token.balance(&ctx.fee_account), + create_fee + contrib_fee, + "fees are charged at funding, never at release" + ); let event = ctx.events.get_event(&id); assert_eq!(event.remaining_escrow, 0); diff --git a/contracts/events/src/tests/grant_pillar.rs b/contracts/events/src/tests/grant_pillar.rs index 40db2a0..ad39d68 100644 --- a/contracts/events/src/tests/grant_pillar.rs +++ b/contracts/events/src/tests/grant_pillar.rs @@ -66,9 +66,9 @@ fn setup<'a>() -> Ctx<'a> { } } -fn single_dist(env: &Env) -> Map { +fn single_dist(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, 100000000000_i128); m } @@ -82,7 +82,7 @@ fn create_grant(ctx: &Ctx, n: u32) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/grant/1"), title: String::from_str(&ctx.env, "Test Grant"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -95,6 +95,7 @@ fn select_winner(ctx: &Ctx, id: u64, recipient: &Address) { WinnerSpec { recipient: recipient.clone(), position: 1, + amount: 10_000_0000000_i128, reputation_bump: 0 }, ]; @@ -128,7 +129,7 @@ fn grant_create_with_single_release_reverts() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/grant"), title: String::from_str(&ctx.env, "Bad Grant"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -150,7 +151,7 @@ fn grant_create_with_zero_milestones_reverts() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/grant"), title: String::from_str(&ctx.env, "Zero Milestones"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -310,7 +311,7 @@ fn claim_milestone_on_single_release_reverts() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hack"), title: String::from_str(&ctx.env, "Single Release"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -345,8 +346,8 @@ fn claim_milestone_op_replay_reverts() { fn two_winner_grant_each_claims_their_share() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 60); - dist.set(2, 40); + dist.set(1, TOTAL_BUDGET * 60 / 100); + dist.set(2, TOTAL_BUDGET * 40 / 100); let params = CreateEventParams { pillar: Pillar::Grant, owner: ctx.owner.clone(), @@ -356,7 +357,7 @@ fn two_winner_grant_each_claims_their_share() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/multi-grant"), title: String::from_str(&ctx.env, "Multi Winner Grant"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -369,11 +370,13 @@ fn two_winner_grant_each_claims_their_share() { WinnerSpec { recipient: w1.clone(), position: 1, + amount: TOTAL_BUDGET * 60 / 100, reputation_bump: 0 }, WinnerSpec { recipient: w2.clone(), position: 2, + amount: TOTAL_BUDGET * 40 / 100, reputation_bump: 0 }, ]; diff --git a/contracts/events/src/tests/hackathon_pillar.rs b/contracts/events/src/tests/hackathon_pillar.rs index 8b64950..5aa5a4b 100644 --- a/contracts/events/src/tests/hackathon_pillar.rs +++ b/contracts/events/src/tests/hackathon_pillar.rs @@ -87,21 +87,21 @@ fn expect_op_err( } } -fn single_winner_dist(env: &Env) -> Map { +fn single_winner_dist(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, 100000000000_i128); m } -fn three_way_dist(env: &Env) -> Map { +fn three_way_dist(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 50); - m.set(2, 30); - m.set(3, 20); + m.set(1, 50000000000_i128); + m.set(2, 30000000000_i128); + m.set(3, 20000000000_i128); m } -fn create_hackathon_with(ctx: &Ctx, dist: Map, deadline: Option) -> u64 { +fn create_hackathon_with(ctx: &Ctx, dist: Map, deadline: Option) -> u64 { let params = CreateEventParams { pillar: Pillar::Hackathon, owner: ctx.owner.clone(), @@ -111,7 +111,7 @@ fn create_hackathon_with(ctx: &Ctx, dist: Map, deadline: Option) content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), title: String::from_str(&ctx.env, "Test Hackathon"), deadline, - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -163,7 +163,7 @@ fn create_rejects_multi_release_kind() { content_uri: String::from_str(&ctx.env, "uri"), title: String::from_str(&ctx.env, "Bad Hackathon"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_winner_dist(&ctx.env), + prize_floors: single_winner_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -183,9 +183,9 @@ fn submit_open_without_prior_apply_creates_anchor() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../project.json"); let op = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri, &op); + ctx.events.submit(&id, &ctx.applicant, &0_u32, &uri, &op); - let sub = ctx.events.get_submission(&id, &ctx.applicant); + let sub = ctx.events.get_submission(&id, &ctx.applicant, &0_u32); assert_eq!(sub.applicant, ctx.applicant); assert_eq!(sub.content_uri, uri); assert_eq!(sub.submitted_at, ctx.env.ledger().timestamp()); @@ -198,14 +198,19 @@ fn resubmit_keeps_original_timestamp_and_updates_uri() { let uri_a = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); let op_a = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri_a, &op_a); - let first_time = ctx.events.get_submission(&id, &ctx.applicant).submitted_at; + ctx.events + .submit(&id, &ctx.applicant, &0_u32, &uri_a, &op_a); + let first_time = ctx + .events + .get_submission(&id, &ctx.applicant, &0_u32) + .submitted_at; let uri_b = String::from_str(&ctx.env, "ipfs://Qm.../v2.json"); let op_b = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri_b, &op_b); + ctx.events + .submit(&id, &ctx.applicant, &0_u32, &uri_b, &op_b); - let second = ctx.events.get_submission(&id, &ctx.applicant); + let second = ctx.events.get_submission(&id, &ctx.applicant, &0_u32); assert_eq!(second.content_uri, uri_b); assert_eq!(second.submitted_at, first_time); } @@ -217,9 +222,11 @@ fn submit_replayed_op_reverts() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); let op = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri, &op); + ctx.events.submit(&id, &ctx.applicant, &0_u32, &uri, &op); - let res = ctx.events.try_submit(&id, &ctx.applicant, &uri, &op); + let res = ctx + .events + .try_submit(&id, &ctx.applicant, &0_u32, &uri, &op); assert!(res.is_err(), "replayed submit op_id must revert"); } @@ -230,12 +237,13 @@ fn withdraw_submission_removes_anchor() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); let op_s = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri, &op_s); + ctx.events.submit(&id, &ctx.applicant, &0_u32, &uri, &op_s); let op_w = BytesN::random(&ctx.env); - ctx.events.withdraw_submission(&id, &ctx.applicant, &op_w); + ctx.events + .withdraw_submission(&id, &ctx.applicant, &0_u32, &op_w); - let res = ctx.events.try_get_submission(&id, &ctx.applicant); + let res = ctx.events.try_get_submission(&id, &ctx.applicant, &0_u32); assert!(res.is_err(), "withdrawn submission is no longer readable"); } @@ -248,6 +256,7 @@ fn remove_submission_on_nonexistent_entry_does_not_corrupt_counter() { ctx.events.submit( &id, &submitter, + &0_u32, &String::from_str(&ctx.env, "ipfs://Qm.../v1.json"), &BytesN::random(&ctx.env), ); @@ -256,7 +265,7 @@ fn remove_submission_on_nonexistent_entry_does_not_corrupt_counter() { // directly for it must be a no-op, not decrement the counter that // `submitter`'s real submission incremented. ctx.env.as_contract(&ctx.events_id, || { - storage::remove_submission(&ctx.env, id, &ctx.applicant); + storage::remove_submission(&ctx.env, id, &ctx.applicant, 0); }); let count = ctx @@ -275,9 +284,9 @@ fn withdraw_submission_frees_the_slot_for_future_submitters() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); ctx.events - .submit(&id, &ctx.applicant, &uri, &BytesN::random(&ctx.env)); + .submit(&id, &ctx.applicant, &0_u32, &uri, &BytesN::random(&ctx.env)); ctx.events - .withdraw_submission(&id, &ctx.applicant, &BytesN::random(&ctx.env)); + .withdraw_submission(&id, &ctx.applicant, &0_u32, &BytesN::random(&ctx.env)); let count = ctx .env @@ -304,7 +313,7 @@ fn submit_beyond_former_cap_succeeds() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../v5001.json"); ctx.events - .submit(&id, &ctx.applicant, &uri, &BytesN::random(&ctx.env)); + .submit(&id, &ctx.applicant, &0_u32, &uri, &BytesN::random(&ctx.env)); let count = ctx .env @@ -329,7 +338,10 @@ fn submit_at_counter_overflow_reverts() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../overflow.json"); let op = BytesN::random(&ctx.env); - let err = expect_op_err(ctx.events.try_submit(&id, &ctx.applicant, &uri, &op)); + let err = expect_op_err( + ctx.events + .try_submit(&id, &ctx.applicant, &0_u32, &uri, &op), + ); assert_eq!( err, Error::TooManyContributors, @@ -345,7 +357,10 @@ fn submit_oversized_content_uri_reverts() { let too_long = "x".repeat((MAX_CONTENT_URI_LEN + 1) as usize); let uri = String::from_str(&ctx.env, &too_long); let op = BytesN::random(&ctx.env); - let err = expect_op_err(ctx.events.try_submit(&id, &ctx.applicant, &uri, &op)); + let err = expect_op_err( + ctx.events + .try_submit(&id, &ctx.applicant, &0_u32, &uri, &op), + ); // Reused rather than a new variant — stays inside the contracterror // 50-variant cap (see BACKLOG.md L7 for precedent). assert_eq!( @@ -361,8 +376,13 @@ fn resubmit_by_existing_applicant_does_not_increment_submission_count() { let id = create_hackathon(&ctx); let uri_a = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); - ctx.events - .submit(&id, &ctx.applicant, &uri_a, &BytesN::random(&ctx.env)); + ctx.events.submit( + &id, + &ctx.applicant, + &0_u32, + &uri_a, + &BytesN::random(&ctx.env), + ); let count_after_first = ctx .env @@ -370,8 +390,13 @@ fn resubmit_by_existing_applicant_does_not_increment_submission_count() { assert_eq!(count_after_first, 1); let uri_b = String::from_str(&ctx.env, "ipfs://Qm.../v2.json"); - ctx.events - .submit(&id, &ctx.applicant, &uri_b, &BytesN::random(&ctx.env)); + ctx.events.submit( + &id, + &ctx.applicant, + &0_u32, + &uri_b, + &BytesN::random(&ctx.env), + ); let count_after_second = ctx .env @@ -401,6 +426,7 @@ fn select_winners_single_recipient_sweeps_escrow() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 50, }, ]; @@ -454,16 +480,19 @@ fn select_winners_multi_position_splits_by_distribution() { WinnerSpec { recipient: first.clone(), position: 1, + amount: TOTAL_BUDGET * 50 / 100, reputation_bump: 60, }, WinnerSpec { recipient: second.clone(), position: 2, + amount: TOTAL_BUDGET * 30 / 100, reputation_bump: 40, }, WinnerSpec { recipient: third.clone(), position: 3, + amount: TOTAL_BUDGET * 20 / 100, reputation_bump: 20, }, ]; @@ -516,21 +545,25 @@ fn select_winners_empty_set_reverts() { } #[test] -fn select_winners_position_not_in_distribution_reverts() { +fn select_winners_allows_a_position_with_no_floor() { let ctx = setup(); - let id = create_hackathon(&ctx); // distribution only has position 1 + let id = create_hackathon(&ctx); // floors cover position 1 only let winners = soroban_sdk::vec![ &ctx.env, WinnerSpec { recipient: ctx.applicant.clone(), position: 2, + amount: 1_0000000_i128, reputation_bump: 0, }, ]; let op = BytesN::random(&ctx.env); - let res = ctx.events.try_select_winners(&id, &winners, &op); - assert!(res.is_err(), "position outside distribution must revert"); + ctx.events.select_winners(&id, &winners, &op); + + let rows = ctx.events.get_winners(&id); + assert_eq!(rows.len(), 1); + assert_eq!(rows.get(0).unwrap().position, 2); } #[test] @@ -545,11 +578,13 @@ fn select_winners_duplicate_position_reverts() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET * 50 / 100, reputation_bump: 0, }, WinnerSpec { recipient: other, position: 1, // duplicate + amount: TOTAL_BUDGET * 50 / 100, reputation_bump: 0, }, ]; @@ -569,6 +604,7 @@ fn select_winners_batches_append_and_position_replay_reverts() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET * 50 / 100, reputation_bump: 0, }, ]; @@ -584,6 +620,7 @@ fn select_winners_batches_append_and_position_replay_reverts() { WinnerSpec { recipient: usurper, position: 1, + amount: TOTAL_BUDGET * 50 / 100, reputation_bump: 0, }, ]; @@ -601,11 +638,13 @@ fn select_winners_batches_append_and_position_replay_reverts() { WinnerSpec { recipient: second.clone(), position: 2, + amount: TOTAL_BUDGET * 30 / 100, reputation_bump: 0, }, WinnerSpec { recipient: third.clone(), position: 3, + amount: TOTAL_BUDGET * 20 / 100, reputation_bump: 0, }, ]; @@ -636,6 +675,7 @@ fn select_winners_replayed_op_reverts() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; @@ -654,6 +694,7 @@ fn select_winners_on_missing_event_reverts() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; @@ -672,6 +713,7 @@ fn select_winners_on_completed_event_reverts() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; @@ -689,6 +731,7 @@ fn select_winners_on_completed_event_reverts() { WinnerSpec { recipient: again, position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; @@ -710,6 +753,7 @@ fn select_winners_demands_owner_auth() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; diff --git a/contracts/events/src/tests/op_id_security.rs b/contracts/events/src/tests/op_id_security.rs index 87fe2be..1aaa682 100644 --- a/contracts/events/src/tests/op_id_security.rs +++ b/contracts/events/src/tests/op_id_security.rs @@ -76,9 +76,9 @@ fn setup<'a>() -> Ctx<'a> { } } -fn dist_100(env: &Env) -> Map { +fn dist_100(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, 100000000000_i128); m } @@ -92,7 +92,7 @@ fn create_bounty(ctx: &Ctx) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/events/op-id-sec"), title: String::from_str(&ctx.env, "OpId Security"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist_100(&ctx.env), + prize_floors: dist_100(&ctx.env), fee_bps_override: None, manager: None, }; @@ -153,6 +153,7 @@ fn bootstrap_self_cannot_front_run_events_child_op_ids() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: 10_000_0000000_i128, reputation_bump: 50, }, ]; @@ -236,7 +237,7 @@ fn event_id_overflow_reverts() { content_uri: String::from_str(env, "https://api.boundless.fi/events/overflow"), title: String::from_str(env, "Overflow"), deadline: Some(env.ledger().timestamp() + 86_400), - winner_distribution: dist_100(env), + prize_floors: dist_100(env), fee_bps_override: None, manager: None, }; @@ -295,6 +296,7 @@ fn permissionless_apply_cannot_squat_select_winners_op_id() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: 10_000_0000000_i128, reputation_bump: 0, }, ]; diff --git a/contracts/events/src/tests/prize_claim.rs b/contracts/events/src/tests/prize_claim.rs index 79f04f4..3288dd4 100644 --- a/contracts/events/src/tests/prize_claim.rs +++ b/contracts/events/src/tests/prize_claim.rs @@ -71,7 +71,7 @@ fn setup<'a>() -> Ctx<'a> { } } -fn create_single(ctx: &Ctx, dist: Map) -> u64 { +fn create_single(ctx: &Ctx, dist: Map) -> u64 { let params = CreateEventParams { pillar: Pillar::Hackathon, owner: ctx.owner.clone(), @@ -81,32 +81,33 @@ fn create_single(ctx: &Ctx, dist: Map) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/prize-claim"), title: String::from_str(&ctx.env, "Prize Claim Suite"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; ctx.events.create_event(¶ms, &BytesN::random(&ctx.env)) } -fn dist_100(env: &Env) -> Map { +fn dist_100(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, TOTAL_BUDGET); m } -fn dist_60_40(env: &Env) -> Map { +fn dist_60_40(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 60); - m.set(2, 40); + m.set(1, TOTAL_BUDGET * 60 / 100); + m.set(2, TOTAL_BUDGET * 40 / 100); m } -fn select_one(ctx: &Ctx, id: u64, recipient: &Address, position: u32, bump: u32) { +fn select_one(ctx: &Ctx, id: u64, recipient: &Address, position: u32, amount: i128, bump: u32) { let winners = soroban_sdk::vec![ &ctx.env, WinnerSpec { recipient: recipient.clone(), position, + amount, reputation_bump: bump, }, ]; @@ -125,7 +126,7 @@ fn claim_pays_recipient_full_prize_and_no_release_fee() { let token = token::Client::new(&ctx.env, &ctx.token_addr); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 50); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET, 50); // Selection alone must not move funds or complete the event. assert_eq!(token.balance(&w), 0); @@ -167,8 +168,8 @@ fn split_claims_pay_exact_amounts_each() { let a = Address::generate(&ctx.env); let b = Address::generate(&ctx.env); - select_one(&ctx, id, &a, 1, 10); - select_one(&ctx, id, &b, 2, 5); + select_one(&ctx, id, &a, 1, TOTAL_BUDGET * 60 / 100, 10); + select_one(&ctx, id, &b, 2, TOTAL_BUDGET * 40 / 100, 5); ctx.events .claim_prize(&id, &2_u32, &BytesN::random(&ctx.env)); @@ -193,7 +194,7 @@ fn topup_after_selection_stays_residual_for_refund() { let token_admin = token::StellarAssetClient::new(&ctx.env, &ctx.token_addr); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 0); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET, 0); // A partner tops up AFTER selection: the prize amount stays anchored // to the selection-time baseline; the top-up is refundable residual. @@ -227,7 +228,7 @@ fn claim_requires_recipient_auth() { let id = create_single(&ctx, dist_100(&ctx.env)); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 0); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET, 0); ctx.env.mock_auths(&[]); let res = ctx @@ -242,7 +243,7 @@ fn claim_demands_the_award_recipients_auth_specifically() { let id = create_single(&ctx, dist_100(&ctx.env)); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 0); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET, 0); ctx.events .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); @@ -264,7 +265,7 @@ fn double_claim_reverts() { let id = create_single(&ctx, dist_60_40(&ctx.env)); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 0); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET * 60 / 100, 0); ctx.events .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); @@ -287,8 +288,8 @@ fn op_id_replay_reverts() { // not yet paid. (Cross-recipient op_id reuse is intentionally allowed — // OpSeen is namespaced per authorizing caller.) let a = Address::generate(&ctx.env); - select_one(&ctx, id, &a, 1, 0); - select_one(&ctx, id, &a, 2, 0); + select_one(&ctx, id, &a, 1, TOTAL_BUDGET * 60 / 100, 0); + select_one(&ctx, id, &a, 2, TOTAL_BUDGET * 40 / 100, 0); let op = BytesN::random(&ctx.env); ctx.events.claim_prize(&id, &1_u32, &op); @@ -305,7 +306,7 @@ fn claim_of_unawarded_position_reverts() { let id = create_single(&ctx, dist_60_40(&ctx.env)); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 0); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET * 60 / 100, 0); // Position 2 is in the distribution but has no award yet. let res = ctx @@ -332,14 +333,14 @@ fn claim_on_multi_release_event_reverts() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/grant"), title: String::from_str(&ctx.env, "Grant"), deadline: None, - winner_distribution: dist_100(&ctx.env), + prize_floors: dist_100(&ctx.env), fee_bps_override: None, manager: None, }; let id = ctx.events.create_event(¶ms, &BytesN::random(&ctx.env)); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 0); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET, 0); let res = ctx .events @@ -360,7 +361,7 @@ fn cancel_blocked_while_unclaimed_prizes_within_window() { let id = create_single(&ctx, dist_60_40(&ctx.env)); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 0); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET * 60 / 100, 0); let res = ctx.events.try_start_cancel(&id, &BytesN::random(&ctx.env)); assert!( @@ -387,7 +388,7 @@ fn cancel_after_window_expiry_sweeps_unclaimed_and_blocks_late_claim() { let token = token::Client::new(&ctx.env, &ctx.token_addr); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 0); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET, 0); ctx.env.ledger().with_mut(|li| { li.timestamp += PRIZE_CLAIM_WINDOW_SECS + 1; @@ -414,14 +415,14 @@ fn claim_window_refreshes_on_a_later_batch() { let id = create_single(&ctx, dist_60_40(&ctx.env)); let a = Address::generate(&ctx.env); - select_one(&ctx, id, &a, 1, 0); + select_one(&ctx, id, &a, 1, TOTAL_BUDGET * 60 / 100, 0); // Move to just before the first window expires, then select batch 2. ctx.env.ledger().with_mut(|li| { li.timestamp += PRIZE_CLAIM_WINDOW_SECS - 100; }); let b = Address::generate(&ctx.env); - select_one(&ctx, id, &b, 2, 0); + select_one(&ctx, id, &b, 2, TOTAL_BUDGET * 40 / 100, 0); // Past the FIRST batch's expiry, but inside the refreshed window: // cancel stays blocked, protecting the late-selected winner. diff --git a/contracts/events/src/tests/token_whitelist.rs b/contracts/events/src/tests/token_whitelist.rs index f8736ca..4e2b20d 100644 --- a/contracts/events/src/tests/token_whitelist.rs +++ b/contracts/events/src/tests/token_whitelist.rs @@ -37,9 +37,9 @@ fn new_token(env: &Env) -> Address { env.register_stellar_asset_contract_v2(issuer).address() } -fn single_dist(env: &Env) -> Map { +fn single_dist(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, 10000000000_i128); m } @@ -122,7 +122,7 @@ fn create_event_with_unsupported_token_reverts() { content_uri: String::from_str(&ctx.env, "https://example.com"), title: String::from_str(&ctx.env, "Bad Token Hack"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -151,7 +151,7 @@ fn create_event_with_deregistered_token_reverts() { content_uri: String::from_str(&ctx.env, "https://example.com"), title: String::from_str(&ctx.env, "Deregistered Token Hack"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; diff --git a/contracts/events/src/types.rs b/contracts/events/src/types.rs index fab766a..936ccff 100644 --- a/contracts/events/src/types.rs +++ b/contracts/events/src/types.rs @@ -73,7 +73,11 @@ pub struct EventRecord { pub title: String, pub created_at: u64, pub deadline: Option, - pub winner_distribution: Map, + /// Advertised minimum per position, in token-native units. `select_winners` + /// may pay above a floor but never below it, so a published prize table is + /// a guarantee rather than an estimate. Positions absent from the map carry + /// no floor and are payable at any positive amount. + pub prize_floors: Map, pub fee_bps_override: Option, } @@ -91,7 +95,7 @@ pub struct CreateEventParams { pub content_uri: String, pub title: String, pub deadline: Option, - pub winner_distribution: Map, + pub prize_floors: Map, pub fee_bps_override: Option, pub manager: Option
, } @@ -139,6 +143,10 @@ pub struct Winner { pub struct WinnerSpec { pub recipient: Address, pub position: u32, + /// Award amount in token-native units. Allocation happens here, at payout, + /// not at create: the event holds a pool and each selection names what it + /// spends from it. + pub amount: i128, pub reputation_bump: u32, } @@ -167,6 +175,8 @@ pub enum DataKey { EventApplicantAt(u64, u32), EventApplicantSlot(u64, Address), + /// Pre-1.7.0 single-submission key. Read only by `migrate`, which moves + /// each row to slot 0 of `EventSubmissionEntry`. Never write this. EventSubmission(u64, Address), EventWinnerCount(u64), @@ -209,6 +219,22 @@ pub enum DataKey { // Appended to cap per-event submission storage growth (security fix). EventSubmissionCount(u64), + + // Appended in 1.7.0. Sum of awarded-but-unclaimed prizes. `remaining_escrow` + // only drops at claim time, so without this a second selection would see + // funds an earlier winner is still entitled to and could promise them twice. + EventOwedTotal(u64), + + // Appended in 1.7.0. Submissions gain a caller-chosen slot, so one wallet + // may hold several distinct entries in one event. The contract assigns no + // meaning to the slot; callers use it for whatever separates their entries. + EventSubmissionEntry(u64, Address, u32), + EventApplicantSubmissionCount(u64, Address), + + // Next event id `migrate_events` has yet to convert. The pass is paged + // because one invocation may touch only 100 ledger entries, so a + // deployment with real history cannot be migrated in a single call. + MigrationCursor, } // ============================================================ diff --git a/contracts/profile/src/admin.rs b/contracts/profile/src/admin.rs index f7c47bc..25413b5 100644 --- a/contracts/profile/src/admin.rs +++ b/contracts/profile/src/admin.rs @@ -7,8 +7,14 @@ use crate::types::{PendingAdmin, PendingEventsContract, PendingUpgrade}; const PENDING_TTL_LEDGERS: u32 = 120_960; +// H6 (audit 2026-06) mandated 17_280 ledgers, ~1 day, on mainnet. Zeroed +// deliberately while mainnet escrow is empty so the 1.7.0 rollout can iterate. +// RESTORE to 17_280 before the first funded campaign: with no window, a +// compromised admin key can propose and apply a wasm swap in one go, and +// cancel_pending_upgrade never gets a chance to fire. Tracked in BACKLOG. +// The cfg split is kept so restoring is a single-value edit. #[cfg(not(feature = "testnet"))] -const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; +const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; #[cfg(feature = "testnet")] const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; diff --git a/contracts/profile/src/tests/admin.rs b/contracts/profile/src/tests/admin.rs index 635c7fb..85fe24f 100644 --- a/contracts/profile/src/tests/admin.rs +++ b/contracts/profile/src/tests/admin.rs @@ -11,7 +11,8 @@ use crate::errors::Error; const EVENTS_CONTRACT_TIMELOCK_LEDGERS: u32 = 17_280; const PENDING_EVENTS_CONTRACT_TTL_LEDGERS: u32 = 120_960; -const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; +// Zeroed with the contract constant; restore both together. +const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; #[test] @@ -189,19 +190,20 @@ fn propose_upgrade_records_pending() { } #[test] -fn apply_upgrade_before_timelock_reverts_profile() { +fn apply_upgrade_is_immediate_while_the_timelock_is_zero_profile() { + // Mirror of the events-side test. When UPGRADE_TIMELOCK_LEDGERS is + // restored to 17_280 this should revert with UpgradeTimelockNotElapsed. let ctx = setup(); let new_hash: BytesN<32> = BytesN::random(&ctx.env); let new_version = String::from_str(&ctx.env, "0.3.0"); ctx.client.propose_upgrade(&new_hash, &new_version); - let err = ctx - .client - .try_apply_upgrade() - .err() - .expect("timelock blocks") - .unwrap(); - assert_eq!(err, Error::UpgradeTimelockNotElapsed); + let pending = ctx.client.get_pending_upgrade().expect("proposal"); + assert_eq!( + pending.available_at_ledger, pending.proposed_at_ledger, + "no window between proposing and applying" + ); + assert_eq!(UPGRADE_TIMELOCK_LEDGERS, 0, "restore me with the constant"); } #[test] diff --git a/docs/figures/threat-model-figure-1-system-dfd.svg b/docs/figures/threat-model-figure-1-system-dfd.svg new file mode 100644 index 0000000..8912642 --- /dev/null +++ b/docs/figures/threat-model-figure-1-system-dfd.svg @@ -0,0 +1,157 @@ + +Figure 1 — Boundless system data-flow diagram with trust boundaries + + + + +TB1 · Internet / Backend + + +TB3 · External service + + +TB5 · Off-chain / On-chain + + +TB6 · events ↔ profile + +User wallets · managed (custodial) or external + + +Builder wallet + + +Organizer wallet + + +Manager wallet + + + + + + +Didit KYC +docs stay at Didit + + +Platform admin +2-of-3 multi-sig + +Web app (browser) +Next.js · holds no keys + + +PostgreSQL +no PII · encrypted keys + +Backend API & orchestrator +auth · KYC guard · tx builder +indexer · custodial wallet signer + +Admin portal +staff only · step-up TOTP + +Stellar RPC +managed endpoint + + +Events storage +instance · persistent · temporary + +boundless-events +escrow · lifecycle +awards · cancellation + +boundless-profile +reputation · earnings + +Token contract +USDC SAC + + +Profile storage +profiles · earnings · binding + + +Ledger balances +escrow · fee account · wallets + +critical custody +all escrow sits in one contract balance · per-event accounting: remaining_escrow, EventOwedTotal + + + + + +HTTPS + +external wallets sign locally + + +REST / JWT + signed XDR + + +documents (direct) + + +HMAC-signed webhook + + +OIDC + TOTP + + +admin API + + +read/write + + +signed XDR + + +getEvents + + +admin ops via RPC · 2-of-3 signature +events & profile · pause · fees · upgrade + + +invoke + + +read/write + + +bootstrap +bump_reputation +register_earnings + + +read/write + + +transfer +deposit · release · refund + + +debit / credit + + +External entity + +Boundless process + +On-chain contract + +Third-party process + + +Data store + +Data flow + +Dashed: local signature / admin ops + +Trust boundary + diff --git a/docs/figures/threat-model-figure-2-escrow-lifecycle.svg b/docs/figures/threat-model-figure-2-escrow-lifecycle.svg new file mode 100644 index 0000000..aaf7030 --- /dev/null +++ b/docs/figures/threat-model-figure-2-escrow-lifecycle.svg @@ -0,0 +1,346 @@ + +Figure 2 — Escrow lifecycle and authorization: who signs each boundless-events transition, how the escrow pool moves, and which ledger accounts are credited + + + + + + +Signers · off-chain wallets (external entities) + + +Owner +organizer wallet; +funds the event + + + +Manager +delegate; the owner +until one accepts + + + +Contributor +partner or +community wallet + + + +Builder +applicant wallet; +never holds escrow + + + +Winner +stored award +recipient + + +Anyone +permissionless crank; +no signature + + + +Admin +2-of-3 multi-sig; +critical custody + + + +TB7 · Owner ↔ Manager + + + +Backend relay · tx builder · indexer +signs for managed wallets · bypassable (TB2) + + +signed XDR via RPC · require_auth() verifies the chip signer + + +direct to RPC · relay optional + + +TB5 · Off-chain / On-chain + +boundless-events 1.7.0 +Managed wallets are signed by the backend under envelope encryption; the admin quorum is never server-held. Crowdfunding releases also need the 2-of-3 admin (rose chip). +Settlement path + + +1 +create_event + +Owner +pool += budget; +fee → fee account +(same tx; fee ≤ 10%); +Σ floors ≤ budget; +crowdfunding: pool +starts at 0 + +Active + + +2 +accept_manager + +Manager +optional; no funds +move; authority +owner → manager; +custody stays with +the owner (TB7) + + +3 +add_funds + +Contributor +pool += amount +(min 10 tokens); +fee at deposit; +crowdfunding: no fee +now, fee at release + + +4 +apply_to_bounty +submit + +Builder +no funds move; +bounty: apply before +submit; resubmitting +replaces the slot + + +5 +select_winners + +Manager +owed += Σ amounts; +pool unchanged; +owed ≤ pool; ≥ floors; +each position once; +90-day window: only +blocks cancel (8) + + +6 +claim_prize + +Winner +pool −= amount; +owed −= amount; +winner pulls own +award; single release +(hackathon, bounty); +pool 0 → Completed + +Completed + + + + + + + +Cancellation path + + +8 +start_cancel + +Manager +refused while prizes are +claimable (90 days); +owed reset to 0; +Active → Cancelling; +owner-only funds: refund +owner, Active → Cancelled + +Cancelling + + +9 +process_cancel_batch + +Anyone +pool −= refunds; +≤ 25 refunds per call, +repeat until done; +pro rata when pool < +outside contributions; +only while Cancelling + + +10 +finalize_cancel + +Anyone +pool −= residual; +→ owner; pool = 0; +only after all refunds +are processed; +Cancelling → Cancelled + +Cancelled + +Milestone release + + +7 +claim_milestone + +Owner + + +Admin · crowdfunding +pool −= milestone amount; +recipient needs a winner record; +grant: award ÷ milestones (from 5); +owed −= amount; +crowdfunding: pool ÷ milestones +left → owner (record set in 1); +fee at release; Admin co-signs + + + + + + +≤ 25 refunds/call + + +any time while Active + + +owner-only funds · start_cancel refunds the owner inline · Active → Cancelled + + +grant: milestone schedule from the award (5) + + +pool = 0 + + +token transfer · deposit / release / refund + +Escrow pool + +critical custody +contract token balance; +one balance for all events; +fees are forwarded, +never pooled + + + + + +remaining_escrow += unreserved + reserved +unreserved +EventOwedTotal (reserved) +released +↑ deposits (1, 3) ↓ claims (6, 7), refunds (9), owner residual (8, 10) +reserved: ↑ select_winners (5) ↓ claims (6, 7) reset to 0 at 8 +after step +1–4 + +5 + + +6·7 + + + +8 + + +9 + + +10 + + + +released (derived) · left the pool to: +Recipient accounts +prize · 6 + + + +Winner wallet +milestone · 7 (grantee / crowdfunding owner) + + + +Milestone recipient +refunds · 9 (full, or pro rata) + + + +Contributor wallets +residual · 8 (owner-only funds), 10 + + + +Owner wallet +fee · 1, 3 (crowdfunding: 7) · never pooled + + + +Fee account + + +External entity + +Process · contract + +Process · Boundless + +Event state + +Data store + +Data / token flow + +Conditional path + +Trust boundary +Signer chips + +Owner +must sign (require_auth) + +Manager +the owner until a manager accepts + +Anyone +permissionless · no signature + + +Admin +2-of-3 multi-sig · critical custody + diff --git a/docs/mainnet-upgrade-runbook-2026-08.md b/docs/mainnet-upgrade-runbook-2026-08.md new file mode 100644 index 0000000..6b4ef2a --- /dev/null +++ b/docs/mainnet-upgrade-runbook-2026-08.md @@ -0,0 +1,269 @@ +# Mainnet upgrade runbook — events 1.7.0 + profile 1.2.0 + +Prepared 2026-08-18 from `feat/escrow-open-pool` @ `870e9a3`. + +Every chain reading below was taken at preparation time. **Re-read them before +signing.** What makes this upgrade safe is the state of mainnet, not the state +of the code. + +--- + +## 1. Artifacts + +| | events | profile | +|---|---|---| +| Contract | `CCFVEGOQJEM47LRAJU2LHEK4KTL5VYN7AOGZ2HH2GNHAMXTILNMMJGQZ` | `CD3KH4OE7HDHHHUYFX3U4L7NLIILMXAY6HM5FEH2UH6UBOKX4HDNE3PC` | +| On chain now | 1.6.0 | 1.1.0 | +| Target | 1.7.0 | 1.2.0 | +| Wasm sha256 | `b87365c16102f7242a8fc3e770f615991841741c40673bb241cb254130be4c26` | `6b4804920a4068dfdcaaa711fac6390850e0a92f4b5ed4e46259694b61cbae12` | +| Size | 62,110 bytes (94% of the 64 KB ceiling) | 16,148 bytes | +| Data migration | **Yes — rewrites every EventRecord** | No; `migrate()` is a version stamp with an empty dispatch | + +Reproduce both hashes before signing: + +``` +stellar contract build --package boundless-events +stellar contract build --package boundless-profile +shasum -a 256 target/wasm32v1-none/release/boundless_events.wasm +shasum -a 256 target/wasm32v1-none/release/boundless_profile.wasm +``` + +Profile's public interface is byte-identical between 1.1.0 and 1.2.0 (verified +by diffing `pub fn` signatures). Its delta is internal: `checked_add` over +`saturating_add` with typed errors (#101), non-malleable child op_id derivation +and namespaced `OpSeen` (#95), SDK 27 storage gating (#102), and the zeroed +timelock. Because the interface is unchanged, events 1.7.0 runs against either +profile version, so the ordering below is convention, not a hard dependency. + +--- + +## 2. Pre-flight — re-run immediately before signing + +The events migration rewrites every `EventRecord`, because `winner_distribution` +and `prize_floors` differ in field name *and* value type: a pre-1.7.0 row cannot +be decoded by the new struct at all. That is only safe because no mainnet event +can still reach `select_winners`. + +``` +for id in 271539957145796609 271539957145796610; do + stellar contract invoke --id CCFVEGOQJEM47LRAJU2LHEK4KTL5VYN7AOGZ2HH2GNHAMXTILNMMJGQZ \ + --source --network mainnet -- get_event --event_id $id +done +``` + +Expected, and true at preparation: + +| Event | Status | Remaining escrow | +|---|---|---| +| `271539957145796609` | Completed | 0 | +| `271539957145796610` | Completed | 0 | + +Then confirm: + +- `…608`, `…611`, `…612`, `…613` all return `#30 EventNotFound` (check the error + code, not just the absence of output — a loose probe reports false positives). +- `is_paused` is `false` on both contracts. +- `get_pending_upgrade` is `null` on both. It was at preparation; a stale + proposal must be cleared with `cancel_pending_upgrade` first. + +**Stop if any event reads Active, or a third event exists.** Its record cannot +be rewritten safely while it can still be selected against. + +--- + +## 3. Three things that will bite you + +**Every admin envelope must be simulated before signing.** `--build-only` +alone produces a transaction with `fee: 100` and no `SorobanTransactionData`: +no footprint, no resource fee. It will be rejected. Pipe every build through +`stellar tx simulate`, which fills both in (the profile propose goes from fee +100 to 318,317 stroops). The pattern throughout is: + +``` +stellar contract invoke --id --source-account boundless-mainnet-msig \ + --network mainnet --build-only -- \ + | stellar tx simulate --source-account boundless-mainnet-msig --network mainnet +``` + +Simulate against current ledger state, so build each envelope close to when it +will be signed rather than batching them up front. + +**The sequence number.** Both prepared envelopes were built against the admin +account's current sequence, so they carry the *same* `seq_num` +(`271536946373722128`). Only one can ever be submitted; the second fails with +`tx_bad_seq`. **Build each envelope only after the previous transaction has +confirmed.** The same applies to every `apply_upgrade`, `migrate_events` and +`migrate` call below — each is a separate admin transaction consuming a +sequence number. + +**The timelock is zero.** `UPGRADE_TIMELOCK_LEDGERS = 0` on both contracts, so +`propose_upgrade` and `apply_upgrade` can land in consecutive ledgers and there +is no window in which `cancel_pending_upgrade` can catch a mistake. The 1.1.0 +upgrade ran with roughly a day. Restoring it is issue #122 and a single-value +edit in `admin.rs` — but it changes both wasm hashes and invalidates every +envelope here. + +--- + +## 4. Signing + +Admin for both contracts is `GCVK72I6TVJVDTTY4UKU6MQT4QJ2T2AAG3NULNEUDM46L3UOQYDSO4O2`: +a 2-of-3 multisig (three weight-1 signers, master key weight 0 and therefore +unable to authorize anything on its own). No key on the build machine can sign +this. Follow `docs/contract-ops-runbook.md` Section 5 for the ceremony; sign +sequentially, then submit with `stellar tx send "" --network mainnet`. + +Decode every envelope before signing: + +``` +stellar xdr decode --type TransactionEnvelope --output json <<< "" +``` + +--- + +## 5. Sequence + +Profile first, matching the deploy convention that events depends on profile. +Each numbered step is a separate 2-of-3 signing ceremony unless marked read-only. + +### 5.1 Upload both wasms + +Any funded mainnet account; **no admin auth required**, so this needs no +ceremony. `apply_upgrade` fails if the wasm is not already on the network. + +``` +stellar contract upload --wasm target/wasm32v1-none/release/boundless_profile.wasm \ + --source --network mainnet # must print 6b480492… +stellar contract upload --wasm target/wasm32v1-none/release/boundless_events.wasm \ + --source --network mainnet # must print b87365c1… +``` + +### 5.2 Profile: 1.1.0 → 1.2.0 + +1. **propose_upgrade** — prepared envelope: + +``` +AAAAAgAAAACqr+kenVNRznjlFU8yE+QTqegANttFtJQbOeXujoYHJwAE220DxLFqAAAAEAAAAAAAAAAAAAAAAQAAAAAAAAAYAAAAAAAAAAH2o/HE+cZznpgt904v7VoQtlwY8dnSkPqh/UC5V+HG0gAAAA9wcm9wb3NlX3VwZ3JhZGUAAAAAAgAAAA0AAAAga0gEkgpAaN/cqqcR+sY5CFDgqS9LXtTkYllpS2HLrhIAAAAOAAAABTEuMi4wAAAAAAAAAQAAAAAAAAAAAAAAAfaj8cT5xnOemC33Ti/tWhC2XBjx2dKQ+qH9QLlX4cbSAAAAD3Byb3Bvc2VfdXBncmFkZQAAAAACAAAADQAAACBrSASSCkBo39yqpxH6xjkIUOCpL0te1ORiWWlLYcuuEgAAAA4AAAAFMS4yLjAAAAAAAAAAAAAAAQAAAAAAAAABAAAAB7njUAz7VZeB5oNZZV83d1x4r//FqENtrKnR0fjjVpsCAAAAAQAAAAYAAAAB9qPxxPnGc56YLfdOL+1aELZcGPHZ0pD6of1AuVfhxtIAAAAUAAAAAQAT6jAAAAAAAAACrAAAAAAABNsJAAAAAA== +``` + +Simulated: fee 318,317 stroops, resource fee 318,217, 684 write bytes. Decodes +to source `GCVK72I6…`, contract `CD3KH4OE…`, `propose_upgrade`, args +`6b480492…` and `"1.2.0"`. + +Regenerate with: + +``` +stellar contract invoke --id CD3KH4OE7HDHHHUYFX3U4L7NLIILMXAY6HM5FEH2UH6UBOKX4HDNE3PC \ + --source-account boundless-mainnet-msig --network mainnet --build-only \ + -- propose_upgrade --new_wasm_hash 6b4804920a4068dfdcaaa711fac6390850e0a92f4b5ed4e46259694b61cbae12 --new_version "1.2.0" \ + | stellar tx simulate --source-account boundless-mainnet-msig --network mainnet +``` + +2. **apply_upgrade** — build fresh (sequence has moved): + +``` +stellar contract invoke --id CD3KH4OE7HDHHHUYFX3U4L7NLIILMXAY6HM5FEH2UH6UBOKX4HDNE3PC \ + --source-account boundless-mainnet-msig --network mainnet --build-only -- apply_upgrade \ + | stellar tx simulate --source-account boundless-mainnet-msig --network mainnet +``` + +3. **migrate** — version stamp only; the dispatch is empty, so nothing is + rewritten. Build fresh, same pattern. + +4. **Verify (read-only):** `version()` reads `"1.2.0"`, `is_paused` still + `false`, `get_admin` unchanged, and `get_events_contract` still points at + `CCFVEGOQ…`. + +### 5.3 Events: 1.6.0 → 1.7.0 + +1. **propose_upgrade** — build it after the profile steps have confirmed; the + sequence will have moved several times by then: + +``` +stellar contract invoke --id CCFVEGOQJEM47LRAJU2LHEK4KTL5VYN7AOGZ2HH2GNHAMXTILNMMJGQZ \ + --source-account boundless-mainnet-msig --network mainnet --build-only \ + -- propose_upgrade --new_wasm_hash b87365c16102f7242a8fc3e770f615991841741c40673bb241cb254130be4c26 --new_version "1.7.0" \ + | stellar tx simulate --source-account boundless-mainnet-msig --network mainnet +``` + +Verify it decodes to source `GCVK72I6…`, contract `CCFVEGOQ…`, +`propose_upgrade`, args `b87365c1…` and `"1.7.0"`. + +2. **apply_upgrade** — build fresh. + +3. **migrate_events(max_events)** — paged; **this is the point of no return** + (see §6). Two events, so one call with `--max_events 8` suffices, but drive + it off the counter rather than the assumption: + +``` +stellar contract invoke --id CCFVEGOQ… --source --network mainnet -- migration_remaining # read-only +``` + +Repeat `migrate_events` until `migration_remaining` reads `0`. + +4. **migrate** — one-shot version stamp. It refuses while any event is still + unconverted, so step 3 must be complete. + +5. **Verify (read-only):** `version()` reads `"1.7.0"`, and both events now + expose `prize_floors` instead of `winner_distribution`: + +``` +stellar contract invoke --id CCFVEGOQ… --source --network mainnet -- get_event --event_id 271539957145796609 +``` + +Expect `prize_floors: {"1": "100000000"}` on `…609` and +`{"1": "600000000", "2": "400000000"}` on `…610` — the absolute amounts the old +percentages would have paid against each budget. + +### 5.4 Backend cutover — immediately after §5.3 step 4 + +`create_event`, `select_winners`, `submit`, `withdraw_submission` and +`get_submission` all changed shape, and `Submitted` / `SubmissionWithdrawn` now +carry a slot. Deploy `boundless-nestjs` from the matching commit as soon as +`migrate()` confirms. + +A lagging backend fails publishes loudly with no funds moved, which is the safe +direction — but **do not publish a bounty in the gap**. + +### 5.5 Record the result + +Update `deployments/mainnet.json` (version, both wasm hashes, `last_upgraded_at`) +and append to `deployments/mainnet-upgrades.jsonl`. + +--- + +## 6. Rollback + +The window closes in stages: + +| Point | Recoverable? | +|---|---| +| After `propose_upgrade` | Yes — `cancel_pending_upgrade`. With a zero timelock this window is only as long as you leave it. | +| After `apply_upgrade`, before `migrate_events` | Yes — propose and apply the previous wasm. Records are still in 1.6.0 shape and untouched. | +| After `migrate_events` | **No.** Records are rewritten to `prize_floors`; 1.6.0 code cannot decode them, so reverting the wasm bricks reads. | +| After `migrate` | No, and `migrate()` cannot be re-run: it returns `#69 MigrationAlreadyApplied`. A defect found later cannot be fixed by re-migrating. | + +Previous events wasm for a pre-migration rollback: +`43b83ae4e5ae689f004278f936a8616a2add5e09bb1024e8830c7f266f464963`. +Previous profile wasm: +`b9e3500cfb559781e68359655f37775c78afffc5a8436daca9d1d1f8e3569b02`. + +If something looks wrong mid-sequence, `pause` is a 2-of-3 call and stops user +operations while you decide. See `docs/mainnet-deploy-runbook.md` §329. + +--- + +## 7. What a signer should weigh + +- **The third-party audit is an open P0** in `BACKLOG.md` under "blocking + mainnet", and this change touches the money path. +- **The timelock is zero** on both contracts (§3). +- **PR #120 is unmerged**, with 6 review comments still open beyond the owed- + reservation fix that is already in this build. +- **`deploy_and_upgrade.sh` has an argument-order bug** — `$5` is the source + account and silently defaults to `alice`. Use explicit `stellar contract + invoke` calls for mainnet, as written above. + +Testing behind this build: 312 contract tests, 2208 backend tests, and the +22-script smoke suite green on testnet. Both binaries are deployed on testnet, +so what mainnet would run has run on a chain. diff --git a/docs/push-payouts-feasibility.md b/docs/push-payouts-feasibility.md new file mode 100644 index 0000000..d4484a4 --- /dev/null +++ b/docs/push-payouts-feasibility.md @@ -0,0 +1,332 @@ +# Push payouts: feasibility and risk analysis + +**Status:** proposal, not a decision +**Date:** 2026-08-08 +**Question:** can we go back to pushing prize money at winner selection, instead of having winners claim it? + +--- + +## 1. The question, and why it is being asked + +Product wants the claim step gone. A winner should not have to press a button to +collect money they have already won. The money should simply arrive. + +The obvious route is to change the contract: put the transfers back inside +`select_winners` so funds move at selection. Before touching the contract, this +document works out whether that is necessary, whether it is safe, and what it +would cost. + +**The short answer:** the product outcome is achievable today with no contract +change at all. The contract change is a worthwhile optimisation afterwards, and +it is more feasible than the 1.3.0 decision suggests, but it is not the +prerequisite it appears to be. + +--- + +## 2. What we actually decided in 1.3.0, and why + +It is worth being precise here, because the reason we switched is not the reason +people remember. + +The switch landed in `56ecff9`, 1.3.0, PR #61, on 2026-07-20. The commit states +the motivation directly: + +> `select_winners` now records prizes; each winner claims in their own +> transaction via the new `claim_prize` entrypoint, **so the winner count per +> event is bounded by the winner_distribution instead of one transaction's +> resource budget.** + +So the driver was **transaction resource limits**, not security and not +authorisation. Under the old push model, every transfer for every winner had to +fit inside a single `select_winners` transaction. An event with a long prize +ladder would hit the ceiling and become unpayable. + +This matters because the reason widely repeated in the frontend and in product +conversation is different. The claim card in the platform explains that the +contract requires the winner's own authorisation so an organiser cannot collect +on their behalf. That is a true description of `claim_prize` as built, but it is +a **consequence** of the pull design, not the reason it was chosen. Nobody +decided that winners must authorise their own payouts as a safety property. We +should not defend a constraint we never intended. + +### The part that is easy to miss + +The same PR did two things at once: + +1. Made `select_winners` **batchable at 50 winners per call**, with each position + awardable exactly once and amounts anchored to the escrow baseline captured at + the first batch. +2. Moved the transfer out into `claim_prize`. + +**Batching alone solves the unbounded-winner problem.** Once selection is +batched, an event with 500 winners is 10 calls instead of 1, and no single +transaction has to carry them all. That is true whether or not the transfer +happens inside the batch. Adding the transfer back makes each winner cost more +resources, so the safe batch size falls, but it does not reintroduce the +unbounded case that motivated the change. + +If that reasoning holds, then the constraint that pushed us to pull was +substantially retired by the other half of the same PR. This should be confirmed +by measurement (section 8) rather than taken on argument, but it reframes the +question from "can we undo a necessary safety decision" to "what is the right +batch size." + +The commit also references "the constraints that sank PR #83" and answers them +with a no-persisted-struct-changes rule. I have not read #83 and it should be +reviewed before any design is finalised, since it is the record of an earlier +attempt that failed. + +--- + +## 3. What changed in the protocol since + +**CAP-73, Protocol 26 (Yardstick)** added a `trust` function to the Stellar Asset +Contract. It lets a contract create an asset's trustline for a G-address during a +contract invocation. The docs name our exact use case: + +> The `trust` function is useful any time a contract distributes an asset to +> accounts that may not hold it yet: for example, an airdrop or payout contract +> calling `trust` before `mint` or `transfer`. + +It is a no-op when the trustline already exists or when the address is a +contract, so it is safe to call unconditionally. + +**But read the next paragraph of the same page carefully:** + +> When a trustline is actually created, the SAC requires authorization from +> `addr`, preserving the opt-in nature of trustlines. + +So CAP-73 does **not** let us push to a stranger who has never opted in. If the +recipient has no trustline, creating one still needs their signature, which is +exactly what we were trying to avoid needing. For a payout transaction submitted +by an organiser, a winner without a trustline remains unpayable. + +This would be a serious problem, except for section 4. + +--- + +## 4. The decisive fact: every payout address is ours + +Boundless does not have a population of winners with arbitrary external wallets. + +- The `Wallet` model stores `encryptedPrivateKey` and `encryptedDataKey`. Every + wallet on the platform is custodial and platform-held under envelope + encryption. +- Wallet activation is **sponsored by the platform** and creates the configured + auto-trustline assets in the same transaction. The platform pays the 1 XLM + account reserve and 0.5 XLM per trustline, and records each sponsorship so the + reserve can be recycled later. +- The escrow submission path validates that the applicant's payout address + matches the caller's managed wallet, and rejects the call when it does not. + +Three consequences, and they carry the whole analysis: + +1. **The trustline risk is already mitigated at the source.** Winners have USDC + trustlines because we created them, sponsored, at activation. CAP-73 is a + useful belt-and-braces call but not a dependency for us. +2. **The base reserve requirement is already satisfied**, by us, for the same + reason. +3. **We hold every winner's key.** Which means the platform can already sign + `claim_prize` on a winner's behalf. The MANAGED signing path that does exactly + this for other operations is built and in production. + +Point 3 is the finding that changes the recommendation. + +--- + +## 5. Three options + +### Option A: auto-claim on the winner's behalf. No contract change. + +When results publish, the backend claims each prize for each winner using the +custodial key it already holds, through the existing MANAGED signing path. + +The winner does nothing and sees money in their wallet. The product outcome is +delivered in full. + +**Cost:** one transaction per winner, fee paid by the platform. Orchestration and +retries on the backend, where we already have the escrow op infrastructure, +idempotency keys and failure handling. + +**Risk:** low. No contract change means no upgrade, no timelock, no migration, no +audit delta, nothing that can strand escrow. A failure is a backend retry, not a +stuck event. + +**The one thing to settle:** it is the platform moving value using a user's key +without a per-instance prompt. The direction of travel is benign, since funds can +only move toward the user and only to their own wallet, but it is a custody +posture that should be written down and reflected in the terms rather than +assumed. `admin-custody-policy.md` is the right home for it. + +**Limitation:** it does not simplify the contract. The claim window, the +unclaimed-prize counter and the cancel sweep all stay. And if Boundless ever +supports genuinely external wallets, this option stops covering those users. + +### Option B: push inside `select_winners`, batched + +Transfers move back into selection, at a measured batch size. + +**What it buys:** the contract becomes simpler in principle. No claim window, no +unclaimed counter, no expiry sweep, no second entrypoint. Money moves when the +organiser locks winners, which is what the original design and the Design spec +always said should happen. + +**What it costs:** every risk in section 6. + +### Option C: push with a pull fallback + +Attempt the transfer inside selection. If it cannot complete for a given +recipient, record the award instead and let that winner claim later. + +Soroban supports this: the codebase already uses `try_` invocations for the +profile calls in `claim_prize`, precisely so a broken profile contract cannot +block a payout. The same pattern applies to a transfer that cannot land. + +This is the design that survives contact with reality. It gets the default path +right, keeps a bad recipient from blocking everyone else in the batch, and keeps +a working path for external wallets if we ever support them. It costs more WASM +than Option B because both paths must exist, which runs into the size ceiling. + +--- + +## 6. Risk register + +Risks are for Options B and C unless stated. Option A carries only the custody +posture item. + +| # | Risk | Severity | Notes and mitigation | +| --- | --- | --- | --- | +| R1 | **One bad recipient reverts the whole batch.** A transfer to an address that cannot receive panics, and a panic aborts the entire invocation. Under pull, a broken recipient hurts only themselves. Under push, they block every other winner in the batch and stop the organiser closing the event. | **High** | This is the single biggest argument against naive push. Mitigations, in order of strength: Option C's `try_` fallback; an off-chain preflight that checks every winner before building the batch; a payout-capability gate at submission time so the failure surfaces weeks earlier. | +| R2 | **In-flight events must not break.** Events already have recorded-but-unclaimed awards. `claim_prize` cannot simply be deleted. | **High** | Dual path until the last pre-upgrade claim window expires, which is up to 90 days from the last pre-upgrade selection. Plan the cutover as a window, not an event. | +| R3 | **Audit timing.** A third-party audit is an open P0 mainnet blocker, budgeted at $30-80k and unscheduled. The money path is the highest-value target in the contract. | **High** (schedule) | Either land this before the audit so it is covered, or accept paying for a re-review of the delta. Changing the payout path *after* an audit is the worst of the three orderings. | +| R4 | **WASM size ceiling.** 64 KB hard limit. The build measured 51,276 bytes at 1.3.0, leaving roughly 12.7 KB. We are now at 1.6.0, which removed per-event caps and added pagination, so the current headroom is unknown. Option C needs both paths resident. | **Medium-high** | Measure the 1.6.0 baseline before designing. If headroom is thin, Option C may not fit and the choice collapses to B or A. | +| R5 | **Error enum is at the 50-case XDR cap.** Any new error variant requires consolidating an existing one first. | **Medium** | Already tracked in the backlog. Budget the consolidation as part of the work, not as a surprise. | +| R6 | **Payment event timing moves.** `WinnerPaid` currently fires when money actually moves, at claim time. Under push it fires at selection. Four escrow subscribers and the indexer key off these events. | **Medium** | Audit every subscriber for timing assumptions. The upside is that the event becomes simpler to reason about, since selection and payment stop being separable. | +| R7 | **Fee incidence shifts.** Winners currently pay their own claim fee. Under push the organiser or platform pays for everyone, in one fat transaction that competes for contract resources under surge pricing. | **Medium** | Absolute cost is small, but it should be modelled rather than assumed, and it is a real cost transfer onto organisers. Note Option A shifts the same cost onto the platform. | +| R8 | **Reentrancy and call ordering.** Transfers inside a state-transition op mean external calls mid-transition. | **Medium** | Effects before interactions, the discipline `claim_prize` already follows and comments. The SAC is trusted so real exposure is low, but auditors will look here first. | +| R9 | **Partial batch failure and retry.** A batch that fails midway must not double-pay on retry. | **Medium** | The per-position award key is already the replay lock, and a revert unwinds the write and the transfer together, so on-chain retry is safe. The orchestrator still needs clean semantics for "batch 3 of 5 failed". | +| R10 | **Liveness machinery becomes vestigial.** The 90-day window, unclaimed counter, cancel gate and expiry sweep exist to guarantee escrow can never strand. With push there is nothing to strand. | **Low-medium** | Do not delete while R2 is live. Deleting later reclaims WASM space, which R4 may need. | +| R11 | **The claim window is a module constant.** Making it per-event requires a storage migration. | **Low** | Only relevant if we keep a fallback path. Already in the backlog for the next real migration window. | + +--- + +## 7. Hard constraints to design within + +- **WASM:** 64 KB ceiling. Last measured 51,276 bytes at 1.3.0. +- **Errors:** enum at the 50-case XDR cap. +- **Upgrade path:** `propose_upgrade`, roughly one day of timelock, `apply_upgrade`, + then `migrate()`, all under a 2-of-3 admin multisig. This is not a same-day + change, and it needs signer coordination. +- **Ledger limits:** roughly 200 disk-read entries and 200 write entries per + transaction on Protocol 27, with the caveat from the docs that these are + validator-settable network settings and must be verified against mainnet rather + than quoted from documentation. +- **No persisted-struct changes**, the rule adopted in 1.3.0 after PR #83. New + state goes under appended data keys, and there is a regression test that turns + CI red if `Winner` gains a field. + +--- + +## 8. What to measure before committing + +Nothing below is a design decision. All of it is arithmetic we have not done, and +the answers determine which option is even available. + +1. **Batch headroom with transfers included.** Simulate `select_winners` on + testnet with the transfer in the loop and find the real per-winner cost in + instructions, entry reads and writes, and IO bytes. This is already an open + backlog item for the selection-only case, where the 50-per-call cap is + explicitly described as conservative and never measured. Do both at once. The + number we care about: does push drop the safe batch from 50 to roughly 25, or + to 5? The first is a non-issue. The second changes the answer. +2. **Current WASM headroom at 1.6.0.** Determines whether Option C fits. +3. **Payout-address population.** Confirm from production data that every winner + address is a platform-held wallet with a live USDC trustline, and that there is + no legacy or edge-case path that produced anything else. The entire trustline + argument rests on this. +4. **Real mainnet ledger limits**, read from network settings rather than docs. +5. **Fee modelling.** Cost per winner under Option A (one transaction each) + against Option B or C (batched), including a surge-pricing allowance. + +--- + +## 9. Recommendation + +**Do Option A now. Decide on B or C after the measurements.** + +The reasoning is that Option A delivers the entire product outcome, this month, +with no contract risk. Winners stop claiming. The button disappears. Money +arrives on its own. Every user is covered, because we hold every key and we +created every trustline. + +Against that, Option B and C deliver no additional user-visible benefit. Their +value is engineering: a simpler contract, fewer moving parts, no vestigial +liveness machinery, and payment semantics that match what the Design spec always +described. Those are real and worth having, but they are worth having at a time +of our choosing, not on the critical path of a product change. + +Sequenced: + +**Phase 0, now.** Run the five measurements in section 8. Read PR #83. Cheap, and +it settles most of the argument. + +**Phase 1, next.** Ship auto-claim on behalf. Update the custody policy to +describe the platform signing payout claims for users, and reflect it in the +terms. Keep manual claim reachable as a fallback so a backend failure never +leaves a winner with no route to their money. + +**Phase 2, decide.** With numbers in hand, choose: + +- If batch headroom is comfortable and WASM allows, do **Option C**, hybrid push + with a pull fallback, and land it before the audit. This is the design that + matches the original intent and survives external wallets later. +- If WASM is tight, do **Option B** with a hard off-chain preflight, accepting + R1 as an operational control rather than a contract-level one. +- If both are tight, stay on Option A indefinitely. It works, and the cost is + carrying machinery we no longer strictly need. + +The one thing not to do is treat the contract change as a blocker for the product +change. It is not one. + +--- + +## 10. Open questions + +1. **Custody posture.** Is the platform signing a value-moving transaction on a + user's behalf, without a per-instance prompt, something we are comfortable + stating publicly in the terms? Option A depends on yes. +2. **External wallets.** Is supporting genuinely external payout addresses on the + roadmap? If yes, Option C becomes materially more attractive and Option A + becomes a stopgap. If no, Option A is close to a permanent answer. +3. **Audit sequencing.** Do we hold the audit until the payout path is final, or + audit now and pay for a delta review later? +4. **Organiser fee incidence.** Under push, organisers pay to distribute. Is that + acceptable, or does the platform absorb it? Note that under Option A the + platform absorbs it by default. +5. **Failure visibility.** When an automatic payout fails for one winner, who is + told, how fast, and what does the winner see in the meantime? This needs an + answer in all three options, and it is a design question as much as an + engineering one. + +--- + +## Sources + +Internal: + +- `56ecff9` and PR #61, the 1.3.0 pull-model commit and its stated rationale. +- `contracts/events/src/event_ops.rs`, `select_winners` and `claim_prize`, + including the pre-1.3.0 push-model compatibility branch. +- `BACKLOG.md`: unmeasured batch headroom, the 50-case error cap, the claim window + constant, and M2 on contract-layer trustline checks not being reliably possible. +- `docs/ARCHITECTURE.md` for the 64 KB WASM ceiling. +- `boundless-nestjs`: the `Wallet` model, `wallet-sponsor.service.ts` sponsored + activation with auto-trustlines, and the MANAGED signing path. + +External: + +- [Stellar Asset Contract: creating trustlines from a contract](https://developers.stellar.org/docs/tokens/stellar-asset-contract#creating-trustlines-from-a-contract) (CAP-73, Protocol 26) +- [Stellar Asset Contract: contract errors](https://developers.stellar.org/docs/tokens/stellar-asset-contract#contract-errors) (`TrustlineMissingError = 13`) +- [Storage strategies in production contracts](https://developers.stellar.org/docs/build/guides/storage/storage-strategies) (per-transaction entry caps, Protocol 27) +- [Fees, resource limits and metering](https://developers.stellar.org/docs/learn/fundamentals/fees-resource-limits-metering) (multidimensional resource model, surge pricing on contract transactions) +- [Analyzing smart contract cost and efficiency](https://developers.stellar.org/docs/build/guides/fees/analyzing-smart-contract-cost) (batch transfer patterns) diff --git a/docs/scout-audit-report.md b/docs/scout-audit-report.md index 5b2f330..00e1da0 100644 --- a/docs/scout-audit-report.md +++ b/docs/scout-audit-report.md @@ -11,8 +11,12 @@ > on-chain (`profile/src/credits.rs`). The 1.0.0 → 1.1.0 upgrade (2026-06) > removed on-chain credits — they are now an off-chain ledger in > boundless-nestjs — so `credits.rs` and its findings (e.g. C-1 / C-6) no -> longer have an on-chain counterpart. References below are preserved -> unedited as the record of the scan. +> longer have an on-chain counterpart. The 1.7.0 → 2.0.0 upgrade removed +> the on-chain participation records in the same way, so the findings that +> touch `apply()`, `remove_applicant()`, `applicant_at()`, +> `applicant_count()` and `applicants_snapshot()` no longer have one +> either. References below are preserved unedited as the record of the +> scan. --- @@ -266,3 +270,50 @@ For audit panel reference -- these Scout warnings require no code change: | C-7 | `events/storage.rs:303,305,327` | `checked_sub(1).ok_or()` in `remove_applicant()` | | M-1 | `events/event_ops.rs:693,707` | `.ok_or(Error::InvalidDistribution)?` replacing `.unwrap()` | | M-2 | `events/storage.rs:309` | `.ok_or(Error::EventNotFound)?` replacing `.expect()` | + +--- + +## Re-scan — September 2026 (events 1.7.0, profile 1.2.0) + +**Date:** 2026-09-11 +**Source:** `feat/escrow-open-pool` @ `0943d26` +**Tool:** `cargo-scout-audit` 0.3.16, detectors toolchain `nightly-2025-08-07` +**Command:** `cargo scout-audit --output-format md -- --target=wasm32v1-none --no-default-features`, run per crate + +**Tooling caveat.** Scout 0.3.16 cannot compile a soroban-sdk 27 project: its +pinned nightly predates the stabilisation of `str::floor_char_boundary`, which +`soroban-sdk-macros 27.0.0` uses (`E0658` in `doc.rs:25`), and its default +`--target=wasm32-unknown-unknown` is rejected outright by the SDK 27 build +script. The scan was therefore run on a scratch copy of the same source with +the workspace pinned to `soroban-sdk = "23.5.2"` (the version the June scans +used) and the `compatibility` crate excluded. The contract source compiles +unchanged against 23.5.2, so detector coverage of `contracts/events/src` and +`contracts/profile/src` is unaffected; only the `soroban_version` enhancement +flags are an artifact of the pin. + +| Crate | Critical | Medium | Minor | Enhancement | +|---|---|---|---|---| +| `boundless_events` | 2 | 19 | 0 | 30 | +| `boundless_profile` | 1 | 2 | 0 | 19 | + +### Real findings + +| ID | File | Finding | Assessment | +|---|---|---|---| +| S-1 | `events/src/event_ops.rs:745` | `let anchor_idx = existing_count + (idx as u32);` — bare `+` on `u32` in the Single-release branch of `select_winners`. | **Real, low.** Needs 2³² winner rows to overflow and `overflow-checks = true` would trap rather than wrap, but it breaks the checked-arithmetic rule the repo adopted for C-3. Change to `existing_count.saturating_add(idx as u32)` before audit fieldwork. | + +### False positives (same classes as June) + +| Detector | Locations | Reason | +|---|---|---| +| `unprotected_update_current_contract_wasm` (CRITICAL ×2) | `events/admin.rs:236`, `profile/admin.rs:216` | Inside `apply_upgrade()`, which opens with `require_admin()`; Scout does not trace access control through the enclosing function (June C-2). | +| `unsafe_map_get` (MEDIUM ×3) | `event_ops.rs:725,793`, `admin.rs:474` | `Map::get` returns `Option`; every site is `if let Some(floor) = …`. | +| `dos_unexpected_revert_with_storage` (MEDIUM ×4) | `admin.rs:383`, `storage.rs:421,700,793` | `floors.set` runs inside admin-gated `migrate_events`; the three `storage.rs` sites are `push_back` on an in-memory `Vec` inside paged read helpers (June M-9). | +| `dos_unbounded_operation` (MEDIUM ×7) | `admin.rs:425-440,464-488`, `event_ops.rs:675-681`, `grant.rs:63-81`, `storage.rs:419-423,698-702,791-795` | Read helpers are bounded by `limit.min(VIEW_PAGE_LIMIT)`. Migration loops walk one event's winner rows per call, admin-only, one-time. `select_winners` (Multi) and `claim_milestone` scan an event's winner rows, bounded by prior selections × milestones and gated by manager/owner auth. | +| `front_running` (MEDIUM ×2) | `escrow.rs:34,72` | Amounts are validated at every call site (`InvalidBudget`, `BelowMinimumContribution`, floor checks); Soroban has no public mempool ordering game equivalent to the detector's model (June M-5). | +| `dynamic_storage` (MEDIUM ×4) | `events/storage.rs:132,162`, `profile/storage.rs:113,135` | Bounded semver strings on admin-only paths (June M-6). | +| `avoid_vec_map_input` (MEDIUM ×1) | `events/lib.rs:225` (`select_winners`) | Every element validated: dedupe, `amount > 0`, floor, ≤ 50 (June M-8). | +| `storage_change_events` (ENHANCEMENT ×47) | `lib.rs` dispatchers, both crates | Dispatcher wrappers delegate to modules that emit events; `ManagerChanged` shipped with two-step delegation (#88), closing the one genuine gap noted in July. | +| `soroban_version` (ENHANCEMENT ×2) | `Cargo.toml` | Artifact of the 23.5.2 pin used to run the tool; the workspace is on 27.0.0. | + +Net: one real low-severity finding (S-1), queued; zero real CRITICAL or MEDIUM. diff --git a/docs/threat-model.md b/docs/threat-model.md index addd583..bf2cb72 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -1,332 +1,515 @@ -# Boundless Platform — STRIDE Threat Model +# Boundless — STRIDE Threat Model -**Version:** 1.3 -**Date:** July 2026 (rev. after the events 1.2.0 cancellation-liveness upgrade; prior rev. after the 1.1.0 credit-removal upgrade) -**Scope:** `boundless-events` + `boundless-profile` Soroban contracts and the off-chain orchestrator (`boundless-nestjs`) -**Prepared by:** Boundless Engineering -**Status:** Submitted for SDF Soroban Security Audit Program +**Version:** 1.0 +**Date:** September 2026 +**Prepared for:** Stellar Development Foundation — Soroban Security Audit Bank +**Prepared by:** Boundless Engineering +**Contracts:** `boundless-events` 1.7.0 (2.0.0 in review), `boundless-profile` 1.2.0 +**Repository:** github.com/boundlessfi/boundless-contract --- -## Section 1 — What Are We Working On? +## Summary -### 1.1 System Description +This document is the STRIDE threat model for the two Soroban contracts that anchor the Boundless platform: `boundless-events`, which holds prize escrow and drives the lifecycle of hackathons, bounties, grants, and crowdfunding campaigns, and `boundless-profile`, which records builder reputation and earnings. It follows the four-question structure recommended by the Stellar Development Foundation: what we are working on, what can go wrong, what we are doing about it, and whether we did a good job. -Boundless is an on-chain coordination platform for hackathons, bounties, grants, and crowdfunding campaigns on the Stellar network. Its core value proposition is trust-minimised prize settlement: escrow locks at event creation, and releases atomically when winners are selected — sponsor default is structurally impossible. +The model covers 72 threats across the six STRIDE categories, each tied to a step in the on-chain flow and to a trust boundary in the data-flow diagram, and each rated for likelihood and impact. Ten are Priority 1 — the controls whose failure would be catastrophic — and all ten are mitigated and tested; every open item is Priority 2 or lower. Forty-nine are mitigated by controls in the contracts or in operating procedure; every contract-level control is exercised by a test that asserts its failure mode. Thirteen are accepted residual risks with a written rationale. Eight are open: three are design questions on which auditor input is requested (recovery when a delegated manager's key is lost, cancellation of a grant that already carries awards, and the treatment of a refund recipient that can no longer receive the token), and five are hardening changes scheduled before audit fieldwork, the most important of which is restoring the upgrade timelock from its current value of zero. -The system consists of two Soroban smart contracts and an off-chain orchestrator: +Three further threats are **void in 2.0.0**, which removes the on-chain participation records (`apply_to_bounty`, `withdraw_application`, `submit`, `withdraw_submission` and the applicant and submission read accessors). Nothing on chain read them: winner selection and both claim paths check the award record and the caller's signature, never the applicant index or a submission anchor, and both entry points were permissionless, so the record they produced was not evidence of anything. Applying and submitting are product facts and now live in the backend's database. The rows below carry the status **Void (2.0.0)** where the surface they describe no longer exists. + +## 1. What are we working on? + +### 1.1 Overview + +Boundless is an on-chain coordination platform for hackathons, bounties, grants, and crowdfunding campaigns on Stellar. Its design goal is trust-minimized prize settlement: the budget for a program is locked in a Soroban contract before any builder does work, and it can leave only to winners, to milestone recipients, or back to the accounts that funded it. Neither the program owner nor the platform admin can redirect escrow. + +An event is an **open pool with committed prize floors**. The owner deposits a budget and publishes a per-position minimum (`prize_floors`). Winner selection names the actual award per position, which may exceed a floor but never fall below it. Every award is reserved against the pool so that a later selection cannot promise funds an earlier winner is still owed. + +**Roles and terms used in this document.** + +- **Owner** — the account that creates and funds an event (the organizer). Custody of the pool always belongs to the owner and to contributors. +- **Manager** — an optional delegate who, once accepted, holds selection and cancellation authority for one event. Where no manager is delegated, the owner is the manager. +- **Contributor** — any account that tops up an event's pool. +- **Builder** — an account that applies to or submits work for an event; a **winner** is a builder named in a selection. +- **Admin** — the platform's 2-of-3 multi-signature account. The only privileged role in either contract. +- **Event types** — hackathon, bounty, grant, and crowdfunding. Hackathons and bounties are **single-release**: each winner pulls their prize with `claim_prize` inside a 90-day window. Grants and crowdfunding campaigns are **multi-release**: funds leave per milestone through `claim_milestone`. +- **Permissionless crank** — an entry point that requires no signature, so that anyone can advance a process (used for cancellation refunds). +- **Managed and external wallets** — a user's Stellar wallet is either *managed* (a keypair the platform generates, sponsors, holds under envelope encryption, and signs with on the user's behalf) or *external* (a wallet such as Freighter whose key never leaves the user's device). Most builders and organizers use managed wallets; organizers may connect an external wallet. + +### 1.2 Scope and assets + +**In scope.** The `boundless-events` and `boundless-profile` contracts at the versions above, the cross-contract interface between them, the administrative operations that govern them, and the custody procedures for the admin account. The backend orchestrator, web application, and admin portal are covered only at the points where they touch the contracts or hold material that could affect them. + +**Out of scope.** Backend and web-application internals, the Didit service, the Stellar network and Soroban host (assumed correct; see 1.7), and the issuer of the supported token. + +| Asset | Where it lives | Why it matters | +|---|---|---| +| Escrow funds | Token balance held by `boundless-events` (one balance for all events; 1,030 USDC across three active bounties at the time of writing) | Critical. The purpose of the system; any path that moves or locks it is the highest-value target. | +| Per-event accounting | `remaining_escrow`, `EventOwedTotal`, winner records, prize awards, contributor amounts | Critical. Drives every release and refund; an inconsistency here becomes a fund movement. | +| Admin signer keys | Three signers on separate machines; 2-of-3 threshold | Critical. The quorum can pause, change fees, re-point bindings, and replace contract code. | +| Fee account key | Same custody as the admin | High. Receives all platform revenue. | +| Prize floors and event terms | `EventRecord` | High. The published guarantee builders rely on. | +| Managed wallet private keys | PostgreSQL, envelope-encrypted: a per-wallet data key wrapped by a master encryption key held only in the backend environment; key material is excluded from ORM reads except on the signing path | Critical. Together with the master key they control every managed owner, manager, builder, and winner wallet. | +| External wallet keys | User devices | High per event. Key loss or compromise affects that party's funds or authority. | +| Reputation and earnings | `boundless-profile` | Medium. Informational; carries no on-chain privilege, but the platform's trust signal. | +| Backend secrets | Server environment | Critical for the wallet master encryption key (see above); High for the rest. No server-held secret can act as the admin. | +| Identity data | Didit only | High; never enters Boundless systems. | +| Availability of off-chain services | Backend, RPC provider, Didit | Medium. Outages delay operations but cannot move funds; every contract entry point remains callable directly. | + +### 1.3 Components | Component | Role | |---|---| -| `boundless-events` | On-chain anchor for all event types. Manages escrow custody (deposit, lock, release, refund), event lifecycle, participant registration, submission anchoring, and winner selection for all four pillars (hackathon, bounty, grant, crowdfunding). | -| `boundless-profile` | Per-user reputation scores and per-token earnings. Called cross-contract by `boundless-events` to bootstrap profiles on application, award reputation on win, and register earnings on payout. (Credit balances were on-chain here through contract 1.0.0; the 1.1.0 upgrade moved them to an off-chain ledger in `boundless-nestjs`.) | -| `boundless-nestjs` | Off-chain orchestrator (NestJS). Holds rich content, user accounts, KYC state, and draft lifecycle. Builds and submits Stellar transactions via RPC. Houses the admin KYC workflow and the server-side admin co-signer. | -| Frontend (`boundless`) | Next.js web app. Runs in the user's browser. Calls the backend API. No direct RPC calls; all chain interaction is mediated by the backend. | -| Admin Portal | Staff-only Next.js app. Calls the backend admin API surface. Enforces step-up TOTP for sensitive mutations. | -| Didit KYC | External identity verification service. Receives user documents; sends HMAC-signed webhooks back to the backend. No PII stored on Boundless infrastructure. | -| Stellar RPC (Nodies) | Managed Soroban RPC endpoint. Backend submits signed transactions; event indexer polls for on-chain events. | -| Admin Multi-sig Account | 2-of-3 Stellar multi-sig G-address (`GCVK72I6TVJVDTTY4UKU6MQT4QJ2T2AAG3NULNEUDM46L3UOQYDSO4O2`). Controls all privileged contract operations (pause, fee changes, upgrade). No single team member can act unilaterally. | -| Organization Treasury Wallets | Stellar classic G-addresses with USDC trustlines. Held by organizer accounts; used to fund event escrow at publish time. | +| `boundless-events` | On-chain anchor for all four event types. Escrow custody (deposit, reserve, release, refund), event lifecycle, applicant, contributor, and submission registries, winner awards, milestone claims, paged cancellation, per-event manager delegation, timelocked upgrades with paged data migration. Mainnet: `CCFVEGOQJEM47LRAJU2LHEK4KTL5VYN7AOGZ2HH2GNHAMXTILNMMJGQZ`. | +| `boundless-profile` | Per-user reputation score and per-token lifetime earnings. Mutated only by the events contract through cross-contract calls; users may bootstrap their own record. Mainnet: `CD3KH4OE7HDHHHUYFX3U4L7NLIILMXAY6HM5FEH2UH6UBOKX4HDNE3PC`. | +| Backend orchestrator | NestJS service. Holds rich content, user accounts, KYC state, draft lifecycle, and the off-chain credit ledger. Builds Stellar transactions, submits them through RPC, indexes contract events, and assembles crowdfunding milestone-release transactions for admin co-authorization. Runs the custodial wallet service: generates keypairs for managed wallets, sponsors their reserves, holds the private keys under envelope encryption, and signs for them. It does not hold the admin quorum. | +| Web application | Next.js front end in the user's browser. Calls the backend API; wallets sign transactions locally. | +| Admin portal | Staff-only application against the backend admin API, with step-up TOTP for sensitive mutations. | +| Didit KYC | External identity verification. Receives documents directly from the user and returns HMAC-signed webhooks. No identity data is stored on Boundless infrastructure. | +| Stellar RPC | Managed Soroban RPC endpoint used for submission and event polling. | +| Admin | 2-of-3 Stellar multi-signature account `GCVK72I6TVJVDTTY4UKU6MQT4QJ2T2AAG3NULNEUDM46L3UOQYDSO4O2` (three signers of weight 1, medium and high thresholds of 2, master key weight 0). Sole holder of every privileged contract operation: pause, fee configuration, supported-token list, upgrades, migration, admin rotation, and the co-authorization of crowdfunding milestone releases. | +| Fee account | `GADSIP2HPINWTMEUNMVYA5NJRL372OV52HDEJTRTLDZXMHADIPQNYH55`. Receives platform fees; same custody as the admin. | +| Owner, contributor, and builder wallets | Stellar accounts holding a trustline to a supported token (USDC on mainnet); managed wallets are created and sponsored by the platform, external wallets are user-held. | ---- +### 1.4 On-chain flow -### 1.2 Dataflow Diagram - -``` - ═══════════════════ TRUST BOUNDARY: Internet ═══════════════════════════ - │ │ - │ [User Browser] [Sponsor Browser] [Admin Browser] │ - │ │ REST/JWT │ REST/JWT │ REST/JWT │ - ═════════════════════════════════════════════════════════════════════════ - │ │ │ - ▼ ▼ ▼ - ┌─────────────────────────────────────────────────────────────┐ - │ BACKEND API (boundless-nestjs) │ - │ ┌──────────────┐ ┌──────────────┐ ┌────────────────┐ │ - │ │ Auth / KYC │ │ Event/Escrow │ │ Admin v2 │ │ - │ │ Guard layer │ │ Orchestrator│ │ (PolicyGuard +│ │ - │ │ (JWT, TOTP, │ │ (build tx, │ │ StepUpGuard) │ │ - │ │ KycGuard) │ │ admin cosign│ │ │ │ - │ └──────┬───────┘ └──────┬───────┘ └───────┬────────┘ │ - │ │ │ │ │ - │ ┌──────▼─────────────────▼───────────────────▼──────────┐ │ - │ │ PostgreSQL DB (PII-free) │ │ - │ │ User accounts, event drafts, escrow op log, │ │ - │ │ KYC status (session_id + status only — no docs) │ │ - │ └────────────────────────────────────────────────────────┘ │ - └──────────────────────────┬──────────────────────────────────┘ - │ - ═══════════════════╪═══════════════════════════════════ - │ TRUST BOUNDARY: Off-chain / On-chain │ - ═══════════════════╪═══════════════════════════════════ - │ signed XDR - ▼ - ┌─────────────────────────┐ - │ Stellar RPC (Nodies) │ - └────────────┬────────────┘ - │ Soroban invocation - ┌────────────────┴────────────────────┐ - ▼ ▼ - ┌──────────────────┐ ┌────────────────────┐ - │ boundless-events │──cross-contract│ boundless-profile │ - │ (escrow, event │───────────────▶│ (reputation + │ - │ lifecycle for │ │ earnings) │ - │ all 4 pillars) │ └────────────────────┘ - └──────────────────┘ - │ - │ USDC token transfer (SAC) - ▼ - ┌──────────────────────────────────┐ - │ Stellar Ledger │ - │ Winner wallets / fee account / │ - │ org treasury wallet │ - └──────────────────────────────────┘ - - ═══════════════════ EXTERNAL SERVICE BOUNDARY ═══════════════ - │ │ - │ [Didit KYC] │ - │ │ HMAC-signed webhook (POST /api/didit/webhook) │ - │ └──────────────────────────────────────────────────────┤ - │ User browser ──HTTPS──▶ Didit hosted flow │ - │ (documents never touch Boundless servers) │ - │ │ - ═════════════════════════════════════════════════════════════ -``` - -**Trust Boundaries:** +The threat tables in Section 2 reference these steps. + +1. **Deploy and configure.** The admin deploys `boundless-profile`, then `boundless-events` (admin, fee account, fee rate, profile address); binds the two with `set_events_contract` (the first binding is single-step, later rotation is a timelocked two-step); and registers supported tokens. +2. **Create.** The owner calls `create_event`. For hackathons, bounties, and grants the full budget plus the platform fee is pulled from the owner in the same transaction; the fee is forwarded to the fee account and the budget is held by the contract. Crowdfunding events start at zero and are funded by contributors. Prize floors must be positive and sum to at most the budget. A manager may be proposed. +3. **Delegate (optional).** The proposed manager calls `accept_manager` within 17,280 ledgers (about one day). From then on the manager, not the owner, holds selection and cancellation authority. Custody of the pool is unchanged. +4. **Top up.** Contributors call `add_funds` (minimum 10 tokens). Non-crowdfunding top-ups pay the fee at deposit; crowdfunding deposits are fee-free and the fee is taken at release. A per-event running total of non-owner contributions is maintained for refunds. +5. **Apply (bounties).** *Removed in 2.0.0.* Builders called `apply_to_bounty`, which bootstrapped their profile. Step numbering is preserved so the threat tables below still resolve. +6. **Submit.** *Removed in 2.0.0.* Builders called `submit(event_id, applicant, slot, content_uri)` to anchor an off-chain submission. No on-chain path ever read either record, and participation is now held off chain. +7. **Select.** The manager calls `select_winners` with up to 50 `WinnerSpec{recipient, position, amount, reputation_bump}` entries per call. Single-release events may select in as many calls as needed; multi-release events accept exactly one selection. Each position is awardable once; each amount must meet the position's floor; the batch total plus everything already awarded but unclaimed (`EventOwedTotal`) must fit in `remaining_escrow`. Single-release events open, or extend, a 90-day claim window. +8. **Claim a prize.** Each winner calls `claim_prize(event_id, position)`. The contract records the payment, then transfers the token, then bootstraps the profile, bumps reputation, and registers earnings on a best-effort basis. +9. **Claim a milestone.** For grants the owner calls `claim_milestone(event_id, recipient, milestone, reputation_bump)`; the recipient must hold a winner record, each (recipient, milestone) pair is claimable once, and the final milestone sweeps the recipient's remainder. Crowdfunding additionally requires the admin's co-authorization, pays the owner `remaining_escrow / milestones_left`, and takes the fee at release. +10. **Cancel.** The manager calls `start_cancel`. It is refused while single-release prizes remain inside their claim window. Events with no outside contributors settle inline; otherwise the event enters `Cancelling`, and anyone may crank `process_cancel_batch` (up to 25 refunds per call) and `finalize_cancel`, which returns the owner's residual. +11. **Govern.** The admin may pause and unpause, change the fee (capped at 10%) and fee account, manage the supported-token list, rotate the admin in two steps, and upgrade: `propose_upgrade`, `apply_upgrade`, `migrate_events` (paged, up to 8 events per call), and `migrate` (one-shot version stamp). + +### 1.5 Data-flow diagrams + +![Figure 1 — System data-flow diagram with trust boundaries](figures/threat-model-figure-1-system-dfd.svg) + +*Figure 1 — System data-flow diagram. Element types are identified in the legend; dashed amber regions are trust boundaries.* + +![Figure 2 — Escrow lifecycle and authorization](figures/threat-model-figure-2-escrow-lifecycle.svg) + +*Figure 2 — Escrow lifecycle inside `boundless-events`. Each step shows which key must sign it and how the pool balance moves; the lower band shows how the pool is accounted for and where funds leave to.* + +### 1.6 Trust boundaries | ID | Boundary | Description | |---|---|---| -| TB1 | Internet / Backend | All user-originated input is untrusted. Enforced by JWT auth, KYC guard, input validation (class-validator + Zod). | -| TB2 | Backend / Stellar network | The network is decentralised; the backend cannot control what other actors submit. Backend verifies all on-chain state before acting. | -| TB3 | Backend / Didit | External service. Webhook authenticity enforced via HMAC. No PII crosses TB3 into Boundless systems. | -| TB4 | Admin browser / Admin Portal / Backend | Staff-only surface. Higher trust but still fully authenticated (OIDC) with step-up TOTP for Tier 2 actions. | -| TB5 | Off-chain / On-chain | The contract enforces its own auth rules independently of the backend. Compromise of the backend does not grant control over on-chain escrow. | +| TB1 | Internet / Backend | All user-originated input is untrusted. Enforced by JWT authentication, the KYC guard, and schema validation. Nothing the backend accepts is trusted by the contracts (see TB5). | +| TB2 | Backend / Stellar network | The network is permissionless; anyone can call the contracts directly and bypass the backend entirely. The backend reads chain state before acting and never assumes it is the only writer. | +| TB3 | Backend / Didit | External service. Webhook authenticity is enforced with HMAC. No identity data crosses into Boundless systems. | +| TB4 | Admin browser / Admin portal / Backend | Staff-only surface with OIDC sign-in and step-up TOTP for sensitive actions. | +| TB5 | Off-chain / On-chain | The contracts enforce every rule themselves: each state change needs the Soroban authorization of the owner, manager, winner, contributor, or admin quorum. The backend can never exceed the authority of the wallets it can sign for. For external wallets that is nothing; for managed wallets it is that wallet's own authority, so a backend compromise that also yields the master encryption key can act as any managed owner, manager, builder, or winner (Spoof.10), but never as the admin quorum, which is held by three external signers. | +| TB6 | events ↔ profile | Cross-contract calls. The profile contract accepts mutations only from the configured events contract address; the events contract treats profile failures on the prize-claim path as non-fatal. | +| TB7 | Owner ↔ Manager | Within one event. The manager holds operational authority (select, cancel, rotate) but never custody: refunds route to the owner and contributors by construction, and `claim_milestone` remains owner-authorized. | + +### 1.7 Threat actors and assumptions -**Data Stores:** +| Actor | Capability | Motivation | +|---|---|---| +| Anonymous network participant | Can call any public entry point directly, spam registries, replay captured transactions; cannot forge signatures. | Theft, disruption, griefing. | +| Builder or contributor | Legitimate participant who may act in bad faith: rewrite a submission, remove a trustline, spam applications. | Advantage in a program; griefing an owner. | +| Owner or manager | Per-event privilege: creates, funds, selects, cancels. May try to reclaim or redirect a pool or favour themselves. | Financial gain at builders' expense. | +| Compromised backend | Can build and submit arbitrary transactions, corrupt off-chain state, and leak secrets. With the wallet master encryption key it can sign as any managed wallet; it can never sign as the admin. | Theft from events whose owner, manager, or winners are managed wallets; bounded by TB5. | +| Compromised single admin signer | One of three signers; cannot meet the threshold alone. | Stepping stone to the quorum. | +| Compromised or coerced admin quorum | Full administrative control: pause, fees, bindings, upgrade (instant while the timelock is zero). | Theft by replacing contract logic. | +| Token issuer | Can freeze or claw back balances under the asset's policy. | Compliance action, not malice; treated as an availability event. | +| Infrastructure providers (RPC, Didit) | Outages, stale or wrong data, forged inbound messages. | Failure or compromise of a third party. | + +**Assumptions.** The model relies on the following; a threat that requires one of them to fail is out of scope unless listed. + +- The Soroban host behaves as specified: `require_auth` verifies a matching authorization entry, re-entrant contract invocation is refused, execution is deterministic, and resource limits are enforced. +- Stellar consensus provides finality; there are no reorganizations. +- Ed25519 signatures cannot be forged, and users control their own keys. Key loss is the user's risk except where the design makes it everyone's (DoS.9). +- Supported tokens are standard Stellar asset contracts with conventional transfer semantics; the supported-token list is curated by the admin. +- The admin signers' machines are not compromised simultaneously (custody policy). +- Users who choose a managed wallet accept platform custody of that wallet's key; the platform's obligation is the key-handling controls in Spoof.10. +- Judging is an off-chain process. The contracts guarantee custody and the published floors, not the fairness of a selection. + +### 1.8 Data stores | Store | Location | Contents | Sensitivity | |---|---|---|---| -| PostgreSQL | Boundless servers | User accounts, event drafts, escrow op log, KYC status (no PII), audit log | Medium — no documents or keys | -| Soroban contract storage | Stellar ledger | Event records, escrow balances, winner assignments, reputation scores, per-token earnings | Public by design — pseudonymous addresses only | -| Didit systems | Didit infrastructure | Identity documents, biometrics, verification decisions | High — held exclusively by Didit | -| Admin multi-sig keys | Distributed (3 signers, separate machines) | Freighter wallet keys (software baseline) | Critical | -| Backend environment | Server environment vars | JWT secret, encryption key, Didit API key, orchestrator signing key | High | +| Events contract — instance storage | Stellar ledger | Admin, pending admin, fee account, fee rate, paused flag, deployment sequence, profile address, version, pending upgrade, migration cursor, supported-token index | Public. Admin-only writes. TTL is extended on every admin write and on event reads. | +| Events contract — persistent storage | Stellar ledger | Event records (including prize floors), manager and pending manager, applicants, contributors, and winners as per-element keys, submissions keyed by (event, address, slot), prize awards keyed by (event, position), owed total, unclaimed-prize count, claim-window expiry, contributor amounts, non-owner contribution total, milestone-claimed flags, cancellation state | Public by design; pseudonymous addresses only. Each event read extends the record's TTL. | +| Events contract — temporary storage | Stellar ledger | Idempotency markers keyed by (authorizing address, op_id) | Public. Lives for at least the network's minimum temporary TTL (17,280 ledgers, about one day on mainnet). | +| Profile contract | Stellar ledger | `Profile{bootstrapped_at, reputation}`, per-token earnings, admin and binding configuration, idempotency markers keyed by (domain, op_id) where the domain is the events contract or the user | Public; pseudonymous. | +| Contract token balance | Stellar ledger (Stellar Asset Contract) | All escrow for all events in one balance; per-event accounting lives in `remaining_escrow` and `EventOwedTotal` | Critical. Released only by contract logic. | +| PostgreSQL | Boundless servers | Accounts, drafts, escrow operation log, credit ledger, KYC status (session identifier and status only), audit log, managed-wallet private keys (envelope-encrypted) | Critical for the encrypted key material; medium otherwise. No identity documents. | +| Didit | Didit infrastructure | Identity documents, biometrics, decisions | High; held exclusively by Didit. | +| Admin signer keys | Three signers on separate machines | Wallet keys (hardware-key upgrade scheduled by the custody policy) | Critical. | +| Backend environment | Server environment variables | Wallet master encryption key, JWT secret, Didit key, transaction-building configuration | Critical (master key). | + +### 1.9 Key parameters + +| Parameter | Value | +|---|---| +| Maximum fee | 1,000 bps (10%) | +| Upgrade timelock | 0 ledgers at present (EoP.10); target 17,280 (about one day) | +| Upgrade proposal expiry | 518,400 ledgers (about 30 days) | +| Admin rotation expiry | 120,960 ledgers (about 7 days) | +| Manager proposal expiry | 17,280 ledgers (about one day) | +| Profile events-binding rotation | 17,280-ledger timelock, 120,960-ledger expiry | +| Prize claim window | 90 days from the most recent selection | +| Winners per `select_winners` call | 50 | +| Refunds per `process_cancel_batch` call | 25 | +| Read page size | 100 entries | +| Minimum contribution | 10 tokens (100,000,000 stroops at 7 decimals) | +| Title length | 120 bytes | +| Submission content URI length | 256 bytes (the event-level content URI set at creation is not capped) | +| Events per `migrate_events` call | 8 | +| Applicant, contributor, submission caps | None; each entry is its own ledger entry paid for by the writer | --- -## Section 2 — What Can Go Wrong? +## 2. What can go wrong? -### STRIDE Reminders +### 2.1 STRIDE reminders -| Category | Definition | Key question | -|---|---|---| -| **Spoofing** | Impersonation of another user or system | Could someone perform an action as someone else? | -| **Tampering** | Unauthorized modification of data or code | Could data be altered unexpectedly? | -| **Repudiation** | Denying an action was taken | Can a party deny responsibility for an on-chain or off-chain action? | -| **Information Disclosure** | Unintended exposure of sensitive data | Is private data exposed where it should not be? | -| **Denial of Service** | Making the system unavailable | Can the system be overwhelmed or blocked? | -| **Elevation of Privilege** | Gaining access beyond what is authorized | Can a user obtain permissions they were not granted? | +| Threat | Definition | Question | +| --- | --- | --- | +| **S**poofing | The ability to impersonate another user or system component to gain unauthorized access. | Is the user who they say they are? | +| **T**ampering | Unauthorized alteration of data or code. | Has the data or code been modified in some way? | +| **R**epudiation | The ability for a system or user to deny having taken a certain action. | Is there enough data to prove the user took the action if they were to deny it? | +| **I**nformation Disclosure | The over-sharing of data expected to be kept private. | Is there anywhere where excessive data is being shared, or controls are not properly in place to protect private information? | +| **D**enial of Service | The ability for an attacker to negatively affect the availability of a system. | Can someone, without authorization, impact the availability of the service or business? | +| **E**levation of Privilege | The ability for an attacker to gain additional privileges and roles beyond what they were initially granted. | Are there ways for a user, without proper authentication and authorization, to gain access to additional privileges? | ---- +### 2.2 Threat table -### Threat Table +Step numbers refer to the on-chain flow in Section 1.4; TB numbers to Section 1.6. Each threat is rated for **likelihood** (L: how easily the threat can be attempted, given who must act and what access they need) and **impact** (I: what is lost if it succeeds), assessed for the design *before* the treatment in Section 3 is applied; the Status column in Section 3 records the residual position. Scales: L = Low / Medium / High; I = Low (cosmetic or easily reversed), Medium (integrity of non-financial data, availability of one flow, or privacy of off-chain data), High (loss or lock of funds for one event or one party), Critical (loss or lock of escrow across events, or control of a contract). Priority combines the two: **P1** critical impact with medium or high likelihood, or high impact with high likelihood; **P2** critical impact with low likelihood, high with medium, or medium with high; **P3** high with low, medium with medium, or low with high; **P4** everything lower. #### Spoofing -| ID | Threat | Affected Component | -|---|---|---| -| Spoof.1 | A caller passes another user's address in `create_event`, `apply`, or `add_funds` to act on their behalf. | `boundless-events` contract | -| Spoof.2 | The backend orchestrator co-signing key is leaked; an attacker uses it to forge admin co-authorization entries on crowdfunding `claim_milestone` calls. | Backend orchestrator, `boundless-events` | -| Spoof.3 | A malicious third party sends a forged Didit webhook to the backend to fraudulently approve a user's KYC status. | Backend `/api/didit/webhook` endpoint | -| Spoof.4 | An attacker reuses a stolen staff JWT to access the admin portal without valid step-up credentials. | Admin portal, backend admin API | -| Spoof.5 | A malicious actor attempts to claim they are the admin multi-sig by submitting a transaction signed by a single key, bypassing the 2-of-3 threshold. | Admin multi-sig account, contract admin functions | +| ID | Threat | Affected component | L | I | Priority | +|---|---|---|---|---|---| +| Spoof.1 | (Steps 2, 4, 5, 6) A caller passes another user's address as `owner`, `from`, or `applicant` in `create_event`, `add_funds`, `apply_to_bounty`, or `submit` to act, spend, or anchor content on their behalf. | `boundless-events` | H | C | P1 | +| Spoof.2 | (Step 9) Key material that satisfies the crowdfunding co-authorization is obtained by an attacker, for example through a backend compromise, and used to release milestones early. | `claim_milestone`, admin custody | L | H | P3 | +| Spoof.3 | A forged Didit webhook approves a user's KYC. | Backend webhook endpoint | M | M | P3 | +| Spoof.4 | A stolen staff session token is replayed against the admin portal without step-up credentials. | Admin portal, backend admin API | M | M | P3 | +| Spoof.5 | (Step 11) A single signer submits an admin transaction claiming to be the multi-signature account. | Admin account, contract admin functions | L | C | P2 | +| Spoof.6 | (Step 8) An attacker calls `claim_prize` for a position awarded to someone else, or a manager "claims on behalf of" a winner into a different wallet. | `claim_prize` | H | H | P1 | +| Spoof.7 | (Step 3) Management of an event is assigned to an address that never consented, making it appear that a known organization endorses or operates the event. | Manager delegation | M | L | P4 | +| Spoof.8 | (Steps 5, 8, 9) An arbitrary account or contract calls `bootstrap`, `bump_reputation`, `slash_reputation`, or `register_earnings` on the profile contract pretending to be the events contract. | `boundless-profile` (TB6) | H | M | P2 | +| Spoof.9 | (Step 11) An admin signer's software-wallet key is exfiltrated from that signer's machine and combined with a second compromised or coerced signer to meet the 2-of-3 threshold. | Admin custody | L | C | P2 | +| Spoof.10 | (Steps 2–10) **Managed-wallet custody.** An attacker who compromises the backend and obtains the wallet master encryption key can decrypt every managed wallet's private key and act as any managed owner, manager, builder, or winner: select attacker-controlled winners, cancel an event and withdraw the refund from the managed owner wallet, or claim prizes. | Custodial wallet service (TB5) | L | C | P2 | #### Tampering -| ID | Threat | Affected Component | -|---|---|---| -| Tamp.1 | An organizer attempts to modify the prize amount or winner allocation after `create_event`, to redirect funds to themselves. | `boundless-events` contract | -| Tamp.2 | An attacker submits a `select_winners` call with a crafted `amounts` vector that sums to more than `remaining_escrow`, draining the contract. | `boundless-events` contract, `escrow.rs` | -| Tamp.3 | A builder attempts to overwrite another builder's submission `content_uri` to substitute their evidence. | `boundless-events` contract | -| Tamp.4 | A malicious or compromised Stellar RPC node returns forged event data to the backend indexer, causing the platform to display incorrect state. | Backend event indexer, Nodies RPC | -| Tamp.5 | The WASM hash in a `propose_upgrade` call is quietly replaced between proposal and application to install malicious contract logic. | Contract upgrade flow (`admin.rs`) | -| Tamp.6 | An attacker intercepts and modifies a partially-signed multi-sig transaction in transit to change the operation parameters (e.g., new admin address). | Admin multi-sig signing workflow | -| Tamp.7 | *(retired in 1.1.0)* An integer underflow in `spend_credits` allows a builder to spend more credits than their balance, wrapping the counter and corrupting credits accounting across the platform. | `boundless-profile` contract 1.0.0, `credits.rs` | -| Tamp.8 | An integer underflow in `remove_applicant` corrupts the applicant index (slot or count wraps to u32::MAX), causing subsequent lookups to find phantom applicants or block legitimate removals. | `boundless-events` contract, `storage.rs` | +| ID | Threat | Affected component | L | I | Priority | +|---|---|---|---|---|---| +| Tamp.1 | (Steps 2, 7) An owner lowers the advertised prizes after builders have committed work. | `boundless-events` | M | H | P2 | +| Tamp.2 | (Step 7) A `select_winners` call whose amounts exceed what the pool holds, drawing on escrow that belongs to other events (all events share one token balance). | Escrow accounting | M | C | P1 | +| Tamp.3 | (Step 6) A builder overwrites another builder's submission, or a third party withdraws it. **Void in 2.0.0:** there are no on-chain submissions. | Submissions | H | M | P2 | +| Tamp.4 | A compromised RPC node returns forged event data to the indexer. | Backend indexer | L | M | P4 | +| Tamp.5 | (Step 11) The WASM hash is swapped between `propose_upgrade` and `apply_upgrade`, or an unreviewed hash is applied. | Upgrade flow | L | C | P2 | +| Tamp.6 | (Step 11) A partially signed multi-signature transaction is altered in transit. | Signing workflow | L | C | P2 | +| Tamp.7 | Integer overflow or underflow in index and count bookkeeping corrupts a registry or an award anchor. | Storage helpers, `select_winners` | L | H | P3 | +| Tamp.8 | (Step 7) **Over-promising across batches.** `remaining_escrow` only drops when a winner claims. Without a reservation, a second `select_winners` call would see funds an earlier winner is still owed and promise them again; the last claimant would find an empty pool. | `select_winners` | M | H | P2 | +| Tamp.9 | (Step 7) A manager awards a position below its published floor, so the published prize table is no longer a guaranteed minimum. | `select_winners` | M | M | P3 | +| Tamp.10 | (Step 11) **Storage-layout migration.** Event records written under the earlier percentage-based distribution cannot be decoded by the current record type. A migration that stops part-way, stamps the version early, or miscomputes owed totals for already-awarded winners could strand or double-count funds. | `migrate_events`, `migrate` | M | C | P1 | +| Tamp.11 | (Steps 8, 9) Re-entrancy through the token contract during a release, double-paying a claim. | Escrow release paths | L | C | P2 | +| Tamp.12 | (Step 7) A duplicate position inside one batch, or a position re-awarded in a later batch, pays the same prize twice. | `select_winners` | M | H | P2 | +| Tamp.13 | (Steps 2–10) A backend retry, or an attacker replaying a captured request, executes the same logical operation twice: two deposits or two claims for one intent. | Idempotency layer | H | H | P1 | +| Tamp.14 | (Steps 5, 8, 9) **Cross-user child op_id collision.** The child op_ids the events contract derives for profile calls are keyed by the parent op_id and the profile address but not by the originating user, and all events-originated markers share one domain in the profile contract. An attacker who learns a victim's pending parent op_id could pre-mark its bootstrap child through `apply_to_bounty`, so that the victim's `claim_milestone` reverted or the victim's `claim_prize` paid out without bootstrapping the profile (reputation lost). **2.0.0 closes the reachable path:** with `apply_to_bounty` gone, no permissionless entry point calls the profile contract, so burning a child marker now requires the owner's, manager's or winner's own signature. The derivation itself is unchanged and the remediation still stands. | Idempotency layer (TB6) | L | M | P4 | +| Tamp.15 | (Steps 6, 10) The manager cancels a hackathon or bounty after builders have submitted work but before any selection, reclaiming the full budget. | `start_cancel` | M | M | P3 | +| Tamp.16 | (Step 7) A multi-release selection names the same recipient at two positions. Only positions are de-duplicated; `claim_milestone` follows the last anchor for that recipient, so the first award's amount stays reserved with no claim path. | `select_winners`, `claim_milestone` | L | M | P4 | +| Tamp.17 | (Steps 6, 7) A builder overwrites the content of an already-judged submission slot after winners are selected; the stored record keeps the original submission time, so the rewrite is indistinguishable from the original in storage. **Void in 2.0.0:** `submit` is removed. | `submit` | M | M | P3 | #### Repudiation -| ID | Threat | Affected Component | -|---|---|---| -| Repud.1 | An organizer claims they never called `select_winners` or disputes the winner list they submitted. | `boundless-events` contract | -| Repud.2 | A winning builder claims they never received their payout. | Stellar ledger, USDC token transfers | -| Repud.3 | A staff member claims they never overrode a KYC decision, or disputes the reason/timestamp. | Backend admin KYC service, audit log | -| Repud.4 | A contributor to an event cancellation claims they never received their refund. | `boundless-events` contract, paged cancel flow | -| Repud.5 | The platform claims an admin co-sign was not performed on a crowdfunding milestone release, to deny a builder's payout claim. | Soroban transaction XDR, backend orchestrator | +| ID | Threat | Affected component | L | I | Priority | +|---|---|---|---|---|---| +| Repud.1 | (Step 7) A manager disputes having selected the winners on record. | `select_winners` | M | M | P3 | +| Repud.2 | (Step 8) A winner claims they never received their prize. | Stellar ledger | M | M | P3 | +| Repud.3 | A staff member disputes a KYC override. | Backend audit log | L | M | P4 | +| Repud.4 | (Step 10) A contributor claims they were never refunded, or a manager denies having started a cancellation. | Cancellation flow | M | M | P3 | +| Repud.5 | (Step 9) The platform denies having co-authorized a crowdfunding milestone release. | Transaction envelope | L | M | P4 | +| Repud.6 | (Step 3) A manager denies having accepted delegation, or an owner denies having delegated, after a contested selection. | Manager delegation | L | M | P4 | +| Repud.7 | (Step 6) A builder disputes what they submitted, or an owner claims a submission pointed at different content than what was judged. | Submission anchors | M | M | P3 | #### Information Disclosure -| ID | Threat | Affected Component | -|---|---|---| -| Info.1 | All Soroban contract storage is publicly readable; event participants' wallet addresses, prize amounts, and winner assignments are visible on-chain. | `boundless-events` contract, Stellar ledger | -| Info.2 | A data breach of the Boundless PostgreSQL database exposes user PII including identity documents. | Backend database | -| Info.3 | The backend orchestrator co-signing key is exposed via misconfigured environment variables, logging, or a code path that serializes config. | Backend environment, orchestrator service | -| Info.4 | Staff admin JWT tokens or TOTP secrets are exposed via a compromised admin workstation or session fixation attack. | Admin portal, staff auth flow | -| Info.5 | A Didit webhook payload containing partial identity data (e.g., name, status) is logged in plaintext by the backend. | Backend webhook handler, logging | -| Info.6 | The fee account private key (used to receive platform fees) is exposed, allowing fee diversion. | Fee account management, backend config | +| ID | Threat | Affected component | L | I | Priority | +|---|---|---|---|---|---| +| Info.1 | All contract storage is publicly readable: participant addresses, prize floors, awards, earnings, reputation. | Stellar ledger | H | L | P3 | +| Info.2 | A Boundless database breach exposes identity documents. | Backend database | L | H | P3 | +| Info.3 | Signing key material or transaction-building secrets are exposed through logs, environment dumps, or configuration serialization. | Backend environment | L | M | P4 | +| Info.4 | Staff session tokens or TOTP secrets are exposed from a compromised admin workstation. | Admin portal | L | M | P4 | +| Info.5 | A Didit webhook payload is logged in plain text. | Backend logging | M | M | P3 | +| Info.6 | The fee account key is exposed, allowing fee diversion. | Fee account custody | L | H | P3 | +| Info.7 | (Step 7) Winner selection is visible on-chain the moment it lands, before the owner's announcement. | `boundless-events` | H | L | P3 | #### Denial of Service -| ID | Threat | Affected Component | -|---|---|---| -| DoS.1 | An attacker floods the `apply` endpoint for a popular hackathon to exhaust the applicant cap (5,000) with bot accounts, locking out legitimate builders. | `boundless-events` contract, backend API | -| DoS.2 | A large hackathon with thousands of contributors triggers a single-transaction cancel that exhausts Stellar ledger resource limits. | `boundless-events` contract, paged cancel flow | -| DoS.3 | An attacker floods the backend API with unauthenticated requests, causing rate limit exhaustion for legitimate users. | Backend API, `GlobalThrottlerGuard` | -| DoS.4 | The Soroban RPC endpoint (Nodies) becomes unavailable, preventing the backend from submitting or reading any transactions. | Nodies RPC dependency | -| DoS.5 | An attacker submits many failing transactions with high compute units to delay legitimate transaction processing. | Stellar network, Soroban fee market | -| DoS.6 | A malicious webhook sender floods the `/api/didit/webhook` endpoint, exhausting backend processing capacity for KYC events. | Backend webhook handler | +| ID | Threat | Affected component | L | I | Priority | +|---|---|---|---|---|---| +| DoS.1 | (Steps 5, 6) Bot accounts flood applications or submissions to a popular event to lock out legitimate builders or bloat state. | Registries | H | M | P2 | +| DoS.2 | (Step 10) A cancellation with thousands of contributors exceeds per-transaction ledger limits and can never settle. | Cancellation flow | M | C | P1 | +| DoS.3 | Unauthenticated request floods exhaust backend rate limits. | Backend API | H | M | P2 | +| DoS.4 | The RPC provider becomes unavailable. | RPC dependency | M | M | P3 | +| DoS.5 | Fee-market spam delays legitimate transactions. | Stellar network | M | L | P4 | +| DoS.6 | Webhook floods exhaust KYC processing. | Backend webhook handler | M | L | P4 | +| DoS.7 | (Step 9) **Profile coupling.** `claim_milestone` calls the profile contract without `try_`; if the profile contract is paused or mis-bound, every grant or crowdfunding milestone claim reverts. `claim_prize` is decoupled, and 2.0.0 removed the other coupled caller (`apply_to_bounty`). | `boundless-events` (TB6) | L | H | P3 | +| DoS.8 | (Steps 7, 8, 10) Winners never claim, or a winner's wallet is lost; the pool cannot be cancelled while a prize is inside its window, so funds are stuck. | `claim_prize`, `start_cancel` | M | M | P3 | +| DoS.9 | (Steps 3, 7, 10) **Manager key loss.** Once a manager has accepted, `select_winners`, `start_cancel`, `propose_manager`, and `cancel_pending_manager` all require the current manager's signature. If that key is lost, the owner cannot select winners, cancel, or reassign, and there is no admin override. | Manager delegation (TB7) | M | H | P2 | +| DoS.10 | (Step 11) A global pause halts every user-facing state-changing path, including `claim_prize`, `process_cancel_batch`, and `finalize_cancel`, freezing funds in flight; time-based windows keep running while paused. | Admin controls | L | H | P3 | +| DoS.11 | Ledger state archival: a dormant event's persistent entries expire, making the event unreadable until restored. | Persistent storage TTL | M | L | P4 | +| DoS.12 | (Step 11) A storage migration on a deployment with real history cannot fit in one transaction; attempted one-shot, it aborts and leaves every event undecodable. | `migrate_events` | M | C | P1 | +| DoS.13 | (Step 6) Grant builders cannot submit: `submit` requires a prior application for grant events, but the application entry point accepts only bounties, so every grant submission fails with `ApplicantNotApplied`. **Void in 2.0.0:** both entry points are removed. | `submit`, `apply_to_bounty` | H | L | P3 | +| DoS.14 | (Step 10) **Refund recipient that cannot receive the token.** `process_cancel_batch` transfers to each contributor in index order without a skip path. A contributor who has removed their trustline, or whose trustline the issuer has frozen, makes every batch containing their index revert, so `finalize_cancel` can never run and every other contributor's refund and the owner's residual stay locked. | Cancellation flow | M | H | P2 | +| DoS.15 | (Steps 2, 4, 9) The fee account lacks, or loses, a trustline to a supported token; every deposit and every crowdfunding release in that token then reverts until the admin rotates the fee account. | Fee account configuration | L | H | P3 | #### Elevation of Privilege -| ID | Threat | Affected Component | -|---|---|---| -| EoP.1 | A builder calls `select_winners` directly (bypassing the backend), attempting to pick themselves as winner on an event they did not create. | `boundless-events` contract | -| EoP.2 | A staff member with `kyc:read` (Tier 0) permission calls the `kyc:override` endpoint (Tier 2) to approve their own or a collaborator's KYC. | Backend admin KYC controller, `PolicyGuard` | -| EoP.3 | An organizer calls `claim_milestone` on a crowdfunding event without the required admin co-authorization, attempting to claim funds early. | `boundless-events` contract, admin.require_auth() | -| EoP.4 | A regular backend API user (builder/organizer) sends requests to the `/admin/v2/*` routes by crafting bearer tokens with elevated claims. | Backend admin API, `StaffAuthGuard` | -| EoP.5 | An attacker calls `bootstrap_self` on `boundless-profile` with another user's address to create or squat a profile record on their behalf. | `boundless-profile` contract | -| EoP.6 | A malicious event owner calls `set_admin` or `propose_upgrade` directly, attempting to rotate the contract admin to an address they control. | `boundless-events` contract, `admin.rs` | -| EoP.7 | The backend orchestrator key is used to approve a `claim_milestone` on behalf of an event where the organizer did not initiate the call, redirecting funds. | Backend orchestrator, `boundless-events` | +| ID | Threat | Affected component | L | I | Priority | +|---|---|---|---|---|---| +| EoP.1 | (Step 7) A builder calls `select_winners` directly, naming themselves. | `select_winners` | H | C | P1 | +| EoP.2 | A read-only staff member invokes a KYC override. | Backend authorization guards | L | M | P4 | +| EoP.3 | (Step 9) An owner claims a crowdfunding milestone without the admin co-authorization. | `claim_milestone` | M | H | P2 | +| EoP.4 | A user session token is presented on admin routes. | Backend staff guard | M | M | P3 | +| EoP.5 | An attacker calls `bootstrap_self` with another user's address to squat a profile or its op_id space. | `boundless-profile` | H | L | P3 | +| EoP.6 | (Step 11) An event owner calls `set_admin`, `propose_upgrade`, `register_supported_token`, or `set_fee_account`. | Admin functions | H | C | P1 | +| EoP.7 | (Step 9) A crowdfunding milestone is co-authorized for a claim the owner did not initiate. | `claim_milestone` | L | M | P4 | +| EoP.8 | (Steps 3, 7, 10) A manager escalates to owner powers: redirects refunds to themselves, changes the owner, claims milestones, or drains the pool by awarding themselves. | Manager delegation (TB7) | M | H | P2 | +| EoP.9 | (Step 9) The owner passes an arbitrary `recipient` or an inflated amount to `claim_milestone` to pay a non-winner or overpay. | `claim_milestone` | M | H | P2 | +| EoP.10 | (Step 11) **Zero upgrade timelock.** The upgrade timelock is configured at 0 ledgers on both contracts. A compromised or coerced 2-of-3 quorum could propose and apply a WASM upgrade in a single step, leaving `cancel_pending_upgrade` and off-chain monitors no window to react. | Upgrade flow, both contracts | L | C | P2 | +| EoP.11 | A non-admin calls `admin_slash_reputation`, or the events contract's slash path is reached from outside the events contract. | `boundless-profile` | M | M | P3 | +| EoP.12 | (Step 11) A non-admin calls `migrate_events` or `migrate` to rewrite event records or stamp a version. | Migration functions | H | C | P1 | +| EoP.13 | (Step 1) The profile contract's events binding is rotated to an attacker-controlled contract that can then mint reputation and earnings at will. | `boundless-profile`, binding rotation | L | H | P3 | +| EoP.14 | (Steps 7, 10) **Cancellation of an awarded grant.** The cancellation guard protects only single-release prizes inside their window. A manager can cancel a grant after grantees have been selected and before milestones are paid; the reservation is released and the awarded funds return to the owner and contributors. | `start_cancel` (TB7) | M | H | P2 | +| EoP.15 | (Steps 7, 9) **Unbounded reputation minting.** `reputation_bump` is chosen by the manager or owner and forwarded to the profile contract unchecked. Any wallet can create a minimal bounty, award itself with `reputation_bump = u32::MAX`, claim, and hold the maximum reputation score. | `select_winners`, `claim_milestone`, `boundless-profile` | H | M | P2 | +| EoP.16 | (Step 1) The admin re-points the events contract's profile binding in a single step (`set_profile_contract` has no timelock), instantly changing every derived child op_id and, through DoS.7, halting milestone claims if the new binding is broken. | `set_profile_contract` (TB6) | L | M | P4 | --- -## Section 3 — What Are We Going to Do About It? +### 2.3 Priority summary -### Mitigation Table +**Priority 1 (10)** — the controls whose failure would be catastrophic; each is mitigated and tested, and these are the controls the audit should verify first: Spoof.1 (acting under another user's address); Spoof.6 (claiming another winner's prize); Tamp.2 (spending more than the pool holds); Tamp.10 (storage migration correctness); Tamp.13 (replayed operations); DoS.2 (large cancellations that cannot settle); DoS.12 (migration that cannot fit in one transaction); EoP.1 (non-manager selects winners); EoP.6 (non-admin calls admin functions); EoP.12 (non-admin rewrites records through migration). -| Threat ID | Mitigation | -|---|---| -| **Spoof.1** | All state-changing contract functions enforce `addr.require_auth()` via the Soroban auth framework. The Soroban runtime validates the caller's cryptographic signature against the address before executing. No caller can submit a valid auth entry for an address they do not control without its private key. | -| **Spoof.2** | The orchestrator co-signing key is scoped exclusively to `admin.require_auth()` within `claim_milestone`. It does NOT control the admin multi-sig account and cannot call `select_winners`, `set_admin`, or any other admin function. A compromise of the orchestrator key does not grant escrow control — the attacker would also need the event organizer's key (separate party). This is accepted residual risk with limited blast radius. Mitigation: rotate the orchestrator key immediately on suspected compromise; the admin multi-sig is not affected. | -| **Spoof.3** | All incoming Didit webhook requests are verified with HMAC-SHA256 using the `DIDIT_WEBHOOK_SECRET` before any payload is processed. Requests with missing or invalid signatures are rejected with HTTP 401 before deserialization. | -| **Spoof.4** | Staff JWTs are short-lived. Tier 2 actions (KYC override) require a valid TOTP code via `StepUpGuard` — a stolen JWT alone is insufficient. Session invalidation is supported via the `BetterAuth` session table. | -| **Spoof.5** | The admin multi-sig account is a Stellar G-address configured with 2-of-3 signers and threshold weights enforced at the Stellar protocol level. A single-signer transaction is rejected by the network before it reaches the contract. The `verify-multisig.sh` script verifies on-chain signer configuration before any governance operation. | -| **Tamp.1** | Prize amounts are locked in `remaining_escrow` at `create_event`. The `select_winners` function computes payouts from `remaining_escrow` at selection time, not from organizer-supplied values at release time. An organizer cannot change the total available after funding. | -| **Tamp.2** | The `select_winners` fee math divides `remaining_escrow` proportionally using basis points. The contract enforces that all percentages sum to 10,000 bps (100%) and releases only from what is held in escrow. An overflowing sum is rejected with `InvalidAllocation`. | -| **Tamp.3** | Submissions are keyed per `(event_id, applicant_address)`. A builder can update their own submission; another address's submission key is inaccessible without their auth. The submitting address is stored at commit time and is immutable thereafter. | -| **Tamp.4** | The backend verifies event existence against the contract before acting (read-then-act). The Nodies RPC endpoint is managed and dedicated; the backend does not follow `_links` navigation. Soroban event payloads include the emitting contract ID; the indexer filters on the known contract address. | -| **Tamp.5** | `propose_upgrade(wasm_hash, new_version)` stores the WASM hash on-chain. `apply_upgrade()` can only be called after the timelock elapses (~1 day) and re-verifies against the stored hash. The proposal is public on-chain; off-chain monitors can detect unexpected upgrade proposals. The admin multi-sig must authorize both steps. | -| **Tamp.6** | Multi-sig transactions are built offline, inspected before signing, and signed independently by each signer. The `verify-multisig.sh` script validates the on-chain signer configuration. Future target state: hardware keys (Yubikey/Ledger) eliminate in-memory key exposure. | -| **Tamp.7** | Retired. In contract 1.0.0 this was mitigated with `profile.credits.checked_sub(amount).ok_or(Error::InsufficientCredits)?` (Scout remediation round 2). The 1.0.0 → 1.1.0 upgrade then removed on-chain credits entirely — `spend_credits` and `credits.rs` no longer exist, and credit accounting lives in the off-chain ledger in `boundless-nestjs`, where it is covered by that system's own controls. | -| **Tamp.8** | `remove_applicant` now uses `slot.checked_sub(1).ok_or(Error::ApplicantNotApplied)?` and `count.checked_sub(1).ok_or(Error::EventNotFound)?` for all three arithmetic operations. Even though the `if slot == 0` guard above makes underflow structurally impossible, checked arithmetic makes the invariant explicit and contract-level: if the guard were ever removed or the code path changed, the checked operation would surface the error rather than silently wrapping. Fixed in Scout remediation round 2 (`events/storage.rs`). | -| **Repud.1** | `select_winners` emits a `WinnersSelected` Soroban event containing the event ID, winner addresses, and amounts. This event is immutable on the Stellar ledger and publicly auditable on Stellar Expert. | -| **Repud.2** | Every USDC release is an on-chain SAC `transfer` call. The transaction hash, sender, recipient, and amount are permanently recorded on the Stellar ledger. | -| **Repud.3** | Every admin KYC action (sync, retrigger, override) is recorded by `AdminAuditService` inside a database transaction with the staff member's ID, action type, target user ID, timestamp, and decision reason. The audit log is append-only. | -| **Repud.4** | The paged cancel flow emits `ContributorRefunded` for each payout, `OwnerResidualRefunded` when applicable, and `EventCancelled` at settlement. Each refund transfer is also an on-chain SAC `transfer`, providing an account-level payment trail. | -| **Repud.5** | The admin co-sign is a `SorobanAuthorizationEntry` embedded in the signed XDR of the transaction. It is visible in the transaction envelope on Stellar Expert — both the organizer's auth and the admin's auth are present and attributable to their respective addresses. | -| **Info.1** | Wallet addresses are pseudonymous. Rich personal data (submission content, project descriptions) is stored off-chain via `content_uri` (IPFS or backend storage), not in contract storage. This transparency is by design for a trustless settlement platform. Users are informed that their address and prize amounts are publicly visible. | -| **Info.2** | The Boundless PostgreSQL database stores only the Didit `session_id` (opaque token) and a normalized status string (`Approved / Declined / In Review`). Identity documents, biometrics, and personal details are never received or stored by Boundless. A full database breach does not expose KYC documents. | -| **Info.3** | The orchestrator signing key is stored as an environment variable, never logged, and never serialized in API responses. The server-side signing path reads the key only at transaction build time. Key rotation procedure: update the env var and redeploy; the key does not appear in source code or git history. | -| **Info.4** | Staff sessions use short-lived JWTs. Tier 2 actions require a fresh TOTP token. Admin workstations are on separate browser profiles from personal use. Future: hardware-backed staff keys. | -| **Info.5** | The Didit webhook handler logs only the `session_id` and normalized status, never the full payload. Log levels are set to `error,warn,log` in production; raw request bodies are not logged. | -| **Info.6** | The fee account is a Stellar G-address whose key is held by the admin multi-sig (same custody as the contract admin). Fee diversion requires compromising the multi-sig. | -| **DoS.1** | Per-event applicant cap is 5,000 entries. The `apply` endpoint requires a valid JWT and KYC-approved status, raising the cost of bot registration significantly. Per-event credit costs on application are enforced by the off-chain credit ledger at the API layer (since the 1.1.0 upgrade; previously on-chain via `application_credit_cost`), making spam economically prohibitive for events with non-zero credit costs. | -| **DoS.2** | The paged cancel design keeps `start_cancel` O(1) with a per-event `NonOwnerContributionTotal`. Once status is `Cancelling`, `process_cancel_batch(max_refunds=25)` and `finalize_cancel` are permissionless, so manager loss cannot strand an in-progress refund. | -| **DoS.3** | `GlobalThrottlerGuard` applies per-IP rate limits globally. The backend runs behind a load balancer with DDoS protection at the infrastructure layer. | -| **DoS.4** | Nodies is a managed, SLA-backed RPC provider. BullMQ queues buffer all async contract operations; if RPC is temporarily unavailable, jobs retry with exponential backoff and are not lost. The `DistributedLockService` prevents duplicate submissions during retry storms. | -| **DoS.5** | Soroban's fee market (resource fees) prices out spam. The backend uses `simulateTransaction` before submission to estimate fees; operations that would exceed configured limits are rejected before they reach the network. | -| **DoS.6** | The webhook endpoint is rate-limited separately. Didit sends webhooks from known IP ranges; IP allowlisting can be added as a hardening measure. Invalid HMAC requests are rejected before any DB write. | -| **EoP.1** | `select_winners` enforces `resolve_manager(env, event_id, &event.owner).require_auth()`. The manager is always the event owner address (set at `create_event`). No other address can produce a valid auth entry for the event owner. | -| **EoP.2** | `PolicyGuard` checks the staff member's permission set against the required permission declared on the route (`@RequirePermission`). `kyc:override` maps to Tier 2. `StepUpGuard` independently verifies a fresh TOTP token for all Tier 2 routes. These are orthogonal checks — bypassing one does not bypass the other. | -| **EoP.3** | `claim_milestone` in `crowdfunding.rs` calls both `event.owner.require_auth()` AND `admin.require_auth()`. Both must be satisfied in the same transaction. Without the platform's server-side admin co-sign, the transaction is rejected by the contract at the auth validation step. | -| **EoP.4** | The `/admin/v2/*` routes are gated by `StaffAuthGuard`, which validates a separate staff JWT issued by the admin authentication system (BetterAuth with a separate secret). A user JWT from the main app is not valid on the admin surface. | -| **EoP.5** | `bootstrap_self` calls `caller.require_auth()` where `caller` is `env.invoker()`. The invoker is the transaction source — it is cryptographically bound to the account that signed the transaction. A third party cannot produce a valid auth for another user's address. | -| **EoP.6** | `set_admin` and `propose_upgrade` each enforce `admin.require_auth()` at the contract level. The admin is the 2-of-3 multi-sig account. No event owner address is the admin; the check is against the configured admin key stored in instance storage. | -| **EoP.7** | The orchestrator only co-signs `claim_milestone` calls that originate from a backend-authenticated organizer session who is the verified owner of the event in the DB. The co-sign path checks the event ownership record before building the transaction. If the organizer's session is not the event owner, the build is refused before a signing key is accessed. | +**Priority 2 (21)** — Spoof.5 (single signer as the multi-signature account); Spoof.8 (forged cross-contract calls into the profile); Spoof.9 (admin signer key exfiltration); Spoof.10 (managed-wallet custody); Tamp.1 (lowering advertised prizes); Tamp.3 (overwriting another builder's submission); Tamp.5 (unreviewed or swapped upgrade hash); Tamp.6 (multi-signature envelope altered in transit); Tamp.8 (over-promising the pool across batches); Tamp.11 (re-entrancy during release); Tamp.12 (paying one position twice); DoS.1 (registry spam); DoS.3 (backend request floods); DoS.9 (manager key loss (open)); DoS.14 (refund recipient blocks cancellation (open)); EoP.3 (crowdfunding claim without admin co-authorization); EoP.8 (manager escalation to owner powers); EoP.9 (milestone paid to a non-winner or overpaid); EoP.10 (zero upgrade timelock (open)); EoP.14 (cancellation of an awarded grant (open)); EoP.15 (unbounded reputation minting (open)). ---- +Priority 3 and 4 threats are listed in the tables above. Every open item (Section 4.1) is Priority 2 or lower; no Priority 1 threat is open. + +## 3. What are we going to do about it? + +Status values: **Mitigated** — a control in the contracts or in operating procedure addresses the threat. **Accepted** — the residual risk is documented and carried. **Open** — a change is scheduled, or auditor input is requested, and the item is disclosed for the audit. -## Section 4 — Did We Do a Good Job? +| Threat | Treatment | Status | +|---|---|---| +| **Spoof.1** | Every state-changing entry point calls `require_auth()` on the address it acts for: `params.owner`, `from`, the award recipient. The host verifies the matching authorization entry at the point `require_auth()` is invoked, and every entry point invokes it before any persistent state is written. Inside the events contract, idempotency markers are keyed by the authorizing address, so a permissionless caller cannot pre-mark an op_id and block a privileged one (the profile-side exception is Tamp.14). | Mitigated | +| **Spoof.2** | The contract has no separate co-signer role: `claim_milestone` for crowdfunding calls `require_auth()` on the admin address, which on mainnet is a 2-of-3 account. A single key cannot satisfy it, and no server holds the quorum, so a backend compromise does not yield the co-authorization. The owner's signature is required in the same transaction and the recipient is the owner (crowdfunding's only winner record), so a compromised quorum changes timing, not destination. Planned hardening: introduce a dedicated validator role for crowdfunding co-authorization so that the admin quorum is not exercised in a routine flow. | Accepted | +| **Spoof.3** | HMAC-SHA256 over the raw body with the shared webhook secret; requests with a missing or invalid signature are rejected before deserialization. | Mitigated | +| **Spoof.4** | Short-lived staff sessions; sensitive routes require a fresh TOTP code; sessions can be invalidated centrally. | Mitigated | +| **Spoof.5** | Threshold weights are enforced by the Stellar protocol on the admin account; a single-signer envelope never reaches the contract. Signer configuration is verified by script before every governance operation. | Mitigated | +| **Spoof.6** | `claim_prize` takes no recipient parameter. The recipient is read from the prize award written at selection, cross-checked against the anchored winner record (recipient and position must both match), and must `require_auth()`. The transfer goes to that stored address only. | Mitigated | +| **Spoof.7** | Delegation is two-step: `propose_manager` by the current authority, then `accept_manager`, which calls `require_auth()` on the proposed address. Proposals expire after 17,280 ledgers; `cancel_pending_manager` withdraws them. `ManagerProposed` and `ManagerChanged` events make the timeline public. Planned hardening: `propose_manager` will emit a cancellation event when it replaces an existing proposal. | Mitigated | +| **Spoof.8** | The four mutation entry points call `require_auth()` on the events contract address stored in instance storage. For a contract address this succeeds only when that contract is the direct invoker, so no account or third contract can satisfy it. | Mitigated | +| **Spoof.10** | Managed keys are envelope-encrypted: a random data key per wallet, wrapped by a master key that exists only in the backend environment; the ciphertext is excluded from ORM reads globally and decrypted only on the signing path; master-key rotation is supported. Network fees are paid by a separate sponsor account, so managed wallets hold no XLM to drain. Every action a managed wallet takes is an on-chain event attributable to that wallet, and organizers may connect an external wallet so that high-value programs never depend on custody. Planned hardening: hold the master key in a KMS/HSM, require external-wallet signing above a per-event value threshold, and alert on anomalous managed-wallet activity. The residual custodial risk is carried and disclosed. | Accepted | +| **Spoof.9** | Signers hold keys on separate machines under a written custody policy; the medium and high thresholds require two signers; every envelope is simulated and inspected by each signer before signing. The policy schedules a hardware-key upgrade at defined value and incident triggers. Until then, the residual risk of two simultaneous signer compromises is carried. | Accepted | +| **Tamp.1** | Prize floors are written once at `create_event` and never mutated; there is no setter. The full budget plus fee is pulled in the create transaction, so the pool exists before any builder sees the event. Selection can pay at or above a floor, never below (Tamp.9). | Mitigated | +| **Tamp.2** | `select_winners` sums the batch with checked arithmetic, adds the already-reserved `EventOwedTotal`, and rejects with `InsufficientEscrow` if the committed total exceeds `remaining_escrow`. `claim_prize` and `claim_milestone` re-check `amount <= remaining_escrow` at release. Because per-event accounting is enforced on every release path, one event cannot spend another's share of the shared token balance. Covered by the escrow fee-math, prize-claim, and grant test suites, which assert recipient and fee-account balance deltas. | Mitigated | +| **Tamp.3** | Submissions were keyed by (event, applicant, slot); `submit` and `withdraw_submission` both `require_auth()`ed on the applicant, and the applicant recorded inside the stored submission was the authenticated address. 2.0.0 removes both entry points, so there is no on-chain submission to overwrite. | Void (2.0.0) | +| **Tamp.4** | The indexer filters on the known contract identifiers and reconciles against `get_event` reads before acting; the RPC endpoint is a dedicated managed node. | Mitigated | +| **Tamp.5** | `propose_upgrade` stores the hash on-chain; `apply_upgrade` reads only the stored hash and is gated by admin authorization, a 30-day expiry, and the timelock. The upgrade runbook requires reproducing the WASM hash from source with a locked build and simulating every admin envelope before signing. The on-chain review window this control relies on is absent while the timelock is zero (EoP.10). | Mitigated | +| **Tamp.6** | Envelopes are built offline, simulated, and inspected by each signer before signing; signers hold keys on separate machines (Spoof.9). | Mitigated | +| **Tamp.7** | The applicant index uses `checked_sub` with typed errors; the submission counters, unclaimed-prize count, and supported-token index use saturating arithmetic that clamps at zero; the workspace builds with `overflow-checks = true` in release, so any remaining bare arithmetic traps rather than wraps. One bare addition remains in the anchor-index computation of `select_winners`; it is unreachable in practice (it would require 2³² winner records) and traps under the release profile. Planned hardening: convert it to saturating arithmetic. | Mitigated | +| **Tamp.8** | The contract maintains `EventOwedTotal`: `select_winners` reserves the batch total plus the amount already owed and rejects if that exceeds `remaining_escrow`; `claim_prize` and `claim_milestone` decrement the owed and remaining balances together with checked arithmetic, failing closed if the reservation has drifted. `start_cancel` releases the reservation only after confirming that no single-release prize is still inside its claim window; multi-release awards carry no such guard (EoP.14). | Mitigated | +| **Tamp.9** | `create_event` requires every floor to be positive and their sum to be at most the budget. `select_winners` rejects any amount below its position's floor with `InvalidDistribution`. Positions without a floor are payable at any positive amount, by design. | Mitigated | +| **Tamp.10** | `migrate_events` walks a stored cursor from the deployment's first event to the latest, up to 8 events per call, admin-only; `migrate` refuses to stamp the version while any event remains unconverted. Records in the earlier layout are decoded with a frozen copy of their original type, and floors are derived as `total_budget × percent / 100`, which reproduces exactly what each position would have been paid. Owed totals are reconstructed for awards recorded in the earlier layout, which carried no reservation. The procedure has been rehearsed on testnet (45 events across 6 calls). Pending operational action: two mainnet event records remain in the earlier layout and cannot be decoded until `migrate_events` runs; both are completed events holding zero escrow, so no funds are affected. The conversion, followed by `migrate` on both contracts, is scheduled as part of the next administrative operation. | Mitigated | +| **Tamp.11** | The Soroban host forbids re-entrant contract invocation, so a token cannot call back into the events contract mid-release; tokens are also admin-approved. `claim_prize` additionally writes all state before the transfer. `claim_milestone` transfers before it marks the milestone claimed, which is safe under the host's re-entrancy prohibition. Planned hardening: reorder `claim_milestone` to record the claim before transferring, matching `claim_prize`. | Mitigated | +| **Tamp.12** | Duplicate positions within a batch are rejected. Across batches the per-position prize award record is the replay lock for single-release events; multi-release events accept exactly one `select_winners` call for the life of the event, so un-awarded headroom in a grant pool can leave only through cancellation. `claim_prize` rejects already-paid awards with `PrizeAlreadyClaimed`. | Mitigated | +| **Tamp.13** | Every fund-moving and registry-mutating call carries a 32-byte `op_id`; a marker keyed by (authorizing address, op_id) in temporary storage rejects repeats with `OpAlreadySeen`. Manager rotation and admin functions rely on Soroban authorization nonces alone. On mainnet a temporary entry lives at least 17,280 ledgers (about one day), covering the backend's retry horizon; beyond that, the original signed authorization has expired, so a replay needs a fresh signature from the user, which is a new intent by definition. Planned hardening: `process_cancel_batch` will reject `max_refunds = 0` so that a no-op call cannot consume a contract-domain op_id. | Mitigated | +| **Tamp.14** | Child op_ids are derived with SHA-256 over the parent, a tag, an index, and the profile contract identifier, which makes them collision-resistant across tags and indices for one parent but does not bind them to the originating user. Exploitation required learning a victim's pending parent op_id (generated randomly by the backend) within the one-day marker lifetime, and the impact is a reverted claim or a lost reputation update, never a loss of funds. 2.0.0 removes the only permissionless entry point that called the profile contract, so a stranger can no longer burn a child marker at all; the remaining exposure needs a privileged signature. Planned remediation, unchanged: include the events-side authorizing address in the child preimage. | Open | +| **Tamp.15** | Cancellation before selection is a deliberate owner right: the pool is the owner's money until it is awarded, and there is no on-chain notion of a submission deadline. The platform's terms and the public `EventCancelled` record are the controls; builders can see on-chain that a pool was withdrawn. | Accepted | +| **Tamp.16** | Manager-inflicted and recoverable through cancellation, but a validation gap. Planned remediation: reject duplicate recipients within a multi-release selection. | Open | +| **Tamp.17** | The ordered `Submitted` events showed every rewrite, so the change was provable from the ledger, but the stored record did not carry it. 2.0.0 removes `submit`: an entry is a row in the backend's database, where it carries its own updated-at and an organizer-visible revision count. | Void (2.0.0) | +| **Repud.1** | `WinnersSelected` plus one winner record and one prize award per position are written in the manager-signed transaction; the manager's authorization entry is in the envelope on the public ledger. Planned hardening: emit one award event per position so that the per-winner linkage does not depend on reading storage in the same ledger. | Mitigated | +| **Repud.2** | `WinnerPaid{event_id, recipient, position, amount}` is emitted alongside the token transfer; both are permanently on-chain, and the payment timestamp is readable through `get_winner_at`. | Mitigated | +| **Repud.3** | Append-only audit log with staff identity, action, target, timestamp, and reason, written in the same database transaction as the change. | Mitigated | +| **Repud.4** | `ContributorRefunded` and `OwnerResidualRefunded` are each backed by a token transfer; `EventCancelled` marks the final state. The manager's authorization entry for `start_cancel` is in the envelope. Planned hardening: emit a `CancellationStarted` event when an event enters `Cancelling`, and emit a zero-amount refund event for contributors whose pro-rata share rounds to zero. | Mitigated | +| **Repud.5** | The admin authorization entries are part of the signed transaction envelope and attributable on any explorer. | Mitigated | +| **Repud.6** | `ManagerProposed`, `ManagerChanged`, and `PendingManagerCancelled` events are emitted; acceptance requires the proposed manager's own signature, so a denial is refutable from the ledger. | Mitigated | +| **Repud.7** | `Submitted{event_id, applicant, slot, content_uri}` and `SubmissionWithdrawn` events are emitted. The anchor binds the content, rather than only a location, only when `content_uri` is content-addressed; the contract stores whatever URI the builder signs. Planned control: the backend will pin submission content and require a content identifier or an embedded hash in the URI. | Accepted | +| **Info.1** | By design for a trustless settlement layer. Addresses are pseudonymous; all rich content stays off-chain behind `content_uri`. Users are told that addresses and amounts are public. | Accepted | +| **Info.2** | The database holds only Didit's opaque session identifier and a status value. Documents and biometrics never reach Boundless. | Mitigated | +| **Info.3** | Secrets are environment variables only, read at transaction-build time, never logged or serialized; rotation is a redeploy (master-key rotation re-wraps the per-wallet data keys). No server-held secret can act as the admin; the master encryption key is the one secret whose exposure matters for escrow (Spoof.10). | Mitigated | +| **Info.4** | Short-lived sessions, fresh TOTP for sensitive actions, dedicated browser profiles; hardware-backed staff keys planned. | Mitigated | +| **Info.5** | The handler logs the session identifier and status only; production log levels exclude request bodies. | Mitigated | +| **Info.6** | The fee account is under the same 2-of-3 custody as the admin; `set_fee_account` is admin-only and emits `FeeAccountUpdated` for monitoring. | Mitigated | +| **Info.7** | Inherent to public ledgers. The backend batches selection and announcement so the gap is minutes; the owner controls when the selection transaction is submitted. | Accepted | +| **DoS.1** | Applicants, contributors, and submissions are per-element persistent entries whose write and rent are paid by the submitter's own transaction, so spam funds its own storage and cannot fill a shared entry. No state-changing path iterates a participant set in one transaction: refunds are cranked in batches of 25 and selection takes an explicit list of at most 50. Winner records are scanned in full by `claim_milestone`, the multi-release branch of `select_winners`, and `migrate_events`, but those records are written only by the manager. Reads page at 100 entries. At the API layer, applying and submitting require a KYC-approved session and, where configured, an off-chain credit cost. | Mitigated | +| **DoS.2** | `start_cancel` is O(1): it reads the running non-owner contribution total instead of scanning contributors, snapshots the refund arithmetic, and flips the event to `Cancelling`. `process_cancel_batch` and `finalize_cancel` are permissionless cranks with contract-domain idempotency keys, so a missing manager cannot strand an in-progress refund. Tested at 220 contributors with a constant start footprint. Current mainnet limits (200 read and 200 write entries per transaction) leave headroom. | Mitigated | +| **DoS.3** | Global per-IP throttling plus infrastructure-level DDoS protection. | Mitigated | +| **DoS.4** | Queue-buffered submissions with exponential backoff and distributed locks limit the impact of an outage; a failover RPC endpoint is planned. The single-provider dependency is carried until then. | Accepted | +| **DoS.5** | Soroban resource fees price out spam; the backend simulates before submitting and refuses operations beyond configured limits. | Mitigated | +| **DoS.6** | Separate rate limit, HMAC rejection before any write, IP allow-listing available. | Mitigated | +| **DoS.7** | The profile contract can be paused only by the admin quorum, its instance TTL is extended on every state-changing call and its persistent entries on every read, and `set_profile_contract` on the events side can re-point the binding. `claim_prize`, the release path with the most claimants, already uses `try_` calls so a profile fault cannot block a payout. 2.0.0 removed `apply_to_bounty`, leaving `claim_milestone` as the single coupled caller. Planned remediation: convert it to `try_` calls so that profile availability never gates a fund movement. Auditor input is requested on whether best-effort reputation updates are the appropriate trade-off. | Open | +| **DoS.8** | Prizes are claimable for 90 days from the most recent selection; later batches extend the window. During the window `start_cancel` is refused, so a manager cannot withdraw funds while a winner's claim is still valid; afterwards `start_cancel` releases the reservation and unclaimed amounts flow back through the normal refund branches (contributors first, then owner). Funds remain reachable provided a manager exists (DoS.9) and every refund recipient can still receive the token (DoS.14). | Mitigated | +| **DoS.9** | Controls in place: delegation is opt-in and two-step, proposals expire, the owner keeps `claim_milestone` authority for grants, and the cancellation cranks are permissionless once cancellation has started. There is no recovery when an accepted manager's key is lost before selection or cancellation. Auditor input is requested on three candidate designs: (a) the owner may `propose_manager` after a period of manager inactivity; (b) the owner may always revoke a manager while no selection has occurred; (c) admin-mediated reassignment with a timelock and event emission. Until one ships, owners are advised to delegate only to keys they can recover. | Open | +| **DoS.10** | Intended emergency brake; admin-only under 2-of-3; `Paused` and `Unpaused` events; the operations runbook requires a written incident record. Pausing never moves escrow, and admin entry points remain callable so the pause can be lifted. Time-based windows (the 90-day claim window, manager and admin proposal expiries) are not extended by a pause; a pause longer than a window would let it lapse. Planned hardening: extend the prize-claim expiry by the pause duration on `unpause`. | Accepted | +| **DoS.11** | Every `get_event` read extends the record's TTL when it drops below 86,400 ledgers (to about 90 days); mainnet's minimum persistent TTL for new entries is about 120 days. Instance storage is extended on every admin write. A truly dormant event can be restored by anyone with a `RestoreFootprint` operation; the backend indexer touches the events it tracks. | Accepted | +| **DoS.12** | `migrate_events` is paged over a persisted cursor and bounds the number of event records per call to 8; each event additionally costs one read per winner record (and one write per multi-release anchor), which is adequate while no event holds more winner records than the per-transaction entry limits allow. `migrate` blocks until the cursor reaches the latest event. | Mitigated | +| **DoS.13** | Fail-closed: no funds or state were affected, and grants proceed through owner-driven selection and milestone claims without on-chain submissions. 2.0.0 removes `submit` and `apply_to_bounty` outright, which was the second of the two candidate remediations and removes the dead branch rather than documenting it. | Void (2.0.0) | +| **DoS.14** | A contributor is refunded to the address they contributed from, and the token is admin-approved; the failure requires a contributor to act against their own refund, or an issuer freeze. Auditor input is requested on the remediation design: record a failed refund as claimable and continue the batch, or provide a permissionless skip that leaves the contributor's amount claimable later. | Open | +| **DoS.15** | Admin-inflicted and reversible with `set_fee_account`. The operations runbook requires verifying that the fee account holds a trustline for every supported token before registering the token or rotating the account; `FeeAccountUpdated` and `TokenRegistered` events are the monitoring hooks. | Accepted | +| **EoP.1** | `select_winners` calls `require_auth()` on the resolved manager: the accepted manager or, absent one, the owner. No other address can satisfy it. | Mitigated | +| **EoP.2** | Route-declared permissions are checked by a policy guard; sensitive routes additionally require a fresh TOTP code through an independent step-up guard. | Mitigated | +| **EoP.3** | `claim_milestone` calls `require_auth()` on the event owner and, for crowdfunding, on the admin; both must be present in the same transaction. The crowdfunding test suite asserts that the admin's authorization is demanded. | Mitigated | +| **EoP.4** | Separate staff token issuer and secret; user tokens fail the staff guard. | Mitigated | +| **EoP.5** | `bootstrap_self` calls `require_auth()` on the user and uses the user's own address as the op_id domain, so it cannot squat events-domain keys. Bootstrap is idempotent and creates an empty profile, so there is nothing to gain from bootstrapping someone else. | Mitigated | +| **EoP.6** | Every admin entry point begins with `require_auth()` on the admin address in instance storage; admin rotation is two-step with a 7-day expiry. The admin test suite asserts that authorization is genuinely required, not mocked. | Mitigated | +| **EoP.7** | The owner's signature is required in the same transaction as the admin co-authorization, the recipient is the owner, and the amount is derived from the pool. The backend assembles the transaction only for an authenticated session that owns the event. | Mitigated | +| **EoP.8** | The manager can select, cancel, and rotate. It cannot change the owner (there is no setter), receive refunds (routed to the stored owner and contributor addresses), claim milestones (owner-authorized), or alter floors. A manager, or an owner acting as manager, can award any address including itself: judging is off-chain by design, and the owner chooses whom to delegate to. `ManagerChanged` lands on-chain before any selection, so the delegation is auditable. | Accepted | +| **EoP.9** | The recipient must already hold a winner record with no milestone; the amount is computed from that award (grants) or from `remaining_escrow / milestones_left` (crowdfunding), never from a caller-supplied value; each (recipient, milestone) pair is claimable once; the milestone index must be within range. | Mitigated | +| **EoP.10** | The timelock is configured at zero on both contracts while mainnet holds live escrow. Restoring it to 17,280 ledgers on both contracts is the next administrative operation and will precede audit fieldwork. Compensating controls meanwhile: the 2-of-3 threshold and the runbook's simulate-and-hash-verify step, which protect against mistakes rather than against a compromised quorum. | Open | +| **EoP.11** | `admin_slash_reputation` requires admin authorization and a non-empty reason, and emits `AdminReputationSlashed`; `slash_reputation` is reachable only through the events-contract domain (Spoof.8). | Mitigated | +| **EoP.12** | `migrate_events` and `migrate` both require admin authorization; the cursor only advances; `migrate` is one-shot per version. | Mitigated | +| **EoP.13** | The first binding is single-step at deployment; rotation is `propose_events_contract`, a 17,280-ledger timelock, then `accept_events_contract` within 7 days, admin-only, with events emitted for monitoring. The timelock is only as strong as the profile contract's upgrade timelock, so this control is fully effective once EoP.10 closes. | Mitigated | +| **EoP.14** | Grantees are named on-chain (`WinnersSelected`, winner records) and cancellation is public (`EventCancelled`), so an un-awarding is visible and attributable, and the funds return to the accounts that supplied them rather than to the manager. Auditor input is requested on extending the cancellation guard to multi-release awards with unpaid milestones, and on whether such a guard should have a time bound. | Open | +| **EoP.15** | Reputation is informational and carries no on-chain privilege; earnings are recorded from actual transfers and cannot be inflated the same way. Planned remediation: cap `reputation_bump` per award in the events contract, and cap `delta` per call in the profile contract. | Open | +| **EoP.16** | Admin-only, emitted for monitoring, and reversible in one step. The profile-side binding is timelocked (EoP.13) because that side can mint reputation; the events-side binding cannot move funds. The asymmetry is carried as a residual risk; a two-step rotation on the events side is a candidate hardening. | Accepted | -### Dataflow Diagram Utility +--- -The dataflow diagram directly informed our threat analysis. Mapping the trust boundary between the off-chain backend and the on-chain contracts (TB5) was the most productive part of the exercise — it clarified that compromise of the backend orchestrator does not automatically translate to loss of on-chain escrow, because the contracts enforce their own auth rules independently. This shaped our decision to scope the orchestrator co-signing key narrowly (crowdfunding admin co-auth only) rather than giving it broader permissions. +## 4. Did we do a good job? -### STRIDE Coverage Assessment +**Has the data-flow diagram been referenced since it was created?** +Yes. Drawing the diagram made two boundaries explicit that the code alone did not: the boundary between owner and manager inside a single event (TB7), and the boundary between the two contracts (TB6). Walking TB7 produced Spoof.7, Repud.6, DoS.9, EoP.8, and EoP.14; walking TB6 produced Spoof.8, Tamp.14, DoS.7, EoP.13, and EoP.16. Figure 2 was drawn after the threat tables and immediately exposed that the cancellation guard is evaluated for only one of the two release kinds. -All six STRIDE categories produced actionable threats. The most significant findings: +**Did the STRIDE model uncover any new design issues or concerns that had not been previously addressed or thought of?** +Yes. The following were not written down anywhere before this exercise: -- **Spoofing:** `require_auth()` coverage across all contract functions is thorough, but the orchestrator key's scope needed explicit documentation. The key's limited blast radius (covered under Spoof.2) was identified during this exercise and has been documented in the operational runbooks. -- **Tampering:** The `select_winners` math (Tamp.2) was scrutinized in the June 2026 Stellar-skill audit (finding M1). The fix — computing winner payouts from `remaining_escrow` at selection time — was already applied, but the threat model gave us additional confidence that the math is correct. -- **Information Disclosure:** Info.2 (database breach does not expose KYC documents) is the strongest privacy property of the design, enabled by the architectural decision to keep PII exclusively with Didit. This would not have been called out as explicitly without the Information Disclosure category forcing the question. -- **Denial of Service:** DoS.2 was the design driver for the paged cancellation architecture. The events 1.2.0 review found that the original `start_cancel` snapshot still contained an unbounded scan and that manager-only crank authorization created a second liveness dependency. The running total and permissionless crank close both gaps. -- **Elevation of Privilege:** EoP.3 (crowdfunding claim requires admin co-auth) is the most operationally sensitive control. The threat model confirmed that the dual-auth design is correctly implemented and the blast radius of an orchestrator key leak is bounded. +1. **Manager key loss locks the pool (DoS.9).** Delegation deliberately removes the owner's ability to act once a manager accepts, so that an owner cannot override a neutral judge. The cost is that nothing can recover an event whose manager disappears. Three candidate designs are listed for audit input. +2. **A refund recipient that cannot receive the token blocks every other refund (DoS.14).** Batched refunds have no skip path, so one removed or frozen trustline can leave an event in `Cancelling` indefinitely. Two remediation designs are listed for audit input. +3. **Awarded grants can be cancelled (EoP.14).** The cancellation guard was written for pull-model prizes and does not cover multi-release awards. +4. **Reputation can be minted without bound (EoP.15).** The reputation bump is caller-chosen and uncapped. Planned remediation: per-award and per-call caps. +5. **Child op_ids are not bound to the user (Tamp.14).** Planned remediation: include the authorizing address in the derivation. +6. **Submissions can be rewritten after judging (Tamp.17)** and **a multi-release selection can strand a reservation by naming one recipient twice (Tamp.16).** Planned remediation for both. +7. **Grant submissions are unreachable (DoS.13).** Fail-closed, so not a security issue, but a dead path. Planned remediation: a grant application entry point or an explicit rejection. +8. **Profile coupling on fund-moving paths (DoS.7).** `claim_prize` treats profile updates as best-effort; `apply_to_bounty` and `claim_milestone` do not. Planned remediation: align them. +9. **Release ordering (Tamp.11).** `claim_milestone` transfers before it records the claim; `claim_prize` does the reverse. Not exploitable under the host's re-entrancy prohibition. Planned remediation: align the ordering. +10. **Event schema gaps (Repud.1, Repud.4).** Selection emits only a count, entering `Cancelling` emits nothing, and zero-value pro-rata refunds leave no trace. Planned remediation: per-award, cancellation-started, and zero-refund events. +11. **Custodial wallets narrow TB5 (Spoof.10).** The assumption that a backend compromise cannot move escrow holds only for external wallets. For managed wallets the backend holds the key, so the escrow of any event whose owner, manager, or winners are managed is only as safe as the master encryption key. Planned hardening: KMS/HSM custody of the master key and external-wallet signing above a value threshold. +12. **Co-authorization uses the admin key (Spoof.2).** The crowdfunding milestone co-authorization is the same admin address that governs upgrades. Planned hardening: a dedicated validator role. +13. **Configuration state (EoP.10, Tamp.10).** Comparing the live mainnet state against the model showed two event records still in the earlier storage layout and live escrow held under a zero upgrade timelock. Both are scheduled for the next administrative operation, and the upgrade runbook will gain a gate that refuses to sign `apply_upgrade` unless the migration steps are queued in the same session. -### Unresolved / Residual Risks +**Did the treatments identified in Section 3 adequately address the issues identified?** +For 49 of the 72 threats, yes; every contract-level control has a test that asserts its failure mode (312 passing: 243 for `boundless-events`, 66 for `boundless-profile`, 3 storage-compatibility fixtures), and the backend and process controls are covered by the backend's own test suite and operating procedures. Two of the contract-level controls (Tamp.5, EoP.13) are fully effective only once EoP.10 closes. Thirteen threats are accepted residual risks with a written rationale. Ten are open: EoP.10, DoS.9, DoS.14, and EoP.14 are the items on which auditor input is most valuable; the remaining six (Tamp.14, Tamp.16, Tamp.17, DoS.7, DoS.13, EoP.15) have a planned remediation scheduled before audit fieldwork. -| Risk | Status | Acceptance Rationale | -|---|---|---| -| Orchestrator co-signing key compromise (Spoof.2, EoP.7) | Accepted, monitored | Blast radius is limited to crowdfunding claim timing; attacker also needs organizer's key. Key rotation procedure is documented. | -| Nodies RPC single point of failure (DoS.4) | Accepted, mitigated | Queue buffering handles transient outages; failover RPC endpoint planned for mainnet. | -| Software multi-sig (vs. hardware) for admin keys | Accepted, time-bounded | Hardware upgrade is triggered at $250K TVL or first signer-machine incident per the admin custody policy. | -| All contract storage public on-chain | Accepted by design | Pseudonymous addresses; PII off-chain via `content_uri`. Informed user consent. | +**Have additional issues been found after the threat model?** +This is the first iteration of the model. It will be revised after the audit report is received, and whenever the architecture changes materially: a new event type, a new authorization flow, a new external service, or a contract upgrade that touches storage layout. -### Post-Modeling Changes +**Any additional thoughts or insights on the threat modeling process that could help improve it next time?** +Two. First, modeling a role boundary (owner versus manager) with the same rigor as a system boundary proved valuable; the same treatment is planned for the grant-committee multi-signature primitive before it is implemented. Second, "what is the recovery path if this key is lost?" and "what happens if this transfer cannot complete?" should be standing questions for every `require_auth` and every token transfer in the contract, not only the admin's; asking them late is what produced DoS.9 and DoS.14. -Four issues surfaced during or directly after the modelling process: +### 4.1 Open items -1. The orchestrator key's scope was not formally written down anywhere. This threat model prompted us to document the key's exact permissions and rotation procedure in the admin runbook. -2. The audit log for KYC overrides did not originally include the `reason` field in the initial implementation. Reviewing the Repudiation category prompted us to confirm this was added and tested -- it is present in `AdminKycActionsService.override()`. -3. **(v1.1)** A second Scout static analysis run after this document was first published identified two previously-undocumented integer underflow vectors: `profile/credits.rs` (Tamp.7) and `events/storage.rs` (Tamp.8). Both were fixed by converting bare `- 1` and `-= amount` operations to `checked_sub` with typed error returns. These threats have been added to Section 2 and Section 3 in this revision. -4. **(v1.1)** The ENHANCEMENT category in the Scout report flagged ~50 profile contract functions for missing event emissions. Code review confirmed all state-mutating functions already emit events in their implementation modules; Scout cannot trace through function call chains. Documented as false positives. -5. **(v1.2)** The 1.0.0 → 1.1.0 contract upgrade (2026-06) removed per-user credit balances from the profile contract; credits now live in an off-chain ledger in `boundless-nestjs`. Component descriptions, the dataflow diagram, and the data-store table were updated; Tamp.7 was marked retired (its subject code, `credits.rs`, no longer exists on-chain); EoP.5 and DoS.1 were reworded to match the post-upgrade surface. Credit-ledger threats are now in scope of the backend's threat surface (TB1), not the contract's. +| ID | Item | Treatment | +|---|---|---| +| EoP.10 | Upgrade timelock is 0 on both contracts while escrow is live | Restore to 17,280 ledgers as the next administrative operation, before audit fieldwork. | +| DoS.9 | Manager key loss strands the pool | Auditor input requested on the three candidate designs in the DoS.9 treatment, Section 3. | +| DoS.14 | A refund recipient that cannot receive the token blocks cancellation | Auditor input requested on the two remediation designs in the DoS.14 treatment, Section 3. | +| EoP.14 | An awarded grant can be cancelled before milestones are paid | Auditor input requested on extending the cancellation guard to multi-release awards. | +| DoS.7 | Profile availability gates milestone claims | Convert `claim_milestone` to `try_` calls before fieldwork. 2.0.0 removed the other coupled caller. | +| Tamp.14 | Child op_ids not bound to the originating user | Include the authorizing address in the derivation before fieldwork. 2.0.0 closed the permissionless path to exploiting it. | +| Tamp.16 | Duplicate recipient in a multi-release selection strands a reservation | Reject duplicate recipients before fieldwork. | +| EoP.15 | Unbounded reputation bump | Cap per award and per call. | + +### 4.1.1 Closed by the 2.0.0 participation removal + +| ID | Item | How | +|---|---|---| +| Tamp.3 | Submission overwritten by another builder | No on-chain submissions exist. | +| Tamp.17 | Submission rewrite after selection | `submit` removed; entries carry their own revision history off chain. | +| DoS.13 | Grant submissions unreachable | `submit` and `apply_to_bounty` removed, which was the second candidate remediation. | -6. **(v1.3)** The events 1.2.0 cancellation-liveness upgrade replaced the unbounded `start_cancel` contributor scan with an O(1) running total and made cancellation batching and finalization permissionless after the manager starts cancellation. Mainnet was verified to contain no event rows before the storage change. +### 4.2 Pending operational action -### Living Document Commitment +| ID | Item | Treatment | +|---|---|---| +| Tamp.10 | Two mainnet event records remain in the earlier storage layout | Run `migrate_events` and `migrate` on both contracts with the next administrative operation; no funds affected. | -This threat model will be revisited before mainnet launch (Tranche 3) and any time the architecture changes materially (new pillar, new auth flow, new external service, contract upgrade). +### 4.3 Accepted residual risks + +| ID | Risk | Rationale | +|---|---|---| +| Spoof.2 | Crowdfunding co-authorization uses the admin quorum | No single key can satisfy it; owner signature also required; dedicated validator role planned. | +| Spoof.9 | Software-held admin signer keys | Two signers required; separate machines; hardware-key upgrade scheduled by policy. | +| Spoof.10 | Managed-wallet custody | Envelope encryption, sponsor separation, on-chain attribution, external-wallet option; KMS/HSM and value-threshold external signing planned. | +| Tamp.15 | Pre-selection cancellation | Owner's money until awarded; visible on-chain; platform terms govern. | +| Repud.7 | `content_uri` not content-addressed by the contract | Backend to pin and hash submissions. | +| Info.1, Info.7 | Public ledger transparency | Pseudonymous; announcement batching. | +| DoS.4 | Single RPC provider | Queue buffering; failover planned. | +| DoS.10 | Global pause freezes claims and does not stop time-based windows | Emergency control under 2-of-3; expiry extension on unpause planned. | +| DoS.11 | State archival of dormant events | TTL extended on read; restorable by anyone. | +| DoS.15 | Fee-account trustline misconfiguration | Admin-inflicted, reversible; runbook check and monitoring events. | +| EoP.8 | Manager may award itself | Judging is off-chain; delegation is the owner's explicit, on-chain-visible choice. | +| EoP.16 | Single-step events-side profile binding | Cannot move funds; reversible; two-step rotation is a candidate hardening. | --- -## Appendix A — Contract Entry Points and Auth Requirements +## 5. Verification and review plan -| Function | Contract | Caller Auth Required | +| Layer | What is in place | Cadence | |---|---|---| -| `__constructor` | events | None (deploy-time only) | -| `create_event` | events | `params.owner.require_auth()` | -| `apply` | events | `applicant.require_auth()` | -| `withdraw_application` | events | `applicant.require_auth()` | -| `submit` | events | `event.owner.require_auth()` (manager) | -| `add_funds` | events | `from.require_auth()` | -| `select_winners` | events | `event.owner.require_auth()` (manager) | -| `claim_milestone` | events | `event.owner.require_auth()` + `admin.require_auth()` | -| `start_cancel` | events | Current event manager, or owner when no manager override exists | -| `process_cancel_batch` | events | None after the event enters `Cancelling` | -| `finalize_cancel` | events | None after the event enters `Cancelling` | -| `set_admin` | events | `admin.require_auth()` (current admin) | -| `accept_admin` | events | `pending.target.require_auth()` | -| `pause` / `unpause` | events | `admin.require_auth()` | -| `propose_upgrade` | events | `admin.require_auth()` | -| `apply_upgrade` | events | `admin.require_auth()` | -| `migrate` | events | `admin.require_auth()` | -| `register_supported_token` | events | `admin.require_auth()` | -| `set_events_contract` (first) | profile | `admin.require_auth()` | -| `propose_events_contract` | profile | `admin.require_auth()` | -| `accept_events_contract` | profile | `admin.require_auth()` | -| `bootstrap_self` | profile | `caller.require_auth()` | +| Contract tests | 312 tests: every payout split asserts recipient and fee-account deltas; admin authorization asserted as genuinely required; idempotency and op_id namespacing; paged cancellation at 220 contributors; storage migration replayed against synthetic mainnet-shaped state. | Every commit (CI, release profile). | +| Live-network tests | 23 testnet smoke scripts drive the deployed contracts through the backend orchestrator across every lifecycle path, including cancellation, grant sweep and replay, crowdfunding co-authorization, and a non-USDC token. | Before every upgrade. | +| Build integrity | Reproducible locked WASM build with a size gate; the upgrade runbook requires reproducing the on-chain hash from source and simulating every admin envelope before any signer signs. | Every upgrade. | +| Static analysis | Scout (CoinFabrik) against the source under review; findings triaged into the repository with a false-positive registry. | Every release. | +| On-chain monitoring | Both contracts emit an event for every state change, including every administrative one (`PendingUpgradeProposed`, `UpgradeApplied`, `AdminUpdated`, `PendingAdminSet`, `FeeAccountUpdated`, `FeeBpsUpdated`, `ProfileContractUpdated`, `TokenRegistered`, `Paused`, `Unpaused`, `EventsContractUpdated`). The backend indexer consumes them; a public analytics model reconciles escrow inflows and outflows. Planned before fieldwork: alerting on every administrative event, a reconciliation check that the contract's token balance is at least the sum of `remaining_escrow`, and stuck-state detection (events in `Cancelling` beyond a threshold, prizes approaching their claim expiry). | Continuous. | +| Incident response | `pause` on both contracts is the first action for any suspected compromise; the custody policy defines emergency signer rotation (pause, provision a new signer, rotate the admin in two steps, unpause), lost-signer scenarios, and a written escalation list. Every incident produces a written record. | As needed; rotation drilled on testnet. | +| External audit | This submission. Findings will be fixed, re-tested, and the fixes rehearsed on testnet before a mainnet upgrade; the threat model will be updated to reference the report. | Once, then on material change. | +| Threat-model review | This document is revised with every release that changes the contract surface, authorization, or storage layout, and reviewed in full at least annually. | Per release; annual. | --- -## Appendix B — Audit Checklist Cross-Reference +## Appendix A — Contract entry points and authorization -| Checklist Item | Status | Evidence | -|---|---|---| -| SCF funded | Yes | SCF grant recipient; Tranche 2 submission | -| Repo hygiene | Yes | `README.md`, `CONTRIBUTING.md`, `BACKLOG.md`, `docs/`, clean CI | -| Integration tests | Yes | 15+ smoke scripts against live testnet; `cargo test --all` 226/226 passing (149 events + 77 profile) | -| Threat model | Yes | This document (v1.1) | -| Dataflow diagram | Yes | Section 1.2 above | -| Tooling scan | Done | Scout (CoinFabrik) two runs June 2026; report at `docs/scout-audit-report.md` | -| Remediation plan | Done | June 2026 Stellar-skill audit (all HIGH/MEDIUM closed); Scout round 1 (C-3, M-1, M-2 fixed); Scout round 2 (C-6, C-7 fixed -- integer underflows in `credits.rs` and `storage.rs`). 0 real CRITICAL or MEDIUM findings remaining. See `docs/scout-audit-report.md`, `BACKLOG.md`. | +| Function | Contract | Authorization | Notes | +|---|---|---|---| +| `__constructor` | events | none (deploy time) | fee at most 10% | +| `create_event` | events | `params.owner` | supported token; floors positive, sum at most budget; deposit plus fee pulled in-transaction (non-crowdfunding) | +| `propose_manager`, `cancel_pending_manager` | events | current manager (owner if none) | proposal expires in 17,280 ledgers; no op_id | +| `accept_manager` | events | proposed manager | no op_id | +| `add_funds` | events | `from` | at least 10 tokens; event `Active` | +| ~~`apply_to_bounty`, `withdraw_application`~~ | events | `applicant` | **Removed in 2.0.0.** Bounty events only; withdrawal was blocked once a submission existed. | +| ~~`submit`, `withdraw_submission`~~ | events | `applicant` | **Removed in 2.0.0.** Bounties and grants required a prior application (grants had no application entry point, DoS.13); crowdfunding rejected; slot-keyed. | +| `select_winners` | events | current manager | at most 50 per call; single-release: per-position once, repeatable; multi-release: exactly one call; at or above floor; owed-total reservation | +| `claim_prize` | events | stored award recipient | single-release only; window enforced at cancellation | +| `claim_milestone` | events | event owner, plus admin for crowdfunding | recipient must hold a winner record | +| `start_cancel` | events | current manager | refused while single-release prizes remain inside the 90-day window; multi-release awards carry no guard (EoP.14) | +| `process_cancel_batch`, `finalize_cancel` | events | none (permissionless crank) | only while `Cancelling` | +| `set_admin` | events, profile | admin | two-step, 7-day expiry | +| `accept_admin` | events, profile | proposed admin | | +| `set_fee_bps`, `set_fee_account`, `set_profile_contract` | events | admin | fee at most 10%; single-step | +| `pause`, `unpause` | events, profile | admin | admin entry points remain callable while paused | +| `register_supported_token`, `deregister_supported_token` | events | admin | | +| `propose_upgrade`, `apply_upgrade`, `cancel_pending_upgrade` | events, profile | admin | timelock currently 0 (EoP.10); 30-day expiry | +| `migrate_events` | events | admin | paged, at most 8 events per call | +| `migrate` | events, profile | admin | one-shot per version; events refuses while records remain unconverted | +| `set_events_contract` (first binding) | profile | admin | single-step only when unset | +| `propose_events_contract`, `accept_events_contract`, `cancel_pending_events_contract` | profile | admin | 17,280-ledger timelock, 7-day expiry | +| `bootstrap`, `bump_reputation`, `slash_reputation`, `register_earnings` | profile | events contract (contract-invoker authorization) | op_id domain is the events contract | +| `bootstrap_self` | profile | `user` | op_id domain is the user | +| `admin_slash_reputation` | profile | admin | non-empty reason required | +| ~~`get_submission`, `get_applicants`, `get_applicants_page`, `get_applicant_count`, `get_applicant_at`, `get_applicant_submission_count`~~ | events | none | **Removed in 2.0.0** with the records they read. | +| All read functions | both | none | list reads page at 100 entries | + +## Appendix B — Audit readiness summary + +| Item | Status | +|---|---| +| Unit and integration tests | 312 passing (243 `boundless-events`, 66 `boundless-profile`, 3 storage-compatibility fixtures). Every payout split asserts both the recipient and the fee-account balance deltas; admin authorization is asserted as genuinely required. | +| Live-network tests | 23 testnet smoke scripts drive the deployed contracts through the backend orchestrator: publish, contribute, submit, select, claim, single- and multi-contributor cancellation, grant sweep and replay, crowdfunding co-authorization, non-USDC token, fee deduction. | +| Continuous integration | Reproducible locked WASM build, WASM size gate, formatting check, mainnet upgrade-guard script, storage-compatibility fixtures, full test suite in release mode. | +| Static analysis | Scout (CoinFabrik) is run against the source under review. The single outstanding item is the arithmetic change described under Tamp.7. | +| Upgrade path | Timelocked propose and apply, on-chain `version()`, paged migration, testnet rehearsal, mainnet runbook with simulation before signing. | +| Admin custody | Documented custody policy, multi-signature pre-flight checklist, and an on-chain signer verification script. | +| Disclosed items | Open: Section 4.1. Pending operational action: Section 4.2. Accepted: Section 4.3. | diff --git a/signing/mainnet-apply-events-1.6.0.xdr b/signing/mainnet-apply-events-1.6.0.xdr new file mode 100644 index 0000000..0732e6f --- /dev/null +++ b/signing/mainnet-apply-events-1.6.0.xdr @@ -0,0 +1 @@ +AAAAAgAAAACqr+kenVNRznjlFU8yE+QTqegANttFtJQbOeXujoYHJwAe3egDxLFqAAAADgAAAAAAAAAAAAAAAQAAAAAAAAAYAAAAAAAAAAGLUhnQSRnPriBNNLORXFTX2uG/A42dHPozTgZeaFtYxAAAAA1hcHBseV91cGdyYWRlAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAGLUhnQSRnPriBNNLORXFTX2uG/A42dHPozTgZeaFtYxAAAAA1hcHBseV91cGdyYWRlAAAAAAAAAAAAAAAAAAABAAAAAAAAAAIAAAAHKoeJv13Tkrkd3+ZZEHKgy5+ucARavzef0du8LbSvHeMAAAAHQ7g65OWuaJ8AQnj5NqhhairdXgm7ECTogwx/Jm9GSWMAAAABAAAABgAAAAGLUhnQSRnPriBNNLORXFTX2uG/A42dHPozTgZeaFtYxAAAABQAAAABAE4W4gAAAAAAAAQcAAAAAAAAWWgAAAAA diff --git a/signing/mainnet-migrate-events-1.6.0.xdr b/signing/mainnet-migrate-events-1.6.0.xdr new file mode 100644 index 0000000..21b0420 --- /dev/null +++ b/signing/mainnet-migrate-events-1.6.0.xdr @@ -0,0 +1 @@ +AAAAAgAAAACqr+kenVNRznjlFU8yE+QTqegANttFtJQbOeXujoYHJwAgD2YDxLFqAAAADwAAAAAAAAAAAAAAAQAAAAAAAAAYAAAAAAAAAAGLUhnQSRnPriBNNLORXFTX2uG/A42dHPozTgZeaFtYxAAAAAdtaWdyYXRlAAAAAAAAAAABAAAAAAAAAAAAAAABi1IZ0EkZz64gTTSzkVxU19rhvwONnRz6M04GXmhbWMQAAAAHbWlncmF0ZQAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAdDuDrk5a5onwBCePk2qGFqKt1eCbsQJOiDDH8mb0ZJYwAAAAEAAAAGAAAAAYtSGdBJGc+uIE00s5FcVNfa4b8DjZ0c+jNOBl5oW1jEAAAAFAAAAAEAMPaIAAAAAAAABFQAAAAAAAGK5gAAAAA= diff --git a/signing/mainnet-propose-events-1.6.0-v2.xdr b/signing/mainnet-propose-events-1.6.0-v2.xdr new file mode 100644 index 0000000..d1ba28f --- /dev/null +++ b/signing/mainnet-propose-events-1.6.0-v2.xdr @@ -0,0 +1 @@ +AAAAAgAAAACqr+kenVNRznjlFU8yE+QTqegANttFtJQbOeXujoYHJwAkhdADxLFqAAAADQAAAAAAAAAAAAAAAQAAAAAAAAAYAAAAAAAAAAGLUhnQSRnPriBNNLORXFTX2uG/A42dHPozTgZeaFtYxAAAAA9wcm9wb3NlX3VwZ3JhZGUAAAAAAgAAAA0AAAAgQ7g65OWuaJ8AQnj5NqhhairdXgm7ECTogwx/Jm9GSWMAAAAOAAAABTEuNi4wAAAAAAAAAQAAAAAAAAAAAAAAAYtSGdBJGc+uIE00s5FcVNfa4b8DjZ0c+jNOBl5oW1jEAAAAD3Byb3Bvc2VfdXBncmFkZQAAAAACAAAADQAAACBDuDrk5a5onwBCePk2qGFqKt1eCbsQJOiDDH8mb0ZJYwAAAA4AAAAFMS42LjAAAAAAAAAAAAAAAQAAAAAAAAABAAAAByqHib9d05K5Hd/mWRByoMufrnAEWr83n9HbvC20rx3jAAAAAQAAAAYAAAABi1IZ0EkZz64gTTSzkVxU19rhvwONnRz6M04GXmhbWMQAAAAUAAAAAQAsF0cAAAAAAAAFGAAAAAAABgFQAAAAAA== diff --git a/signing/mainnet-propose-events-1.6.0.xdr b/signing/mainnet-propose-events-1.6.0.xdr new file mode 100644 index 0000000..1cd93bd --- /dev/null +++ b/signing/mainnet-propose-events-1.6.0.xdr @@ -0,0 +1 @@ +AAAAAgAAAACqr+kenVNRznjlFU8yE+QTqegANttFtJQbOeXujoYHJwAGAegDxLFqAAAADQAAAAAAAAAAAAAAAQAAAAAAAAAYAAAAAAAAAAGLUhnQSRnPriBNNLORXFTX2uG/A42dHPozTgZeaFtYxAAAAA9wcm9wb3NlX3VwZ3JhZGUAAAAAAgAAAA0AAAAgQ7g65OWuaJ8AQnj5NqhhairdXgm7ECTogwx/Jm9GSWMAAAAOAAAABTEuNi4wAAAAAAAAAQAAAAAAAAAAAAAAAYtSGdBJGc+uIE00s5FcVNfa4b8DjZ0c+jNOBl5oW1jEAAAAD3Byb3Bvc2VfdXBncmFkZQAAAAACAAAADQAAACBDuDrk5a5onwBCePk2qGFqKt1eCbsQJOiDDH8mb0ZJYwAAAA4AAAAFMS42LjAAAAAAAAAAAAAAAQAAAAAAAAABAAAAByqHib9d05K5Hd/mWRByoMufrnAEWr83n9HbvC20rx3jAAAAAQAAAAYAAAABi1IZ0EkZz64gTTSzkVxU19rhvwONnRz6M04GXmhbWMQAAAAUAAAAAQAsF0cAAAAAAAAFGAAAAAAABgGEAAAAAA==