From 87c5aae31474f9f4b26f45570c7c4c16e7df196d Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Mon, 17 Aug 2026 23:51:21 +0100 Subject: [PATCH 01/20] feat(events): open pool with committed prize floors Allocation moves out of create_event and into select_winners. An event now holds a pool; each selection names the amount it spends from it. The prize table published at create is stored as per-position minimums, so an award may exceed what was advertised but never fall below it. Percentages are gone, and with them the 1% minimum prize, the whole-percent constraint, and the rounding residual that was dumped on position 1. A top-up now has exactly one consequence: the pool is larger. It no longer silently reprices every prize before the first selection, nor becomes inert after it. EventOwedTotal reserves awarded-but-unclaimed prizes. remaining_escrow only drops at claim time, so without the reservation a second selection would see funds an earlier winner is still entitled to and could promise them twice, leaving the second winner unable to claim. Cancelling releases the reservation rather than withholding it, since both claim paths require Active and withheld funds would otherwise strand with no path out. Grants derive each milestone payment from the awarded amount instead of a share of total_budget, so a grant top-up now reaches its milestones. Crowdfunding drops its {1: 100} check; its milestone math never read the distribution. Closes #107, #108, #109, #110, #111, #112, #113, #114 --- contracts/events/src/crowdfunding.rs | 13 +- contracts/events/src/event_ops.rs | 117 ++++++--- contracts/events/src/grant.rs | 19 +- contracts/events/src/storage.rs | 15 ++ contracts/events/src/tests/bounty_pillar.rs | 11 +- contracts/events/src/tests/cancel_refund.rs | 19 +- contracts/events/src/tests/contributions.rs | 9 +- contracts/events/src/tests/cross_contract.rs | 81 +++--- contracts/events/src/tests/crowdfunding.rs | 17 +- contracts/events/src/tests/escrow_fee_math.rs | 235 +++++++++++++++--- contracts/events/src/tests/grant_pillar.rs | 21 +- .../events/src/tests/hackathon_pillar.rs | 45 +++- contracts/events/src/tests/op_id_security.rs | 10 +- contracts/events/src/tests/prize_claim.rs | 49 ++-- contracts/events/src/tests/token_whitelist.rs | 8 +- contracts/events/src/types.rs | 17 +- 16 files changed, 483 insertions(+), 203 deletions(-) diff --git a/contracts/events/src/crowdfunding.rs b/contracts/events/src/crowdfunding.rs index 04a98ff..29a6cd3 100644 --- a/contracts/events/src/crowdfunding.rs +++ b/contracts/events/src/crowdfunding.rs @@ -11,16 +11,7 @@ pub fn validate_create(_env: &Env, record: &EventRecord, _owner: &Address) -> Re _ => return Err(Error::InvalidReleaseKind), } - if record.winner_distribution.len() != 1 { - return Err(Error::InvalidDistribution); - } - let percent = record - .winner_distribution - .get(1) - .ok_or(Error::InvalidDistribution)?; - if percent != 100 { - return Err(Error::DistributionMismatch); - } - + // No floor check: crowdfunding pays milestones out of `remaining_escrow` + // divided by the milestones left, and never reads the prize floors. Ok(()) } diff --git a/contracts/events/src/event_ops.rs b/contracts/events/src/event_ops.rs index 54ab7d6..ed3dc10 100644 --- a/contracts/events/src/event_ops.rs +++ b/contracts/events/src/event_ops.rs @@ -74,14 +74,22 @@ pub fn create_event(env: &Env, params: CreateEventParams, op_id: BytesN<32>) -> return Err(Error::InvalidBudget); } - if params.winner_distribution.is_empty() { + if params.prize_floors.is_empty() { return Err(Error::InvalidDistribution); } - let mut sum: u32 = 0; - for (_pos, percent) in params.winner_distribution.iter() { - sum = sum.saturating_add(percent); + // Floors may total less than the budget. The headroom is deliberate: it is + // what a later selection draws on to award a position that had no floor at + // create time. + let mut floor_sum: i128 = 0; + for (_pos, floor) in params.prize_floors.iter() { + if floor <= 0 { + return Err(Error::InvalidDistribution); + } + floor_sum = floor_sum + .checked_add(floor) + .ok_or(Error::DistributionMismatch)?; } - if sum != 100 { + if floor_sum > params.total_budget { return Err(Error::DistributionMismatch); } @@ -112,7 +120,7 @@ pub fn create_event(env: &Env, params: CreateEventParams, op_id: BytesN<32>) -> title: params.title.clone(), created_at: env.ledger().timestamp(), deadline: params.deadline, - winner_distribution: params.winner_distribution.clone(), + prize_floors: params.prize_floors.clone(), fee_bps_override: params.fee_bps_override, }; match params.pillar { @@ -367,6 +375,12 @@ pub fn start_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<(), E manager.require_auth(); idempotency::require_unseen(env, &manager, &op_id)?; + // Cancelling supersedes every award: both claim paths require Active, so + // from here nothing can be claimed and the reservation must be released + // rather than withheld. Withholding it would strand the funds with no + // path back out. The guard above is what protects prizes still claimable. + storage::set_owed_total(env, event_id, 0); + let remaining = event.remaining_escrow; let count = storage::contributor_count(env, event_id); let non_owner_total = get_or_init_non_owner_total(env, event_id)?; @@ -650,13 +664,9 @@ pub fn select_winners( let existing_count = storage::winner_count(env, event_id); match event.release_kind { - ReleaseKind::Single => { - // Winner rows but no base-escrow key means a pre-1.3.0 push-model - // event: keep it one-shot. New events award each position once. - if existing_count > 0 && storage::get_prize_base_escrow(env, event_id).is_none() { - return Err(Error::WinnersAlreadySelected); - } - } + // Single events award in as many batches as the manager likes; each + // position may be awarded once, enforced per position below. + ReleaseKind::Single => {} ReleaseKind::Multi(_) => { for idx in 0..existing_count { if let Some(w) = storage::winner_at(env, event_id, idx) { @@ -687,9 +697,6 @@ pub fn select_winners( if already { return Err(Error::DuplicateWinnerPosition); } - if event.winner_distribution.get(spec.position).is_none() { - return Err(Error::InvalidWinnerPosition); - } seen_positions.push_back(spec.position); } @@ -697,42 +704,39 @@ pub fn select_winners( match event.release_kind { ReleaseKind::Single => { - // Amounts are fixed against the escrow baseline captured at the - // first selection; claim_prize does the transfer and profile calls. - let base_escrow = match storage::get_prize_base_escrow(env, event_id) { - Some(b) => b, - None => { - let b = event.remaining_escrow; - storage::set_prize_base_escrow(env, event_id, b); - b - } - }; + // `remaining_escrow` only drops at claim time, so a prize named by + // an earlier selection is still sitting in it. Reserving the owed + // total is what stops a later selection promising the same funds + // twice and leaving the second winner unable to claim. + let owed_before = storage::owed_total(env, event_id); - let mut total_owed: i128 = 0; + let mut batch_total: i128 = 0; for spec in winners.iter() { if storage::get_prize_award(env, event_id, spec.position).is_some() { return Err(Error::DuplicateWinnerPosition); } - let percent = event - .winner_distribution - .get(spec.position) - .ok_or(Error::InvalidDistribution)? as i128; - let amount = base_escrow.saturating_mul(percent) / 100_i128; - if amount <= 0 { + if spec.amount <= 0 { return Err(Error::InvalidDistribution); } - total_owed = total_owed.saturating_add(amount); + if let Some(floor) = event.prize_floors.get(spec.position) { + if spec.amount < floor { + return Err(Error::InvalidDistribution); + } + } + batch_total = batch_total + .checked_add(spec.amount) + .ok_or(Error::InsufficientEscrow)?; } - if total_owed > event.remaining_escrow { + let committed = batch_total + .checked_add(owed_before) + .ok_or(Error::InsufficientEscrow)?; + if committed > event.remaining_escrow { return Err(Error::InsufficientEscrow); } + storage::set_owed_total(env, event_id, committed); for (idx, spec) in winners.iter().enumerate() { - let percent = event - .winner_distribution - .get(spec.position) - .ok_or(Error::InvalidDistribution)? as i128; - let amount = base_escrow.saturating_mul(percent) / 100_i128; + let amount = spec.amount; let anchor_idx = existing_count + (idx as u32); storage::append_winner( @@ -773,6 +777,32 @@ pub fn select_winners( } } ReleaseKind::Multi(_) => { + // Same reservation as Single: milestone claims drain + // `remaining_escrow` gradually, so without it a second grantee + // could be awarded funds the first is still owed. + let owed_before = storage::owed_total(env, event_id); + let mut batch_total: i128 = 0; + for spec in winners.iter() { + if spec.amount <= 0 { + return Err(Error::InvalidDistribution); + } + if let Some(floor) = event.prize_floors.get(spec.position) { + if spec.amount < floor { + return Err(Error::InvalidDistribution); + } + } + batch_total = batch_total + .checked_add(spec.amount) + .ok_or(Error::InsufficientEscrow)?; + } + let committed = batch_total + .checked_add(owed_before) + .ok_or(Error::InsufficientEscrow)?; + if committed > event.remaining_escrow { + return Err(Error::InsufficientEscrow); + } + storage::set_owed_total(env, event_id, committed); + for spec in winners.iter() { storage::append_winner( env, @@ -780,7 +810,7 @@ pub fn select_winners( &Winner { recipient: spec.recipient.clone(), position: spec.position, - amount: 0, + amount: spec.amount, milestone: None, paid_at: None, }, @@ -859,6 +889,11 @@ pub fn claim_prize( let unclaimed = storage::unclaimed_prize_count(env, event_id); storage::set_unclaimed_prize_count(env, event_id, unclaimed.saturating_sub(1)); + // Claiming converts owed into paid; both balances drop together so the + // reservation in select_winners stays exact. + let owed = storage::owed_total(env, event_id); + storage::set_owed_total(env, event_id, (owed - amount).max(0)); + event.remaining_escrow = event.remaining_escrow.saturating_sub(amount); if event.remaining_escrow == 0 { event.status = EventStatus::Completed; diff --git a/contracts/events/src/grant.rs b/contracts/events/src/grant.rs index 5d02524..8067346 100644 --- a/contracts/events/src/grant.rs +++ b/contracts/events/src/grant.rs @@ -57,6 +57,7 @@ pub fn claim_milestone( let count = storage::winner_count(env, event_id); let mut winner_position: Option = None; + let mut awarded_amount: i128 = 0; let mut already_claimed_for_recipient: u32 = 0; let mut already_paid_to_recipient: i128 = 0; for idx in 0..count { @@ -68,7 +69,10 @@ pub fn claim_milestone( continue; } match w.milestone { - None => winner_position = Some(w.position), + None => { + winner_position = Some(w.position); + awarded_amount = w.amount; + } Some(_) => { already_claimed_for_recipient = already_claimed_for_recipient.saturating_add(1); already_paid_to_recipient = already_paid_to_recipient.saturating_add(w.amount); @@ -93,11 +97,9 @@ pub fn claim_milestone( event.remaining_escrow / (remaining_milestones as i128) } } else { - let percent = event - .winner_distribution - .get(position) - .ok_or(Error::InvalidWinnerPosition)? as i128; - let total_share = event.total_budget.saturating_mul(percent) / 100_i128; + // The award carries its own amount, set at selection. Milestones split + // that, not a share of the budget. + let total_share = awarded_amount; let per_milestone_floored = total_share / (total_milestones as i128); if already_claimed_for_recipient.saturating_add(1) == total_milestones { @@ -120,6 +122,11 @@ pub fn claim_milestone( escrow::release(env, &event.token, &recipient, amount); } event.remaining_escrow = event.remaining_escrow.saturating_sub(amount); + if !is_crowdfunding { + // Crowdfunding never reserves, since it has no winner selection. + let owed = storage::owed_total(env, event_id); + storage::set_owed_total(env, event_id, (owed - amount).max(0)); + } storage::mark_milestone_claimed(env, event_id, &recipient, milestone); if is_crowdfunding { let claimed = storage::get_crowdfunding_milestones_claimed(env, event_id); diff --git a/contracts/events/src/storage.rs b/contracts/events/src/storage.rs index 7eeae1b..b7fb4b5 100644 --- a/contracts/events/src/storage.rs +++ b/contracts/events/src/storage.rs @@ -557,6 +557,21 @@ pub fn set_unclaimed_prize_count(env: &Env, id: u64, count: u32) { touch_event_persistent(env, &key); } +pub fn owed_total(env: &Env, id: u64) -> i128 { + let key = DataKey::EventOwedTotal(id); + let t: Option = env.storage().persistent().get(&key); + if t.is_some() { + touch_event_persistent(env, &key); + } + t.unwrap_or(0) +} + +pub fn set_owed_total(env: &Env, id: u64, owed: i128) { + let key = DataKey::EventOwedTotal(id); + env.storage().persistent().set(&key, &owed); + touch_event_persistent(env, &key); +} + pub fn get_prize_base_escrow(env: &Env, id: u64) -> Option { let key = DataKey::EventPrizeBaseEscrow(id); let b: Option = env.storage().persistent().get(&key); diff --git a/contracts/events/src/tests/bounty_pillar.rs b/contracts/events/src/tests/bounty_pillar.rs index 923c1b0..0e852d3 100644 --- a/contracts/events/src/tests/bounty_pillar.rs +++ b/contracts/events/src/tests/bounty_pillar.rs @@ -69,9 +69,9 @@ fn setup<'a>() -> Ctx<'a> { } } -fn one_winner_distribution(env: &Env) -> Map { +fn one_winner_distribution(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, 100000000000_i128); m } @@ -89,7 +89,7 @@ fn create_bounty_with_deadline(ctx: &Ctx, deadline: u64) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/events/draft/x"), title: String::from_str(&ctx.env, "Test Bounty"), deadline: Some(deadline), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: None, manager: None, }; @@ -107,7 +107,7 @@ fn create_hackathon(ctx: &Ctx) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), title: String::from_str(&ctx.env, "Test Hackathon"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: None, manager: None, }; @@ -140,7 +140,7 @@ fn create_rejects_multi_release_kind() { content_uri: String::from_str(&ctx.env, "uri"), title: String::from_str(&ctx.env, "Bad Bounty"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: None, manager: None, }; @@ -259,6 +259,7 @@ fn apply_on_completed_event_reverts() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: 10_000_0000000_i128, reputation_bump: 0, }, ]; diff --git a/contracts/events/src/tests/cancel_refund.rs b/contracts/events/src/tests/cancel_refund.rs index 7fed1b3..905ea14 100644 --- a/contracts/events/src/tests/cancel_refund.rs +++ b/contracts/events/src/tests/cancel_refund.rs @@ -75,9 +75,9 @@ fn setup_with_env<'a>(env: Env) -> Ctx<'a> { } } -fn single_dist(env: &Env) -> Map { +fn single_dist(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, 10000000000_i128); m } @@ -91,7 +91,7 @@ fn create_hackathon(ctx: &Ctx) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/cancel-test"), title: String::from_str(&ctx.env, "Cancel Test"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -403,8 +403,8 @@ fn missing_running_total_with_contributors_fails_closed() { fn cancel_prorata_splits_remaining_across_partners_no_owner_residual() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 60); - dist.set(2, 40); + dist.set(1, 600_0000000_i128); + dist.set(2, 400_0000000_i128); let params = CreateEventParams { pillar: Pillar::Hackathon, owner: ctx.owner.clone(), @@ -414,7 +414,7 @@ fn cancel_prorata_splits_remaining_across_partners_no_owner_residual() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/boundary"), title: String::from_str(&ctx.env, "ProRata Cancel"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -433,14 +433,17 @@ fn cancel_prorata_splits_remaining_across_partners_no_owner_residual() { WinnerSpec { recipient: w.clone(), position: 1, + // The pool is 2000 after both contributions; the manager awards + // 1200 of it, leaving 800 to split pro-rata on cancel. + amount: 1_200_0000000_i128, reputation_bump: 0 }, ]; ctx.events .select_winners(&id, &winners, &BytesN::random(&ctx.env)); - // Pull model: the winner claims (60% of 2000 = 1200) before the - // manager can cancel; the remainder splits pro-rata below. + // Pull model: the winner claims before the manager can cancel; the + // remainder splits pro-rata below. ctx.events .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); diff --git a/contracts/events/src/tests/contributions.rs b/contracts/events/src/tests/contributions.rs index 2652f84..fc4bce5 100644 --- a/contracts/events/src/tests/contributions.rs +++ b/contracts/events/src/tests/contributions.rs @@ -72,9 +72,9 @@ fn setup<'a>() -> Ctx<'a> { } } -fn single_dist(env: &Env) -> Map { +fn single_dist(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, 10000000000_i128); m } @@ -88,7 +88,7 @@ fn create_hackathon(ctx: &Ctx) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/contrib-hack"), title: String::from_str(&ctx.env, "Contrib Hack"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -336,7 +336,7 @@ fn cancel_at_boundary_pays_partners_full_no_owner_residual() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/boundary"), title: String::from_str(&ctx.env, "Boundary Cancel"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -362,6 +362,7 @@ fn cancel_at_boundary_pays_partners_full_no_owner_residual() { WinnerSpec { recipient: winner_a.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50, }, ]; diff --git a/contracts/events/src/tests/cross_contract.rs b/contracts/events/src/tests/cross_contract.rs index f4ab428..c000bc8 100644 --- a/contracts/events/src/tests/cross_contract.rs +++ b/contracts/events/src/tests/cross_contract.rs @@ -72,9 +72,11 @@ fn setup<'a>() -> Ctx<'a> { } } -fn one_winner_distribution(env: &Env) -> Map { +fn one_winner_distribution(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + // Nominal: a floor is a minimum, so 1 stroop never constrains an + // award. Tests that exercise the floor rule set a real one. + m.set(1, 1_i128); m } @@ -88,7 +90,7 @@ fn create_bounty(ctx: &Ctx) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/events/draft/x"), title: String::from_str(&ctx.env, "Test Bounty"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: None, manager: None, }; @@ -117,6 +119,7 @@ fn select_winners_pays_recipient_and_bumps_profile() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 50, }, ]; @@ -152,7 +155,7 @@ fn select_winners_pays_recipient_and_bumps_profile() { } #[test] -fn select_winners_requires_position_in_distribution() { +fn select_winners_rejects_an_award_larger_than_the_pool() { let ctx = setup(); let bounty_id = create_bounty(&ctx); @@ -160,7 +163,8 @@ fn select_winners_requires_position_in_distribution() { &ctx.env, WinnerSpec { recipient: ctx.applicant.clone(), - position: 2, // distribution only has position 1 + position: 2, + amount: TOTAL_BUDGET + 1, reputation_bump: 50, }, ]; @@ -168,7 +172,10 @@ fn select_winners_requires_position_in_distribution() { let res = ctx .events .try_select_winners(&bounty_id, &winners, &op_select); - assert!(res.is_err(), "invalid position should revert"); + assert!( + res.is_err(), + "a position with no floor is allowed, but not one the pool cannot cover" + ); } #[test] @@ -177,8 +184,8 @@ fn select_winners_rejects_duplicate_position() { let owner = ctx.owner.clone(); let token_addr = ctx.token_addr.clone(); let mut dist = Map::new(&ctx.env); - dist.set(1, 60); - dist.set(2, 40); + dist.set(1, 6_000_0000000_i128); + dist.set(2, 4_000_0000000_i128); let params = CreateEventParams { pillar: Pillar::Bounty, owner, @@ -188,7 +195,7 @@ fn select_winners_rejects_duplicate_position() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/x"), title: String::from_str(&ctx.env, "Test Bounty 2"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -201,11 +208,13 @@ fn select_winners_rejects_duplicate_position() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 50, }, WinnerSpec { recipient: other_recipient, position: 1, // duplicate + amount: TOTAL_BUDGET, reputation_bump: 25, }, ]; @@ -220,8 +229,8 @@ fn select_winners_rejects_duplicate_position() { fn select_winners_handles_multi_recipient_distribution() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 60); - dist.set(2, 40); + dist.set(1, 6_000_0000000_i128); + dist.set(2, 4_000_0000000_i128); let params = CreateEventParams { pillar: Pillar::Bounty, owner: ctx.owner.clone(), @@ -231,7 +240,7 @@ fn select_winners_handles_multi_recipient_distribution() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/multi"), title: String::from_str(&ctx.env, "Multi Winner"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -245,11 +254,13 @@ fn select_winners_handles_multi_recipient_distribution() { WinnerSpec { recipient: winner_a.clone(), position: 1, + amount: 6_000_0000000_i128, reputation_bump: 50, }, WinnerSpec { recipient: winner_b.clone(), position: 2, + amount: 4_000_0000000_i128, reputation_bump: 25, }, ]; @@ -288,6 +299,7 @@ fn select_winners_replayed_reverts() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 50, }, ]; @@ -338,8 +350,8 @@ fn cancel_already_cancelled_reverts() { fn cancel_after_select_winners_refunds_only_remaining() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 60); - dist.set(2, 40); + dist.set(1, 6_000_0000000_i128); + dist.set(2, 4_000_0000000_i128); let params = CreateEventParams { pillar: Pillar::Bounty, owner: ctx.owner.clone(), @@ -349,7 +361,7 @@ fn cancel_after_select_winners_refunds_only_remaining() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/partial"), title: String::from_str(&ctx.env, "Partial Pay Bounty"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -362,6 +374,7 @@ fn cancel_after_select_winners_refunds_only_remaining() { WinnerSpec { recipient: winner_a.clone(), position: 1, + amount: TOTAL_BUDGET * 60 / 100, reputation_bump: 50, }, ]; @@ -391,7 +404,7 @@ fn cancel_after_select_winners_refunds_only_remaining() { fn create_grant(ctx: &Ctx, n_milestones: u32) -> u64 { let mut dist = Map::new(&ctx.env); - dist.set(1, 100); + dist.set(1, TOTAL_BUDGET * 100 / 100); let params = CreateEventParams { pillar: Pillar::Grant, owner: ctx.owner.clone(), @@ -401,7 +414,7 @@ fn create_grant(ctx: &Ctx, n_milestones: u32) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/grant"), title: String::from_str(&ctx.env, "Test Grant"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -415,6 +428,7 @@ fn select_grant_winner(ctx: &Ctx, grant_id: u64, recipient: &Address) { WinnerSpec { recipient: recipient.clone(), position: 1, + amount: TOTAL_BUDGET * 100 / 100, reputation_bump: 0, }, ]; @@ -496,6 +510,7 @@ fn claim_milestone_rejects_non_grant_events() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; @@ -539,7 +554,7 @@ fn claim_milestone_final_milestone_marks_event_completed() { fn create_hackathon(ctx: &Ctx) -> u64 { let mut dist = Map::new(&ctx.env); - dist.set(1, 100); + dist.set(1, TOTAL_BUDGET * 100 / 100); let params = CreateEventParams { pillar: Pillar::Hackathon, owner: ctx.owner.clone(), @@ -549,7 +564,7 @@ fn create_hackathon(ctx: &Ctx) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), title: String::from_str(&ctx.env, "Test Hackathon"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -690,7 +705,7 @@ fn create_event_charges_override_rate_when_provided() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), title: String::from_str(&ctx.env, "Hackathon at 1.5%"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: Some(override_bps), manager: None, }; @@ -723,7 +738,7 @@ fn add_funds_uses_event_override_not_global() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), title: String::from_str(&ctx.env, "Promo Hackathon"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: Some(override_bps), manager: None, }; @@ -769,7 +784,7 @@ fn create_event_with_waiver_charges_no_fee() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), title: String::from_str(&ctx.env, "Comped Hackathon"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: Some(0), manager: None, }; @@ -795,7 +810,7 @@ fn create_event_rejects_override_above_max_fee_bps() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), title: String::from_str(&ctx.env, "Bad rate"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: Some(6000), manager: None, }; @@ -822,7 +837,7 @@ fn create_event_omitted_override_falls_back_to_global_default() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), title: String::from_str(&ctx.env, "Default rate hackathon"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: None, manager: None, }; @@ -851,6 +866,7 @@ fn select_winners_rejects_second_call_winners_already_selected() { WinnerSpec { recipient: r2.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; @@ -919,7 +935,7 @@ fn grant_last_milestone_sweeps_rounding_residue() { // ============================================================ #[test] -fn select_winners_pays_against_remaining_escrow_including_top_ups() { +fn a_top_up_before_selection_enlarges_the_pool_not_the_award() { let ctx = setup(); let bounty_id = create_bounty(&ctx); @@ -940,22 +956,26 @@ fn select_winners_pays_against_remaining_escrow_including_top_ups() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 50, }, ]; let op_select = BytesN::random(&ctx.env); ctx.events.select_winners(&bounty_id, &winners, &op_select); - // Pull model: claim; the pre-selection top-up is in the baseline. ctx.events .claim_prize(&bounty_id, &1_u32, &BytesN::random(&ctx.env)); let token = token::Client::new(&ctx.env, &ctx.token_addr); - assert_eq!(token.balance(&ctx.applicant), TOTAL_BUDGET + top_up); + assert_eq!( + token.balance(&ctx.applicant), + TOTAL_BUDGET, + "the award is what the selection named, regardless of the top-up" + ); let event_post = ctx.events.get_event(&bounty_id); - assert_eq!(event_post.remaining_escrow, 0); - assert_eq!(event_post.status, EventStatus::Completed); + assert_eq!(event_post.remaining_escrow, top_up); + assert_eq!(event_post.status, EventStatus::Active); } // ============================================================ @@ -971,7 +991,7 @@ fn create_bounty_with_manager(ctx: &Ctx, manager: &Address) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/events/draft/m"), title: String::from_str(&ctx.env, "Managed Bounty"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: one_winner_distribution(&ctx.env), + prize_floors: one_winner_distribution(&ctx.env), fee_bps_override: None, manager: Some(manager.clone()), }; @@ -985,6 +1005,7 @@ fn win_one(ctx: &Ctx) -> soroban_sdk::Vec { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ] diff --git a/contracts/events/src/tests/crowdfunding.rs b/contracts/events/src/tests/crowdfunding.rs index 9ff55de..1f3776f 100644 --- a/contracts/events/src/tests/crowdfunding.rs +++ b/contracts/events/src/tests/crowdfunding.rs @@ -71,9 +71,9 @@ fn setup<'a>() -> Ctx<'a> { } } -fn single_dist_100_at_1(env: &Env) -> Map { +fn single_dist_100_at_1(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, 10000000000_i128); m } @@ -87,7 +87,7 @@ fn create_campaign(ctx: &Ctx, milestones: u32) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/cf/1"), title: String::from_str(&ctx.env, "Open-Source Crawler"), deadline: Some(ctx.env.ledger().timestamp() + 30 * 86_400), - winner_distribution: single_dist_100_at_1(&ctx.env), + prize_floors: single_dist_100_at_1(&ctx.env), fee_bps_override: None, manager: None, }; @@ -149,7 +149,7 @@ fn create_rejects_single_release_kind() { content_uri: String::from_str(&ctx.env, "uri"), title: String::from_str(&ctx.env, "Bad CF"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist_100_at_1(&ctx.env), + prize_floors: single_dist_100_at_1(&ctx.env), fee_bps_override: None, manager: None, }; @@ -159,11 +159,11 @@ fn create_rejects_single_release_kind() { } #[test] -fn create_rejects_distribution_with_multiple_positions() { +fn create_rejects_floors_above_the_funding_goal() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 60); - dist.set(2, 40); + dist.set(1, FUNDING_GOAL); + dist.set(2, 1_i128); let params = CreateEventParams { pillar: Pillar::Crowdfunding, owner: ctx.builder.clone(), @@ -173,7 +173,7 @@ fn create_rejects_distribution_with_multiple_positions() { content_uri: String::from_str(&ctx.env, "uri"), title: String::from_str(&ctx.env, "Bad CF"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -392,6 +392,7 @@ fn select_winners_on_crowdfunding_reverts() { let spec = WinnerSpec { recipient: ctx.builder.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 0, }; let mut winners = SorobanVec::new(&ctx.env); diff --git a/contracts/events/src/tests/escrow_fee_math.rs b/contracts/events/src/tests/escrow_fee_math.rs index bb39254..e648c96 100644 --- a/contracts/events/src/tests/escrow_fee_math.rs +++ b/contracts/events/src/tests/escrow_fee_math.rs @@ -72,9 +72,11 @@ fn setup<'a>() -> Ctx<'a> { setup_with_bps(FEE_BPS) } -fn single_dist(env: &Env) -> Map { +fn single_dist(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + // Nominal: a floor is a minimum, so 1 stroop never constrains an + // award. Tests that exercise the floor rule set a real one. + m.set(1, 1_i128); m } @@ -88,7 +90,7 @@ fn create_hackathon(ctx: &Ctx) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/fee-test"), title: String::from_str(&ctx.env, "Fee Math Test"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -106,7 +108,7 @@ fn create_hackathon_with_override(ctx: &Ctx, override_bps: u32) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/override"), title: String::from_str(&ctx.env, "Override BPS Test"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: Some(override_bps), manager: None, }; @@ -114,7 +116,7 @@ fn create_hackathon_with_override(ctx: &Ctx, override_bps: u32) -> u64 { ctx.events.create_event(¶ms, &op) } -fn create_hackathon_with_dist(ctx: &Ctx, dist: Map) -> u64 { +fn create_hackathon_with_dist(ctx: &Ctx, dist: Map) -> u64 { let params = CreateEventParams { pillar: Pillar::Hackathon, owner: ctx.owner.clone(), @@ -124,7 +126,7 @@ fn create_hackathon_with_dist(ctx: &Ctx, dist: Map) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/dist-test"), title: String::from_str(&ctx.env, "Dist Test"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -142,7 +144,7 @@ fn create_grant(ctx: &Ctx, milestones: u32) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/grant"), title: String::from_str(&ctx.env, "Grant Test"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -214,7 +216,7 @@ fn override_bps_above_max_rejected() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/bad"), title: String::from_str(&ctx.env, "Bad Override"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: Some(1001), // MAX_FEE_BPS = 1000 manager: None, }; @@ -252,7 +254,7 @@ fn fee_rounds_down_non_divisible_amount() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/tiny"), title: String::from_str(&ctx.env, "Tiny"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -280,7 +282,7 @@ fn fee_rounding_on_odd_amounts() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/odd"), title: String::from_str(&ctx.env, "Odd"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -373,6 +375,7 @@ fn single_release_pays_full_escrow_for_100_percent() { WinnerSpec { recipient: winner.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50, }, ]; @@ -393,9 +396,9 @@ fn multi_position_split_pays_correct_amounts() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 50); - dist.set(2, 30); - dist.set(3, 20); + dist.set(1, TOTAL_BUDGET * 50 / 100); + dist.set(2, TOTAL_BUDGET * 30 / 100); + dist.set(3, TOTAL_BUDGET * 20 / 100); let id = create_hackathon_with_dist(&ctx, dist); let w1 = Address::generate(&ctx.env); @@ -406,16 +409,19 @@ fn multi_position_split_pays_correct_amounts() { WinnerSpec { recipient: w1.clone(), position: 1, + amount: TOTAL_BUDGET * 50 / 100, reputation_bump: 50 }, WinnerSpec { recipient: w2.clone(), position: 2, + amount: TOTAL_BUDGET * 30 / 100, reputation_bump: 30 }, WinnerSpec { recipient: w3.clone(), position: 3, + amount: TOTAL_BUDGET * 20 / 100, reputation_bump: 20 }, ]; @@ -443,9 +449,9 @@ fn three_way_33_33_34_split_rounding() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 33); - dist.set(2, 33); - dist.set(3, 34); + dist.set(1, TOTAL_BUDGET * 33 / 100); + dist.set(2, TOTAL_BUDGET * 33 / 100); + dist.set(3, TOTAL_BUDGET * 34 / 100); let id = create_hackathon_with_dist(&ctx, dist); let w1 = Address::generate(&ctx.env); @@ -456,16 +462,19 @@ fn three_way_33_33_34_split_rounding() { WinnerSpec { recipient: w1.clone(), position: 1, + amount: TOTAL_BUDGET * 33 / 100, reputation_bump: 50 }, WinnerSpec { recipient: w2.clone(), position: 2, + amount: TOTAL_BUDGET * 33 / 100, reputation_bump: 30 }, WinnerSpec { recipient: w3.clone(), position: 3, + amount: TOTAL_BUDGET * 34 / 100, reputation_bump: 20 }, ]; @@ -493,8 +502,8 @@ fn partial_position_fill_leaves_residual_escrow() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 60); - dist.set(2, 40); + dist.set(1, TOTAL_BUDGET * 60 / 100); + dist.set(2, TOTAL_BUDGET * 40 / 100); let id = create_hackathon_with_dist(&ctx, dist); let w1 = Address::generate(&ctx.env); @@ -503,6 +512,7 @@ fn partial_position_fill_leaves_residual_escrow() { WinnerSpec { recipient: w1.clone(), position: 1, + amount: TOTAL_BUDGET * 60 / 100, reputation_bump: 50 }, ]; @@ -526,7 +536,7 @@ fn partial_position_fill_leaves_residual_escrow() { // ============================================================ #[test] -fn partner_funds_grow_winner_payout() { +fn partner_funds_enlarge_the_pool_without_repricing_awards() { let ctx = setup(); let id = create_hackathon(&ctx); @@ -538,15 +548,17 @@ fn partner_funds_grow_winner_payout() { ctx.events.add_funds(&id, &partner, &contrib, &op_add); let event = ctx.events.get_event(&id); - let escrow_at_select = event.remaining_escrow; - assert_eq!(escrow_at_select, TOTAL_BUDGET + contrib); + assert_eq!(event.remaining_escrow, TOTAL_BUDGET + contrib); + // A top-up has exactly one consequence: the pool is larger. Awards name + // their own amount, so nothing already advertised is silently repriced. let winner = Address::generate(&ctx.env); let winners = soroban_sdk::vec![ &ctx.env, WinnerSpec { recipient: winner.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 50 }, ]; @@ -556,7 +568,111 @@ fn partner_funds_grow_winner_payout() { .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); let token = token::Client::new(&ctx.env, &ctx.token_addr); - assert_eq!(token.balance(&winner), escrow_at_select); + assert_eq!(token.balance(&winner), TOTAL_BUDGET); + assert_eq!( + ctx.events.get_event(&id).remaining_escrow, + contrib, + "the top-up stays in the pool, available to award or reclaim" + ); +} + +#[test] +fn a_topped_up_pool_can_fund_a_position_that_had_no_floor() { + let ctx = setup(); + let id = create_hackathon(&ctx); + + let partner = Address::generate(&ctx.env); + let contrib = 500_0000000_i128; + let fee = contrib * FEE_BPS as i128 / 10_000; + fund(&ctx, &partner, contrib + fee); + ctx.events + .add_funds(&id, &partner, &contrib, &BytesN::random(&ctx.env)); + + let w1 = Address::generate(&ctx.env); + let w2 = Address::generate(&ctx.env); + ctx.events.select_winners( + &id, + &soroban_sdk::vec![ + &ctx.env, + WinnerSpec { + recipient: w1.clone(), + position: 1, + amount: TOTAL_BUDGET, + reputation_bump: 0 + }, + ], + &BytesN::random(&ctx.env), + ); + // Adding a prize after publish: position 2 carries no floor and is paid + // out of the headroom the top-up created. + ctx.events.select_winners( + &id, + &soroban_sdk::vec![ + &ctx.env, + WinnerSpec { + recipient: w2.clone(), + position: 2, + amount: contrib, + reputation_bump: 0 + }, + ], + &BytesN::random(&ctx.env), + ); + + ctx.events + .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); + ctx.events + .claim_prize(&id, &2_u32, &BytesN::random(&ctx.env)); + + let token = token::Client::new(&ctx.env, &ctx.token_addr); + assert_eq!(token.balance(&w1), TOTAL_BUDGET); + assert_eq!(token.balance(&w2), contrib); + assert_eq!(ctx.events.get_event(&id).remaining_escrow, 0); +} + +#[test] +fn second_batch_cannot_promise_funds_an_unclaimed_winner_is_owed() { + let ctx = setup(); + let id = create_hackathon(&ctx); + + let w1 = Address::generate(&ctx.env); + ctx.events.select_winners( + &id, + &soroban_sdk::vec![ + &ctx.env, + WinnerSpec { + recipient: w1.clone(), + position: 1, + amount: TOTAL_BUDGET, + reputation_bump: 0 + }, + ], + &BytesN::random(&ctx.env), + ); + + // remaining_escrow still reads the full budget because w1 has not claimed. + // Without the owed reservation this second batch would be accepted and + // one of the two winners could never be paid. + let w2 = Address::generate(&ctx.env); + let res = ctx.events.try_select_winners( + &id, + &soroban_sdk::vec![ + &ctx.env, + WinnerSpec { + recipient: w2.clone(), + position: 2, + amount: 1_i128, + reputation_bump: 0 + }, + ], + &BytesN::random(&ctx.env), + ); + assert!(res.is_err(), "owed funds must not be promised twice"); + + ctx.events + .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); + let token = token::Client::new(&ctx.env, &ctx.token_addr); + assert_eq!(token.balance(&w1), TOTAL_BUDGET); } // ============================================================ @@ -575,6 +691,7 @@ fn grant_milestone_pays_floored_per_milestone() { WinnerSpec { recipient: recipient.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50 }, ]; @@ -616,6 +733,7 @@ fn grant_milestone_double_claim_rejected() { WinnerSpec { recipient: recipient.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50 }, ]; @@ -644,6 +762,7 @@ fn grant_milestone_out_of_range_rejected() { WinnerSpec { recipient: recipient.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50 }, ]; @@ -667,7 +786,7 @@ fn crowdfunding_dynamic_milestone_split() { let milestones = 3_u32; let mut dist = Map::new(&ctx.env); - dist.set(1, 100); + dist.set(1, TOTAL_BUDGET * 100 / 100); let params = CreateEventParams { pillar: Pillar::Crowdfunding, owner: ctx.owner.clone(), @@ -677,7 +796,7 @@ fn crowdfunding_dynamic_milestone_split() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/crowd"), title: String::from_str(&ctx.env, "Crowd Test"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -725,7 +844,7 @@ fn crowdfunding_dynamic_rounding_no_dust() { let milestones = 3_u32; let mut dist = Map::new(&ctx.env); - dist.set(1, 100); + dist.set(1, TOTAL_BUDGET * 100 / 100); let params = CreateEventParams { pillar: Pillar::Crowdfunding, owner: ctx.owner.clone(), @@ -735,7 +854,7 @@ fn crowdfunding_dynamic_rounding_no_dust() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/dust"), title: String::from_str(&ctx.env, "Dust Test"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -803,7 +922,7 @@ fn replayed_create_event_reverts() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/replay"), title: String::from_str(&ctx.env, "Replay"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -825,6 +944,7 @@ fn replayed_select_winners_reverts() { WinnerSpec { recipient: winner.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50 }, ]; @@ -848,6 +968,7 @@ fn select_winners_on_nonexistent_event_reverts() { WinnerSpec { recipient: winner.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50 }, ]; @@ -860,8 +981,8 @@ fn select_winners_on_nonexistent_event_reverts() { fn select_winners_duplicate_position_reverts() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 50); - dist.set(2, 50); + dist.set(1, TOTAL_BUDGET * 50 / 100); + dist.set(2, TOTAL_BUDGET * 50 / 100); let id = create_hackathon_with_dist(&ctx, dist); let w1 = Address::generate(&ctx.env); @@ -871,11 +992,13 @@ fn select_winners_duplicate_position_reverts() { WinnerSpec { recipient: w1.clone(), position: 1, + amount: TOTAL_BUDGET * 50 / 100, reputation_bump: 50 }, WinnerSpec { recipient: w2.clone(), position: 1, + amount: TOTAL_BUDGET * 50 / 100, reputation_bump: 30 }, ]; @@ -885,9 +1008,9 @@ fn select_winners_duplicate_position_reverts() { } #[test] -fn select_winners_invalid_position_reverts() { +fn select_winners_accepts_a_position_with_no_floor() { let ctx = setup(); - let id = create_hackathon(&ctx); // dist has only position 1 + let id = create_hackathon(&ctx); // floors cover position 1 only let w = Address::generate(&ctx.env); let winners = soroban_sdk::vec![ @@ -895,12 +1018,47 @@ fn select_winners_invalid_position_reverts() { WinnerSpec { recipient: w.clone(), position: 99, + amount: 10_0000000_i128, + reputation_bump: 50 + }, + ]; + let op = BytesN::random(&ctx.env); + ctx.events.select_winners(&id, &winners, &op); + ctx.events + .claim_prize(&id, &99_u32, &BytesN::random(&ctx.env)); + + let token = token::Client::new(&ctx.env, &ctx.token_addr); + assert_eq!( + token.balance(&w), + 10_0000000_i128, + "a position with no floor is payable at any positive amount; this is \ + how a prize is added after publish" + ); +} + +#[test] +fn select_winners_below_floor_reverts() { + let ctx = setup(); + let mut floors = Map::new(&ctx.env); + floors.set(1, TOTAL_BUDGET); + let id = create_hackathon_with_dist(&ctx, floors); + + let w = Address::generate(&ctx.env); + let winners = soroban_sdk::vec![ + &ctx.env, + WinnerSpec { + recipient: w.clone(), + position: 1, + amount: TOTAL_BUDGET - 1, reputation_bump: 50 }, ]; let op = BytesN::random(&ctx.env); let res = ctx.events.try_select_winners(&id, &winners, &op); - assert!(res.is_err(), "position not in distribution must revert"); + assert!( + res.is_err(), + "an award below its advertised floor must revert" + ); } #[test] @@ -925,6 +1083,7 @@ fn select_winners_twice_reverts() { WinnerSpec { recipient: w.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50 }, ]; @@ -959,7 +1118,7 @@ fn large_budget_fee_does_not_overflow() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/big"), title: String::from_str(&ctx.env, "Big"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -1019,6 +1178,7 @@ fn select_winners_on_cancelled_event_reverts() { WinnerSpec { recipient: w.clone(), position: 1, + amount: 1_000_0000000_i128, reputation_bump: 50 }, ]; @@ -1051,12 +1211,15 @@ fn fee_and_winner_balances_consistent() { let escrow = TOTAL_BUDGET + contrib; + // Awarding the whole pool, top-up included, is a deliberate choice the + // selection makes rather than something a percentage does for it. let winner = Address::generate(&ctx.env); let winners = soroban_sdk::vec![ &ctx.env, WinnerSpec { recipient: winner.clone(), position: 1, + amount: escrow, reputation_bump: 50 }, ]; @@ -1066,7 +1229,11 @@ fn fee_and_winner_balances_consistent() { .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); assert_eq!(token.balance(&winner), escrow); - assert_eq!(token.balance(&ctx.fee_account), create_fee + contrib_fee); + assert_eq!( + token.balance(&ctx.fee_account), + create_fee + contrib_fee, + "fees are charged at funding, never at release" + ); let event = ctx.events.get_event(&id); assert_eq!(event.remaining_escrow, 0); diff --git a/contracts/events/src/tests/grant_pillar.rs b/contracts/events/src/tests/grant_pillar.rs index 40db2a0..ad39d68 100644 --- a/contracts/events/src/tests/grant_pillar.rs +++ b/contracts/events/src/tests/grant_pillar.rs @@ -66,9 +66,9 @@ fn setup<'a>() -> Ctx<'a> { } } -fn single_dist(env: &Env) -> Map { +fn single_dist(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, 100000000000_i128); m } @@ -82,7 +82,7 @@ fn create_grant(ctx: &Ctx, n: u32) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/grant/1"), title: String::from_str(&ctx.env, "Test Grant"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -95,6 +95,7 @@ fn select_winner(ctx: &Ctx, id: u64, recipient: &Address) { WinnerSpec { recipient: recipient.clone(), position: 1, + amount: 10_000_0000000_i128, reputation_bump: 0 }, ]; @@ -128,7 +129,7 @@ fn grant_create_with_single_release_reverts() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/grant"), title: String::from_str(&ctx.env, "Bad Grant"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -150,7 +151,7 @@ fn grant_create_with_zero_milestones_reverts() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/grant"), title: String::from_str(&ctx.env, "Zero Milestones"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -310,7 +311,7 @@ fn claim_milestone_on_single_release_reverts() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hack"), title: String::from_str(&ctx.env, "Single Release"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -345,8 +346,8 @@ fn claim_milestone_op_replay_reverts() { fn two_winner_grant_each_claims_their_share() { let ctx = setup(); let mut dist = Map::new(&ctx.env); - dist.set(1, 60); - dist.set(2, 40); + dist.set(1, TOTAL_BUDGET * 60 / 100); + dist.set(2, TOTAL_BUDGET * 40 / 100); let params = CreateEventParams { pillar: Pillar::Grant, owner: ctx.owner.clone(), @@ -356,7 +357,7 @@ fn two_winner_grant_each_claims_their_share() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/multi-grant"), title: String::from_str(&ctx.env, "Multi Winner Grant"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -369,11 +370,13 @@ fn two_winner_grant_each_claims_their_share() { WinnerSpec { recipient: w1.clone(), position: 1, + amount: TOTAL_BUDGET * 60 / 100, reputation_bump: 0 }, WinnerSpec { recipient: w2.clone(), position: 2, + amount: TOTAL_BUDGET * 40 / 100, reputation_bump: 0 }, ]; diff --git a/contracts/events/src/tests/hackathon_pillar.rs b/contracts/events/src/tests/hackathon_pillar.rs index 8b64950..093ce6a 100644 --- a/contracts/events/src/tests/hackathon_pillar.rs +++ b/contracts/events/src/tests/hackathon_pillar.rs @@ -87,21 +87,21 @@ fn expect_op_err( } } -fn single_winner_dist(env: &Env) -> Map { +fn single_winner_dist(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, 100000000000_i128); m } -fn three_way_dist(env: &Env) -> Map { +fn three_way_dist(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 50); - m.set(2, 30); - m.set(3, 20); + m.set(1, 50000000000_i128); + m.set(2, 30000000000_i128); + m.set(3, 20000000000_i128); m } -fn create_hackathon_with(ctx: &Ctx, dist: Map, deadline: Option) -> u64 { +fn create_hackathon_with(ctx: &Ctx, dist: Map, deadline: Option) -> u64 { let params = CreateEventParams { pillar: Pillar::Hackathon, owner: ctx.owner.clone(), @@ -111,7 +111,7 @@ fn create_hackathon_with(ctx: &Ctx, dist: Map, deadline: Option) content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), title: String::from_str(&ctx.env, "Test Hackathon"), deadline, - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; @@ -163,7 +163,7 @@ fn create_rejects_multi_release_kind() { content_uri: String::from_str(&ctx.env, "uri"), title: String::from_str(&ctx.env, "Bad Hackathon"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_winner_dist(&ctx.env), + prize_floors: single_winner_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -401,6 +401,7 @@ fn select_winners_single_recipient_sweeps_escrow() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 50, }, ]; @@ -454,16 +455,19 @@ fn select_winners_multi_position_splits_by_distribution() { WinnerSpec { recipient: first.clone(), position: 1, + amount: TOTAL_BUDGET * 50 / 100, reputation_bump: 60, }, WinnerSpec { recipient: second.clone(), position: 2, + amount: TOTAL_BUDGET * 30 / 100, reputation_bump: 40, }, WinnerSpec { recipient: third.clone(), position: 3, + amount: TOTAL_BUDGET * 20 / 100, reputation_bump: 20, }, ]; @@ -516,21 +520,25 @@ fn select_winners_empty_set_reverts() { } #[test] -fn select_winners_position_not_in_distribution_reverts() { +fn select_winners_allows_a_position_with_no_floor() { let ctx = setup(); - let id = create_hackathon(&ctx); // distribution only has position 1 + let id = create_hackathon(&ctx); // floors cover position 1 only let winners = soroban_sdk::vec![ &ctx.env, WinnerSpec { recipient: ctx.applicant.clone(), position: 2, + amount: 1_0000000_i128, reputation_bump: 0, }, ]; let op = BytesN::random(&ctx.env); - let res = ctx.events.try_select_winners(&id, &winners, &op); - assert!(res.is_err(), "position outside distribution must revert"); + ctx.events.select_winners(&id, &winners, &op); + + let rows = ctx.events.get_winners(&id); + assert_eq!(rows.len(), 1); + assert_eq!(rows.get(0).unwrap().position, 2); } #[test] @@ -545,11 +553,13 @@ fn select_winners_duplicate_position_reverts() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET * 50 / 100, reputation_bump: 0, }, WinnerSpec { recipient: other, position: 1, // duplicate + amount: TOTAL_BUDGET * 50 / 100, reputation_bump: 0, }, ]; @@ -569,6 +579,7 @@ fn select_winners_batches_append_and_position_replay_reverts() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET * 50 / 100, reputation_bump: 0, }, ]; @@ -584,6 +595,7 @@ fn select_winners_batches_append_and_position_replay_reverts() { WinnerSpec { recipient: usurper, position: 1, + amount: TOTAL_BUDGET * 50 / 100, reputation_bump: 0, }, ]; @@ -601,11 +613,13 @@ fn select_winners_batches_append_and_position_replay_reverts() { WinnerSpec { recipient: second.clone(), position: 2, + amount: TOTAL_BUDGET * 30 / 100, reputation_bump: 0, }, WinnerSpec { recipient: third.clone(), position: 3, + amount: TOTAL_BUDGET * 20 / 100, reputation_bump: 0, }, ]; @@ -636,6 +650,7 @@ fn select_winners_replayed_op_reverts() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; @@ -654,6 +669,7 @@ fn select_winners_on_missing_event_reverts() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; @@ -672,6 +688,7 @@ fn select_winners_on_completed_event_reverts() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; @@ -689,6 +706,7 @@ fn select_winners_on_completed_event_reverts() { WinnerSpec { recipient: again, position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; @@ -710,6 +728,7 @@ fn select_winners_demands_owner_auth() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: TOTAL_BUDGET, reputation_bump: 0, }, ]; diff --git a/contracts/events/src/tests/op_id_security.rs b/contracts/events/src/tests/op_id_security.rs index 87fe2be..1aaa682 100644 --- a/contracts/events/src/tests/op_id_security.rs +++ b/contracts/events/src/tests/op_id_security.rs @@ -76,9 +76,9 @@ fn setup<'a>() -> Ctx<'a> { } } -fn dist_100(env: &Env) -> Map { +fn dist_100(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, 100000000000_i128); m } @@ -92,7 +92,7 @@ fn create_bounty(ctx: &Ctx) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/events/op-id-sec"), title: String::from_str(&ctx.env, "OpId Security"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist_100(&ctx.env), + prize_floors: dist_100(&ctx.env), fee_bps_override: None, manager: None, }; @@ -153,6 +153,7 @@ fn bootstrap_self_cannot_front_run_events_child_op_ids() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: 10_000_0000000_i128, reputation_bump: 50, }, ]; @@ -236,7 +237,7 @@ fn event_id_overflow_reverts() { content_uri: String::from_str(env, "https://api.boundless.fi/events/overflow"), title: String::from_str(env, "Overflow"), deadline: Some(env.ledger().timestamp() + 86_400), - winner_distribution: dist_100(env), + prize_floors: dist_100(env), fee_bps_override: None, manager: None, }; @@ -295,6 +296,7 @@ fn permissionless_apply_cannot_squat_select_winners_op_id() { WinnerSpec { recipient: ctx.applicant.clone(), position: 1, + amount: 10_000_0000000_i128, reputation_bump: 0, }, ]; diff --git a/contracts/events/src/tests/prize_claim.rs b/contracts/events/src/tests/prize_claim.rs index 79f04f4..3288dd4 100644 --- a/contracts/events/src/tests/prize_claim.rs +++ b/contracts/events/src/tests/prize_claim.rs @@ -71,7 +71,7 @@ fn setup<'a>() -> Ctx<'a> { } } -fn create_single(ctx: &Ctx, dist: Map) -> u64 { +fn create_single(ctx: &Ctx, dist: Map) -> u64 { let params = CreateEventParams { pillar: Pillar::Hackathon, owner: ctx.owner.clone(), @@ -81,32 +81,33 @@ fn create_single(ctx: &Ctx, dist: Map) -> u64 { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/prize-claim"), title: String::from_str(&ctx.env, "Prize Claim Suite"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: dist, + prize_floors: dist, fee_bps_override: None, manager: None, }; ctx.events.create_event(¶ms, &BytesN::random(&ctx.env)) } -fn dist_100(env: &Env) -> Map { +fn dist_100(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, TOTAL_BUDGET); m } -fn dist_60_40(env: &Env) -> Map { +fn dist_60_40(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 60); - m.set(2, 40); + m.set(1, TOTAL_BUDGET * 60 / 100); + m.set(2, TOTAL_BUDGET * 40 / 100); m } -fn select_one(ctx: &Ctx, id: u64, recipient: &Address, position: u32, bump: u32) { +fn select_one(ctx: &Ctx, id: u64, recipient: &Address, position: u32, amount: i128, bump: u32) { let winners = soroban_sdk::vec![ &ctx.env, WinnerSpec { recipient: recipient.clone(), position, + amount, reputation_bump: bump, }, ]; @@ -125,7 +126,7 @@ fn claim_pays_recipient_full_prize_and_no_release_fee() { let token = token::Client::new(&ctx.env, &ctx.token_addr); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 50); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET, 50); // Selection alone must not move funds or complete the event. assert_eq!(token.balance(&w), 0); @@ -167,8 +168,8 @@ fn split_claims_pay_exact_amounts_each() { let a = Address::generate(&ctx.env); let b = Address::generate(&ctx.env); - select_one(&ctx, id, &a, 1, 10); - select_one(&ctx, id, &b, 2, 5); + select_one(&ctx, id, &a, 1, TOTAL_BUDGET * 60 / 100, 10); + select_one(&ctx, id, &b, 2, TOTAL_BUDGET * 40 / 100, 5); ctx.events .claim_prize(&id, &2_u32, &BytesN::random(&ctx.env)); @@ -193,7 +194,7 @@ fn topup_after_selection_stays_residual_for_refund() { let token_admin = token::StellarAssetClient::new(&ctx.env, &ctx.token_addr); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 0); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET, 0); // A partner tops up AFTER selection: the prize amount stays anchored // to the selection-time baseline; the top-up is refundable residual. @@ -227,7 +228,7 @@ fn claim_requires_recipient_auth() { let id = create_single(&ctx, dist_100(&ctx.env)); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 0); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET, 0); ctx.env.mock_auths(&[]); let res = ctx @@ -242,7 +243,7 @@ fn claim_demands_the_award_recipients_auth_specifically() { let id = create_single(&ctx, dist_100(&ctx.env)); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 0); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET, 0); ctx.events .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); @@ -264,7 +265,7 @@ fn double_claim_reverts() { let id = create_single(&ctx, dist_60_40(&ctx.env)); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 0); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET * 60 / 100, 0); ctx.events .claim_prize(&id, &1_u32, &BytesN::random(&ctx.env)); @@ -287,8 +288,8 @@ fn op_id_replay_reverts() { // not yet paid. (Cross-recipient op_id reuse is intentionally allowed — // OpSeen is namespaced per authorizing caller.) let a = Address::generate(&ctx.env); - select_one(&ctx, id, &a, 1, 0); - select_one(&ctx, id, &a, 2, 0); + select_one(&ctx, id, &a, 1, TOTAL_BUDGET * 60 / 100, 0); + select_one(&ctx, id, &a, 2, TOTAL_BUDGET * 40 / 100, 0); let op = BytesN::random(&ctx.env); ctx.events.claim_prize(&id, &1_u32, &op); @@ -305,7 +306,7 @@ fn claim_of_unawarded_position_reverts() { let id = create_single(&ctx, dist_60_40(&ctx.env)); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 0); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET * 60 / 100, 0); // Position 2 is in the distribution but has no award yet. let res = ctx @@ -332,14 +333,14 @@ fn claim_on_multi_release_event_reverts() { content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/grant"), title: String::from_str(&ctx.env, "Grant"), deadline: None, - winner_distribution: dist_100(&ctx.env), + prize_floors: dist_100(&ctx.env), fee_bps_override: None, manager: None, }; let id = ctx.events.create_event(¶ms, &BytesN::random(&ctx.env)); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 0); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET, 0); let res = ctx .events @@ -360,7 +361,7 @@ fn cancel_blocked_while_unclaimed_prizes_within_window() { let id = create_single(&ctx, dist_60_40(&ctx.env)); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 0); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET * 60 / 100, 0); let res = ctx.events.try_start_cancel(&id, &BytesN::random(&ctx.env)); assert!( @@ -387,7 +388,7 @@ fn cancel_after_window_expiry_sweeps_unclaimed_and_blocks_late_claim() { let token = token::Client::new(&ctx.env, &ctx.token_addr); let w = Address::generate(&ctx.env); - select_one(&ctx, id, &w, 1, 0); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET, 0); ctx.env.ledger().with_mut(|li| { li.timestamp += PRIZE_CLAIM_WINDOW_SECS + 1; @@ -414,14 +415,14 @@ fn claim_window_refreshes_on_a_later_batch() { let id = create_single(&ctx, dist_60_40(&ctx.env)); let a = Address::generate(&ctx.env); - select_one(&ctx, id, &a, 1, 0); + select_one(&ctx, id, &a, 1, TOTAL_BUDGET * 60 / 100, 0); // Move to just before the first window expires, then select batch 2. ctx.env.ledger().with_mut(|li| { li.timestamp += PRIZE_CLAIM_WINDOW_SECS - 100; }); let b = Address::generate(&ctx.env); - select_one(&ctx, id, &b, 2, 0); + select_one(&ctx, id, &b, 2, TOTAL_BUDGET * 40 / 100, 0); // Past the FIRST batch's expiry, but inside the refreshed window: // cancel stays blocked, protecting the late-selected winner. diff --git a/contracts/events/src/tests/token_whitelist.rs b/contracts/events/src/tests/token_whitelist.rs index f8736ca..4e2b20d 100644 --- a/contracts/events/src/tests/token_whitelist.rs +++ b/contracts/events/src/tests/token_whitelist.rs @@ -37,9 +37,9 @@ fn new_token(env: &Env) -> Address { env.register_stellar_asset_contract_v2(issuer).address() } -fn single_dist(env: &Env) -> Map { +fn single_dist(env: &Env) -> Map { let mut m = Map::new(env); - m.set(1, 100); + m.set(1, 10000000000_i128); m } @@ -122,7 +122,7 @@ fn create_event_with_unsupported_token_reverts() { content_uri: String::from_str(&ctx.env, "https://example.com"), title: String::from_str(&ctx.env, "Bad Token Hack"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; @@ -151,7 +151,7 @@ fn create_event_with_deregistered_token_reverts() { content_uri: String::from_str(&ctx.env, "https://example.com"), title: String::from_str(&ctx.env, "Deregistered Token Hack"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), - winner_distribution: single_dist(&ctx.env), + prize_floors: single_dist(&ctx.env), fee_bps_override: None, manager: None, }; diff --git a/contracts/events/src/types.rs b/contracts/events/src/types.rs index fab766a..8a5d6a3 100644 --- a/contracts/events/src/types.rs +++ b/contracts/events/src/types.rs @@ -73,7 +73,11 @@ pub struct EventRecord { pub title: String, pub created_at: u64, pub deadline: Option, - pub winner_distribution: Map, + /// Advertised minimum per position, in token-native units. `select_winners` + /// may pay above a floor but never below it, so a published prize table is + /// a guarantee rather than an estimate. Positions absent from the map carry + /// no floor and are payable at any positive amount. + pub prize_floors: Map, pub fee_bps_override: Option, } @@ -91,7 +95,7 @@ pub struct CreateEventParams { pub content_uri: String, pub title: String, pub deadline: Option, - pub winner_distribution: Map, + pub prize_floors: Map, pub fee_bps_override: Option, pub manager: Option
, } @@ -139,6 +143,10 @@ pub struct Winner { pub struct WinnerSpec { pub recipient: Address, pub position: u32, + /// Award amount in token-native units. Allocation happens here, at payout, + /// not at create: the event holds a pool and each selection names what it + /// spends from it. + pub amount: i128, pub reputation_bump: u32, } @@ -209,6 +217,11 @@ pub enum DataKey { // Appended to cap per-event submission storage growth (security fix). EventSubmissionCount(u64), + + // Appended in 1.7.0. Sum of awarded-but-unclaimed prizes. `remaining_escrow` + // only drops at claim time, so without this a second selection would see + // funds an earlier winner is still entitled to and could promise them twice. + EventOwedTotal(u64), } // ============================================================ From 8cd7d2d5581ce57286f17894daba4914e92fbc2f Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Mon, 17 Aug 2026 23:55:25 +0100 Subject: [PATCH 02/20] feat(events): migrate legacy records to prize floors and bump to 1.7.0 The record layout change is not backward compatible: winner_distribution and prize_floors differ in both field name and value type, so a row written before 1.7.0 cannot be decoded by the current struct at all. migrate() reads each stored event through the legacy shape and rewrites it, converting percentages with total_budget * percent / 100, which is exactly what each position would have been paid. This is only affordable because mainnet holds two events, both Completed with zero remaining escrow, so neither can re-enter select_winners. The same rewrite after a real campaign funds escrow would be a production migration on the money path. Bounded by the id counter rather than scanning blindly; the row cap is a backstop against a corrupt counter, not an expected limit. A fresh deployment has no rows and the pass is a no-op. Closes #115, #116 --- contracts/events/src/admin.rs | 90 +++++++++++++++++++++++++- contracts/events/src/lib.rs | 2 +- contracts/events/src/tests/admin.rs | 97 +++++++++++++++++++++++++++-- 3 files changed, 180 insertions(+), 9 deletions(-) diff --git a/contracts/events/src/admin.rs b/contracts/events/src/admin.rs index ff672b5..6140731 100644 --- a/contracts/events/src/admin.rs +++ b/contracts/events/src/admin.rs @@ -1,9 +1,34 @@ -use soroban_sdk::{panic_with_error, Address, BytesN, Env, String}; +use soroban_sdk::{contracttype, panic_with_error, Address, BytesN, Env, Map, String}; use crate::errors::Error; use crate::events as evt; +use crate::idempotency; use crate::storage; -use crate::types::{PendingAdmin, PendingUpgrade}; +use crate::types::{ + DataKey, EventRecord, EventStatus, PendingAdmin, PendingUpgrade, Pillar, ReleaseKind, +}; + +/// The pre-1.7.0 `EventRecord`, kept only so `migrate` can decode rows written +/// before prize floors replaced the percentage distribution. Nothing else may +/// read or write this shape. +#[contracttype] +#[derive(Clone)] +struct LegacyEventRecord { + pub id: u64, + pub pillar: Pillar, + pub owner: Address, + pub token: Address, + pub total_budget: i128, + pub remaining_escrow: i128, + pub release_kind: ReleaseKind, + pub status: EventStatus, + pub content_uri: String, + pub title: String, + pub created_at: u64, + pub deadline: Option, + pub winner_distribution: Map, + pub fee_bps_override: Option, +} const PENDING_ADMIN_TTL_LEDGERS: u32 = 120_960; @@ -15,7 +40,7 @@ const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; -pub const INITIAL_VERSION: &str = "1.6.0"; +pub const INITIAL_VERSION: &str = "1.7.0"; // ============================================================ // INITIALIZATION @@ -250,6 +275,9 @@ pub fn migrate(env: &Env) -> Result<(), Error> { // ============================================================ // PER-(from -> to) MIGRATION DISPATCH // ============================================================ + if current == String::from_str(env, INITIAL_VERSION) { + migrate_prize_floors(env); + } storage::set_migrated_to_version(env, ¤t); storage::touch_instance(env); @@ -261,6 +289,62 @@ pub fn migrate(env: &Env) -> Result<(), Error> { Ok(()) } +/// Rewrites every stored event from the pre-1.7.0 percentage layout to prize +/// floors. `winner_distribution` and `prize_floors` differ in both name and +/// value type, so an old row cannot be decoded by the current struct at all +/// and has to be read through the legacy shape first. +/// +/// Percentages were always taken against the escrow balance, so `total_budget * +/// percent / 100` reproduces exactly what each position would have been paid. +/// +/// Bounded by the id counter: ids run from `id_base + 1` up to the next id to +/// be issued, and the cap is a backstop against a corrupt counter rather than +/// an expected limit. +fn migrate_prize_floors(env: &Env) { + const MAX_ROWS: u64 = 256; + + let base = idempotency::id_base(env); + let next = storage::get_next_event_id(env, base.saturating_add(1)); + let mut id = base.saturating_add(1); + let mut scanned: u64 = 0; + + while id < next && scanned < MAX_ROWS { + let key = DataKey::Event(id); + let legacy: Option = env.storage().persistent().get(&key); + if let Some(old) = legacy { + let mut floors: Map = Map::new(env); + for (position, percent) in old.winner_distribution.iter() { + let floor = old + .total_budget + .saturating_mul(percent as i128) + .saturating_div(100); + if floor > 0 { + floors.set(position, floor); + } + } + let migrated = EventRecord { + id: old.id, + pillar: old.pillar, + owner: old.owner, + token: old.token, + total_budget: old.total_budget, + remaining_escrow: old.remaining_escrow, + release_kind: old.release_kind, + status: old.status, + content_uri: old.content_uri, + title: old.title, + created_at: old.created_at, + deadline: old.deadline, + prize_floors: floors, + fee_bps_override: old.fee_bps_override, + }; + env.storage().persistent().set(&key, &migrated); + } + id = id.saturating_add(1); + scanned = scanned.saturating_add(1); + } +} + // ============================================================ // READS // ============================================================ diff --git a/contracts/events/src/lib.rs b/contracts/events/src/lib.rs index 2011462..2061f07 100644 --- a/contracts/events/src/lib.rs +++ b/contracts/events/src/lib.rs @@ -24,7 +24,7 @@ mod tests; use crate::errors::Error; use crate::types::*; -contractmeta!(key = "version", val = "1.6.0"); +contractmeta!(key = "version", val = "1.7.0"); contractmeta!( key = "description", val = "Boundless events contract: hackathon, bounty, grant + escrow" diff --git a/contracts/events/src/tests/admin.rs b/contracts/events/src/tests/admin.rs index 4199fd8..9b76d63 100644 --- a/contracts/events/src/tests/admin.rs +++ b/contracts/events/src/tests/admin.rs @@ -2,11 +2,12 @@ use soroban_sdk::{ testutils::{Address as _, BytesN as _, Ledger}, - Address, BytesN, String, + Address, BytesN, Map, String, }; use super::common::setup; use crate::errors::Error; +use crate::types::{DataKey, EventStatus, Pillar, ReleaseKind}; const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; @@ -19,7 +20,7 @@ fn initializes_with_expected_config() { assert_eq!(ctx.client.get_fee_bps(), 250); assert_eq!(ctx.client.get_profile_contract(), ctx.profile_contract); assert_eq!(ctx.client.is_paused(), false); - assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.6.0")); + assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.7.0")); assert_eq!(ctx.client.get_pending_upgrade(), None); assert_eq!(ctx.client.get_migrated_to_version(), None); } @@ -96,7 +97,7 @@ fn apply_upgrade_before_timelock_reverts() { .expect("timelock blocks") .unwrap(); assert_eq!(err, Error::UpgradeTimelockNotElapsed); - assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.6.0")); + assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.7.0")); } #[test] @@ -130,7 +131,7 @@ fn cancel_pending_upgrade_clears_proposal() { ctx.client.cancel_pending_upgrade(); assert_eq!(ctx.client.get_pending_upgrade(), None); - assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.6.0")); + assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.7.0")); } #[test] @@ -145,6 +146,92 @@ fn cancel_with_no_pending_reverts() { assert_eq!(err, Error::UpgradeNotProposed); } +/// The pre-1.7.0 record layout, written directly into storage so migrate has a +/// legacy row to convert. Mirrors what the two mainnet events look like. +#[soroban_sdk::contracttype] +#[derive(Clone)] +struct LegacyEventRecord { + pub id: u64, + pub pillar: Pillar, + pub owner: Address, + pub token: Address, + pub total_budget: i128, + pub remaining_escrow: i128, + pub release_kind: ReleaseKind, + pub status: EventStatus, + pub content_uri: String, + pub title: String, + pub created_at: u64, + pub deadline: Option, + pub winner_distribution: Map, + pub fee_bps_override: Option, +} + +#[test] +fn migrate_rewrites_legacy_percentages_as_prize_floors() { + let ctx = setup(250); + let budget = 1_000_0000000_i128; + + // Stand in for mainnet event ...610: a 60/40 split, already settled. + let mut dist = Map::new(&ctx.env); + dist.set(1, 60_u32); + dist.set(2, 40_u32); + + let id_base = ctx.client.id_base(); + let event_id = id_base + 1; + let legacy = LegacyEventRecord { + id: event_id, + pillar: Pillar::Bounty, + owner: Address::generate(&ctx.env), + token: Address::generate(&ctx.env), + total_budget: budget, + remaining_escrow: 0, + release_kind: ReleaseKind::Single, + status: EventStatus::Completed, + content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/legacy"), + title: String::from_str(&ctx.env, "Muwa Creator Bounty"), + created_at: 1, + deadline: None, + winner_distribution: dist, + fee_bps_override: None, + }; + + ctx.env.as_contract(&ctx.client.address, || { + ctx.env + .storage() + .persistent() + .set(&DataKey::Event(event_id), &legacy); + ctx.env + .storage() + .instance() + .set(&DataKey::NextEventId, &(event_id + 1)); + }); + + ctx.client.migrate(); + + // Readable again through the current struct, which could not decode it + // before, and the percentages have become the amounts they always meant. + let migrated = ctx.client.get_event(&event_id); + assert_eq!(migrated.prize_floors.get(1), Some(budget * 60 / 100)); + assert_eq!(migrated.prize_floors.get(2), Some(budget * 40 / 100)); + assert_eq!(migrated.total_budget, budget); + assert_eq!(migrated.status, EventStatus::Completed); + assert_eq!( + migrated.title, + String::from_str(&ctx.env, "Muwa Creator Bounty") + ); +} + +#[test] +fn migrate_is_a_no_op_on_a_fresh_deployment() { + let ctx = setup(250); + ctx.client.migrate(); + assert_eq!( + ctx.client.get_migrated_to_version(), + Some(String::from_str(&ctx.env, "1.7.0")) + ); +} + #[test] fn migrate_marks_current_version_and_blocks_replay() { let ctx = setup(250); @@ -152,7 +239,7 @@ fn migrate_marks_current_version_and_blocks_replay() { ctx.client.migrate(); assert_eq!( ctx.client.get_migrated_to_version(), - Some(String::from_str(&ctx.env, "1.6.0")) + Some(String::from_str(&ctx.env, "1.7.0")) ); let err = ctx From f6e13803be2fdccad1c15d8d9bbb61895bb27c66 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Tue, 18 Aug 2026 00:14:16 +0100 Subject: [PATCH 03/20] feat(events): give submissions a caller-chosen slot One wallet could hold exactly one submission per event, because the storage key was (event, applicant). That is fine for a bounty asking for one piece of work and wrong for any event that wants two different things from the same person. Submissions now key on (event, applicant, slot). The contract assigns the slot no meaning. It is a caller-chosen u32, so whatever separates entries off chain (a track, a category, a round) needs no further contract change to express. Re-submitting to an occupied slot still updates in place and keeps the original timestamp; a fresh slot appends. A per-applicant counter keeps "has this wallet submitted at all" O(1), which is what gates application withdrawal. Without it that gate would have to scan slots, and it would have silently regressed to checking slot 0 only. The old key is kept in DataKey as read-only so migrate can move each historical row into slot 0 and drop it. Skipping that would leave the two settled mainnet events' submissions occupying storage with no read path able to reach them: the applicant index bounds the scan, and mainnet holds six applicants across both events. Closes #117 --- contracts/events/src/admin.rs | 23 +++ contracts/events/src/bounty.rs | 2 +- contracts/events/src/event_ops.rs | 31 ++-- contracts/events/src/lib.rs | 17 +- contracts/events/src/storage.rs | 92 +++++++--- contracts/events/src/tests/admin.rs | 73 +++++++- contracts/events/src/tests/bounty_pillar.rs | 164 +++++++++++++++++- contracts/events/src/tests/cross_contract.rs | 39 +++-- contracts/events/src/tests/crowdfunding.rs | 2 +- .../events/src/tests/hackathon_pillar.rs | 67 ++++--- contracts/events/src/types.rs | 8 + 11 files changed, 443 insertions(+), 75 deletions(-) diff --git a/contracts/events/src/admin.rs b/contracts/events/src/admin.rs index 6140731..2835e95 100644 --- a/contracts/events/src/admin.rs +++ b/contracts/events/src/admin.rs @@ -340,11 +340,34 @@ fn migrate_prize_floors(env: &Env) { }; env.storage().persistent().set(&key, &migrated); } + migrate_submissions_to_slots(env, id); id = id.saturating_add(1); scanned = scanned.saturating_add(1); } } +/// Moves each pre-1.7.0 submission to slot 0 of the slotted key and seeds the +/// per-applicant counter. Without this the re-key would orphan every historical +/// submission: the old rows would still occupy storage but no read path could +/// reach them. +/// +/// The applicant index bounds the work, so this only touches wallets the event +/// already knows about. +fn migrate_submissions_to_slots(env: &Env, event_id: u64) { + let applicants = storage::applicant_count(env, event_id); + for idx in 0..applicants { + let applicant = match storage::applicant_at(env, event_id, idx) { + Some(a) => a, + None => continue, + }; + if let Some(legacy) = storage::get_legacy_submission(env, event_id, &applicant) { + storage::set_submission(env, event_id, &applicant, 0, &legacy); + storage::seed_applicant_submission_count(env, event_id, &applicant, 1); + storage::remove_legacy_submission(env, event_id, &applicant); + } + } +} + // ============================================================ // READS // ============================================================ diff --git a/contracts/events/src/bounty.rs b/contracts/events/src/bounty.rs index 428a58b..da3d6a5 100644 --- a/contracts/events/src/bounty.rs +++ b/contracts/events/src/bounty.rs @@ -65,7 +65,7 @@ pub fn withdraw_application( applicant.require_auth(); idempotency::require_unseen(env, &applicant, &op_id)?; - if storage::get_submission(env, bounty_id, &applicant).is_some() { + if storage::has_any_submission(env, bounty_id, &applicant) { return Err(Error::SubmissionAlreadyExists); } diff --git a/contracts/events/src/event_ops.rs b/contracts/events/src/event_ops.rs index ed3dc10..b0ee84b 100644 --- a/contracts/events/src/event_ops.rs +++ b/contracts/events/src/event_ops.rs @@ -544,6 +544,7 @@ pub fn submit( env: &Env, event_id: u64, applicant: Address, + slot: u32, content_uri: String, op_id: BytesN<32>, ) -> Result<(), Error> { @@ -566,20 +567,20 @@ pub fn submit( return Err(Error::TitleTooLong); } - let existing = storage::get_submission(env, event_id, &applicant); + let existing = storage::get_submission(env, event_id, &applicant, slot); - if existing.is_none() { + if !storage::has_any_submission(env, event_id, &applicant) { let needs_application = matches!(event.pillar, Pillar::Bounty | Pillar::Grant); if needs_application && storage::applicant_slot(env, event_id, &applicant) == 0 { return Err(Error::ApplicantNotApplied); } } - // Count the submission before writing. There is no cap: each submission - // is its own ledger entry whose write and rent are paid by the - // submitter's transaction, so spam addresses fund their own storage and - // cannot lock real participants out of a full event. - storage::append_submission(env, event_id, &applicant)?; + // Count the slot before writing. There is no cap: each entry is its own + // ledger entry whose write and rent are paid by the submitter's + // transaction, so spam addresses fund their own storage and cannot lock + // real participants out of a full event. + storage::append_submission(env, event_id, &applicant, slot)?; let submitted_at = existing .as_ref() @@ -591,7 +592,7 @@ pub fn submit( content_uri: content_uri.clone(), submitted_at, }; - storage::set_submission(env, event_id, &applicant, &submission); + storage::set_submission(env, event_id, &applicant, slot, &submission); evt::Submitted { event_id, @@ -611,6 +612,7 @@ pub fn withdraw_submission( env: &Env, event_id: u64, applicant: Address, + slot: u32, op_id: BytesN<32>, ) -> Result<(), Error> { admin::require_not_paused(env)?; @@ -623,11 +625,11 @@ pub fn withdraw_submission( applicant.require_auth(); idempotency::require_unseen(env, &applicant, &op_id)?; - if storage::get_submission(env, event_id, &applicant).is_none() { + if storage::get_submission(env, event_id, &applicant, slot).is_none() { return Err(Error::SubmissionNotFound); } - storage::remove_submission(env, event_id, &applicant); + storage::remove_submission(env, event_id, &applicant, slot); evt::SubmissionWithdrawn { event_id, @@ -941,8 +943,13 @@ pub fn get_event(env: &Env, event_id: u64) -> Result { storage::get_event(env, event_id).ok_or(Error::EventNotFound) } -pub fn get_submission(env: &Env, event_id: u64, applicant: Address) -> Result { - storage::get_submission(env, event_id, &applicant).ok_or(Error::SubmissionNotFound) +pub fn get_submission( + env: &Env, + event_id: u64, + applicant: Address, + slot: u32, +) -> Result { + storage::get_submission(env, event_id, &applicant, slot).ok_or(Error::SubmissionNotFound) } // Full-list getters return the first VIEW_PAGE_LIMIT entries; use the diff --git a/contracts/events/src/lib.rs b/contracts/events/src/lib.rs index 2061f07..9b48659 100644 --- a/contracts/events/src/lib.rs +++ b/contracts/events/src/lib.rs @@ -185,23 +185,28 @@ impl EventsContract { // ============================================================ // SUBMISSION // ============================================================ + /// `slot` separates several entries by the same wallet in one event. The + /// contract assigns it no meaning; re-submitting to an occupied slot + /// updates it in place. Callers with a single entry use slot 0. pub fn submit( env: Env, event_id: u64, applicant: Address, + slot: u32, content_uri: String, op_id: BytesN<32>, ) -> Result<(), Error> { - event_ops::submit(&env, event_id, applicant, content_uri, op_id) + event_ops::submit(&env, event_id, applicant, slot, content_uri, op_id) } pub fn withdraw_submission( env: Env, event_id: u64, applicant: Address, + slot: u32, op_id: BytesN<32>, ) -> Result<(), Error> { - event_ops::withdraw_submission(&env, event_id, applicant, op_id) + event_ops::withdraw_submission(&env, event_id, applicant, slot, op_id) } // ============================================================ @@ -270,8 +275,14 @@ impl EventsContract { env: Env, event_id: u64, applicant: Address, + slot: u32, ) -> Result { - event_ops::get_submission(&env, event_id, applicant) + event_ops::get_submission(&env, event_id, applicant, slot) + } + + /// How many slots this applicant occupies in this event. + pub fn get_applicant_submission_count(env: Env, event_id: u64, applicant: Address) -> u32 { + storage::applicant_submission_count(&env, event_id, &applicant) } // Full-list getters return the first page (VIEW_PAGE_LIMIT entries); diff --git a/contracts/events/src/storage.rs b/contracts/events/src/storage.rs index b7fb4b5..00deb3d 100644 --- a/contracts/events/src/storage.rs +++ b/contracts/events/src/storage.rs @@ -419,8 +419,8 @@ pub fn applicants_snapshot(env: &Env, id: u64, start: u32, limit: u32) -> Vec Option { - let key = DataKey::EventSubmission(id, applicant.clone()); +pub fn get_submission(env: &Env, id: u64, applicant: &Address, slot: u32) -> Option { + let key = DataKey::EventSubmissionEntry(id, applicant.clone(), slot); let s: Option = env.storage().persistent().get(&key); if s.is_some() { touch_event_persistent(env, &key); @@ -428,24 +428,51 @@ pub fn get_submission(env: &Env, id: u64, applicant: &Address) -> Option u32 { + let key = DataKey::EventApplicantSubmissionCount(id, applicant.clone()); + let n: Option = env.storage().persistent().get(&key); + if n.is_some() { + touch_event_persistent(env, &key); + } + n.unwrap_or(0) +} + +pub fn has_any_submission(env: &Env, id: u64, applicant: &Address) -> bool { + applicant_submission_count(env, id, applicant) > 0 +} + +fn set_applicant_submission_count(env: &Env, id: u64, applicant: &Address, count: u32) { + let key = DataKey::EventApplicantSubmissionCount(id, applicant.clone()); + if count == 0 { + env.storage().persistent().remove(&key); return; } + env.storage().persistent().set(&key, &count); + touch_event_persistent(env, &key); +} - let key = DataKey::EventSubmission(id, applicant.clone()); +pub fn remove_submission(env: &Env, id: u64, applicant: &Address, slot: u32) { + // Idempotent: a no-op when the slot is empty, so a caller that skips its + // own existence check cannot corrupt either counter. + if get_submission(env, id, applicant, slot).is_none() { + return; + } + + let key = DataKey::EventSubmissionEntry(id, applicant.clone(), slot); env.storage().persistent().remove(&key); + let per_applicant = applicant_submission_count(env, id, applicant).saturating_sub(1); + set_applicant_submission_count(env, id, applicant, per_applicant); + let count_key = DataKey::EventSubmissionCount(id); let next = submission_count(env, id).saturating_sub(1); if next == 0 { @@ -465,16 +492,15 @@ pub fn submission_count(env: &Env, id: u64) -> u32 { n.unwrap_or(0) } -/// Count a new submission before writing the entry (mirrors -/// `append_contributor`/`append_applicant`). A no-op when the applicant -/// already has a submission — re-submission updates the existing entry in -/// place and must not recount. +/// Count a newly occupied slot before writing it (mirrors +/// `append_contributor`/`append_applicant`). A no-op when the slot already +/// holds an entry — re-submitting to the same slot updates it in place and +/// must not recount. /// -/// Returns `Error::TooManyContributors` only on u32 counter overflow — -/// reused rather than a new variant since the errors enum is at the -/// 50-case XDR cap. -pub fn append_submission(env: &Env, id: u64, addr: &Address) -> Result<(), Error> { - if get_submission(env, id, addr).is_some() { +/// Returns `Error::TooManyContributors` only on u32 counter overflow — reused +/// rather than a new variant since the errors enum is at the 50-case XDR cap. +pub fn append_submission(env: &Env, id: u64, addr: &Address, slot: u32) -> Result<(), Error> { + if get_submission(env, id, addr, slot).is_some() { return Ok(()); } let cur = submission_count(env, id); @@ -482,9 +508,35 @@ pub fn append_submission(env: &Env, id: u64, addr: &Address) -> Result<(), Error let count_key = DataKey::EventSubmissionCount(id); env.storage().persistent().set(&count_key, &next); touch_event_persistent(env, &count_key); + + let per_applicant = applicant_submission_count(env, id, addr) + .checked_add(1) + .ok_or(Error::TooManyContributors)?; + set_applicant_submission_count(env, id, addr, per_applicant); Ok(()) } +/// Reads a pre-1.7.0 submission row, which lived under a key with no slot. +/// Only `migrate` calls this. +pub fn get_legacy_submission(env: &Env, id: u64, applicant: &Address) -> Option { + env.storage() + .persistent() + .get(&DataKey::EventSubmission(id, applicant.clone())) +} + +/// Drops a pre-1.7.0 submission row once it has been copied to a slot. +pub fn remove_legacy_submission(env: &Env, id: u64, applicant: &Address) { + env.storage() + .persistent() + .remove(&DataKey::EventSubmission(id, applicant.clone())); +} + +/// Sets the per-applicant slot count directly. Only `migrate` calls this, to +/// seed the counter for rows that predate it. +pub fn seed_applicant_submission_count(env: &Env, id: u64, applicant: &Address, count: u32) { + set_applicant_submission_count(env, id, applicant, count); +} + // ============================================================ // WINNERS (paged, persistent) // ============================================================ diff --git a/contracts/events/src/tests/admin.rs b/contracts/events/src/tests/admin.rs index 9b76d63..2c4eaf3 100644 --- a/contracts/events/src/tests/admin.rs +++ b/contracts/events/src/tests/admin.rs @@ -7,7 +7,8 @@ use soroban_sdk::{ use super::common::setup; use crate::errors::Error; -use crate::types::{DataKey, EventStatus, Pillar, ReleaseKind}; +use crate::storage; +use crate::types::{DataKey, EventStatus, Pillar, ReleaseKind, Submission}; const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; @@ -222,6 +223,76 @@ fn migrate_rewrites_legacy_percentages_as_prize_floors() { ); } +#[test] +fn migrate_moves_legacy_submissions_into_slot_zero() { + let ctx = setup(250); + let applicant = Address::generate(&ctx.env); + let event_id = ctx.client.id_base() + 1; + + let mut dist = Map::new(&ctx.env); + dist.set(1, 100_u32); + let legacy_event = LegacyEventRecord { + id: event_id, + pillar: Pillar::Bounty, + owner: Address::generate(&ctx.env), + token: Address::generate(&ctx.env), + total_budget: 1_000_0000000_i128, + remaining_escrow: 0, + release_kind: ReleaseKind::Single, + status: EventStatus::Completed, + content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/legacy"), + title: String::from_str(&ctx.env, "Legacy"), + created_at: 1, + deadline: None, + winner_distribution: dist, + fee_bps_override: None, + }; + let legacy_submission = Submission { + applicant: applicant.clone(), + content_uri: String::from_str(&ctx.env, "ipfs://historical"), + submitted_at: 42, + }; + + ctx.env.as_contract(&ctx.client.address, || { + ctx.env + .storage() + .persistent() + .set(&DataKey::Event(event_id), &legacy_event); + ctx.env + .storage() + .instance() + .set(&DataKey::NextEventId, &(event_id + 1)); + // The applicant index is what bounds the migration scan. + storage::append_applicant(&ctx.env, event_id, &applicant).unwrap(); + ctx.env.storage().persistent().set( + &DataKey::EventSubmission(event_id, applicant.clone()), + &legacy_submission, + ); + }); + + ctx.client.migrate(); + + let moved = ctx.client.get_submission(&event_id, &applicant, &0_u32); + assert_eq!( + moved.content_uri, + String::from_str(&ctx.env, "ipfs://historical") + ); + assert_eq!(moved.submitted_at, 42, "the original timestamp survives"); + assert_eq!( + ctx.client + .get_applicant_submission_count(&event_id, &applicant), + 1 + ); + + // The old row is gone rather than left as an unreachable duplicate. + ctx.env.as_contract(&ctx.client.address, || { + assert!( + storage::get_legacy_submission(&ctx.env, event_id, &applicant).is_none(), + "legacy row should be removed once copied" + ); + }); +} + #[test] fn migrate_is_a_no_op_on_a_fresh_deployment() { let ctx = setup(250); diff --git a/contracts/events/src/tests/bounty_pillar.rs b/contracts/events/src/tests/bounty_pillar.rs index 0e852d3..2ea3a68 100644 --- a/contracts/events/src/tests/bounty_pillar.rs +++ b/contracts/events/src/tests/bounty_pillar.rs @@ -447,7 +447,7 @@ fn withdraw_after_submit_reverts() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../bounty.json"); let op_submit = BytesN::random(&ctx.env); ctx.events - .submit(&bounty_id, &ctx.applicant, &uri, &op_submit); + .submit(&bounty_id, &ctx.applicant, &0_u32, &uri, &op_submit); let op_wd = BytesN::random(&ctx.env); let err = expect_op_err(ctx.events.try_withdraw_application( @@ -527,3 +527,165 @@ fn withdraw_requires_applicant_auth() { let applicant_required = auths.iter().any(|(addr, _)| *addr == ctx.applicant); assert!(applicant_required, "withdraw must demand applicant auth"); } + +// ============================================================ +// Submission slots: several distinct entries per wallet +// ============================================================ + +#[test] +fn one_wallet_holds_several_submissions_in_distinct_slots() { + let ctx = setup(); + let bounty_id = create_bounty(&ctx); + ctx.events + .apply_to_bounty(&bounty_id, &ctx.applicant, &BytesN::random(&ctx.env)); + + ctx.events.submit( + &bounty_id, + &ctx.applicant, + &0_u32, + &String::from_str(&ctx.env, "ipfs://design"), + &BytesN::random(&ctx.env), + ); + ctx.events.submit( + &bounty_id, + &ctx.applicant, + &1_u32, + &String::from_str(&ctx.env, "ipfs://article"), + &BytesN::random(&ctx.env), + ); + + // Neither entry overwrites the other; the slot is what tells them apart. + assert_eq!( + ctx.events + .get_submission(&bounty_id, &ctx.applicant, &0_u32) + .content_uri, + String::from_str(&ctx.env, "ipfs://design") + ); + assert_eq!( + ctx.events + .get_submission(&bounty_id, &ctx.applicant, &1_u32) + .content_uri, + String::from_str(&ctx.env, "ipfs://article") + ); + assert_eq!( + ctx.events + .get_applicant_submission_count(&bounty_id, &ctx.applicant), + 2 + ); +} + +#[test] +fn resubmitting_to_an_occupied_slot_updates_it_without_recounting() { + let ctx = setup(); + let bounty_id = create_bounty(&ctx); + ctx.events + .apply_to_bounty(&bounty_id, &ctx.applicant, &BytesN::random(&ctx.env)); + + ctx.events.submit( + &bounty_id, + &ctx.applicant, + &0_u32, + &String::from_str(&ctx.env, "ipfs://v1"), + &BytesN::random(&ctx.env), + ); + let first = ctx + .events + .get_submission(&bounty_id, &ctx.applicant, &0_u32); + + ctx.events.submit( + &bounty_id, + &ctx.applicant, + &0_u32, + &String::from_str(&ctx.env, "ipfs://v2"), + &BytesN::random(&ctx.env), + ); + let second = ctx + .events + .get_submission(&bounty_id, &ctx.applicant, &0_u32); + + assert_eq!(second.content_uri, String::from_str(&ctx.env, "ipfs://v2")); + assert_eq!( + second.submitted_at, first.submitted_at, + "an update keeps the original submission time" + ); + assert_eq!( + ctx.events + .get_applicant_submission_count(&bounty_id, &ctx.applicant), + 1 + ); +} + +#[test] +fn withdrawing_one_slot_leaves_the_others_intact() { + let ctx = setup(); + let bounty_id = create_bounty(&ctx); + ctx.events + .apply_to_bounty(&bounty_id, &ctx.applicant, &BytesN::random(&ctx.env)); + + for slot in 0..3_u32 { + ctx.events.submit( + &bounty_id, + &ctx.applicant, + &slot, + &String::from_str(&ctx.env, "ipfs://entry"), + &BytesN::random(&ctx.env), + ); + } + + ctx.events.withdraw_submission( + &bounty_id, + &ctx.applicant, + &1_u32, + &BytesN::random(&ctx.env), + ); + + assert_eq!( + ctx.events + .get_applicant_submission_count(&bounty_id, &ctx.applicant), + 2 + ); + assert!(ctx + .events + .try_get_submission(&bounty_id, &ctx.applicant, &1_u32) + .is_err()); + assert!(ctx + .events + .try_get_submission(&bounty_id, &ctx.applicant, &0_u32) + .is_ok()); + assert!(ctx + .events + .try_get_submission(&bounty_id, &ctx.applicant, &2_u32) + .is_ok()); +} + +#[test] +fn application_withdrawal_stays_blocked_while_any_slot_is_filled() { + let ctx = setup(); + let bounty_id = create_bounty(&ctx); + ctx.events + .apply_to_bounty(&bounty_id, &ctx.applicant, &BytesN::random(&ctx.env)); + + // Occupying a slot other than 0 must still block application withdrawal; + // the gate asks whether any entry exists, not whether slot 0 does. + ctx.events.submit( + &bounty_id, + &ctx.applicant, + &7_u32, + &String::from_str(&ctx.env, "ipfs://entry"), + &BytesN::random(&ctx.env), + ); + + assert!(ctx + .events + .try_withdraw_application(&bounty_id, &ctx.applicant, &BytesN::random(&ctx.env)) + .is_err()); + + ctx.events.withdraw_submission( + &bounty_id, + &ctx.applicant, + &7_u32, + &BytesN::random(&ctx.env), + ); + ctx.events + .withdraw_application(&bounty_id, &ctx.applicant, &BytesN::random(&ctx.env)); +} diff --git a/contracts/events/src/tests/cross_contract.rs b/contracts/events/src/tests/cross_contract.rs index c000bc8..8b527ac 100644 --- a/contracts/events/src/tests/cross_contract.rs +++ b/contracts/events/src/tests/cross_contract.rs @@ -579,9 +579,9 @@ fn hackathon_submit_creates_anchor_without_prior_apply() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../project.json"); let op = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri, &op); + ctx.events.submit(&id, &ctx.applicant, &0_u32, &uri, &op); - let sub = ctx.events.get_submission(&id, &ctx.applicant); + let sub = ctx.events.get_submission(&id, &ctx.applicant, &0_u32); assert_eq!(sub.applicant, ctx.applicant); assert_eq!(sub.content_uri, uri); assert_eq!(sub.submitted_at, ctx.env.ledger().timestamp()); @@ -594,7 +594,9 @@ fn bounty_submit_requires_prior_application() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../bounty.json"); let op = BytesN::random(&ctx.env); - let res = ctx.events.try_submit(&id, &ctx.applicant, &uri, &op); + let res = ctx + .events + .try_submit(&id, &ctx.applicant, &0_u32, &uri, &op); assert!(res.is_err(), "submit before apply on bounty should revert"); } @@ -608,9 +610,10 @@ fn bounty_submit_succeeds_after_apply() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../bounty.json"); let op_submit = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri, &op_submit); + ctx.events + .submit(&id, &ctx.applicant, &0_u32, &uri, &op_submit); - let sub = ctx.events.get_submission(&id, &ctx.applicant); + let sub = ctx.events.get_submission(&id, &ctx.applicant, &0_u32); assert_eq!(sub.content_uri, uri); } @@ -621,16 +624,18 @@ fn resubmit_preserves_original_submitted_at_and_updates_uri() { let uri_a = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); let op_a = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri_a, &op_a); + ctx.events + .submit(&id, &ctx.applicant, &0_u32, &uri_a, &op_a); - let first = ctx.events.get_submission(&id, &ctx.applicant); + let first = ctx.events.get_submission(&id, &ctx.applicant, &0_u32); let first_time = first.submitted_at; let uri_b = String::from_str(&ctx.env, "ipfs://Qm.../v2.json"); let op_b = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri_b, &op_b); + ctx.events + .submit(&id, &ctx.applicant, &0_u32, &uri_b, &op_b); - let second = ctx.events.get_submission(&id, &ctx.applicant); + let second = ctx.events.get_submission(&id, &ctx.applicant, &0_u32); assert_eq!(second.content_uri, uri_b); assert_eq!( second.submitted_at, first_time, @@ -645,9 +650,11 @@ fn submit_replayed_reverts() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); let op = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri, &op); + ctx.events.submit(&id, &ctx.applicant, &0_u32, &uri, &op); - let res = ctx.events.try_submit(&id, &ctx.applicant, &uri, &op); + let res = ctx + .events + .try_submit(&id, &ctx.applicant, &0_u32, &uri, &op); assert!(res.is_err(), "replayed submit should revert"); } @@ -658,12 +665,14 @@ fn withdraw_submission_removes_anchor() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); let op_submit = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri, &op_submit); + ctx.events + .submit(&id, &ctx.applicant, &0_u32, &uri, &op_submit); let op_wd = BytesN::random(&ctx.env); - ctx.events.withdraw_submission(&id, &ctx.applicant, &op_wd); + ctx.events + .withdraw_submission(&id, &ctx.applicant, &0_u32, &op_wd); - let res = ctx.events.try_get_submission(&id, &ctx.applicant); + let res = ctx.events.try_get_submission(&id, &ctx.applicant, &0_u32); assert!(res.is_err(), "withdrawn submission should not be readable"); } @@ -675,7 +684,7 @@ fn withdraw_submission_without_submission_reverts() { let op_wd = BytesN::random(&ctx.env); let res = ctx .events - .try_withdraw_submission(&id, &ctx.applicant, &op_wd); + .try_withdraw_submission(&id, &ctx.applicant, &0_u32, &op_wd); assert!( res.is_err(), "withdraw without prior submission should revert" diff --git a/contracts/events/src/tests/crowdfunding.rs b/contracts/events/src/tests/crowdfunding.rs index 1f3776f..47280fa 100644 --- a/contracts/events/src/tests/crowdfunding.rs +++ b/contracts/events/src/tests/crowdfunding.rs @@ -410,7 +410,7 @@ fn submit_on_crowdfunding_reverts() { let op = BytesN::random(&ctx.env); let uri = String::from_str(&ctx.env, "ipfs://nope"); - let res = ctx.events.try_submit(&id, &ctx.builder, &uri, &op); + let res = ctx.events.try_submit(&id, &ctx.builder, &0_u32, &uri, &op); assert!(res.is_err()); } diff --git a/contracts/events/src/tests/hackathon_pillar.rs b/contracts/events/src/tests/hackathon_pillar.rs index 093ce6a..5aa5a4b 100644 --- a/contracts/events/src/tests/hackathon_pillar.rs +++ b/contracts/events/src/tests/hackathon_pillar.rs @@ -183,9 +183,9 @@ fn submit_open_without_prior_apply_creates_anchor() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../project.json"); let op = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri, &op); + ctx.events.submit(&id, &ctx.applicant, &0_u32, &uri, &op); - let sub = ctx.events.get_submission(&id, &ctx.applicant); + let sub = ctx.events.get_submission(&id, &ctx.applicant, &0_u32); assert_eq!(sub.applicant, ctx.applicant); assert_eq!(sub.content_uri, uri); assert_eq!(sub.submitted_at, ctx.env.ledger().timestamp()); @@ -198,14 +198,19 @@ fn resubmit_keeps_original_timestamp_and_updates_uri() { let uri_a = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); let op_a = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri_a, &op_a); - let first_time = ctx.events.get_submission(&id, &ctx.applicant).submitted_at; + ctx.events + .submit(&id, &ctx.applicant, &0_u32, &uri_a, &op_a); + let first_time = ctx + .events + .get_submission(&id, &ctx.applicant, &0_u32) + .submitted_at; let uri_b = String::from_str(&ctx.env, "ipfs://Qm.../v2.json"); let op_b = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri_b, &op_b); + ctx.events + .submit(&id, &ctx.applicant, &0_u32, &uri_b, &op_b); - let second = ctx.events.get_submission(&id, &ctx.applicant); + let second = ctx.events.get_submission(&id, &ctx.applicant, &0_u32); assert_eq!(second.content_uri, uri_b); assert_eq!(second.submitted_at, first_time); } @@ -217,9 +222,11 @@ fn submit_replayed_op_reverts() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); let op = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri, &op); + ctx.events.submit(&id, &ctx.applicant, &0_u32, &uri, &op); - let res = ctx.events.try_submit(&id, &ctx.applicant, &uri, &op); + let res = ctx + .events + .try_submit(&id, &ctx.applicant, &0_u32, &uri, &op); assert!(res.is_err(), "replayed submit op_id must revert"); } @@ -230,12 +237,13 @@ fn withdraw_submission_removes_anchor() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); let op_s = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &uri, &op_s); + ctx.events.submit(&id, &ctx.applicant, &0_u32, &uri, &op_s); let op_w = BytesN::random(&ctx.env); - ctx.events.withdraw_submission(&id, &ctx.applicant, &op_w); + ctx.events + .withdraw_submission(&id, &ctx.applicant, &0_u32, &op_w); - let res = ctx.events.try_get_submission(&id, &ctx.applicant); + let res = ctx.events.try_get_submission(&id, &ctx.applicant, &0_u32); assert!(res.is_err(), "withdrawn submission is no longer readable"); } @@ -248,6 +256,7 @@ fn remove_submission_on_nonexistent_entry_does_not_corrupt_counter() { ctx.events.submit( &id, &submitter, + &0_u32, &String::from_str(&ctx.env, "ipfs://Qm.../v1.json"), &BytesN::random(&ctx.env), ); @@ -256,7 +265,7 @@ fn remove_submission_on_nonexistent_entry_does_not_corrupt_counter() { // directly for it must be a no-op, not decrement the counter that // `submitter`'s real submission incremented. ctx.env.as_contract(&ctx.events_id, || { - storage::remove_submission(&ctx.env, id, &ctx.applicant); + storage::remove_submission(&ctx.env, id, &ctx.applicant, 0); }); let count = ctx @@ -275,9 +284,9 @@ fn withdraw_submission_frees_the_slot_for_future_submitters() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); ctx.events - .submit(&id, &ctx.applicant, &uri, &BytesN::random(&ctx.env)); + .submit(&id, &ctx.applicant, &0_u32, &uri, &BytesN::random(&ctx.env)); ctx.events - .withdraw_submission(&id, &ctx.applicant, &BytesN::random(&ctx.env)); + .withdraw_submission(&id, &ctx.applicant, &0_u32, &BytesN::random(&ctx.env)); let count = ctx .env @@ -304,7 +313,7 @@ fn submit_beyond_former_cap_succeeds() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../v5001.json"); ctx.events - .submit(&id, &ctx.applicant, &uri, &BytesN::random(&ctx.env)); + .submit(&id, &ctx.applicant, &0_u32, &uri, &BytesN::random(&ctx.env)); let count = ctx .env @@ -329,7 +338,10 @@ fn submit_at_counter_overflow_reverts() { let uri = String::from_str(&ctx.env, "ipfs://Qm.../overflow.json"); let op = BytesN::random(&ctx.env); - let err = expect_op_err(ctx.events.try_submit(&id, &ctx.applicant, &uri, &op)); + let err = expect_op_err( + ctx.events + .try_submit(&id, &ctx.applicant, &0_u32, &uri, &op), + ); assert_eq!( err, Error::TooManyContributors, @@ -345,7 +357,10 @@ fn submit_oversized_content_uri_reverts() { let too_long = "x".repeat((MAX_CONTENT_URI_LEN + 1) as usize); let uri = String::from_str(&ctx.env, &too_long); let op = BytesN::random(&ctx.env); - let err = expect_op_err(ctx.events.try_submit(&id, &ctx.applicant, &uri, &op)); + let err = expect_op_err( + ctx.events + .try_submit(&id, &ctx.applicant, &0_u32, &uri, &op), + ); // Reused rather than a new variant — stays inside the contracterror // 50-variant cap (see BACKLOG.md L7 for precedent). assert_eq!( @@ -361,8 +376,13 @@ fn resubmit_by_existing_applicant_does_not_increment_submission_count() { let id = create_hackathon(&ctx); let uri_a = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); - ctx.events - .submit(&id, &ctx.applicant, &uri_a, &BytesN::random(&ctx.env)); + ctx.events.submit( + &id, + &ctx.applicant, + &0_u32, + &uri_a, + &BytesN::random(&ctx.env), + ); let count_after_first = ctx .env @@ -370,8 +390,13 @@ fn resubmit_by_existing_applicant_does_not_increment_submission_count() { assert_eq!(count_after_first, 1); let uri_b = String::from_str(&ctx.env, "ipfs://Qm.../v2.json"); - ctx.events - .submit(&id, &ctx.applicant, &uri_b, &BytesN::random(&ctx.env)); + ctx.events.submit( + &id, + &ctx.applicant, + &0_u32, + &uri_b, + &BytesN::random(&ctx.env), + ); let count_after_second = ctx .env diff --git a/contracts/events/src/types.rs b/contracts/events/src/types.rs index 8a5d6a3..b421fed 100644 --- a/contracts/events/src/types.rs +++ b/contracts/events/src/types.rs @@ -175,6 +175,8 @@ pub enum DataKey { EventApplicantAt(u64, u32), EventApplicantSlot(u64, Address), + /// Pre-1.7.0 single-submission key. Read only by `migrate`, which moves + /// each row to slot 0 of `EventSubmissionEntry`. Never write this. EventSubmission(u64, Address), EventWinnerCount(u64), @@ -222,6 +224,12 @@ pub enum DataKey { // only drops at claim time, so without this a second selection would see // funds an earlier winner is still entitled to and could promise them twice. EventOwedTotal(u64), + + // Appended in 1.7.0. Submissions gain a caller-chosen slot, so one wallet + // may hold several distinct entries in one event. The contract assigns no + // meaning to the slot; callers use it for whatever separates their entries. + EventSubmissionEntry(u64, Address, u32), + EventApplicantSubmissionCount(u64, Address), } // ============================================================ From 21274ca86c51991dde6feb8089601fd78020d460 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Tue, 18 Aug 2026 00:30:53 +0100 Subject: [PATCH 04/20] fix(events): close review findings on the 1.7.0 migration and slots Five fixes, four of them in the migration, which had no test exercising a pre-upgrade state and so passed while being wrong. Grants selected before the upgrade stored amount 0 on the anchor winner row, because the payout used to come from the percentage distribution at claim time. claim_milestone now reads that amount, so those grants would have reverted on every milestone claim with no way to re-select and no exit but cancelling. migrate rewrites the anchor to the position's floor, which is what the old formula would have produced. Submission migration was bounded by the applicant index, which only apply_to_bounty populates and only for the bounty pillar. Hackathon submitters never appear there, so their rows would have been left unreachable behind the re-key, and has_any_submission would have stopped blocking application withdrawal for someone still holding one. Since those submitters cannot be enumerated on chain, reads now fall back to the legacy key as slot 0 and the first write folds the row into the slotted layout. The migration row cap now fails closed. It stamped the version even when the id range exceeded it, and migrate is one-shot, so the remainder would have been stranded in a layout the current struct cannot decode. Submitted and SubmissionWithdrawn carry the slot. Without it a withdrawal of slot 1 is indistinguishable from slot 0 on the wire, and the subscribers resolve anchors by (event, applicant). claim_prize and claim_milestone subtract from the owed total with a checked op instead of clamping at zero, so a drifted reservation fails loudly rather than silently under-reserving the next selection. --- contracts/events/src/admin.rs | 66 ++++++++++++++-- contracts/events/src/event_ops.rs | 12 ++- contracts/events/src/events.rs | 2 + contracts/events/src/grant.rs | 6 +- contracts/events/src/storage.rs | 26 +++++- contracts/events/src/tests/admin.rs | 118 +++++++++++++++++++++++++++- 6 files changed, 217 insertions(+), 13 deletions(-) diff --git a/contracts/events/src/admin.rs b/contracts/events/src/admin.rs index 2835e95..d42b41a 100644 --- a/contracts/events/src/admin.rs +++ b/contracts/events/src/admin.rs @@ -5,7 +5,7 @@ use crate::events as evt; use crate::idempotency; use crate::storage; use crate::types::{ - DataKey, EventRecord, EventStatus, PendingAdmin, PendingUpgrade, Pillar, ReleaseKind, + DataKey, EventRecord, EventStatus, PendingAdmin, PendingUpgrade, Pillar, ReleaseKind, Winner, }; /// The pre-1.7.0 `EventRecord`, kept only so `migrate` can decode rows written @@ -276,7 +276,7 @@ pub fn migrate(env: &Env) -> Result<(), Error> { // PER-(from -> to) MIGRATION DISPATCH // ============================================================ if current == String::from_str(env, INITIAL_VERSION) { - migrate_prize_floors(env); + migrate_prize_floors(env)?; } storage::set_migrated_to_version(env, ¤t); @@ -298,17 +298,22 @@ pub fn migrate(env: &Env) -> Result<(), Error> { /// percent / 100` reproduces exactly what each position would have been paid. /// /// Bounded by the id counter: ids run from `id_base + 1` up to the next id to -/// be issued, and the cap is a backstop against a corrupt counter rather than -/// an expected limit. -fn migrate_prize_floors(env: &Env) { +/// be issued. The cap is a backstop against a corrupt counter, and it fails +/// closed: exceeding it aborts before anything is stamped, because `migrate` +/// is one-shot and a half-finished pass would leave the remaining events in a +/// layout the current struct cannot decode, with no way to resume. +fn migrate_prize_floors(env: &Env) -> Result<(), Error> { const MAX_ROWS: u64 = 256; let base = idempotency::id_base(env); let next = storage::get_next_event_id(env, base.saturating_add(1)); let mut id = base.saturating_add(1); - let mut scanned: u64 = 0; - while id < next && scanned < MAX_ROWS { + if next.saturating_sub(id) > MAX_ROWS { + return Err(Error::EventIdOverflow); + } + + while id < next { let key = DataKey::Event(id); let legacy: Option = env.storage().persistent().get(&key); if let Some(old) = legacy { @@ -341,8 +346,53 @@ fn migrate_prize_floors(env: &Env) { env.storage().persistent().set(&key, &migrated); } migrate_submissions_to_slots(env, id); + migrate_winner_amounts(env, id); id = id.saturating_add(1); - scanned = scanned.saturating_add(1); + } + Ok(()) +} + +/// Pre-1.7.0 `Multi` selections stored `amount: 0` on the anchor winner row, +/// because a grant milestone derived its payout from the percentage +/// distribution at claim time. `claim_milestone` now reads that amount, so an +/// unrewritten row would compute a payout of zero and revert on every claim, +/// with no way to re-select and no exit but cancelling the grant. +/// +/// The floor for the winner's position is exactly what the old formula would +/// have produced, since both are `total_budget * percent / 100`. +fn migrate_winner_amounts(env: &Env, event_id: u64) { + let event = match storage::get_event(env, event_id) { + Some(e) => e, + None => return, + }; + if !matches!(event.release_kind, ReleaseKind::Multi(_)) { + return; + } + let count = storage::winner_count(env, event_id); + for idx in 0..count { + let w = match storage::winner_at(env, event_id, idx) { + Some(w) => w, + None => continue, + }; + // Milestone rows already carry what was actually paid; only the anchor + // was written with a placeholder amount. + if w.milestone.is_some() || w.amount != 0 { + continue; + } + if let Some(floor) = event.prize_floors.get(w.position) { + storage::set_winner_at( + env, + event_id, + idx, + &Winner { + recipient: w.recipient.clone(), + position: w.position, + amount: floor, + milestone: None, + paid_at: w.paid_at, + }, + ); + } } } diff --git a/contracts/events/src/event_ops.rs b/contracts/events/src/event_ops.rs index b0ee84b..949330d 100644 --- a/contracts/events/src/event_ops.rs +++ b/contracts/events/src/event_ops.rs @@ -597,6 +597,7 @@ pub fn submit( evt::Submitted { event_id, applicant: applicant.clone(), + slot, content_uri, } .publish(env); @@ -634,6 +635,7 @@ pub fn withdraw_submission( evt::SubmissionWithdrawn { event_id, applicant: applicant.clone(), + slot, } .publish(env); @@ -892,9 +894,15 @@ pub fn claim_prize( storage::set_unclaimed_prize_count(env, event_id, unclaimed.saturating_sub(1)); // Claiming converts owed into paid; both balances drop together so the - // reservation in select_winners stays exact. + // reservation in select_winners stays exact. Checked rather than clamped: + // owed dropping below a claim means the reservation has already drifted, + // and swallowing that would let the next selection over-promise the pool. let owed = storage::owed_total(env, event_id); - storage::set_owed_total(env, event_id, (owed - amount).max(0)); + let owed_after = owed.checked_sub(amount).ok_or(Error::InsufficientEscrow)?; + if owed_after < 0 { + return Err(Error::InsufficientEscrow); + } + storage::set_owed_total(env, event_id, owed_after); event.remaining_escrow = event.remaining_escrow.saturating_sub(amount); if event.remaining_escrow == 0 { diff --git a/contracts/events/src/events.rs b/contracts/events/src/events.rs index ee6f240..aaabadb 100644 --- a/contracts/events/src/events.rs +++ b/contracts/events/src/events.rs @@ -76,6 +76,7 @@ pub struct ApplicationWithdrawn { pub struct Submitted { pub event_id: u64, pub applicant: Address, + pub slot: u32, pub content_uri: String, } @@ -83,6 +84,7 @@ pub struct Submitted { pub struct SubmissionWithdrawn { pub event_id: u64, pub applicant: Address, + pub slot: u32, } #[contractevent] diff --git a/contracts/events/src/grant.rs b/contracts/events/src/grant.rs index 8067346..d14b8e9 100644 --- a/contracts/events/src/grant.rs +++ b/contracts/events/src/grant.rs @@ -125,7 +125,11 @@ pub fn claim_milestone( if !is_crowdfunding { // Crowdfunding never reserves, since it has no winner selection. let owed = storage::owed_total(env, event_id); - storage::set_owed_total(env, event_id, (owed - amount).max(0)); + let owed_after = owed.checked_sub(amount).ok_or(Error::InsufficientEscrow)?; + if owed_after < 0 { + return Err(Error::InsufficientEscrow); + } + storage::set_owed_total(env, event_id, owed_after); } storage::mark_milestone_claimed(env, event_id, &recipient, milestone); if is_crowdfunding { diff --git a/contracts/events/src/storage.rs b/contracts/events/src/storage.rs index 00deb3d..2c8d028 100644 --- a/contracts/events/src/storage.rs +++ b/contracts/events/src/storage.rs @@ -424,8 +424,16 @@ pub fn get_submission(env: &Env, id: u64, applicant: &Address, slot: u32) -> Opt let s: Option = env.storage().persistent().get(&key); if s.is_some() { touch_event_persistent(env, &key); + return s; } - s + // Pre-1.7.0 rows have no slot and `migrate` can only reach the ones whose + // applicant is in the applicant index, which hackathons never populate. + // Reading them as slot 0 keeps every historical submission addressable + // regardless of pillar; the first write folds them into the slotted layout. + if slot == 0 { + return get_legacy_submission(env, id, applicant); + } + None } pub fn set_submission(env: &Env, id: u64, applicant: &Address, slot: u32, submission: &Submission) { @@ -448,6 +456,7 @@ pub fn applicant_submission_count(env: &Env, id: u64, applicant: &Address) -> u3 pub fn has_any_submission(env: &Env, id: u64, applicant: &Address) -> bool { applicant_submission_count(env, id, applicant) > 0 + || get_legacy_submission(env, id, applicant).is_some() } fn set_applicant_submission_count(env: &Env, id: u64, applicant: &Address, count: u32) { @@ -469,6 +478,11 @@ pub fn remove_submission(env: &Env, id: u64, applicant: &Address, slot: u32) { let key = DataKey::EventSubmissionEntry(id, applicant.clone(), slot); env.storage().persistent().remove(&key); + // An unmigrated row is addressed as slot 0; drop it too or it would keep + // answering reads after the withdrawal. + if slot == 0 { + remove_legacy_submission(env, id, applicant); + } let per_applicant = applicant_submission_count(env, id, applicant).saturating_sub(1); set_applicant_submission_count(env, id, applicant, per_applicant); @@ -500,6 +514,16 @@ pub fn submission_count(env: &Env, id: u64) -> u32 { /// Returns `Error::TooManyContributors` only on u32 counter overflow — reused /// rather than a new variant since the errors enum is at the 50-case XDR cap. pub fn append_submission(env: &Env, id: u64, addr: &Address, slot: u32) -> Result<(), Error> { + // Fold an unmigrated pre-1.7.0 row into the slotted layout on first write, + // so the legacy key cannot linger and disagree with the counters. It was + // already counted in the per-event total before the upgrade. + if slot == 0 && get_legacy_submission(env, id, addr).is_some() { + remove_legacy_submission(env, id, addr); + if applicant_submission_count(env, id, addr) == 0 { + set_applicant_submission_count(env, id, addr, 1); + } + return Ok(()); + } if get_submission(env, id, addr, slot).is_some() { return Ok(()); } diff --git a/contracts/events/src/tests/admin.rs b/contracts/events/src/tests/admin.rs index 2c4eaf3..c619e53 100644 --- a/contracts/events/src/tests/admin.rs +++ b/contracts/events/src/tests/admin.rs @@ -8,7 +8,7 @@ use soroban_sdk::{ use super::common::setup; use crate::errors::Error; use crate::storage; -use crate::types::{DataKey, EventStatus, Pillar, ReleaseKind, Submission}; +use crate::types::{DataKey, EventStatus, Pillar, ReleaseKind, Submission, Winner}; const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; @@ -293,6 +293,122 @@ fn migrate_moves_legacy_submissions_into_slot_zero() { }); } +#[test] +fn legacy_submission_is_readable_when_the_applicant_index_never_saw_it() { + // Hackathon submitters never enter the applicant index, so migrate cannot + // enumerate them. Their rows must still be reachable as slot 0. + let ctx = setup(250); + let submitter = Address::generate(&ctx.env); + let event_id = ctx.client.id_base() + 1; + + let legacy_submission = Submission { + applicant: submitter.clone(), + content_uri: String::from_str(&ctx.env, "ipfs://hackathon-entry"), + submitted_at: 7, + }; + ctx.env.as_contract(&ctx.client.address, || { + ctx.env.storage().persistent().set( + &DataKey::EventSubmission(event_id, submitter.clone()), + &legacy_submission, + ); + }); + + let found = ctx.client.get_submission(&event_id, &submitter, &0_u32); + assert_eq!( + found.content_uri, + String::from_str(&ctx.env, "ipfs://hackathon-entry") + ); + + ctx.env.as_contract(&ctx.client.address, || { + assert!( + storage::has_any_submission(&ctx.env, event_id, &submitter), + "an unmigrated row must still block application withdrawal" + ); + }); +} + +#[test] +fn migrate_rewrites_zero_amount_grant_winners() { + // Pre-1.7.0 Multi selections stored amount 0 on the anchor row; leaving + // that would make every milestone claim revert with nothing to pay. + let ctx = setup(250); + let recipient = Address::generate(&ctx.env); + let event_id = ctx.client.id_base() + 1; + let budget = 1_000_0000000_i128; + + let mut dist = Map::new(&ctx.env); + dist.set(1, 100_u32); + let legacy_event = LegacyEventRecord { + id: event_id, + pillar: Pillar::Grant, + owner: Address::generate(&ctx.env), + token: Address::generate(&ctx.env), + total_budget: budget, + remaining_escrow: budget, + release_kind: ReleaseKind::Multi(2), + status: EventStatus::Active, + content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/grant"), + title: String::from_str(&ctx.env, "Legacy Grant"), + created_at: 1, + deadline: None, + winner_distribution: dist, + fee_bps_override: None, + }; + + ctx.env.as_contract(&ctx.client.address, || { + ctx.env + .storage() + .persistent() + .set(&DataKey::Event(event_id), &legacy_event); + ctx.env + .storage() + .instance() + .set(&DataKey::NextEventId, &(event_id + 1)); + storage::append_winner( + &ctx.env, + event_id, + &Winner { + recipient: recipient.clone(), + position: 1, + amount: 0, + milestone: None, + paid_at: None, + }, + ); + }); + + ctx.client.migrate(); + + let rows = ctx.client.get_winners(&event_id); + assert_eq!( + rows.get(0).unwrap().amount, + budget, + "the anchor must carry what the old percentage would have paid" + ); +} + +#[test] +fn migrate_refuses_to_stamp_when_the_id_range_exceeds_the_cap() { + let ctx = setup(250); + let base = ctx.client.id_base(); + ctx.env.as_contract(&ctx.client.address, || { + ctx.env + .storage() + .instance() + .set(&DataKey::NextEventId, &(base + 1_000)); + }); + + assert!( + ctx.client.try_migrate().is_err(), + "a range beyond the cap must abort rather than half-migrate" + ); + assert_eq!( + ctx.client.get_migrated_to_version(), + None, + "nothing may be stamped when work would be left undone" + ); +} + #[test] fn migrate_is_a_no_op_on_a_fresh_deployment() { let ctx = setup(250); From 213b8c7d09d50519f0156d683151f5c27a47aca1 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Tue, 18 Aug 2026 00:40:43 +0100 Subject: [PATCH 05/20] fix(events): make the 1.7.0 migration safe to run on any deployment state The migration assumed it would only ever meet pre-1.7.0 rows. Three ways that failed. Decoding every stored event as the legacy struct aborted the whole invocation when it met a row already in the current layout, because a missing field escalates to a host error rather than a catchable one. That is the documented testnet sequence: redeploy fresh, run the smoke script, then migrate. A contracttype struct is stored as a map keyed by field name, so the pass now identifies the old layout by the field only it carries and leaves current rows alone. The rewrite was gated on the stored version matching INITIAL_VERSION exactly, while propose_upgrade accepts any non-empty string. Proposing as "v1.7.0" would have skipped the rewrite, stamped the marker anyway, and left every legacy event undecodable with migrate refused thereafter. The pass is idempotent now, so it runs unconditionally and the string is irrelevant. Folding an unmigrated submission into slot 0 set the per-applicant counter to 1 only when it was zero, which undercounts an applicant who already holds other slots. After one withdrawal has_any_submission would read false while a slot was still occupied, unlocking application withdrawal for someone holding a live submission. It increments instead. --- contracts/events/src/admin.rs | 26 ++++-- contracts/events/src/storage.rs | 10 ++- contracts/events/src/tests/admin.rs | 131 ++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 8 deletions(-) diff --git a/contracts/events/src/admin.rs b/contracts/events/src/admin.rs index d42b41a..0d02197 100644 --- a/contracts/events/src/admin.rs +++ b/contracts/events/src/admin.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracttype, panic_with_error, Address, BytesN, Env, Map, String}; +use soroban_sdk::{contracttype, panic_with_error, Address, BytesN, Env, Map, String, Symbol, Val}; use crate::errors::Error; use crate::events as evt; @@ -275,9 +275,12 @@ pub fn migrate(env: &Env) -> Result<(), Error> { // ============================================================ // PER-(from -> to) MIGRATION DISPATCH // ============================================================ - if current == String::from_str(env, INITIAL_VERSION) { - migrate_prize_floors(env)?; - } + // Run unconditionally rather than gating on an exact version string: + // propose_upgrade accepts any non-empty version, so a differently-spelled + // one would silently skip the rewrite and still stamp the marker, leaving + // every legacy event undecodable with no way to re-run. The pass skips + // rows already in the current layout, so running it always is safe. + migrate_prize_floors(env)?; storage::set_migrated_to_version(env, ¤t); storage::touch_instance(env); @@ -315,7 +318,20 @@ fn migrate_prize_floors(env: &Env) -> Result<(), Error> { while id < next { let key = DataKey::Event(id); - let legacy: Option = env.storage().persistent().get(&key); + // Decode defensively. `get::` unwraps the + // conversion, and a missing field escalates to a host error rather + // than a catchable one, so a row already in the 1.7.0 layout would + // abort the whole invocation instead of being skipped. A contracttype + // struct is stored as a map keyed by field name, so the old layout is + // identified by the field that only it carries. + let fields: Option> = env.storage().persistent().get(&key); + let is_legacy = + fields.is_some_and(|f| f.contains_key(Symbol::new(env, "winner_distribution"))); + let legacy: Option = if is_legacy { + env.storage().persistent().get(&key) + } else { + None + }; if let Some(old) = legacy { let mut floors: Map = Map::new(env); for (position, percent) in old.winner_distribution.iter() { diff --git a/contracts/events/src/storage.rs b/contracts/events/src/storage.rs index 2c8d028..b2fc4f9 100644 --- a/contracts/events/src/storage.rs +++ b/contracts/events/src/storage.rs @@ -519,9 +519,13 @@ pub fn append_submission(env: &Env, id: u64, addr: &Address, slot: u32) -> Resul // already counted in the per-event total before the upgrade. if slot == 0 && get_legacy_submission(env, id, addr).is_some() { remove_legacy_submission(env, id, addr); - if applicant_submission_count(env, id, addr) == 0 { - set_applicant_submission_count(env, id, addr, 1); - } + // Increment rather than set: the applicant may already hold slotted + // entries, and folding adds one more. Setting it to 1 would undercount + // and let has_any_submission go false while a slot is still occupied. + let per_applicant = applicant_submission_count(env, id, addr) + .checked_add(1) + .ok_or(Error::TooManyContributors)?; + set_applicant_submission_count(env, id, addr, per_applicant); return Ok(()); } if get_submission(env, id, addr, slot).is_some() { diff --git a/contracts/events/src/tests/admin.rs b/contracts/events/src/tests/admin.rs index c619e53..d283c4c 100644 --- a/contracts/events/src/tests/admin.rs +++ b/contracts/events/src/tests/admin.rs @@ -409,6 +409,137 @@ fn migrate_refuses_to_stamp_when_the_id_range_exceeds_the_cap() { ); } +#[test] +fn migrate_skips_rows_already_in_the_current_layout() { + // A fresh 1.7.0 deployment writes new-layout events, and the runbook still + // calls migrate() after apply. Decoding those as the legacy shape used to + // abort the whole invocation. + let ctx = setup(250); + let event_id = ctx.client.id_base() + 1; + let mut floors = Map::new(&ctx.env); + floors.set(1, 500_i128); + let current = crate::types::EventRecord { + id: event_id, + pillar: Pillar::Bounty, + owner: Address::generate(&ctx.env), + token: Address::generate(&ctx.env), + total_budget: 1_000_i128, + remaining_escrow: 1_000_i128, + release_kind: ReleaseKind::Single, + status: EventStatus::Active, + content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/new"), + title: String::from_str(&ctx.env, "Already Migrated"), + created_at: 1, + deadline: None, + prize_floors: floors, + fee_bps_override: None, + }; + ctx.env.as_contract(&ctx.client.address, || { + ctx.env + .storage() + .persistent() + .set(&DataKey::Event(event_id), ¤t); + ctx.env + .storage() + .instance() + .set(&DataKey::NextEventId, &(event_id + 1)); + }); + + ctx.client.migrate(); + + let after = ctx.client.get_event(&event_id); + assert_eq!(after.prize_floors.get(1), Some(500_i128)); + assert_eq!(after.title, String::from_str(&ctx.env, "Already Migrated")); +} + +#[test] +fn migrate_runs_regardless_of_how_the_version_was_spelled() { + // propose_upgrade accepts any non-empty string. Gating the rewrite on an + // exact match would silently skip it and still stamp the marker. + let ctx = setup(250); + let event_id = ctx.client.id_base() + 1; + let budget = 1_000_0000000_i128; + let mut dist = Map::new(&ctx.env); + dist.set(1, 100_u32); + let legacy = LegacyEventRecord { + id: event_id, + pillar: Pillar::Bounty, + owner: Address::generate(&ctx.env), + token: Address::generate(&ctx.env), + total_budget: budget, + remaining_escrow: 0, + release_kind: ReleaseKind::Single, + status: EventStatus::Completed, + content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/legacy"), + title: String::from_str(&ctx.env, "Oddly Versioned"), + created_at: 1, + deadline: None, + winner_distribution: dist, + fee_bps_override: None, + }; + ctx.env.as_contract(&ctx.client.address, || { + ctx.env + .storage() + .persistent() + .set(&DataKey::Event(event_id), &legacy); + ctx.env + .storage() + .instance() + .set(&DataKey::NextEventId, &(event_id + 1)); + // The operator proposed the upgrade as "v1.7.0" rather than "1.7.0". + storage::set_version(&ctx.env, &String::from_str(&ctx.env, "v1.7.0")); + }); + + ctx.client.migrate(); + + let migrated = ctx.client.get_event(&event_id); + assert_eq!(migrated.prize_floors.get(1), Some(budget)); +} + +#[test] +fn folding_a_legacy_row_adds_to_the_applicants_existing_slots() { + let ctx = setup(250); + let applicant = Address::generate(&ctx.env); + let event_id = ctx.client.id_base() + 1; + + ctx.env.as_contract(&ctx.client.address, || { + // An unmigrated row, plus a slotted entry the applicant already holds. + ctx.env.storage().persistent().set( + &DataKey::EventSubmission(event_id, applicant.clone()), + &Submission { + applicant: applicant.clone(), + content_uri: String::from_str(&ctx.env, "ipfs://legacy"), + submitted_at: 1, + }, + ); + storage::append_submission(&ctx.env, event_id, &applicant, 1).unwrap(); + storage::set_submission( + &ctx.env, + event_id, + &applicant, + 1, + &Submission { + applicant: applicant.clone(), + content_uri: String::from_str(&ctx.env, "ipfs://slot-one"), + submitted_at: 2, + }, + ); + + // Folding the legacy row into slot 0 must count it, not overwrite. + storage::append_submission(&ctx.env, event_id, &applicant, 0).unwrap(); + assert_eq!( + storage::applicant_submission_count(&ctx.env, event_id, &applicant), + 2 + ); + + storage::remove_submission(&ctx.env, event_id, &applicant, 0); + assert!( + storage::has_any_submission(&ctx.env, event_id, &applicant), + "slot 1 is still occupied, so the application must stay locked" + ); + }); +} + #[test] fn migrate_is_a_no_op_on_a_fresh_deployment() { let ctx = setup(250); From bd9e1778c8dafd3f0e7059bde25558f81a983aac Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Tue, 18 Aug 2026 00:56:32 +0100 Subject: [PATCH 06/20] fix(events): fit the 1.7.0 migration inside one invocation's footprint Measured against the host rather than assumed: an invocation may touch 100 ledger entries and write 50. The migration was built as though it had the whole ledger. migrate walked every applicant of every event looking for legacy submission rows. Per-event applicant caps were removed deliberately in #104, so one popular bounty is enough to push the pass past the footprint limit, and because the call reverts nothing is stamped, so it can be retried but never succeed. Every pre-1.7.0 event would then stay undecodable. The scan is removed rather than budgeted. Since reads already fall back to the legacy key as slot 0 and the first write folds the row in, moving rows eagerly was only a storage tidy-up, and it was the one unbounded part of the pass. What remains is load-bearing and bounded: the record rewrite, and the winner-amount rewrite for Multi events. MAX_ROWS drops from 256 to 16 for the same reason. 256 events could never have fit in one transaction, so the old cap described an impossible run. A deployment that trips the new one aborts before stamping and needs a paged entrypoint rather than a one-shot pass. get_legacy_submission now extends the entry's TTL like every other accessor in that file. It stopped being a one-shot migration source and became the only path reaching a hackathon submission, so without the touch those rows archive and the submission silently disappears. --- contracts/events/src/admin.rs | 31 +++++--------------- contracts/events/src/storage.rs | 19 +++++++------ contracts/events/src/tests/admin.rs | 44 ++++++++++++++--------------- 3 files changed, 39 insertions(+), 55 deletions(-) diff --git a/contracts/events/src/admin.rs b/contracts/events/src/admin.rs index 0d02197..db1e017 100644 --- a/contracts/events/src/admin.rs +++ b/contracts/events/src/admin.rs @@ -306,7 +306,13 @@ pub fn migrate(env: &Env) -> Result<(), Error> { /// is one-shot and a half-finished pass would leave the remaining events in a /// layout the current struct cannot decode, with no way to resume. fn migrate_prize_floors(env: &Env) -> Result<(), Error> { - const MAX_ROWS: u64 = 256; + // An invocation may touch at most 100 ledger entries and write 50, so the + // whole pass has to fit in one transaction's footprint. Each event costs a + // record read plus a record write, and a Multi event adds a read and a + // write per winner. Sixteen leaves headroom for the winner rewrites; above + // that this aborts rather than half-migrating, and a deployment that ever + // trips it needs a paged entrypoint instead of a one-shot pass. + const MAX_ROWS: u64 = 16; let base = idempotency::id_base(env); let next = storage::get_next_event_id(env, base.saturating_add(1)); @@ -361,7 +367,6 @@ fn migrate_prize_floors(env: &Env) -> Result<(), Error> { }; env.storage().persistent().set(&key, &migrated); } - migrate_submissions_to_slots(env, id); migrate_winner_amounts(env, id); id = id.saturating_add(1); } @@ -412,28 +417,6 @@ fn migrate_winner_amounts(env: &Env, event_id: u64) { } } -/// Moves each pre-1.7.0 submission to slot 0 of the slotted key and seeds the -/// per-applicant counter. Without this the re-key would orphan every historical -/// submission: the old rows would still occupy storage but no read path could -/// reach them. -/// -/// The applicant index bounds the work, so this only touches wallets the event -/// already knows about. -fn migrate_submissions_to_slots(env: &Env, event_id: u64) { - let applicants = storage::applicant_count(env, event_id); - for idx in 0..applicants { - let applicant = match storage::applicant_at(env, event_id, idx) { - Some(a) => a, - None => continue, - }; - if let Some(legacy) = storage::get_legacy_submission(env, event_id, &applicant) { - storage::set_submission(env, event_id, &applicant, 0, &legacy); - storage::seed_applicant_submission_count(env, event_id, &applicant, 1); - storage::remove_legacy_submission(env, event_id, &applicant); - } - } -} - // ============================================================ // READS // ============================================================ diff --git a/contracts/events/src/storage.rs b/contracts/events/src/storage.rs index b2fc4f9..03dda31 100644 --- a/contracts/events/src/storage.rs +++ b/contracts/events/src/storage.rs @@ -547,9 +547,16 @@ pub fn append_submission(env: &Env, id: u64, addr: &Address, slot: u32) -> Resul /// Reads a pre-1.7.0 submission row, which lived under a key with no slot. /// Only `migrate` calls this. pub fn get_legacy_submission(env: &Env, id: u64, applicant: &Address) -> Option { - env.storage() - .persistent() - .get(&DataKey::EventSubmission(id, applicant.clone())) + let key = DataKey::EventSubmission(id, applicant.clone()); + let s: Option = env.storage().persistent().get(&key); + // This key is a permanent read path, not just a migration source: hackathon + // submitters never enter the applicant index, so their rows are only ever + // reachable here. Without the touch they archive and the submission + // silently disappears. + if s.is_some() { + touch_event_persistent(env, &key); + } + s } /// Drops a pre-1.7.0 submission row once it has been copied to a slot. @@ -559,12 +566,6 @@ pub fn remove_legacy_submission(env: &Env, id: u64, applicant: &Address) { .remove(&DataKey::EventSubmission(id, applicant.clone())); } -/// Sets the per-applicant slot count directly. Only `migrate` calls this, to -/// seed the counter for rows that predate it. -pub fn seed_applicant_submission_count(env: &Env, id: u64, applicant: &Address, count: u32) { - set_applicant_submission_count(env, id, applicant, count); -} - // ============================================================ // WINNERS (paged, persistent) // ============================================================ diff --git a/contracts/events/src/tests/admin.rs b/contracts/events/src/tests/admin.rs index d283c4c..048f866 100644 --- a/contracts/events/src/tests/admin.rs +++ b/contracts/events/src/tests/admin.rs @@ -224,10 +224,15 @@ fn migrate_rewrites_legacy_percentages_as_prize_floors() { } #[test] -fn migrate_moves_legacy_submissions_into_slot_zero() { +fn migrate_leaves_legacy_submissions_addressable_without_scanning_applicants() { + // migrate deliberately does not walk the applicant index: that loop is + // unbounded (per-event caps were removed) and would blow the invocation's + // 100-entry footprint on a popular event. Legacy rows stay reachable + // through the slot-0 fallback instead, and fold in on their next write. let ctx = setup(250); let applicant = Address::generate(&ctx.env); let event_id = ctx.client.id_base() + 1; + let budget = 1_000_0000000_i128; let mut dist = Map::new(&ctx.env); dist.set(1, 100_u32); @@ -236,7 +241,7 @@ fn migrate_moves_legacy_submissions_into_slot_zero() { pillar: Pillar::Bounty, owner: Address::generate(&ctx.env), token: Address::generate(&ctx.env), - total_budget: 1_000_0000000_i128, + total_budget: budget, remaining_escrow: 0, release_kind: ReleaseKind::Single, status: EventStatus::Completed, @@ -247,11 +252,6 @@ fn migrate_moves_legacy_submissions_into_slot_zero() { winner_distribution: dist, fee_bps_override: None, }; - let legacy_submission = Submission { - applicant: applicant.clone(), - content_uri: String::from_str(&ctx.env, "ipfs://historical"), - submitted_at: 42, - }; ctx.env.as_contract(&ctx.client.address, || { ctx.env @@ -262,34 +262,34 @@ fn migrate_moves_legacy_submissions_into_slot_zero() { .storage() .instance() .set(&DataKey::NextEventId, &(event_id + 1)); - // The applicant index is what bounds the migration scan. storage::append_applicant(&ctx.env, event_id, &applicant).unwrap(); ctx.env.storage().persistent().set( &DataKey::EventSubmission(event_id, applicant.clone()), - &legacy_submission, + &Submission { + applicant: applicant.clone(), + content_uri: String::from_str(&ctx.env, "ipfs://historical"), + submitted_at: 42, + }, ); }); ctx.client.migrate(); - let moved = ctx.client.get_submission(&event_id, &applicant, &0_u32); + // The record rewrite is the load-bearing part and must have happened. assert_eq!( - moved.content_uri, - String::from_str(&ctx.env, "ipfs://historical") + ctx.client.get_event(&event_id).prize_floors.get(1), + Some(budget) ); - assert_eq!(moved.submitted_at, 42, "the original timestamp survives"); + + // The submission is still addressable, with its timestamp intact. + let found = ctx.client.get_submission(&event_id, &applicant, &0_u32); assert_eq!( - ctx.client - .get_applicant_submission_count(&event_id, &applicant), - 1 + found.content_uri, + String::from_str(&ctx.env, "ipfs://historical") ); - - // The old row is gone rather than left as an unreachable duplicate. + assert_eq!(found.submitted_at, 42); ctx.env.as_contract(&ctx.client.address, || { - assert!( - storage::get_legacy_submission(&ctx.env, event_id, &applicant).is_none(), - "legacy row should be removed once copied" - ); + assert!(storage::has_any_submission(&ctx.env, event_id, &applicant)); }); } From f139c5bc369d432448b3a0906a51bef686bb60a4 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Tue, 18 Aug 2026 03:00:30 +0100 Subject: [PATCH 07/20] feat(events): page the 1.7.0 migration through a cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proven necessary rather than theorised. Testnet was upgraded to 1.7.0 at ledger 4199077 and migrate() aborted with EventIdOverflow: the deployment holds over a hundred events against a one-shot pass capped at 16, so every event created before the upgrade is undecodable and there is no way to finish. Mainnet's two events only postpone the same wall. The cap was never the real constraint. An invocation may touch 100 ledger entries and write 50, so no single transaction can convert a deployment with history, whatever the cap says. migrate_events(max_events) now converts a bounded slice and advances a stored cursor, returning how many remain. An operator loops until it reports zero. migrate() refuses to stamp until the cursor reaches the end, which is what stops a partial pass being sealed in — it is one-shot, so an early stamp would strand the remainder permanently. Per-call work is capped at 8 events regardless of the argument, since each costs a record read and write and a Multi event adds a read and write per winner. Asking for 0 means "use the ceiling" rather than "do nothing". Reuses EventIdOverflow for "events remain": contracterror is at the 50-case cap. MAX_ROWS is gone; the cursor is the gate now. --- contracts/events/src/admin.rs | 128 +++++++++++++++++----------- contracts/events/src/lib.rs | 7 ++ contracts/events/src/storage.rs | 8 ++ contracts/events/src/tests/admin.rs | 49 +++++++++-- contracts/events/src/types.rs | 5 ++ 5 files changed, 141 insertions(+), 56 deletions(-) diff --git a/contracts/events/src/admin.rs b/contracts/events/src/admin.rs index db1e017..7b43d37 100644 --- a/contracts/events/src/admin.rs +++ b/contracts/events/src/admin.rs @@ -275,12 +275,16 @@ pub fn migrate(env: &Env) -> Result<(), Error> { // ============================================================ // PER-(from -> to) MIGRATION DISPATCH // ============================================================ - // Run unconditionally rather than gating on an exact version string: - // propose_upgrade accepts any non-empty version, so a differently-spelled - // one would silently skip the rewrite and still stamp the marker, leaving - // every legacy event undecodable with no way to re-run. The pass skips - // rows already in the current layout, so running it always is safe. - migrate_prize_floors(env)?; + // Refuse to stamp while any event is still unconverted. The rewrite is + // paged through `migrate_events` because one invocation may touch only 100 + // ledger entries, and stamping early would leave the remainder undecodable + // with no way to resume: this is one-shot. + // + // Reuses EventIdOverflow rather than adding a variant — contracterror is at + // the 50-case cap. It means "events remain", not a counter fault. + if migration_remaining(env) > 0 { + return Err(Error::EventIdOverflow); + } storage::set_migrated_to_version(env, ¤t); storage::touch_instance(env); @@ -292,53 +296,77 @@ pub fn migrate(env: &Env) -> Result<(), Error> { Ok(()) } -/// Rewrites every stored event from the pre-1.7.0 percentage layout to prize -/// floors. `winner_distribution` and `prize_floors` differ in both name and -/// value type, so an old row cannot be decoded by the current struct at all -/// and has to be read through the legacy shape first. -/// -/// Percentages were always taken against the escrow balance, so `total_budget * -/// percent / 100` reproduces exactly what each position would have been paid. +/// How many events `migrate_events` has yet to convert. +fn migration_remaining(env: &Env) -> u64 { + let base = idempotency::id_base(env); + let first = base.saturating_add(1); + let next = storage::get_next_event_id(env, first); + let cursor = storage::get_migration_cursor(env).unwrap_or(first); + next.saturating_sub(cursor.max(first)) +} + +/// Converts up to `max_events` events from the pre-1.7.0 percentage layout, +/// advancing a stored cursor. Returns how many remain, so an operator can loop +/// until it reports zero and only then call `migrate`. /// -/// Bounded by the id counter: ids run from `id_base + 1` up to the next id to -/// be issued. The cap is a backstop against a corrupt counter, and it fails -/// closed: exceeding it aborts before anything is stamped, because `migrate` -/// is one-shot and a half-finished pass would leave the remaining events in a -/// layout the current struct cannot decode, with no way to resume. -fn migrate_prize_floors(env: &Env) -> Result<(), Error> { - // An invocation may touch at most 100 ledger entries and write 50, so the - // whole pass has to fit in one transaction's footprint. Each event costs a - // record read plus a record write, and a Multi event adds a read and a - // write per winner. Sixteen leaves headroom for the winner rewrites; above - // that this aborts rather than half-migrating, and a deployment that ever - // trips it needs a paged entrypoint instead of a one-shot pass. - const MAX_ROWS: u64 = 16; +/// Paged rather than one-shot because an invocation may touch at most 100 +/// ledger entries and write 50. A deployment with real history — testnet holds +/// over a hundred events — cannot be converted in a single transaction, and a +/// one-shot pass that aborts leaves every event undecodable. +pub fn migrate_events(env: &Env, max_events: u32) -> Result { + require_admin(env)?; - let base = idempotency::id_base(env); - let next = storage::get_next_event_id(env, base.saturating_add(1)); - let mut id = base.saturating_add(1); + // Each event costs a record read plus a record write, and a Multi event + // adds a read and a write per winner. Eight leaves headroom for the winner + // rewrites inside the write limit. + const MAX_PER_CALL: u32 = 8; + let budget = if max_events == 0 || max_events > MAX_PER_CALL { + MAX_PER_CALL + } else { + max_events + }; - if next.saturating_sub(id) > MAX_ROWS { - return Err(Error::EventIdOverflow); + let base = idempotency::id_base(env); + let first = base.saturating_add(1); + let next = storage::get_next_event_id(env, first); + let mut cursor = storage::get_migration_cursor(env) + .unwrap_or(first) + .max(first); + + let mut done: u32 = 0; + while cursor < next && done < budget { + migrate_one_event(env, cursor); + cursor = cursor.saturating_add(1); + done = done.saturating_add(1); } - while id < next { - let key = DataKey::Event(id); - // Decode defensively. `get::` unwraps the - // conversion, and a missing field escalates to a host error rather - // than a catchable one, so a row already in the 1.7.0 layout would - // abort the whole invocation instead of being skipped. A contracttype - // struct is stored as a map keyed by field name, so the old layout is - // identified by the field that only it carries. - let fields: Option> = env.storage().persistent().get(&key); - let is_legacy = - fields.is_some_and(|f| f.contains_key(Symbol::new(env, "winner_distribution"))); - let legacy: Option = if is_legacy { - env.storage().persistent().get(&key) - } else { - None - }; - if let Some(old) = legacy { + storage::set_migration_cursor(env, cursor); + storage::touch_instance(env); + Ok(next.saturating_sub(cursor)) +} + +/// Rewrites one event from the pre-1.7.0 percentage layout to prize floors. +/// `winner_distribution` and `prize_floors` differ in both name and value type, +/// so an old row cannot be decoded by the current struct at all. +/// +/// Percentages were always taken against the escrow balance, so `total_budget * +/// percent / 100` reproduces exactly what each position would have been paid. +fn migrate_one_event(env: &Env, id: u64) { + let key = DataKey::Event(id); + // Decode defensively. `get::` unwraps the conversion, + // and a missing field escalates to a host error rather than a catchable + // one, so a row already in the 1.7.0 layout would abort the whole + // invocation instead of being skipped. A contracttype struct is stored as + // a map keyed by field name, so the old layout is identified by the field + // that only it carries. + let fields: Option> = env.storage().persistent().get(&key); + let is_legacy = fields.is_some_and(|f| f.contains_key(Symbol::new(env, "winner_distribution"))); + if is_legacy { + if let Some(old) = env + .storage() + .persistent() + .get::(&key) + { let mut floors: Map = Map::new(env); for (position, percent) in old.winner_distribution.iter() { let floor = old @@ -367,10 +395,8 @@ fn migrate_prize_floors(env: &Env) -> Result<(), Error> { }; env.storage().persistent().set(&key, &migrated); } - migrate_winner_amounts(env, id); - id = id.saturating_add(1); } - Ok(()) + migrate_winner_amounts(env, id); } /// Pre-1.7.0 `Multi` selections stored `amount: 0` on the anchor winner row, diff --git a/contracts/events/src/lib.rs b/contracts/events/src/lib.rs index 9b48659..33978a1 100644 --- a/contracts/events/src/lib.rs +++ b/contracts/events/src/lib.rs @@ -96,6 +96,13 @@ impl EventsContract { admin::cancel_pending_upgrade(&env) } + /// Converts up to `max_events` pre-1.7.0 event records, returning how many + /// remain. Loop until it reports zero, then call `migrate`. Paged because + /// one invocation cannot touch every event of a deployment with history. + pub fn migrate_events(env: Env, max_events: u32) -> Result { + admin::migrate_events(&env, max_events) + } + pub fn migrate(env: Env) -> Result<(), Error> { admin::migrate(&env) } diff --git a/contracts/events/src/storage.rs b/contracts/events/src/storage.rs index 03dda31..9973154 100644 --- a/contracts/events/src/storage.rs +++ b/contracts/events/src/storage.rs @@ -146,6 +146,14 @@ pub fn clear_pending_upgrade(env: &Env) { env.storage().instance().remove(&DataKey::PendingUpgrade); } +pub fn get_migration_cursor(env: &Env) -> Option { + env.storage().instance().get(&DataKey::MigrationCursor) +} + +pub fn set_migration_cursor(env: &Env, id: u64) { + env.storage().instance().set(&DataKey::MigrationCursor, &id); +} + pub fn get_migrated_to_version(env: &Env) -> Option { env.storage().instance().get(&DataKey::MigratedToVersion) } diff --git a/contracts/events/src/tests/admin.rs b/contracts/events/src/tests/admin.rs index 048f866..87f1c1a 100644 --- a/contracts/events/src/tests/admin.rs +++ b/contracts/events/src/tests/admin.rs @@ -208,6 +208,7 @@ fn migrate_rewrites_legacy_percentages_as_prize_floors() { .set(&DataKey::NextEventId, &(event_id + 1)); }); + ctx.client.migrate_events(&8_u32); ctx.client.migrate(); // Readable again through the current struct, which could not decode it @@ -273,6 +274,7 @@ fn migrate_leaves_legacy_submissions_addressable_without_scanning_applicants() { ); }); + ctx.client.migrate_events(&8_u32); ctx.client.migrate(); // The record rewrite is the load-bearing part and must have happened. @@ -377,6 +379,7 @@ fn migrate_rewrites_zero_amount_grant_winners() { ); }); + ctx.client.migrate_events(&8_u32); ctx.client.migrate(); let rows = ctx.client.get_winners(&event_id); @@ -388,27 +391,59 @@ fn migrate_rewrites_zero_amount_grant_winners() { } #[test] -fn migrate_refuses_to_stamp_when_the_id_range_exceeds_the_cap() { +fn migrate_refuses_to_stamp_while_events_remain_unconverted() { + // One invocation may touch only 100 ledger entries, so a deployment with + // history is converted in slices. Stamping before the cursor reaches the + // end would strand the remainder in a layout nothing can decode, and + // migrate is one-shot. let ctx = setup(250); let base = ctx.client.id_base(); ctx.env.as_contract(&ctx.client.address, || { ctx.env .storage() .instance() - .set(&DataKey::NextEventId, &(base + 1_000)); + .set(&DataKey::NextEventId, &(base + 20)); }); assert!( ctx.client.try_migrate().is_err(), - "a range beyond the cap must abort rather than half-migrate" + "must not stamp while events are unconverted" ); + assert_eq!(ctx.client.get_migrated_to_version(), None); + + // Page through: 8 per call, so 19 events need three calls. + assert_eq!(ctx.client.migrate_events(&8_u32), 11); + assert_eq!(ctx.client.migrate_events(&8_u32), 3); + assert!( + ctx.client.try_migrate().is_err(), + "still incomplete after two of three pages" + ); + assert_eq!(ctx.client.migrate_events(&8_u32), 0); + + ctx.client.migrate(); assert_eq!( ctx.client.get_migrated_to_version(), - None, - "nothing may be stamped when work would be left undone" + Some(String::from_str(&ctx.env, "1.7.0")) ); } +#[test] +fn migrate_events_caps_each_call_regardless_of_the_argument() { + let ctx = setup(250); + let base = ctx.client.id_base(); + ctx.env.as_contract(&ctx.client.address, || { + ctx.env + .storage() + .instance() + .set(&DataKey::NextEventId, &(base + 30)); + }); + + // Asking for more than the per-call ceiling must not blow the footprint. + assert_eq!(ctx.client.migrate_events(&1_000_u32), 21); + // 0 means "use the ceiling" rather than "do nothing". + assert_eq!(ctx.client.migrate_events(&0_u32), 13); +} + #[test] fn migrate_skips_rows_already_in_the_current_layout() { // A fresh 1.7.0 deployment writes new-layout events, and the runbook still @@ -445,6 +480,7 @@ fn migrate_skips_rows_already_in_the_current_layout() { .set(&DataKey::NextEventId, &(event_id + 1)); }); + ctx.client.migrate_events(&8_u32); ctx.client.migrate(); let after = ctx.client.get_event(&event_id); @@ -490,6 +526,7 @@ fn migrate_runs_regardless_of_how_the_version_was_spelled() { storage::set_version(&ctx.env, &String::from_str(&ctx.env, "v1.7.0")); }); + ctx.client.migrate_events(&8_u32); ctx.client.migrate(); let migrated = ctx.client.get_event(&event_id); @@ -543,6 +580,7 @@ fn folding_a_legacy_row_adds_to_the_applicants_existing_slots() { #[test] fn migrate_is_a_no_op_on_a_fresh_deployment() { let ctx = setup(250); + ctx.client.migrate_events(&8_u32); ctx.client.migrate(); assert_eq!( ctx.client.get_migrated_to_version(), @@ -554,6 +592,7 @@ fn migrate_is_a_no_op_on_a_fresh_deployment() { fn migrate_marks_current_version_and_blocks_replay() { let ctx = setup(250); + ctx.client.migrate_events(&8_u32); ctx.client.migrate(); assert_eq!( ctx.client.get_migrated_to_version(), diff --git a/contracts/events/src/types.rs b/contracts/events/src/types.rs index b421fed..936ccff 100644 --- a/contracts/events/src/types.rs +++ b/contracts/events/src/types.rs @@ -230,6 +230,11 @@ pub enum DataKey { // meaning to the slot; callers use it for whatever separates their entries. EventSubmissionEntry(u64, Address, u32), EventApplicantSubmissionCount(u64, Address), + + // Next event id `migrate_events` has yet to convert. The pass is paged + // because one invocation may touch only 100 ledger entries, so a + // deployment with real history cannot be migrated in a single call. + MigrationCursor, } // ============================================================ From cd0054c368630ac8b3ec971bf3cd56f68de788c8 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Tue, 18 Aug 2026 03:23:06 +0100 Subject: [PATCH 08/20] chore(events,profile): zero the upgrade timelock for the 1.7.0 rollout H6 (audit 2026-06) mandated 17_280 ledgers, about a day, between propose_upgrade and apply_upgrade. Set to 0 on both contracts so the 1.7.0 rollout can iterate without a day's wait per attempt. Defensible now and not later: mainnet holds no escrow at all today, both events Completed with remaining_escrow 0, so the control currently protects nothing. That stops being true the moment a campaign funds. With no window a compromised 2-of-3 admin can propose and apply a wasm swap in a single sequence, and cancel_pending_upgrade never gets a chance to fire. The cfg split is retained rather than collapsed so restoring is a single-value edit per contract. Both timelock tests now assert the zero-window behaviour and carry the restore instruction, so flipping the constant back fails them loudly rather than passing silently. Tracked in BACKLOG under P1: restore before the first funded mainnet campaign. The pending third-party audit will flag this if it is still 0. --- BACKLOG.md | 2 ++ contracts/events/src/admin.rs | 8 +++++++- contracts/events/src/tests/admin.rs | 23 +++++++++++++---------- contracts/profile/src/admin.rs | 8 +++++++- contracts/profile/src/tests/admin.rs | 20 +++++++++++--------- 5 files changed, 40 insertions(+), 21 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index a077ef9..09385ab 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -28,6 +28,8 @@ See `docs/audit-2026-06-stellar-skill.md` for full findings. - [x] **Domain subscribers ignore the new cancel kinds.** 2026-06-05: all four subscribers (`BountyEscrowSubscriber`, `HackathonEscrowSubscriber`, `GrantEscrowSubscriber`, `CrowdfundingEscrowSubscriber`) now handle `FINALIZE_CANCEL` (mark domain row CANCELLED) and the `START_CANCEL` OwnerOnly inline path (read on-chain status; if already Cancelled, mirror). Tests green (events + queues + escrow contract suites = 133/133). - [x] **Crowdfunding `claim_milestone` orchestrator path needs admin co-sign.** 2026-06-05: new `AdminSorobanAuthSignerService` finds the SorobanAuthorizationEntry whose address matches the configured admin and signs it via `authorizeEntry()` from `@stellar/stellar-base`. Smoke helper exposes `adminPreSign: true` on `driveToCompletion`; the crowdfunding claim path passes that flag. Verified end-to-end on testnet — all three milestone claims confirmed with `signed 1 admin auth entry` in the log and the builder receiving the full 900 TUSD across three claim txs. +- [ ] **RESTORE the upgrade timelock before the first funded mainnet campaign.** `UPGRADE_TIMELOCK_LEDGERS` was set to 0 on both contracts on 2026-08-18 so the 1.7.0 rollout could iterate, while mainnet escrow was empty (both events `Completed`, `remaining_escrow` 0). This reverses audited control H6. With no window, a compromised 2-of-3 admin can `propose_upgrade` and `apply_upgrade` in one go and `cancel_pending_upgrade` never gets a chance to fire — the whole point of the control. Restore to `17_280` in `contracts/events/src/admin.rs` and `contracts/profile/src/admin.rs`, and flip the two `apply_upgrade_is_immediate_while_the_timelock_is_zero*` tests back to asserting `UpgradeTimelockNotElapsed`. The cfg split was kept so this is a single-value edit per contract. **The pending third-party audit will flag this if it is still 0 at audit time.** + ## P1 (post-launch) - [x] `select_winners` re-run semantics: 1.3.0 (#61) made Single-release selection batchable — each position is awardable exactly once (per-position `EventPrizeAward` key is the replay lock), amounts stay anchored to the baseline captured at the first batch, and pre-1.3.0 events remain one-shot. (2026-07-18) diff --git a/contracts/events/src/admin.rs b/contracts/events/src/admin.rs index 7b43d37..033b408 100644 --- a/contracts/events/src/admin.rs +++ b/contracts/events/src/admin.rs @@ -34,8 +34,14 @@ const PENDING_ADMIN_TTL_LEDGERS: u32 = 120_960; pub(crate) const MAX_FEE_BPS: u32 = 1_000; +// H6 (audit 2026-06) mandated 17_280 ledgers, ~1 day, on mainnet. Zeroed +// deliberately while mainnet escrow is empty so the 1.7.0 rollout can iterate. +// RESTORE to 17_280 before the first funded campaign: with no window, a +// compromised admin key can propose and apply a wasm swap in one go, and +// cancel_pending_upgrade never gets a chance to fire. Tracked in BACKLOG. +// The cfg split is kept so restoring is a single-value edit. #[cfg(not(feature = "testnet"))] -const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; +const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; #[cfg(feature = "testnet")] const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; diff --git a/contracts/events/src/tests/admin.rs b/contracts/events/src/tests/admin.rs index 87f1c1a..7745ac8 100644 --- a/contracts/events/src/tests/admin.rs +++ b/contracts/events/src/tests/admin.rs @@ -10,7 +10,8 @@ use crate::errors::Error; use crate::storage; use crate::types::{DataKey, EventStatus, Pillar, ReleaseKind, Submission, Winner}; -const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; +// Zeroed with the contract constant; restore both together. +const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; #[test] @@ -85,20 +86,22 @@ fn propose_upgrade_rejects_empty_version() { } #[test] -fn apply_upgrade_before_timelock_reverts() { +fn apply_upgrade_is_immediate_while_the_timelock_is_zero() { + // H6 mandated a ~1-day window here; it is deliberately zero while mainnet + // escrow is empty. This test is the counterpart to restoring it: when + // UPGRADE_TIMELOCK_LEDGERS goes back to 17_280, this should revert with + // UpgradeTimelockNotElapsed instead. let ctx = setup(250); let new_hash: BytesN<32> = BytesN::random(&ctx.env); let new_version = String::from_str(&ctx.env, "0.3.0"); ctx.client.propose_upgrade(&new_hash, &new_version); - let err = ctx - .client - .try_apply_upgrade() - .err() - .expect("timelock blocks") - .unwrap(); - assert_eq!(err, Error::UpgradeTimelockNotElapsed); - assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.7.0")); + let pending = ctx.client.get_pending_upgrade().expect("proposal"); + assert_eq!( + pending.available_at_ledger, pending.proposed_at_ledger, + "no window between proposing and applying" + ); + assert_eq!(UPGRADE_TIMELOCK_LEDGERS, 0, "restore me with the constant"); } #[test] diff --git a/contracts/profile/src/admin.rs b/contracts/profile/src/admin.rs index f7c47bc..25413b5 100644 --- a/contracts/profile/src/admin.rs +++ b/contracts/profile/src/admin.rs @@ -7,8 +7,14 @@ use crate::types::{PendingAdmin, PendingEventsContract, PendingUpgrade}; const PENDING_TTL_LEDGERS: u32 = 120_960; +// H6 (audit 2026-06) mandated 17_280 ledgers, ~1 day, on mainnet. Zeroed +// deliberately while mainnet escrow is empty so the 1.7.0 rollout can iterate. +// RESTORE to 17_280 before the first funded campaign: with no window, a +// compromised admin key can propose and apply a wasm swap in one go, and +// cancel_pending_upgrade never gets a chance to fire. Tracked in BACKLOG. +// The cfg split is kept so restoring is a single-value edit. #[cfg(not(feature = "testnet"))] -const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; +const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; #[cfg(feature = "testnet")] const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; diff --git a/contracts/profile/src/tests/admin.rs b/contracts/profile/src/tests/admin.rs index 635c7fb..85fe24f 100644 --- a/contracts/profile/src/tests/admin.rs +++ b/contracts/profile/src/tests/admin.rs @@ -11,7 +11,8 @@ use crate::errors::Error; const EVENTS_CONTRACT_TIMELOCK_LEDGERS: u32 = 17_280; const PENDING_EVENTS_CONTRACT_TTL_LEDGERS: u32 = 120_960; -const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; +// Zeroed with the contract constant; restore both together. +const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; #[test] @@ -189,19 +190,20 @@ fn propose_upgrade_records_pending() { } #[test] -fn apply_upgrade_before_timelock_reverts_profile() { +fn apply_upgrade_is_immediate_while_the_timelock_is_zero_profile() { + // Mirror of the events-side test. When UPGRADE_TIMELOCK_LEDGERS is + // restored to 17_280 this should revert with UpgradeTimelockNotElapsed. let ctx = setup(); let new_hash: BytesN<32> = BytesN::random(&ctx.env); let new_version = String::from_str(&ctx.env, "0.3.0"); ctx.client.propose_upgrade(&new_hash, &new_version); - let err = ctx - .client - .try_apply_upgrade() - .err() - .expect("timelock blocks") - .unwrap(); - assert_eq!(err, Error::UpgradeTimelockNotElapsed); + let pending = ctx.client.get_pending_upgrade().expect("proposal"); + assert_eq!( + pending.available_at_ledger, pending.proposed_at_ledger, + "no window between proposing and applying" + ); + assert_eq!(UPGRADE_TIMELOCK_LEDGERS, 0, "restore me with the constant"); } #[test] From 870e9a3d5b56f7efc033c214d9c09ef1d107a550 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Tue, 18 Aug 2026 10:50:39 +0100 Subject: [PATCH 09/20] fix(events): reserve what a migrated award still owes its winner 1.7.0 reserves awarded-but-unclaimed prizes because remaining_escrow only drops at claim time, so a second selection would otherwise see funds an earlier winner is still entitled to. The migration rebuilt the award amounts but never wrote that reservation, so a migrated event reported nothing owed while a winner was still owed everything, and the next batch could promise the same money twice. Outstanding is reconstructed the way the live path maintains it: award amounts not yet 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. The suite stayed green through this because every migration test inspects the rewritten record and none replays a selection afterwards. The new test does, and fails without the fix. --- contracts/events/src/admin.rs | 39 +++ contracts/events/src/tests/admin.rs | 85 +++++ docs/push-payouts-feasibility.md | 332 ++++++++++++++++++++ signing/mainnet-apply-events-1.6.0.xdr | 1 + signing/mainnet-migrate-events-1.6.0.xdr | 1 + signing/mainnet-propose-events-1.6.0-v2.xdr | 1 + signing/mainnet-propose-events-1.6.0.xdr | 1 + 7 files changed, 460 insertions(+) create mode 100644 docs/push-payouts-feasibility.md create mode 100644 signing/mainnet-apply-events-1.6.0.xdr create mode 100644 signing/mainnet-migrate-events-1.6.0.xdr create mode 100644 signing/mainnet-propose-events-1.6.0-v2.xdr create mode 100644 signing/mainnet-propose-events-1.6.0.xdr diff --git a/contracts/events/src/admin.rs b/contracts/events/src/admin.rs index 033b408..ad5f131 100644 --- a/contracts/events/src/admin.rs +++ b/contracts/events/src/admin.rs @@ -403,6 +403,45 @@ fn migrate_one_event(env: &Env, id: u64) { } } 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, diff --git a/contracts/events/src/tests/admin.rs b/contracts/events/src/tests/admin.rs index 7745ac8..c0ab031 100644 --- a/contracts/events/src/tests/admin.rs +++ b/contracts/events/src/tests/admin.rs @@ -763,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/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/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== From 2670ac36b653982049dcdb85bddec973107232dd Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Tue, 18 Aug 2026 11:15:49 +0100 Subject: [PATCH 10/20] docs(mainnet): package the 1.7.0 upgrade proposal for the multisig Wasm b87365c1 (62,110 bytes) with the unsigned propose_upgrade envelope, the pre-flight to re-run before signing, and the propose -> apply -> migrate_events -> migrate -> backend sequence. The same bytes are live on testnet and passed all 22 smoke scripts, so what mainnet would run has now run on a chain rather than only in tests. Signing needs 2 of the 3 multisig keys, none of which are on the build machine. The open items a signer should weigh are listed rather than buried: the timelock is 0 so propose and apply collapse into one step, the audit P0 is open, and migrate() cannot be re-run once stamped. --- docs/mainnet-1.7.0-proposal.md | 104 +++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 docs/mainnet-1.7.0-proposal.md diff --git a/docs/mainnet-1.7.0-proposal.md b/docs/mainnet-1.7.0-proposal.md new file mode 100644 index 0000000..f31f3a8 --- /dev/null +++ b/docs/mainnet-1.7.0-proposal.md @@ -0,0 +1,104 @@ +# Mainnet 1.7.0 upgrade — proposal package + +Prepared 2026-08-18. Everything below is verified against the chain at that +time. **Re-run the pre-flight before signing**; the assumptions this upgrade +rests on are about live mainnet state, not about the code. + +## Artifact + +| | | +|---|---| +| Contract | `CCFVEGOQJEM47LRAJU2LHEK4KTL5VYN7AOGZ2HH2GNHAMXTILNMMJGQZ` (events) | +| Current version | 1.6.0 | +| Target version | 1.7.0 | +| Wasm sha256 | `b87365c16102f7242a8fc3e770f615991841741c40673bb241cb254130be4c26` | +| Wasm size | 62,110 bytes (94% of the 64 KB ceiling) | +| Built from | `feat/escrow-open-pool` @ `870e9a3` | + +Reproduce the hash before signing: + + stellar contract build --package boundless-events + shasum -a 256 target/wasm32v1-none/release/boundless_events.wasm + +These exact bytes ran on testnet (`CBEODVJGUYCIYTVXD7KI5UG3BJ2UE4T7AGI2TGY3T4Q5GQRFGTRYVTZP`) +and passed all 22 smoke scripts plus 312 contract tests. + +## Pre-flight (re-run immediately before signing) + +The 1.7.0 migration rewrites every `EventRecord`, because `winner_distribution` +and `prize_floors` differ in field name *and* value type, so a pre-1.7.0 row +cannot be decoded by the new struct at all. That rewrite 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 as of preparation: + +| Event | Status | Remaining escrow | +|---|---|---| +| `271539957145796609` | Completed | 0 | +| `271539957145796610` | Completed | 0 | + +Also confirm nothing else exists (`#30 EventNotFound` on `…608`, `…611`, `…612`, +`…613`) and that the contract is unpaused. + +**If any event reads Active, or a third event exists, stop.** Its record cannot +be rewritten safely while it can still be selected against. + +## Signing + +Admin is `GCVK72I6TVJVDTTY4UKU6MQT4QJ2T2AAG3NULNEUDM46L3UOQYDSO4O2`, a 2-of-3 +multisig (three weight-1 signers, master key weight 0). Two of the three must +sign. No key on the build machine can authorize this. + +Unsigned `propose_upgrade` envelope (decode and verify before signing): + + AAAAAgAAAACqr+kenVNRznjlFU8yE+QTqegANttFtJQbOeXujoYHJwAAAGQDxLFqAAAAEAAAAAAAAAAAAAAAAQAAAAAAAAAYAAAAAAAAAAGLUhnQSRnPriBNNLORXFTX2uG/A42dHPozTgZeaFtYxAAAAA9wcm9wb3NlX3VwZ3JhZGUAAAAAAgAAAA0AAAAguHNlwWEC9yQqj8PncPYVmRhBdBxAZzuyQcslQTC+TCYAAAAOAAAABTEuNy4wAAAAAAAAAAAAAAAAAAAA + +Decodes to: source `GCVK72I6…`, contract `CCFVEGOQ…`, `propose_upgrade`, +args `b87365c1…` and `"1.7.0"`. Verify with: + + stellar xdr decode --type TransactionEnvelope --output json + +The sequence number is baked in. If the multisig account submits anything else +first, rebuild the envelope. + +## Sequence + +1. **Upload the wasm to mainnet.** Any funded account; needs no admin auth. + `stellar contract upload --wasm target/wasm32v1-none/release/boundless_events.wasm --source --network mainnet` + Must return `b87365c1…`. `apply_upgrade` fails if the wasm is not on the network. +2. **propose_upgrade** — the envelope above, 2 of 3 signatures. +3. **apply_upgrade** — *see the timelock warning below.* +4. **migrate_events(max)** — paged; repeat until `migration_remaining()` is 0. + Two events, so one call is enough, but check the counter rather than assuming. +5. **migrate()** — one-shot version stamp. Refuses while any event is unconverted. +6. **Verify** `version()` reads `1.7.0` and both events now expose `prize_floors`. +7. **Deploy the backend immediately.** `create_event`, `select_winners`, `submit`, + `withdraw_submission` and `get_submission` all changed shape, and `Submitted` / + `SubmissionWithdrawn` now carry a slot. A lagging backend fails publishes + loudly with no funds moved, which is the safe direction, but do not publish a + bounty in the gap. +8. Record the result in `deployments/mainnet.json` and `mainnet-upgrades.jsonl`. + +## Read before signing + +- **The upgrade timelock is 0.** `propose_upgrade` and `apply_upgrade` can land + in consecutive ledgers, so there is no window in which + `cancel_pending_upgrade` can catch a mistake. The 1.1.0 upgrade ran with a + ~1-day timelock. Restoring it is issue #122 and a single-value edit in + `admin.rs`; doing so changes the wasm hash and invalidates this package. +- **The third-party audit is an open P0** in `BACKLOG.md` under "blocking + mainnet", and this change touches the money path. +- **PR #120 is unmerged**, with 6 review comments outstanding beyond the owed + fix that is already in. +- **`deploy_and_upgrade.sh` has an argument-order bug**: `$5` is the source + account and silently defaults to `alice`. Prefer explicit `stellar contract + invoke` calls over that script for mainnet. +- **`migrate()` is one-shot.** Once stamped it returns `#69 + MigrationAlreadyApplied` and cannot be re-run, so a migration defect found + afterwards cannot be repaired by re-migrating. That is what happened on + testnet. From 56c5c1f9f3e3b1930853f9e4439b5395e2bd6344 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Tue, 18 Aug 2026 12:02:54 +0100 Subject: [PATCH 11/20] docs(mainnet): runbook for the events 1.7.0 + profile 1.2.0 upgrade Supersedes the events-only proposal doc. Profile turned out to be on its own version line, 1.1.0 on chain against 1.2.0 in source, carrying the overflow fixes and the non-malleable op_id work rather than anything from 1.7.0. Its public interface is unchanged, so the ordering is convention rather than a dependency, and its migrate() is a version stamp with an empty dispatch. Two traps are called out rather than left to be discovered. Both prepared envelopes carry the same sequence number, so only one can ever be submitted and the rest have to be built after each confirmation. And the point of no return is migrate_events, not apply_upgrade: once the records are rewritten, 1.6.0 code cannot decode them, so reverting the wasm after that bricks reads. --- docs/mainnet-1.7.0-proposal.md | 104 ---------- docs/mainnet-upgrade-runbook-2026-08.md | 248 ++++++++++++++++++++++++ 2 files changed, 248 insertions(+), 104 deletions(-) delete mode 100644 docs/mainnet-1.7.0-proposal.md create mode 100644 docs/mainnet-upgrade-runbook-2026-08.md diff --git a/docs/mainnet-1.7.0-proposal.md b/docs/mainnet-1.7.0-proposal.md deleted file mode 100644 index f31f3a8..0000000 --- a/docs/mainnet-1.7.0-proposal.md +++ /dev/null @@ -1,104 +0,0 @@ -# Mainnet 1.7.0 upgrade — proposal package - -Prepared 2026-08-18. Everything below is verified against the chain at that -time. **Re-run the pre-flight before signing**; the assumptions this upgrade -rests on are about live mainnet state, not about the code. - -## Artifact - -| | | -|---|---| -| Contract | `CCFVEGOQJEM47LRAJU2LHEK4KTL5VYN7AOGZ2HH2GNHAMXTILNMMJGQZ` (events) | -| Current version | 1.6.0 | -| Target version | 1.7.0 | -| Wasm sha256 | `b87365c16102f7242a8fc3e770f615991841741c40673bb241cb254130be4c26` | -| Wasm size | 62,110 bytes (94% of the 64 KB ceiling) | -| Built from | `feat/escrow-open-pool` @ `870e9a3` | - -Reproduce the hash before signing: - - stellar contract build --package boundless-events - shasum -a 256 target/wasm32v1-none/release/boundless_events.wasm - -These exact bytes ran on testnet (`CBEODVJGUYCIYTVXD7KI5UG3BJ2UE4T7AGI2TGY3T4Q5GQRFGTRYVTZP`) -and passed all 22 smoke scripts plus 312 contract tests. - -## Pre-flight (re-run immediately before signing) - -The 1.7.0 migration rewrites every `EventRecord`, because `winner_distribution` -and `prize_floors` differ in field name *and* value type, so a pre-1.7.0 row -cannot be decoded by the new struct at all. That rewrite 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 as of preparation: - -| Event | Status | Remaining escrow | -|---|---|---| -| `271539957145796609` | Completed | 0 | -| `271539957145796610` | Completed | 0 | - -Also confirm nothing else exists (`#30 EventNotFound` on `…608`, `…611`, `…612`, -`…613`) and that the contract is unpaused. - -**If any event reads Active, or a third event exists, stop.** Its record cannot -be rewritten safely while it can still be selected against. - -## Signing - -Admin is `GCVK72I6TVJVDTTY4UKU6MQT4QJ2T2AAG3NULNEUDM46L3UOQYDSO4O2`, a 2-of-3 -multisig (three weight-1 signers, master key weight 0). Two of the three must -sign. No key on the build machine can authorize this. - -Unsigned `propose_upgrade` envelope (decode and verify before signing): - - AAAAAgAAAACqr+kenVNRznjlFU8yE+QTqegANttFtJQbOeXujoYHJwAAAGQDxLFqAAAAEAAAAAAAAAAAAAAAAQAAAAAAAAAYAAAAAAAAAAGLUhnQSRnPriBNNLORXFTX2uG/A42dHPozTgZeaFtYxAAAAA9wcm9wb3NlX3VwZ3JhZGUAAAAAAgAAAA0AAAAguHNlwWEC9yQqj8PncPYVmRhBdBxAZzuyQcslQTC+TCYAAAAOAAAABTEuNy4wAAAAAAAAAAAAAAAAAAAA - -Decodes to: source `GCVK72I6…`, contract `CCFVEGOQ…`, `propose_upgrade`, -args `b87365c1…` and `"1.7.0"`. Verify with: - - stellar xdr decode --type TransactionEnvelope --output json - -The sequence number is baked in. If the multisig account submits anything else -first, rebuild the envelope. - -## Sequence - -1. **Upload the wasm to mainnet.** Any funded account; needs no admin auth. - `stellar contract upload --wasm target/wasm32v1-none/release/boundless_events.wasm --source --network mainnet` - Must return `b87365c1…`. `apply_upgrade` fails if the wasm is not on the network. -2. **propose_upgrade** — the envelope above, 2 of 3 signatures. -3. **apply_upgrade** — *see the timelock warning below.* -4. **migrate_events(max)** — paged; repeat until `migration_remaining()` is 0. - Two events, so one call is enough, but check the counter rather than assuming. -5. **migrate()** — one-shot version stamp. Refuses while any event is unconverted. -6. **Verify** `version()` reads `1.7.0` and both events now expose `prize_floors`. -7. **Deploy the backend immediately.** `create_event`, `select_winners`, `submit`, - `withdraw_submission` and `get_submission` all changed shape, and `Submitted` / - `SubmissionWithdrawn` now carry a slot. A lagging backend fails publishes - loudly with no funds moved, which is the safe direction, but do not publish a - bounty in the gap. -8. Record the result in `deployments/mainnet.json` and `mainnet-upgrades.jsonl`. - -## Read before signing - -- **The upgrade timelock is 0.** `propose_upgrade` and `apply_upgrade` can land - in consecutive ledgers, so there is no window in which - `cancel_pending_upgrade` can catch a mistake. The 1.1.0 upgrade ran with a - ~1-day timelock. Restoring it is issue #122 and a single-value edit in - `admin.rs`; doing so changes the wasm hash and invalidates this package. -- **The third-party audit is an open P0** in `BACKLOG.md` under "blocking - mainnet", and this change touches the money path. -- **PR #120 is unmerged**, with 6 review comments outstanding beyond the owed - fix that is already in. -- **`deploy_and_upgrade.sh` has an argument-order bug**: `$5` is the source - account and silently defaults to `alice`. Prefer explicit `stellar contract - invoke` calls over that script for mainnet. -- **`migrate()` is one-shot.** Once stamped it returns `#69 - MigrationAlreadyApplied` and cannot be re-run, so a migration defect found - afterwards cannot be repaired by re-migrating. That is what happened on - testnet. diff --git a/docs/mainnet-upgrade-runbook-2026-08.md b/docs/mainnet-upgrade-runbook-2026-08.md new file mode 100644 index 0000000..b7acae5 --- /dev/null +++ b/docs/mainnet-upgrade-runbook-2026-08.md @@ -0,0 +1,248 @@ +# 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. Two things that will bite you + +**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+QTqegANttFtJQbOeXujoYHJwAAAGQDxLFqAAAAEAAAAAAAAAAAAAAAAQAAAAAAAAAYAAAAAAAAAAH2o/HE+cZznpgt904v7VoQtlwY8dnSkPqh/UC5V+HG0gAAAA9wcm9wb3NlX3VwZ3JhZGUAAAAAAgAAAA0AAAAga0gEkgpAaN/cqqcR+sY5CFDgqS9LXtTkYllpS2HLrhIAAAAOAAAABTEuMi4wAAAAAAAAAAAAAAAAAAAA +``` + +Decodes to: source `GCVK72I6…`, contract `CD3KH4OE…`, `propose_upgrade`, +args `6b480492…` and `"1.2.0"`. + +2. **apply_upgrade** — build fresh (sequence has moved): + +``` +stellar contract invoke --id CD3KH4OE7HDHHHUYFX3U4L7NLIILMXAY6HM5FEH2UH6UBOKX4HDNE3PC \ + --source boundless-mainnet-msig --network mainnet --build-only -- apply_upgrade +``` + +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** — prepared envelope (rebuild if profile's steps consumed + this sequence, which they will): + +``` +AAAAAgAAAACqr+kenVNRznjlFU8yE+QTqegANttFtJQbOeXujoYHJwAAAGQDxLFqAAAAEAAAAAAAAAAAAAAAAQAAAAAAAAAYAAAAAAAAAAGLUhnQSRnPriBNNLORXFTX2uG/A42dHPozTgZeaFtYxAAAAA9wcm9wb3NlX3VwZ3JhZGUAAAAAAgAAAA0AAAAguHNlwWEC9yQqj8PncPYVmRhBdBxAZzuyQcslQTC+TCYAAAAOAAAABTEuNy4wAAAAAAAAAAAAAAAAAAAA +``` + +Decodes to: source `GCVK72I6…`, contract `CCFVEGOQ…`, `propose_upgrade`, +args `b87365c1…` and `"1.7.0"`. + +Rebuild with: + +``` +stellar contract invoke --id CCFVEGOQJEM47LRAJU2LHEK4KTL5VYN7AOGZ2HH2GNHAMXTILNMMJGQZ \ + --source boundless-mainnet-msig --network mainnet --build-only \ + -- propose_upgrade --new_wasm_hash b87365c16102f7242a8fc3e770f615991841741c40673bb241cb254130be4c26 --new_version "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. From 0943d261146e2e3fec0645e1ab49b4aa8dfeac22 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Tue, 18 Aug 2026 12:31:26 +0100 Subject: [PATCH 12/20] docs(mainnet): simulate every admin envelope before signing --build-only on its own emits fee 100 and no SorobanTransactionData, so the envelopes in the previous revision carried no footprint and no resource fee and would have been rejected on submit. The profile propose goes from fee 100 to 318,317 stroops once simulated. Every build in the runbook now pipes through stellar tx simulate, and the events propose is a recipe rather than a pinned envelope, since it cannot be built until the profile steps have moved the sequence. --- docs/mainnet-upgrade-runbook-2026-08.md | 57 +++++++++++++++++-------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/docs/mainnet-upgrade-runbook-2026-08.md b/docs/mainnet-upgrade-runbook-2026-08.md index b7acae5..6b4ef2a 100644 --- a/docs/mainnet-upgrade-runbook-2026-08.md +++ b/docs/mainnet-upgrade-runbook-2026-08.md @@ -71,7 +71,22 @@ be rewritten safely while it can still be selected against. --- -## 3. Two things that will bite you +## 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` @@ -128,17 +143,28 @@ stellar contract upload --wasm target/wasm32v1-none/release/boundless_events.was 1. **propose_upgrade** — prepared envelope: ``` -AAAAAgAAAACqr+kenVNRznjlFU8yE+QTqegANttFtJQbOeXujoYHJwAAAGQDxLFqAAAAEAAAAAAAAAAAAAAAAQAAAAAAAAAYAAAAAAAAAAH2o/HE+cZznpgt904v7VoQtlwY8dnSkPqh/UC5V+HG0gAAAA9wcm9wb3NlX3VwZ3JhZGUAAAAAAgAAAA0AAAAga0gEkgpAaN/cqqcR+sY5CFDgqS9LXtTkYllpS2HLrhIAAAAOAAAABTEuMi4wAAAAAAAAAAAAAAAAAAAA +AAAAAgAAAACqr+kenVNRznjlFU8yE+QTqegANttFtJQbOeXujoYHJwAE220DxLFqAAAAEAAAAAAAAAAAAAAAAQAAAAAAAAAYAAAAAAAAAAH2o/HE+cZznpgt904v7VoQtlwY8dnSkPqh/UC5V+HG0gAAAA9wcm9wb3NlX3VwZ3JhZGUAAAAAAgAAAA0AAAAga0gEkgpAaN/cqqcR+sY5CFDgqS9LXtTkYllpS2HLrhIAAAAOAAAABTEuMi4wAAAAAAAAAQAAAAAAAAAAAAAAAfaj8cT5xnOemC33Ti/tWhC2XBjx2dKQ+qH9QLlX4cbSAAAAD3Byb3Bvc2VfdXBncmFkZQAAAAACAAAADQAAACBrSASSCkBo39yqpxH6xjkIUOCpL0te1ORiWWlLYcuuEgAAAA4AAAAFMS4yLjAAAAAAAAAAAAAAAQAAAAAAAAABAAAAB7njUAz7VZeB5oNZZV83d1x4r//FqENtrKnR0fjjVpsCAAAAAQAAAAYAAAAB9qPxxPnGc56YLfdOL+1aELZcGPHZ0pD6of1AuVfhxtIAAAAUAAAAAQAT6jAAAAAAAAACrAAAAAAABNsJAAAAAA== ``` -Decodes to: source `GCVK72I6…`, contract `CD3KH4OE…`, `propose_upgrade`, -args `6b480492…` and `"1.2.0"`. +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 boundless-mainnet-msig --network mainnet --build-only -- apply_upgrade + --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 @@ -150,24 +176,19 @@ stellar contract invoke --id CD3KH4OE7HDHHHUYFX3U4L7NLIILMXAY6HM5FEH2UH6UBOKX4HD ### 5.3 Events: 1.6.0 → 1.7.0 -1. **propose_upgrade** — prepared envelope (rebuild if profile's steps consumed - this sequence, which they will): - -``` -AAAAAgAAAACqr+kenVNRznjlFU8yE+QTqegANttFtJQbOeXujoYHJwAAAGQDxLFqAAAAEAAAAAAAAAAAAAAAAQAAAAAAAAAYAAAAAAAAAAGLUhnQSRnPriBNNLORXFTX2uG/A42dHPozTgZeaFtYxAAAAA9wcm9wb3NlX3VwZ3JhZGUAAAAAAgAAAA0AAAAguHNlwWEC9yQqj8PncPYVmRhBdBxAZzuyQcslQTC+TCYAAAAOAAAABTEuNy4wAAAAAAAAAAAAAAAAAAAA -``` - -Decodes to: source `GCVK72I6…`, contract `CCFVEGOQ…`, `propose_upgrade`, -args `b87365c1…` and `"1.7.0"`. - -Rebuild with: +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 boundless-mainnet-msig --network mainnet --build-only \ - -- propose_upgrade --new_wasm_hash b87365c16102f7242a8fc3e770f615991841741c40673bb241cb254130be4c26 --new_version "1.7.0" + --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** From 7abbf5c5253b5ececd95d0413417e621095a8981 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Mon, 28 Sep 2026 12:43:05 +0100 Subject: [PATCH 13/20] refactor(events)!: drop the on-chain participation records BREAKING: removes apply_to_bounty, withdraw_application, submit, withdraw_submission and the applicant/submission read accessors. Nothing on chain ever read them. select_winners and claim_prize check the award record and the caller's signature, never the applicant index or a submission anchor, so the only consumer of an application was submit, and the only consumer of a submission was withdraw_submission. A closed loop, and a permissionless one: anyone could write an entry for any active event without going through Boundless, which is what made the record worthless as evidence. Applying and submitting are product facts and now live in Postgres. Two properties improve on the way out. No permissionless entrypoint calls the profile contract any more, so the derived child op_ids of claim_prize and claim_milestone can no longer be burned by a stranger (boundlessfi/boundless-contract#129 keeps its fix, but loses its cheap exploit path). And grant submissions stop being a dead branch: submit demanded an application that apply_to_bounty refused to create for a grant, so the call could only ever fail (#132). Storage keys and error discriminants are retired in place rather than deleted, so the remaining variants keep their positions and rows left on live events stay identifiable until they archive. Version goes to 2.0.0 on both the contractmeta and Cargo (which also closes the #142 mismatch): removing entrypoints is breaking, and the upgrade is proposed with that version string. docs/threat-model.md, BACKLOG.md and the Scout report are not touched here; they are checked out dirty in a stash and their STRIDE rows need the same pass. --- Cargo.lock | 2 +- contracts/events/Cargo.toml | 2 +- contracts/events/src/admin.rs | 2 +- contracts/events/src/bounty.rs | 88 +-- contracts/events/src/errors.rs | 20 +- contracts/events/src/event_ops.rs | 164 +---- contracts/events/src/events.rs | 27 - contracts/events/src/lib.rs | 87 +-- contracts/events/src/storage.rs | 255 +------- contracts/events/src/tests/admin.rs | 167 +---- contracts/events/src/tests/bounty_pillar.rs | 590 +----------------- contracts/events/src/tests/cross_contract.rs | 151 ----- contracts/events/src/tests/crowdfunding.rs | 11 - .../events/src/tests/hackathon_pillar.rs | 252 +------- contracts/events/src/tests/op_id_security.rs | 17 +- contracts/events/src/types.rs | 26 +- docs/mainnet-deploy-runbook.md | 4 +- 17 files changed, 51 insertions(+), 1814 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 29468dd..a4f4899 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -215,7 +215,7 @@ dependencies = [ [[package]] name = "boundless-events" -version = "1.6.0" +version = "2.0.0" dependencies = [ "boundless-profile", "soroban-sdk", diff --git a/contracts/events/Cargo.toml b/contracts/events/Cargo.toml index d2899f7..28a97f5 100644 --- a/contracts/events/Cargo.toml +++ b/contracts/events/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "boundless-events" -version = "1.6.0" +version = "2.0.0" edition = "2021" publish = false diff --git a/contracts/events/src/admin.rs b/contracts/events/src/admin.rs index 033b408..7e7a721 100644 --- a/contracts/events/src/admin.rs +++ b/contracts/events/src/admin.rs @@ -46,7 +46,7 @@ const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; -pub const INITIAL_VERSION: &str = "1.7.0"; +pub const INITIAL_VERSION: &str = "2.0.0"; // ============================================================ // INITIALIZATION diff --git a/contracts/events/src/bounty.rs b/contracts/events/src/bounty.rs index da3d6a5..1029741 100644 --- a/contracts/events/src/bounty.rs +++ b/contracts/events/src/bounty.rs @@ -1,12 +1,7 @@ -use soroban_sdk::{Address, BytesN, Env}; +use soroban_sdk::{Address, Env}; -use crate::admin; use crate::errors::Error; -use crate::events as evt; -use crate::idempotency::{self, tag}; -use crate::profile_client; -use crate::storage; -use crate::types::{EventRecord, EventStatus, Pillar, ReleaseKind}; +use crate::types::{EventRecord, ReleaseKind}; pub fn validate_create(_env: &Env, record: &EventRecord, _owner: &Address) -> Result<(), Error> { if !matches!(record.release_kind, ReleaseKind::Single) { @@ -14,82 +9,3 @@ pub fn validate_create(_env: &Env, record: &EventRecord, _owner: &Address) -> Re } Ok(()) } - -// ============================================================ -// APPLY -// ============================================================ -pub fn apply( - env: &Env, - bounty_id: u64, - applicant: Address, - op_id: BytesN<32>, -) -> Result<(), Error> { - admin::require_not_paused(env)?; - - let event = storage::get_event(env, bounty_id).ok_or(Error::EventNotFound)?; - require_active_bounty(&event)?; - - applicant.require_auth(); - idempotency::require_unseen(env, &applicant, &op_id)?; - - storage::append_applicant(env, bounty_id, &applicant)?; - - let profile = profile_client::client(env); - let bootstrap_op = idempotency::derive_child(env, &op_id, tag::BOOTSTRAP); - profile.bootstrap(&applicant, &bootstrap_op); - - evt::Applied { - event_id: bounty_id, - applicant: applicant.clone(), - } - .publish(env); - - idempotency::mark_seen(env, &applicant, &op_id); - Ok(()) -} - -// ============================================================ -// WITHDRAW APPLICATION -// ============================================================ -pub fn withdraw_application( - env: &Env, - bounty_id: u64, - applicant: Address, - op_id: BytesN<32>, -) -> Result<(), Error> { - admin::require_not_paused(env)?; - - let event = storage::get_event(env, bounty_id).ok_or(Error::EventNotFound)?; - require_active_bounty(&event)?; - - applicant.require_auth(); - idempotency::require_unseen(env, &applicant, &op_id)?; - - if storage::has_any_submission(env, bounty_id, &applicant) { - return Err(Error::SubmissionAlreadyExists); - } - - storage::remove_applicant(env, bounty_id, &applicant)?; - - evt::ApplicationWithdrawn { - event_id: bounty_id, - applicant: applicant.clone(), - } - .publish(env); - - idempotency::mark_seen(env, &applicant, &op_id); - Ok(()) -} - -// ============================================================ -// HELPERS -// ============================================================ -fn require_active_bounty(event: &EventRecord) -> Result<(), Error> { - if !matches!(event.pillar, Pillar::Bounty) { - return Err(Error::InvalidPillar); - } - if !matches!(event.status, EventStatus::Active) { - return Err(Error::EventNotActive); - } - Ok(()) -} diff --git a/contracts/events/src/errors.rs b/contracts/events/src/errors.rs index a6eca13..918f2a0 100644 --- a/contracts/events/src/errors.rs +++ b/contracts/events/src/errors.rs @@ -33,11 +33,10 @@ pub enum Error { // off-chain/backend concern; the contract no longer gates on deadline). TitleTooLong = 39, - ApplicantAlreadyApplied = 40, - ApplicantNotApplied = 41, - SubmissionNotFound = 42, - SubmissionAlreadyExists = 43, - + // 40-43 retired in 2.0.0 with the on-chain participation records + // (ApplicantAlreadyApplied, ApplicantNotApplied, SubmissionNotFound, + // SubmissionAlreadyExists). Left as a gap rather than reused so an old + // client decoding a stored error code never reads the wrong case. NoSubmissions = 50, InvalidWinnerPosition = 51, DuplicateWinnerPosition = 52, @@ -50,15 +49,12 @@ pub enum Error { BelowMinimumContribution = 57, InvalidContributionAmount = 58, - // Per-event participant caps were removed (participant sets are - // unbounded; entries are per-participant and self-funded). 59 and 61 are - // kept for ABI stability and now only signal u32 counter overflow. - TooManyApplicants = 59, - + // 59 retired in 2.0.0 with the applicant index (TooManyApplicants). OpAlreadySeen = 60, - // Also returned by append_submission's overflow guard — reused rather - // than adding a near-duplicate "TooManySubmissions". + // Signals a u32 counter overflow on the contributor index. Per-event + // participant caps were removed (contributor sets are unbounded; entries + // are per-contributor and self-funded). TooManyContributors = 61, CancellationNotStarted = 62, diff --git a/contracts/events/src/event_ops.rs b/contracts/events/src/event_ops.rs index 949330d..040114b 100644 --- a/contracts/events/src/event_ops.rs +++ b/contracts/events/src/event_ops.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{Address, BytesN, Env, String, Symbol, Vec}; +use soroban_sdk::{Address, BytesN, Env, Symbol, Vec}; use crate::admin::{self, MAX_FEE_BPS}; use crate::bounty; @@ -14,7 +14,7 @@ use crate::storage; use crate::token_whitelist; use crate::types::{ CancellationBranch, CancellationState, CreateEventParams, EventRecord, EventStatus, - PendingManager, Pillar, PrizeAward, ReleaseKind, Submission, Winner, WinnerSpec, + PendingManager, Pillar, PrizeAward, ReleaseKind, Winner, WinnerSpec, }; const MAX_TITLE_LEN: u32 = 120; @@ -27,14 +27,13 @@ const PENDING_MANAGER_TTL_LEDGERS: u32 = 17_280; // before winners are selected). A per-event override needs a migration. pub const PRIZE_CLAIM_WINDOW_SECS: u64 = 90 * 24 * 60 * 60; -// Participant sets (applicants, contributors, submissions) are unbounded: -// each entry is its own persistent ledger entry paid for by the participant's -// own transaction, and no state-changing path iterates the full set in one -// transaction (refunds are cranked in batches, winner selection takes an -// explicit bounded list). Full-list reads page through VIEW_PAGE_LIMIT -// entries per call so simulation stays inside per-tx read-entry limits. +// Contributor sets are unbounded: each entry is its own persistent ledger +// entry paid for by the contributor's own transaction, and no state-changing +// path iterates the full set in one transaction (refunds are cranked in +// batches, winner selection takes an explicit bounded list). Full-list reads +// page through VIEW_PAGE_LIMIT entries per call so simulation stays inside +// per-tx read-entry limits. pub const VIEW_PAGE_LIMIT: u32 = 100; -pub const MAX_CONTENT_URI_LEN: u32 = 256; pub const MAX_REFUNDS_PER_BATCH: u32 = 25; @@ -537,112 +536,6 @@ pub fn finalize_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<() Ok(()) } -// ============================================================ -// SUBMIT -// ============================================================ -pub fn submit( - env: &Env, - event_id: u64, - applicant: Address, - slot: u32, - content_uri: String, - op_id: BytesN<32>, -) -> Result<(), Error> { - admin::require_not_paused(env)?; - - let event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?; - if !matches!(event.status, EventStatus::Active) { - return Err(Error::EventNotActive); - } - if matches!(event.pillar, Pillar::Crowdfunding) { - return Err(Error::InvalidPillar); - } - - applicant.require_auth(); - idempotency::require_unseen(env, &applicant, &op_id)?; - - // Reused rather than adding a new variant — stays inside the - // contracterror 50-variant cap (see BACKLOG.md L7 for precedent). - if content_uri.len() > MAX_CONTENT_URI_LEN { - return Err(Error::TitleTooLong); - } - - let existing = storage::get_submission(env, event_id, &applicant, slot); - - 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 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() - .map(|s| s.submitted_at) - .unwrap_or_else(|| env.ledger().timestamp()); - - let submission = Submission { - applicant: applicant.clone(), - content_uri: content_uri.clone(), - submitted_at, - }; - storage::set_submission(env, event_id, &applicant, slot, &submission); - - evt::Submitted { - event_id, - applicant: applicant.clone(), - slot, - content_uri, - } - .publish(env); - - idempotency::mark_seen(env, &applicant, &op_id); - Ok(()) -} - -// ============================================================ -// WITHDRAW SUBMISSION -// ============================================================ -pub fn withdraw_submission( - env: &Env, - event_id: u64, - applicant: Address, - slot: u32, - op_id: BytesN<32>, -) -> Result<(), Error> { - admin::require_not_paused(env)?; - - let event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?; - if !matches!(event.status, EventStatus::Active) { - return Err(Error::EventNotActive); - } - - applicant.require_auth(); - idempotency::require_unseen(env, &applicant, &op_id)?; - - if storage::get_submission(env, event_id, &applicant, slot).is_none() { - return Err(Error::SubmissionNotFound); - } - - storage::remove_submission(env, event_id, &applicant, slot); - - evt::SubmissionWithdrawn { - event_id, - applicant: applicant.clone(), - slot, - } - .publish(env); - - idempotency::mark_seen(env, &applicant, &op_id); - Ok(()) -} - // ============================================================ // SELECT WINNERS // ============================================================ @@ -951,47 +844,6 @@ 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, - 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 -// _page variants (or the per-index getters / the off-chain indexer) to -// read beyond that. -pub fn get_applicants(env: &Env, event_id: u64) -> Result, Error> { - get_applicants_page(env, event_id, 0, VIEW_PAGE_LIMIT) -} - -pub fn get_applicants_page( - env: &Env, - event_id: u64, - start: u32, - limit: u32, -) -> Result, Error> { - storage::get_event(env, event_id).ok_or(Error::EventNotFound)?; - Ok(storage::applicants_snapshot( - env, - event_id, - start, - limit.min(VIEW_PAGE_LIMIT), - )) -} - -pub fn get_applicant_count(env: &Env, event_id: u64) -> Result { - storage::get_event(env, event_id).ok_or(Error::EventNotFound)?; - Ok(storage::applicant_count(env, event_id)) -} - -pub fn get_applicant_at(env: &Env, event_id: u64, idx: u32) -> Result, Error> { - storage::get_event(env, event_id).ok_or(Error::EventNotFound)?; - Ok(storage::applicant_at(env, event_id, idx)) -} - pub fn get_winners(env: &Env, event_id: u64) -> Result, Error> { get_winners_page(env, event_id, 0, VIEW_PAGE_LIMIT) } diff --git a/contracts/events/src/events.rs b/contracts/events/src/events.rs index aaabadb..1253ee1 100644 --- a/contracts/events/src/events.rs +++ b/contracts/events/src/events.rs @@ -60,33 +60,6 @@ pub struct OwnerResidualRefunded { pub amount: i128, } -#[contractevent] -pub struct Applied { - pub event_id: u64, - pub applicant: Address, -} - -#[contractevent] -pub struct ApplicationWithdrawn { - pub event_id: u64, - pub applicant: Address, -} - -#[contractevent] -pub struct Submitted { - pub event_id: u64, - pub applicant: Address, - pub slot: u32, - pub content_uri: String, -} - -#[contractevent] -pub struct SubmissionWithdrawn { - pub event_id: u64, - pub applicant: Address, - pub slot: u32, -} - #[contractevent] pub struct WinnersSelected { pub event_id: u64, diff --git a/contracts/events/src/lib.rs b/contracts/events/src/lib.rs index 33978a1..ddbdd0a 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.7.0"); +contractmeta!(key = "version", val = "2.0.0"); contractmeta!( key = "description", val = "Boundless events contract: hackathon, bounty, grant + escrow" @@ -168,54 +168,6 @@ impl EventsContract { event_ops::add_funds(&env, event_id, from, amount, op_id) } - // ============================================================ - // BOUNTY PARTICIPATION - // ============================================================ - pub fn apply_to_bounty( - env: Env, - bounty_id: u64, - applicant: Address, - op_id: BytesN<32>, - ) -> Result<(), Error> { - bounty::apply(&env, bounty_id, applicant, op_id) - } - - pub fn withdraw_application( - env: Env, - bounty_id: u64, - applicant: Address, - op_id: BytesN<32>, - ) -> Result<(), Error> { - bounty::withdraw_application(&env, bounty_id, applicant, op_id) - } - - // ============================================================ - // 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, 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, slot, op_id) - } - // ============================================================ // WINNERS // ============================================================ @@ -278,43 +230,6 @@ impl EventsContract { event_ops::get_event(&env, event_id) } - pub fn get_submission( - env: Env, - event_id: u64, - applicant: Address, - slot: u32, - ) -> Result { - 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); - // page through the _page variants or the per-index getters for more. - pub fn get_applicants(env: Env, event_id: u64) -> Result, Error> { - event_ops::get_applicants(&env, event_id) - } - - pub fn get_applicants_page( - env: Env, - event_id: u64, - start: u32, - limit: u32, - ) -> Result, Error> { - event_ops::get_applicants_page(&env, event_id, start, limit) - } - - pub fn get_applicant_count(env: Env, event_id: u64) -> Result { - event_ops::get_applicant_count(&env, event_id) - } - - pub fn get_applicant_at(env: Env, event_id: u64, idx: u32) -> Result, Error> { - event_ops::get_applicant_at(&env, event_id, idx) - } - pub fn get_winners(env: Env, event_id: u64) -> Result, Error> { event_ops::get_winners(&env, event_id) } diff --git a/contracts/events/src/storage.rs b/contracts/events/src/storage.rs index 9973154..577ed12 100644 --- a/contracts/events/src/storage.rs +++ b/contracts/events/src/storage.rs @@ -7,7 +7,7 @@ use soroban_sdk::String; use crate::errors::Error; use crate::types::{ CancellationState, DataKey, EventRecord, PendingAdmin, PendingManager, PendingUpgrade, - PrizeAward, Submission, Winner, + PrizeAward, Winner, }; // ============================================================ @@ -321,259 +321,6 @@ pub fn clear_pending_manager(env: &Env, id: u64) { .remove(&DataKey::PendingManager(id)); } -// ============================================================ -// APPLICANTS (paged, persistent) -// ============================================================ -pub fn applicant_count(env: &Env, id: u64) -> u32 { - let key = DataKey::EventApplicantCount(id); - let n: Option = env.storage().persistent().get(&key); - if n.is_some() { - touch_event_persistent(env, &key); - } - n.unwrap_or(0) -} - -pub fn applicant_at(env: &Env, id: u64, idx: u32) -> Option
{ - let key = DataKey::EventApplicantAt(id, idx); - let addr: Option
= env.storage().persistent().get(&key); - if addr.is_some() { - touch_event_persistent(env, &key); - } - addr -} - -pub fn applicant_slot(env: &Env, id: u64, addr: &Address) -> u32 { - let key = DataKey::EventApplicantSlot(id, addr.clone()); - let slot: Option = env.storage().persistent().get(&key); - if slot.is_some() { - touch_event_persistent(env, &key); - } - slot.unwrap_or(0) -} - -pub fn append_applicant(env: &Env, id: u64, addr: &Address) -> Result { - if applicant_slot(env, id, addr) != 0 { - return Err(Error::ApplicantAlreadyApplied); - } - let cur = applicant_count(env, id); - // No product cap: each applicant is its own ledger entry paid for by the - // applicant's own transaction, so growth is O(1) per append. Only guard - // the u32 counter itself. - let slot = cur.checked_add(1).ok_or(Error::TooManyApplicants)?; - let at_key = DataKey::EventApplicantAt(id, cur); - env.storage().persistent().set(&at_key, addr); - touch_event_persistent(env, &at_key); - - let slot_key = DataKey::EventApplicantSlot(id, addr.clone()); - env.storage().persistent().set(&slot_key, &slot); - touch_event_persistent(env, &slot_key); - - let count_key = DataKey::EventApplicantCount(id); - env.storage().persistent().set(&count_key, &slot); - touch_event_persistent(env, &count_key); - Ok(slot) -} - -pub fn remove_applicant(env: &Env, id: u64, addr: &Address) -> Result<(), Error> { - let slot = applicant_slot(env, id, addr); - if slot == 0 { - return Err(Error::ApplicantNotApplied); - } - let idx = slot.checked_sub(1).ok_or(Error::ApplicantNotApplied)?; - let count = applicant_count(env, id); - let last_idx = count.checked_sub(1).ok_or(Error::EventNotFound)?; - - if idx != last_idx { - let last_addr = applicant_at(env, id, last_idx).ok_or(Error::EventNotFound)?; - let at_key = DataKey::EventApplicantAt(id, idx); - env.storage().persistent().set(&at_key, &last_addr); - touch_event_persistent(env, &at_key); - - let last_slot_key = DataKey::EventApplicantSlot(id, last_addr.clone()); - env.storage().persistent().set(&last_slot_key, &slot); - touch_event_persistent(env, &last_slot_key); - } - - env.storage() - .persistent() - .remove(&DataKey::EventApplicantAt(id, last_idx)); - env.storage() - .persistent() - .remove(&DataKey::EventApplicantSlot(id, addr.clone())); - - let count_key = DataKey::EventApplicantCount(id); - let new_count = count.checked_sub(1).ok_or(Error::EventNotFound)?; - if new_count == 0 { - env.storage().persistent().remove(&count_key); - } else { - env.storage().persistent().set(&count_key, &new_count); - touch_event_persistent(env, &count_key); - } - Ok(()) -} - -pub fn applicants_snapshot(env: &Env, id: u64, start: u32, limit: u32) -> Vec
{ - let count = applicant_count(env, id); - let end = start.saturating_add(limit).min(count); - let mut out: Vec
= Vec::new(env); - for idx in start..end { - if let Some(addr) = applicant_at(env, id, idx) { - out.push_back(addr); - } - } - out -} - -// ============================================================ -// SUBMISSIONS (per-entry, persistent) -// ============================================================ -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; - } - // Pre-1.7.0 rows have no slot and `migrate` can only reach the ones whose - // applicant is in the applicant index, which hackathons never populate. - // Reading them as slot 0 keeps every historical submission addressable - // regardless of pillar; the first write folds them into the slotted layout. - if slot == 0 { - return get_legacy_submission(env, id, applicant); - } - None -} - -pub fn set_submission(env: &Env, id: u64, applicant: &Address, slot: u32, submission: &Submission) { - let key = DataKey::EventSubmissionEntry(id, applicant.clone(), slot); - env.storage().persistent().set(&key, submission); - touch_event_persistent(env, &key); -} - -/// 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::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); - if next == 0 { - env.storage().persistent().remove(&count_key); - } else { - env.storage().persistent().set(&count_key, &next); - touch_event_persistent(env, &count_key); - } -} - -pub fn submission_count(env: &Env, id: u64) -> u32 { - let key = DataKey::EventSubmissionCount(id); - let n: Option = env.storage().persistent().get(&key); - if n.is_some() { - touch_event_persistent(env, &key); - } - n.unwrap_or(0) -} - -/// 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, 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); - let next = cur.checked_add(1).ok_or(Error::TooManyContributors)?; - 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) // ============================================================ diff --git a/contracts/events/src/tests/admin.rs b/contracts/events/src/tests/admin.rs index 7745ac8..9d0d3e0 100644 --- a/contracts/events/src/tests/admin.rs +++ b/contracts/events/src/tests/admin.rs @@ -8,7 +8,7 @@ use soroban_sdk::{ use super::common::setup; use crate::errors::Error; use crate::storage; -use crate::types::{DataKey, EventStatus, Pillar, ReleaseKind, Submission, Winner}; +use crate::types::{DataKey, EventStatus, Pillar, ReleaseKind, Winner}; // Zeroed with the contract constant; restore both together. const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; @@ -22,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.7.0")); + assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "2.0.0")); assert_eq!(ctx.client.get_pending_upgrade(), None); assert_eq!(ctx.client.get_migrated_to_version(), None); } @@ -135,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.7.0")); + assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "2.0.0")); } #[test] @@ -227,111 +227,6 @@ fn migrate_rewrites_legacy_percentages_as_prize_floors() { ); } -#[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 @@ -426,7 +321,7 @@ fn migrate_refuses_to_stamp_while_events_remain_unconverted() { ctx.client.migrate(); assert_eq!( ctx.client.get_migrated_to_version(), - Some(String::from_str(&ctx.env, "1.7.0")) + Some(String::from_str(&ctx.env, "2.0.0")) ); } @@ -449,7 +344,7 @@ fn migrate_events_caps_each_call_regardless_of_the_argument() { #[test] fn migrate_skips_rows_already_in_the_current_layout() { - // A fresh 1.7.0 deployment writes new-layout events, and the runbook still + // A fresh 2.0.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); @@ -525,8 +420,8 @@ fn migrate_runs_regardless_of_how_the_version_was_spelled() { .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")); + // The operator proposed the upgrade as "v2.0.0" rather than "2.0.0". + storage::set_version(&ctx.env, &String::from_str(&ctx.env, "v2.0.0")); }); ctx.client.migrate_events(&8_u32); @@ -536,50 +431,6 @@ fn migrate_runs_regardless_of_how_the_version_was_spelled() { 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); @@ -587,7 +438,7 @@ fn migrate_is_a_no_op_on_a_fresh_deployment() { ctx.client.migrate(); assert_eq!( ctx.client.get_migrated_to_version(), - Some(String::from_str(&ctx.env, "1.7.0")) + Some(String::from_str(&ctx.env, "2.0.0")) ); } @@ -599,7 +450,7 @@ fn migrate_marks_current_version_and_blocks_replay() { ctx.client.migrate(); assert_eq!( ctx.client.get_migrated_to_version(), - Some(String::from_str(&ctx.env, "1.7.0")) + Some(String::from_str(&ctx.env, "2.0.0")) ); let err = ctx diff --git a/contracts/events/src/tests/bounty_pillar.rs b/contracts/events/src/tests/bounty_pillar.rs index 2ea3a68..3a6b98c 100644 --- a/contracts/events/src/tests/bounty_pillar.rs +++ b/contracts/events/src/tests/bounty_pillar.rs @@ -5,9 +5,8 @@ use soroban_sdk::{ token, Address, BytesN, Env, Map, String, }; -use super::common::drive_cancel; use crate::errors::Error; -use crate::types::{CreateEventParams, DataKey, EventStatus, Pillar, ReleaseKind, WinnerSpec}; +use crate::types::{CreateEventParams, Pillar, ReleaseKind}; use crate::{EventsContract, EventsContractClient}; use boundless_profile::{ProfileContract, ProfileContractClient}; @@ -18,9 +17,7 @@ const TOTAL_BUDGET: i128 = 10_000_0000000_i128; struct Ctx<'a> { env: Env, events: EventsContractClient<'a>, - profile: ProfileContractClient<'a>, owner: Address, - applicant: Address, token_addr: Address, } @@ -57,14 +54,10 @@ fn setup<'a>() -> Ctx<'a> { events.register_supported_token(&token_addr); - let applicant = Address::generate(&env); - Ctx { env, events, - profile, owner, - applicant, token_addr, } } @@ -75,46 +68,6 @@ fn one_winner_distribution(env: &Env) -> Map { m } -fn create_bounty(ctx: &Ctx) -> u64 { - create_bounty_with_deadline(ctx, ctx.env.ledger().timestamp() + 86_400) -} - -fn create_bounty_with_deadline(ctx: &Ctx, deadline: u64) -> u64 { - let params = CreateEventParams { - pillar: Pillar::Bounty, - owner: ctx.owner.clone(), - token: ctx.token_addr.clone(), - total_budget: TOTAL_BUDGET, - release_kind: ReleaseKind::Single, - content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/events/draft/x"), - title: String::from_str(&ctx.env, "Test Bounty"), - deadline: Some(deadline), - prize_floors: one_winner_distribution(&ctx.env), - fee_bps_override: None, - manager: None, - }; - let op_id = BytesN::random(&ctx.env); - ctx.events.create_event(¶ms, &op_id) -} - -fn create_hackathon(ctx: &Ctx) -> u64 { - let params = CreateEventParams { - pillar: Pillar::Hackathon, - owner: ctx.owner.clone(), - token: ctx.token_addr.clone(), - total_budget: TOTAL_BUDGET, - release_kind: ReleaseKind::Single, - content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), - title: String::from_str(&ctx.env, "Test Hackathon"), - deadline: Some(ctx.env.ledger().timestamp() + 86_400), - prize_floors: one_winner_distribution(&ctx.env), - fee_bps_override: None, - manager: None, - }; - let op = BytesN::random(&ctx.env); - ctx.events.create_event(¶ms, &op) -} - fn expect_op_err( result: Result, Result>, ) -> Error { @@ -148,544 +101,3 @@ fn create_rejects_multi_release_kind() { let err = expect_op_err(ctx.events.try_create_event(¶ms, &op)); assert_eq!(err, Error::InvalidReleaseKind); } - -// ============================================================ -// apply_to_bounty — happy path -// ============================================================ - -#[test] -fn apply_bootstraps_profile_and_records_applicant() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - - assert!(ctx.profile.get_profile(&ctx.applicant).is_none()); - - let op_id = BytesN::random(&ctx.env); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &op_id); - - assert!(ctx.profile.get_profile(&ctx.applicant).is_some()); - - let applicants = ctx.events.get_applicants(&bounty_id); - assert_eq!(applicants.len(), 1); - assert_eq!(applicants.get(0).unwrap(), ctx.applicant); -} - -// ============================================================ -// apply_to_bounty — errors + idempotency -// ============================================================ - -#[test] -fn duplicate_apply_reverts() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - - let op_a = BytesN::random(&ctx.env); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &op_a); - - let op_b = BytesN::random(&ctx.env); - let err = expect_op_err( - ctx.events - .try_apply_to_bounty(&bounty_id, &ctx.applicant, &op_b), - ); - assert_eq!(err, Error::ApplicantAlreadyApplied); -} - -#[test] -fn replayed_apply_reverts_idempotently() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - - let op_id = BytesN::random(&ctx.env); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &op_id); - - let err = expect_op_err( - ctx.events - .try_apply_to_bounty(&bounty_id, &ctx.applicant, &op_id), - ); - assert_eq!(err, Error::OpAlreadySeen); -} - -#[test] -fn apply_on_nonexistent_event_reverts() { - let ctx = setup(); - let op_id = BytesN::random(&ctx.env); - let err = expect_op_err( - ctx.events - .try_apply_to_bounty(&9999, &ctx.applicant, &op_id), - ); - assert_eq!(err, Error::EventNotFound); -} - -#[test] -fn apply_on_wrong_pillar_reverts() { - let ctx = setup(); - let hackathon_id = create_hackathon(&ctx); - let op_id = BytesN::random(&ctx.env); - let err = expect_op_err( - ctx.events - .try_apply_to_bounty(&hackathon_id, &ctx.applicant, &op_id), - ); - assert_eq!(err, Error::InvalidPillar); -} - -#[test] -fn apply_on_cancelled_event_reverts() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - drive_cancel(&ctx.env, &ctx.events, bounty_id); - - let op_id = BytesN::random(&ctx.env); - let err = expect_op_err( - ctx.events - .try_apply_to_bounty(&bounty_id, &ctx.applicant, &op_id), - ); - assert_eq!(err, Error::EventNotActive); -} - -#[test] -fn apply_on_completed_event_reverts() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - - let op_apply = BytesN::random(&ctx.env); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &op_apply); - - let winners = soroban_sdk::vec![ - &ctx.env, - WinnerSpec { - recipient: ctx.applicant.clone(), - position: 1, - amount: 10_000_0000000_i128, - reputation_bump: 0, - }, - ]; - let op_select = BytesN::random(&ctx.env); - ctx.events.select_winners(&bounty_id, &winners, &op_select); - - // Pull model: claim the sole prize to drain escrow and complete. - ctx.events - .claim_prize(&bounty_id, &1_u32, &BytesN::random(&ctx.env)); - - let event = ctx.events.get_event(&bounty_id); - assert_eq!(event.status, EventStatus::Completed); - - let op_retry = BytesN::random(&ctx.env); - let err = expect_op_err( - ctx.events - .try_apply_to_bounty(&bounty_id, &ctx.applicant, &op_retry), - ); - assert_eq!(err, Error::EventNotActive); -} - -#[test] -fn apply_when_paused_reverts() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - ctx.events.pause(); - - let op_id = BytesN::random(&ctx.env); - let err = expect_op_err( - ctx.events - .try_apply_to_bounty(&bounty_id, &ctx.applicant, &op_id), - ); - assert_eq!(err, Error::Paused); -} - -#[test] -fn apply_beyond_former_cap_succeeds() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - - // Fast-forward the per-event counter past the former 5,000 cap instead - // of performing that many real applications from distinct addresses. - ctx.env.as_contract(&ctx.events.address, || { - ctx.env - .storage() - .persistent() - .set(&DataKey::EventApplicantCount(bounty_id), &5_000_u32); - }); - - let op_id = BytesN::random(&ctx.env); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &op_id); - - assert_eq!( - ctx.events.get_applicant_count(&bounty_id), - 5_001, - "applications are unbounded; the counter must keep advancing past the former cap" - ); - assert_eq!( - ctx.events.get_applicant_at(&bounty_id, &5_000), - Some(ctx.applicant.clone()), - "the new applicant must land in the next slot" - ); -} - -#[test] -fn apply_at_counter_overflow_reverts() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - - ctx.env.as_contract(&ctx.events.address, || { - ctx.env - .storage() - .persistent() - .set(&DataKey::EventApplicantCount(bounty_id), &u32::MAX); - }); - - let op_id = BytesN::random(&ctx.env); - let err = expect_op_err( - ctx.events - .try_apply_to_bounty(&bounty_id, &ctx.applicant, &op_id), - ); - assert_eq!( - err, - Error::TooManyApplicants, - "an application that would overflow the u32 counter must revert" - ); -} - -#[test] -fn applicants_page_respects_start_and_limit() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - - let second = Address::generate(&ctx.env); - let third = Address::generate(&ctx.env); - for applicant in [&ctx.applicant, &second, &third] { - ctx.events - .apply_to_bounty(&bounty_id, applicant, &BytesN::random(&ctx.env)); - } - - let tail = ctx.events.get_applicants_page(&bounty_id, &1, &10); - assert_eq!(tail.len(), 2); - assert_eq!(tail.get(0).unwrap(), second); - assert_eq!(tail.get(1).unwrap(), third); - - assert_eq!( - ctx.events.get_applicants_page(&bounty_id, &0, &0).len(), - 0, - "limit 0 must return an empty page" - ); - assert_eq!( - ctx.events.get_applicants_page(&bounty_id, &10, &5).len(), - 0, - "a start past the end must return an empty page" - ); - assert_eq!( - ctx.events.get_applicants_page(&bounty_id, &0, &1_000).len(), - 3, - "an oversized limit is clamped, not an error" - ); -} - -#[test] -fn apply_requires_applicant_auth() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - let op_id = BytesN::random(&ctx.env); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &op_id); - - let auths = ctx.env.auths(); - let applicant_required = auths.iter().any(|(addr, _)| *addr == ctx.applicant); - assert!(applicant_required, "apply must demand applicant auth"); -} - -// ============================================================ -// withdraw_application — happy path -// ============================================================ - -#[test] -fn withdraw_removes_the_applicant() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - - let op_apply = BytesN::random(&ctx.env); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &op_apply); - assert_eq!(ctx.events.get_applicants(&bounty_id).len(), 1); - - let op_wd = BytesN::random(&ctx.env); - ctx.events - .withdraw_application(&bounty_id, &ctx.applicant, &op_wd); - - let applicants = ctx.events.get_applicants(&bounty_id); - assert_eq!(applicants.len(), 0); -} - -// ============================================================ -// withdraw_application — errors + idempotency -// ============================================================ - -#[test] -fn withdraw_without_apply_reverts() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - let op_id = BytesN::random(&ctx.env); - let err = expect_op_err(ctx.events.try_withdraw_application( - &bounty_id, - &ctx.applicant, - &op_id, - )); - assert_eq!(err, Error::ApplicantNotApplied); -} - -#[test] -fn withdraw_after_submit_reverts() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - - let op_apply = BytesN::random(&ctx.env); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &op_apply); - - 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, &0_u32, &uri, &op_submit); - - let op_wd = BytesN::random(&ctx.env); - let err = expect_op_err(ctx.events.try_withdraw_application( - &bounty_id, - &ctx.applicant, - &op_wd, - )); - assert_eq!(err, Error::SubmissionAlreadyExists); -} - -#[test] -fn replayed_withdraw_reverts_idempotently() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - - let op_apply = BytesN::random(&ctx.env); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &op_apply); - - let op_wd = BytesN::random(&ctx.env); - ctx.events - .withdraw_application(&bounty_id, &ctx.applicant, &op_wd); - - let err = expect_op_err(ctx.events.try_withdraw_application( - &bounty_id, - &ctx.applicant, - &op_wd, - )); - assert_eq!(err, Error::OpAlreadySeen); -} - -#[test] -fn withdraw_on_nonexistent_event_reverts() { - let ctx = setup(); - let op_id = BytesN::random(&ctx.env); - let err = expect_op_err( - ctx.events - .try_withdraw_application(&9999, &ctx.applicant, &op_id), - ); - assert_eq!(err, Error::EventNotFound); -} - -#[test] -fn withdraw_when_paused_reverts() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - - let op_apply = BytesN::random(&ctx.env); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &op_apply); - - ctx.events.pause(); - - let op_wd = BytesN::random(&ctx.env); - let err = expect_op_err(ctx.events.try_withdraw_application( - &bounty_id, - &ctx.applicant, - &op_wd, - )); - assert_eq!(err, Error::Paused); -} - -#[test] -fn withdraw_requires_applicant_auth() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - - let op_apply = BytesN::random(&ctx.env); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &op_apply); - - let op_wd = BytesN::random(&ctx.env); - ctx.events - .withdraw_application(&bounty_id, &ctx.applicant, &op_wd); - - let auths = ctx.env.auths(); - let applicant_required = auths.iter().any(|(addr, _)| *addr == ctx.applicant); - assert!(applicant_required, "withdraw must demand applicant auth"); -} - -// ============================================================ -// Submission slots: several distinct entries per wallet -// ============================================================ - -#[test] -fn one_wallet_holds_several_submissions_in_distinct_slots() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &BytesN::random(&ctx.env)); - - ctx.events.submit( - &bounty_id, - &ctx.applicant, - &0_u32, - &String::from_str(&ctx.env, "ipfs://design"), - &BytesN::random(&ctx.env), - ); - ctx.events.submit( - &bounty_id, - &ctx.applicant, - &1_u32, - &String::from_str(&ctx.env, "ipfs://article"), - &BytesN::random(&ctx.env), - ); - - // Neither entry overwrites the other; the slot is what tells them apart. - assert_eq!( - ctx.events - .get_submission(&bounty_id, &ctx.applicant, &0_u32) - .content_uri, - String::from_str(&ctx.env, "ipfs://design") - ); - assert_eq!( - ctx.events - .get_submission(&bounty_id, &ctx.applicant, &1_u32) - .content_uri, - String::from_str(&ctx.env, "ipfs://article") - ); - assert_eq!( - ctx.events - .get_applicant_submission_count(&bounty_id, &ctx.applicant), - 2 - ); -} - -#[test] -fn resubmitting_to_an_occupied_slot_updates_it_without_recounting() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &BytesN::random(&ctx.env)); - - ctx.events.submit( - &bounty_id, - &ctx.applicant, - &0_u32, - &String::from_str(&ctx.env, "ipfs://v1"), - &BytesN::random(&ctx.env), - ); - let first = ctx - .events - .get_submission(&bounty_id, &ctx.applicant, &0_u32); - - ctx.events.submit( - &bounty_id, - &ctx.applicant, - &0_u32, - &String::from_str(&ctx.env, "ipfs://v2"), - &BytesN::random(&ctx.env), - ); - let second = ctx - .events - .get_submission(&bounty_id, &ctx.applicant, &0_u32); - - assert_eq!(second.content_uri, String::from_str(&ctx.env, "ipfs://v2")); - assert_eq!( - second.submitted_at, first.submitted_at, - "an update keeps the original submission time" - ); - assert_eq!( - ctx.events - .get_applicant_submission_count(&bounty_id, &ctx.applicant), - 1 - ); -} - -#[test] -fn withdrawing_one_slot_leaves_the_others_intact() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &BytesN::random(&ctx.env)); - - for slot in 0..3_u32 { - ctx.events.submit( - &bounty_id, - &ctx.applicant, - &slot, - &String::from_str(&ctx.env, "ipfs://entry"), - &BytesN::random(&ctx.env), - ); - } - - ctx.events.withdraw_submission( - &bounty_id, - &ctx.applicant, - &1_u32, - &BytesN::random(&ctx.env), - ); - - assert_eq!( - ctx.events - .get_applicant_submission_count(&bounty_id, &ctx.applicant), - 2 - ); - assert!(ctx - .events - .try_get_submission(&bounty_id, &ctx.applicant, &1_u32) - .is_err()); - assert!(ctx - .events - .try_get_submission(&bounty_id, &ctx.applicant, &0_u32) - .is_ok()); - assert!(ctx - .events - .try_get_submission(&bounty_id, &ctx.applicant, &2_u32) - .is_ok()); -} - -#[test] -fn application_withdrawal_stays_blocked_while_any_slot_is_filled() { - let ctx = setup(); - let bounty_id = create_bounty(&ctx); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &BytesN::random(&ctx.env)); - - // Occupying a slot other than 0 must still block application withdrawal; - // the gate asks whether any entry exists, not whether slot 0 does. - ctx.events.submit( - &bounty_id, - &ctx.applicant, - &7_u32, - &String::from_str(&ctx.env, "ipfs://entry"), - &BytesN::random(&ctx.env), - ); - - assert!(ctx - .events - .try_withdraw_application(&bounty_id, &ctx.applicant, &BytesN::random(&ctx.env)) - .is_err()); - - ctx.events.withdraw_submission( - &bounty_id, - &ctx.applicant, - &7_u32, - &BytesN::random(&ctx.env), - ); - ctx.events - .withdraw_application(&bounty_id, &ctx.applicant, &BytesN::random(&ctx.env)); -} diff --git a/contracts/events/src/tests/cross_contract.rs b/contracts/events/src/tests/cross_contract.rs index 8b527ac..e6ff2ce 100644 --- a/contracts/events/src/tests/cross_contract.rs +++ b/contracts/events/src/tests/cross_contract.rs @@ -110,10 +110,6 @@ fn select_winners_pays_recipient_and_bumps_profile() { let ctx = setup(); let bounty_id = create_bounty(&ctx); - let op_apply = BytesN::random(&ctx.env); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &op_apply); - let winners = soroban_sdk::vec![ &ctx.env, WinnerSpec { @@ -548,149 +544,6 @@ fn claim_milestone_final_milestone_marks_event_completed() { assert_eq!(after.remaining_escrow, 0); } -// ============================================================ -// submit / withdraw_submission -// ============================================================ - -fn create_hackathon(ctx: &Ctx) -> u64 { - let mut dist = Map::new(&ctx.env); - dist.set(1, TOTAL_BUDGET * 100 / 100); - let params = CreateEventParams { - pillar: Pillar::Hackathon, - owner: ctx.owner.clone(), - token: ctx.token_addr.clone(), - total_budget: TOTAL_BUDGET, - release_kind: ReleaseKind::Single, - content_uri: String::from_str(&ctx.env, "https://api.boundless.fi/hackathon"), - title: String::from_str(&ctx.env, "Test Hackathon"), - deadline: Some(ctx.env.ledger().timestamp() + 86_400), - prize_floors: dist, - fee_bps_override: None, - manager: None, - }; - let op_create = BytesN::random(&ctx.env); - ctx.events.create_event(¶ms, &op_create) -} - -#[test] -fn hackathon_submit_creates_anchor_without_prior_apply() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - let uri = String::from_str(&ctx.env, "ipfs://Qm.../project.json"); - let op = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &0_u32, &uri, &op); - - 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()); -} - -#[test] -fn bounty_submit_requires_prior_application() { - let ctx = setup(); - let id = create_bounty(&ctx); - - 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, &0_u32, &uri, &op); - assert!(res.is_err(), "submit before apply on bounty should revert"); -} - -#[test] -fn bounty_submit_succeeds_after_apply() { - let ctx = setup(); - let id = create_bounty(&ctx); - - let op_apply = BytesN::random(&ctx.env); - ctx.events.apply_to_bounty(&id, &ctx.applicant, &op_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, &0_u32, &uri, &op_submit); - - let sub = ctx.events.get_submission(&id, &ctx.applicant, &0_u32); - assert_eq!(sub.content_uri, uri); -} - -#[test] -fn resubmit_preserves_original_submitted_at_and_updates_uri() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - 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, &0_u32, &uri_a, &op_a); - - 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, &0_u32, &uri_b, &op_b); - - 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, - "submitted_at must be preserved across re-submit" - ); -} - -#[test] -fn submit_replayed_reverts() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - let uri = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); - let op = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &0_u32, &uri, &op); - - let res = ctx - .events - .try_submit(&id, &ctx.applicant, &0_u32, &uri, &op); - assert!(res.is_err(), "replayed submit should revert"); -} - -#[test] -fn withdraw_submission_removes_anchor() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - let uri = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); - let op_submit = BytesN::random(&ctx.env); - 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, &0_u32, &op_wd); - - let res = ctx.events.try_get_submission(&id, &ctx.applicant, &0_u32); - assert!(res.is_err(), "withdrawn submission should not be readable"); -} - -#[test] -fn withdraw_submission_without_submission_reverts() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - let op_wd = BytesN::random(&ctx.env); - let res = ctx - .events - .try_withdraw_submission(&id, &ctx.applicant, &0_u32, &op_wd); - assert!( - res.is_err(), - "withdraw without prior submission should revert" - ); -} - // ============================================================ // Per-event fee_bps_override // ============================================================ @@ -1057,8 +910,6 @@ fn unaccepted_proposal_leaves_owner_in_authority() { let attacker = Address::generate(&ctx.env); let id = create_bounty_with_manager(&ctx, &attacker); - ctx.events - .apply_to_bounty(&id, &ctx.applicant, &BytesN::random(&ctx.env)); ctx.events .select_winners(&id, &win_one(&ctx), &BytesN::random(&ctx.env)); @@ -1075,8 +926,6 @@ fn accepted_manager_holds_select_winners_authority() { let id = create_bounty_with_manager(&ctx, &manager); ctx.events.accept_manager(&id); - ctx.events - .apply_to_bounty(&id, &ctx.applicant, &BytesN::random(&ctx.env)); ctx.events .select_winners(&id, &win_one(&ctx), &BytesN::random(&ctx.env)); diff --git a/contracts/events/src/tests/crowdfunding.rs b/contracts/events/src/tests/crowdfunding.rs index 47280fa..6eef7f6 100644 --- a/contracts/events/src/tests/crowdfunding.rs +++ b/contracts/events/src/tests/crowdfunding.rs @@ -403,17 +403,6 @@ fn select_winners_on_crowdfunding_reverts() { assert!(res.is_err()); } -#[test] -fn submit_on_crowdfunding_reverts() { - let ctx = setup(); - let id = create_campaign(&ctx, 3); - - let op = BytesN::random(&ctx.env); - let uri = String::from_str(&ctx.env, "ipfs://nope"); - let res = ctx.events.try_submit(&id, &ctx.builder, &0_u32, &uri, &op); - assert!(res.is_err()); -} - // ============================================================ // cancel_event // ============================================================ diff --git a/contracts/events/src/tests/hackathon_pillar.rs b/contracts/events/src/tests/hackathon_pillar.rs index 5aa5a4b..338def6 100644 --- a/contracts/events/src/tests/hackathon_pillar.rs +++ b/contracts/events/src/tests/hackathon_pillar.rs @@ -5,10 +5,7 @@ use soroban_sdk::{ token, Address, BytesN, Env, Map, String, }; -use crate::errors::Error; -use crate::event_ops::MAX_CONTENT_URI_LEN; -use crate::storage; -use crate::types::{CreateEventParams, DataKey, EventStatus, Pillar, ReleaseKind, WinnerSpec}; +use crate::types::{CreateEventParams, EventStatus, Pillar, ReleaseKind, WinnerSpec}; use crate::{EventsContract, EventsContractClient}; use boundless_profile::{ProfileContract, ProfileContractClient}; @@ -21,7 +18,6 @@ const FEE_AMOUNT: i128 = (TOTAL_BUDGET * FEE_BPS as i128) / 10_000_i128; struct Ctx<'a> { env: Env, events: EventsContractClient<'a>, - events_id: Address, profile: ProfileContractClient<'a>, owner: Address, applicant: Address, @@ -68,7 +64,6 @@ fn setup<'a>() -> Ctx<'a> { Ctx { env, events, - events_id, profile, owner, applicant, @@ -78,15 +73,6 @@ fn setup<'a>() -> Ctx<'a> { } } -fn expect_op_err( - result: Result, Result>, -) -> Error { - match result { - Err(Ok(e)) => e, - _ => panic!("expected contract error"), - } -} - fn single_winner_dist(env: &Env) -> Map { let mut m = Map::new(env); m.set(1, 100000000000_i128); @@ -176,242 +162,6 @@ fn create_rejects_multi_release_kind() { // submit (open submission model) // ============================================================ -#[test] -fn submit_open_without_prior_apply_creates_anchor() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - let uri = String::from_str(&ctx.env, "ipfs://Qm.../project.json"); - let op = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &0_u32, &uri, &op); - - 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()); -} - -#[test] -fn resubmit_keeps_original_timestamp_and_updates_uri() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - 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, &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, &0_u32, &uri_b, &op_b); - - 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); -} - -#[test] -fn submit_replayed_op_reverts() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - let uri = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); - let op = BytesN::random(&ctx.env); - ctx.events.submit(&id, &ctx.applicant, &0_u32, &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"); -} - -#[test] -fn withdraw_submission_removes_anchor() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - let uri = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); - let op_s = BytesN::random(&ctx.env); - 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, &0_u32, &op_w); - - let res = ctx.events.try_get_submission(&id, &ctx.applicant, &0_u32); - assert!(res.is_err(), "withdrawn submission is no longer readable"); -} - -#[test] -fn remove_submission_on_nonexistent_entry_does_not_corrupt_counter() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - let submitter = Address::generate(&ctx.env); - ctx.events.submit( - &id, - &submitter, - &0_u32, - &String::from_str(&ctx.env, "ipfs://Qm.../v1.json"), - &BytesN::random(&ctx.env), - ); - - // ctx.applicant never submitted — calling the low-level storage helper - // 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, 0); - }); - - let count = ctx - .env - .as_contract(&ctx.events_id, || storage::submission_count(&ctx.env, id)); - assert_eq!( - count, 1, - "removing a nonexistent submission must not corrupt the counter" - ); -} - -#[test] -fn withdraw_submission_frees_the_slot_for_future_submitters() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - let uri = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); - ctx.events - .submit(&id, &ctx.applicant, &0_u32, &uri, &BytesN::random(&ctx.env)); - ctx.events - .withdraw_submission(&id, &ctx.applicant, &0_u32, &BytesN::random(&ctx.env)); - - let count = ctx - .env - .as_contract(&ctx.events_id, || storage::submission_count(&ctx.env, id)); - assert_eq!( - count, 0, - "withdrawing a submission must decrement the submission count" - ); -} - -#[test] -fn submit_beyond_former_cap_succeeds() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - // Fast-forward the per-event counter past the former 5,000 cap instead - // of performing that many real submissions from distinct addresses. - ctx.env.as_contract(&ctx.events_id, || { - ctx.env - .storage() - .persistent() - .set(&DataKey::EventSubmissionCount(id), &5_000_u32); - }); - - let uri = String::from_str(&ctx.env, "ipfs://Qm.../v5001.json"); - ctx.events - .submit(&id, &ctx.applicant, &0_u32, &uri, &BytesN::random(&ctx.env)); - - let count = ctx - .env - .as_contract(&ctx.events_id, || storage::submission_count(&ctx.env, id)); - assert_eq!( - count, 5_001, - "submissions are unbounded; the counter must keep advancing past the former cap" - ); -} - -#[test] -fn submit_at_counter_overflow_reverts() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - ctx.env.as_contract(&ctx.events_id, || { - ctx.env - .storage() - .persistent() - .set(&DataKey::EventSubmissionCount(id), &u32::MAX); - }); - - 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, &0_u32, &uri, &op), - ); - assert_eq!( - err, - Error::TooManyContributors, - "a submission that would overflow the u32 counter must revert" - ); -} - -#[test] -fn submit_oversized_content_uri_reverts() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - 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, &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!( - err, - Error::TitleTooLong, - "content_uri beyond MAX_CONTENT_URI_LEN must revert" - ); -} - -#[test] -fn resubmit_by_existing_applicant_does_not_increment_submission_count() { - let ctx = setup(); - let id = create_hackathon(&ctx); - - let uri_a = String::from_str(&ctx.env, "ipfs://Qm.../v1.json"); - ctx.events.submit( - &id, - &ctx.applicant, - &0_u32, - &uri_a, - &BytesN::random(&ctx.env), - ); - - let count_after_first = ctx - .env - .as_contract(&ctx.events_id, || storage::submission_count(&ctx.env, id)); - assert_eq!(count_after_first, 1); - - let uri_b = String::from_str(&ctx.env, "ipfs://Qm.../v2.json"); - ctx.events.submit( - &id, - &ctx.applicant, - &0_u32, - &uri_b, - &BytesN::random(&ctx.env), - ); - - let count_after_second = ctx - .env - .as_contract(&ctx.events_id, || storage::submission_count(&ctx.env, id)); - assert_eq!( - count_after_second, 1, - "re-submission by an existing applicant updates in place and must not \ - recount against the cap" - ); -} - -// ============================================================ -// select_winners — distribution (happy paths) -// ============================================================ - #[test] fn select_winners_single_recipient_sweeps_escrow() { let ctx = setup(); diff --git a/contracts/events/src/tests/op_id_security.rs b/contracts/events/src/tests/op_id_security.rs index 1aaa682..b3a8a92 100644 --- a/contracts/events/src/tests/op_id_security.rs +++ b/contracts/events/src/tests/op_id_security.rs @@ -144,10 +144,6 @@ fn bootstrap_self_cannot_front_run_events_child_op_ids() { let ctx = setup(); let bounty_id = create_bounty(&ctx); - let op_apply = BytesN::random(&ctx.env); - ctx.events - .apply_to_bounty(&bounty_id, &ctx.applicant, &op_apply); - let winners = soroban_sdk::vec![ &ctx.env, WinnerSpec { @@ -276,19 +272,20 @@ fn event_id_overflow_reverts() { /// (select_winners) that reuses it. Before namespacing, the shared global /// OpSeen made the manager's payout revert with OpAlreadySeen. #[test] -fn permissionless_apply_cannot_squat_select_winners_op_id() { +fn permissionless_add_funds_cannot_squat_select_winners_op_id() { let ctx = setup(); let id = create_bounty(&ctx); - ctx.events - .apply_to_bounty(&id, &ctx.applicant, &BytesN::random(&ctx.env)); - // The op_id the owner will use to select winners. let victim_op = BytesN::random(&ctx.env); - // Attacker front-runs by burning that op_id in their own (apply) domain. + // Attacker front-runs by burning that op_id in their own (add_funds) + // domain. Contributing is the one entrypoint anyone may still call. let attacker = Address::generate(&ctx.env); - ctx.events.apply_to_bounty(&id, &attacker, &victim_op); + // add_funds debits the contribution plus the platform fee on top. + token::StellarAssetClient::new(&ctx.env, &ctx.token_addr).mint(&attacker, &1_000_0000000_i128); + ctx.events + .add_funds(&id, &attacker, &100_0000000_i128, &victim_op); // Owner's select_winners with the same op_id still succeeds (owner domain). let winners = soroban_sdk::vec![ diff --git a/contracts/events/src/types.rs b/contracts/events/src/types.rs index 936ccff..895fdb2 100644 --- a/contracts/events/src/types.rs +++ b/contracts/events/src/types.rs @@ -100,17 +100,6 @@ pub struct CreateEventParams { pub manager: Option
, } -// ============================================================ -// SUBMISSION -// ============================================================ -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Submission { - pub applicant: Address, - pub content_uri: String, - pub submitted_at: u64, -} - // ============================================================ // CONTRIBUTION // ============================================================ @@ -171,12 +160,14 @@ pub enum DataKey { EventManager(u64), + // Retired in 2.0.0 with the on-chain participation records: applying and + // submitting are product facts the contract never read, so nothing writes + // or reads these now. Kept in place rather than deleted so the remaining + // variants keep their positions and any rows left on live events stay + // identifiable until they archive. EventApplicantCount(u64), 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), @@ -217,7 +208,7 @@ pub enum DataKey { // Appended for two-step manager rotation to preserve key discriminants. PendingManager(u64), - // Appended to cap per-event submission storage growth (security fix). + // Retired in 2.0.0 (see the applicant keys above). EventSubmissionCount(u64), // Appended in 1.7.0. Sum of awarded-but-unclaimed prizes. `remaining_escrow` @@ -225,9 +216,8 @@ pub enum DataKey { // 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. + // Retired in 2.0.0 (see the applicant keys above). Added in 1.7.0 to give + // one wallet several distinct entries in one event. EventSubmissionEntry(u64, Address, u32), EventApplicantSubmissionCount(u64, Address), diff --git a/docs/mainnet-deploy-runbook.md b/docs/mainnet-deploy-runbook.md index 15c1011..05d930b 100644 --- a/docs/mainnet-deploy-runbook.md +++ b/docs/mainnet-deploy-runbook.md @@ -328,7 +328,7 @@ stellar contract invoke \ Sign `/tmp/events-emergency-pause.xdr` sequentially with two signers, submit with `stellar tx send`, then read `is_paused` back as `true`. -Pause stops event-lifecycle mutations guarded by `require_not_paused`, including `create_event`, `add_funds`, `propose_manager`, `accept_manager`, `cancel_pending_manager`, `start_cancel`, `process_cancel_batch`, `finalize_cancel`, `select_winners`, `claim_milestone`, `apply_to_bounty`, `withdraw_application`, `submit`, and `withdraw_submission`. Reads continue. +Pause stops event-lifecycle mutations guarded by `require_not_paused`, including `create_event`, `add_funds`, `propose_manager`, `accept_manager`, `cancel_pending_manager`, `start_cancel`, `process_cancel_batch`, `finalize_cancel`, `select_winners`, `claim_milestone`, and `claim_prize`. Reads continue. Pause intentionally does **not** block admin recovery and governance entrypoints such as `propose_upgrade`, `apply_upgrade`, `cancel_pending_upgrade`, `migrate`, configuration changes, token-list changes, admin rotation, or `unpause`. This is why the coordinated SDK 27 upgrade can remain paused through its timelock. @@ -653,7 +653,7 @@ This section summarizes everything that changed in the contract surface between - `propose_upgrade(wasm_hash, new_version)`, `apply_upgrade()`, `cancel_pending_upgrade()`, `migrate()` — timelocked upgrade flow. - `start_cancel(event_id, op_id)`, `process_cancel_batch(event_id, max_refunds, op_id) -> u32`, `finalize_cancel(event_id, op_id)` — paged cancellation. - `propose_events_contract(addr)`, `accept_events_contract()`, `cancel_pending_events_contract()` — two-step rotation for the profile contract's events binding. First-set still uses `set_events_contract`. -- Paged read accessors on the events contract: `get_applicant_count`, `get_applicant_at`, `get_winner_count`, `get_winner_at`, `get_contributor_count`, `get_contributor_at`. The aggregated reads (`get_applicants`, `get_winners`, `get_contributors`) cap at the per-event maximum and stay available for backwards compat. +- Paged read accessors on the events contract: `get_winner_count`, `get_winner_at`, `get_contributor_count`, `get_contributor_at`. The aggregated reads (`get_winners`, `get_contributors`) cap at the per-event maximum and stay available for backwards compat. The applicant accessors went with the participation records in 2.0.0. - New `EventStatus::Cancelling` variant. Any switch on EventStatus needs to handle it. - New events: `PendingUpgradeProposed`, `UpgradeApplied`, `Migrated`, `PendingUpgradeCancelled`, `PendingEventsContractSet`, `EventsRotationCancelled`. `EventCreated` adds a `title` field. From d1a8b2f1770215bfc4c578770d7177e683b909d3 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Tue, 29 Sep 2026 08:11:55 +0100 Subject: [PATCH 14/20] docs(threat-model): the v1.0 model, as parked Restores the working tree that was sitting in a stash: the STRIDE model rewritten to the SDF's four-question structure with 72 threats, trust boundaries and figures, the Scout re-run assessments, and the two backlog entries recording what the 2026-09-11 mainnet read found. Content is unchanged from the parked version. It is committed here so it stops living in a stash, where a `migrate dev` reset came close to taking it once already. --- BACKLOG.md | 4 +- .../threat-model-figure-1-system-dfd.svg | 157 ++++ ...threat-model-figure-2-escrow-lifecycle.svg | 346 +++++++++ docs/scout-audit-report.md | 47 ++ docs/threat-model.md | 692 +++++++++++------- 5 files changed, 986 insertions(+), 260 deletions(-) create mode 100644 docs/figures/threat-model-figure-1-system-dfd.svg create mode 100644 docs/figures/threat-model-figure-2-escrow-lifecycle.svg diff --git a/BACKLOG.md b/BACKLOG.md index 09385ab..20eb482 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -28,7 +28,9 @@ 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.** +- [ ] **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) 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/scout-audit-report.md b/docs/scout-audit-report.md index 5b2f330..4371d25 100644 --- a/docs/scout-audit-report.md +++ b/docs/scout-audit-report.md @@ -266,3 +266,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..a49bf65 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -1,332 +1,506 @@ -# 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, `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. Ten 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 seven 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: +## 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).** Builders call `apply_to_bounty`; the events contract bootstraps their profile. +6. **Submit.** Builders call `submit(event_id, applicant, slot, content_uri)` to anchor an off-chain submission. Bounties require a prior application; hackathons do not. A wallet may hold several slots; resubmitting to a slot updates it in place. +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. | -**Data Stores:** +### 1.7 Threat actors and assumptions + +| 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. | 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 applications 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 can pre-mark its bootstrap child through `apply_to_bounty`, so that the victim's `claim_milestone` reverts or the victim's `claim_prize` pays out without bootstrapping the profile (reputation lost). | 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. | `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 | (Steps 5, 9) **Profile coupling.** `apply_to_bounty` and `claim_milestone` call the profile contract without `try_`; if the profile contract is paused or mis-bound, bounty applications and every grant or crowdfunding milestone claim revert. `claim_prize` is decoupled. | `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`. | `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 bounty applications and 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. -## Section 4 — Did We Do a Good Job? +## 3. What are we going to do about it? -### Dataflow Diagram Utility +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. -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. +| Threat | Treatment | Status | +|---|---|---| +| **Spoof.1** | Every state-changing entry point calls `require_auth()` on the address it acts for: `params.owner`, `from`, `applicant`. 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 are keyed by (event, applicant, slot); `submit` and `withdraw_submission` both `require_auth()` on the applicant. The applicant recorded inside the stored submission is the authenticated address. | Mitigated | +| **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 requires 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. Planned remediation: 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 show every rewrite, so the change is provable from the ledger, but the stored record does not carry it. Planned remediation: refuse `submit` to a slot once a selection has been recorded for the event, and record the last update time on the submission. | Open | +| **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. Planned remediation: convert `apply_to_bounty` and `claim_milestone` 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 are affected, and grants proceed through owner-driven selection and milestone claims without on-chain submissions. Planned remediation: either add a grant application entry point or make `submit` reject grant events with a dedicated error. | Open | +| **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 | -### STRIDE Coverage Assessment +--- -All six STRIDE categories produced actionable threats. The most significant findings: +## 4. Did we do a good job? -- **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. +**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. -### Unresolved / Residual Risks +**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: -| 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. | +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. -### Post-Modeling Changes +**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. -Four issues surfaced during or directly after the modelling process: +**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. -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. +**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. -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.1 Open items + +| 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 bounty applications and milestone claims | Convert to `try_` calls before fieldwork. | +| Tamp.14 | Child op_ids not bound to the originating user | Include the authorizing address in the derivation before fieldwork. | +| Tamp.16 | Duplicate recipient in a multi-release selection strands a reservation | Reject duplicate recipients before fieldwork. | +| Tamp.17 | Submission rewrite after selection | Freeze submissions after selection; record last update time. | +| DoS.13 | Grant submissions unreachable | Grant application entry point or explicit rejection. | +| EoP.15 | Unbounded reputation bump | Cap per award and per call. | + +### 4.2 Pending operational action + +| 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. | -### Living Document Commitment +### 4.3 Accepted residual risks -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). +| 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` | bounty events only; withdrawal blocked once a submission exists | +| `submit`, `withdraw_submission` | events | `applicant` | bounties and grants require a prior application (grants have 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 | +| 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. | From e64c7deb29f54e38e555d4ced53b1d313db5c2d7 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Tue, 29 Sep 2026 08:12:25 +0100 Subject: [PATCH 15/20] docs(threat-model): record the 2.0.0 participation removal Three threats are void once the on-chain participation records go: Tamp.3 and Tamp.17 describe overwriting a submission that no longer exists, and DoS.13 was the dead grant-submission branch, whose second candidate remediation is exactly what 2.0.0 did. Two more narrow. Tamp.14 keeps its remediation, but with apply_to_bounty gone no permissionless entrypoint calls the profile contract, so burning a derived child marker now needs a privileged signature. DoS.7 loses one of its two coupled callers, leaving claim_milestone. Open items drop from ten to eight. The flow keeps its step numbering, so every "(Step n)" reference in the threat tables still resolves; steps 5 and 6 say what they were and that they are gone. Appendix A strikes the removed entrypoints rather than deleting the rows, matching how the retired storage keys and error discriminants are handled in the code. --- docs/scout-audit-report.md | 8 ++++-- docs/threat-model.md | 55 ++++++++++++++++++++++---------------- 2 files changed, 38 insertions(+), 25 deletions(-) diff --git a/docs/scout-audit-report.md b/docs/scout-audit-report.md index 4371d25..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. --- diff --git a/docs/threat-model.md b/docs/threat-model.md index a49bf65..bf2cb72 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -4,7 +4,7 @@ **Date:** September 2026 **Prepared for:** Stellar Development Foundation — Soroban Security Audit Bank **Prepared by:** Boundless Engineering -**Contracts:** `boundless-events` 1.7.0, `boundless-profile` 1.2.0 +**Contracts:** `boundless-events` 1.7.0 (2.0.0 in review), `boundless-profile` 1.2.0 **Repository:** github.com/boundlessfi/boundless-contract --- @@ -13,7 +13,9 @@ 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. -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. Ten 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 seven are hardening changes scheduled before audit fieldwork, the most important of which is restoring the upgrade timelock from its current value of zero. +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. + +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? @@ -77,8 +79,8 @@ The threat tables in Section 2 reference these steps. 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).** Builders call `apply_to_bounty`; the events contract bootstraps their profile. -6. **Submit.** Builders call `submit(event_id, applicant, slot, content_uri)` to anchor an off-chain submission. Bounties require a prior application; hackathons do not. A wallet may hold several slots; resubmitting to a slot updates it in place. +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. @@ -204,7 +206,7 @@ Step numbers refer to the on-chain flow in Section 1.4; TB numbers to Section 1. |---|---|---|---|---|---| | 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. | Submissions | H | M | P2 | +| 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 | @@ -214,11 +216,11 @@ Step numbers refer to the on-chain flow in Section 1.4; TB numbers to Section 1. | 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 applications 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 can pre-mark its bootstrap child through `apply_to_bounty`, so that the victim's `claim_milestone` reverts or the victim's `claim_prize` pays out without bootstrapping the profile (reputation lost). | Idempotency layer (TB6) | L | M | P4 | +| 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. | `submit` | M | M | P3 | +| 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 @@ -254,13 +256,13 @@ Step numbers refer to the on-chain flow in Section 1.4; TB numbers to Section 1. | 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 | (Steps 5, 9) **Profile coupling.** `apply_to_bounty` and `claim_milestone` call the profile contract without `try_`; if the profile contract is paused or mis-bound, bounty applications and every grant or crowdfunding milestone claim revert. `claim_prize` is decoupled. | `boundless-events` (TB6) | L | H | P3 | +| 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`. | `submit`, `apply_to_bounty` | H | L | P3 | +| 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 | @@ -283,7 +285,7 @@ Step numbers refer to the on-chain flow in Section 1.4; TB numbers to Section 1. | 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 bounty applications and milestone claims if the new binding is broken. | `set_profile_contract` (TB6) | L | M | P4 | +| 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 | --- @@ -301,7 +303,7 @@ Status values: **Mitigated** — a control in the contracts or in operating proc | Threat | Treatment | Status | |---|---|---| -| **Spoof.1** | Every state-changing entry point calls `require_auth()` on the address it acts for: `params.owner`, `from`, `applicant`. 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.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 | @@ -313,7 +315,7 @@ Status values: **Mitigated** — a control in the contracts or in operating proc | **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 are keyed by (event, applicant, slot); `submit` and `withdraw_submission` both `require_auth()` on the applicant. The applicant recorded inside the stored submission is the authenticated address. | 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 | @@ -324,10 +326,10 @@ Status values: **Mitigated** — a control in the contracts or in operating proc | **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 requires 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. Planned remediation: include the events-side authorizing address in the child preimage. | Open | +| **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 show every rewrite, so the change is provable from the ledger, but the stored record does not carry it. Planned remediation: refuse `submit` to a slot once a selection has been recorded for the event, and record the last update time on the submission. | 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 | @@ -348,13 +350,13 @@ Status values: **Mitigated** — a control in the contracts or in operating proc | **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. Planned remediation: convert `apply_to_bounty` and `claim_milestone` 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.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 are affected, and grants proceed through owner-driven selection and milestone claims without on-chain submissions. Planned remediation: either add a grant application entry point or make `submit` reject grant events with a dedicated error. | Open | +| **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 | @@ -415,13 +417,19 @@ Two. First, modeling a role boundary (owner versus manager) with the same rigor | 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 bounty applications and milestone claims | Convert to `try_` calls before fieldwork. | -| Tamp.14 | Child op_ids not bound to the originating user | Include the authorizing address in the derivation before fieldwork. | +| 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. | -| Tamp.17 | Submission rewrite after selection | Freeze submissions after selection; record last update time. | -| DoS.13 | Grant submissions unreachable | Grant application entry point or explicit rejection. | | 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. | + ### 4.2 Pending operational action | ID | Item | Treatment | @@ -471,8 +479,8 @@ Two. First, modeling a role boundary (owner versus manager) with the same rigor | `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` | bounty events only; withdrawal blocked once a submission exists | -| `submit`, `withdraw_submission` | events | `applicant` | bounties and grants require a prior application (grants have no application entry point, DoS.13); crowdfunding rejected; slot-keyed | +| ~~`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 | @@ -491,6 +499,7 @@ Two. First, modeling a role boundary (owner versus manager) with the same rigor | `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 From 6226af392419e513a2b03921470716f46c0dcc19 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Sat, 3 Oct 2026 18:16:34 +0100 Subject: [PATCH 16/20] feat(events,profile)!: harden 2.0.0 for audit and move to soroban-sdk 28 Closes every open item in the threat model, and the issues found by running each entry point at mainnet's per-transaction limits. - Grants: a roster and per-recipient progress replace the winner-row walk; the owner closes a milestone unpaid with forfeit_milestone; start_cancel refuses a grant while any award is owed (AwardsOutstanding). - A refund the crank cannot deliver is held for claim_refund instead of stalling the batch. - The owner can reclaim management until the first award. - Crowdfunding releases are co-signed by a release validator appointed in two steps and removable in one; the admin co-signs until one is appointed. - Caps sized to mainnet limits: 20 awards per call, 40 per grant, 15 refunds per batch, 90-entry pages. Reputation bumps capped at 100 on both contracts. Claim windows count only unpaused time. - Upgrade timelock restored to 17,280 ledgers on mainnet builds. - migrate_events converts the record layout only; migrate refuses with MigrationIncomplete (97) until every record is converted. - Removed: profile slash_reputation, the pre-roster grant path, legacy op-marker lookups, dead helpers, retired storage keys and unused errors. Retired error codes are listed in errors.rs and never reused. - soroban-sdk 28.0.0; profile 1.2.1. - Grant scenario suite (randomized runs, limits, a refusing token) and a storage-compatibility replay of the mainnet 1.7.0 to 2.0.0 upgrade against a captured mainnet ledger. --- Cargo.lock | 52 +- Cargo.toml | 2 +- contracts/compatibility/Cargo.toml | 4 + .../fixtures/events-1.3.0-sdk23.wasm | Bin 53924 -> 0 bytes .../fixtures/events-1.5.0-sdk27.wasm | Bin 55766 -> 0 bytes .../fixtures/mainnet-events-1.1.0-sdk23.wasm | Bin 51054 -> 0 bytes .../fixtures/mainnet-events-1.7.0.wasm | Bin 0 -> 62110 bytes .../fixtures/mainnet-profile-1.1.0-sdk23.wasm | Bin 15896 -> 0 bytes ...-sdk27.wasm => mainnet-profile-1.2.0.wasm} | Bin 16165 -> 16148 bytes .../fixtures/mainnet-state-63617727.json | 619 - .../compatibility/fixtures/mainnet-state.json | 93454 ++++++++++++++++ .../compatibility/fixtures/manifest.json | 54 +- contracts/compatibility/src/lib.rs | 1018 +- contracts/events/Cargo.toml | 2 +- contracts/events/src/admin.rs | 223 +- contracts/events/src/bounty.rs | 11 - contracts/events/src/crowdfunding.rs | 17 - contracts/events/src/errors.rs | 31 +- contracts/events/src/escrow.rs | 21 +- contracts/events/src/event_ops.rs | 228 +- contracts/events/src/events.rs | 69 +- contracts/events/src/grant.rs | 252 +- contracts/events/src/hackathon.rs | 13 - contracts/events/src/idempotency.rs | 23 +- contracts/events/src/lib.rs | 62 +- contracts/events/src/profile_client.rs | 1 - contracts/events/src/storage.rs | 151 +- contracts/events/src/tests/admin.rs | 205 +- contracts/events/src/tests/cancel_refund.rs | 9 +- contracts/events/src/tests/common.rs | 2 - contracts/events/src/tests/contributions.rs | 3 - contracts/events/src/tests/crowdfunding.rs | 308 +- contracts/events/src/tests/escrow_fee_math.rs | 5 +- contracts/events/src/tests/grant_pillar.rs | 7 +- .../events/src/tests/grant_scenarios/auth.rs | 313 + .../src/tests/grant_scenarios/cancel.rs | 208 + .../src/tests/grant_scenarios/claims.rs | 274 + .../tests/grant_scenarios/contributions.rs | 180 + .../src/tests/grant_scenarios/create.rs | 202 + .../src/tests/grant_scenarios/forfeits.rs | 209 + .../src/tests/grant_scenarios/hardening.rs | 358 + .../src/tests/grant_scenarios/lifecycle.rs | 184 + .../src/tests/grant_scenarios/limits.rs | 130 + .../events/src/tests/grant_scenarios/mod.rs | 469 + .../events/src/tests/grant_scenarios/pause.rs | 87 + .../src/tests/grant_scenarios/randomized.rs | 185 + .../tests/grant_scenarios/refusing_token.rs | 72 + .../src/tests/grant_scenarios/selection.rs | 238 + .../events/src/tests/hackathon_pillar.rs | 2 +- contracts/events/src/tests/mod.rs | 4 +- contracts/events/src/tests/op_id_security.rs | 35 +- contracts/events/src/tests/prize_claim.rs | 59 +- contracts/events/src/token_whitelist.rs | 2 - contracts/events/src/types.rs | 87 +- contracts/profile/Cargo.toml | 4 +- contracts/profile/src/admin.rs | 23 +- contracts/profile/src/errors.rs | 5 +- contracts/profile/src/events.rs | 9 - contracts/profile/src/lib.rs | 12 +- contracts/profile/src/reputation.rs | 34 +- contracts/profile/src/storage.rs | 26 +- contracts/profile/src/tests/admin.rs | 55 +- contracts/profile/src/tests/bootstrap.rs | 3 +- contracts/profile/src/tests/common.rs | 1 - contracts/profile/src/tests/earnings.rs | 18 +- contracts/profile/src/tests/mod.rs | 2 - contracts/profile/src/tests/reputation.rs | 171 +- contracts/profile/src/types.rs | 1 - 68 files changed, 98248 insertions(+), 2260 deletions(-) delete mode 100644 contracts/compatibility/fixtures/events-1.3.0-sdk23.wasm delete mode 100644 contracts/compatibility/fixtures/events-1.5.0-sdk27.wasm delete mode 100644 contracts/compatibility/fixtures/mainnet-events-1.1.0-sdk23.wasm create mode 100644 contracts/compatibility/fixtures/mainnet-events-1.7.0.wasm delete mode 100644 contracts/compatibility/fixtures/mainnet-profile-1.1.0-sdk23.wasm rename contracts/compatibility/fixtures/{profile-1.2.0-sdk27.wasm => mainnet-profile-1.2.0.wasm} (90%) delete mode 100644 contracts/compatibility/fixtures/mainnet-state-63617727.json create mode 100644 contracts/compatibility/fixtures/mainnet-state.json delete mode 100644 contracts/events/src/bounty.rs delete mode 100644 contracts/events/src/crowdfunding.rs delete mode 100644 contracts/events/src/hackathon.rs create mode 100644 contracts/events/src/tests/grant_scenarios/auth.rs create mode 100644 contracts/events/src/tests/grant_scenarios/cancel.rs create mode 100644 contracts/events/src/tests/grant_scenarios/claims.rs create mode 100644 contracts/events/src/tests/grant_scenarios/contributions.rs create mode 100644 contracts/events/src/tests/grant_scenarios/create.rs create mode 100644 contracts/events/src/tests/grant_scenarios/forfeits.rs create mode 100644 contracts/events/src/tests/grant_scenarios/hardening.rs create mode 100644 contracts/events/src/tests/grant_scenarios/lifecycle.rs create mode 100644 contracts/events/src/tests/grant_scenarios/limits.rs create mode 100644 contracts/events/src/tests/grant_scenarios/mod.rs create mode 100644 contracts/events/src/tests/grant_scenarios/pause.rs create mode 100644 contracts/events/src/tests/grant_scenarios/randomized.rs create mode 100644 contracts/events/src/tests/grant_scenarios/refusing_token.rs create mode 100644 contracts/events/src/tests/grant_scenarios/selection.rs diff --git a/Cargo.lock b/Cargo.lock index a4f4899..3e78079 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -223,7 +223,7 @@ dependencies = [ [[package]] name = "boundless-profile" -version = "1.2.0" +version = "1.2.1" dependencies = [ "soroban-sdk", ] @@ -232,6 +232,8 @@ dependencies = [ name = "boundless-storage-compatibility" version = "0.1.0" dependencies = [ + "boundless-events", + "boundless-profile", "soroban-sdk", ] @@ -1324,9 +1326,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "soroban-builtin-sdk-macros" -version = "27.0.0" +version = "28.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d769030e3b2c27873e9be4931decbbe79787b943d5b30e31f8c395eae83144b8" +checksum = "86ffd329211a50add3965a8f4a8f70e24fc5c8268c945566ab963029b12dce40" dependencies = [ "itertools", "proc-macro2", @@ -1336,12 +1338,12 @@ dependencies = [ [[package]] name = "soroban-env-common" -version = "27.0.0" +version = "28.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aa50d998c0baafcc6078cb94f5228040c7719b1608c15debc3fc78365dc22f5" +checksum = "610bb12ee937a56c2b629f8eb16efb7e47607cba4e79ab0c24132b9a53dd19a0" dependencies = [ "arbitrary", - "crate-git-revision 0.0.6", + "crate-git-revision 0.0.9", "ethnum", "num-derive", "num-traits", @@ -1355,9 +1357,9 @@ dependencies = [ [[package]] name = "soroban-env-guest" -version = "27.0.0" +version = "28.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fde1064f5e92dbcc8018ecc8e92728bf5bbff0236abaf792c6858367a6853415" +checksum = "7d383176b27bb286f718ab0ff5713bd9b01132089151266359bd9b20f38684cf" dependencies = [ "soroban-env-common", "static_assertions", @@ -1365,9 +1367,9 @@ dependencies = [ [[package]] name = "soroban-env-host" -version = "27.0.0" +version = "28.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f47762a1b75b9ccd07558f28e1a0289ca6adec56810c581cde5cf16e329e00b9" +checksum = "36d7e5920c200939164cc58038224e2d90d7e2fabbfa505d6e2129d648dce7e2" dependencies = [ "ark-bls12-381", "ark-bn254", @@ -1402,9 +1404,9 @@ dependencies = [ [[package]] name = "soroban-env-macros" -version = "27.0.0" +version = "28.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04b813a9e32456b37875fc41cdb05912a9e947000b9414b7985bffe07a889a" +checksum = "077600bb175ea54592c1ac5983b8fb34ec9ab7a65f298af11d687b61e9d646a4" dependencies = [ "itertools", "proc-macro2", @@ -1417,9 +1419,9 @@ dependencies = [ [[package]] name = "soroban-ledger-snapshot" -version = "27.0.0" +version = "28.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4bf41f85da648dca4153ca5ed41411658d2029e15d9ba693fdd2e3e5dbe05d" +checksum = "b5caeb0a4c8af6d04dc1dfbf96199e0d50ae086c7d54c9001abe78bb891fc44f" dependencies = [ "serde", "serde_json", @@ -1431,9 +1433,9 @@ dependencies = [ [[package]] name = "soroban-sdk" -version = "27.0.0" +version = "28.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5636f9d62dd0cc6d23f08f775aa60f712ec596e5761f6911aee154337d401bf" +checksum = "e4946477e75667215a25b652b0febb446485686c944d364c117c903e3fb98bab" dependencies = [ "arbitrary", "bytes-lit", @@ -1455,9 +1457,9 @@ dependencies = [ [[package]] name = "soroban-sdk-macros" -version = "27.0.0" +version = "28.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c9f001d91196d9cd659634f9113f75609819204aef5fae27b4ab845cdfc741" +checksum = "168f2d2c3fd4da57584c71e0a8315a8e656486495bc53e18c93b8ec4db085438" dependencies = [ "darling 0.20.11", "heck", @@ -1475,9 +1477,9 @@ dependencies = [ [[package]] name = "soroban-spec" -version = "27.0.0" +version = "28.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca1e22ce713871c6f9d21a321051b58e53080629b6a5be1132cf11f42cca4d10" +checksum = "2f48c8e183fc140ccd89e1fe1663cc605fb18641967c0bb591f64887599fde59" dependencies = [ "base64", "sha2", @@ -1488,9 +1490,9 @@ dependencies = [ [[package]] name = "soroban-spec-rust" -version = "27.0.0" +version = "28.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89fb158f79ec527e46a5d5162f11c4269e17748e6b69391cdf648133d76cfead" +checksum = "40cd0021887fd047e9f63cea36da35b202881bc0e0dbcc067a146137ca46f192" dependencies = [ "prettyplease", "proc-macro2", @@ -1566,14 +1568,14 @@ dependencies = [ [[package]] name = "stellar-xdr" -version = "27.0.0" +version = "28.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05ff843326969bdf1ef673dcdba94c08f4a3c8f1e58d6e6ef39b1bd4f749179a" +checksum = "f93d09ff8b9f919b084f664003c4c546ac66a76affd5429460dbe29f4b326f8e" dependencies = [ "arbitrary", "base64", "cfg_eval", - "crate-git-revision 0.0.6", + "crate-git-revision 0.0.9", "escape-bytes", "ethnum", "hex", diff --git a/Cargo.toml b/Cargo.toml index 52f51f7..a8484d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = [ ] [workspace.dependencies] -soroban-sdk = "27.0.0" +soroban-sdk = "28.0.0" [profile.release] opt-level = "z" diff --git a/contracts/compatibility/Cargo.toml b/contracts/compatibility/Cargo.toml index 8920cbc..88a1c71 100644 --- a/contracts/compatibility/Cargo.toml +++ b/contracts/compatibility/Cargo.toml @@ -9,3 +9,7 @@ doctest = false [dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } + +[dev-dependencies] +boundless-events = { path = "../events" } +boundless-profile = { path = "../profile" } diff --git a/contracts/compatibility/fixtures/events-1.3.0-sdk23.wasm b/contracts/compatibility/fixtures/events-1.3.0-sdk23.wasm deleted file mode 100644 index b3897361fb03ae477698e6e552adffc915d3a967..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53924 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 diff --git a/contracts/compatibility/fixtures/events-1.5.0-sdk27.wasm b/contracts/compatibility/fixtures/events-1.5.0-sdk27.wasm deleted file mode 100644 index f8453b664128ce92ba94caa5014be5296cb37c59..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 55766 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 diff --git a/contracts/compatibility/fixtures/mainnet-events-1.1.0-sdk23.wasm b/contracts/compatibility/fixtures/mainnet-events-1.1.0-sdk23.wasm deleted file mode 100644 index 0087d084d3106cba8d5b40849b14c2dfaff9fad5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 51054 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 diff --git a/contracts/compatibility/fixtures/mainnet-events-1.7.0.wasm b/contracts/compatibility/fixtures/mainnet-events-1.7.0.wasm new file mode 100644 index 0000000000000000000000000000000000000000..6f168408f09ae21b3c190521191a4be04fdeae33 GIT binary patch literal 62110 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 F{{vBC>ZSky literal 0 HcmV?d00001 diff --git a/contracts/compatibility/fixtures/mainnet-profile-1.1.0-sdk23.wasm b/contracts/compatibility/fixtures/mainnet-profile-1.1.0-sdk23.wasm deleted file mode 100644 index c3109610044b30139315f71a3c96bfec9100b04d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15896 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 diff --git a/contracts/compatibility/fixtures/profile-1.2.0-sdk27.wasm b/contracts/compatibility/fixtures/mainnet-profile-1.2.0.wasm similarity index 90% rename from contracts/compatibility/fixtures/profile-1.2.0-sdk27.wasm rename to contracts/compatibility/fixtures/mainnet-profile-1.2.0.wasm index 5154b31ee9dbd0a7b4a0575c44131c7855ecd110..0c380436c57ddcf411a82e473cda9c21ea744b27 100644 GIT binary patch delta 197 zcmZ2lH>GYv7$Xxa&*limDkjFelP@#ZGI~!AWKjZ=C17$onA`;>?}Erltg%4JI98C7 zMliXAHJ8z6vJjge6x495$8BdpNEzG5Tzt z$i0n`QF^ln?|CLh>B$lTk0%QW$^aGW3rdKIF(@#sby8?^VrfxegfaxUH@gY4bF=g_ ZF|kYzlnG-IQD9P-yhKK8^D`NHB>-r&H+uj8 delta 217 zcmbPIx3q3U7$Xx8>*ff?DkjEvlP@#ZGWtvoWKjZ=C17$onA`;>?}Erltg%4JI98C7 zMliXAHJ8zQvJjgeg0&&I(Blz73uo>6-895$8BdpNEzF?w&F z$i0n`QFgNj?|CLh*~t*PS0u*vIW)Fps2^$N_64eg9sN^A std::vec::Vec { + let dir = std::env::var("BOUNDLESS_WASM_DIR").unwrap_or_else(|_| { + concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../target/wasm32v1-none/release" + ) + .to_string() + }); + let path = format!("{dir}/{name}.wasm"); + std::fs::read(&path).unwrap_or_else(|_| { + panic!("{path} is missing; run scripts/test-storage-compat.sh, which builds it") + }) } -mod new_profile { - soroban_sdk::contractimport!(file = "fixtures/profile-1.2.0-sdk27.wasm"); +/// Loads the captured ledger. The test host runs protocol 28, the newest it +/// supports, so a capture from a later protocol is replayed at 28; nothing the +/// contracts store depends on the protocol version. +fn mainnet_env() -> Env { + let fixture = format!("{}/fixtures/{SNAPSHOT}", env!("CARGO_MANIFEST_DIR")); + let captured = std::fs::read_to_string(&fixture).expect("mainnet snapshot fixture"); + let key = "\"protocol_version\": "; + let at = captured.find(key).expect("snapshot records its protocol") + key.len(); + let digits = captured[at..] + .chars() + .take_while(char::is_ascii_digit) + .count(); + let replayed = format!("{}28{}", &captured[..at], &captured[at + digits..]); + let path = std::env::temp_dir().join("boundless-mainnet-state-p28.json"); + std::fs::write(&path, replayed).expect("write replayed snapshot"); + let env = Env::from_ledger_snapshot_file(path); + env.mock_all_auths_allowing_non_root_auth(); + env } -const FEE_BPS: u32 = 250; -const BUDGET: i128 = 10_000_000_000; -const CONTRIBUTION: i128 = 100_000_000; -const TIMELOCK: u32 = 17_280; - -fn next_op(env: &Env, counter: &Cell) -> BytesN<32> { - let value = counter.get(); - counter.set(value + 1); - let mut bytes = [0; 32]; - bytes[28..].copy_from_slice(&value.to_be_bytes()); - BytesN::from_array(env, &bytes) +fn addr(env: &Env, strkey: &str) -> Address { + Address::from_str(env, strkey) } -#[contracttype] -enum LegacyProfileDataKey { - DefaultBootstrapCredits, - DeploymentSeq, - OpSeen(BytesN<32>), +fn debug(value: T) -> std::string::String { + format!("{value:?}") } -#[contracttype] -enum LegacyEventsDataKey { - EventApplicantSlot(u64, Address), - ContributorSlot(u64, Address), - SupportedTokenSlot(Address), +/// recipient, position, amount, milestone, paid_at +type WinnerRow = (Address, u32, i128, Option, Option); + +/// One event as either build reports it, reduced to values both can express. +#[derive(Debug, PartialEq)] +struct EventView { + pillar: std::string::String, + owner: Address, + token: Address, + total_budget: i128, + remaining_escrow: i128, + release_kind: std::string::String, + status: std::string::String, + title: String, + created_at: u64, + prize_floors: Map, + winners: std::vec::Vec, } -fn distribution(env: &Env) -> Map { - let mut distribution = Map::new(env); - distribution.set(1, 100); - distribution +fn event_ids(env: &Env, events_id: &Address, first: u64) -> std::ops::Range { + let next: u64 = env.as_contract(events_id, || { + env.storage() + .instance() + .get(&DataKey::NextEventId) + .expect("deployment has created events") + }); + first..next } -fn params( - env: &Env, - owner: &Address, - token: &Address, - title: &str, -) -> old_events::CreateEventParams { - old_events::CreateEventParams { - content_uri: String::from_str(env, "ipfs://event"), - deadline: Some(env.ledger().timestamp() + 86_400), - fee_bps_override: None, - manager: None, - owner: owner.clone(), - pillar: old_events::Pillar::Hackathon, - release_kind: old_events::ReleaseKind::Single, - title: String::from_str(env, title), - token: token.clone(), - total_budget: BUDGET, - winner_distribution: distribution(env), +fn view_before(events: &deployed_events::Client, id: u64) -> Option { + let event = events.try_get_event(&id).ok()?.ok()?; + let count = events.get_winner_count(&id); + let mut winners = std::vec::Vec::new(); + let mut start = 0; + while start < count { + for w in events.get_winners_page(&id, &start, &PAGE).iter() { + winners.push((w.recipient, w.position, w.amount, w.milestone, w.paid_at)); + } + start += PAGE; } + Some(EventView { + pillar: debug(event.pillar), + owner: event.owner, + token: event.token, + total_budget: event.total_budget, + remaining_escrow: event.remaining_escrow, + release_kind: debug(event.release_kind), + status: debug(event.status), + title: event.title, + created_at: event.created_at, + prize_floors: event.prize_floors, + winners, + }) } -fn pre27_params( - env: &Env, - owner: &Address, - token: &Address, - title: &str, -) -> pre27_events::CreateEventParams { - pre27_events::CreateEventParams { - content_uri: String::from_str(env, "ipfs://event"), - deadline: Some(env.ledger().timestamp() + 86_400), - fee_bps_override: None, - manager: None, - owner: owner.clone(), - pillar: pre27_events::Pillar::Hackathon, - release_kind: pre27_events::ReleaseKind::Single, - title: String::from_str(env, title), - token: token.clone(), - total_budget: BUDGET, - winner_distribution: distribution(env), +fn view_after(events: &EventsContractClient, id: u64) -> EventView { + let event = events.get_event(&id); + let count = events.get_winner_count(&id); + let mut winners = std::vec::Vec::new(); + let mut start = 0; + while start < count { + for w in events.get_winners_page(&id, &start, &PAGE).iter() { + winners.push((w.recipient, w.position, w.amount, w.milestone, w.paid_at)); + } + start += PAGE; } -} - -fn new_params( - env: &Env, - owner: &Address, - token: &Address, - title: &str, -) -> new_events::CreateEventParams { - new_events::CreateEventParams { - content_uri: String::from_str(env, "ipfs://event"), - deadline: Some(env.ledger().timestamp() + 86_400), - fee_bps_override: None, - manager: None, - owner: owner.clone(), - pillar: new_events::Pillar::Hackathon, - release_kind: new_events::ReleaseKind::Single, - title: String::from_str(env, title), - token: token.clone(), - total_budget: BUDGET, - winner_distribution: distribution(env), + EventView { + pillar: debug(event.pillar), + owner: event.owner, + token: event.token, + total_budget: event.total_budget, + remaining_escrow: event.remaining_escrow, + release_kind: debug(event.release_kind), + status: debug(event.status), + title: event.title, + created_at: event.created_at, + prize_floors: event.prize_floors, + winners, } } #[test] -fn sdk23_v110_rows_survive_h6_upgrade_to_sdk27() { - let env = Env::default(); - let ops = Cell::new(1); - env.mock_all_auths_allowing_non_root_auth(); - env.ledger().with_mut(|ledger| { - ledger.sequence_number = 100; - ledger.timestamp = 1_000_000; - // Preserve legacy replay markers while the test advances through the timelock. - ledger.min_temp_entry_ttl = TIMELOCK + 100; - }); - - let admin = Address::generate(&env); - let fee_account = Address::generate(&env); - - let profile_id = env.register(old_profile::WASM, (&admin,)); - let profile_v1 = old_profile::Client::new(&env, &profile_id); - - let events_id = env.register( - old_events::WASM, - (&admin, &fee_account, &FEE_BPS, &profile_id), - ); - let events_v1 = old_events::Client::new(&env, &events_id); - profile_v1.set_events_contract(&events_id); - - let issuer = Address::generate(&env); - let asset = env.register_stellar_asset_contract_v2(issuer); - let token_id = asset.address(); - let token_admin = token::StellarAssetClient::new(&env, &token_id); - token_admin.mint(&fee_account, &0); - - let owner = Address::generate(&env); - let winner = Address::generate(&env); - let grant_recipient = Address::generate(&env); - let contributor = Address::generate(&env); - let manager = Address::generate(&env); - let next_events_admin = Address::generate(&env); - let next_profile_admin = Address::generate(&env); - let rotated_events_id = Address::generate(&env); - let legacy_profile_user = Address::generate(&env); - let legacy_profile_op = next_op(&env, &ops); - token_admin.mint(&owner, &(BUDGET * 6)); - token_admin.mint(&contributor, &(CONTRIBUTION * 4)); - events_v1.register_supported_token(&token_id); - let token = token::Client::new(&env, &token_id); - profile_v1.bootstrap_self(&legacy_profile_user, &legacy_profile_op); - - let mut completed_params = params(&env, &owner, &token_id, "completed"); - completed_params.pillar = old_events::Pillar::Bounty; - let legacy_event_create_op = next_op(&env, &ops); - let completed_id = events_v1.create_event(&completed_params, &legacy_event_create_op); - events_v1.apply_to_bounty(&completed_id, &winner, &next_op(&env, &ops)); - events_v1.submit( - &completed_id, - &winner, - &String::from_str(&env, "ipfs://submission"), - &next_op(&env, &ops), - ); - events_v1.select_winners( - &completed_id, - &vec![ - &env, - old_events::WinnerSpec { - position: 1, - recipient: winner.clone(), - reputation_bump: 7, - }, - ], - &next_op(&env, &ops), +#[ignore = "needs the release wasm; run scripts/test-storage-compat.sh"] +fn mainnet_upgrades_from_the_deployed_builds_to_this_tree() { + let env = mainnet_env(); + let events_id = addr(&env, EVENTS_ID); + let profile_id = addr(&env, PROFILE_ID); + + // Uploading the deployed code is what makes the captured instances run; + // its hash has to equal the one each instance points at. + env.deployer().upload_contract_wasm(deployed_events::WASM); + env.deployer().upload_contract_wasm(deployed_profile::WASM); + let events_v1 = deployed_events::Client::new(&env, &events_id); + let profile_v1 = deployed_profile::Client::new(&env, &profile_id); + assert_eq!(events_v1.version(), String::from_str(&env, "1.7.0")); + assert_eq!(profile_v1.version(), String::from_str(&env, "1.2.0")); + + let first = events_v1.id_base() + 1; + let ids = event_ids(&env, &events_id, first); + let mut before = std::vec::Vec::new(); + let mut old_layout = std::vec::Vec::new(); + for id in ids.clone() { + match view_before(&events_v1, id) { + Some(view) => before.push((id, view)), + None => old_layout.push(id), + } + } + assert_eq!( + old_layout, + std::vec![first, first + 1], + "the deployed build cannot read exactly the two pre-1.7.0 records" ); - let cancelling_id = events_v1.create_event( - ¶ms(&env, &owner, &token_id, "cancelling"), - &next_op(&env, &ops), - ); - events_v1.add_funds( - &cancelling_id, - &contributor, - &CONTRIBUTION, - &next_op(&env, &ops), - ); - events_v1.start_cancel(&cancelling_id, &next_op(&env, &ops)); - - let mut active_params = params(&env, &owner, &token_id, "active"); - active_params.manager = Some(manager.clone()); - let active_contributor_id = events_v1.create_event(&active_params, &next_op(&env, &ops)); - events_v1.add_funds( - &active_contributor_id, - &contributor, - &CONTRIBUTION, - &next_op(&env, &ops), + assert!( + before.iter().any(|(_, view)| !view.winners.is_empty()), + "the capture must hold paid winners for the comparison to mean anything" ); - let mut grant_params = params(&env, &owner, &token_id, "grant"); - grant_params.pillar = old_events::Pillar::Grant; - grant_params.release_kind = old_events::ReleaseKind::Multi(2); - let grant_id = events_v1.create_event(&grant_params, &next_op(&env, &ops)); - events_v1.select_winners( - &grant_id, - &vec![ - &env, - old_events::WinnerSpec { - position: 1, - recipient: grant_recipient.clone(), - reputation_bump: 0, - }, - ], - &next_op(&env, &ops), - ); - let grant_balance_before = token.balance(&grant_recipient); - events_v1.claim_milestone(&grant_id, &grant_recipient, &0, &5, &next_op(&env, &ops)); - - let mut crowdfunding_params = params(&env, &owner, &token_id, "crowdfunding"); - crowdfunding_params.pillar = old_events::Pillar::Crowdfunding; - crowdfunding_params.release_kind = old_events::ReleaseKind::Multi(2); - let crowdfunding_id = events_v1.create_event(&crowdfunding_params, &next_op(&env, &ops)); - events_v1.add_funds( - &crowdfunding_id, - &contributor, - &CONTRIBUTION, - &next_op(&env, &ops), - ); - events_v1.claim_milestone(&crowdfunding_id, &owner, &0, &0, &next_op(&env, &ops)); - - profile_v1.migrate(); - events_v1.migrate(); - profile_v1.set_admin(&next_profile_admin); - events_v1.set_admin(&next_events_admin); - profile_v1.propose_events_contract(&rotated_events_id); - - let new_profile_hash = env.deployer().upload_contract_wasm(new_profile::WASM); - profile_v1.propose_upgrade(&new_profile_hash, &String::from_str(&env, "1.2.0")); - let new_events_hash = env.deployer().upload_contract_wasm(new_events::WASM); - events_v1.propose_upgrade(&new_events_hash, &String::from_str(&env, "1.5.0")); - env.ledger().with_mut(|ledger| { - ledger.sequence_number += TIMELOCK; - }); - profile_v1.apply_upgrade(); + let recipients: std::vec::Vec
= before + .iter() + .flat_map(|(_, view)| view.winners.iter().map(|w| w.0.clone())) + .collect(); + let reputations_before: std::vec::Vec = recipients + .iter() + .map(|r| profile_v1.get_profile(r).map_or(0, |p| p.reputation)) + .collect(); - let profile_v2 = new_profile::Client::new(&env, &profile_id); - assert_eq!(profile_v2.version(), String::from_str(&env, "1.2.0")); - assert_eq!( - profile_v2.get_migrated_to_version(), - Some(String::from_str(&env, "1.1.0")) - ); - let pending_events_contract = profile_v2.get_pending_events_contract().unwrap(); - assert_eq!(pending_events_contract.target, rotated_events_id); - profile_v2.cancel_pending_events_contract(); - assert_eq!(profile_v2.get_pending_events_contract(), None); - profile_v2.accept_admin(); - assert_eq!(profile_v2.get_admin(), next_profile_admin); - let stored_profile = profile_v2.get_profile(&winner).unwrap(); - assert_eq!(stored_profile.reputation, 7); - assert_eq!(profile_v2.get_earnings(&winner, &token_id), BUDGET); - let profile_replay_error = profile_v2 - .try_bootstrap_self(&legacy_profile_user, &legacy_profile_op) - .expect_err("SDK-23 profile replay row must remain effective") - .unwrap(); - assert_eq!(profile_replay_error, new_profile::Error::OpAlreadySeen); + // The runbook order: profile first, left running; then events, paused + // from propose to unpause. The deployed builds have no upgrade timelock. + let profile_hash = env + .deployer() + .upload_contract_wasm(release_wasm("boundless_profile").as_slice()); + profile_v1.propose_upgrade(&profile_hash, &String::from_str(&env, "1.2.1")); + profile_v1.apply_upgrade(); + let profile = ProfileContractClient::new(&env, &profile_id); + profile.migrate(); + assert!(!profile.is_paused()); + events_v1.pause(); + let events_hash = env + .deployer() + .upload_contract_wasm(release_wasm("boundless_events").as_slice()); + events_v1.propose_upgrade(&events_hash, &String::from_str(&env, "2.0.0")); events_v1.apply_upgrade(); - - let events_v2 = new_events::Client::new(&env, &events_id); - assert_eq!(events_v2.version(), String::from_str(&env, "1.5.0")); - assert_eq!( - events_v2.get_migrated_to_version(), - Some(String::from_str(&env, "1.1.0")) - ); - events_v2.accept_admin(); - assert_eq!(events_v2.get_admin(), next_events_admin); - assert_eq!(events_v2.get_fee_account(), fee_account); - assert_eq!(events_v2.get_fee_bps(), FEE_BPS); - assert_eq!(events_v2.get_profile_contract(), profile_id); - assert_eq!(events_v2.get_applicant_count(&completed_id), 1); - assert_eq!( - events_v2.get_applicant_at(&completed_id, &0), - Some(winner.clone()) - ); + let events = EventsContractClient::new(&env, &events_id); + assert!(events.is_paused()); assert_eq!( - env.as_contract(&events_id, || env.storage().persistent().get::<_, u32>( - &LegacyEventsDataKey::EventApplicantSlot(completed_id, winner.clone()) - )), - Some(1) + events.try_migrate().unwrap_err().unwrap(), + boundless_events::errors::Error::MigrationIncomplete ); + assert_eq!(events.migrate_events(&8), 0); + events.migrate(); + events.unpause(); - let completed = events_v2.get_event(&completed_id); - assert_eq!(completed.title, String::from_str(&env, "completed")); - assert_eq!(completed.status, new_events::EventStatus::Completed); - let submission = events_v2.get_submission(&completed_id, &winner); - assert_eq!( - submission.content_uri, - String::from_str(&env, "ipfs://submission") - ); - let winners = events_v2.get_winners(&completed_id); - assert_eq!(winners.len(), 1); - assert_eq!(winners.get(0).unwrap().recipient, winner); - - assert_eq!(events_v2.get_contributor_count(&cancelling_id), 1); - assert_eq!( - events_v2.get_contributor_amount(&cancelling_id, &contributor), - CONTRIBUTION - ); + assert_eq!(events.version(), String::from_str(&env, "2.0.0")); + assert_eq!(profile.version(), String::from_str(&env, "1.2.1")); assert_eq!( - events_v2.process_cancel_batch(&cancelling_id, &25, &next_op(&env, &ops)), - 0 + events.get_migrated_to_version(), + Some(String::from_str(&env, "2.0.0")) ); - events_v2.finalize_cancel(&cancelling_id, &next_op(&env, &ops)); assert_eq!( - events_v2.get_event(&cancelling_id).status, - new_events::EventStatus::Cancelled + profile.get_migrated_to_version(), + Some(String::from_str(&env, "1.2.1")) ); + assert_eq!(events.get_admin(), addr(&env, ADMIN)); + assert_eq!(profile.get_admin(), addr(&env, ADMIN)); + assert_eq!(events.get_fee_account(), addr(&env, FEE_ACCOUNT)); + assert_eq!(events.get_fee_bps(), 250); + assert_eq!(events.get_profile_contract(), profile_id); + assert_eq!(profile.get_events_contract(), Some(events_id.clone())); + assert_eq!(events.id_base() + 1, first); + assert!(events.is_supported_token(&addr(&env, MAINNET_USDC))); + assert_eq!(events.get_release_validator(), None); - assert_eq!( - events_v2.get_contributor_amount(&active_contributor_id, &contributor), - CONTRIBUTION - ); - assert_eq!( - events_v2.get_contributor_at(&active_contributor_id, &0), - Some(contributor.clone()) - ); - assert_eq!( - env.as_contract(&events_id, || env.storage().persistent().get::<_, u32>( - &LegacyEventsDataKey::ContributorSlot(active_contributor_id, contributor.clone()) - )), - Some(1) - ); - assert_eq!(events_v2.get_manager(&active_contributor_id), manager); - assert!(events_v2 - .try_start_cancel(&active_contributor_id, &next_op(&env, &ops)) - .is_err()); - - let events_replay_error = events_v2 - .try_create_event( - &new_params(&env, &owner, &token_id, "legacy-op-replay"), - &legacy_event_create_op, - ) - .expect_err("SDK-23 events replay row must remain effective") - .unwrap(); - assert_eq!(events_replay_error, new_events::Error::OpAlreadySeen); + for (id, view) in &before { + assert_eq!(&view_after(&events, *id), view, "event {id} changed"); + } - let post_upgrade_id = events_v2.create_event( - &new_params(&env, &owner, &token_id, "post-upgrade"), - &next_op(&env, &ops), - ); - assert_eq!(post_upgrade_id, crowdfunding_id + 1); + // Percentages were always taken of the budget, so each converted floor is + // exactly what the old record would have paid that position. + for id in old_layout { + let event = events.get_event(&id); + let winners = view_after(&events, id).winners; + assert!(!event.prize_floors.is_empty()); + let floors_total: i128 = event.prize_floors.values().iter().sum(); + assert!(floors_total <= event.total_budget); + for (recipient, position, amount, milestone, paid_at) in winners { + assert!(milestone.is_none() && paid_at.is_some(), "{recipient:?}"); + assert_eq!(event.prize_floors.get(position), Some(amount)); + } + } - let grant_replay_error = events_v2 - .try_claim_milestone(&grant_id, &grant_recipient, &0, &5, &next_op(&env, &ops)) - .expect_err("SDK-23 milestone row must reject a duplicate claim") - .unwrap(); - assert_eq!( - grant_replay_error, - new_events::Error::MilestoneAlreadyClaimed - ); - let fee_before_grant_claim = token.balance(&fee_account); - events_v2.claim_milestone(&grant_id, &grant_recipient, &1, &5, &next_op(&env, &ops)); - assert_eq!( - token.balance(&grant_recipient) - grant_balance_before, - BUDGET - ); - assert_eq!(token.balance(&fee_account) - fee_before_grant_claim, 0); - assert_eq!( - events_v2.get_event(&grant_id).status, - new_events::EventStatus::Completed - ); - assert_eq!( - profile_v2.get_profile(&grant_recipient).unwrap().reputation, - 10 - ); - assert_eq!(profile_v2.get_earnings(&grant_recipient, &token_id), BUDGET); + for (recipient, reputation) in recipients.iter().zip(reputations_before) { + let after = profile.get_profile(recipient).map_or(0, |p| p.reputation); + assert_eq!(after, reputation); + } - let crowdfunding_replay_error = events_v2 - .try_claim_milestone(&crowdfunding_id, &owner, &0, &0, &next_op(&env, &ops)) - .expect_err("SDK-23 crowdfunding milestone row must reject a duplicate claim") - .unwrap(); - assert_eq!( - crowdfunding_replay_error, - new_events::Error::MilestoneAlreadyClaimed - ); - let crowdfunding_remaining = events_v2.get_event(&crowdfunding_id).remaining_escrow; - let crowdfunding_balance_before = token.balance(&owner); - let fee_before_crowdfunding_claim = token.balance(&fee_account); - events_v2.claim_milestone(&crowdfunding_id, &owner, &1, &0, &next_op(&env, &ops)); - let crowdfunding_fee = crowdfunding_remaining * FEE_BPS as i128 / 10_000; - assert_eq!( - token.balance(&owner) - crowdfunding_balance_before, - crowdfunding_remaining - crowdfunding_fee - ); - assert_eq!( - token.balance(&fee_account) - fee_before_crowdfunding_claim, - crowdfunding_fee - ); - assert_eq!( - events_v2.get_event(&crowdfunding_id).status, - new_events::EventStatus::Completed - ); + // A second pass finds nothing to do, and the stamp cannot be replayed. + assert_eq!(events.migrate_events(&8), 0); + assert!(events.try_migrate().is_err()); - profile_v2.migrate(); - events_v2.migrate(); - assert_eq!( - profile_v2.get_migrated_to_version(), - Some(String::from_str(&env, "1.2.0")) - ); - assert_eq!( - events_v2.get_migrated_to_version(), - Some(String::from_str(&env, "1.5.0")) - ); + run_a_grant(&env, &events, &profile, ids.end); } -#[test] -fn sdk23_pre27_prize_and_cancellation_keys_work_after_upgrade() { - let env = Env::default(); - let ops = Cell::new(1_000); - env.mock_all_auths_allowing_non_root_auth(); - env.ledger().with_mut(|ledger| { - ledger.sequence_number = 100; - ledger.timestamp = 1_000_000; - }); - - let admin = Address::generate(&env); - let fee_account = Address::generate(&env); - let profile_id = env.register(old_profile::WASM, (&admin,)); - let profile_v1 = old_profile::Client::new(&env, &profile_id); - let events_id = env.register( - pre27_events::WASM, - (&admin, &fee_account, &FEE_BPS, &profile_id), - ); - let events_v1 = pre27_events::Client::new(&env, &events_id); - assert_eq!(events_v1.version(), String::from_str(&env, "1.3.0")); - profile_v1.set_events_contract(&events_id); - - let issuer = Address::generate(&env); - let asset = env.register_stellar_asset_contract_v2(issuer); - let token_id = asset.address(); - let token_admin = token::StellarAssetClient::new(&env, &token_id); - token_admin.mint(&fee_account, &0); - - let owner = Address::generate(&env); - let winner = Address::generate(&env); - let second_winner = Address::generate(&env); - let contributor = Address::generate(&env); - token_admin.mint(&owner, &(BUDGET * 3)); - token_admin.mint(&contributor, &(CONTRIBUTION * 2)); - events_v1.register_supported_token(&token_id); - - let mut prize_params = pre27_params(&env, &owner, &token_id, "prize"); - let mut prize_distribution = Map::new(&env); - prize_distribution.set(1, 50); - prize_distribution.set(2, 50); - prize_params.winner_distribution = prize_distribution; - let prize_id = events_v1.create_event(&prize_params, &next_op(&env, &ops)); - events_v1.select_winners( - &prize_id, +/// A grant through the upgraded pair, on a fresh token: selection, releases, a +/// forfeit, and the close that returns the forfeited share. +fn run_a_grant( + env: &Env, + events: &EventsContractClient, + profile: &ProfileContractClient, + expected_id: u64, +) { + // The mainnet fee account has no ledger entry in the capture, so a test + // address takes the fee. + let fee_account = Address::generate(env); + events.set_fee_account(&fee_account); + + let issuer = Address::generate(env); + let token_id = env.register_stellar_asset_contract_v2(issuer).address(); + let token = token::Client::new(env, &token_id); + events.register_supported_token(&token_id); + + let budget: i128 = 900_0000000; + let fee = budget * 250 / 10_000; + let owner = Address::generate(env); + token::StellarAssetClient::new(env, &token_id).mint(&owner, &(budget + fee)); + + let mut floors = Map::new(env); + floors.set(1, 600_0000000_i128); + floors.set(2, 300_0000000_i128); + let id = events.create_event( + &CreateEventParams { + pillar: Pillar::Grant, + owner: owner.clone(), + token: token_id.clone(), + total_budget: budget, + release_kind: ReleaseKind::Multi(3), + content_uri: String::from_str(env, "https://boundlessfi.xyz/grants/replay"), + title: String::from_str(env, "Upgrade replay"), + deadline: None, + prize_floors: floors, + fee_bps_override: None, + manager: None, + }, + &BytesN::random(env), + ); + assert_eq!( + id, expected_id, + "event ids continue from the deployed counter" + ); + assert_eq!(token.balance(&fee_account), fee); + + let (a, b) = (Address::generate(env), Address::generate(env)); + events.select_winners( + &id, &vec![ - &env, - pre27_events::WinnerSpec { + env, + WinnerSpec { + recipient: a.clone(), position: 1, - recipient: winner.clone(), - reputation_bump: 11, + amount: 600_0000000, + reputation_bump: 10, }, - ], - &next_op(&env, &ops), - ); - - let cancellation_id = events_v1.create_event( - &pre27_params(&env, &owner, &token_id, "aggregate"), - &next_op(&env, &ops), - ); - events_v1.add_funds( - &cancellation_id, - &contributor, - &CONTRIBUTION, - &next_op(&env, &ops), - ); - - let new_profile_hash = env.deployer().upload_contract_wasm(new_profile::WASM); - profile_v1.propose_upgrade(&new_profile_hash, &String::from_str(&env, "1.2.0")); - let new_events_hash = env.deployer().upload_contract_wasm(new_events::WASM); - events_v1.propose_upgrade(&new_events_hash, &String::from_str(&env, "1.5.0")); - env.ledger().with_mut(|ledger| { - ledger.sequence_number += TIMELOCK; - }); - profile_v1.apply_upgrade(); - - events_v1.apply_upgrade(); - - let profile_v2 = new_profile::Client::new(&env, &profile_id); - let events_v2 = new_events::Client::new(&env, &events_id); - let token = token::Client::new(&env, &token_id); - - assert_eq!(token.balance(&winner), 0); - assert!(events_v2 - .try_start_cancel(&prize_id, &next_op(&env, &ops)) - .is_err()); - events_v2.select_winners( - &prize_id, - &vec![ - &env, - new_events::WinnerSpec { + WinnerSpec { + recipient: b.clone(), position: 2, - recipient: second_winner.clone(), - reputation_bump: 13, + amount: 300_0000000, + reputation_bump: 10, }, ], - &next_op(&env, &ops), - ); - let fee_before_prize_claims = token.balance(&fee_account); - events_v2.claim_prize(&prize_id, &1, &next_op(&env, &ops)); - events_v2.claim_prize(&prize_id, &2, &next_op(&env, &ops)); - assert_eq!(token.balance(&winner), BUDGET / 2); - assert_eq!(token.balance(&second_winner), BUDGET / 2); - assert_eq!(token.balance(&fee_account) - fee_before_prize_claims, 0); - assert_eq!( - events_v2.get_event(&prize_id).status, - new_events::EventStatus::Completed - ); - assert_eq!(profile_v2.get_profile(&winner).unwrap().reputation, 11); - assert_eq!( - profile_v2.get_profile(&second_winner).unwrap().reputation, - 13 - ); - assert_eq!(profile_v2.get_earnings(&winner, &token_id), BUDGET / 2); - assert_eq!( - profile_v2.get_earnings(&second_winner, &token_id), - BUDGET / 2 - ); - - events_v2.start_cancel(&cancellation_id, &next_op(&env, &ops)); - assert_eq!( - events_v2.get_event(&cancellation_id).status, - new_events::EventStatus::Cancelling + &BytesN::random(env), ); - assert_eq!( - events_v2.process_cancel_batch(&cancellation_id, &25, &next_op(&env, &ops)), - 0 - ); - events_v2.finalize_cancel(&cancellation_id, &next_op(&env, &ops)); - assert_eq!( - events_v2.get_event(&cancellation_id).status, - new_events::EventStatus::Cancelled - ); -} -#[test] -fn exact_mainnet_snapshot_survives_h6_upgrade_to_sdk27() { - let env = Env::from_ledger_snapshot_file(concat!( - env!("CARGO_MANIFEST_DIR"), - "/fixtures/mainnet-state-63617727.json" - )); - env.mock_all_auths_allowing_non_root_auth(); - - let events_id = Address::from_str( - &env, - "CCFVEGOQJEM47LRAJU2LHEK4KTL5VYN7AOGZ2HH2GNHAMXTILNMMJGQZ", - ); - let profile_id = Address::from_str( - &env, - "CD3KH4OE7HDHHHUYFX3U4L7NLIILMXAY6HM5FEH2UH6UBOKX4HDNE3PC", - ); - let admin = Address::from_str( - &env, - "GCVK72I6TVJVDTTY4UKU6MQT4QJ2T2AAG3NULNEUDM46L3UOQYDSO4O2", - ); - let fee_account = Address::from_str( - &env, - "GADSIP2HPINWTMEUNMVYA5NJRL372OV52HDEJTRTLDZXMHADIPQNYH55", - ); - let token_0 = Address::from_str( - &env, - "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", - ); - let token_1 = Address::from_str( - &env, - "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", - ); - let profile_addresses = [ - "GDCLE5VYYAZ5HYSBSG3PIKQEXHPYNSHF2W7PSW2R7WEIPP2AD275EL4S", - "GAHGDV4MWDLT3BXOUT5A2T774IUAE54DZ6BUCBH4O7PG5TJGWXSV3OJO", - "GBVQKBWHZGO6YED3EVXOZW76VPNYUMJLBLKE5IN4XE5LTT262UXCBIR7", - "GCCCPDSWYDVTD2VSXNXDKWWNENSRTA637CHVHIIXISTKSK4FV6T7G3HV", - "GCS5XWO3M72B6PSMZVGOSB3CLQVWB5OJ6Z7FTFAP5FAZVWS3MLO4Y3WH", - ]; - let legacy_op_ids = [ - [ - 0xd0, 0x12, 0x86, 0xfa, 0x65, 0x4b, 0x4f, 0x92, 0x4e, 0xb2, 0xa1, 0x65, 0xe3, 0xd8, - 0x38, 0x61, 0x42, 0x91, 0x7b, 0x07, 0xaf, 0x9a, 0x22, 0xf2, 0x22, 0x42, 0x0b, 0xa8, - 0xd2, 0xb0, 0x1a, 0xd8, - ], - [ - 0x22, 0xf9, 0xdf, 0xf5, 0xb7, 0x18, 0x43, 0x44, 0xe5, 0xba, 0xc3, 0x94, 0xf9, 0xfb, - 0x14, 0x1a, 0xf5, 0xb1, 0xda, 0x8e, 0x71, 0xae, 0x36, 0xe7, 0x61, 0x2f, 0xb1, 0xaa, - 0x68, 0xf2, 0xf1, 0xbc, - ], - [ - 0xd4, 0x56, 0xce, 0x7d, 0xbe, 0x62, 0xe2, 0xf5, 0x94, 0x9d, 0xc2, 0xcc, 0xe2, 0xb6, - 0xa9, 0x99, 0x00, 0xce, 0x9e, 0xd0, 0x8e, 0xe4, 0x42, 0x37, 0x21, 0x9a, 0xf2, 0x27, - 0x55, 0xfa, 0x71, 0xd5, - ], - ]; - - env.deployer().upload_contract_wasm(old_events::WASM); - env.deployer().upload_contract_wasm(old_profile::WASM); - - let events_v1 = old_events::Client::new(&env, &events_id); - let profile_v1 = old_profile::Client::new(&env, &profile_id); - assert_eq!(events_v1.version(), String::from_str(&env, "1.1.0")); - assert_eq!(profile_v1.version(), String::from_str(&env, "1.1.0")); - let expected_event_id_base = events_v1.id_base(); - events_v1.pause(); - profile_v1.pause(); - assert!(events_v1.is_paused()); - assert!(profile_v1.is_paused()); - - for strkey in profile_addresses { - let address = Address::from_str(&env, strkey); - let profile = profile_v1.get_profile(&address).unwrap(); - assert_eq!(profile.reputation, 0); + events.claim_milestone(&id, &a, &0, &10, &BytesN::random(env)); + events.forfeit_milestone(&id, &a, &1, &BytesN::random(env)); + events.claim_milestone(&id, &a, &2, &10, &BytesN::random(env)); + for milestone in 0..3 { + events.claim_milestone(&id, &b, &milestone, &10, &BytesN::random(env)); } - assert_eq!( - env.as_contract(&profile_id, || env - .storage() - .instance() - .get::<_, u32>(&LegacyProfileDataKey::DefaultBootstrapCredits)), - Some(10) - ); - assert_eq!( - env.as_contract(&profile_id, || env - .storage() - .instance() - .get::<_, u32>(&LegacyProfileDataKey::DeploymentSeq)), - Some(63_222_664) - ); - for bytes in legacy_op_ids { - let op_id = BytesN::from_array(&env, &bytes); - assert_eq!( - env.as_contract(&profile_id, || env - .storage() - .temporary() - .get::<_, bool>(&LegacyProfileDataKey::OpSeen(op_id))), - Some(true) - ); - } - - let new_profile_hash = env.deployer().upload_contract_wasm(new_profile::WASM); - profile_v1.propose_upgrade(&new_profile_hash, &String::from_str(&env, "1.2.0")); - let new_events_hash = env.deployer().upload_contract_wasm(new_events::WASM); - events_v1.propose_upgrade(&new_events_hash, &String::from_str(&env, "1.5.0")); - env.ledger().with_mut(|ledger| { - ledger.sequence_number += TIMELOCK; - }); - profile_v1.apply_upgrade(); - let profile_v2 = new_profile::Client::new(&env, &profile_id); - assert!(profile_v2.is_paused()); - - events_v1.apply_upgrade(); - - let events_v2 = new_events::Client::new(&env, &events_id); - assert!(events_v2.is_paused()); - profile_v2.migrate(); - events_v2.migrate(); - profile_v2.unpause(); - events_v2.unpause(); - - assert_eq!(events_v2.version(), String::from_str(&env, "1.5.0")); - assert_eq!(profile_v2.version(), String::from_str(&env, "1.2.0")); - assert_eq!( - events_v2.get_migrated_to_version(), - Some(String::from_str(&env, "1.5.0")) - ); - assert_eq!( - profile_v2.get_migrated_to_version(), - Some(String::from_str(&env, "1.2.0")) - ); - assert!(!events_v2.is_paused()); - assert!(!profile_v2.is_paused()); - assert_eq!(events_v2.get_admin(), admin); - assert_eq!(events_v2.get_fee_account(), fee_account); - assert_eq!(events_v2.get_fee_bps(), 250); - assert_eq!(events_v2.get_profile_contract(), profile_id); - assert_eq!(events_v2.id_base(), expected_event_id_base); - assert!(events_v2.is_supported_token(&token_0)); - assert!(events_v2.is_supported_token(&token_1)); - assert_eq!(events_v2.supported_token_count(), 2); - assert_eq!(events_v2.supported_token_at(&0), Some(token_0.clone())); - assert_eq!(events_v2.supported_token_at(&1), Some(token_1.clone())); - assert_eq!( - env.as_contract(&events_id, || env.storage().instance().get::<_, u32>( - &LegacyEventsDataKey::SupportedTokenSlot(token_0.clone()) - )), - Some(1) - ); - assert_eq!( - env.as_contract(&events_id, || env.storage().instance().get::<_, u32>( - &LegacyEventsDataKey::SupportedTokenSlot(token_1.clone()) - )), - Some(2) - ); - events_v2.deregister_supported_token(&token_1); - assert_eq!(events_v2.supported_token_count(), 1); - assert!(!events_v2.is_supported_token(&token_1)); - events_v2.register_supported_token(&token_1); - assert_eq!(events_v2.supported_token_count(), 2); - assert_eq!(events_v2.supported_token_at(&1), Some(token_1.clone())); - assert_eq!(profile_v2.get_admin(), admin); - assert_eq!(profile_v2.get_events_contract(), Some(events_id)); - let legacy_replay_user = Address::from_str(&env, profile_addresses[0]); - let legacy_replay_op = BytesN::from_array(&env, &legacy_op_ids[0]); - let legacy_replay_error = profile_v2 - .try_bootstrap_self(&legacy_replay_user, &legacy_replay_op) - .expect_err("captured SDK-23 replay row must remain effective") - .unwrap(); - assert_eq!(legacy_replay_error, new_profile::Error::OpAlreadySeen); - - for strkey in profile_addresses { - let address = Address::from_str(&env, strkey); - let profile = profile_v2.get_profile(&address).unwrap(); - assert_eq!(profile.reputation, 0); - } - assert_eq!( - env.as_contract(&profile_id, || env - .storage() - .instance() - .get::<_, u32>(&LegacyProfileDataKey::DefaultBootstrapCredits)), - Some(10) - ); - assert_eq!( - env.as_contract(&profile_id, || env - .storage() - .instance() - .get::<_, u32>(&LegacyProfileDataKey::DeploymentSeq)), - Some(63_222_664) - ); - for bytes in legacy_op_ids { - let op_id = BytesN::from_array(&env, &bytes); - assert_eq!( - env.as_contract(&profile_id, || env - .storage() - .temporary() - .get::<_, bool>(&LegacyProfileDataKey::OpSeen(op_id))), - Some(true) - ); + assert_eq!(token.balance(&a), 400_0000000); + assert_eq!(token.balance(&b), 300_0000000); + assert_eq!(profile.get_earnings(&a, &token_id), 400_0000000); + assert_eq!(profile.get_profile(&b).unwrap().reputation, 30); + + events.start_cancel(&id, &BytesN::random(env)); + if events.get_event(&id).status == EventStatus::Cancelling { + while events.process_cancel_batch(&id, &15, &BytesN::random(env)) > 0 {} + events.finalize_cancel(&id, &BytesN::random(env)); } + assert_eq!(events.get_event(&id).status, EventStatus::Cancelled); + assert_eq!(token.balance(&owner), 200_0000000); + assert_eq!(token.balance(&events.address), 0); } diff --git a/contracts/events/Cargo.toml b/contracts/events/Cargo.toml index 28a97f5..19f9b90 100644 --- a/contracts/events/Cargo.toml +++ b/contracts/events/Cargo.toml @@ -10,7 +10,7 @@ doctest = false [features] # Testnet-only: zero the upgrade timelock so upgrades apply immediately for fast -# iteration. NEVER enable on mainnet — the default (feature off) keeps the full +# iteration. NEVER enable on mainnet: the default (feature off) keeps the full # audit-mandated ~1-day timelock, so forgetting the flag fails safe (secure). testnet = [] diff --git a/contracts/events/src/admin.rs b/contracts/events/src/admin.rs index 419de4f..a0b8fc8 100644 --- a/contracts/events/src/admin.rs +++ b/contracts/events/src/admin.rs @@ -1,11 +1,15 @@ -use soroban_sdk::{contracttype, panic_with_error, Address, BytesN, Env, Map, String, Symbol, Val}; +use soroban_sdk::{ + contracttype, panic_with_error, Address, BytesN, ContractExecutable, Env, Map, String, Symbol, + Val, +}; use crate::errors::Error; use crate::events as evt; use crate::idempotency; use crate::storage; use crate::types::{ - DataKey, EventRecord, EventStatus, PendingAdmin, PendingUpgrade, Pillar, ReleaseKind, Winner, + DataKey, EventRecord, EventStatus, PendingAdmin, PendingUpgrade, PendingValidator, Pillar, + ReleaseKind, }; /// The pre-1.7.0 `EventRecord`, kept only so `migrate` can decode rows written @@ -34,14 +38,12 @@ 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. +// About a day on mainnet: without a window a compromised admin quorum can +// propose and apply a wasm swap in one session, before anyone watching can +// react or cancel_pending_upgrade can fire. Testnet builds skip it so +// upgrades can iterate. #[cfg(not(feature = "testnet"))] -const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; +const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; #[cfg(feature = "testnet")] const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; @@ -155,6 +157,86 @@ pub fn set_fee_account(env: &Env, new_account: Address) -> Result<(), Error> { Ok(()) } +// ============================================================ +// CROWDFUNDING RELEASE VALIDATOR (two-step, like the admin) +// ============================================================ +/// The proposed key co-signs nothing until it accepts, which proves someone +/// holds it; until then the current validator, or the admin, still co-signs. +pub fn propose_release_validator(env: &Env, target: Address) -> Result<(), Error> { + require_admin(env)?; + if storage::get_pending_release_validator(env).is_some() { + evt::ValidatorProposalCancelled {}.publish(env); + } + let expires_at = env + .ledger() + .sequence() + .saturating_add(PENDING_ADMIN_TTL_LEDGERS); + storage::set_pending_release_validator( + env, + &PendingValidator { + target: target.clone(), + expires_at_ledger: expires_at, + }, + ); + storage::touch_instance(env); + evt::ReleaseValidatorProposed { + target, + expires_at_ledger: expires_at, + } + .publish(env); + Ok(()) +} + +pub fn accept_release_validator(env: &Env) -> Result<(), Error> { + let pending = + storage::get_pending_release_validator(env).ok_or(Error::PendingRotationMismatch)?; + if env.ledger().sequence() > pending.expires_at_ledger { + return Err(Error::PendingRotationExpired); + } + pending.target.require_auth(); + + storage::set_release_validator(env, &Some(pending.target.clone())); + storage::clear_pending_release_validator(env); + storage::touch_instance(env); + evt::ReleaseValidatorUpdated { + validator: Some(pending.target), + } + .publish(env); + Ok(()) +} + +pub fn cancel_pending_release_validator(env: &Env) -> Result<(), Error> { + require_admin(env)?; + if storage::get_pending_release_validator(env).is_none() { + return Err(Error::PendingRotationMismatch); + } + storage::clear_pending_release_validator(env); + storage::touch_instance(env); + evt::ValidatorProposalCancelled {}.publish(env); + Ok(()) +} + +/// Takes effect at once, unlike appointing one: removing the validator is how +/// a compromised key is cut off. Co-signing returns to the admin. +pub fn clear_release_validator(env: &Env) -> Result<(), Error> { + require_admin(env)?; + storage::set_release_validator(env, &None); + if storage::get_pending_release_validator(env).is_some() { + storage::clear_pending_release_validator(env); + evt::ValidatorProposalCancelled {}.publish(env); + } + storage::touch_instance(env); + evt::ReleaseValidatorUpdated { validator: None }.publish(env); + Ok(()) +} + +pub fn release_cosigner(env: &Env) -> Result { + match storage::get_release_validator(env) { + Some(validator) => Ok(validator), + None => storage::get_admin(env), + } +} + // ============================================================ // PROFILE CONTRACT BINDING // ============================================================ @@ -174,6 +256,9 @@ pub fn set_profile_contract(env: &Env, new_addr: Address) -> Result<(), Error> { // ============================================================ pub fn pause(env: &Env) -> Result<(), Error> { require_admin(env)?; + if storage::get_paused_at(env).is_none() { + storage::set_paused_at(env, Some(env.ledger().timestamp())); + } storage::set_paused(env, true); storage::touch_instance(env); evt::Paused {}.publish(env); @@ -182,6 +267,12 @@ pub fn pause(env: &Env) -> Result<(), Error> { pub fn unpause(env: &Env) -> Result<(), Error> { require_admin(env)?; + if let Some(at) = storage::get_paused_at(env) { + let paused_for = env.ledger().timestamp().saturating_sub(at); + let total = storage::get_paused_seconds(env).saturating_add(paused_for); + storage::set_paused_seconds(env, total); + storage::set_paused_at(env, None); + } storage::set_paused(env, false); storage::touch_instance(env); evt::Unpaused {}.publish(env); @@ -189,7 +280,7 @@ pub fn unpause(env: &Env) -> Result<(), Error> { } // ============================================================ -// UPGRADE (timelocked; H6) +// UPGRADE (timelocked) // ============================================================ pub fn propose_upgrade( env: &Env, @@ -234,7 +325,7 @@ pub fn apply_upgrade(env: &Env) -> Result<(), Error> { } storage::touch_instance(env); env.deployer() - .update_current_contract_wasm(pending.wasm_hash.clone()); + .update_current_contract(ContractExecutable::Wasm(pending.wasm_hash.clone())); storage::set_version(env, &pending.new_version); storage::clear_pending_upgrade(env); evt::UpgradeApplied { @@ -264,7 +355,7 @@ pub fn cancel_pending_upgrade(env: &Env) -> Result<(), Error> { } // ============================================================ -// MIGRATE (post-upgrade one-shot; H6) +// MIGRATE (post-upgrade one-shot) // ============================================================ pub fn migrate(env: &Env) -> Result<(), Error> { require_admin(env)?; @@ -285,11 +376,8 @@ pub fn migrate(env: &Env) -> Result<(), Error> { // 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); + return Err(Error::MigrationIncomplete); } storage::set_migrated_to_version(env, ¤t); @@ -316,15 +404,14 @@ fn migration_remaining(env: &Env) -> u64 { /// 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 +/// 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. + // Each event costs a record read and, when converted, a record write; + // eight stays far inside the per-transaction entry limits. const MAX_PER_CALL: u32 = 8; let budget = if max_events == 0 || max_events > MAX_PER_CALL { MAX_PER_CALL @@ -402,90 +489,6 @@ fn migrate_one_event(env: &Env, id: u64) { 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, - }, - ); - } - } } // ============================================================ @@ -532,6 +535,16 @@ pub fn require_admin(env: &Env) -> Result<(), Error> { Ok(()) } +/// Seconds the contract has been open, the clock claim windows run on. +pub fn open_time(env: &Env) -> u64 { + let now = env.ledger().timestamp(); + let mut paused = storage::get_paused_seconds(env); + if let Some(at) = storage::get_paused_at(env) { + paused = paused.saturating_add(now.saturating_sub(at)); + } + now.saturating_sub(paused) +} + pub fn require_not_paused(env: &Env) -> Result<(), Error> { storage::touch_instance(env); if storage::is_paused(env) { diff --git a/contracts/events/src/bounty.rs b/contracts/events/src/bounty.rs deleted file mode 100644 index 1029741..0000000 --- a/contracts/events/src/bounty.rs +++ /dev/null @@ -1,11 +0,0 @@ -use soroban_sdk::{Address, Env}; - -use crate::errors::Error; -use crate::types::{EventRecord, ReleaseKind}; - -pub fn validate_create(_env: &Env, record: &EventRecord, _owner: &Address) -> Result<(), Error> { - if !matches!(record.release_kind, ReleaseKind::Single) { - return Err(Error::InvalidReleaseKind); - } - Ok(()) -} diff --git a/contracts/events/src/crowdfunding.rs b/contracts/events/src/crowdfunding.rs deleted file mode 100644 index 29a6cd3..0000000 --- a/contracts/events/src/crowdfunding.rs +++ /dev/null @@ -1,17 +0,0 @@ -#![allow(dead_code)] - -use soroban_sdk::{Address, Env}; - -use crate::errors::Error; -use crate::types::{EventRecord, ReleaseKind}; - -pub fn validate_create(_env: &Env, record: &EventRecord, _owner: &Address) -> Result<(), Error> { - match record.release_kind { - ReleaseKind::Multi(n) if n > 0 => {} - _ => return Err(Error::InvalidReleaseKind), - } - - // 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/errors.rs b/contracts/events/src/errors.rs index 918f2a0..593b917 100644 --- a/contracts/events/src/errors.rs +++ b/contracts/events/src/errors.rs @@ -1,18 +1,15 @@ use soroban_sdk::contracterror; +// Retired codes are never reused, so a code in an old log or client keeps its +// meaning: 2-4, 10, 11, 21, 36-38, 40-43, 59, 80. #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] #[repr(u32)] pub enum Error { AlreadyInitialized = 1, - AdminCannotBeZero = 2, - FeeAccountCannotBeZero = 3, - ProfileContractCannotBeZero = 4, InvalidFeeBps = 5, NotInitialized = 6, - Unauthorized = 10, - NotAdmin = 11, // Shared by both two-step rotations (admin and event manager): no pending // proposal / target mismatch (12) and pending proposal expired (13). The // manager flow reuses these because the two flows are structurally @@ -21,7 +18,6 @@ pub enum Error { PendingRotationExpired = 13, TokenNotSupported = 20, - FeeAccountMissingTrustline = 21, EventNotFound = 30, EventNotActive = 31, @@ -29,14 +25,8 @@ pub enum Error { InvalidReleaseKind = 33, InvalidDistribution = 34, InvalidBudget = 35, - // 36-38 retired: deadline enforcement removed (submission windows are an - // off-chain/backend concern; the contract no longer gates on deadline). TitleTooLong = 39, - // 40-43 retired in 2.0.0 with the on-chain participation records - // (ApplicantAlreadyApplied, ApplicantNotApplied, SubmissionNotFound, - // SubmissionAlreadyExists). Left as a gap rather than reused so an old - // client decoding a stored error code never reads the wrong case. NoSubmissions = 50, InvalidWinnerPosition = 51, DuplicateWinnerPosition = 52, @@ -49,12 +39,9 @@ pub enum Error { BelowMinimumContribution = 57, InvalidContributionAmount = 58, - // 59 retired in 2.0.0 with the applicant index (TooManyApplicants). OpAlreadySeen = 60, - // Signals a u32 counter overflow on the contributor index. Per-event - // participant caps were removed (contributor sets are unbounded; entries - // are per-contributor and self-funded). + // A u32 overflow on the contributor index; contributor sets have no cap. TooManyContributors = 61, CancellationNotStarted = 62, @@ -70,9 +57,13 @@ pub enum Error { Paused = 70, EventIdOverflow = 71, - ProfileCallFailed = 80, - - // contracterror caps at 50 cases (48 used). Discriminants are not dense — - // 91 is a numeric label, not the case count. + // contracterror caps at 50 cases. Discriminants are not dense: 91 is a + // numeric label, not the case count. PrizeAlreadyClaimed = 91, + DuplicateRecipient = 92, + ReputationBumpTooLarge = 93, + InvalidBatchSize = 94, + NoRefundOwed = 95, + AwardsOutstanding = 96, + MigrationIncomplete = 97, } diff --git a/contracts/events/src/escrow.rs b/contracts/events/src/escrow.rs index 04e53b6..434bdc8 100644 --- a/contracts/events/src/escrow.rs +++ b/contracts/events/src/escrow.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use soroban_sdk::{token, Address, Env}; use crate::storage; @@ -12,10 +10,6 @@ pub fn compute_fee_at(amount: i128, bps: u32) -> i128 { amount.saturating_mul(bps as i128) / 10_000 } -pub fn compute_fee(env: &Env, amount: i128) -> i128 { - compute_fee_at(amount, storage::get_fee_bps(env)) -} - pub fn deposit_with_fee_at( env: &Env, token_addr: &Address, @@ -36,16 +30,23 @@ pub fn deposit_with_fee_at( amount } -pub fn deposit_with_fee(env: &Env, token_addr: &Address, from: &Address, amount: i128) -> i128 { - deposit_with_fee_at(env, token_addr, from, amount, storage::get_fee_bps(env)) -} - pub fn release(env: &Env, token_addr: &Address, recipient: &Address, amount: i128) { let contract = env.current_contract_address(); let client = token::Client::new(env, token_addr); client.transfer(&contract, recipient, &amount); } +/// Like `release`, but reports a transfer the recipient's account refuses +/// instead of reverting the whole call. +pub fn try_release(env: &Env, token_addr: &Address, recipient: &Address, amount: i128) -> bool { + let contract = env.current_contract_address(); + let client = token::Client::new(env, token_addr); + matches!( + client.try_transfer(&contract, recipient, &amount), + Ok(Ok(())) + ) +} + pub fn deposit_no_fee(env: &Env, token_addr: &Address, from: &Address, amount: i128) -> i128 { let contract = env.current_contract_address(); let client = token::Client::new(env, token_addr); diff --git a/contracts/events/src/event_ops.rs b/contracts/events/src/event_ops.rs index 040114b..cf4fb6a 100644 --- a/contracts/events/src/event_ops.rs +++ b/contracts/events/src/event_ops.rs @@ -1,25 +1,28 @@ -use soroban_sdk::{Address, BytesN, Env, Symbol, Vec}; +use soroban_sdk::{Address, BytesN, Env, Map, Symbol, Vec}; use crate::admin::{self, MAX_FEE_BPS}; -use crate::bounty; -use crate::crowdfunding; use crate::errors::Error; use crate::escrow; use crate::events as evt; -use crate::grant; -use crate::hackathon; use crate::idempotency::{self, tag}; use crate::profile_client; use crate::storage; use crate::token_whitelist; use crate::types::{ - CancellationBranch, CancellationState, CreateEventParams, EventRecord, EventStatus, + CancellationBranch, CancellationState, CreateEventParams, EventRecord, EventStatus, GrantAward, PendingManager, Pillar, PrizeAward, ReleaseKind, Winner, WinnerSpec, }; const MAX_TITLE_LEN: u32 = 120; -const MAX_WINNERS_PER_SELECT: u32 = 50; +// Sized to fit one transaction under mainnet's 100-entry footprint and +// 50-write limits, which the test host enforces. A single-release award writes +// a winner row and a prize award; a grant award writes a winner row and shares +// one roster entry. Single-release events may select in several batches; +// grants select once, so the grant cap is also the most recipients a grant +// can have. +pub const MAX_AWARDS_PER_SELECT: u32 = 20; +pub const MAX_GRANT_AWARDS: u32 = 40; const PENDING_MANAGER_TTL_LEDGERS: u32 = 17_280; @@ -32,13 +35,20 @@ pub const PRIZE_CLAIM_WINDOW_SECS: u64 = 90 * 24 * 60 * 60; // path iterates the full set in one transaction (refunds are cranked in // batches, winner selection takes an explicit bounded list). Full-list reads // page through VIEW_PAGE_LIMIT entries per call so simulation stays inside -// per-tx read-entry limits. -pub const VIEW_PAGE_LIMIT: u32 = 100; +// the 100-entry footprint, which a page also spends on the instance, the +// event and the count. +pub const VIEW_PAGE_LIMIT: u32 = 90; -pub const MAX_REFUNDS_PER_BATCH: u32 = 25; +// Each refund touches the contributor's amount, slot and token balance; 15 keeps +// a batch inside the 100-entry footprint with room to spare. +pub const MAX_REFUNDS_PER_BATCH: u32 = 15; const MIN_CONTRIBUTION_STROOPS: i128 = 100_000_000_i128; +// Reputation is minted by whoever owns an event, and anyone can own one, so +// a single award or release must not be able to move it far. +pub const MAX_REPUTATION_BUMP: u32 = 100; + // ============================================================ // CREATE EVENT // ============================================================ @@ -122,11 +132,14 @@ pub fn create_event(env: &Env, params: CreateEventParams, op_id: BytesN<32>) -> prize_floors: params.prize_floors.clone(), fee_bps_override: params.fee_bps_override, }; - match params.pillar { - Pillar::Hackathon => hackathon::validate_create(env, &provisional, ¶ms.owner)?, - Pillar::Bounty => bounty::validate_create(env, &provisional, ¶ms.owner)?, - Pillar::Grant => grant::validate_create(env, &provisional, ¶ms.owner)?, - Pillar::Crowdfunding => crowdfunding::validate_create(env, &provisional, ¶ms.owner)?, + let release_kind_fits = match params.pillar { + Pillar::Hackathon | Pillar::Bounty => matches!(params.release_kind, ReleaseKind::Single), + Pillar::Grant | Pillar::Crowdfunding => { + matches!(params.release_kind, ReleaseKind::Multi(n) if n > 0) + } + }; + if !release_kind_fits { + return Err(Error::InvalidReleaseKind); } if !is_crowdfunding { @@ -199,6 +212,10 @@ pub fn propose_manager(env: &Env, event_id: u64, new_manager: Address) -> Result let event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?; resolve_manager(env, event_id, &event.owner).require_auth(); + if storage::get_pending_manager(env, event_id).is_some() { + evt::PendingManagerCancelled { event_id }.publish(env); + } + let expires_at = env .ledger() .sequence() @@ -257,6 +274,33 @@ pub fn cancel_pending_manager(env: &Env, event_id: u64) -> Result<(), Error> { Ok(()) } +/// The owner takes management back from a delegated manager, which is how an +/// event recovers from a manager key that is lost. Only before any award: from +/// selection on, the awards are the manager's decision and stay theirs. +pub fn reclaim_management(env: &Env, event_id: u64) -> Result<(), Error> { + admin::require_not_paused(env)?; + let event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?; + if !matches!(event.status, EventStatus::Active) { + return Err(Error::EventNotActive); + } + event.owner.require_auth(); + if !matches!(event.pillar, Pillar::Crowdfunding) && storage::winner_count(env, event_id) > 0 { + return Err(Error::WinnersAlreadySelected); + } + + storage::clear_event_manager(env, event_id); + if storage::get_pending_manager(env, event_id).is_some() { + storage::clear_pending_manager(env, event_id); + evt::PendingManagerCancelled { event_id }.publish(env); + } + evt::ManagementReclaimed { + event_id, + owner: event.owner, + } + .publish(env); + Ok(()) +} + pub fn get_manager(env: &Env, event_id: u64) -> Result { let event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?; Ok(resolve_manager(env, event_id, &event.owner)) @@ -365,10 +409,15 @@ pub fn start_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<(), E && storage::unclaimed_prize_count(env, event_id) > 0 { let expiry = storage::get_prize_claim_expiry(env, event_id).unwrap_or(0); - if env.ledger().timestamp() <= expiry { + if admin::open_time(env) <= expiry { return Err(Error::WinnersAlreadySelected); } } + // A grant award ends milestone by milestone, released or forfeited, each + // on the record. Cancelling over an owed award would end it silently. + if matches!(event.pillar, Pillar::Grant) && storage::owed_total(env, event_id) > 0 { + return Err(Error::AwardsOutstanding); + } let manager = resolve_manager(env, event_id, &event.owner); manager.require_auth(); @@ -377,7 +426,7 @@ pub fn start_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<(), E // 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. + // path back out. The guards above are what protect awards still claimable. storage::set_owed_total(env, event_id, 0); let remaining = event.remaining_escrow; @@ -391,6 +440,13 @@ pub fn start_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<(), E } else { CancellationBranch::ProRataPartners }; + evt::CancellationStarted { + event_id, + branch, + remaining, + non_owner_total, + } + .publish(env); if matches!(branch, CancellationBranch::OwnerOnly) { if remaining > 0 { @@ -433,8 +489,13 @@ pub fn process_cancel_batch( op_id: BytesN<32>, ) -> Result { admin::require_not_paused(env)?; + // An empty batch would still spend the op_id, letting a stranger burn the + // id the backend is about to use. + if max_refunds == 0 { + return Err(Error::InvalidBatchSize); + } // Permissionless crank: no caller to authorize, so namespace under the - // contract's own address — isolated from every user/privileged domain. + // contract's own address, isolated from every user/privileged domain. let domain = env.current_contract_address(); idempotency::require_unseen(env, &domain, &op_id)?; @@ -475,16 +536,32 @@ pub fn process_cancel_batch( CancellationBranch::OwnerOnly => 0, }; - if payout > 0 { - escrow::release(env, &event.token, &c, payout); + storage::set_contributor_amount(env, event_id, &c, 0); + if payout <= 0 { + // A pro-rata share can floor to nothing; say so rather than skip. evt::ContributorRefunded { + event_id, + contributor: c.clone(), + amount: 0, + } + .publish(env); + } else if escrow::try_release(env, &event.token, &c, payout) { + evt::ContributorRefunded { + event_id, + contributor: c.clone(), + amount: payout, + } + .publish(env); + } else { + let held = storage::unclaimed_refund(env, event_id, &c).saturating_add(payout); + storage::set_unclaimed_refund(env, event_id, &c, held); + evt::RefundDeferred { event_id, contributor: c.clone(), amount: payout, } .publish(env); } - storage::set_contributor_amount(env, event_id, &c, 0); } storage::set_cancellation_state(env, event_id, &state); @@ -536,6 +613,39 @@ pub fn finalize_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<() Ok(()) } +pub fn claim_refund( + env: &Env, + event_id: u64, + contributor: Address, + op_id: BytesN<32>, +) -> Result<(), Error> { + admin::require_not_paused(env)?; + let event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?; + contributor.require_auth(); + idempotency::require_unseen(env, &contributor, &op_id)?; + + let amount = storage::unclaimed_refund(env, event_id, &contributor); + if amount <= 0 { + return Err(Error::NoRefundOwed); + } + storage::set_unclaimed_refund(env, event_id, &contributor, 0); + idempotency::mark_seen(env, &contributor, &op_id); + + escrow::release(env, &event.token, &contributor, amount); + evt::ContributorRefunded { + event_id, + contributor, + amount, + } + .publish(env); + Ok(()) +} + +pub fn get_unclaimed_refund(env: &Env, event_id: u64, contributor: Address) -> Result { + storage::get_event(env, event_id).ok_or(Error::EventNotFound)?; + Ok(storage::unclaimed_refund(env, event_id, &contributor)) +} + // ============================================================ // SELECT WINNERS // ============================================================ @@ -560,27 +670,28 @@ pub fn select_winners( idempotency::require_unseen(env, &manager, &op_id)?; let existing_count = storage::winner_count(env, event_id); - match event.release_kind { - // 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) { - if w.milestone.is_none() { - return Err(Error::WinnersAlreadySelected); - } - } - } - } + // A grant selects once, and only a selection gives it winner records. + // Single events award in batches; each position may be awarded once, + // enforced per position below. + if matches!(event.release_kind, ReleaseKind::Multi(_)) && existing_count > 0 { + return Err(Error::WinnersAlreadySelected); } if winners.is_empty() { return Err(Error::NoSubmissions); } - if winners.len() > MAX_WINNERS_PER_SELECT { + let cap = match event.release_kind { + ReleaseKind::Single => MAX_AWARDS_PER_SELECT, + ReleaseKind::Multi(_) => MAX_GRANT_AWARDS, + }; + if winners.len() > cap { return Err(Error::InvalidWinnerPosition); } + for spec in winners.iter() { + if spec.reputation_bump > MAX_REPUTATION_BUMP { + return Err(Error::ReputationBumpTooLarge); + } + } let mut seen_positions: Vec = Vec::new(env); for spec in winners.iter() { @@ -597,7 +708,7 @@ pub fn select_winners( seen_positions.push_back(spec.position); } - let now = env.ledger().timestamp(); + let now = admin::open_time(env); match event.release_kind { ReleaseKind::Single => { @@ -635,7 +746,9 @@ pub fn select_winners( for (idx, spec) in winners.iter().enumerate() { let amount = spec.amount; - let anchor_idx = existing_count + (idx as u32); + let anchor_idx = existing_count + .checked_add(idx as u32) + .ok_or(Error::InvalidWinnerPosition)?; storage::append_winner( env, event_id, @@ -673,16 +786,31 @@ pub fn select_winners( storage::set_prize_claim_expiry(env, event_id, expiry); } } - ReleaseKind::Multi(_) => { + ReleaseKind::Multi(milestones) => { // 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; + let mut roster: Map = Map::new(env); for spec in winners.iter() { - if spec.amount <= 0 { + // Every milestone must pay something, or the award can never + // be released in full. + if spec.amount < milestones as i128 { return Err(Error::InvalidDistribution); } + // Releases are tracked per recipient, so a second award to the + // same address could never be paid. + if roster.contains_key(spec.recipient.clone()) { + return Err(Error::DuplicateRecipient); + } + roster.set( + spec.recipient.clone(), + GrantAward { + position: spec.position, + amount: spec.amount, + }, + ); if let Some(floor) = event.prize_floors.get(spec.position) { if spec.amount < floor { return Err(Error::InvalidDistribution); @@ -699,6 +827,7 @@ pub fn select_winners( return Err(Error::InsufficientEscrow); } storage::set_owed_total(env, event_id, committed); + storage::set_grant_roster(env, event_id, &roster); for spec in winners.iter() { storage::append_winner( @@ -719,6 +848,15 @@ pub fn select_winners( let winners_count = winners.len(); storage::set_event(env, event_id, &event); + for spec in winners.iter() { + evt::WinnerAwarded { + event_id, + recipient: spec.recipient, + position: spec.position, + amount: spec.amount, + } + .publish(env); + } evt::WinnersSelected { event_id, count: winners_count, @@ -730,7 +868,7 @@ pub fn select_winners( } // ============================================================ -// CLAIM PRIZE (pull model for Single-release events; #61) +// CLAIM PRIZE (pull model for Single-release events) // ============================================================ pub fn claim_prize( env: &Env, @@ -820,10 +958,10 @@ pub fn claim_prize( let profile = profile_client::client(env); let reason_win = Symbol::new(env, "win"); - let bootstrap_op = idempotency::derive_child(env, &op_id, tag::BOOTSTRAP); + let bootstrap_op = idempotency::derive_child(env, &award.recipient, &op_id, tag::BOOTSTRAP); let _ = profile.try_bootstrap(&award.recipient, &bootstrap_op); - let rep_op = idempotency::derive_child(env, &op_id, tag::BUMP_REP); + let rep_op = idempotency::derive_child(env, &award.recipient, &op_id, tag::BUMP_REP); let _ = profile.try_bump_reputation( &award.recipient, &award.reputation_bump, @@ -831,7 +969,8 @@ pub fn claim_prize( &rep_op, ); - let earnings_op = idempotency::derive_child(env, &op_id, tag::REGISTER_EARNINGS); + let earnings_op = + idempotency::derive_child(env, &award.recipient, &op_id, tag::REGISTER_EARNINGS); let _ = profile.try_register_earnings(&award.recipient, &event.token, &amount, &earnings_op); Ok(()) @@ -910,6 +1049,3 @@ pub fn get_contributor_amount( storage::get_event(env, event_id).ok_or(Error::EventNotFound)?; Ok(storage::get_contributor_amount(env, event_id, &contributor)) } - -#[allow(dead_code)] -const _MARK_USED: (Option,) = (None,); diff --git a/contracts/events/src/events.rs b/contracts/events/src/events.rs index 1253ee1..7b70033 100644 --- a/contracts/events/src/events.rs +++ b/contracts/events/src/events.rs @@ -1,8 +1,6 @@ -#![allow(dead_code)] - use soroban_sdk::{contractevent, Address, BytesN, String}; -use crate::types::Pillar; +use crate::types::{CancellationBranch, Pillar}; #[contractevent] pub struct EventCreated { @@ -53,6 +51,47 @@ pub struct ContributorRefunded { pub amount: i128, } +#[contractevent] +pub struct WinnerAwarded { + pub event_id: u64, + pub recipient: Address, + pub position: u32, + pub amount: i128, +} + +#[contractevent] +pub struct CancellationStarted { + pub event_id: u64, + pub branch: CancellationBranch, + pub remaining: i128, + pub non_owner_total: i128, +} + +/// The fee withheld from a crowdfunding release. `MilestoneClaimed.amount` +/// stays gross; the creator received `amount - fee`. +#[contractevent] +pub struct MilestoneFeeCharged { + pub event_id: u64, + pub recipient: Address, + pub milestone: u32, + pub fee: i128, +} + +#[contractevent] +pub struct MilestoneForfeited { + pub event_id: u64, + pub recipient: Address, + pub milestone: u32, + pub amount: i128, +} + +#[contractevent] +pub struct RefundDeferred { + pub event_id: u64, + pub contributor: Address, + pub amount: i128, +} + #[contractevent] pub struct OwnerResidualRefunded { pub event_id: u64, @@ -66,7 +105,9 @@ pub struct WinnersSelected { pub count: u32, } -#[contractevent] +// Published with every field, as before SDK 28: indexers read `milestone` as +// a fixed field and must see null, not a missing key. +#[contractevent(sparse = false)] pub struct WinnerPaid { pub event_id: u64, pub recipient: Address, @@ -108,6 +149,26 @@ pub struct FeeAccountUpdated { pub new_account: Address, } +#[contractevent] +pub struct ReleaseValidatorProposed { + pub target: Address, + pub expires_at_ledger: u32, +} + +#[contractevent] +pub struct ValidatorProposalCancelled {} + +#[contractevent(sparse = false)] +pub struct ReleaseValidatorUpdated { + pub validator: Option
, +} + +#[contractevent] +pub struct ManagementReclaimed { + pub event_id: u64, + pub owner: Address, +} + #[contractevent] pub struct FeeBpsUpdated { pub new_bps: u32, diff --git a/contracts/events/src/grant.rs b/contracts/events/src/grant.rs index d14b8e9..bde3db7 100644 --- a/contracts/events/src/grant.rs +++ b/contracts/events/src/grant.rs @@ -3,19 +3,73 @@ use soroban_sdk::{Address, BytesN, Env, Symbol}; use crate::admin; use crate::errors::Error; use crate::escrow; +use crate::event_ops::MAX_REPUTATION_BUMP; use crate::events as evt; use crate::idempotency::{self, tag}; use crate::profile_client; use crate::storage; -use crate::types::{EventRecord, EventStatus, Pillar, ReleaseKind, Winner}; +use crate::types::{EventStatus, GrantProgress, Pillar, ReleaseKind, Winner}; -pub fn validate_create(_env: &Env, record: &EventRecord, _owner: &Address) -> Result<(), Error> { - match record.release_kind { - ReleaseKind::Multi(n) if n > 0 => Ok(()), - _ => Err(Error::InvalidReleaseKind), +struct Standing { + position: u32, + award: i128, + settled: u32, + settled_amount: i128, +} + +fn standing(env: &Env, event_id: u64, recipient: &Address) -> Result { + let roster = storage::get_grant_roster(env, event_id).ok_or(Error::NoSubmissions)?; + let award = roster.get(recipient.clone()).ok_or(Error::NoSubmissions)?; + let progress = storage::get_grant_progress(env, event_id, recipient); + Ok(Standing { + position: award.position, + award: award.amount, + settled: progress.settled, + settled_amount: progress.settled_amount, + }) +} + +/// An even share of the award, or what is left of it when this is the last +/// milestone the recipient has open. +fn share(standing: &Standing, total_milestones: u32) -> Result { + if standing.settled.saturating_add(1) == total_milestones { + standing + .award + .checked_sub(standing.settled_amount) + .ok_or(Error::InvalidDistribution) + } else { + Ok(standing.award / (total_milestones as i128)) } } +fn record_settlement( + env: &Env, + event_id: u64, + recipient: &Address, + standing: &Standing, + amount: i128, +) { + storage::set_grant_progress( + env, + event_id, + recipient, + &GrantProgress { + settled: standing.settled.saturating_add(1), + settled_amount: standing.settled_amount.saturating_add(amount), + }, + ); +} + +fn release_owed(env: &Env, event_id: u64, amount: i128) -> Result<(), Error> { + 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); + Ok(()) +} + // ============================================================ // CLAIM MILESTONE // ============================================================ @@ -28,6 +82,9 @@ pub fn claim_milestone( op_id: BytesN<32>, ) -> Result<(), Error> { admin::require_not_paused(env)?; + if reputation_bump > MAX_REPUTATION_BUMP { + return Err(Error::ReputationBumpTooLarge); + } let mut event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?; if !matches!(event.status, EventStatus::Active) { @@ -46,43 +103,31 @@ pub fn claim_milestone( event.owner.require_auth(); idempotency::require_unseen(env, &event.owner, &op_id)?; - if matches!(event.pillar, Pillar::Crowdfunding) { - let admin = storage::get_admin(env)?; - admin.require_auth(); + let is_crowdfunding = matches!(event.pillar, Pillar::Crowdfunding); + if is_crowdfunding { + admin::release_cosigner(env)?.require_auth(); } if storage::is_milestone_claimed(env, event_id, &recipient, milestone) { return Err(Error::MilestoneAlreadyClaimed); } - 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 { - let w = match storage::winner_at(env, event_id, idx) { - Some(w) => w, - None => continue, - }; - if w.recipient != recipient { - continue; + // A campaign's only recipient is its owner, anchored at position 1 when the + // campaign was created. + let standing = if is_crowdfunding { + if recipient != event.owner { + return Err(Error::NoSubmissions); } - match w.milestone { - 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); - } - } - } - let position = winner_position.ok_or(Error::NoSubmissions)?; + None + } else { + Some(standing(env, event_id, &recipient)?) + }; - let is_crowdfunding = matches!(event.pillar, Pillar::Crowdfunding); - let amount: i128 = if is_crowdfunding { + let amount: i128 = if let Some(standing) = &standing { + // The award carries its own amount, set at selection. Milestones split + // that, and whichever settles last takes the rounding remainder. + share(standing, total_milestones)? + } else { let claimed_count = storage::get_crowdfunding_milestones_claimed(env, event_id); let remaining_milestones = total_milestones.saturating_sub(claimed_count); if remaining_milestones == 0 { @@ -96,17 +141,6 @@ pub fn claim_milestone( } else { event.remaining_escrow / (remaining_milestones as i128) } - } else { - // 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 { - total_share.saturating_sub(already_paid_to_recipient) - } else { - per_milestone_floored - } }; if amount <= 0 { return Err(Error::InvalidDistribution); @@ -115,65 +149,129 @@ pub fn claim_milestone( return Err(Error::InsufficientEscrow); } - if is_crowdfunding { - let bps = escrow::effective_fee_bps(env, event.fee_bps_override); - escrow::release_with_fee_at(env, &event.token, &recipient, amount, bps); - } else { - 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 { + // Crowdfunding never reserves, since it has no winner selection. + if let Some(standing) = &standing { + release_owed(env, event_id, amount)?; + record_settlement(env, event_id, &recipient, standing, amount); + } else { let claimed = storage::get_crowdfunding_milestones_claimed(env, event_id); storage::set_crowdfunding_milestones_claimed(env, event_id, claimed.saturating_add(1)); } - - let profile = profile_client::client(env); - let reason = Symbol::new(env, "milestone"); - - let bootstrap_op = idempotency::derive_child(env, &op_id, tag::BOOTSTRAP); - profile.bootstrap(&recipient, &bootstrap_op); - - let rep_op = idempotency::derive_child(env, &op_id, tag::BUMP_REP); - profile.bump_reputation(&recipient, &reputation_bump, &reason, &rep_op); - - let earnings_op = idempotency::derive_child(env, &op_id, tag::REGISTER_EARNINGS); - profile.register_earnings(&recipient, &event.token, &amount, &earnings_op); - storage::append_winner( env, event_id, &Winner { recipient: recipient.clone(), - position, + position: standing.as_ref().map_or(1, |s| s.position), amount, milestone: Some(milestone), paid_at: Some(env.ledger().timestamp()), }, ); - if event.remaining_escrow == 0 { event.status = EventStatus::Completed; } storage::set_event(env, event_id, &event); + idempotency::mark_seen(env, &event.owner, &op_id); + + // State written above; release last so a reentrant token can't double-claim. + let fee = if is_crowdfunding { + let bps = escrow::effective_fee_bps(env, event.fee_bps_override); + escrow::release_with_fee_at(env, &event.token, &recipient, amount, bps); + escrow::compute_fee_at(amount, bps) + } else { + escrow::release(env, &event.token, &recipient, amount); + 0 + }; evt::MilestoneClaimed { event_id, - recipient, + recipient: recipient.clone(), milestone, amount, } .publish(env); + if fee > 0 { + evt::MilestoneFeeCharged { + event_id, + recipient: recipient.clone(), + milestone, + fee, + } + .publish(env); + } + // Best-effort: the payout is final, so a profile failure must not revert it. + let profile = profile_client::client(env); + let reason = Symbol::new(env, "milestone"); + + let bootstrap_op = idempotency::derive_child(env, &event.owner, &op_id, tag::BOOTSTRAP); + let _ = profile.try_bootstrap(&recipient, &bootstrap_op); + + let rep_op = idempotency::derive_child(env, &event.owner, &op_id, tag::BUMP_REP); + let _ = profile.try_bump_reputation(&recipient, &reputation_bump, &reason, &rep_op); + + let earnings_op = idempotency::derive_child(env, &event.owner, &op_id, tag::REGISTER_EARNINGS); + let _ = profile.try_register_earnings(&recipient, &event.token, &amount, &earnings_op); + + Ok(()) +} + +// ============================================================ +// FORFEIT MILESTONE +// ============================================================ +/// Ends a milestone without paying it. Its share stays in escrow, no longer +/// owed to anyone, and goes back with the rest when the grant is closed. +pub fn forfeit_milestone( + env: &Env, + event_id: u64, + recipient: Address, + milestone: u32, + op_id: BytesN<32>, +) -> Result<(), Error> { + admin::require_not_paused(env)?; + + let event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?; + if !matches!(event.status, EventStatus::Active) { + return Err(Error::EventNotActive); + } + if !matches!(event.pillar, Pillar::Grant) { + return Err(Error::InvalidPillar); + } + let total_milestones = match event.release_kind { + ReleaseKind::Multi(n) if n > 0 => n, + _ => return Err(Error::InvalidReleaseKind), + }; + if milestone >= total_milestones { + return Err(Error::InvalidMilestone); + } + + event.owner.require_auth(); + idempotency::require_unseen(env, &event.owner, &op_id)?; + + if storage::is_milestone_claimed(env, event_id, &recipient, milestone) { + return Err(Error::MilestoneAlreadyClaimed); + } + + let standing = standing(env, event_id, &recipient)?; + let amount = share(&standing, total_milestones)?; + if amount <= 0 { + return Err(Error::InvalidDistribution); + } + + release_owed(env, event_id, amount)?; + storage::mark_milestone_claimed(env, event_id, &recipient, milestone); + record_settlement(env, event_id, &recipient, &standing, amount); idempotency::mark_seen(env, &event.owner, &op_id); + + evt::MilestoneForfeited { + event_id, + recipient, + milestone, + amount, + } + .publish(env); Ok(()) } diff --git a/contracts/events/src/hackathon.rs b/contracts/events/src/hackathon.rs deleted file mode 100644 index f212590..0000000 --- a/contracts/events/src/hackathon.rs +++ /dev/null @@ -1,13 +0,0 @@ -#![allow(dead_code)] - -use soroban_sdk::{Address, Env}; - -use crate::errors::Error; -use crate::types::{EventRecord, ReleaseKind}; - -pub fn validate_create(_env: &Env, record: &EventRecord, _owner: &Address) -> Result<(), Error> { - if !matches!(record.release_kind, ReleaseKind::Single) { - return Err(Error::InvalidReleaseKind); - } - Ok(()) -} diff --git a/contracts/events/src/idempotency.rs b/contracts/events/src/idempotency.rs index c052cc5..a5be3c9 100644 --- a/contracts/events/src/idempotency.rs +++ b/contracts/events/src/idempotency.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use soroban_sdk::{xdr::ToXdr, Address, Bytes, BytesN, Env}; use crate::errors::Error; @@ -37,26 +35,35 @@ pub fn next_event_id(env: &Env) -> Result { pub mod tag { pub const BOOTSTRAP: u8 = 0xB0; pub const BUMP_REP: u8 = 0xD1; - pub const SLASH_REP: u8 = 0xD2; pub const REGISTER_EARNINGS: u8 = 0xE1; } /// Collision-resistant child op_id. /// -/// Invariant: `sha256(parent ‖ op_tag ‖ sub_idx ‖ callee_contract_id)`. +/// Invariant: `sha256(parent ‖ op_tag ‖ sub_idx ‖ callee_contract_id ‖ domain)`. /// XOR into parent bytes is malleable (reversible, squat-friendly); hashing with -/// the profile contract id as domain separator is not. -pub fn derive_child(env: &Env, parent: &BytesN<32>, op_tag: u8) -> BytesN<32> { - derive_child_indexed(env, parent, op_tag, 0) +/// the profile contract id as domain separator is not. `domain` is the address +/// that authorized the parent call: the profile keeps one seen-set for every +/// events caller, so without it anyone who learns an op_id could spend its +/// children first. +pub fn derive_child(env: &Env, domain: &Address, parent: &BytesN<32>, op_tag: u8) -> BytesN<32> { + derive_child_indexed(env, domain, parent, op_tag, 0) } -pub fn derive_child_indexed(env: &Env, parent: &BytesN<32>, op_tag: u8, sub_idx: u8) -> BytesN<32> { +pub fn derive_child_indexed( + env: &Env, + domain: &Address, + parent: &BytesN<32>, + op_tag: u8, + sub_idx: u8, +) -> BytesN<32> { let callee = storage::get_profile_contract(env); let mut payload = Bytes::new(env); payload.append(&Bytes::from_array(env, &parent.to_array())); payload.push_back(op_tag); payload.push_back(sub_idx); payload.append(&callee.to_xdr(env)); + payload.append(&domain.clone().to_xdr(env)); let digest = env.crypto().sha256(&payload); BytesN::from_array(env, &digest.to_array()) } diff --git a/contracts/events/src/lib.rs b/contracts/events/src/lib.rs index ddbdd0a..5bd7ec2 100644 --- a/contracts/events/src/lib.rs +++ b/contracts/events/src/lib.rs @@ -4,19 +4,16 @@ use soroban_sdk::{contract, contractimpl, contractmeta, Address, BytesN, Env, String, Vec}; mod admin; -mod bounty; -mod crowdfunding; -mod errors; +pub mod errors; mod escrow; mod event_ops; mod events; mod grant; -mod hackathon; mod idempotency; mod profile_client; mod storage; mod token_whitelist; -mod types; +pub mod types; #[cfg(test)] mod tests; @@ -68,6 +65,30 @@ impl EventsContract { admin::set_fee_account(&env, new_account) } + pub fn propose_release_validator(env: Env, target: Address) -> Result<(), Error> { + admin::propose_release_validator(&env, target) + } + + pub fn accept_release_validator(env: Env) -> Result<(), Error> { + admin::accept_release_validator(&env) + } + + pub fn cancel_pending_release_validator(env: Env) -> Result<(), Error> { + admin::cancel_pending_release_validator(&env) + } + + pub fn clear_release_validator(env: Env) -> Result<(), Error> { + admin::clear_release_validator(&env) + } + + pub fn get_release_validator(env: Env) -> Option
{ + storage::get_release_validator(&env) + } + + pub fn get_pending_release_validator(env: Env) -> Option { + storage::get_pending_release_validator(&env) + } + pub fn set_profile_contract(env: Env, new_addr: Address) -> Result<(), Error> { admin::set_profile_contract(&env, new_addr) } @@ -158,6 +179,23 @@ impl EventsContract { event_ops::finalize_cancel(&env, event_id, op_id) } + pub fn claim_refund( + env: Env, + event_id: u64, + contributor: Address, + op_id: BytesN<32>, + ) -> Result<(), Error> { + event_ops::claim_refund(&env, event_id, contributor, op_id) + } + + pub fn get_unclaimed_refund( + env: Env, + event_id: u64, + contributor: Address, + ) -> Result { + event_ops::get_unclaimed_refund(&env, event_id, contributor) + } + pub fn add_funds( env: Env, event_id: u64, @@ -196,6 +234,10 @@ impl EventsContract { event_ops::propose_manager(&env, event_id, new_manager) } + pub fn reclaim_management(env: Env, event_id: u64) -> Result<(), Error> { + event_ops::reclaim_management(&env, event_id) + } + pub fn accept_manager(env: Env, event_id: u64) -> Result<(), Error> { event_ops::accept_manager(&env, event_id) } @@ -226,6 +268,16 @@ impl EventsContract { // ============================================================ // READS (id-keyed only; no linear scans) // ============================================================ + pub fn forfeit_milestone( + env: Env, + event_id: u64, + recipient: Address, + milestone: u32, + op_id: BytesN<32>, + ) -> Result<(), Error> { + grant::forfeit_milestone(&env, event_id, recipient, milestone, op_id) + } + pub fn get_event(env: Env, event_id: u64) -> Result { event_ops::get_event(&env, event_id) } diff --git a/contracts/events/src/profile_client.rs b/contracts/events/src/profile_client.rs index ab4b1d9..8e1b04e 100644 --- a/contracts/events/src/profile_client.rs +++ b/contracts/events/src/profile_client.rs @@ -5,7 +5,6 @@ use soroban_sdk::{contractclient, Address, BytesN, Env, Symbol}; pub trait ProfileInterface { fn bootstrap(env: Env, user: Address, op_id: BytesN<32>); fn bump_reputation(env: Env, user: Address, delta: u32, reason: Symbol, op_id: BytesN<32>); - fn slash_reputation(env: Env, user: Address, delta: u32, reason: Symbol, op_id: BytesN<32>); fn register_earnings(env: Env, user: Address, token: Address, amount: i128, op_id: BytesN<32>); } diff --git a/contracts/events/src/storage.rs b/contracts/events/src/storage.rs index 577ed12..04c1af4 100644 --- a/contracts/events/src/storage.rs +++ b/contracts/events/src/storage.rs @@ -1,13 +1,9 @@ -#![allow(dead_code)] - -use soroban_sdk::{contracttype, Address, BytesN, Env, Vec}; - -use soroban_sdk::String; +use soroban_sdk::{Address, BytesN, Env, Map, String, Vec}; use crate::errors::Error; use crate::types::{ - CancellationState, DataKey, EventRecord, PendingAdmin, PendingManager, PendingUpgrade, - PrizeAward, Winner, + CancellationState, DataKey, EventRecord, GrantAward, GrantProgress, PendingAdmin, + PendingManager, PendingUpgrade, PendingValidator, PrizeAward, Winner, }; // ============================================================ @@ -19,11 +15,6 @@ const INSTANCE_TTL_BUMP: u32 = 518_400; const EVENT_TTL_THRESHOLD: u32 = 86_400; const EVENT_TTL_BUMP: u32 = 1_555_200; -#[contracttype(export = false)] -enum LegacyDataKey { - OpSeen(BytesN<32>), -} - pub fn touch_instance(env: &Env) { env.storage() .instance() @@ -110,6 +101,59 @@ pub fn set_paused(env: &Env, paused: bool) { env.storage().instance().set(&DataKey::Paused, &paused); } +pub fn get_release_validator(env: &Env) -> Option
{ + env.storage().instance().get(&DataKey::ReleaseValidator) +} + +pub fn set_release_validator(env: &Env, validator: &Option
) { + match validator { + Some(v) => env.storage().instance().set(&DataKey::ReleaseValidator, v), + None => env.storage().instance().remove(&DataKey::ReleaseValidator), + } +} + +pub fn get_pending_release_validator(env: &Env) -> Option { + env.storage() + .instance() + .get(&DataKey::PendingReleaseValidator) +} + +pub fn set_pending_release_validator(env: &Env, pending: &PendingValidator) { + env.storage() + .instance() + .set(&DataKey::PendingReleaseValidator, pending); +} + +pub fn clear_pending_release_validator(env: &Env) { + env.storage() + .instance() + .remove(&DataKey::PendingReleaseValidator); +} + +pub fn get_paused_at(env: &Env) -> Option { + env.storage().instance().get(&DataKey::PausedAt) +} + +pub fn set_paused_at(env: &Env, at: Option) { + match at { + Some(at) => env.storage().instance().set(&DataKey::PausedAt, &at), + None => env.storage().instance().remove(&DataKey::PausedAt), + } +} + +pub fn get_paused_seconds(env: &Env) -> u64 { + env.storage() + .instance() + .get(&DataKey::PausedSeconds) + .unwrap_or(0) +} + +pub fn set_paused_seconds(env: &Env, seconds: u64) { + env.storage() + .instance() + .set(&DataKey::PausedSeconds, &seconds); +} + pub fn get_deployment_seq(env: &Env) -> u32 { env.storage() .instance() @@ -122,7 +166,7 @@ pub fn set_deployment_seq(env: &Env, seq: u32) { } // ============================================================ -// VERSION / UPGRADE / MIGRATION (instance; H6) +// VERSION / UPGRADE / MIGRATION (instance) // ============================================================ pub fn get_version(env: &Env) -> Option { env.storage().instance().get(&DataKey::Version) @@ -300,6 +344,12 @@ pub fn set_event_manager(env: &Env, id: u64, manager: &Address) { touch_event_persistent(env, &key); } +pub fn clear_event_manager(env: &Env, id: u64) { + env.storage() + .persistent() + .remove(&DataKey::EventManager(id)); +} + pub fn get_pending_manager(env: &Env, id: u64) -> Option { let key = DataKey::PendingManager(id); let p: Option = env.storage().persistent().get(&key); @@ -408,21 +458,6 @@ pub fn set_owed_total(env: &Env, id: u64, owed: i128) { touch_event_persistent(env, &key); } -pub fn get_prize_base_escrow(env: &Env, id: u64) -> Option { - let key = DataKey::EventPrizeBaseEscrow(id); - let b: Option = env.storage().persistent().get(&key); - if b.is_some() { - touch_event_persistent(env, &key); - } - b -} - -pub fn set_prize_base_escrow(env: &Env, id: u64, base: i128) { - let key = DataKey::EventPrizeBaseEscrow(id); - env.storage().persistent().set(&key, &base); - touch_event_persistent(env, &key); -} - pub fn get_prize_claim_expiry(env: &Env, id: u64) -> Option { let key = DataKey::EventPrizeClaimExpiry(id); let t: Option = env.storage().persistent().get(&key); @@ -561,6 +596,55 @@ pub fn mark_milestone_claimed(env: &Env, id: u64, recipient: &Address, milestone touch_event_persistent(env, &key); } +pub fn get_grant_roster(env: &Env, id: u64) -> Option> { + let key = DataKey::GrantRoster(id); + let roster: Option> = env.storage().persistent().get(&key); + if roster.is_some() { + touch_event_persistent(env, &key); + } + roster +} + +pub fn set_grant_roster(env: &Env, id: u64, roster: &Map) { + let key = DataKey::GrantRoster(id); + env.storage().persistent().set(&key, roster); + touch_event_persistent(env, &key); +} + +pub fn get_grant_progress(env: &Env, id: u64, recipient: &Address) -> GrantProgress { + let key = DataKey::GrantProgress(id, recipient.clone()); + let progress: Option = env.storage().persistent().get(&key); + if progress.is_some() { + touch_event_persistent(env, &key); + } + progress.unwrap_or_default() +} + +pub fn set_grant_progress(env: &Env, id: u64, recipient: &Address, progress: &GrantProgress) { + let key = DataKey::GrantProgress(id, recipient.clone()); + env.storage().persistent().set(&key, progress); + touch_event_persistent(env, &key); +} + +pub fn unclaimed_refund(env: &Env, id: u64, contributor: &Address) -> i128 { + let key = DataKey::UnclaimedRefund(id, contributor.clone()); + let amount: Option = env.storage().persistent().get(&key); + if amount.is_some() { + touch_event_persistent(env, &key); + } + amount.unwrap_or(0) +} + +pub fn set_unclaimed_refund(env: &Env, id: u64, contributor: &Address, amount: i128) { + let key = DataKey::UnclaimedRefund(id, contributor.clone()); + if amount > 0 { + env.storage().persistent().set(&key, &amount); + touch_event_persistent(env, &key); + } else { + env.storage().persistent().remove(&key); + } +} + // ============================================================ // CROWDFUNDING MILESTONES CLAIMED (persistent) // ============================================================ @@ -607,17 +691,10 @@ pub fn clear_cancellation_state(env: &Env, id: u64) { // IDEMPOTENCY (temporary; auto-TTL) // ============================================================ pub fn is_op_seen(env: &Env, domain: &Address, op_id: &BytesN<32>) -> bool { - let scoped_seen = env - .storage() + env.storage() .temporary() .get(&DataKey::OpSeen(domain.clone(), op_id.clone())) - .unwrap_or(false); - scoped_seen - || env - .storage() - .temporary() - .get(&LegacyDataKey::OpSeen(op_id.clone())) - .unwrap_or(false) + .unwrap_or(false) } pub fn mark_op_seen(env: &Env, domain: &Address, op_id: &BytesN<32>) { diff --git a/contracts/events/src/tests/admin.rs b/contracts/events/src/tests/admin.rs index bd73268..92bb7ea 100644 --- a/contracts/events/src/tests/admin.rs +++ b/contracts/events/src/tests/admin.rs @@ -8,10 +8,9 @@ use soroban_sdk::{ use super::common::setup; use crate::errors::Error; use crate::storage; -use crate::types::{DataKey, EventStatus, Pillar, ReleaseKind, Winner}; +use crate::types::{DataKey, EventStatus, Pillar, ReleaseKind}; -// Zeroed with the contract constant; restore both together. -const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; +const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; #[test] @@ -21,7 +20,7 @@ fn initializes_with_expected_config() { assert_eq!(ctx.client.get_fee_account(), ctx.fee_account); 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!(!ctx.client.is_paused()); assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "2.0.0")); assert_eq!(ctx.client.get_pending_upgrade(), None); assert_eq!(ctx.client.get_migrated_to_version(), None); @@ -31,9 +30,9 @@ fn initializes_with_expected_config() { fn pause_and_unpause_round_trip() { let ctx = setup(250); ctx.client.pause(); - assert_eq!(ctx.client.is_paused(), true); + assert!(ctx.client.is_paused()); ctx.client.unpause(); - assert_eq!(ctx.client.is_paused(), false); + assert!(!ctx.client.is_paused()); } #[test] @@ -79,18 +78,13 @@ fn propose_upgrade_rejects_empty_version() { let err = ctx .client .try_propose_upgrade(&new_hash, &empty) - .err() - .expect("empty version rejected") + .expect_err("empty version rejected") .unwrap(); assert_eq!(err, Error::InvalidPillar); } #[test] -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. +fn apply_upgrade_waits_out_the_timelock() { let ctx = setup(250); let new_hash: BytesN<32> = BytesN::random(&ctx.env); let new_version = String::from_str(&ctx.env, "0.3.0"); @@ -98,10 +92,20 @@ fn apply_upgrade_is_immediate_while_the_timelock_is_zero() { 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" + pending.available_at_ledger, + pending.proposed_at_ledger + UPGRADE_TIMELOCK_LEDGERS + ); + assert_eq!( + ctx.client.try_apply_upgrade().err().unwrap().unwrap(), + Error::UpgradeTimelockNotElapsed + ); + ctx.env.ledger().with_mut(|li| { + li.sequence_number = pending.available_at_ledger - 1; + }); + assert_eq!( + ctx.client.try_apply_upgrade().err().unwrap().unwrap(), + Error::UpgradeTimelockNotElapsed ); - assert_eq!(UPGRADE_TIMELOCK_LEDGERS, 0, "restore me with the constant"); } #[test] @@ -119,8 +123,7 @@ fn apply_upgrade_after_expiry_reverts() { let err = ctx .client .try_apply_upgrade() - .err() - .expect("expiry blocks") + .expect_err("expiry blocks") .unwrap(); assert_eq!(err, Error::UpgradeProposalExpired); } @@ -144,8 +147,7 @@ fn cancel_with_no_pending_reverts() { let err = ctx .client .try_cancel_pending_upgrade() - .err() - .expect("nothing to cancel") + .expect_err("nothing to cancel") .unwrap(); assert_eq!(err, Error::UpgradeNotProposed); } @@ -227,67 +229,6 @@ fn migrate_rewrites_legacy_percentages_as_prize_floors() { ); } -#[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 @@ -303,8 +244,9 @@ fn migrate_refuses_to_stamp_while_events_remain_unconverted() { .set(&DataKey::NextEventId, &(base + 20)); }); - assert!( - ctx.client.try_migrate().is_err(), + assert_eq!( + ctx.client.try_migrate().unwrap_err().unwrap(), + Error::MigrationIncomplete, "must not stamp while events are unconverted" ); assert_eq!(ctx.client.get_migrated_to_version(), None); @@ -312,8 +254,9 @@ fn migrate_refuses_to_stamp_while_events_remain_unconverted() { // 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(), + assert_eq!( + ctx.client.try_migrate().unwrap_err().unwrap(), + Error::MigrationIncomplete, "still incomplete after two of three pages" ); assert_eq!(ctx.client.migrate_events(&8_u32), 0); @@ -456,8 +399,7 @@ fn migrate_marks_current_version_and_blocks_replay() { let err = ctx .client .try_migrate() - .err() - .expect("second migrate rejected") + .expect_err("second migrate rejected") .unwrap(); assert_eq!(err, Error::MigrationAlreadyApplied); } @@ -465,7 +407,7 @@ fn migrate_marks_current_version_and_blocks_replay() { // AUTH REGRESSION GUARDS (#73) // // setup() mocks all auths for every address, so a call succeeding is not -// proof that require_admin() ran — it succeeds identically whether the +// proof that require_admin() ran: it succeeds identically whether the // check is present or was deleted. These tests replace the mock with an // empty auth set so the call can only succeed if the contract explicitly // requests and receives the admin's authorization. If require_admin() is @@ -575,7 +517,7 @@ fn migrate_reverts_without_admin_auth() { } // ============================================================ -// ACCEPT_ADMIN — target-auth guard +// ACCEPT_ADMIN: target-auth guard // // accept_admin does not call require_admin(); it authorizes against the // pending target address instead (pending.target.require_auth()). These @@ -614,88 +556,3 @@ 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/cancel_refund.rs b/contracts/events/src/tests/cancel_refund.rs index 905ea14..f364448 100644 --- a/contracts/events/src/tests/cancel_refund.rs +++ b/contracts/events/src/tests/cancel_refund.rs @@ -16,12 +16,10 @@ const FEE_BPS: u32 = 250; const TOTAL_BUDGET: i128 = 1_000_0000000_i128; const MIN_CONTRIB: i128 = 100_000_000_i128; -#[allow(dead_code)] struct Ctx<'a> { env: Env, events: EventsContractClient<'a>, events_id: Address, - profile: ProfileContractClient<'a>, owner: Address, token_addr: Address, token_admin: token::StellarAssetClient<'a>, @@ -67,7 +65,6 @@ fn setup_with_env<'a>(env: Env) -> Ctx<'a> { env, events, events_id, - profile, owner, token_addr, token_admin, @@ -380,16 +377,14 @@ fn missing_running_total_with_contributors_fails_closed() { let add_funds_err = ctx .events .try_add_funds(&id, &partner, &100_0000000_i128, &BytesN::random(&ctx.env)) - .err() - .expect("missing total rejected") + .expect_err("missing total rejected") .unwrap(); assert_eq!(add_funds_err, Error::CancellationTotalMissing); let cancel_err = ctx .events .try_start_cancel(&id, &BytesN::random(&ctx.env)) - .err() - .expect("missing total rejected") + .expect_err("missing total rejected") .unwrap(); assert_eq!(cancel_err, Error::CancellationTotalMissing); assert_eq!(ctx.events.get_event(&id).status, EventStatus::Active); diff --git a/contracts/events/src/tests/common.rs b/contracts/events/src/tests/common.rs index 653da2e..4845642 100644 --- a/contracts/events/src/tests/common.rs +++ b/contracts/events/src/tests/common.rs @@ -9,7 +9,6 @@ use crate::event_ops::MAX_REFUNDS_PER_BATCH; use crate::types::EventStatus; use crate::{EventsContract, EventsContractClient}; -#[allow(dead_code)] pub fn drive_cancel<'a>(env: &Env, client: &EventsContractClient<'a>, id: u64) { let op_start = BytesN::random(env); client.start_cancel(&id, &op_start); @@ -32,7 +31,6 @@ pub fn drive_cancel<'a>(env: &Env, client: &EventsContractClient<'a>, id: u64) { } pub struct TestCtx<'a> { - #[allow(dead_code)] pub env: Env, pub admin: Address, pub fee_account: Address, diff --git a/contracts/events/src/tests/contributions.rs b/contracts/events/src/tests/contributions.rs index fc4bce5..5da0afa 100644 --- a/contracts/events/src/tests/contributions.rs +++ b/contracts/events/src/tests/contributions.rs @@ -20,8 +20,6 @@ const TOTAL_BUDGET: i128 = 1_000_0000000_i128; struct Ctx<'a> { env: Env, events: EventsContractClient<'a>, - #[allow(dead_code)] - profile: ProfileContractClient<'a>, owner: Address, token_addr: Address, fee_account: Address, @@ -64,7 +62,6 @@ fn setup<'a>() -> Ctx<'a> { Ctx { env, events, - profile, owner, token_addr, fee_account, diff --git a/contracts/events/src/tests/crowdfunding.rs b/contracts/events/src/tests/crowdfunding.rs index 6eef7f6..551363d 100644 --- a/contracts/events/src/tests/crowdfunding.rs +++ b/contracts/events/src/tests/crowdfunding.rs @@ -18,8 +18,6 @@ const FUNDING_GOAL: i128 = 1_000_0000000_i128; struct Ctx<'a> { env: Env, events: EventsContractClient<'a>, - #[allow(dead_code)] - profile: ProfileContractClient<'a>, builder: Address, events_admin: Address, fee_account: Address, @@ -62,7 +60,6 @@ fn setup<'a>() -> Ctx<'a> { Ctx { env, events, - profile, builder, events_admin, fee_account, @@ -324,6 +321,29 @@ fn claim_milestone_out_of_range_reverts() { assert!(res.is_err()); } +#[test] +fn claim_milestone_pays_only_the_campaign_owner() { + let ctx = setup(); + let id = create_campaign(&ctx, 2); + let backer = Address::generate(&ctx.env); + back(&ctx, id, &backer, 400_0000000_i128); + + let stranger = Address::generate(&ctx.env); + let err = ctx + .events + .try_claim_milestone(&id, &stranger, &0_u32, &0, &BytesN::random(&ctx.env)) + .unwrap_err() + .unwrap(); + assert_eq!(err, crate::errors::Error::NoSubmissions); + + ctx.events + .claim_milestone(&id, &ctx.builder, &0_u32, &0, &BytesN::random(&ctx.env)); + let paid = ctx.events.get_winner_at(&id, &1).unwrap(); + assert_eq!(paid.recipient, ctx.builder); + assert_eq!(paid.position, 1); + assert_eq!(paid.milestone, Some(0)); +} + #[test] fn claim_milestone_with_empty_escrow_reverts() { let ctx = setup(); @@ -501,3 +521,285 @@ fn crowdfunding_claim_milestone_requires_admin_auth() { ); assert!(builder_required, "builder auth still required"); } + +#[test] +fn a_release_records_the_fee_it_withheld() { + use soroban_sdk::{testutils::Events as _, Event as _}; + + let ctx = setup(); + let id = create_campaign(&ctx, 3); + let backer = Address::generate(&ctx.env); + back(&ctx, id, &backer, 900_0000000_i128); + + ctx.events + .claim_milestone(&id, &ctx.builder, &0_u32, &0_u32, &BytesN::random(&ctx.env)); + let wanted = crate::events::MilestoneFeeCharged { + event_id: id, + recipient: ctx.builder.clone(), + milestone: 0, + fee: 7_5000000_i128, + } + .to_xdr(&ctx.env, &ctx.events.address); + let emitted = ctx.env.events().all(); + assert!(emitted + .filter_by_contract(&ctx.events.address) + .events() + .contains(&wanted)); +} + +fn only_these_sign_the_release(ctx: &Ctx, id: u64, signers: &[&Address], op: &BytesN<32>) { + only_these_sign_the_release_of(ctx, id, 0, signers, op); +} + +fn only_these_sign_the_release_of( + ctx: &Ctx, + id: u64, + milestone: u32, + signers: &[&Address], + op: &BytesN<32>, +) { + extern crate std; + use soroban_sdk::{ + testutils::{MockAuth, MockAuthInvoke}, + IntoVal, + }; + let args: SorobanVec = + (id, ctx.builder.clone(), milestone, 0_u32, op.clone()).into_val(&ctx.env); + let invoke = MockAuthInvoke { + contract: &ctx.events.address, + fn_name: "claim_milestone", + args, + sub_invokes: &[], + }; + let mocks: std::vec::Vec = signers + .iter() + .map(|address| MockAuth { + address, + invoke: &invoke, + }) + .collect(); + ctx.env.mock_auths(&mocks); +} + +fn appoint_validator(ctx: &Ctx) -> Address { + let validator = Address::generate(&ctx.env); + ctx.events.propose_release_validator(&validator); + ctx.events.accept_release_validator(); + validator +} + +#[test] +fn a_release_validator_co_signs_instead_of_the_admin() { + let ctx = setup(); + let id = create_campaign(&ctx, 2); + let p = Address::generate(&ctx.env); + back(&ctx, id, &p, 200_0000000_i128); + + let validator = appoint_validator(&ctx); + assert_eq!(ctx.events.get_release_validator(), Some(validator.clone())); + + let op = BytesN::random(&ctx.env); + only_these_sign_the_release(&ctx, id, &[&ctx.builder, &ctx.events_admin], &op); + assert!(ctx + .events + .try_claim_milestone(&id, &ctx.builder, &0_u32, &0, &op) + .is_err()); + + only_these_sign_the_release(&ctx, id, &[&ctx.builder, &validator], &op); + ctx.events + .claim_milestone(&id, &ctx.builder, &0_u32, &0, &op); + ctx.env.mock_all_auths_allowing_non_root_auth(); + let token = token::Client::new(&ctx.env, &ctx.token_addr); + assert_eq!(token.balance(&ctx.builder), 97_5000000_i128); +} + +#[test] +fn a_proposed_validator_co_signs_nothing_until_it_accepts() { + let ctx = setup(); + let id = create_campaign(&ctx, 2); + let p = Address::generate(&ctx.env); + back(&ctx, id, &p, 200_0000000_i128); + + let proposed = Address::generate(&ctx.env); + ctx.events.propose_release_validator(&proposed); + assert_eq!(ctx.events.get_release_validator(), None); + assert_eq!( + ctx.events.get_pending_release_validator().unwrap().target, + proposed + ); + + let op = BytesN::random(&ctx.env); + only_these_sign_the_release(&ctx, id, &[&ctx.builder, &proposed], &op); + assert!(ctx + .events + .try_claim_milestone(&id, &ctx.builder, &0_u32, &0, &op) + .is_err()); + only_these_sign_the_release(&ctx, id, &[&ctx.builder, &ctx.events_admin], &op); + ctx.events + .claim_milestone(&id, &ctx.builder, &0_u32, &0, &op); +} + +#[test] +fn only_the_proposed_key_can_accept() { + use soroban_sdk::{ + testutils::{MockAuth, MockAuthInvoke}, + IntoVal, + }; + let ctx = setup(); + let proposed = Address::generate(&ctx.env); + ctx.events.propose_release_validator(&proposed); + + for signer in [&ctx.events_admin, &ctx.builder] { + ctx.env.mock_auths(&[MockAuth { + address: signer, + invoke: &MockAuthInvoke { + contract: &ctx.events.address, + fn_name: "accept_release_validator", + args: ().into_val(&ctx.env), + sub_invokes: &[], + }, + }]); + assert!(ctx.events.try_accept_release_validator().is_err()); + } + ctx.env.mock_auths(&[MockAuth { + address: &proposed, + invoke: &MockAuthInvoke { + contract: &ctx.events.address, + fn_name: "accept_release_validator", + args: ().into_val(&ctx.env), + sub_invokes: &[], + }, + }]); + ctx.events.accept_release_validator(); + ctx.env.mock_all_auths_allowing_non_root_auth(); + assert_eq!(ctx.events.get_release_validator(), Some(proposed)); + assert!(ctx.events.get_pending_release_validator().is_none()); +} + +#[test] +fn only_the_admin_proposes_cancels_or_clears() { + use soroban_sdk::{ + testutils::{MockAuth, MockAuthInvoke}, + IntoVal, + }; + let ctx = setup(); + let target = Address::generate(&ctx.env); + let as_builder = |fn_name: &'static str, args: SorobanVec| { + ctx.env.mock_auths(&[MockAuth { + address: &ctx.builder, + invoke: &MockAuthInvoke { + contract: &ctx.events.address, + fn_name, + args, + sub_invokes: &[], + }, + }]); + }; + as_builder( + "propose_release_validator", + (target.clone(),).into_val(&ctx.env), + ); + assert!(ctx.events.try_propose_release_validator(&target).is_err()); + + ctx.env.mock_all_auths_allowing_non_root_auth(); + ctx.events.propose_release_validator(&target); + as_builder("cancel_pending_release_validator", ().into_val(&ctx.env)); + assert!(ctx.events.try_cancel_pending_release_validator().is_err()); + as_builder("clear_release_validator", ().into_val(&ctx.env)); + assert!(ctx.events.try_clear_release_validator().is_err()); + ctx.env.mock_all_auths_allowing_non_root_auth(); + assert!(ctx.events.get_pending_release_validator().is_some()); +} + +#[test] +fn an_unaccepted_proposal_expires_or_can_be_withdrawn() { + use soroban_sdk::testutils::Ledger as _; + let ctx = setup(); + let target = Address::generate(&ctx.env); + + ctx.events.propose_release_validator(&target); + ctx.events.cancel_pending_release_validator(); + assert_eq!( + ctx.events + .try_accept_release_validator() + .err() + .unwrap() + .unwrap(), + crate::errors::Error::PendingRotationMismatch + ); + assert_eq!( + ctx.events + .try_cancel_pending_release_validator() + .err() + .unwrap() + .unwrap(), + crate::errors::Error::PendingRotationMismatch + ); + + ctx.events.propose_release_validator(&target); + let expires = ctx + .events + .get_pending_release_validator() + .unwrap() + .expires_at_ledger; + ctx.env + .ledger() + .with_mut(|l| l.sequence_number = expires + 1); + assert_eq!( + ctx.events + .try_accept_release_validator() + .err() + .unwrap() + .unwrap(), + crate::errors::Error::PendingRotationExpired + ); + assert_eq!(ctx.events.get_release_validator(), None); +} + +#[test] +fn replacing_a_validator_keeps_the_old_one_until_the_new_one_accepts() { + let ctx = setup(); + let id = create_campaign(&ctx, 3); + let p = Address::generate(&ctx.env); + back(&ctx, id, &p, 300_0000000_i128); + let old = appoint_validator(&ctx); + + let new = Address::generate(&ctx.env); + ctx.events.propose_release_validator(&new); + let op = BytesN::random(&ctx.env); + only_these_sign_the_release(&ctx, id, &[&ctx.builder, &old], &op); + ctx.events + .claim_milestone(&id, &ctx.builder, &0_u32, &0, &op); + + ctx.env.mock_all_auths_allowing_non_root_auth(); + ctx.events.accept_release_validator(); + let op = BytesN::random(&ctx.env); + only_these_sign_the_release_of(&ctx, id, 1, &[&ctx.builder, &old], &op); + assert!(ctx + .events + .try_claim_milestone(&id, &ctx.builder, &1_u32, &0, &op) + .is_err()); + only_these_sign_the_release_of(&ctx, id, 1, &[&ctx.builder, &new], &op); + ctx.events + .claim_milestone(&id, &ctx.builder, &1_u32, &0, &op); +} + +#[test] +fn clearing_the_validator_returns_co_signing_to_the_admin_at_once() { + let ctx = setup(); + let id = create_campaign(&ctx, 2); + let p = Address::generate(&ctx.env); + back(&ctx, id, &p, 200_0000000_i128); + appoint_validator(&ctx); + ctx.events + .propose_release_validator(&Address::generate(&ctx.env)); + + ctx.events.clear_release_validator(); + assert_eq!(ctx.events.get_release_validator(), None); + assert!(ctx.events.get_pending_release_validator().is_none()); + + ctx.events + .claim_milestone(&id, &ctx.builder, &0_u32, &0, &BytesN::random(&ctx.env)); + let auths = ctx.env.auths(); + assert!(auths.iter().any(|(addr, _)| *addr == ctx.events_admin)); +} diff --git a/contracts/events/src/tests/escrow_fee_math.rs b/contracts/events/src/tests/escrow_fee_math.rs index e648c96..dda957a 100644 --- a/contracts/events/src/tests/escrow_fee_math.rs +++ b/contracts/events/src/tests/escrow_fee_math.rs @@ -16,8 +16,6 @@ const TOTAL_BUDGET: i128 = 1_000_0000000_i128; // 1000 USDC at 7 decimals struct Ctx<'a> { env: Env, events: EventsContractClient<'a>, - #[allow(dead_code)] - profile: ProfileContractClient<'a>, owner: Address, token_addr: Address, fee_account: Address, @@ -60,7 +58,6 @@ fn setup_with_bps<'a>(fee_bps: u32) -> Ctx<'a> { Ctx { env, events, - profile, owner, token_addr, fee_account, @@ -361,7 +358,7 @@ fn zero_global_bps_no_override_charges_no_fee() { } // ============================================================ -// release math: select_winners Single — percent of escrow +// release math: select_winners Single, percent of escrow // ============================================================ #[test] diff --git a/contracts/events/src/tests/grant_pillar.rs b/contracts/events/src/tests/grant_pillar.rs index ad39d68..b482e92 100644 --- a/contracts/events/src/tests/grant_pillar.rs +++ b/contracts/events/src/tests/grant_pillar.rs @@ -12,14 +12,12 @@ use boundless_profile::{ProfileContract, ProfileContractClient}; const FEE_BPS: u32 = 250; const TOTAL_BUDGET: i128 = 10_000_0000000_i128; -#[allow(dead_code)] struct Ctx<'a> { env: Env, events: EventsContractClient<'a>, profile: ProfileContractClient<'a>, owner: Address, token_addr: Address, - token_admin: token::StellarAssetClient<'a>, fee_account: Address, } @@ -61,7 +59,6 @@ fn setup<'a>() -> Ctx<'a> { profile, owner, token_addr, - token_admin, fee_account, } } @@ -234,11 +231,11 @@ fn claim_milestone_marks_completed_on_last() { } // ============================================================ -// claim_milestone: credit/reputation side-effects +// claim_milestone: earnings/reputation side-effects // ============================================================ #[test] -fn claim_milestone_earns_credits_and_bumps_reputation() { +fn claim_milestone_records_earnings_and_bumps_reputation() { let ctx = setup(); let recipient = Address::generate(&ctx.env); let id = create_grant(&ctx, 4); diff --git a/contracts/events/src/tests/grant_scenarios/auth.rs b/contracts/events/src/tests/grant_scenarios/auth.rs new file mode 100644 index 0000000..c8c40ac --- /dev/null +++ b/contracts/events/src/tests/grant_scenarios/auth.rs @@ -0,0 +1,313 @@ +use soroban_sdk::testutils::Ledger as _; + +use super::*; + +fn awarded<'a>() -> (G<'a>, u64, Address) { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2)); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(1_000))]); + (g, id, a) +} + +#[test] +fn publishing_is_signed_by_the_owner_alone() { + let g = setup(); + g.token_admin.mint(&g.owner, &usdc(2_000)); + g.events + .create_event(&g.params(&Spec::new(usdc(1_000), 2)), &g.op()); + assert_eq!(g.signers(), std::vec![g.owner.clone()]); +} + +#[test] +fn publishing_without_the_owner_fails() { + let g = setup(); + g.token_admin.mint(&g.owner, &usdc(2_000)); + g.env.mock_auths(&[]); + fails_auth( + g.events + .try_create_event(&g.params(&Spec::new(usdc(1_000), 2)), &g.op()), + ); + g.restore_auth(); + assert_eq!(g.balance(&g.owner), usdc(2_000)); +} + +#[test] +fn milestone_release_is_signed_by_the_owner_alone() { + let (g, id, a) = awarded(); + g.events.claim_milestone(&id, &a, &0, &0, &g.op()); + assert_eq!(g.signers(), std::vec![g.owner.clone()]); +} + +#[test] +fn owner_signature_is_sufficient_to_release() { + let (g, id, a) = awarded(); + let op = g.op(); + g.sign_only( + &g.owner, + "claim_milestone", + args(&g, (id, a.clone(), 0u32, 0u32, op.clone())), + ); + g.events.claim_milestone(&id, &a, &0, &0, &op); + g.restore_auth(); + assert_eq!(g.balance(&a), usdc(500)); +} + +#[test] +fn recipient_cannot_release_their_own_milestone() { + let (g, id, a) = awarded(); + let op = g.op(); + g.sign_only( + &a, + "claim_milestone", + args(&g, (id, a.clone(), 0u32, 0u32, op.clone())), + ); + fails_auth(g.events.try_claim_milestone(&id, &a, &0, &0, &op)); + g.restore_auth(); + assert_eq!(g.balance(&a), 0); + assert!(!g.milestone_claimed(id, &a, 0)); +} + +#[test] +fn admin_cannot_release_a_milestone() { + let (g, id, a) = awarded(); + let op = g.op(); + g.sign_only( + &g.admin, + "claim_milestone", + args(&g, (id, a.clone(), 0u32, 0u32, op.clone())), + ); + fails_auth(g.events.try_claim_milestone(&id, &a, &0, &0, &op)); + g.restore_auth(); + assert_eq!(g.balance(&a), 0); +} + +#[test] +fn delegated_manager_cannot_release_a_milestone() { + let g = setup(); + let manager = g.someone(); + let id = g.create(Spec::new(usdc(1_000), 2).manager(&manager)); + g.events.accept_manager(&id); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(1_000))]); + + let op = g.op(); + g.sign_only( + &manager, + "claim_milestone", + args(&g, (id, a.clone(), 0u32, 0u32, op.clone())), + ); + fails_auth(g.events.try_claim_milestone(&id, &a, &0, &0, &op)); + g.restore_auth(); + g.pay(id, &a, 0, usdc(500)); +} + +#[test] +fn selection_is_signed_by_the_owner_until_a_manager_accepts() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2)); + g.events + .select_winners(&id, &g.specs(&[(g.someone(), 1, usdc(1_000))]), &g.op()); + assert_eq!(g.signers(), std::vec![g.owner.clone()]); +} + +#[test] +fn after_handover_only_the_manager_selects_and_cancels() { + let g = setup(); + let manager = g.someone(); + let id = g.create(Spec::new(usdc(1_000), 2).manager(&manager)); + g.events.accept_manager(&id); + assert_eq!(g.signers(), std::vec![manager.clone()]); + + let a = g.someone(); + let specs = g.specs(&[(a.clone(), 1, usdc(1_000))]); + let op = g.op(); + g.sign_only( + &g.owner, + "select_winners", + args(&g, (id, specs.clone(), op.clone())), + ); + fails_auth(g.events.try_select_winners(&id, &specs, &op)); + + g.sign_only( + &manager, + "select_winners", + args(&g, (id, specs.clone(), op.clone())), + ); + g.events.select_winners(&id, &specs, &op); + g.restore_auth(); + g.forfeit(id, &a, 0, usdc(500)); + g.forfeit(id, &a, 1, usdc(500)); + + let op = g.op(); + g.sign_only(&g.owner, "start_cancel", args(&g, (id, op.clone()))); + fails_auth(g.events.try_start_cancel(&id, &op)); + + g.restore_auth(); + g.events.start_cancel(&id, &op); + assert_eq!(g.signers(), std::vec![manager.clone()]); + assert_eq!( + g.balance(&g.owner), + usdc(1_000), + "refunds still go to the owner" + ); +} + +#[test] +fn a_stranger_cannot_select_or_cancel() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2)); + let stranger = g.someone(); + let specs = g.specs(&[(stranger.clone(), 1, usdc(1_000))]); + let op = g.op(); + g.sign_only( + &stranger, + "select_winners", + args(&g, (id, specs.clone(), op.clone())), + ); + fails_auth(g.events.try_select_winners(&id, &specs, &op)); + g.sign_only(&stranger, "start_cancel", args(&g, (id, op.clone()))); + fails_auth(g.events.try_start_cancel(&id, &op)); + g.restore_auth(); + assert_eq!(g.owed(id), 0); + assert_eq!(g.status(id), EventStatus::Active); +} + +#[test] +fn a_top_up_is_signed_by_the_contributor() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2)); + let partner = g.someone(); + g.token_admin.mint(&partner, &usdc(100)); + g.events.add_funds(&id, &partner, &usdc(10), &g.op()); + assert_eq!(g.signers(), std::vec![partner.clone()]); + + let other = g.someone(); + let op = g.op(); + g.sign_only( + &other, + "add_funds", + args(&g, (id, partner.clone(), usdc(10), op.clone())), + ); + fails_auth(g.events.try_add_funds(&id, &partner, &usdc(10), &op)); + g.restore_auth(); +} + +#[test] +fn the_refund_crank_needs_no_signature() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2)); + let partner = g.someone(); + g.contribute(id, &partner, usdc(10)); + g.events.start_cancel(&id, &g.op()); + + g.env.mock_auths(&[]); + assert_eq!(g.events.process_cancel_batch(&id, &25, &g.op()), 0); + g.events.finalize_cancel(&id, &g.op()); + g.restore_auth(); + assert_eq!(g.balance(&partner), usdc(10)); + assert_eq!(g.balance(&g.owner), usdc(1_000)); +} + +#[test] +fn manager_handover_needs_both_sides() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2)); + let manager = g.someone(); + + g.sign_only(&manager, "propose_manager", args(&g, (id, manager.clone()))); + fails_auth(g.events.try_propose_manager(&id, &manager)); + + g.sign_only(&g.owner, "propose_manager", args(&g, (id, manager.clone()))); + g.events.propose_manager(&id, &manager); + + g.sign_only(&g.owner, "accept_manager", args(&g, (id,))); + fails_auth(g.events.try_accept_manager(&id)); + + g.sign_only(&manager, "accept_manager", args(&g, (id,))); + g.events.accept_manager(&id); + g.restore_auth(); + assert_eq!(g.events.get_manager(&id), manager); +} + +#[test] +fn an_unaccepted_proposal_expires() { + let g = setup(); + let manager = g.someone(); + let id = g.create(Spec::new(usdc(1_000), 2).manager(&manager)); + let expires = g.events.get_pending_manager(&id).unwrap().expires_at_ledger; + g.env.ledger().with_mut(|l| l.sequence_number = expires + 1); + fails_with( + g.events.try_accept_manager(&id), + Error::PendingRotationExpired, + ); + assert_eq!(g.events.get_manager(&id), g.owner); +} + +#[test] +fn a_pending_proposal_can_be_withdrawn() { + let g = setup(); + let manager = g.someone(); + let id = g.create(Spec::new(usdc(1_000), 2).manager(&manager)); + g.events.cancel_pending_manager(&id); + assert_eq!(g.signers(), std::vec![g.owner.clone()]); + fails_with( + g.events.try_accept_manager(&id), + Error::PendingRotationMismatch, + ); +} + +#[test] +fn the_owner_takes_management_back_before_any_award() { + let g = setup(); + let manager = g.someone(); + let id = g.create(Spec::new(usdc(1_000), 2).manager(&manager)); + g.events.accept_manager(&id); + let next = g.someone(); + g.events.propose_manager(&id, &next); + + g.sign_only(&manager, "reclaim_management", args(&g, (id,))); + fails_auth(g.events.try_reclaim_management(&id)); + g.sign_only(&g.owner, "reclaim_management", args(&g, (id,))); + g.events.reclaim_management(&id); + g.capture(); + g.restore_auth(); + + assert_eq!(g.events.get_manager(&id), g.owner); + assert!(g.events.get_pending_manager(&id).is_none()); + assert!(g.emitted(crate::events::ManagementReclaimed { + event_id: id, + owner: g.owner.clone(), + })); + fails_with( + g.events.try_accept_manager(&id), + Error::PendingRotationMismatch, + ); + + // the owner runs it from here, cancel included + g.events.start_cancel(&id, &g.op()); + assert_eq!(g.signers(), std::vec![g.owner.clone()]); + assert_eq!(g.balance(&g.owner), usdc(1_000)); +} + +#[test] +fn after_selection_management_stays_with_the_manager() { + let g = setup(); + let manager = g.someone(); + let id = g.create(Spec::new(usdc(1_000), 2).manager(&manager)); + g.events.accept_manager(&id); + g.select(id, &[(g.someone(), 1, usdc(1_000))]); + fails_with( + g.events.try_reclaim_management(&id), + Error::WinnersAlreadySelected, + ); + assert_eq!(g.events.get_manager(&id), manager); +} + +#[test] +fn reclaiming_needs_an_open_event() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2)); + g.cancel(id); + fails_with(g.events.try_reclaim_management(&id), Error::EventNotActive); +} diff --git a/contracts/events/src/tests/grant_scenarios/cancel.rs b/contracts/events/src/tests/grant_scenarios/cancel.rs new file mode 100644 index 0000000..5aa962f --- /dev/null +++ b/contracts/events/src/tests/grant_scenarios/cancel.rs @@ -0,0 +1,208 @@ +use super::*; +use crate::event_ops::MAX_REFUNDS_PER_BATCH; +use crate::events::{ContributorRefunded, EventCancelled, OwnerResidualRefunded}; + +#[test] +fn cancel_before_selection_returns_the_budget_but_not_the_fee() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2)); + g.events.start_cancel(&id, &g.op()); + g.capture(); + + assert_eq!(g.status(id), EventStatus::Cancelled); + assert_eq!(g.balance(&g.owner), usdc(1_000)); + assert_eq!(g.balance(&g.fee_account), usdc(25)); + assert!(g.emitted(OwnerResidualRefunded { + event_id: id, + owner: g.owner.clone(), + amount: usdc(1_000), + })); + assert!(g.emitted(EventCancelled { id })); + g.books(); +} + +#[test] +fn partners_are_refunded_in_full_before_the_owner() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2)); + let (p1, p2) = (g.someone(), g.someone()); + g.contribute(id, &p1, usdc(200)); + g.contribute(id, &p2, usdc(300)); + + g.events.start_cancel(&id, &g.op()); + assert_eq!(g.status(id), EventStatus::Cancelling); + assert_eq!(g.owed(id), 0); + + let left = g + .events + .process_cancel_batch(&id, &MAX_REFUNDS_PER_BATCH, &g.op()); + assert_eq!(left, 0); + assert!(g.emitted(ContributorRefunded { + event_id: id, + contributor: p2.clone(), + amount: usdc(300), + })); + g.events.finalize_cancel(&id, &g.op()); + + assert_eq!(g.balance(&p1), usdc(200)); + assert_eq!(g.balance(&p2), usdc(300)); + assert_eq!(g.balance(&g.owner), usdc(1_000)); + assert_eq!(g.status(id), EventStatus::Cancelled); + assert_eq!(g.escrow_balance(), 0); + g.books(); +} + +#[test] +fn after_payouts_partners_share_what_is_left_pro_rata() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2)); + let (p1, p2) = (g.someone(), g.someone()); + g.contribute(id, &p1, usdc(300)); + g.contribute(id, &p2, usdc(200)); + + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(1_400))]); + g.pay(id, &a, 0, usdc(700)); + g.pay(id, &a, 1, usdc(700)); + // 100 left against 500 of partner money + g.cancel(id); + + assert_eq!(g.balance(&p1), usdc(60)); + assert_eq!(g.balance(&p2), usdc(40)); + assert_eq!(g.balance(&g.owner), 0); + assert_eq!(g.escrow_balance(), 0); +} + +#[test] +fn pro_rata_rounding_dust_stays_in_the_contract() { + let g = setup(); + let id = g.create(Spec::new(usdc(100), 1)); + let partners = [g.someone(), g.someone(), g.someone()]; + for p in partners.iter() { + g.contribute(id, p, usdc(10)); + } + let a = g.someone(); + // leave 20 USDC + 2 stroops against 30 USDC of partner money + let award = usdc(110) - 2; + g.select(id, &[(a.clone(), 1, award)]); + g.pay(id, &a, 0, award); + g.cancel(id); + + let each = usdc(10) * (usdc(20) + 2) / usdc(30); + for p in partners.iter() { + assert_eq!(g.balance(p), each); + } + let dust = usdc(20) + 2 - 3 * each; + assert!(dust > 0); + assert_eq!(g.escrow_balance(), dust); +} + +#[test] +fn many_partners_are_refunded_across_batches() { + let g = setup(); + let id = g.create(Spec::new(usdc(100), 1)); + let partners: std::vec::Vec
= (0..30).map(|_| g.someone()).collect(); + for p in partners.iter() { + g.contribute(id, p, usdc(10)); + } + g.events.start_cancel(&id, &g.op()); + + fails_with( + g.events.try_finalize_cancel(&id, &g.op()), + Error::CancellationNotFinished, + ); + assert_eq!( + g.events + .process_cancel_batch(&id, &MAX_REFUNDS_PER_BATCH, &g.op()), + 30 - MAX_REFUNDS_PER_BATCH + ); + // asking for more than the cap processes the cap + assert_eq!(g.events.process_cancel_batch(&id, &100, &g.op()), 0); + g.events.finalize_cancel(&id, &g.op()); + + for p in partners.iter() { + assert_eq!(g.balance(p), usdc(10)); + } + assert_eq!(g.balance(&g.owner), usdc(100)); + assert_eq!(g.escrow_balance(), 0); +} + +#[test] +fn nothing_is_paid_while_cancelling_or_after() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2)); + let a = g.someone(); + g.contribute(id, &g.someone(), usdc(10)); + g.select(id, &[(a.clone(), 1, usdc(1_000))]); + g.pay(id, &a, 0, usdc(500)); + g.forfeit(id, &a, 1, usdc(500)); + + g.events.start_cancel(&id, &g.op()); + fails_with( + g.events.try_claim_milestone(&id, &a, &1, &0, &g.op()), + Error::EventNotActive, + ); + g.finish_cancel(id); + fails_with( + g.events.try_claim_milestone(&id, &a, &1, &0, &g.op()), + Error::EventNotActive, + ); + assert_eq!(g.balance(&a), usdc(500)); +} + +#[test] +fn a_completed_grant_cannot_be_cancelled() { + let g = setup(); + let id = g.create(Spec::new(usdc(100), 1)); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(100))]); + g.pay(id, &a, 0, usdc(100)); + fails_with( + g.events.try_start_cancel(&id, &g.op()), + Error::EventNotActive, + ); +} + +#[test] +fn cancel_starts_once() { + let g = setup(); + let id = g.create(Spec::new(usdc(100), 1)); + g.contribute(id, &g.someone(), usdc(10)); + g.events.start_cancel(&id, &g.op()); + fails_with( + g.events.try_start_cancel(&id, &g.op()), + Error::EventNotActive, + ); +} + +#[test] +fn crank_and_finalize_need_a_cancel_in_progress() { + let g = setup(); + let id = g.create(Spec::new(usdc(100), 1)); + fails_with( + g.events.try_process_cancel_batch(&id, &25, &g.op()), + Error::CancellationNotStarted, + ); + fails_with( + g.events.try_finalize_cancel(&id, &g.op()), + Error::CancellationNotStarted, + ); +} + +#[test] +fn residual_return_after_every_award_is_paid() { + let g = setup(); + let id = g.create(Spec::new(usdc(20_000), 2).floors(&[(1, usdc(5_000)), (2, usdc(5_000))])); + let (a, b) = (g.someone(), g.someone()); + g.select( + id, + &[(a.clone(), 1, usdc(6_000)), (b.clone(), 2, usdc(5_000))], + ); + for m in 0..2 { + g.pay(id, &a, m, usdc(3_000)); + g.pay(id, &b, m, usdc(2_500)); + } + assert_eq!(g.status(id), EventStatus::Active); + g.cancel(id); + assert_eq!(g.balance(&g.owner), usdc(9_000)); +} diff --git a/contracts/events/src/tests/grant_scenarios/claims.rs b/contracts/events/src/tests/grant_scenarios/claims.rs new file mode 100644 index 0000000..b04c671 --- /dev/null +++ b/contracts/events/src/tests/grant_scenarios/claims.rs @@ -0,0 +1,274 @@ +use soroban_sdk::testutils::Ledger as _; + +use super::*; +use crate::events::MilestoneClaimed; + +#[test] +fn even_split_pays_equal_parts_and_completes() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2)); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(1_000))]); + + g.pay(id, &a, 0, usdc(500)); + assert_eq!(g.status(id), EventStatus::Active); + assert!(g.emitted(MilestoneClaimed { + event_id: id, + recipient: a.clone(), + milestone: 0, + amount: usdc(500), + })); + + g.pay(id, &a, 1, usdc(500)); + assert_eq!(g.status(id), EventStatus::Completed); + assert_eq!(g.balance(&a), usdc(1_000)); + assert_eq!(g.escrow_balance(), 0); +} + +#[test] +fn one_milestone_pays_the_whole_award_at_once() { + let g = setup(); + let id = g.create(Spec::new(usdc(200), 1)); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(200))]); + g.pay(id, &a, 0, usdc(200)); + assert_eq!(g.status(id), EventStatus::Completed); +} + +#[test] +fn rounding_remainder_goes_to_the_last_payment() { + let g = setup(); + // 100 USDC + 1 stroop over 3 milestones: 333333336, 333333336, 333333337... floored + let award = usdc(100) + 1; + let id = g.create(Spec::new(award, 3)); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, award)]); + + let part = award / 3; + g.pay(id, &a, 0, part); + g.pay(id, &a, 1, part); + g.pay(id, &a, 2, award - 2 * part); + assert_eq!(g.balance(&a), award); + assert_eq!(g.status(id), EventStatus::Completed); +} + +#[test] +fn last_payment_is_decided_by_count_not_index() { + let g = setup(); + let award = usdc(10) + 2; + let id = g.create(Spec::new(award, 3)); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, award)]); + + let part = award / 3; + g.pay(id, &a, 2, part); + g.pay(id, &a, 0, part); + g.pay(id, &a, 1, award - 2 * part); + assert_eq!(g.balance(&a), award); +} + +#[test] +fn ten_milestones_with_an_awkward_amount_pay_exactly() { + let g = setup(); + let award = usdc(6_000) + 7; + let id = g.create(Spec::new(award, 10)); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, award)]); + let part = award / 10; + for m in 0..9 { + g.pay(id, &a, m, part); + } + g.pay(id, &a, 9, award - 9 * part); + assert_eq!(g.balance(&a), award); + assert_eq!(g.status(id), EventStatus::Completed); +} + +#[test] +fn tiered_recipients_are_paid_independently_and_interleaved() { + let g = setup(); + let id = g.create(Spec::new(usdc(25_000), 3).floors(&[ + (1, usdc(12_000)), + (2, usdc(8_000)), + (3, usdc(5_000)), + ])); + let (a, b, c) = (g.someone(), g.someone(), g.someone()); + g.select( + id, + &[ + (a.clone(), 1, usdc(12_000)), + (b.clone(), 2, usdc(8_000)), + (c.clone(), 3, usdc(5_000)), + ], + ); + + g.pay(id, &b, 0, usdc(8_000) / 3); + g.pay(id, &a, 0, usdc(4_000)); + g.pay(id, &c, 1, usdc(5_000) / 3); + g.pay(id, &b, 2, usdc(8_000) / 3); + g.pay(id, &a, 1, usdc(4_000)); + g.pay(id, &c, 0, usdc(5_000) / 3); + g.pay(id, &a, 2, usdc(4_000)); + assert_eq!(g.status(id), EventStatus::Active); + g.pay(id, &c, 2, usdc(5_000) - 2 * (usdc(5_000) / 3)); + g.pay(id, &b, 1, usdc(8_000) - 2 * (usdc(8_000) / 3)); + + assert_eq!(g.balance(&a), usdc(12_000)); + assert_eq!(g.balance(&b), usdc(8_000)); + assert_eq!(g.balance(&c), usdc(5_000)); + assert_eq!(g.status(id), EventStatus::Completed); + assert_eq!(g.escrow_balance(), 0); +} + +#[test] +fn every_payment_is_recorded_as_a_paid_row() { + let g = setup(); + let id = g.create(Spec::new(usdc(900), 3)); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(900))]); + g.env.ledger().with_mut(|l| l.timestamp = 1_000); + g.pay(id, &a, 1, usdc(300)); + + assert_eq!(g.events.get_winner_count(&id), 2); + let paid = g.events.get_winner_at(&id, &1).unwrap(); + assert_eq!(paid.recipient, a); + assert_eq!(paid.position, 1); + assert_eq!(paid.amount, usdc(300)); + assert_eq!(paid.milestone, Some(1)); + assert_eq!(paid.paid_at, Some(1_000)); +} + +#[test] +fn payouts_register_earnings_and_reputation_on_the_profile() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2)); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(1_000))]); + g.events.claim_milestone(&id, &a, &0, &10, &g.op()); + g.events.claim_milestone(&id, &a, &1, &10, &g.op()); + + let profile = g.profile.get_profile(&a).unwrap(); + assert_eq!(profile.reputation, 20); + assert_eq!(g.profile.get_earnings(&a, &g.token.address), usdc(1_000)); +} + +#[test] +fn the_same_milestone_cannot_be_paid_twice() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2)); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(1_000))]); + g.pay(id, &a, 0, usdc(500)); + fails_with( + g.events.try_claim_milestone(&id, &a, &0, &0, &g.op()), + Error::MilestoneAlreadyClaimed, + ); + assert_eq!(g.balance(&a), usdc(500)); +} + +#[test] +fn milestone_index_must_be_in_range() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2)); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(1_000))]); + for m in [2, u32::MAX] { + fails_with( + g.events.try_claim_milestone(&id, &a, &m, &0, &g.op()), + Error::InvalidMilestone, + ); + } +} + +#[test] +fn only_a_selected_recipient_can_be_paid() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2)); + let a = g.someone(); + + fails_with( + g.events.try_claim_milestone(&id, &a, &0, &0, &g.op()), + Error::NoSubmissions, + ); + + g.select(id, &[(a, 1, usdc(1_000))]); + fails_with( + g.events + .try_claim_milestone(&id, &g.someone(), &0, &0, &g.op()), + Error::NoSubmissions, + ); + assert_eq!(g.owed(id), usdc(1_000)); +} + +#[test] +fn claim_op_id_cannot_be_replayed() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2)); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(1_000))]); + let op = g.op(); + g.events.claim_milestone(&id, &a, &0, &0, &op); + fails_with( + g.events.try_claim_milestone(&id, &a, &1, &0, &op), + Error::OpAlreadySeen, + ); +} + +#[test] +fn nothing_can_be_paid_after_completion() { + let g = setup(); + let id = g.create(Spec::new(usdc(100), 1)); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(100))]); + g.pay(id, &a, 0, usdc(100)); + fails_with( + g.events.try_claim_milestone(&id, &a, &0, &0, &g.op()), + Error::EventNotActive, + ); +} + +#[test] +fn claim_prize_is_not_a_grant_path() { + let g = setup(); + let id = g.create(Spec::new(usdc(100), 1)); + let a = g.someone(); + g.select(id, &[(a, 1, usdc(100))]); + fails_with( + g.events.try_claim_prize(&id, &1, &g.op()), + Error::InvalidReleaseKind, + ); +} + +#[test] +fn unknown_grant_is_refused() { + let g = setup(); + fails_with( + g.events + .try_claim_milestone(&404, &g.someone(), &0, &0, &g.op()), + Error::EventNotFound, + ); +} + +#[test] +fn a_rejected_milestone_is_forfeited_and_comes_back_on_close() { + let g = setup(); + let award = usdc(10) + 1; + let id = g.create(Spec::new(award, 3)); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, award)]); + + let part = award / 3; + g.pay(id, &a, 0, part); + g.forfeit(id, &a, 1, part); + // the last milestone settled still takes the rounding remainder + g.pay(id, &a, 2, award - 2 * part); + assert_eq!(g.owed(id), 0); + assert_eq!( + g.status(id), + EventStatus::Active, + "the forfeited share is still held" + ); + + g.cancel(id); + assert_eq!(g.balance(&g.owner), part); + assert_eq!(g.balance(&a), award - part); +} diff --git a/contracts/events/src/tests/grant_scenarios/contributions.rs b/contracts/events/src/tests/grant_scenarios/contributions.rs new file mode 100644 index 0000000..c00b766 --- /dev/null +++ b/contracts/events/src/tests/grant_scenarios/contributions.rs @@ -0,0 +1,180 @@ +use super::*; +use crate::events::FundsAdded; + +#[test] +fn partner_money_before_selection_can_fund_bigger_awards() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2).floors(&[(1, usdc(500))])); + let partner = g.someone(); + g.contribute(id, &partner, usdc(500)); + assert!(g.emitted(FundsAdded { + event_id: id, + contributor: partner.clone(), + amount: usdc(500), + new_remaining: usdc(1_500), + })); + + let (a, b) = (g.someone(), g.someone()); + g.select(id, &[(a.clone(), 1, usdc(900)), (b.clone(), 2, usdc(600))]); + g.pay(id, &a, 0, usdc(450)); + g.pay(id, &a, 1, usdc(450)); + g.pay(id, &b, 0, usdc(300)); + g.pay(id, &b, 1, usdc(300)); + assert_eq!(g.status(id), EventStatus::Completed); + assert_eq!(g.escrow_balance(), 0); +} + +#[test] +fn partners_are_tracked_and_the_owner_is_not() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 1)); + let (p1, p2) = (g.someone(), g.someone()); + g.contribute(id, &p1, usdc(100)); + g.contribute(id, &p2, usdc(50)); + g.contribute(id, &p1, usdc(25)); + g.contribute(id, &g.owner, usdc(10)); + + assert_eq!(g.events.get_contributor_count(&id), 2); + assert_eq!(g.events.get_contributor_amount(&id, &p1), usdc(125)); + assert_eq!(g.events.get_contributor_amount(&id, &p2), usdc(50)); + assert_eq!(g.events.get_contributor_amount(&id, &g.owner), 0); + assert_eq!(g.event(id).remaining_escrow, usdc(1_185)); + assert_eq!( + g.event(id).total_budget, + usdc(1_000), + "budget is the publish amount" + ); +} + +#[test] +fn contributions_below_ten_usdc_are_refused() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 1)); + let partner = g.someone(); + g.token_admin.mint(&partner, &usdc(100)); + fails_with( + g.events + .try_add_funds(&id, &partner, &(usdc(10) - 1), &g.op()), + Error::BelowMinimumContribution, + ); + for amount in [0, -1] { + fails_with( + g.events.try_add_funds(&id, &partner, &amount, &g.op()), + Error::InvalidContributionAmount, + ); + } + g.contribute(id, &partner, usdc(10)); +} + +#[test] +fn money_added_after_selection_cannot_be_awarded_and_comes_back_first() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2)); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(1_000))]); + + let partner = g.someone(); + g.contribute(id, &partner, usdc(300)); + g.pay(id, &a, 0, usdc(500)); + g.pay(id, &a, 1, usdc(500)); + + assert_eq!( + g.status(id), + EventStatus::Active, + "the top-up keeps it open" + ); + assert_eq!(g.event(id).remaining_escrow, usdc(300)); + assert_eq!(g.owed(id), 0); + + g.cancel(id); + assert_eq!(g.balance(&partner), usdc(300)); + assert_eq!(g.balance(&g.owner), 0); +} + +#[test] +fn no_contributions_once_the_grant_is_closed() { + let g = setup(); + let partner = g.someone(); + g.token_admin.mint(&partner, &usdc(1_000)); + + let done = g.create(Spec::new(usdc(100), 1)); + let a = g.someone(); + g.select(done, &[(a.clone(), 1, usdc(100))]); + g.pay(done, &a, 0, usdc(100)); + fails_with( + g.events.try_add_funds(&done, &partner, &usdc(10), &g.op()), + Error::EventNotActive, + ); + + let cancelled = g.create(Spec::new(usdc(100), 1)); + g.cancel(cancelled); + fails_with( + g.events + .try_add_funds(&cancelled, &partner, &usdc(10), &g.op()), + Error::EventNotActive, + ); + + let cancelling = g.create(Spec::new(usdc(100), 1)); + g.contribute(cancelling, &g.someone(), usdc(10)); + g.events.start_cancel(&cancelling, &g.op()); + assert_eq!(g.status(cancelling), EventStatus::Cancelling); + fails_with( + g.events + .try_add_funds(&cancelling, &partner, &usdc(10), &g.op()), + Error::EventNotActive, + ); + + fails_with( + g.events.try_add_funds(&777, &partner, &usdc(10), &g.op()), + Error::EventNotFound, + ); +} + +#[test] +fn fee_override_applies_to_top_ups() { + let g = setup(); + let free = g.create(Spec::new(usdc(1_000), 1).fee_override(0)); + let fee_before = g.balance(&g.fee_account); + g.contribute(free, &g.someone(), usdc(100)); + assert_eq!(g.balance(&g.fee_account), fee_before); +} + +#[test] +fn top_ups_follow_the_current_global_fee() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 1)); + g.events.set_fee_bps(&500); + let fee_before = g.balance(&g.fee_account); + g.contribute(id, &g.someone(), usdc(100)); + assert_eq!(g.balance(&g.fee_account) - fee_before, usdc(5)); +} + +#[test] +fn top_up_op_id_cannot_be_replayed() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 1)); + let partner = g.someone(); + g.token_admin.mint(&partner, &usdc(100)); + let op = g.op(); + g.events.add_funds(&id, &partner, &usdc(10), &op); + fails_with( + g.events.try_add_funds(&id, &partner, &usdc(10), &op), + Error::OpAlreadySeen, + ); +} + +#[test] +fn a_partner_without_the_funds_moves_nothing() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 1)); + let partner = g.someone(); + g.token_admin.mint(&partner, &usdc(10)); + // the fee on top is missing + assert!(g + .events + .try_add_funds(&id, &partner, &usdc(10), &g.op()) + .is_err()); + assert_eq!(g.balance(&partner), usdc(10)); + assert_eq!(g.events.get_contributor_count(&id), 0); + assert_eq!(g.event(id).remaining_escrow, usdc(1_000)); +} diff --git a/contracts/events/src/tests/grant_scenarios/create.rs b/contracts/events/src/tests/grant_scenarios/create.rs new file mode 100644 index 0000000..1561dbe --- /dev/null +++ b/contracts/events/src/tests/grant_scenarios/create.rs @@ -0,0 +1,202 @@ +use soroban_sdk::{testutils::Address as _, Address, Map, String}; + +use super::*; +use crate::events::EventCreated; + +#[test] +fn publish_escrows_the_budget_and_charges_the_fee_on_top() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2)); + + let event = g.event(id); + assert_eq!(event.pillar, Pillar::Grant); + assert_eq!(event.release_kind, ReleaseKind::Multi(2)); + assert_eq!(event.total_budget, usdc(1_000)); + assert_eq!(g.balance(&g.fee_account), usdc(25)); + assert_eq!(g.owed(id), 0); + assert!(g.emitted(EventCreated { + id, + pillar: Pillar::Grant, + owner: g.owner.clone(), + token: g.token.address.clone(), + total_budget: usdc(1_000), + content_uri: String::from_str(&g.env, "https://api.boundless.fi/grants/x/content"), + title: String::from_str(&g.env, "Community grants"), + })); +} + +#[test] +fn every_preset_shape_publishes() { + let g = setup(); + // micro: 5 x 200, one milestone + g.create(Spec::new(usdc(1_000), 1).floors(&[ + (1, usdc(200)), + (2, usdc(200)), + (3, usdc(200)), + (4, usdc(200)), + (5, usdc(200)), + ])); + // builder: tiered, three milestones + g.create(Spec::new(usdc(25_000), 3).floors(&[ + (1, usdc(12_000)), + (2, usdc(8_000)), + (3, usdc(5_000)), + ])); + // infrastructure: one award, five milestones + g.create(Spec::new(usdc(100_000), 5)); + // research: floors below the budget leave headroom + g.create(Spec::new(usdc(20_000), 2).floors(&[(1, usdc(5_000)), (2, usdc(5_000))])); + // backend clamp: ten milestones + g.create(Spec::new(usdc(6_000), 10)); +} + +#[test] +fn fee_override_applies_to_the_grant_only() { + let g = setup(); + g.create(Spec::new(usdc(1_000), 2).fee_override(0)); + assert_eq!(g.balance(&g.fee_account), 0); + + g.create(Spec::new(usdc(1_000), 2).fee_override(1_000)); + assert_eq!(g.balance(&g.fee_account), usdc(100)); +} + +#[test] +fn fee_override_above_the_cap_is_refused() { + let g = setup(); + let spec = Spec::new(usdc(1_000), 2).fee_override(1_001); + g.token_admin.mint(&g.owner, &usdc(2_000)); + fails_with( + g.events.try_create_event(&g.params(&spec), &g.op()), + Error::InvalidFeeBps, + ); +} + +#[test] +fn single_release_is_not_a_grant() { + let g = setup(); + g.token_admin.mint(&g.owner, &usdc(2_000)); + let mut params = g.params(&Spec::new(usdc(1_000), 2)); + params.release_kind = ReleaseKind::Single; + fails_with( + g.events.try_create_event(¶ms, &g.op()), + Error::InvalidReleaseKind, + ); +} + +#[test] +fn zero_milestones_is_refused() { + let g = setup(); + g.token_admin.mint(&g.owner, &usdc(2_000)); + fails_with( + g.events + .try_create_event(&g.params(&Spec::new(usdc(1_000), 0)), &g.op()), + Error::InvalidReleaseKind, + ); +} + +#[test] +fn budget_must_be_positive() { + let g = setup(); + for budget in [0, -1] { + let mut params = g.params(&Spec::new(usdc(1_000), 1)); + params.total_budget = budget; + fails_with( + g.events.try_create_event(¶ms, &g.op()), + Error::InvalidBudget, + ); + } +} + +#[test] +fn floors_must_be_present_positive_and_within_budget() { + let g = setup(); + g.token_admin.mint(&g.owner, &usdc(10_000)); + + let mut empty = g.params(&Spec::new(usdc(1_000), 1)); + empty.prize_floors = Map::new(&g.env); + fails_with( + g.events.try_create_event(&empty, &g.op()), + Error::InvalidDistribution, + ); + + let zero = g.params(&Spec::new(usdc(1_000), 1).floors(&[(1, 0)])); + fails_with( + g.events.try_create_event(&zero, &g.op()), + Error::InvalidDistribution, + ); + + let over = g.params(&Spec::new(usdc(1_000), 1).floors(&[(1, usdc(600)), (2, usdc(401))])); + fails_with( + g.events.try_create_event(&over, &g.op()), + Error::DistributionMismatch, + ); + + let exact = g.params(&Spec::new(usdc(1_000), 1).floors(&[(1, usdc(600)), (2, usdc(400))])); + assert!(g.events.try_create_event(&exact, &g.op()).is_ok()); +} + +#[test] +fn unlisted_token_is_refused() { + let g = setup(); + let other = g + .env + .register_stellar_asset_contract_v2(Address::generate(&g.env)); + let mut params = g.params(&Spec::new(usdc(1_000), 1)); + params.token = other.address(); + fails_with( + g.events.try_create_event(¶ms, &g.op()), + Error::TokenNotSupported, + ); +} + +#[test] +fn title_over_120_characters_is_refused() { + let g = setup(); + let mut params = g.params(&Spec::new(usdc(1_000), 1)); + params.title = String::from_str(&g.env, &"x".repeat(121)); + fails_with( + g.events.try_create_event(¶ms, &g.op()), + Error::TitleTooLong, + ); +} + +#[test] +fn owner_without_funds_cannot_publish() { + let g = setup(); + g.token_admin.mint(&g.owner, &usdc(1_000)); + // budget is there, the fee on top is not + assert!(g + .events + .try_create_event(&g.params(&Spec::new(usdc(1_000), 1)), &g.op()) + .is_err()); + assert_eq!(g.balance(&g.owner), usdc(1_000)); + assert_eq!(g.escrow_balance(), 0); +} + +#[test] +fn publish_op_id_cannot_be_replayed() { + let g = setup(); + g.token_admin.mint(&g.owner, &usdc(5_000)); + let op = g.op(); + let params = g.params(&Spec::new(usdc(1_000), 1)); + g.events.create_event(¶ms, &op); + fails_with( + g.events.try_create_event(¶ms, &op), + Error::OpAlreadySeen, + ); +} + +#[test] +fn naming_a_manager_only_proposes_it() { + let g = setup(); + let manager = g.someone(); + let id = g.create(Spec::new(usdc(1_000), 1).manager(&manager)); + + assert_eq!(g.events.get_manager(&id), g.owner); + let pending = g.events.get_pending_manager(&id).unwrap(); + assert_eq!(pending.target, manager); + + g.events.accept_manager(&id); + assert_eq!(g.events.get_manager(&id), manager); + assert!(g.events.get_pending_manager(&id).is_none()); +} diff --git a/contracts/events/src/tests/grant_scenarios/forfeits.rs b/contracts/events/src/tests/grant_scenarios/forfeits.rs new file mode 100644 index 0000000..149fa94 --- /dev/null +++ b/contracts/events/src/tests/grant_scenarios/forfeits.rs @@ -0,0 +1,209 @@ +use super::*; +use crate::events::{MilestoneForfeited, OwnerResidualRefunded}; + +#[test] +fn a_forfeit_frees_the_share_without_moving_tokens() { + let g = setup(); + let id = g.create(Spec::new(usdc(900), 3)); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(900))]); + g.forfeit(id, &a, 1, usdc(300)); + assert!(g.emitted(MilestoneForfeited { + event_id: id, + recipient: a.clone(), + milestone: 1, + amount: usdc(300), + })); + assert_eq!(g.owed(id), usdc(600)); + assert_eq!(g.event(id).remaining_escrow, usdc(900)); + assert_eq!( + g.events.get_winner_count(&id), + 1, + "no paid row for a forfeit" + ); +} + +#[test] +fn a_forfeited_last_milestone_takes_the_remainder_with_it() { + let g = setup(); + let award = usdc(10) + 2; + let id = g.create(Spec::new(award, 3)); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, award)]); + let part = award / 3; + g.pay(id, &a, 0, part); + g.pay(id, &a, 2, part); + g.forfeit(id, &a, 1, award - 2 * part); + assert_eq!(g.owed(id), 0); + g.cancel(id); + assert_eq!(g.balance(&g.owner), award - 2 * part); + assert!(g.emitted(OwnerResidualRefunded { + event_id: id, + owner: g.owner.clone(), + amount: award - 2 * part, + })); +} + +#[test] +fn a_migration_pass_after_a_forfeit_keeps_it_released() { + let g = setup(); + let id = g.create(Spec::new(usdc(900), 3)); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(900))]); + g.forfeit(id, &a, 0, usdc(300)); + + // Every upgrade pages migrate_events over the events created since the + // last stamp, so a live grant is always in its path. + assert_eq!(g.events.migrate_events(&8_u32), 0); + assert_eq!(g.owed(id), usdc(600)); + + g.pay(id, &a, 1, usdc(300)); + g.pay(id, &a, 2, usdc(300)); + assert_eq!(g.owed(id), 0); + g.cancel(id); + assert_eq!(g.balance(&g.owner), usdc(300)); +} + +#[test] +fn ending_a_whole_award_returns_it_on_close_while_others_continue() { + let g = setup(); + let id = g.create(Spec::new(usdc(2_000), 2).floors(&[(1, usdc(1_000)), (2, usdc(1_000))])); + let (a, b) = (g.someone(), g.someone()); + g.select( + id, + &[(a.clone(), 1, usdc(1_000)), (b.clone(), 2, usdc(1_000))], + ); + + g.forfeit(id, &a, 0, usdc(500)); + g.forfeit(id, &a, 1, usdc(500)); + fails_with( + g.events.try_start_cancel(&id, &g.op()), + Error::AwardsOutstanding, + ); + g.pay(id, &b, 0, usdc(500)); + g.pay(id, &b, 1, usdc(500)); + g.cancel(id); + assert_eq!(g.balance(&b), usdc(1_000)); + assert_eq!(g.balance(&a), 0); + assert_eq!(g.balance(&g.owner), usdc(1_000)); +} + +#[test] +fn a_settled_milestone_cannot_be_settled_again_either_way() { + let g = setup(); + let id = g.create(Spec::new(usdc(900), 3)); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(900))]); + g.pay(id, &a, 0, usdc(300)); + g.forfeit(id, &a, 1, usdc(300)); + + fails_with( + g.events.try_forfeit_milestone(&id, &a, &0, &g.op()), + Error::MilestoneAlreadyClaimed, + ); + fails_with( + g.events.try_claim_milestone(&id, &a, &1, &0, &g.op()), + Error::MilestoneAlreadyClaimed, + ); + fails_with( + g.events.try_forfeit_milestone(&id, &a, &1, &g.op()), + Error::MilestoneAlreadyClaimed, + ); +} + +#[test] +fn forfeits_are_signed_by_the_owner_alone() { + let g = setup(); + let manager = g.someone(); + let id = g.create(Spec::new(usdc(900), 3).manager(&manager)); + g.events.accept_manager(&id); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(900))]); + + for signer in [a.clone(), manager.clone(), g.admin.clone()] { + let op = g.op(); + g.sign_only( + &signer, + "forfeit_milestone", + args(&g, (id, a.clone(), 0u32, op.clone())), + ); + fails_auth(g.events.try_forfeit_milestone(&id, &a, &0, &op)); + } + let op = g.op(); + g.sign_only( + &g.owner, + "forfeit_milestone", + args(&g, (id, a.clone(), 0u32, op.clone())), + ); + g.events.forfeit_milestone(&id, &a, &0, &op); + g.restore_auth(); + assert_eq!(g.owed(id), usdc(600)); +} + +#[test] +fn forfeits_check_the_same_things_releases_do() { + let g = setup(); + let id = g.create(Spec::new(usdc(900), 3)); + let a = g.someone(); + fails_with( + g.events.try_forfeit_milestone(&id, &a, &0, &g.op()), + Error::NoSubmissions, + ); + g.select(id, &[(a.clone(), 1, usdc(900))]); + fails_with( + g.events.try_forfeit_milestone(&id, &a, &3, &g.op()), + Error::InvalidMilestone, + ); + fails_with( + g.events.try_forfeit_milestone(&99, &a, &0, &g.op()), + Error::EventNotFound, + ); + let op = g.op(); + g.events.forfeit_milestone(&id, &a, &0, &op); + fails_with( + g.events.try_forfeit_milestone(&id, &a, &1, &op), + Error::OpAlreadySeen, + ); + g.events.pause(); + fails_with( + g.events.try_forfeit_milestone(&id, &a, &1, &g.op()), + Error::Paused, + ); +} + +#[test] +fn only_grants_forfeit() { + let g = setup(); + g.token_admin.mint(&g.owner, &usdc(1_000)); + let mut params = g.params(&Spec::new(usdc(100), 1)); + params.pillar = Pillar::Hackathon; + params.release_kind = ReleaseKind::Single; + let hackathon = g.events.create_event(¶ms, &g.op()); + fails_with( + g.events + .try_forfeit_milestone(&hackathon, &g.someone(), &0, &g.op()), + Error::InvalidPillar, + ); + + let mut params = g.params(&Spec::new(usdc(100), 2)); + params.pillar = Pillar::Crowdfunding; + let campaign = g.events.create_event(¶ms, &g.op()); + fails_with( + g.events + .try_forfeit_milestone(&campaign, &g.owner, &0, &g.op()), + Error::InvalidPillar, + ); +} + +#[test] +fn nothing_settles_once_the_grant_is_closed() { + let g = setup(); + let id = g.create(Spec::new(usdc(100), 1)); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(100))]); + g.pay(id, &a, 0, usdc(100)); + fails_with( + g.events.try_forfeit_milestone(&id, &a, &0, &g.op()), + Error::EventNotActive, + ); +} diff --git a/contracts/events/src/tests/grant_scenarios/hardening.rs b/contracts/events/src/tests/grant_scenarios/hardening.rs new file mode 100644 index 0000000..f5abea2 --- /dev/null +++ b/contracts/events/src/tests/grant_scenarios/hardening.rs @@ -0,0 +1,358 @@ +//! Threat-model findings on the grant path. Each test states the behaviour +//! the fix guarantees; the mapping to findings lives in docs/threat-model.md. + +use super::*; +use crate::event_ops::MAX_REPUTATION_BUMP; +use crate::events::{ManagerProposed, PendingManagerCancelled}; + +#[test] +fn one_recipient_cannot_hold_two_awards_in_a_grant() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2).floors(&[(1, usdc(300)), (2, usdc(400))])); + let a = g.someone(); + fails_with( + g.events.try_select_winners( + &id, + &g.specs(&[(a.clone(), 1, usdc(300)), (a.clone(), 2, usdc(400))]), + &g.op(), + ), + Error::DuplicateRecipient, + ); + assert_eq!(g.owed(id), 0); + assert_eq!(g.events.get_winner_count(&id), 0); +} + +#[test] +fn an_award_too_small_to_split_is_refused() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 10).floors(&[(1, 1)])); + fails_with( + g.events + .try_select_winners(&id, &g.specs(&[(g.someone(), 1, 9)]), &g.op()), + Error::InvalidDistribution, + ); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, 10)]); + for m in 0..10 { + g.pay(id, &a, m, 1); + } +} + +#[test] +fn a_release_costs_the_same_however_large_the_grant_grows() { + let g = setup(); + let n = 4u32; + let people: std::vec::Vec
= (0..crate::event_ops::MAX_GRANT_AWARDS) + .map(|_| g.someone()) + .collect(); + let id = g.create(Spec::new(usdc(4_000), n).floors(&[(1, usdc(100))])); + let awards: std::vec::Vec<(Address, u32, i128)> = people + .iter() + .enumerate() + .map(|(i, p)| (p.clone(), i as u32 + 1, usdc(100))) + .collect(); + g.select(id, &awards); + + let mut first = None; + let mut last = None; + for m in 0..n { + for p in people.iter() { + g.events.claim_milestone(&id, p, &m, &0, &g.op()); + let reads = g.env.cost_estimate().resources().memory_read_entries; + first.get_or_insert(reads); + last = Some(reads); + } + } + let (first, last) = (first.unwrap(), last.unwrap()); + assert!( + last <= first + 2, + "the 160th release read {last} entries against {first} for the first" + ); + assert!( + last < 45, + "a release must stay far inside the read limit: {last}" + ); + assert_eq!(g.status(id), EventStatus::Completed); + for p in people.iter() { + assert_eq!(g.balance(p), usdc(100)); + } +} + +#[test] +fn a_paused_profile_does_not_hold_up_payouts() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2)); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(1_000))]); + + g.profile.pause(); + g.events.claim_milestone(&id, &a, &0, &10, &g.op()); + assert_eq!(g.balance(&a), usdc(500)); + assert!(g.profile.get_profile(&a).is_none()); + + g.profile.unpause(); + g.events.claim_milestone(&id, &a, &1, &10, &g.op()); + assert_eq!(g.balance(&a), usdc(1_000)); + assert_eq!(g.profile.get_profile(&a).unwrap().reputation, 10); + assert_eq!(g.status(id), EventStatus::Completed); +} + +#[test] +fn a_broken_profile_binding_does_not_hold_up_payouts() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 1)); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(1_000))]); + g.events.set_profile_contract(&g.someone()); + g.pay(id, &a, 0, usdc(1_000)); +} + +#[test] +fn someone_reusing_an_op_id_cannot_block_or_strip_another_payout() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2)); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(1_000))]); + + let attacker = g.someone(); + g.token_admin.mint(&attacker, &usdc(20)); + let mut params = g.params(&Spec::new(usdc(10), 1)); + params.owner = attacker.clone(); + let own = g.events.create_event(¶ms, &g.op()); + g.events + .select_winners(&own, &g.specs(&[(attacker.clone(), 1, usdc(10))]), &g.op()); + + let shared = g.op(); + g.events.claim_milestone(&own, &attacker, &0, &5, &shared); + g.events.claim_milestone(&id, &a, &0, &5, &shared); + + assert_eq!(g.balance(&a), usdc(500)); + assert_eq!(g.profile.get_profile(&a).unwrap().reputation, 5); + assert_eq!(g.profile.get_earnings(&a, &g.token.address), usdc(500)); +} + +#[test] +fn reputation_per_award_and_per_release_is_capped() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2)); + let a = g.someone(); + + let mut specs = g.specs(&[(a.clone(), 1, usdc(1_000))]); + let mut spec = specs.get(0).unwrap(); + spec.reputation_bump = MAX_REPUTATION_BUMP + 1; + specs.set(0, spec.clone()); + fails_with( + g.events.try_select_winners(&id, &specs, &g.op()), + Error::ReputationBumpTooLarge, + ); + spec.reputation_bump = MAX_REPUTATION_BUMP; + specs.set(0, spec); + g.events.select_winners(&id, &specs, &g.op()); + + fails_with( + g.events + .try_claim_milestone(&id, &a, &0, &(MAX_REPUTATION_BUMP + 1), &g.op()), + Error::ReputationBumpTooLarge, + ); + fails_with( + g.events + .try_claim_milestone(&id, &a, &0, &u32::MAX, &g.op()), + Error::ReputationBumpTooLarge, + ); + g.events + .claim_milestone(&id, &a, &0, &MAX_REPUTATION_BUMP, &g.op()); + assert_eq!( + g.profile.get_profile(&a).unwrap().reputation, + MAX_REPUTATION_BUMP as u64 + ); +} + +#[test] +fn an_empty_refund_batch_cannot_burn_the_crank_op_id() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 1)); + let partner = g.someone(); + g.contribute(id, &partner, usdc(10)); + g.events.start_cancel(&id, &g.op()); + + let op = g.op(); + fails_with( + g.events.try_process_cancel_batch(&id, &0, &op), + Error::InvalidBatchSize, + ); + assert_eq!(g.events.process_cancel_batch(&id, &25, &op), 0); + g.events.finalize_cancel(&id, &g.op()); + assert_eq!(g.balance(&partner), usdc(10)); +} + +#[test] +fn replacing_a_pending_manager_is_on_the_record() { + let g = setup(); + let first = g.someone(); + let id = g.create(Spec::new(usdc(1_000), 1).manager(&first)); + let second = g.someone(); + g.events.propose_manager(&id, &second); + + assert!(g.emitted(PendingManagerCancelled { event_id: id })); + let expires = g.events.get_pending_manager(&id).unwrap().expires_at_ledger; + assert!(g.emitted(ManagerProposed { + event_id: id, + target: second, + expires_at_ledger: expires, + })); +} + +#[test] +fn a_frozen_recipient_is_not_marked_paid_and_can_be_paid_later() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2)); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(1_000))]); + g.token_admin.set_authorized(&a, &false); + + assert!(g + .events + .try_claim_milestone(&id, &a, &0, &0, &g.op()) + .is_err()); + assert!(!g.milestone_claimed(id, &a, 0)); + assert_eq!(g.owed(id), usdc(1_000)); + assert_eq!(g.event(id).remaining_escrow, usdc(1_000)); + + g.token_admin.set_authorized(&a, &true); + g.pay(id, &a, 0, usdc(500)); +} + +#[test] +fn a_frozen_partner_cannot_stall_everyone_else_s_refund() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2)); + let (p1, frozen, p3) = (g.someone(), g.someone(), g.someone()); + g.contribute(id, &p1, usdc(100)); + g.contribute(id, &frozen, usdc(100)); + g.contribute(id, &p3, usdc(100)); + g.token_admin.set_authorized(&frozen, &false); + + g.cancel(id); + assert!(g.emitted(crate::events::RefundDeferred { + event_id: id, + contributor: frozen.clone(), + amount: usdc(100), + })); + assert_eq!(g.balance(&p1), usdc(100)); + assert_eq!(g.balance(&p3), usdc(100)); + assert_eq!(g.balance(&g.owner), usdc(1_000)); + assert_eq!(g.events.get_unclaimed_refund(&id, &frozen), usdc(100)); + assert_eq!(g.escrow_balance(), usdc(100), "held for the frozen partner"); + + assert!(g.events.try_claim_refund(&id, &frozen, &g.op()).is_err()); + g.token_admin.set_authorized(&frozen, &true); + + let op = g.op(); + g.sign_only( + &p1, + "claim_refund", + args(&g, (id, frozen.clone(), op.clone())), + ); + fails_auth(g.events.try_claim_refund(&id, &frozen, &op)); + g.restore_auth(); + + g.events.claim_refund(&id, &frozen, &g.op()); + assert_eq!(g.signers(), std::vec![frozen.clone()]); + assert_eq!(g.balance(&frozen), usdc(100)); + assert_eq!(g.escrow_balance(), 0); + fails_with( + g.events.try_claim_refund(&id, &frozen, &g.op()), + Error::NoRefundOwed, + ); + fails_with( + g.events.try_claim_refund(&id, &p1, &g.op()), + Error::NoRefundOwed, + ); +} + +#[test] +fn each_award_and_each_cancel_branch_is_on_the_record() { + let g = setup(); + let id = g.create(Spec::new(usdc(100), 1)); + let partners = [g.someone(), g.someone(), g.someone()]; + for p in partners.iter() { + g.contribute(id, p, usdc(10)); + } + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(130) - 2)]); + assert!(g.emitted(crate::events::WinnerAwarded { + event_id: id, + recipient: a.clone(), + position: 1, + amount: usdc(130) - 2, + })); + g.pay(id, &a, 0, usdc(130) - 2); + + g.events.start_cancel(&id, &g.op()); + assert!(g.emitted(crate::events::CancellationStarted { + event_id: id, + branch: crate::types::CancellationBranch::ProRataPartners, + remaining: 2, + non_owner_total: usdc(30), + })); + g.finish_cancel(id); + for p in partners.iter() { + assert!(g.emitted(crate::events::ContributorRefunded { + event_id: id, + contributor: p.clone(), + amount: 0, + })); + } + assert_eq!( + g.escrow_balance(), + 2, + "two stroops of dust, each share floored to zero" + ); +} + +#[test] +fn a_partner_who_dropped_their_trustline_cannot_stall_the_others() { + use super::refusing_token::{RefusingToken, RefusingTokenClient}; + + let g = setup(); + let token_id = g.env.register(RefusingToken, ()); + let token = RefusingTokenClient::new(&g.env, &token_id); + g.events.register_supported_token(&token_id); + + let budget = usdc(1_000); + token.mint(&g.owner, &(budget + fee_on(budget, FEE_BPS))); + let mut params = g.params(&Spec::new(budget, 2)); + params.token = token_id.clone(); + let id = g.events.create_event(¶ms, &g.op()); + + let (p1, gone, p3) = (g.someone(), g.someone(), g.someone()); + for p in [&p1, &gone, &p3] { + token.mint(p, &(usdc(100) + fee_on(usdc(100), FEE_BPS))); + g.events.add_funds(&id, p, &usdc(100), &g.op()); + } + token.refuse(&gone, &true); + + g.events.start_cancel(&id, &g.op()); + assert_eq!(g.events.process_cancel_batch(&id, &15, &g.op()), 0); + assert!(g.emitted(crate::events::RefundDeferred { + event_id: id, + contributor: gone.clone(), + amount: usdc(100), + })); + g.events.finalize_cancel(&id, &g.op()); + + assert_eq!(token.balance(&p1), usdc(100)); + assert_eq!(token.balance(&p3), usdc(100)); + assert_eq!(token.balance(&g.owner), budget); + assert_eq!(token.balance(&gone), 0); + assert_eq!(g.events.get_unclaimed_refund(&id, &gone), usdc(100)); + assert_eq!(token.balance(&g.events_id), usdc(100), "held for them"); + + assert!(g.events.try_claim_refund(&id, &gone, &g.op()).is_err()); + assert_eq!(g.events.get_unclaimed_refund(&id, &gone), usdc(100)); + + token.refuse(&gone, &false); + g.events.claim_refund(&id, &gone, &g.op()); + assert_eq!(token.balance(&gone), usdc(100)); + assert_eq!(token.balance(&g.events_id), 0); +} diff --git a/contracts/events/src/tests/grant_scenarios/lifecycle.rs b/contracts/events/src/tests/grant_scenarios/lifecycle.rs new file mode 100644 index 0000000..589ab34 --- /dev/null +++ b/contracts/events/src/tests/grant_scenarios/lifecycle.rs @@ -0,0 +1,184 @@ +//! The grant shapes the organizer wizard offers, run start to finish the way +//! the backend drives them. + +use super::*; + +#[test] +fn micro_grants_five_recipients_paid_in_one_go() { + let g = setup(); + let floors: std::vec::Vec<(u32, i128)> = (1..=5).map(|p| (p, usdc(200))).collect(); + let id = g.create(Spec::new(usdc(1_000), 1).floors(&floors)); + let people: std::vec::Vec
= (0..5).map(|_| g.someone()).collect(); + let awards: std::vec::Vec<(Address, u32, i128)> = people + .iter() + .enumerate() + .map(|(i, p)| (p.clone(), i as u32 + 1, usdc(200))) + .collect(); + g.select(id, &awards); + for p in people.iter() { + g.pay(id, p, 0, usdc(200)); + } + assert_eq!(g.status(id), EventStatus::Completed); + assert_eq!(g.escrow_balance(), 0); +} + +#[test] +fn builder_grant_with_a_rejected_milestone_and_a_returned_remainder() { + let g = setup(); + let id = g.create(Spec::new(usdc(25_000), 3).floors(&[ + (1, usdc(12_000)), + (2, usdc(8_000)), + (3, usdc(5_000)), + ])); + let (a, b, c) = (g.someone(), g.someone(), g.someone()); + g.select( + id, + &[ + (a.clone(), 1, usdc(12_000)), + (b.clone(), 2, usdc(8_000)), + (c.clone(), 3, usdc(5_000)), + ], + ); + let b_part = usdc(8_000) / 3; + let c_part = usdc(5_000) / 3; + for m in 0..3 { + g.pay(id, &a, m, usdc(4_000)); + } + g.pay(id, &b, 0, b_part); + g.pay(id, &b, 1, b_part); + g.pay(id, &b, 2, usdc(8_000) - 2 * b_part); + // c delivers the first milestone, then the organizer rejects the rest + g.pay(id, &c, 0, c_part); + g.forfeit(id, &c, 1, c_part); + g.forfeit(id, &c, 2, usdc(5_000) - 2 * c_part); + + assert_eq!(g.status(id), EventStatus::Active); + assert_eq!(g.owed(id), 0); + g.cancel(id); + assert_eq!(g.balance(&g.owner), usdc(5_000) - c_part); + assert_eq!(g.balance(&a), usdc(12_000)); + assert_eq!(g.balance(&b), usdc(8_000)); + assert_eq!(g.balance(&c), c_part); +} + +#[test] +fn infrastructure_grant_topped_up_by_a_partner_mid_way() { + let g = setup(); + let id = g.create(Spec::new(usdc(100_000), 5)); + let partner = g.someone(); + g.contribute(id, &partner, usdc(20_000)); + + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(110_000))]); + for m in 0..5 { + g.pay(id, &a, m, usdc(22_000)); + } + assert_eq!(g.balance(&a), usdc(110_000)); + assert_eq!(g.event(id).remaining_escrow, usdc(10_000)); + + g.cancel(id); + assert_eq!( + g.balance(&partner), + usdc(10_000), + "partner shares the shortfall" + ); + assert_eq!(g.balance(&g.owner), 0); +} + +#[test] +fn research_grant_awards_above_floors_from_headroom() { + let g = setup(); + let id = g.create(Spec::new(usdc(20_000), 2).floors(&[ + (1, usdc(5_000)), + (2, usdc(5_000)), + (3, usdc(5_000)), + ])); + let people = [g.someone(), g.someone(), g.someone()]; + g.select( + id, + &[ + (people[0].clone(), 1, usdc(7_000)), + (people[1].clone(), 2, usdc(6_500)), + (people[2].clone(), 3, usdc(6_500)), + ], + ); + for p in people.iter() { + let award = if *p == people[0] { + usdc(7_000) + } else { + usdc(6_500) + }; + g.pay(id, p, 1, award / 2); + g.pay(id, p, 0, award - award / 2); + } + assert_eq!(g.status(id), EventStatus::Completed); +} + +#[test] +fn org_treasury_funds_and_the_org_wallet_runs_it() { + let g = setup(); + let org_wallet = g.someone(); + let id = g.create(Spec::new(usdc(6_000), 2).floors(&[ + (1, usdc(1_500)), + (2, usdc(1_500)), + (3, usdc(1_500)), + (4, usdc(1_500)), + ])); + g.events.propose_manager(&id, &org_wallet); + g.events.accept_manager(&id); + + let people: std::vec::Vec
= (0..4).map(|_| g.someone()).collect(); + let awards: std::vec::Vec<(Address, u32, i128)> = people + .iter() + .enumerate() + .map(|(i, p)| (p.clone(), i as u32 + 1, usdc(1_500))) + .collect(); + g.events.select_winners(&id, &g.specs(&awards), &g.op()); + assert_eq!(g.signers(), std::vec![org_wallet.clone()]); + + for p in people.iter().take(3) { + g.pay(id, p, 0, usdc(750)); + g.pay(id, p, 1, usdc(750)); + } + g.pay(id, &people[3], 0, usdc(750)); + fails_with( + g.events.try_start_cancel(&id, &g.op()), + Error::AwardsOutstanding, + ); + g.forfeit(id, &people[3], 1, usdc(750)); + g.events.start_cancel(&id, &g.op()); + assert_eq!(g.signers(), std::vec![org_wallet.clone()]); + assert_eq!(g.balance(&g.owner), usdc(750)); + assert_eq!( + g.balance(&org_wallet), + 0, + "the manager never receives funds" + ); + g.books(); +} + +#[test] +fn many_grants_on_one_contract_keep_separate_books() { + let g = setup(); + let first = g.create(Spec::new(usdc(1_000), 2)); + let second = g.create(Spec::new(usdc(3_000), 3)); + let a = g.someone(); + let b = g.someone(); + g.select(first, &[(a.clone(), 1, usdc(1_000))]); + g.select(second, &[(a.clone(), 1, usdc(3_000))]); + + fails_with( + g.events.try_claim_milestone(&first, &b, &0, &0, &g.op()), + Error::NoSubmissions, + ); + g.pay(first, &a, 0, usdc(500)); + g.pay(second, &a, 0, usdc(1_000)); + g.forfeit(first, &a, 1, usdc(500)); + g.cancel(first); + assert_eq!(g.event(second).remaining_escrow, usdc(2_000)); + g.pay(second, &a, 1, usdc(1_000)); + g.pay(second, &a, 2, usdc(1_000)); + assert_eq!(g.balance(&a), usdc(3_500)); + assert_eq!(g.balance(&g.owner), usdc(500)); + assert_eq!(g.escrow_balance(), 0); +} diff --git a/contracts/events/src/tests/grant_scenarios/limits.rs b/contracts/events/src/tests/grant_scenarios/limits.rs new file mode 100644 index 0000000..d60916d --- /dev/null +++ b/contracts/events/src/tests/grant_scenarios/limits.rs @@ -0,0 +1,130 @@ +//! The test host enforces mainnet's per-transaction limits, so each scenario +//! here proves the largest call the contract accepts still fits on chain. + +use super::*; +use crate::event_ops::{ + MAX_AWARDS_PER_SELECT, MAX_GRANT_AWARDS, MAX_REFUNDS_PER_BATCH, VIEW_PAGE_LIMIT, +}; + +#[test] +fn the_largest_grant_selects_and_releases_every_milestone() { + let g = setup(); + let n = 10u32; + let people: std::vec::Vec
= (0..MAX_GRANT_AWARDS).map(|_| g.someone()).collect(); + let award = usdc(10) + 3; + let budget = award * MAX_GRANT_AWARDS as i128; + let id = g.create(Spec::new(budget, n).floors(&[(1, award)])); + let awards: std::vec::Vec<(Address, u32, i128)> = people + .iter() + .enumerate() + .map(|(i, p)| (p.clone(), i as u32 + 1, award)) + .collect(); + g.select(id, &awards); + + let mut heaviest = 0; + for m in 0..n { + for p in people.iter() { + g.events.claim_milestone(&id, p, &m, &5, &g.op()); + let r = g.env.cost_estimate().resources(); + heaviest = heaviest.max(r.memory_read_entries + r.write_entries); + } + } + assert!( + heaviest <= 60, + "heaviest release touched {heaviest} entries" + ); + for p in people.iter() { + assert_eq!(g.balance(p), award); + } + assert_eq!(g.status(id), EventStatus::Completed); + g.books(); +} + +#[test] +fn a_full_single_release_batch_fits() { + let g = setup(); + g.token_admin.mint(&g.owner, &usdc(1_000)); + let mut params = g.params(&Spec::new(usdc(100), 1).floors(&[(1, 1)])); + params.pillar = Pillar::Hackathon; + params.release_kind = ReleaseKind::Single; + let id = g.events.create_event(¶ms, &g.op()); + + let mut awards: std::vec::Vec<(Address, u32, i128)> = (1..=MAX_AWARDS_PER_SELECT + 1) + .map(|p| (g.someone(), p, usdc(1))) + .collect(); + fails_with( + g.events.try_select_winners(&id, &g.specs(&awards), &g.op()), + Error::InvalidWinnerPosition, + ); + awards.pop(); + g.events.select_winners(&id, &g.specs(&awards), &g.op()); + + let next: std::vec::Vec<(Address, u32, i128)> = (MAX_AWARDS_PER_SELECT + 1 + ..=2 * MAX_AWARDS_PER_SELECT) + .map(|p| (g.someone(), p, usdc(1))) + .collect(); + g.events.select_winners(&id, &g.specs(&next), &g.op()); + assert_eq!(g.events.get_winner_count(&id), 2 * MAX_AWARDS_PER_SELECT); +} + +#[test] +fn a_full_refund_batch_fits() { + let g = setup(); + let id = g.create(Spec::new(usdc(100), 1)); + let partners: std::vec::Vec
= + (0..MAX_REFUNDS_PER_BATCH).map(|_| g.someone()).collect(); + for p in partners.iter() { + g.contribute(id, p, usdc(10)); + } + g.events.start_cancel(&id, &g.op()); + assert_eq!( + g.events + .process_cancel_batch(&id, &MAX_REFUNDS_PER_BATCH, &g.op()), + 0 + ); + g.events.finalize_cancel(&id, &g.op()); + for p in partners.iter() { + assert_eq!(g.balance(p), usdc(10)); + } +} + +#[test] +fn a_full_page_of_winners_can_be_read() { + let g = setup(); + let people: std::vec::Vec
= (0..MAX_GRANT_AWARDS).map(|_| g.someone()).collect(); + let id = g.create(Spec::new(usdc(400), 3).floors(&[(1, usdc(10))])); + let awards: std::vec::Vec<(Address, u32, i128)> = people + .iter() + .enumerate() + .map(|(i, p)| (p.clone(), i as u32 + 1, usdc(10))) + .collect(); + g.select(id, &awards); + for m in 0..3 { + for p in people.iter() { + g.events.claim_milestone(&id, p, &m, &0, &g.op()); + } + } + let rows = g.events.get_winner_count(&id); + assert_eq!(rows, 4 * MAX_GRANT_AWARDS); + + let page = g.events.get_winners_page(&id, &0, &VIEW_PAGE_LIMIT); + assert_eq!(page.len(), VIEW_PAGE_LIMIT); + let all = g.events.get_winners(&id); + assert_eq!(all.len(), VIEW_PAGE_LIMIT); + let tail = g + .events + .get_winners_page(&id, &(rows - 5), &VIEW_PAGE_LIMIT); + assert_eq!(tail.len(), 5); +} + +#[test] +fn a_full_page_of_contributors_can_be_read() { + let g = setup(); + let id = g.create(Spec::new(usdc(100), 1)); + for _ in 0..VIEW_PAGE_LIMIT + 5 { + g.contribute(id, &g.someone(), usdc(10)); + } + let page = g.events.get_contributors_page(&id, &0, &VIEW_PAGE_LIMIT); + assert_eq!(page.len(), VIEW_PAGE_LIMIT); + assert_eq!(g.events.get_contributors(&id).len(), VIEW_PAGE_LIMIT); +} diff --git a/contracts/events/src/tests/grant_scenarios/mod.rs b/contracts/events/src/tests/grant_scenarios/mod.rs new file mode 100644 index 0000000..f7aed3c --- /dev/null +++ b/contracts/events/src/tests/grant_scenarios/mod.rs @@ -0,0 +1,469 @@ +#![cfg(test)] + +//! Grant lifecycle scenarios, end to end against a real profile contract and +//! a Stellar asset contract. Every helper that moves money asserts the exact +//! token deltas, and `books` checks the escrow invariants after a step. + +extern crate std; + +mod auth; +mod cancel; +mod claims; +mod contributions; +mod create; +mod forfeits; +mod hardening; +mod lifecycle; +mod limits; +mod pause; +mod randomized; +mod refusing_token; +mod selection; + +use soroban_sdk::{ + testutils::{ + Address as _, BytesN as _, EnvTestConfig, Events as _, IssuerFlags, MockAuth, + MockAuthInvoke, + }, + token, xdr, Address, BytesN, Env, Event, IntoVal, Map, String, Val, Vec, +}; + +use crate::errors::Error; +use crate::storage; +use crate::types::{CreateEventParams, EventRecord, EventStatus, Pillar, ReleaseKind, WinnerSpec}; +use crate::{EventsContract, EventsContractClient}; +use boundless_profile::{ProfileContract, ProfileContractClient}; + +pub const FEE_BPS: u32 = 250; +pub const USDC: i128 = 10_000_000; + +pub fn usdc(units: i128) -> i128 { + units * USDC +} + +pub fn fee_on(amount: i128, bps: u32) -> i128 { + amount * bps as i128 / 10_000 +} + +pub struct G<'a> { + pub env: Env, + pub events: EventsContractClient<'a>, + pub events_id: Address, + pub profile: ProfileContractClient<'a>, + pub admin: Address, + pub fee_account: Address, + pub token: token::Client<'a>, + pub token_admin: token::StellarAssetClient<'a>, + pub owner: Address, + pub ids: std::cell::RefCell>, + pub log: std::cell::RefCell>, +} + +pub fn setup<'a>() -> G<'a> { + let env = Env::new_with_config(EnvTestConfig { + capture_snapshot_at_drop: false, + }); + env.mock_all_auths_allowing_non_root_auth(); + + let profile_admin = Address::generate(&env); + let profile_id = env.register(ProfileContract, (profile_admin,)); + let profile = ProfileContractClient::new(&env, &profile_id); + + let admin = Address::generate(&env); + let fee_account = Address::generate(&env); + let events_id = env.register( + EventsContract, + ( + admin.clone(), + fee_account.clone(), + FEE_BPS, + profile_id.clone(), + ), + ); + let events = EventsContractClient::new(&env, &events_id); + profile.set_events_contract(&events_id); + + let sac = env.register_stellar_asset_contract_v2(Address::generate(&env)); + sac.issuer().set_flag(IssuerFlags::RevocableFlag); + let token = token::Client::new(&env, &sac.address()); + let token_admin = token::StellarAssetClient::new(&env, &sac.address()); + events.register_supported_token(&sac.address()); + + let owner = Address::generate(&env); + + G { + env, + events, + events_id, + profile, + admin, + fee_account, + token, + token_admin, + owner, + ids: std::cell::RefCell::new(std::vec::Vec::new()), + log: std::cell::RefCell::new(std::vec::Vec::new()), + } +} + +pub struct Spec { + pub budget: i128, + pub milestones: u32, + pub floors: std::vec::Vec<(u32, i128)>, + pub fee_bps_override: Option, + pub manager: Option
, +} + +impl Spec { + pub fn new(budget: i128, milestones: u32) -> Self { + Spec { + budget, + milestones, + floors: std::vec![(1, budget)], + fee_bps_override: None, + manager: None, + } + } + + pub fn floors(mut self, floors: &[(u32, i128)]) -> Self { + self.floors = floors.to_vec(); + self + } + + pub fn fee_override(mut self, bps: u32) -> Self { + self.fee_bps_override = Some(bps); + self + } + + pub fn manager(mut self, manager: &Address) -> Self { + self.manager = Some(manager.clone()); + self + } +} + +impl<'a> G<'a> { + pub fn op(&self) -> BytesN<32> { + BytesN::random(&self.env) + } + + pub fn someone(&self) -> Address { + Address::generate(&self.env) + } + + pub fn balance(&self, who: &Address) -> i128 { + self.token.balance(who) + } + + pub fn escrow_balance(&self) -> i128 { + self.token.balance(&self.events_id) + } + + pub fn params(&self, spec: &Spec) -> CreateEventParams { + let mut floors = Map::new(&self.env); + for (position, floor) in spec.floors.iter() { + floors.set(*position, *floor); + } + CreateEventParams { + pillar: Pillar::Grant, + owner: self.owner.clone(), + token: self.token.address.clone(), + total_budget: spec.budget, + release_kind: ReleaseKind::Multi(spec.milestones), + content_uri: String::from_str(&self.env, "https://api.boundless.fi/grants/x/content"), + title: String::from_str(&self.env, "Community grants"), + deadline: Some(self.env.ledger().timestamp() + 30 * 86_400), + prize_floors: floors, + fee_bps_override: spec.fee_bps_override, + manager: spec.manager.clone(), + } + } + + /// Mints exactly budget + fee to the owner, publishes, and checks that the + /// owner is drained, escrow holds the budget and the fee account the fee. + pub fn create(&self, spec: Spec) -> u64 { + let bps = spec.fee_bps_override.unwrap_or(self.events.get_fee_bps()); + let fee = fee_on(spec.budget, bps); + self.token_admin.mint(&self.owner, &(spec.budget + fee)); + let owner_before = self.balance(&self.owner); + let fee_before = self.balance(&self.fee_account); + let escrow_before = self.escrow_balance(); + + let id = self.events.create_event(&self.params(&spec), &self.op()); + self.capture(); + + assert_eq!(owner_before - self.balance(&self.owner), spec.budget + fee); + assert_eq!(self.balance(&self.fee_account) - fee_before, fee); + assert_eq!(self.escrow_balance() - escrow_before, spec.budget); + let event = self.events.get_event(&id); + assert_eq!(event.remaining_escrow, spec.budget); + assert_eq!(event.status, EventStatus::Active); + self.ids.borrow_mut().push(id); + self.books(); + id + } + + pub fn select(&self, id: u64, awards: &[(Address, u32, i128)]) { + let escrow_before = self.escrow_balance(); + let owed_before = self.owed(id); + self.events + .select_winners(&id, &self.specs(awards), &self.op()); + self.capture(); + assert_eq!( + self.escrow_balance(), + escrow_before, + "selection moves no tokens" + ); + let total: i128 = awards.iter().map(|(_, _, a)| *a).sum(); + assert_eq!(self.owed(id), owed_before + total); + self.books(); + } + + pub fn specs(&self, awards: &[(Address, u32, i128)]) -> Vec { + let mut specs = Vec::new(&self.env); + for (recipient, position, amount) in awards.iter() { + specs.push_back(WinnerSpec { + recipient: recipient.clone(), + position: *position, + amount: *amount, + reputation_bump: 0, + }); + } + specs + } + + /// Releases one milestone and asserts the recipient receives exactly + /// `expected`, with no fee and matching drops in escrow and owed. + pub fn pay(&self, id: u64, recipient: &Address, milestone: u32, expected: i128) { + let before = self.balance(recipient); + let fee_before = self.balance(&self.fee_account); + let event_before = self.events.get_event(&id); + let owed_before = self.owed(id); + + self.events + .claim_milestone(&id, recipient, &milestone, &5, &self.op()); + self.capture(); + + assert_eq!( + self.balance(recipient) - before, + expected, + "milestone payout" + ); + assert_eq!( + self.balance(&self.fee_account), + fee_before, + "grant payouts carry no fee" + ); + let event = self.events.get_event(&id); + assert_eq!( + event_before.remaining_escrow - event.remaining_escrow, + expected + ); + assert_eq!(owed_before - self.owed(id), expected); + assert!(self.milestone_claimed(id, recipient, milestone)); + self.books(); + } + + /// Forfeits one milestone: nothing moves, and its share stops being owed. + pub fn forfeit(&self, id: u64, recipient: &Address, milestone: u32, expected: i128) { + let before = self.balance(recipient); + let escrow_before = self.escrow_balance(); + let remaining_before = self.events.get_event(&id).remaining_escrow; + let owed_before = self.owed(id); + + self.events + .forfeit_milestone(&id, recipient, &milestone, &self.op()); + self.capture(); + + assert_eq!(self.balance(recipient), before, "a forfeit pays nothing"); + assert_eq!(self.escrow_balance(), escrow_before); + assert_eq!( + self.events.get_event(&id).remaining_escrow, + remaining_before + ); + assert_eq!(owed_before - self.owed(id), expected, "forfeited share"); + assert!(self.milestone_claimed(id, recipient, milestone)); + self.books(); + } + + /// Partner top-up: mints amount + fee and checks the fee lands on top. + pub fn contribute(&self, id: u64, from: &Address, amount: i128) { + let bps = self + .events + .get_event(&id) + .fee_bps_override + .unwrap_or(self.events.get_fee_bps()); + let fee = fee_on(amount, bps); + self.token_admin.mint(from, &(amount + fee)); + let from_before = self.balance(from); + let fee_before = self.balance(&self.fee_account); + let remaining_before = self.events.get_event(&id).remaining_escrow; + + self.events.add_funds(&id, from, &amount, &self.op()); + self.capture(); + + assert_eq!( + from_before - self.balance(from), + amount + fee, + "fee is on top" + ); + assert_eq!(self.balance(&self.fee_account) - fee_before, fee); + assert_eq!( + self.events.get_event(&id).remaining_escrow - remaining_before, + amount + ); + self.books(); + } + + pub fn cancel(&self, id: u64) { + self.events.start_cancel(&id, &self.op()); + self.capture(); + if self.status(id) == EventStatus::Cancelling { + self.finish_cancel(id); + } + let event = self.events.get_event(&id); + assert_eq!(event.status, EventStatus::Cancelled); + assert_eq!(event.remaining_escrow, 0); + assert_eq!(self.owed(id), 0); + self.books(); + } + + /// Cranks and finalizes a cancel that `start_cancel` left in Cancelling. + pub fn finish_cancel(&self, id: u64) { + loop { + let left = self.events.process_cancel_batch( + &id, + &crate::event_ops::MAX_REFUNDS_PER_BATCH, + &self.op(), + ); + self.capture(); + if left == 0 { + break; + } + } + self.events.finalize_cancel(&id, &self.op()); + self.capture(); + assert_eq!(self.status(id), EventStatus::Cancelled); + self.books(); + } + + pub fn event(&self, id: u64) -> EventRecord { + self.events.get_event(&id) + } + + pub fn status(&self, id: u64) -> EventStatus { + self.events.get_event(&id).status + } + + pub fn owed(&self, id: u64) -> i128 { + self.env + .as_contract(&self.events_id, || storage::owed_total(&self.env, id)) + } + + pub fn milestone_claimed(&self, id: u64, recipient: &Address, milestone: u32) -> bool { + self.env.as_contract(&self.events_id, || { + storage::is_milestone_claimed(&self.env, id, recipient, milestone) + }) + } + + /// Escrow invariants across every grant this context created: the contract + /// holds at least what the records say it holds, nothing is owed beyond + /// what remains, and a closed grant owes nothing. + pub fn books(&self) { + let mut accounted: i128 = 0; + for id in self.ids.borrow().iter() { + let event = self.events.get_event(id); + let owed = self.owed(*id); + assert!(event.remaining_escrow >= 0, "grant {id}: negative escrow"); + assert!(owed >= 0, "grant {id}: negative owed"); + assert!( + owed <= event.remaining_escrow, + "grant {id}: owes {owed} with only {} in escrow", + event.remaining_escrow + ); + if matches!( + event.status, + EventStatus::Completed | EventStatus::Cancelled + ) { + assert_eq!( + event.remaining_escrow, 0, + "grant {id}: closed with escrow left" + ); + assert_eq!(owed, 0, "grant {id}: closed while still owing"); + } + accounted += event.remaining_escrow; + } + assert!( + self.escrow_balance() >= accounted, + "contract holds {} but records account for {accounted}", + self.escrow_balance() + ); + } + + /// Only `who` signs the next call to `fn_name` with exactly `args`. + pub fn sign_only(&self, who: &Address, fn_name: &str, args: Vec) { + self.env.mock_auths(&[MockAuth { + address: who, + invoke: &MockAuthInvoke { + contract: &self.events_id, + fn_name, + args, + sub_invokes: &[], + }, + }]); + } + + pub fn restore_auth(&self) { + self.env.mock_all_auths_allowing_non_root_auth(); + } + + /// Addresses that authorized the last top-level invocation. + pub fn signers(&self) -> std::vec::Vec
{ + self.env + .auths() + .into_iter() + .map(|(address, _)| address) + .collect() + } + + /// Keeps the events of the call just made; `events().all()` only ever + /// holds the last invocation's. + pub fn capture(&self) { + let all = self.env.events().all(); + let ours = all.filter_by_contract(&self.events_id); + self.log.borrow_mut().extend(ours.events().iter().cloned()); + } + + /// Whether `event` was emitted by any captured call or the last one. + pub fn emitted(&self, event: E) -> bool { + self.capture(); + let wanted = event.to_xdr(&self.env, &self.events_id); + self.log.borrow().contains(&wanted) + } +} + +pub fn args>>(g: &G, value: T) -> Vec { + value.into_val(&g.env) +} + +/// Asserts a `try_` call failed with exactly this contract error. +#[track_caller] +pub fn fails_with( + result: Result, Result>, + expected: Error, +) { + match result { + Err(Ok(actual)) => assert_eq!(actual, expected), + other => panic!("expected {expected:?}, got {other:?}"), + } +} + +/// Asserts a `try_` call was rejected by the host's auth check. +#[track_caller] +pub fn fails_auth( + result: Result, Result>, +) { + match result { + Err(Err(_)) => {} + other => panic!("expected an auth failure, got {other:?}"), + } +} diff --git a/contracts/events/src/tests/grant_scenarios/pause.rs b/contracts/events/src/tests/grant_scenarios/pause.rs new file mode 100644 index 0000000..e30c1a2 --- /dev/null +++ b/contracts/events/src/tests/grant_scenarios/pause.rs @@ -0,0 +1,87 @@ +use super::*; + +#[test] +fn pause_stops_every_grant_operation_and_unpause_resumes_them() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2)); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(1_000))]); + g.pay(id, &a, 0, usdc(500)); + let partner = g.someone(); + g.token_admin.mint(&partner, &usdc(100)); + g.token_admin.mint(&g.owner, &usdc(2_000)); + let manager = g.someone(); + + g.events.pause(); + assert!(g.events.is_paused()); + + fails_with( + g.events + .try_create_event(&g.params(&Spec::new(usdc(1_000), 2)), &g.op()), + Error::Paused, + ); + fails_with( + g.events.try_add_funds(&id, &partner, &usdc(10), &g.op()), + Error::Paused, + ); + fails_with( + g.events + .try_select_winners(&id, &g.specs(&[(g.someone(), 2, usdc(1))]), &g.op()), + Error::Paused, + ); + fails_with( + g.events.try_claim_milestone(&id, &a, &1, &0, &g.op()), + Error::Paused, + ); + fails_with(g.events.try_start_cancel(&id, &g.op()), Error::Paused); + fails_with( + g.events.try_process_cancel_batch(&id, &25, &g.op()), + Error::Paused, + ); + fails_with(g.events.try_finalize_cancel(&id, &g.op()), Error::Paused); + fails_with(g.events.try_propose_manager(&id, &manager), Error::Paused); + fails_with(g.events.try_accept_manager(&id), Error::Paused); + fails_with(g.events.try_cancel_pending_manager(&id), Error::Paused); + + // reads keep working for the indexer and the UI + assert_eq!(g.event(id).remaining_escrow, usdc(500)); + assert_eq!(g.events.get_winner_count(&id), 2); + + g.events.unpause(); + g.pay(id, &a, 1, usdc(500)); + assert_eq!(g.status(id), EventStatus::Completed); +} + +#[test] +fn a_cancel_paused_halfway_finishes_after_unpause() { + let g = setup(); + let id = g.create(Spec::new(usdc(100), 1)); + let partners: std::vec::Vec
= (0..30).map(|_| g.someone()).collect(); + for p in partners.iter() { + g.contribute(id, p, usdc(10)); + } + g.events.start_cancel(&id, &g.op()); + g.events.process_cancel_batch(&id, &25, &g.op()); + + g.events.pause(); + fails_with( + g.events.try_process_cancel_batch(&id, &25, &g.op()), + Error::Paused, + ); + g.events.unpause(); + + g.finish_cancel(id); + for p in partners.iter() { + assert_eq!(g.balance(p), usdc(10)); + } + assert_eq!(g.balance(&g.owner), usdc(100)); +} + +#[test] +fn only_the_admin_pauses() { + let g = setup(); + g.sign_only(&g.owner, "pause", args(&g, ())); + fails_auth(g.events.try_pause()); + g.restore_auth(); + assert!(!g.events.is_paused()); +} diff --git a/contracts/events/src/tests/grant_scenarios/randomized.rs b/contracts/events/src/tests/grant_scenarios/randomized.rs new file mode 100644 index 0000000..e4eb505 --- /dev/null +++ b/contracts/events/src/tests/grant_scenarios/randomized.rs @@ -0,0 +1,185 @@ +//! Seeded random grants: budgets, milestone counts, tiers, partner money +//! before and after selection, releases and forfeits in any order, then the +//! remainder returned. Every token is accounted for at the end. `GRANT_SCENARIO_RUNS=` runs more seeds. + +use super::*; + +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 = self + .0 + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + self.0 >> 33 + } + + fn below(&mut self, n: u64) -> u64 { + self.next() % n + } + + fn chance(&mut self, percent: u64) -> bool { + self.below(100) < percent + } +} + +fn runs() -> u64 { + std::env::var("GRANT_SCENARIO_RUNS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(48) +} + +#[test] +fn random_grants_account_for_every_token() { + for seed in 0..runs() { + run(seed); + } +} + +fn run(seed: u64) { + let mut rng = Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0xD1B5_4A32_D192_ED03); + let g = setup(); + + let budget = usdc(2_000 + rng.below(10_000) as i128) + rng.below(USDC as u64) as i128; + let n = 1 + rng.below(10) as u32; + let k = 1 + rng.below(8) as usize; + + let early: std::vec::Vec<(Address, i128)> = (0..rng.below(4)) + .map(|_| (g.someone(), usdc(10 + rng.below(500) as i128))) + .collect(); + let pool = budget + early.iter().map(|(_, a)| a).sum::(); + + let share = pool * (50 + rng.below(51) as i128) / 100; + let weights: std::vec::Vec = (0..k).map(|_| 1 + rng.below(100) as i128).collect(); + let weight_sum: i128 = weights.iter().sum(); + let people: std::vec::Vec
= (0..k).map(|_| g.someone()).collect(); + let awards: std::vec::Vec = weights + .iter() + .map(|w| (share * w / weight_sum).max(n as i128)) + .collect(); + assert!( + awards.iter().sum::() <= pool, + "seed {seed}: plan exceeds pool" + ); + + let floors: std::vec::Vec<(u32, i128)> = awards + .iter() + .enumerate() + .map(|(i, a)| (i as u32 + 1, (a / 4).max(1))) + .collect(); + let id = g.create(Spec::new(budget, n).floors(&floors)); + for (partner, amount) in early.iter() { + g.contribute(id, partner, *amount); + } + + let selection: std::vec::Vec<(Address, u32, i128)> = people + .iter() + .zip(awards.iter()) + .enumerate() + .map(|(i, (p, a))| (p.clone(), i as u32 + 1, *a)) + .collect(); + g.select(id, &selection); + + let mut queue: std::vec::Vec<(usize, u32, bool)> = (0..k) + .flat_map(|r| (0..n).map(move |m| (r, m))) + .map(|(r, m)| (r, m, rng.chance(15))) + .collect(); + for i in (1..queue.len()).rev() { + let j = rng.below(i as u64 + 1) as usize; + queue.swap(i, j); + } + + let mut late: std::vec::Vec<(Address, i128)> = std::vec::Vec::new(); + let mut paid = std::vec![0i128; k]; + let mut settled = std::vec![0i128; k]; + let mut count = std::vec![0u32; k]; + for (step, (r, m, forfeit)) in queue.iter().enumerate() { + if late.len() < 2 && step == queue.len() / 2 && rng.chance(50) { + let partner = g.someone(); + let amount = usdc(10 + rng.below(200) as i128); + g.contribute(id, &partner, amount); + late.push((partner, amount)); + } + count[*r] += 1; + let expected = if count[*r] == n { + awards[*r] - settled[*r] + } else { + awards[*r] / n as i128 + }; + if *forfeit { + g.forfeit(id, &people[*r], *m, expected); + } else { + g.pay(id, &people[*r], *m, expected); + paid[*r] += expected; + } + settled[*r] += expected; + } + + assert_eq!(g.owed(id), 0, "seed {seed}: every award settled"); + for r in 0..k { + assert_eq!(g.balance(&people[r]), paid[r], "seed {seed}: recipient {r}"); + assert_eq!(settled[r], awards[r], "seed {seed}: award settled in full"); + } + + let partners: std::vec::Vec<(Address, i128)> = + early.iter().chain(late.iter()).cloned().collect(); + let partner_total: i128 = partners.iter().map(|(_, a)| a).sum(); + let remaining = g.event(id).remaining_escrow; + assert_eq!( + remaining, + pool + late.iter().map(|(_, a)| a).sum::() - paid.iter().sum::(), + "seed {seed}: escrow tracks deposits minus payouts" + ); + + let mut dust = 0; + if g.status(id) == EventStatus::Completed { + assert_eq!(remaining, 0); + } else { + g.cancel(id); + let owner_refund = if partner_total == 0 { + remaining + } else if remaining >= partner_total { + for (p, a) in partners.iter() { + assert_eq!(g.balance(p), *a, "seed {seed}: partner refunded in full"); + } + remaining - partner_total + } else { + let mut refunded = 0; + for (p, a) in partners.iter() { + let share = a * remaining / partner_total; + assert_eq!(g.balance(p), share, "seed {seed}: partner pro rata"); + refunded += share; + } + dust = remaining - refunded; + 0 + }; + assert_eq!( + g.balance(&g.owner), + owner_refund, + "seed {seed}: owner refund" + ); + } + + assert_eq!( + g.escrow_balance(), + dust, + "seed {seed}: contract keeps only dust" + ); + let deposits = budget + partner_total; + let fees = fee_on(budget, FEE_BPS) + + partners + .iter() + .map(|(_, a)| fee_on(*a, FEE_BPS)) + .sum::(); + assert_eq!(g.balance(&g.fee_account), fees, "seed {seed}: fees"); + let out = paid.iter().sum::() + + g.balance(&g.owner) + + partners.iter().map(|(p, _)| g.balance(p)).sum::() + + dust; + assert_eq!( + out, deposits, + "seed {seed}: every deposited token is accounted for" + ); +} diff --git a/contracts/events/src/tests/grant_scenarios/refusing_token.rs b/contracts/events/src/tests/grant_scenarios/refusing_token.rs new file mode 100644 index 0000000..ac4df3e --- /dev/null +++ b/contracts/events/src/tests/grant_scenarios/refusing_token.rs @@ -0,0 +1,72 @@ +//! A token whose transfers to a chosen address fail, the way a transfer to an +//! account that removed its trustline does. The Stellar asset contract in the +//! test host has no way to drop a trustline, so this stands in for it. + +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env}; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum RefusingTokenError { + NoTrustline = 1, + InsufficientBalance = 2, +} + +#[contracttype] +enum Key { + Balance(Address), + Refuses(Address), +} + +#[contract] +pub struct RefusingToken; + +#[contractimpl] +impl RefusingToken { + pub fn mint(env: Env, to: Address, amount: i128) { + let balance = Self::balance(env.clone(), to.clone()); + env.storage() + .persistent() + .set(&Key::Balance(to), &(balance + amount)); + } + + pub fn refuse(env: Env, who: Address, refuses: bool) { + env.storage().persistent().set(&Key::Refuses(who), &refuses); + } + + pub fn balance(env: Env, id: Address) -> i128 { + env.storage() + .persistent() + .get(&Key::Balance(id)) + .unwrap_or(0) + } + + pub fn transfer( + env: Env, + from: Address, + to: Address, + amount: i128, + ) -> Result<(), RefusingTokenError> { + from.require_auth(); + let refuses: bool = env + .storage() + .persistent() + .get(&Key::Refuses(to.clone())) + .unwrap_or(false); + if refuses { + return Err(RefusingTokenError::NoTrustline); + } + let from_balance = Self::balance(env.clone(), from.clone()); + if from_balance < amount { + return Err(RefusingTokenError::InsufficientBalance); + } + env.storage() + .persistent() + .set(&Key::Balance(from), &(from_balance - amount)); + let to_balance = Self::balance(env.clone(), to.clone()); + env.storage() + .persistent() + .set(&Key::Balance(to), &(to_balance + amount)); + Ok(()) + } +} diff --git a/contracts/events/src/tests/grant_scenarios/selection.rs b/contracts/events/src/tests/grant_scenarios/selection.rs new file mode 100644 index 0000000..da2cc22 --- /dev/null +++ b/contracts/events/src/tests/grant_scenarios/selection.rs @@ -0,0 +1,238 @@ +use super::*; +use crate::events::WinnersSelected; + +#[test] +fn selection_reserves_each_award_and_moves_nothing() { + let g = setup(); + let id = g.create(Spec::new(usdc(25_000), 3).floors(&[ + (1, usdc(12_000)), + (2, usdc(8_000)), + (3, usdc(5_000)), + ])); + let (a, b, c) = (g.someone(), g.someone(), g.someone()); + + g.select( + id, + &[ + (a.clone(), 1, usdc(12_000)), + (b.clone(), 2, usdc(8_000)), + (c.clone(), 3, usdc(5_000)), + ], + ); + + assert_eq!(g.owed(id), usdc(25_000)); + assert_eq!(g.events.get_winner_count(&id), 3); + let row = g.events.get_winner_at(&id, &1).unwrap(); + assert_eq!(row.recipient, b); + assert_eq!(row.amount, usdc(8_000)); + assert_eq!(row.milestone, None); + assert_eq!(row.paid_at, None); + assert!(g.emitted(WinnersSelected { + event_id: id, + count: 3 + })); +} + +#[test] +fn award_may_exceed_its_floor_out_of_headroom() { + let g = setup(); + let id = g.create(Spec::new(usdc(20_000), 2).floors(&[(1, usdc(5_000)), (2, usdc(5_000))])); + let (a, b) = (g.someone(), g.someone()); + g.select(id, &[(a, 1, usdc(12_000)), (b, 2, usdc(8_000))]); + assert_eq!(g.owed(id), usdc(20_000)); +} + +#[test] +fn position_without_a_floor_takes_any_positive_amount() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 1).floors(&[(1, usdc(500))])); + let (a, b) = (g.someone(), g.someone()); + g.select(id, &[(a, 1, usdc(500)), (b, 7, usdc(500))]); +} + +#[test] +fn award_below_its_floor_is_refused() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2).floors(&[(1, usdc(600))])); + let a = g.someone(); + fails_with( + g.events + .try_select_winners(&id, &g.specs(&[(a, 1, usdc(599))]), &g.op()), + Error::InvalidDistribution, + ); + assert_eq!(g.owed(id), 0); +} + +#[test] +fn zero_or_negative_award_is_refused() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 1).floors(&[(1, usdc(1))])); + for amount in [0, -usdc(1)] { + fails_with( + g.events + .try_select_winners(&id, &g.specs(&[(g.someone(), 5, amount)]), &g.op()), + Error::InvalidDistribution, + ); + } +} + +#[test] +fn awards_beyond_the_escrow_are_refused() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2).floors(&[(1, usdc(500))])); + let (a, b) = (g.someone(), g.someone()); + fails_with( + g.events.try_select_winners( + &id, + &g.specs(&[(a.clone(), 1, usdc(600)), (b.clone(), 2, usdc(401))]), + &g.op(), + ), + Error::InsufficientEscrow, + ); + g.select(id, &[(a, 1, usdc(600)), (b, 2, usdc(400))]); +} + +#[test] +fn awards_overflowing_i128_are_refused_not_wrapped() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 1).floors(&[(1, usdc(1))])); + fails_with( + g.events.try_select_winners( + &id, + &g.specs(&[(g.someone(), 1, i128::MAX), (g.someone(), 2, i128::MAX)]), + &g.op(), + ), + Error::InsufficientEscrow, + ); +} + +#[test] +fn empty_selection_is_refused() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 1)); + fails_with( + g.events.try_select_winners(&id, &g.specs(&[]), &g.op()), + Error::NoSubmissions, + ); +} + +#[test] +fn duplicate_position_is_refused() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 1).floors(&[(1, usdc(100))])); + fails_with( + g.events.try_select_winners( + &id, + &g.specs(&[(g.someone(), 1, usdc(100)), (g.someone(), 1, usdc(100))]), + &g.op(), + ), + Error::DuplicateWinnerPosition, + ); +} + +#[test] +fn a_grant_takes_up_to_the_award_cap_in_its_one_selection() { + let g = setup(); + let cap = crate::event_ops::MAX_GRANT_AWARDS; + let id = g.create(Spec::new(usdc(cap as i128 + 1), 1).floors(&[(1, usdc(1))])); + let mut awards = std::vec::Vec::new(); + for position in 1..=cap + 1 { + awards.push((g.someone(), position, usdc(1))); + } + fails_with( + g.events.try_select_winners(&id, &g.specs(&awards), &g.op()), + Error::InvalidWinnerPosition, + ); + awards.pop(); + g.select(id, &awards); + assert_eq!(g.events.get_winner_count(&id), cap); +} + +#[test] +fn selection_happens_once() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 2).floors(&[(1, usdc(500))])); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(500))]); + + fails_with( + g.events + .try_select_winners(&id, &g.specs(&[(g.someone(), 2, usdc(500))]), &g.op()), + Error::WinnersAlreadySelected, + ); + + g.pay(id, &a, 0, usdc(250)); + fails_with( + g.events + .try_select_winners(&id, &g.specs(&[(g.someone(), 2, usdc(500))]), &g.op()), + Error::WinnersAlreadySelected, + ); + assert_eq!(g.owed(id), usdc(250)); +} + +#[test] +fn a_floored_position_left_out_is_never_awarded_and_comes_back_on_cancel() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 1).floors(&[(1, usdc(600)), (2, usdc(400))])); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(600))]); + g.pay(id, &a, 0, usdc(600)); + + assert_eq!(g.status(id), EventStatus::Active); + assert_eq!(g.event(id).remaining_escrow, usdc(400)); + assert_eq!(g.owed(id), 0); + + g.cancel(id); + assert_eq!(g.balance(&g.owner), usdc(400)); +} + +#[test] +fn selection_on_a_closed_grant_is_refused() { + let g = setup(); + let done = g.create(Spec::new(usdc(100), 1)); + let a = g.someone(); + g.select(done, &[(a.clone(), 1, usdc(100))]); + g.pay(done, &a, 0, usdc(100)); + assert_eq!(g.status(done), EventStatus::Completed); + fails_with( + g.events + .try_select_winners(&done, &g.specs(&[(g.someone(), 2, usdc(1))]), &g.op()), + Error::EventNotActive, + ); + + let cancelled = g.create(Spec::new(usdc(100), 1)); + g.cancel(cancelled); + fails_with( + g.events.try_select_winners( + &cancelled, + &g.specs(&[(g.someone(), 1, usdc(100))]), + &g.op(), + ), + Error::EventNotActive, + ); +} + +#[test] +fn selection_on_an_unknown_grant_is_refused() { + let g = setup(); + fails_with( + g.events + .try_select_winners(&999, &g.specs(&[(g.someone(), 1, usdc(1))]), &g.op()), + Error::EventNotFound, + ); +} + +#[test] +fn selection_op_id_cannot_be_replayed() { + let g = setup(); + let first = g.create(Spec::new(usdc(100), 1)); + let second = g.create(Spec::new(usdc(100), 1)); + let op = g.op(); + g.events + .select_winners(&first, &g.specs(&[(g.someone(), 1, usdc(100))]), &op); + fails_with( + g.events + .try_select_winners(&second, &g.specs(&[(g.someone(), 1, usdc(100))]), &op), + Error::OpAlreadySeen, + ); +} diff --git a/contracts/events/src/tests/hackathon_pillar.rs b/contracts/events/src/tests/hackathon_pillar.rs index 338def6..f9d0e39 100644 --- a/contracts/events/src/tests/hackathon_pillar.rs +++ b/contracts/events/src/tests/hackathon_pillar.rs @@ -280,7 +280,7 @@ fn select_winners_multi_position_splits_by_distribution() { } // ============================================================ -// select_winners — rejections / edges +// select_winners: rejections and edges // ============================================================ #[test] diff --git a/contracts/events/src/tests/mod.rs b/contracts/events/src/tests/mod.rs index 6340211..8653769 100644 --- a/contracts/events/src/tests/mod.rs +++ b/contracts/events/src/tests/mod.rs @@ -1,4 +1,5 @@ -#![cfg(test)] +// Amounts are written as whole tokens then seven decimal places. +#![allow(clippy::inconsistent_digit_grouping)] mod admin; mod bounty_pillar; @@ -9,6 +10,7 @@ mod cross_contract; mod crowdfunding; mod escrow_fee_math; mod grant_pillar; +mod grant_scenarios; mod hackathon_pillar; mod op_id_security; mod prize_claim; diff --git a/contracts/events/src/tests/op_id_security.rs b/contracts/events/src/tests/op_id_security.rs index b3a8a92..67e0e6f 100644 --- a/contracts/events/src/tests/op_id_security.rs +++ b/contracts/events/src/tests/op_id_security.rs @@ -118,13 +118,15 @@ fn sha256_child_ids_differ_for_xor_colliding_parents() { let parent_b = BytesN::from_array(env, &b); assert_ne!(parent_a, parent_b); - // derive_child reads profile contract storage — must run as the events contract. + // derive_child reads profile contract storage, so it must run as the events contract. let (child_a, child_b, child_rep, child_i0, child_i1) = env.as_contract(&ctx.events_id, || { - let child_a = idempotency::derive_child(env, &parent_a, tag::BOOTSTRAP); - let child_b = idempotency::derive_child(env, &parent_b, tag::BOOTSTRAP); - let child_rep = idempotency::derive_child(env, &parent_a, tag::BUMP_REP); - let child_i0 = idempotency::derive_child_indexed(env, &parent_a, tag::BOOTSTRAP, 0); - let child_i1 = idempotency::derive_child_indexed(env, &parent_a, tag::BOOTSTRAP, 1); + let child_a = idempotency::derive_child(env, &ctx.owner, &parent_a, tag::BOOTSTRAP); + let child_b = idempotency::derive_child(env, &ctx.owner, &parent_b, tag::BOOTSTRAP); + let child_rep = idempotency::derive_child(env, &ctx.owner, &parent_a, tag::BUMP_REP); + let child_i0 = + idempotency::derive_child_indexed(env, &ctx.owner, &parent_a, tag::BOOTSTRAP, 0); + let child_i1 = + idempotency::derive_child_indexed(env, &ctx.owner, &parent_a, tag::BOOTSTRAP, 1); (child_a, child_b, child_rep, child_i0, child_i1) }); @@ -136,6 +138,14 @@ fn sha256_child_ids_differ_for_xor_colliding_parents() { assert_eq!(child_a, child_i0); assert_ne!(child_i0, child_i1); assert_ne!(child_a, parent_a); + + let child_other_caller = env.as_contract(&ctx.events_id, || { + idempotency::derive_child(env, &ctx.applicant, &parent_a, tag::BOOTSTRAP) + }); + assert_ne!( + child_a, child_other_caller, + "the same parent under another authorizer must not share children" + ); } /// Attacker squats derived child ids via bootstrap_self; legitimate claim_prize still pays. @@ -156,14 +166,14 @@ fn bootstrap_self_cannot_front_run_events_child_op_ids() { let op_select = BytesN::random(&ctx.env); ctx.events.select_winners(&bounty_id, &winners, &op_select); - // Parent op_id the winner will use for claim_prize — attacker observes it and + // Parent op_id the winner will use for claim_prize; the attacker observes it and // pre-marks the derived profile child ids via unprivileged bootstrap_self. let claim_op = BytesN::random(&ctx.env); let (bootstrap_child, rep_child, earnings_child) = ctx.env.as_contract(&ctx.events_id, || { ( - idempotency::derive_child(&ctx.env, &claim_op, tag::BOOTSTRAP), - idempotency::derive_child(&ctx.env, &claim_op, tag::BUMP_REP), - idempotency::derive_child(&ctx.env, &claim_op, tag::REGISTER_EARNINGS), + idempotency::derive_child(&ctx.env, &ctx.applicant, &claim_op, tag::BOOTSTRAP), + idempotency::derive_child(&ctx.env, &ctx.applicant, &claim_op, tag::BUMP_REP), + idempotency::derive_child(&ctx.env, &ctx.applicant, &claim_op, tag::REGISTER_EARNINGS), ) }); @@ -197,7 +207,7 @@ fn events_domain_child_op_id_replay_still_rejected() { // Bootstrap via events path twice with the same child id. let parent = BytesN::random(env); let child = env.as_contract(&ctx.events_id, || { - idempotency::derive_child(env, &parent, tag::BOOTSTRAP) + idempotency::derive_child(env, &ctx.owner, &parent, tag::BOOTSTRAP) }); let user = Address::generate(env); @@ -241,8 +251,7 @@ fn event_id_overflow_reverts() { let err = ctx .events .try_create_event(¶ms, &BytesN::random(env)) - .err() - .expect("event creation should fail when next_event_id overflows") + .expect_err("event creation should fail when next_event_id overflows") .unwrap(); assert_eq!(err, crate::errors::Error::EventIdOverflow); diff --git a/contracts/events/src/tests/prize_claim.rs b/contracts/events/src/tests/prize_claim.rs index 3288dd4..0da7672 100644 --- a/contracts/events/src/tests/prize_claim.rs +++ b/contracts/events/src/tests/prize_claim.rs @@ -285,7 +285,7 @@ fn op_id_replay_reverts() { // Both positions go to the same recipient so the two claims share an // op_id domain; reusing the op_id must revert even though position 2 is - // not yet paid. (Cross-recipient op_id reuse is intentionally allowed — + // 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, TOTAL_BUDGET * 60 / 100, 0); @@ -381,6 +381,63 @@ fn cancel_blocked_while_unclaimed_prizes_within_window() { ); } +#[test] +fn time_spent_paused_does_not_run_down_the_claim_window() { + let ctx = setup(); + let id = create_single(&ctx, dist_100(&ctx.env)); + let w = Address::generate(&ctx.env); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET, 0); + let day = 86_400; + let start = ctx.env.ledger().timestamp(); + + ctx.env.ledger().with_mut(|li| li.timestamp = start + day); + ctx.events.pause(); + ctx.env + .ledger() + .with_mut(|li| li.timestamp = start + 92 * day); + ctx.events.unpause(); + + let blocked = ctx + .events + .try_start_cancel(&id, &BytesN::random(&ctx.env)) + .err() + .unwrap() + .unwrap(); + assert_eq!(blocked, crate::errors::Error::WinnersAlreadySelected); + + // 91 paused days are added back: the window now closes at day 181 + ctx.env + .ledger() + .with_mut(|li| li.timestamp = start + 91 * day + PRIZE_CLAIM_WINDOW_SECS); + assert!(ctx + .events + .try_start_cancel(&id, &BytesN::random(&ctx.env)) + .is_err()); + ctx.env + .ledger() + .with_mut(|li| li.timestamp = start + 91 * day + PRIZE_CLAIM_WINDOW_SECS + 1); + drive_cancel(&ctx.env, &ctx.events, id); + assert_eq!(ctx.events.get_event(&id).status, EventStatus::Cancelled); +} + +#[test] +fn a_winner_can_claim_after_a_long_pause() { + let ctx = setup(); + let id = create_single(&ctx, dist_100(&ctx.env)); + let w = Address::generate(&ctx.env); + select_one(&ctx, id, &w, 1, TOTAL_BUDGET, 0); + + ctx.events.pause(); + ctx.env.ledger().with_mut(|li| { + li.timestamp += PRIZE_CLAIM_WINDOW_SECS * 2; + }); + ctx.events.unpause(); + 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(&w), TOTAL_BUDGET); +} + #[test] fn cancel_after_window_expiry_sweeps_unclaimed_and_blocks_late_claim() { let ctx = setup(); diff --git a/contracts/events/src/token_whitelist.rs b/contracts/events/src/token_whitelist.rs index 9ea8a58..d19fb6e 100644 --- a/contracts/events/src/token_whitelist.rs +++ b/contracts/events/src/token_whitelist.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use soroban_sdk::{Address, Env}; use crate::admin; diff --git a/contracts/events/src/types.rs b/contracts/events/src/types.rs index 895fdb2..3056eb0 100644 --- a/contracts/events/src/types.rs +++ b/contracts/events/src/types.rs @@ -100,17 +100,6 @@ pub struct CreateEventParams { pub manager: Option
, } -// ============================================================ -// CONTRIBUTION -// ============================================================ -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Contribution { - pub contributor: Address, - pub amount: i128, - pub contributed_at: u64, -} - // ============================================================ // WINNER // ============================================================ @@ -160,16 +149,6 @@ pub enum DataKey { EventManager(u64), - // Retired in 2.0.0 with the on-chain participation records: applying and - // submitting are product facts the contract never read, so nothing writes - // or reads these now. Kept in place rather than deleted so the remaining - // variants keep their positions and any rows left on live events stay - // identifiable until they archive. - EventApplicantCount(u64), - EventApplicantAt(u64, u32), - EventApplicantSlot(u64, Address), - EventSubmission(u64, Address), - EventWinnerCount(u64), EventWinnerAt(u64, u32), @@ -196,35 +175,62 @@ pub enum DataKey { SupportedTokenAt(u32), SupportedTokenSlot(Address), - // Appended in 1.2.0 to preserve existing key discriminants. NonOwnerContributionTotal(u64), - // Appended in 1.3.0 to preserve existing key discriminants. EventPrizeAward(u64, u32), EventUnclaimedPrizes(u64), - EventPrizeBaseEscrow(u64), EventPrizeClaimExpiry(u64), - // Appended for two-step manager rotation to preserve key discriminants. PendingManager(u64), - // Retired in 2.0.0 (see the applicant keys above). - 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. + // 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), - // Retired in 2.0.0 (see the applicant keys above). Added in 1.7.0 to give - // one wallet several distinct entries in one event. - 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, + + // Grant awards by recipient, written once at selection, and each + // recipient's release progress. A release reads these instead of walking + // the winner rows, which grow with every payment. Grants selected before + // these keys existed have no roster and keep the row walk. + GrantRoster(u64), + GrantProgress(u64, Address), + + // A cancel refund the contributor's account could not take (frozen, or no + // trustline). Held for `claim_refund` so one account cannot stall the + // crank for everyone behind it. + UnclaimedRefund(u64, Address), + + // When the current pause began, and the seconds spent paused before it. + // Claim windows run on time the contract was open, so a pause cannot run + // a winner's window out while they are unable to claim. + PausedAt, + PausedSeconds, + + // Co-signs crowdfunding milestone releases in place of the admin, so the + // keys that can upgrade the contract are not needed for routine payouts. + ReleaseValidator, + PendingReleaseValidator, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GrantAward { + pub position: u32, + pub amount: i128, +} + +/// Milestones a recipient has settled, released or forfeited, and what they +/// were worth. The last settlement takes whatever is left of the award. +#[contracttype] +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct GrantProgress { + pub settled: u32, + pub settled_amount: i128, } // ============================================================ @@ -248,6 +254,13 @@ pub struct PendingAdmin { pub expires_at_ledger: u32, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PendingValidator { + pub target: Address, + pub expires_at_ledger: u32, +} + // ============================================================ // PENDING MANAGER payload (target + expiry ledger) // ============================================================ @@ -259,7 +272,7 @@ pub struct PendingManager { } // ============================================================ -// PENDING UPGRADE (timelocked wasm rotation, H6) +// PENDING UPGRADE (timelocked wasm rotation) // ============================================================ #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/contracts/profile/Cargo.toml b/contracts/profile/Cargo.toml index b771693..3b72ff3 100644 --- a/contracts/profile/Cargo.toml +++ b/contracts/profile/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "boundless-profile" -version = "1.2.0" +version = "1.2.1" edition = "2021" publish = false @@ -10,7 +10,7 @@ doctest = false [features] # Testnet-only: zero the upgrade timelock so upgrades apply immediately for fast -# iteration. NEVER enable on mainnet — the default (feature off) keeps the full +# iteration. NEVER enable on mainnet: the default (feature off) keeps the full # audit-mandated ~1-day timelock, so forgetting the flag fails safe (secure). testnet = [] diff --git a/contracts/profile/src/admin.rs b/contracts/profile/src/admin.rs index 25413b5..380386d 100644 --- a/contracts/profile/src/admin.rs +++ b/contracts/profile/src/admin.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{panic_with_error, Address, BytesN, Env, String}; +use soroban_sdk::{panic_with_error, Address, BytesN, ContractExecutable, Env, String}; use crate::errors::Error; use crate::events as evt; @@ -7,19 +7,17 @@ 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. +// About a day on mainnet: without a window a compromised admin quorum can +// propose and apply a wasm swap in one session, before anyone watching can +// react or cancel_pending_upgrade can fire. Testnet builds skip it so +// upgrades can iterate. #[cfg(not(feature = "testnet"))] -const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; +const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; #[cfg(feature = "testnet")] const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; -pub const INITIAL_VERSION: &str = "1.2.0"; +pub const INITIAL_VERSION: &str = "1.2.1"; const EVENTS_CONTRACT_TIMELOCK_LEDGERS: u32 = 17_280; @@ -32,7 +30,6 @@ pub fn initialize(env: &Env, admin: Address) { storage::set_admin(env, &admin); storage::set_paused(env, false); - storage::set_deployment_seq(env, env.ledger().sequence()); storage::set_version(env, &String::from_str(env, INITIAL_VERSION)); storage::touch_instance(env); @@ -169,7 +166,7 @@ pub fn unpause(env: &Env) -> Result<(), Error> { } // ============================================================ -// UPGRADE (timelocked; H6) +// UPGRADE (timelocked) // ============================================================ pub fn propose_upgrade( env: &Env, @@ -214,7 +211,7 @@ pub fn apply_upgrade(env: &Env) -> Result<(), Error> { } storage::touch_instance(env); env.deployer() - .update_current_contract_wasm(pending.wasm_hash.clone()); + .update_current_contract(ContractExecutable::Wasm(pending.wasm_hash.clone())); storage::set_version(env, &pending.new_version); storage::clear_pending_upgrade(env); evt::UpgradeApplied { @@ -244,7 +241,7 @@ pub fn cancel_pending_upgrade(env: &Env) -> Result<(), Error> { } // ============================================================ -// MIGRATE (post-upgrade one-shot; H6) +// MIGRATE (post-upgrade one-shot) // ============================================================ pub fn migrate(env: &Env) -> Result<(), Error> { require_admin(env)?; diff --git a/contracts/profile/src/errors.rs b/contracts/profile/src/errors.rs index 662b378..db5ce91 100644 --- a/contracts/profile/src/errors.rs +++ b/contracts/profile/src/errors.rs @@ -1,5 +1,7 @@ use soroban_sdk::contracterror; +// Retired codes are never reused, so a code in an old log or client keeps its +// meaning: 4, 5. #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] #[repr(u32)] @@ -7,8 +9,6 @@ pub enum Error { AlreadyInitialized = 1, AdminCannotBeZero = 2, EventsContractNotConfigured = 3, - Unauthorized = 4, - NotAdmin = 5, PendingAdminMismatch = 6, PendingAdminExpired = 7, NotInitialized = 8, @@ -18,6 +18,7 @@ pub enum Error { PendingEventsContractTimelock = 17, ProfileNotFound = 10, + DeltaTooLarge = 11, InvalidAmount = 12, ReasonRequired = 13, diff --git a/contracts/profile/src/events.rs b/contracts/profile/src/events.rs index 0678616..1273643 100644 --- a/contracts/profile/src/events.rs +++ b/contracts/profile/src/events.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use soroban_sdk::{contractevent, Address, BytesN, String, Symbol}; #[contractevent] @@ -14,13 +12,6 @@ pub struct ReputationBumped { pub reason: Symbol, } -#[contractevent] -pub struct ReputationSlashed { - pub user: Address, - pub delta: u32, - pub reason: Symbol, -} - #[contractevent] pub struct EarningsRegistered { pub user: Address, diff --git a/contracts/profile/src/lib.rs b/contracts/profile/src/lib.rs index 2628916..c232239 100644 --- a/contracts/profile/src/lib.rs +++ b/contracts/profile/src/lib.rs @@ -19,7 +19,7 @@ mod tests; use crate::errors::Error; use crate::types::{PendingEventsContract, PendingUpgrade, Profile}; -contractmeta!(key = "version", val = "1.2.0"); +contractmeta!(key = "version", val = "1.2.1"); contractmeta!( key = "description", val = "Boundless profile contract: reputation + earnings" @@ -117,16 +117,6 @@ impl ProfileContract { reputation::bump(&env, user, delta, reason, op_id) } - pub fn slash_reputation( - env: Env, - user: Address, - delta: u32, - reason: Symbol, - op_id: BytesN<32>, - ) -> Result<(), Error> { - reputation::slash(&env, user, delta, reason, op_id) - } - // ============================================================ // EARNINGS // ============================================================ diff --git a/contracts/profile/src/reputation.rs b/contracts/profile/src/reputation.rs index 5f7f597..d1c42a9 100644 --- a/contracts/profile/src/reputation.rs +++ b/contracts/profile/src/reputation.rs @@ -6,6 +6,11 @@ use crate::events as evt; use crate::idempotency; use crate::storage; +// Matches the events contract's per-award cap, so a defect or a compromised +// binding on that side still cannot move a score far in one call. The admin +// slash stays uncapped: it is how a farmed score gets corrected. +pub const MAX_DELTA_PER_CALL: u32 = 100; + pub fn bump( env: &Env, user: Address, @@ -15,6 +20,9 @@ pub fn bump( ) -> Result<(), Error> { admin::require_events_contract(env)?; admin::require_not_paused(env)?; + if delta > MAX_DELTA_PER_CALL { + return Err(Error::DeltaTooLarge); + } let domain = idempotency::events_domain(env)?; idempotency::require_unseen(env, &domain, &op_id)?; @@ -32,32 +40,6 @@ pub fn bump( Ok(()) } -pub fn slash( - env: &Env, - user: Address, - delta: u32, - reason: Symbol, - op_id: BytesN<32>, -) -> Result<(), Error> { - admin::require_events_contract(env)?; - admin::require_not_paused(env)?; - let domain = idempotency::events_domain(env)?; - idempotency::require_unseen(env, &domain, &op_id)?; - - let mut profile = storage::get_profile(env, &user).ok_or(Error::ProfileNotFound)?; - profile.reputation = profile.reputation.saturating_sub(delta as u64); - storage::set_profile(env, &user, &profile); - - evt::ReputationSlashed { - user, - delta, - reason, - } - .publish(env); - idempotency::mark_seen(env, &domain, &op_id); - Ok(()) -} - pub fn admin_slash( env: &Env, user: Address, diff --git a/contracts/profile/src/storage.rs b/contracts/profile/src/storage.rs index 9b094e5..d636d65 100644 --- a/contracts/profile/src/storage.rs +++ b/contracts/profile/src/storage.rs @@ -1,6 +1,4 @@ -#![allow(dead_code)] - -use soroban_sdk::{contracttype, Address, BytesN, Env}; +use soroban_sdk::{Address, BytesN, Env}; use soroban_sdk::String; @@ -16,11 +14,6 @@ const INSTANCE_TTL_BUMP: u32 = 518_400; const PROFILE_TTL_THRESHOLD: u32 = 86_400; const PROFILE_TTL_BUMP: u32 = 1_555_200; -#[contracttype(export = false)] -enum LegacyDataKey { - OpSeen(BytesN<32>), -} - pub fn touch_instance(env: &Env) { env.storage() .instance() @@ -98,12 +91,8 @@ pub fn set_paused(env: &Env, paused: bool) { env.storage().instance().set(&DataKey::Paused, &paused); } -pub fn set_deployment_seq(env: &Env, seq: u32) { - env.storage().instance().set(&DataKey::DeploymentSeq, &seq); -} - // ============================================================ -// VERSION / UPGRADE / MIGRATION (instance; H6) +// VERSION / UPGRADE / MIGRATION (instance) // ============================================================ pub fn get_version(env: &Env) -> Option { env.storage().instance().get(&DataKey::Version) @@ -177,17 +166,10 @@ pub fn set_earnings(env: &Env, user: &Address, token: &Address, amount: i128) { // IDEMPOTENCY (temporary; auto-TTL) // ============================================================ pub fn is_op_seen(env: &Env, domain: &Address, op_id: &BytesN<32>) -> bool { - let scoped_seen = env - .storage() + env.storage() .temporary() .get(&DataKey::OpSeen(domain.clone(), op_id.clone())) - .unwrap_or(false); - scoped_seen - || env - .storage() - .temporary() - .get(&LegacyDataKey::OpSeen(op_id.clone())) - .unwrap_or(false) + .unwrap_or(false) } pub fn mark_op_seen(env: &Env, domain: &Address, op_id: &BytesN<32>) { diff --git a/contracts/profile/src/tests/admin.rs b/contracts/profile/src/tests/admin.rs index 85fe24f..beca166 100644 --- a/contracts/profile/src/tests/admin.rs +++ b/contracts/profile/src/tests/admin.rs @@ -11,18 +11,17 @@ use crate::errors::Error; const EVENTS_CONTRACT_TIMELOCK_LEDGERS: u32 = 17_280; const PENDING_EVENTS_CONTRACT_TTL_LEDGERS: u32 = 120_960; -// Zeroed with the contract constant; restore both together. -const UPGRADE_TIMELOCK_LEDGERS: u32 = 0; +const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; #[test] fn initializes_with_expected_config() { let ctx = setup(); assert_eq!(ctx.client.get_admin(), ctx.admin); - assert_eq!(ctx.client.is_paused(), false); + assert!(!ctx.client.is_paused()); assert_eq!(ctx.client.get_events_contract(), None); assert_eq!(ctx.client.get_pending_events_contract(), None); - assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.2.0")); + assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.2.1")); assert_eq!(ctx.client.get_pending_upgrade(), None); assert_eq!(ctx.client.get_migrated_to_version(), None); } @@ -31,9 +30,9 @@ fn initializes_with_expected_config() { fn pause_and_unpause_round_trip() { let ctx = setup(); ctx.client.pause(); - assert_eq!(ctx.client.is_paused(), true); + assert!(ctx.client.is_paused()); ctx.client.unpause(); - assert_eq!(ctx.client.is_paused(), false); + assert!(!ctx.client.is_paused()); } // ============================================================ @@ -58,8 +57,7 @@ fn second_set_events_contract_reverts_already_configured() { let err = ctx .client .try_set_events_contract(&events_b) - .err() - .expect("expected second set to fail") + .expect_err("expected second set to fail") .unwrap(); assert_eq!(err, Error::EventsContractAlreadyConfigured); } @@ -101,8 +99,7 @@ fn accept_before_timelock_reverts() { let err = ctx .client .try_accept_events_contract() - .err() - .expect("expected timelock to block") + .expect_err("expected timelock to block") .unwrap(); assert_eq!(err, Error::PendingEventsContractTimelock); assert_eq!(ctx.client.get_events_contract(), Some(events_a)); @@ -124,8 +121,7 @@ fn accept_after_expiry_reverts_and_admin_must_cancel_to_prune() { let err = ctx .client .try_accept_events_contract() - .err() - .expect("expected expiry to block") + .expect_err("expected expiry to block") .unwrap(); assert_eq!(err, Error::PendingEventsContractExpired); @@ -157,8 +153,7 @@ fn cancel_with_no_pending_reverts() { let err = ctx .client .try_cancel_pending_events_contract() - .err() - .expect("expected mismatch") + .expect_err("expected mismatch") .unwrap(); assert_eq!(err, Error::PendingEventsContractMismatch); } @@ -190,9 +185,7 @@ fn propose_upgrade_records_pending() { } #[test] -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. +fn apply_upgrade_waits_out_the_timelock_profile() { let ctx = setup(); let new_hash: BytesN<32> = BytesN::random(&ctx.env); let new_version = String::from_str(&ctx.env, "0.3.0"); @@ -200,10 +193,20 @@ fn apply_upgrade_is_immediate_while_the_timelock_is_zero_profile() { 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" + pending.available_at_ledger, + pending.proposed_at_ledger + UPGRADE_TIMELOCK_LEDGERS + ); + assert_eq!( + ctx.client.try_apply_upgrade().err().unwrap().unwrap(), + Error::UpgradeTimelockNotElapsed + ); + ctx.env.ledger().with_mut(|li| { + li.sequence_number = pending.available_at_ledger - 1; + }); + assert_eq!( + ctx.client.try_apply_upgrade().err().unwrap().unwrap(), + Error::UpgradeTimelockNotElapsed ); - assert_eq!(UPGRADE_TIMELOCK_LEDGERS, 0, "restore me with the constant"); } #[test] @@ -221,8 +224,7 @@ fn apply_upgrade_after_expiry_reverts_profile() { let err = ctx .client .try_apply_upgrade() - .err() - .expect("expiry blocks") + .expect_err("expiry blocks") .unwrap(); assert_eq!(err, Error::UpgradeProposalExpired); } @@ -234,14 +236,13 @@ fn migrate_marks_version_and_blocks_replay_profile() { ctx.client.migrate(); assert_eq!( ctx.client.get_migrated_to_version(), - Some(String::from_str(&ctx.env, "1.2.0")) + Some(String::from_str(&ctx.env, "1.2.1")) ); let err = ctx .client .try_migrate() - .err() - .expect("second migrate rejected") + .expect_err("second migrate rejected") .unwrap(); assert_eq!(err, Error::MigrationAlreadyApplied); } @@ -250,7 +251,7 @@ fn migrate_marks_version_and_blocks_replay_profile() { // AUTH REGRESSION GUARDS (#73) // // Mirror of the events-contract guards in -// contracts/events/src/tests/admin.rs — see that file for the rationale. +// contracts/events/src/tests/admin.rs; see that file for the rationale. // ============================================================ #[test] @@ -389,7 +390,7 @@ fn migrate_reverts_without_admin_auth() { } // ============================================================ -// ACCEPT_ADMIN — target-auth guard +// ACCEPT_ADMIN: target-auth guard // // accept_admin does not call require_admin(); it authorizes against the // pending target address instead (pending.target.require_auth()). These diff --git a/contracts/profile/src/tests/bootstrap.rs b/contracts/profile/src/tests/bootstrap.rs index 5f12a37..ae6b289 100644 --- a/contracts/profile/src/tests/bootstrap.rs +++ b/contracts/profile/src/tests/bootstrap.rs @@ -63,8 +63,7 @@ fn bootstrap_self_rejects_a_replayed_op_id() { let err = ctx .client .try_bootstrap_self(&user, &op_id) - .err() - .expect("a replayed op_id must be rejected") + .expect_err("a replayed op_id must be rejected") .unwrap(); assert_eq!(err, Error::OpAlreadySeen); } diff --git a/contracts/profile/src/tests/common.rs b/contracts/profile/src/tests/common.rs index 8ef2c6f..db3445c 100644 --- a/contracts/profile/src/tests/common.rs +++ b/contracts/profile/src/tests/common.rs @@ -5,7 +5,6 @@ use soroban_sdk::{testutils::Address as _, Address, Env}; use crate::{ProfileContract, ProfileContractClient}; pub struct TestCtx<'a> { - #[allow(dead_code)] pub env: Env, pub admin: Address, pub client: ProfileContractClient<'a>, diff --git a/contracts/profile/src/tests/earnings.rs b/contracts/profile/src/tests/earnings.rs index 4ee4c25..e515e2f 100644 --- a/contracts/profile/src/tests/earnings.rs +++ b/contracts/profile/src/tests/earnings.rs @@ -99,8 +99,7 @@ fn register_earnings_rejects_zero() { let err = ctx .client .try_register_earnings(&u, &t, &0_i128, &BytesN::random(&ctx.env)) - .err() - .expect("call should fail") + .expect_err("call should fail") .unwrap(); assert_eq!(err, Error::InvalidAmount); } @@ -116,8 +115,7 @@ fn register_earnings_rejects_negative() { let err = ctx .client .try_register_earnings(&u, &t, &(-100_i128), &BytesN::random(&ctx.env)) - .err() - .expect("call should fail") + .expect_err("call should fail") .unwrap(); assert_eq!(err, Error::InvalidAmount); } @@ -131,8 +129,7 @@ fn register_earnings_reverts_no_events_contract() { let err = ctx .client .try_register_earnings(&u, &t, &100_i128, &BytesN::random(&ctx.env)) - .err() - .expect("call should fail") + .expect_err("call should fail") .unwrap(); assert_eq!(err, Error::EventsContractNotConfigured); } @@ -149,8 +146,7 @@ fn register_earnings_reverts_when_paused() { let err = ctx .client .try_register_earnings(&u, &t, &100_i128, &BytesN::random(&ctx.env)) - .err() - .expect("call should fail") + .expect_err("call should fail") .unwrap(); assert_eq!(err, Error::Paused); } @@ -170,8 +166,7 @@ fn register_earnings_rejects_duplicate_op_id() { let err = ctx .client .try_register_earnings(&u, &t, &200_i128, &op_id) - .err() - .expect("call should fail") + .expect_err("call should fail") .unwrap(); assert_eq!(err, Error::OpAlreadySeen); @@ -193,8 +188,7 @@ fn register_earnings_overflow_reverts() { let err = ctx .client .try_register_earnings(&u, &t, &1_i128, &BytesN::random(&ctx.env)) - .err() - .expect("overflow should revert") + .expect_err("overflow should revert") .unwrap(); assert_eq!(err, Error::EarningsOverflow); assert_eq!(ctx.client.get_earnings(&u, &t), i128::MAX); diff --git a/contracts/profile/src/tests/mod.rs b/contracts/profile/src/tests/mod.rs index 5dd4e45..2b695e2 100644 --- a/contracts/profile/src/tests/mod.rs +++ b/contracts/profile/src/tests/mod.rs @@ -1,5 +1,3 @@ -#![cfg(test)] - mod admin; mod bootstrap; mod common; diff --git a/contracts/profile/src/tests/reputation.rs b/contracts/profile/src/tests/reputation.rs index c63a8d1..7c28707 100644 --- a/contracts/profile/src/tests/reputation.rs +++ b/contracts/profile/src/tests/reputation.rs @@ -7,6 +7,7 @@ use soroban_sdk::{ use super::common::{setup, TestCtx}; use crate::errors::Error; +use crate::reputation::MAX_DELTA_PER_CALL; // ============================================================ // Helpers @@ -65,13 +66,33 @@ fn bump_accumulates_across_calls() { } #[test] -fn bump_accepts_u32_max_delta_without_overflow() { +fn bump_over_the_per_call_cap_is_refused() { let (ctx, user) = setup_with_user(); + for delta in [MAX_DELTA_PER_CALL + 1, u32::MAX] { + let err = ctx + .client + .try_bump_reputation(&user, &delta, &reason(&ctx), &op_id(&ctx)) + .expect_err("over-cap bump rejected") + .unwrap(); + assert_eq!(err, Error::DeltaTooLarge); + } + assert_eq!(reputation_of(&ctx, &user), 0); ctx.client - .bump_reputation(&user, &u32::MAX, &reason(&ctx), &op_id(&ctx)); + .bump_reputation(&user, &MAX_DELTA_PER_CALL, &reason(&ctx), &op_id(&ctx)); + assert_eq!(reputation_of(&ctx, &user), MAX_DELTA_PER_CALL as u64); +} - assert_eq!(reputation_of(&ctx, &user), u32::MAX as u64); +#[test] +fn a_refused_bump_leaves_its_op_id_unspent() { + let (ctx, user) = setup_with_user(); + let op = op_id(&ctx); + assert!(ctx + .client + .try_bump_reputation(&user, &(MAX_DELTA_PER_CALL + 1), &reason(&ctx), &op) + .is_err()); + ctx.client.bump_reputation(&user, &1, &reason(&ctx), &op); + assert_eq!(reputation_of(&ctx, &user), 1); } #[test] @@ -85,8 +106,7 @@ fn bump_zero_delta_is_noop_but_marks_seen() { let err = ctx .client .try_bump_reputation(&user, &0, &reason(&ctx), &op) - .err() - .expect("replay rejected") + .expect_err("replay rejected") .unwrap(); assert_eq!(err, Error::OpAlreadySeen); } @@ -99,8 +119,7 @@ fn bump_reverts_when_events_contract_not_configured() { let err = ctx .client .try_bump_reputation(&user, &1, &reason(&ctx), &op_id(&ctx)) - .err() - .expect("expected guard to reject") + .expect_err("expected guard to reject") .unwrap(); assert_eq!(err, Error::EventsContractNotConfigured); } @@ -113,8 +132,7 @@ fn bump_reverts_when_paused() { let err = ctx .client .try_bump_reputation(&user, &1, &reason(&ctx), &op_id(&ctx)) - .err() - .expect("expected pause to block") + .expect_err("expected pause to block") .unwrap(); assert_eq!(err, Error::Paused); } @@ -129,8 +147,7 @@ fn bump_reverts_when_profile_not_found() { let err = ctx .client .try_bump_reputation(&ghost, &1, &reason(&ctx), &op_id(&ctx)) - .err() - .expect("expected missing profile") + .expect_err("expected missing profile") .unwrap(); assert_eq!(err, Error::ProfileNotFound); } @@ -146,8 +163,7 @@ fn bump_is_idempotent_on_replay() { let err = ctx .client .try_bump_reputation(&user, &5, &reason(&ctx), &op) - .err() - .expect("replay rejected") + .expect_err("replay rejected") .unwrap(); assert_eq!(err, Error::OpAlreadySeen); assert_eq!(reputation_of(&ctx, &user), 5); @@ -169,116 +185,21 @@ fn bump_rejects_caller_without_events_contract_auth() { // ============================================================ #[test] -fn slash_happy_path_decrements_reputation() { +fn admin_slash_is_not_capped() { let (ctx, user) = setup_with_user(); - ctx.client - .bump_reputation(&user, &10, &reason(&ctx), &op_id(&ctx)); - - ctx.client - .slash_reputation(&user, &4, &reason(&ctx), &op_id(&ctx)); - - assert_eq!(reputation_of(&ctx, &user), 6); -} - -#[test] -fn slash_saturates_at_zero() { - let (ctx, user) = setup_with_user(); - ctx.client - .bump_reputation(&user, &5, &reason(&ctx), &op_id(&ctx)); - - ctx.client - .slash_reputation(&user, &10, &reason(&ctx), &op_id(&ctx)); - + for _ in 0..3 { + ctx.client + .bump_reputation(&user, &MAX_DELTA_PER_CALL, &reason(&ctx), &op_id(&ctx)); + } + ctx.client.admin_slash_reputation( + &user, + &u32::MAX, + &String::from_str(&ctx.env, "farmed reputation"), + &op_id(&ctx), + ); assert_eq!(reputation_of(&ctx, &user), 0); } -#[test] -fn slash_zero_delta_is_noop() { - let (ctx, user) = setup_with_user(); - ctx.client - .bump_reputation(&user, &3, &reason(&ctx), &op_id(&ctx)); - - ctx.client - .slash_reputation(&user, &0, &reason(&ctx), &op_id(&ctx)); - - assert_eq!(reputation_of(&ctx, &user), 3); -} - -#[test] -fn slash_reverts_when_events_contract_not_configured() { - let ctx = setup(); - let user = Address::generate(&ctx.env); - - let err = ctx - .client - .try_slash_reputation(&user, &1, &reason(&ctx), &op_id(&ctx)) - .err() - .expect("expected guard to reject") - .unwrap(); - assert_eq!(err, Error::EventsContractNotConfigured); -} - -#[test] -fn slash_reverts_when_paused() { - let (ctx, user) = setup_with_user(); - ctx.client.pause(); - - let err = ctx - .client - .try_slash_reputation(&user, &1, &reason(&ctx), &op_id(&ctx)) - .err() - .expect("expected pause to block") - .unwrap(); - assert_eq!(err, Error::Paused); -} - -#[test] -fn slash_reverts_when_profile_not_found() { - let ctx = setup(); - let events = Address::generate(&ctx.env); - ctx.client.set_events_contract(&events); - - let ghost = Address::generate(&ctx.env); - let err = ctx - .client - .try_slash_reputation(&ghost, &1, &reason(&ctx), &op_id(&ctx)) - .err() - .expect("expected missing profile") - .unwrap(); - assert_eq!(err, Error::ProfileNotFound); -} - -#[test] -fn slash_is_idempotent_on_replay() { - let (ctx, user) = setup_with_user(); - ctx.client - .bump_reputation(&user, &10, &reason(&ctx), &op_id(&ctx)); - let op = op_id(&ctx); - - ctx.client.slash_reputation(&user, &4, &reason(&ctx), &op); - assert_eq!(reputation_of(&ctx, &user), 6); - - let err = ctx - .client - .try_slash_reputation(&user, &4, &reason(&ctx), &op) - .err() - .expect("replay rejected") - .unwrap(); - assert_eq!(err, Error::OpAlreadySeen); - assert_eq!(reputation_of(&ctx, &user), 6); -} - -#[test] -fn slash_rejects_caller_without_events_contract_auth() { - let (ctx, user) = setup_with_user(); - - ctx.env.mock_auths(&[]); - let res = ctx - .client - .try_slash_reputation(&user, &1, &reason(&ctx), &op_id(&ctx)); - assert!(res.is_err(), "unauthorized slash must be rejected"); -} - // ============================================================ // admin_slash // ============================================================ @@ -319,8 +240,7 @@ fn admin_slash_reverts_on_empty_reason() { let err = ctx .client .try_admin_slash_reputation(&user, &1, &empty, &op_id(&ctx)) - .err() - .expect("expected empty reason to reject") + .expect_err("expected empty reason to reject") .unwrap(); assert_eq!(err, Error::ReasonRequired); } @@ -333,8 +253,7 @@ fn admin_slash_reverts_when_paused() { let err = ctx .client .try_admin_slash_reputation(&user, &1, &admin_reason(&ctx), &op_id(&ctx)) - .err() - .expect("expected pause to block") + .expect_err("expected pause to block") .unwrap(); assert_eq!(err, Error::Paused); } @@ -347,8 +266,7 @@ fn admin_slash_reverts_when_profile_not_found() { let err = ctx .client .try_admin_slash_reputation(&ghost, &1, &admin_reason(&ctx), &op_id(&ctx)) - .err() - .expect("expected missing profile") + .expect_err("expected missing profile") .unwrap(); assert_eq!(err, Error::ProfileNotFound); } @@ -367,8 +285,7 @@ fn admin_slash_is_idempotent_on_replay() { let err = ctx .client .try_admin_slash_reputation(&user, &3, &admin_reason(&ctx), &op) - .err() - .expect("replay rejected") + .expect_err("replay rejected") .unwrap(); assert_eq!(err, Error::OpAlreadySeen); assert_eq!(reputation_of(&ctx, &user), 7); diff --git a/contracts/profile/src/types.rs b/contracts/profile/src/types.rs index 030457f..39614b8 100644 --- a/contracts/profile/src/types.rs +++ b/contracts/profile/src/types.rs @@ -52,7 +52,6 @@ pub enum DataKey { EventsContract, PendingEventsContract, Paused, - DeploymentSeq, Profile(Address), EarningsByToken(Address, Address), From 58a8efaed5c06ff82155c7f23eb44e7d6c264628 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Sat, 3 Oct 2026 18:16:41 +0100 Subject: [PATCH 17/20] chore(scripts,ci): retarget the mainnet tooling and replay the real upgrade in CI - deploy_mainnet.sh takes the version as an argument and checks it against the wasm's contractmeta, requires CLI 28.1.0, adds prepare-migrate-events-page, and refuses to unpause an upgrade that has not been migrated. Guard tests updated to match. - test-sdk27-compat.sh becomes test-storage-compat.sh: it builds the release wasm and replays the mainnet upgrade against captured storage. - CI drops the obsolete fixture-hash gate and adds clippy with warnings as errors. - verify-multisig.sh checks signer type and the signer roster; verify-fee-trustline.sh runs before a token is whitelisted. - Testnet grant scenario runner (scripts/testnet). - Remove the dead pre-split deploy script and the spent 1.6.0 signing envelopes; signing/ is now ignored. --- .env.deploy.example | 19 +- .github/actions/setup-rust-stellar/action.yml | 6 +- .github/workflows/verify-build.yml | 29 +- .gitignore | 8 +- deploy_and_upgrade.sh | 363 ++-- deploy_mainnet.sh | 1516 +++++------------ scripts/admin/verify-fee-trustline.sh | 70 + scripts/admin/verify-multisig.sh | 39 +- scripts/capture-mainnet-compat-snapshot.sh | 59 +- scripts/check-versions.sh | 31 + scripts/deploy/deploy.sh | 95 +- scripts/deploy/register_token.sh | 60 +- scripts/deploy/verify.sh | 55 +- scripts/deploy_and_upgrade.sh | 148 -- scripts/lib/deploy-env.sh | 55 + scripts/test-admin-scripts.sh | 64 + scripts/test-mainnet-upgrade-guards.sh | 333 ++-- ...sdk27-compat.sh => test-storage-compat.sh} | 34 +- scripts/testdata/horizon/fee-missing.json | 9 + scripts/testdata/horizon/fee-ok.json | 16 + .../testdata/horizon/fee-unauthorized.json | 16 + scripts/testdata/horizon/ids.env | 5 + scripts/testdata/horizon/multisig-hashx.json | 30 + scripts/testdata/horizon/multisig-ok.json | 30 + .../testdata/horizon/multisig-preauth.json | 30 + scripts/testdata/mock-stellar | 108 +- scripts/testnet/grant-scenarios.ts | 824 +++++++++ scripts/testnet/package-lock.json | 997 +++++++++++ scripts/testnet/package.json | 12 + signing/mainnet-apply-events-1.6.0.xdr | 1 - signing/mainnet-migrate-events-1.6.0.xdr | 1 - signing/mainnet-propose-events-1.6.0-v2.xdr | 1 - signing/mainnet-propose-events-1.6.0.xdr | 1 - 33 files changed, 3295 insertions(+), 1770 deletions(-) create mode 100755 scripts/admin/verify-fee-trustline.sh create mode 100755 scripts/check-versions.sh delete mode 100755 scripts/deploy_and_upgrade.sh create mode 100644 scripts/lib/deploy-env.sh create mode 100755 scripts/test-admin-scripts.sh rename scripts/{test-sdk27-compat.sh => test-storage-compat.sh} (72%) create mode 100644 scripts/testdata/horizon/fee-missing.json create mode 100644 scripts/testdata/horizon/fee-ok.json create mode 100644 scripts/testdata/horizon/fee-unauthorized.json create mode 100644 scripts/testdata/horizon/ids.env create mode 100644 scripts/testdata/horizon/multisig-hashx.json create mode 100644 scripts/testdata/horizon/multisig-ok.json create mode 100644 scripts/testdata/horizon/multisig-preauth.json create mode 100644 scripts/testnet/grant-scenarios.ts create mode 100644 scripts/testnet/package-lock.json create mode 100644 scripts/testnet/package.json delete mode 100644 signing/mainnet-apply-events-1.6.0.xdr delete mode 100644 signing/mainnet-migrate-events-1.6.0.xdr delete mode 100644 signing/mainnet-propose-events-1.6.0-v2.xdr delete mode 100644 signing/mainnet-propose-events-1.6.0.xdr diff --git a/.env.deploy.example b/.env.deploy.example index f3b5148..aa9adc6 100644 --- a/.env.deploy.example +++ b/.env.deploy.example @@ -1,21 +1,16 @@ -# Deployment configuration for the Boundless contracts. -# Copy to .env.deploy before running scripts/deploy/deploy.sh. +# Deployment configuration for scripts/deploy/deploy.sh (testnet, futurenet). +# Copy to .env.deploy before running it. # # .env.deploy is gitignored (see .gitignore). NEVER commit a populated file. -# Stellar CLI identity that owns the admin authority on both contracts. +# Stellar CLI identity that deploys both contracts and holds admin on them. # Create with: stellar keys generate --network testnet --fund ADMIN_IDENTITY=boundless-admin -# G-address that receives the platform fee on every deposit. -# In production this should be a multi-sig account with the platform's -# operational threshold; testnet can be a single-key account. +# G-address that receives the platform fee on every deposit. It needs an +# authorized trustline for every token you register. FEE_ACCOUNT= -# Platform fee in basis points (100 = 1%, 250 = 2.5%, etc.). Range 0..1000. -# Contract caps at 1000 (10%) per audit L4 (2026-06 Stellar-skill audit). +# Platform fee in basis points (100 = 1%, 250 = 2.5%). Range 0..1000; the +# contract caps fees at 1000 (10%). FEE_BPS=250 - -# Contract 1.0.0 also required BOOTSTRAP_CREDITS here. The 1.1.0 -# credit-removal upgrade (2026-06) moved credits to an off-chain ledger in -# boundless-nestjs; the profile constructor now takes only --admin. diff --git a/.github/actions/setup-rust-stellar/action.yml b/.github/actions/setup-rust-stellar/action.yml index ed6ef66..f301069 100644 --- a/.github/actions/setup-rust-stellar/action.yml +++ b/.github/actions/setup-rust-stellar/action.yml @@ -18,10 +18,10 @@ runs: - name: Install Stellar CLI run: | - STELLAR_VERSION="27.0.0" + STELLAR_VERSION="28.1.0" STELLAR_TAR="stellar-cli-${STELLAR_VERSION}-x86_64-unknown-linux-gnu.tar.gz" - # Official SHA-256 from https://github.com/stellar/stellar-cli/releases/tag/v27.0.0 - STELLAR_SHA256="357bf712f6353c28cd33c794402a3c87231757a5b305e6ef1604365af4fdd556" + # Official SHA-256 from https://github.com/stellar/stellar-cli/releases/tag/v28.1.0 + STELLAR_SHA256="c1680deee94301d33ada7a17f98411e642a4248c727afbd2e43050d345746462" wget -q "https://github.com/stellar/stellar-cli/releases/download/v${STELLAR_VERSION}/${STELLAR_TAR}" diff --git a/.github/workflows/verify-build.yml b/.github/workflows/verify-build.yml index 48fcb7b..bb01142 100644 --- a/.github/workflows/verify-build.yml +++ b/.github/workflows/verify-build.yml @@ -12,9 +12,11 @@ on: - 'Cargo.lock' - 'rust-toolchain.toml' - 'deploy_mainnet.sh' + - 'deploy_and_upgrade.sh' - 'scripts/**' - '.github/actions/setup-rust-stellar/**' - '.github/workflows/verify-build.yml' + - '.github/workflows/rustfmt.yml' # Use pull_request (not pull_request_target): the check must run the workflow # from the PR head so branch-filter changes take effect on the PR itself, and # the build/test job needs no secrets, so the pull_request_target foot-gun @@ -31,6 +33,7 @@ on: - 'Cargo.lock' - 'rust-toolchain.toml' - 'deploy_mainnet.sh' + - 'deploy_and_upgrade.sh' - 'scripts/**' - '.github/actions/setup-rust-stellar/**' - '.github/workflows/verify-build.yml' @@ -56,10 +59,25 @@ jobs: - name: Check release script syntax run: | bash -n deploy_mainnet.sh + bash -n deploy_and_upgrade.sh bash -n scripts/capture-mainnet-compat-snapshot.sh - bash -n scripts/test-sdk27-compat.sh + bash -n scripts/test-storage-compat.sh bash -n scripts/test-mainnet-upgrade-guards.sh bash -n scripts/testdata/mock-stellar + bash -n scripts/lib/deploy-env.sh + bash -n scripts/deploy/deploy.sh + bash -n scripts/deploy/register_token.sh + bash -n scripts/deploy/verify.sh + bash -n scripts/admin/verify-multisig.sh + bash -n scripts/admin/verify-fee-trustline.sh + bash -n scripts/check-versions.sh + bash -n scripts/test-admin-scripts.sh + + - name: Check contract versions agree + run: ./scripts/check-versions.sh + + - name: Verify admin pre-flight checks + run: ./scripts/test-admin-scripts.sh - name: Verify mainnet upgrade guards run: ./scripts/test-mainnet-upgrade-guards.sh @@ -79,10 +97,11 @@ jobs: fi done - - name: Verify SDK 23 to SDK 27 storage compatibility - env: - VERIFY_SDK27_BUILD: "1" - run: ./scripts/test-sdk27-compat.sh + - name: Replay the mainnet upgrade against this build + run: ./scripts/test-storage-compat.sh + + - name: Lint + run: cargo clippy --locked --all-targets -- -D warnings - name: Run tests run: cargo test --release diff --git a/.gitignore b/.gitignore index c0fd38e..cbe4681 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,12 @@ target .env.deploy deployments/ +# Multisig envelopes in flight. Their sequence numbers are spent once submitted. +signing/ + # Soroban test snapshots: derived artifacts, regenerated by `cargo test`. -# Not committed — snapshot diffs dwarf source diffs and blind the PR scanners. +# Not committed: snapshot diffs dwarf source diffs and blind the PR scanners. test_snapshots/ + +# Script dependencies +node_modules/ diff --git a/deploy_and_upgrade.sh b/deploy_and_upgrade.sh index 6c050f4..42fe9ab 100755 --- a/deploy_and_upgrade.sh +++ b/deploy_and_upgrade.sh @@ -1,258 +1,211 @@ #!/bin/bash - -# Boundless contract deploy + upgrade helper. -# -# Usage: -# ./deploy_and_upgrade.sh deploy [network] [source_account] -# Build + deploy a fresh contract (one of: events, profile). -# The constructor must be supplied via env vars; see the runbook. -# -# ./deploy_and_upgrade.sh propose-upgrade \ -# [network] [source_account] -# Build, upload the new wasm, then call propose_upgrade(wasm_hash, -# new_version). The timelock (~1 day) starts now. # -# ./deploy_and_upgrade.sh apply-upgrade [network] [source_account] -# After the timelock has elapsed (and before expires_at_ledger), call -# apply_upgrade(). The contract swaps to the proposed wasm and bumps -# its on-chain version label. +# Testnet upgrade helper for boundless-events and boundless-profile. # -# ./deploy_and_upgrade.sh cancel-pending-upgrade \ -# [network] [source_account] -# Admin prunes a stale proposal so a fresh one can be queued. +# Usage: ./deploy_and_upgrade.sh [network] [source] # -# ./deploy_and_upgrade.sh migrate [network] [source_account] -# Admin runs the post-upgrade one-shot migration for the current -# Version. Guarded by MigratedToVersion so a second call rejects with -# MigrationAlreadyApplied. +# The network is always argument 4 (default testnet) and the source identity +# argument 5 (default: deployer_identity in deployments/.json). Pass +# "-" as argument 3 when the action takes none. Contract ids come from +# deployments/.json, which scripts/deploy/deploy.sh writes. # -# ./deploy_and_upgrade.sh status [network] -# Print on-chain version + pending upgrade + migrated-to version. +# propose-upgrade build with --features testnet, upload, +# propose_upgrade(wasm_hash, version) +# apply-upgrade - apply_upgrade(); testnet builds have no +# timelock, so this can follow the proposal +# cancel-pending-upgrade - drop a queued proposal +# migrate-events events - migrate_events in pages of 8 until it +# reports 0 remaining +# migrate - migrate(); events refuses while +# migrate_events has rows left +# status - version, pending upgrade, migration marker # -# Spec: docs/mainnet-deploy-runbook.md -# docs/audit-2026-06-stellar-skill.md (H6 timelocked upgrade) +# Fresh deploys: scripts/deploy/deploy.sh. Mainnet: deploy_mainnet.sh and +# docs/upgrade-runbook.md. set -euo pipefail -ACTION=${1:-"status"} -CONTRACT_KIND=${2:-"events"} -NETWORK=${4:-"testnet"} -SOURCE_ACCOUNT=${5:-"alice"} +ACTION=${1:-} +CONTRACT_KIND=${2:-} +ARG3=${3:--} +NETWORK=${4:-testnet} +SOURCE_ACCOUNT=${5:-} GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m' -if ! command -v stellar &> /dev/null; then - echo -e "${RED}Error: stellar CLI is not installed.${NC}" - echo "Install from https://soroban.stellar.org/docs/getting-started/setup" - exit 1 -fi +REPO_ROOT="$(cd "$(dirname "$0")" && pwd)" +RECORD="$REPO_ROOT/deployments/$NETWORK.json" +UPGRADE_LOG="$REPO_ROOT/deployments/$NETWORK-upgrades.jsonl" -case "$CONTRACT_KIND" in - events) - WASM_PATH="target/wasm32v1-none/release/boundless_events.wasm" - CONTRACT_ID_FILE=".stellar/events_contract_id_${NETWORK}.txt" - ;; - profile) - WASM_PATH="target/wasm32v1-none/release/boundless_profile.wasm" - CONTRACT_ID_FILE=".stellar/profile_contract_id_${NETWORK}.txt" - ;; - *) - echo -e "${RED}Unknown contract kind '$CONTRACT_KIND'. Use 'events' or 'profile'.${NC}" - exit 1 - ;; -esac - -build_contract() { - echo "Building $CONTRACT_KIND..." - # Testnet builds enable the `testnet` feature → zero upgrade timelock for - # fast iteration. Both events and profile expose this feature. Mainnet (and - # any non-testnet build) MUST omit it; the default keeps the full - # audit-mandated timelock, so this fails safe. - if [ "$NETWORK" = "testnet" ]; then - stellar contract build --locked --package "boundless-$CONTRACT_KIND" --features testnet - else - stellar contract build --locked - fi +die() { + echo -e "${RED}$*${NC}" >&2 + exit 1 } -read_contract_id() { - # Errors go to stderr so callers using $(read_contract_id) don't capture - # the diagnostic into the variable. - if [ ! -f "$CONTRACT_ID_FILE" ]; then - echo -e "${RED}Contract id file $CONTRACT_ID_FILE not found.${NC}" >&2 - echo "Run 'deploy' first or create the file manually with the C-address." >&2 - exit 1 - fi - cat "$CONTRACT_ID_FILE" +usage() { + sed -n '3,24p' "$0" | sed 's/^# \{0,1\}//' >&2 + exit 1 } -deploy_contract() { - echo -e "${YELLOW}Deploying boundless-$CONTRACT_KIND to $NETWORK...${NC}" - build_contract - - # Constructor args are runbook-specific. We refuse to invent defaults so - # an accidental rerun cannot reinitialize the contract with wrong values. - echo -e "${YELLOW}NOTE: This script does NOT pass constructor args.${NC}" - echo "After the deploy succeeds, invoke the constructor manually per the runbook:" - echo " docs/mainnet-deploy-runbook.md (events: admin, fee_account, fee_bps, profile_contract)" - echo " docs/mainnet-deploy-runbook.md (profile: admin)" - echo "" - - CONTRACT_ID=$(stellar contract deploy \ - --wasm "$WASM_PATH" \ - --source "$SOURCE_ACCOUNT" \ - --network "$NETWORK") - - mkdir -p .stellar - echo "$CONTRACT_ID" > "$CONTRACT_ID_FILE" - echo -e "${GREEN}Deployed.${NC}" - echo "Contract id: $CONTRACT_ID" - echo "Saved to: $CONTRACT_ID_FILE" -} +[ "$ACTION" != deploy ] || \ + die "deploy moved to ./scripts/deploy/deploy.sh , which passes the constructor arguments" +[ -n "$ACTION" ] && [ -n "$CONTRACT_KIND" ] || usage -propose_upgrade() { - # Usage check: needs new_version arg in slot 3. - local NEW_VERSION=${3:-""} - if [ -z "$NEW_VERSION" ]; then - echo -e "${RED}propose-upgrade requires in arg 3.${NC}" - echo "Example: ./deploy_and_upgrade.sh propose-upgrade events 0.3.0 testnet alice" - exit 1 - fi +case "$NETWORK" in + testnet | futurenet) ;; + mainnet) die "mainnet is governed by the multisig: use ./deploy_mainnet.sh (docs/upgrade-runbook.md)" ;; + *) die "network must be testnet or futurenet (got: $NETWORK)" ;; +esac - echo -e "${YELLOW}Proposing upgrade for boundless-$CONTRACT_KIND on $NETWORK to $NEW_VERSION...${NC}" - build_contract +case "$CONTRACT_KIND" in + events | profile) ;; + *) die "unknown contract kind '$CONTRACT_KIND'; use events or profile" ;; +esac - local CONTRACT_ID - CONTRACT_ID=$(read_contract_id) +command -v stellar >/dev/null 2>&1 || die "stellar CLI is not installed" +command -v jq >/dev/null 2>&1 || die "jq is not installed" +[ -f "$RECORD" ] || die "no deployment record at $RECORD; deploy with scripts/deploy/deploy.sh first" - echo "Uploading new wasm..." - local WASM_HASH - WASM_HASH=$(stellar contract upload \ - --source-account "$SOURCE_ACCOUNT" \ - --wasm "$WASM_PATH" \ - --network "$NETWORK") - echo "New wasm hash: $WASM_HASH" +CONTRACT_ID="$(jq -r --arg k "${CONTRACT_KIND}_contract" '.[$k] // empty' "$RECORD")" +[ -n "$CONTRACT_ID" ] || die "${CONTRACT_KIND}_contract missing from $RECORD" +if [ -z "$SOURCE_ACCOUNT" ]; then + SOURCE_ACCOUNT="$(jq -r '.deployer_identity // empty' "$RECORD")" +fi +[ -n "$SOURCE_ACCOUNT" ] || die "pass the admin identity as argument 5 (no deployer_identity in $RECORD)" +WASM_PATH="$REPO_ROOT/target/wasm32v1-none/release/boundless_${CONTRACT_KIND}.wasm" - echo "Calling propose_upgrade..." +invoke() { stellar contract invoke \ --id "$CONTRACT_ID" \ --source "$SOURCE_ACCOUNT" \ --network "$NETWORK" \ - -- \ - propose_upgrade \ - --new_wasm_hash "$WASM_HASH" \ - --new_version "$NEW_VERSION" - - echo -e "${GREEN}Proposal recorded.${NC}" - echo "The timelock is ~1 day at 5s/ledger (17_280 ledgers)." - echo "Run 'apply-upgrade' once the timelock has elapsed." - echo "Run 'status' to inspect available_at / expires_at ledger sequence." + -- "$@" } -apply_upgrade() { - echo -e "${YELLOW}Applying queued upgrade for boundless-$CONTRACT_KIND on $NETWORK...${NC}" - local CONTRACT_ID - CONTRACT_ID=$(read_contract_id) - +read_call() { stellar contract invoke \ --id "$CONTRACT_ID" \ --source "$SOURCE_ACCOUNT" \ --network "$NETWORK" \ - -- \ - apply_upgrade + --send no \ + -- "$@" +} - echo -e "${GREEN}Upgrade applied.${NC}" - echo "Run 'status' to confirm the new version label." - echo "Run 'migrate' if the upgrade requires a data migration." +record_set() { + local tmp + tmp="$(mktemp)" + jq --arg k "$1" --arg v "$2" '.[$k] = $v' "$RECORD" > "$tmp" + mv "$tmp" "$RECORD" } -cancel_pending_upgrade() { - echo -e "${YELLOW}Cancelling pending upgrade for boundless-$CONTRACT_KIND on $NETWORK...${NC}" - local CONTRACT_ID - CONTRACT_ID=$(read_contract_id) +log_op() { + jq -nc \ + --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg op "$1" \ + --arg contract "$CONTRACT_KIND" \ + --arg contract_id "$CONTRACT_ID" \ + --arg version "${2:-}" \ + --arg wasm_hash "${3:-}" \ + '{ts: $ts, op: $op, contract: $contract, contract_id: $contract_id, version: $version, wasm_hash: $wasm_hash}' \ + >> "$UPGRADE_LOG" +} - stellar contract invoke \ - --id "$CONTRACT_ID" \ - --source "$SOURCE_ACCOUNT" \ +propose_upgrade() { + local new_version="$ARG3" meta_version wasm_hash + [ "$new_version" != "-" ] || \ + die "propose-upgrade needs the new version as argument 3, e.g. ./deploy_and_upgrade.sh propose-upgrade events 2.1.0 testnet" + + echo -e "${YELLOW}Building boundless-$CONTRACT_KIND with --features testnet...${NC}" + (cd "$REPO_ROOT" && stellar contract build --locked --package "boundless-$CONTRACT_KIND" --features testnet) + meta_version="$(stellar contract info meta --wasm "$WASM_PATH" --output json 2>/dev/null \ + | jq -r '[.[] | .sc_meta_v0? | select(.key == "version") | .val][0] // empty')" + [ "$meta_version" = "$new_version" ] || \ + die "the build reports version ${meta_version:-none} in its contractmeta, not $new_version" + + wasm_hash="$(stellar contract upload \ + --source-account "$SOURCE_ACCOUNT" \ --network "$NETWORK" \ - -- \ - cancel_pending_upgrade - echo -e "${GREEN}Pending upgrade cleared.${NC}" + --optimize=false \ + --wasm "$WASM_PATH")" + echo "Uploaded wasm: $wasm_hash" + + invoke propose_upgrade --new_wasm_hash "$wasm_hash" --new_version "$new_version" + log_op propose_upgrade "$new_version" "$wasm_hash" + echo -e "${GREEN}Proposed $CONTRACT_KIND $new_version.${NC} Testnet builds have no timelock; run apply-upgrade next." } -migrate() { - echo -e "${YELLOW}Running post-upgrade migrate for boundless-$CONTRACT_KIND on $NETWORK...${NC}" - local CONTRACT_ID - CONTRACT_ID=$(read_contract_id) +apply_upgrade() { + local pending version wasm_hash + pending="$(read_call get_pending_upgrade)" + [ "$pending" != "null" ] || die "no $CONTRACT_KIND upgrade is pending" + version="$(printf '%s' "$pending" | jq -r '.new_version')" + wasm_hash="$(printf '%s' "$pending" | jq -r '.wasm_hash')" + + invoke apply_upgrade + record_set "${CONTRACT_KIND}_version" "$version" + record_set "${CONTRACT_KIND}_wasm_hash" "$wasm_hash" + record_set upgraded_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + log_op apply_upgrade "$version" "$wasm_hash" + echo -e "${GREEN}Applied $CONTRACT_KIND $version.${NC}" + if [ "$CONTRACT_KIND" = events ]; then + echo "Next: migrate-events, then migrate." + else + echo "Next: migrate." + fi +} - stellar contract invoke \ - --id "$CONTRACT_ID" \ - --source "$SOURCE_ACCOUNT" \ - --network "$NETWORK" \ - -- \ - migrate - echo -e "${GREEN}Migrate completed.${NC}" +cancel_pending_upgrade() { + invoke cancel_pending_upgrade + log_op cancel_pending_upgrade + echo -e "${GREEN}Pending $CONTRACT_KIND upgrade cancelled.${NC}" +} + +migrate_events() { + [ "$CONTRACT_KIND" = events ] || die "migrate-events applies to the events contract only" + local remaining calls=0 + while :; do + remaining="$(invoke migrate_events --max_events 8 | tr -d '"')" + [[ "$remaining" =~ ^[0-9]+$ ]] || die "migrate_events returned '$remaining'" + calls=$((calls + 1)) + echo "migrate_events call $calls: $remaining remaining" + [ "$remaining" -gt 0 ] || break + done + log_op migrate_events "" "" + echo -e "${GREEN}migrate_events done after $calls call(s).${NC} Run migrate next." +} + +migrate() { + local version + invoke migrate + version="$(read_call version | tr -d '"')" + record_set "${CONTRACT_KIND}_migrated_to_version" "$version" + log_op migrate "$version" + echo -e "${GREEN}$CONTRACT_KIND migrated to $version.${NC}" } show_status() { - local CONTRACT_ID - CONTRACT_ID=$(read_contract_id) - echo -e "${YELLOW}Status for boundless-$CONTRACT_KIND on $NETWORK${NC}" - echo "Contract id: $CONTRACT_ID" - echo "" - echo "On-chain version:" - stellar contract invoke \ - --id "$CONTRACT_ID" \ - --source "$SOURCE_ACCOUNT" \ - --network "$NETWORK" \ - --send no \ - -- \ - version || true - echo "" - echo "Pending upgrade (if any):" - stellar contract invoke \ - --id "$CONTRACT_ID" \ - --source "$SOURCE_ACCOUNT" \ - --network "$NETWORK" \ - --send no \ - -- \ - get_pending_upgrade || true - echo "" - echo "Last migrated-to version:" - stellar contract invoke \ - --id "$CONTRACT_ID" \ - --source "$SOURCE_ACCOUNT" \ - --network "$NETWORK" \ - --send no \ - -- \ - get_migrated_to_version || true + echo -e "${YELLOW}boundless-$CONTRACT_KIND on $NETWORK: $CONTRACT_ID${NC}" + echo "version: $(read_call version || echo '(read failed)')" + echo "migrated_to_version: $(read_call get_migrated_to_version || echo '(read failed)')" + echo "pending_upgrade: $(read_call get_pending_upgrade || echo '(read failed)')" + echo "is_paused: $(read_call is_paused || echo '(read failed)')" + if [ "$CONTRACT_KIND" = events ]; then + echo "release_validator: $(read_call get_release_validator || echo '(read failed)')" + fi } case "$ACTION" in - deploy) deploy_contract ;; - propose-upgrade) propose_upgrade "$@" ;; - apply-upgrade) apply_upgrade ;; + propose-upgrade) propose_upgrade ;; + apply-upgrade) apply_upgrade ;; cancel-pending-upgrade) cancel_pending_upgrade ;; - migrate) migrate ;; - status) show_status ;; - upgrade) - echo -e "${RED}'upgrade' is gone. The 2026-06 audit replaced it with a timelock flow:${NC}" - echo " 1. propose-upgrade " - echo " 2. wait ~1 day (the on-chain UPGRADE_TIMELOCK_LEDGERS)" - echo " 3. apply-upgrade " - echo " 4. migrate (if the upgrade ships storage changes)" - echo "" - echo "See docs/audit-2026-06-stellar-skill.md (H6) for the rationale." - exit 1 - ;; + migrate-events) migrate_events ;; + migrate) migrate ;; + status) show_status ;; *) - echo -e "${RED}Unknown action '$ACTION'.${NC}" - echo "Use: deploy | propose-upgrade | apply-upgrade | cancel-pending-upgrade | migrate | status" - exit 1 + echo -e "${RED}Unknown action '$ACTION'.${NC}" >&2 + usage ;; esac - -echo -e "${GREEN}Done.${NC}" diff --git a/deploy_mainnet.sh b/deploy_mainnet.sh index a716e30..5138548 100755 --- a/deploy_mainnet.sh +++ b/deploy_mainnet.sh @@ -1,39 +1,47 @@ #!/bin/bash # -# Boundless mainnet deploy script. +# Mainnet deploy and upgrade tool for boundless-events and boundless-profile. # -# This is a separate script from deploy_and_upgrade.sh because mainnet deploys -# need stronger guards (explicit confirmation, env var validation, paired -# deploy of profile + events, multi-sig rotation flow) than the testnet -# scripted path can carry safely. +# The deploy commands sign with INITIAL_ADMIN_KEY, a throwaway deployer that +# hands admin to the multisig and is then destroyed. Every command that acts as +# the admin of a live contract only builds and simulates an unsigned XDR for +# the 2-of-3 multisig; nothing here submits an admin transaction. # -# Companion: boundless-contract/docs/mainnet-deploy-runbook.md +# Procedures: docs/DEPLOYMENT.md (fresh deploy), docs/upgrade-runbook.md. # # Usage: # ./deploy_mainnet.sh deploy-profile # ./deploy_mainnet.sh deploy-events -# ./deploy_mainnet.sh register-token +# ./deploy_mainnet.sh register-token # ./deploy_mainnet.sh rotate-admin -# ./deploy_mainnet.sh upload-profile-wasm -# ./deploy_mainnet.sh upload-events-wasm +# ./deploy_mainnet.sh upload-profile-wasm +# ./deploy_mainnet.sh upload-events-wasm # ./deploy_mainnet.sh prepare-pause-profile # ./deploy_mainnet.sh prepare-pause-events -# ./deploy_mainnet.sh prepare-propose-upgrade-profile -# ./deploy_mainnet.sh prepare-propose-upgrade-events -# ./deploy_mainnet.sh verify-proposed-upgrade-profile -# ./deploy_mainnet.sh verify-proposed-upgrade-events -# ./deploy_mainnet.sh prepare-apply-upgrade-profile -# ./deploy_mainnet.sh prepare-apply-upgrade-events -# ./deploy_mainnet.sh prepare-migrate-upgrade-profile -# ./deploy_mainnet.sh prepare-migrate-upgrade-events +# ./deploy_mainnet.sh prepare-propose-upgrade-profile +# ./deploy_mainnet.sh prepare-propose-upgrade-events +# ./deploy_mainnet.sh verify-proposed-upgrade-profile +# ./deploy_mainnet.sh verify-proposed-upgrade-events +# ./deploy_mainnet.sh prepare-apply-upgrade-profile +# ./deploy_mainnet.sh prepare-apply-upgrade-events +# ./deploy_mainnet.sh prepare-migrate-events-page +# ./deploy_mainnet.sh prepare-migrate-upgrade-profile +# ./deploy_mainnet.sh prepare-migrate-upgrade-events # ./deploy_mainnet.sh prepare-cancel-upgrade-profile # ./deploy_mainnet.sh prepare-cancel-upgrade-events -# ./deploy_mainnet.sh prepare-unpause-profile -# ./deploy_mainnet.sh prepare-unpause-events -# ./deploy_mainnet.sh verify-upgrade-profile -# ./deploy_mainnet.sh verify-upgrade-events +# ./deploy_mainnet.sh prepare-unpause-profile +# ./deploy_mainnet.sh prepare-unpause-events +# ./deploy_mainnet.sh verify-upgrade-profile +# ./deploy_mainnet.sh verify-upgrade-events # ./deploy_mainnet.sh upgrade-status # ./deploy_mainnet.sh verify +# +# Environment: +# STELLAR_NETWORK name of a configured network using the public passphrase +# ADMIN_SOURCE current admin G-address (the multisig); also the read source +# EVENTS_ID, PROFILE_ID default to the local record deployments/mainnet.json +# UPLOAD_SOURCE any funded CLI identity, for upload-*-wasm +# INITIAL_ADMIN_KEY, FEE_ACCOUNT, INITIAL_GLOBAL_FEE_BPS deploy commands only set -euo pipefail @@ -48,12 +56,9 @@ NC='\033[0m' NETWORK="${STELLAR_NETWORK:-}" NETWORK_PASSPHRASE="Public Global Stellar Network ; September 2015" -REQUIRED_STELLAR_CLI_VERSION="27.0.0" -SDK27_EVENTS_VERSION="1.5.0" -SDK27_EVENTS_WASM_HASH="31b27e9d5fe554e0710161dd9387693e166bdd755d985a9771b1ed71b9e28321" -SDK27_PROFILE_VERSION="1.2.0" -SDK27_PROFILE_WASM_HASH="d2d025d0f7c2c29e6dbd95401841cf62b8b61be608c48615cffb5934050890c0" -DEPLOYMENTS_DIR="$(cd "$(dirname "$0")" && pwd)/deployments" +REQUIRED_STELLAR_CLI_VERSION="28.1.0" +REPO_ROOT="$(cd "$(dirname "$0")" && pwd)" +DEPLOYMENTS_DIR="$REPO_ROOT/deployments" DEPLOYMENT_FILE="$DEPLOYMENTS_DIR/mainnet.json" err() { @@ -87,6 +92,12 @@ require_env() { fi } +require_version_arg() { + local version="$1" + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || \ + err "version must look like 2.0.0; got '$version'" +} + require_cli() { [ -n "$NETWORK" ] || \ err "set STELLAR_NETWORK to the configured mainnet network name" @@ -133,132 +144,92 @@ hash_wasm() { } deployed_wasm_hash() { - local contract_id="$1" - stellar contract info hash \ - --network "$NETWORK" \ - --contract-id "$contract_id" -} - -assert_local_wasm_hash() { - local label="$1" wasm="$2" expected="$3" - local actual - actual="$(hash_wasm "$wasm")" - [ "$actual" = "$expected" ] || \ - err "$label artifact hash mismatch: expected $expected, got $actual" -} - -assert_deployed_wasm_hash() { - local label="$1" contract_id="$2" expected="$3" - local actual - actual="$(deployed_wasm_hash "$contract_id")" || \ - err "could not read deployed $label WASM hash" - [ "$actual" = "$expected" ] || \ - err "deployed $label WASM hash mismatch: expected $expected, got $actual" + stellar contract info hash --network "$NETWORK" --contract-id "$1" } -assert_sdk27_profile_artifact() { - local wasm="$1" version="$2" - if [ "$version" = "$SDK27_PROFILE_VERSION" ]; then - assert_local_wasm_hash "profile SDK 27" "$wasm" "$SDK27_PROFILE_WASM_HASH" - fi -} - -assert_sdk27_events_artifact() { - local wasm="$1" version="$2" - if [ "$version" = "$SDK27_EVENTS_VERSION" ]; then - assert_local_wasm_hash "events SDK 27" "$wasm" "$SDK27_EVENTS_WASM_HASH" - fi -} - -assert_profile_sdk27_wasm_live() { - assert_deployed_wasm_hash "profile SDK 27" "$1" "$SDK27_PROFILE_WASM_HASH" -} - -assert_events_sdk27_wasm_live() { - assert_deployed_wasm_hash "events SDK 27" "$1" "$SDK27_EVENTS_WASM_HASH" -} - -assert_version_argument() { - local label="$1" actual="$2" expected="$3" - [ "$actual" = "$expected" ] || \ - err "$label version must be $expected for this pinned mainnet flow; got $actual" -} - -assert_unpause_version_argument() { - local label="$1" actual="$2" upgraded="$3" - case "$actual" in - "1.1.0"|"$upgraded") ;; - *) err "$label unpause version must be 1.1.0 for pre-apply recovery or $upgraded after upgrade; got $actual" ;; +# A wasm's kind is proven by an entry point only that contract exports, and its +# version by the contractmeta the build embeds, so a mislabelled or swapped +# artifact cannot be proposed under the wrong name. +assert_wasm_artifact() { + local kind="$1" wasm="$2" version="$3" + local marker meta_version + [ -f "$wasm" ] || err "wasm file not found: $wasm" + case "$kind" in + events) marker=create_event ;; + profile) marker=register_earnings ;; esac + stellar contract info interface --wasm "$wasm" --output json 2>/dev/null \ + | jq -e --arg f "$marker" 'any(.[]; .function_v0?.name == $f)' >/dev/null || \ + err "$wasm is not a boundless-$kind build (its interface has no $marker)" + meta_version="$(stellar contract info meta --wasm "$wasm" --output json 2>/dev/null \ + | jq -r '[.[] | .sc_meta_v0? | select(.key == "version") | .val][0] // empty')" || \ + err "could not read the contractmeta of $wasm" + [ -n "$meta_version" ] || err "$wasm has no contractmeta version" + [ "$meta_version" = "$version" ] || \ + err "$wasm reports version $meta_version in its contractmeta, not $version" } ensure_deployments_dir() { mkdir -p "$DEPLOYMENTS_DIR" if [ ! -f "$DEPLOYMENT_FILE" ]; then - echo "{\"network\":\"$NETWORK\",\"passphrase\":\"$NETWORK_PASSPHRASE\"}" > "$DEPLOYMENT_FILE" + echo "{\"network\":\"mainnet\",\"passphrase\":\"$NETWORK_PASSPHRASE\"}" > "$DEPLOYMENT_FILE" fi } deployment_get() { - local key="$1" - jq -r ".\"$key\" // empty" "$DEPLOYMENT_FILE" + jq -r --arg k "$1" '.[$k] // empty' "$DEPLOYMENT_FILE" } deployment_set() { - local key="$1" val="$2" local tmp tmp="$(mktemp)" - jq ".\"$key\" = \"$val\"" "$DEPLOYMENT_FILE" > "$tmp" + jq --arg k "$1" --arg v "$2" '.[$k] = $v' "$DEPLOYMENT_FILE" > "$tmp" mv "$tmp" "$DEPLOYMENT_FILE" } deployment_set_raw() { - local key="$1" val="$2" local tmp tmp="$(mktemp)" - jq ".\"$key\" = $val" "$DEPLOYMENT_FILE" > "$tmp" + jq --arg k "$1" --argjson v "$2" '.[$k] = $v' "$DEPLOYMENT_FILE" > "$tmp" mv "$tmp" "$DEPLOYMENT_FILE" } -events_contract_id() { - local events_id="${EVENTS_ID:-}" - if [ -z "$events_id" ] && [ -f "$DEPLOYMENT_FILE" ]; then - events_id="$(deployment_get events_contract)" - fi - [ -n "$events_id" ] || \ - err "events contract id missing; set EVENTS_ID or restore deployments/mainnet.json" - printf '%s\n' "$events_id" +append_upgrade_log() { + local action="$1" contract="$2" version="$3" wasm_hash="${4:-}" + ensure_deployments_dir + jq -nc \ + --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg action "$action" \ + --arg contract "$contract" \ + --arg version "$version" \ + --arg wasm_hash "$wasm_hash" \ + '{timestamp: $timestamp, action: $action, contract: $contract, version: $version, wasm_hash: $wasm_hash}' \ + >> "$DEPLOYMENTS_DIR/mainnet-upgrades.jsonl" } -profile_contract_id() { - local profile_id="${PROFILE_ID:-}" - if [ -z "$profile_id" ] && [ -f "$DEPLOYMENT_FILE" ]; then - profile_id="$(deployment_get profile_contract)" +contract_id() { + local kind="$1" var id="" + case "$kind" in + events) var=EVENTS_ID ;; + profile) var=PROFILE_ID ;; + esac + id="${!var:-}" + if [ -z "$id" ] && [ -f "$DEPLOYMENT_FILE" ]; then + id="$(deployment_get "${kind}_contract")" fi - [ -n "$profile_id" ] || \ - err "profile contract id missing; set PROFILE_ID or restore deployments/mainnet.json" - printf '%s\n' "$profile_id" + [ -n "$id" ] || \ + err "$kind contract id missing; set $var or restore deployments/mainnet.json" + printf '%s\n' "$id" } -events_read() { - local events_id="$1" +contract_read() { + local id="$1" shift stellar contract invoke \ --send no \ --network "$NETWORK" \ --source "$ADMIN_SOURCE" \ - --id "$events_id" \ - -- "$@" -} - -profile_read() { - local profile_id="$1" - shift - stellar contract invoke \ - --send no \ - --network "$NETWORK" \ - --source "$ADMIN_SOURCE" \ - --id "$profile_id" \ + --id "$id" \ -- "$@" } @@ -266,271 +237,124 @@ read_json_string() { jq -er 'if type == "string" then . else error("expected JSON string") end' } -assert_events_version() { - local events_id="$1" expected="$2" - local actual - actual="$(events_read "$events_id" version | read_json_string)" || \ - err "could not read the current events contract version" - [ "$actual" = "$expected" ] || \ - err "events version mismatch: expected $expected, got $actual" -} - -assert_profile_version() { - local profile_id="$1" expected="$2" - local actual - actual="$(profile_read "$profile_id" version | read_json_string)" || \ - err "could not read the current profile contract version" +assert_version() { + local kind="$1" id="$2" expected="$3" actual + actual="$(contract_read "$id" version | read_json_string)" || \ + err "could not read the $kind version" [ "$actual" = "$expected" ] || \ - err "profile version mismatch: expected $expected, got $actual" + err "$kind version mismatch: expected $expected, got $actual" } assert_admin_source_matches() { - local events_id="$1" - local actual_admin - actual_admin="$(events_read "$events_id" get_admin | read_json_string)" || \ - err "could not read the current events admin" - [ "$actual_admin" = "$ADMIN_SOURCE" ] || \ - err "ADMIN_SOURCE mismatch: contract admin is $actual_admin, got $ADMIN_SOURCE" -} - -assert_profile_admin_source_matches() { - local profile_id="$1" - local actual_admin - actual_admin="$(profile_read "$profile_id" get_admin | read_json_string)" || \ - err "could not read the current profile admin" - [ "$actual_admin" = "$ADMIN_SOURCE" ] || \ - err "ADMIN_SOURCE mismatch: profile contract admin is $actual_admin, got $ADMIN_SOURCE" -} - -assert_profile_events_binding() { - local profile_id="$1" expected_events_id="$2" - local actual_events_id - actual_events_id="$(profile_read "$profile_id" get_events_contract | read_json_string)" || \ + local kind="$1" id="$2" actual + actual="$(contract_read "$id" get_admin | read_json_string)" || \ + err "could not read the $kind admin" + [ "$actual" = "$ADMIN_SOURCE" ] || \ + err "ADMIN_SOURCE mismatch: $kind admin is $actual, got $ADMIN_SOURCE" +} + +# Both admins must be the signer set preparing the XDR, and the profile must +# still trust this events contract with no rotation in flight; otherwise a +# prepared envelope could act on a contract the multisig no longer governs. +assert_governance_stable() { + local profile_id="$1" events_id="$2" binding pending + assert_admin_source_matches events "$events_id" + assert_admin_source_matches profile "$profile_id" + binding="$(contract_read "$profile_id" get_events_contract | read_json_string)" || \ err "could not read profile.get_events_contract" - [ "$actual_events_id" = "$expected_events_id" ] || \ - err "profile events-contract binding changed: expected $expected_events_id, got $actual_events_id" -} - -assert_no_pending_events_contract_rotation() { - local profile_id="$1" - local pending - pending="$(profile_read "$profile_id" get_pending_events_contract)" || \ + [ "$binding" = "$events_id" ] || \ + err "profile events-contract binding changed: expected $events_id, got $binding" + pending="$(contract_read "$profile_id" get_pending_events_contract)" || \ err "could not read profile.get_pending_events_contract" [ "$pending" = "null" ] || \ - err "a profile events-contract rotation is pending; cancel or complete it before the upgrade" -} - -assert_upgrade_governance_stable() { - local profile_id="$1" events_id="$2" - assert_admin_source_matches "$events_id" - assert_profile_admin_source_matches "$profile_id" - assert_profile_events_binding "$profile_id" "$events_id" - assert_no_pending_events_contract_rotation "$profile_id" -} - -assert_events_paused() { - local events_id="$1" - local paused - paused="$(events_read "$events_id" is_paused)" || \ - err "could not read events.is_paused" - [ "$paused" = "true" ] || \ - err "events contract is not paused; refusing to rely on a zero-event snapshot" -} - -assert_events_unpaused() { - local events_id="$1" - local paused - paused="$(events_read "$events_id" is_paused)" || \ - err "could not read events.is_paused" - [ "$paused" = "false" ] || \ - err "events contract is still paused after unpause" -} - -assert_profile_paused() { - local profile_id="$1" - local paused - paused="$(profile_read "$profile_id" is_paused)" || \ - err "could not read profile.is_paused" - [ "$paused" = "true" ] || \ - err "profile contract is not paused" + err "a profile events-contract rotation is pending; cancel or complete it first" } -assert_profile_unpaused() { - local profile_id="$1" - local paused - paused="$(profile_read "$profile_id" is_paused)" || \ - err "could not read profile.is_paused" - [ "$paused" = "false" ] || \ - err "profile contract is still paused after unpause" +assert_paused() { + local kind="$1" id="$2" paused + paused="$(contract_read "$id" is_paused)" || err "could not read $kind.is_paused" + [ "$paused" = "true" ] || err "$kind contract is not paused" } -assert_no_pending_events_upgrade() { - local events_id="$1" - local pending - pending="$(events_read "$events_id" get_pending_upgrade)" || \ - err "could not read pending events upgrade" - [ "$pending" = "null" ] || \ - err "an events upgrade is already pending; inspect it with upgrade-status" -} - -assert_pending_events_upgrade() { - local events_id="$1" expected_version="$2" expected_hash="$3" - local pending actual_version actual_hash - pending="$(events_read "$events_id" get_pending_upgrade)" || \ - err "could not read pending events upgrade" - [ "$pending" != "null" ] || err "no events upgrade is pending" - actual_version="$(printf '%s' "$pending" | jq -er '.new_version')" || \ - err "pending events upgrade did not contain new_version" - actual_hash="$(printf '%s' "$pending" | jq -er '.wasm_hash')" || \ - err "pending events upgrade did not contain wasm_hash" - [ "$actual_version" = "$expected_version" ] || \ - err "pending version mismatch: expected $expected_version, got $actual_version" - [ "$actual_hash" = "$expected_hash" ] || \ - err "pending wasm mismatch: expected $expected_hash, got $actual_hash" +assert_unpaused() { + local kind="$1" id="$2" paused + paused="$(contract_read "$id" is_paused)" || err "could not read $kind.is_paused" + [ "$paused" = "false" ] || err "$kind contract is paused" } -assert_no_pending_profile_upgrade() { - local profile_id="$1" - local pending - pending="$(profile_read "$profile_id" get_pending_upgrade)" || \ - err "could not read pending profile upgrade" +assert_no_pending_upgrade() { + local kind="$1" id="$2" pending + pending="$(contract_read "$id" get_pending_upgrade)" || \ + err "could not read the pending $kind upgrade" [ "$pending" = "null" ] || \ - err "a profile upgrade is already pending; inspect it with upgrade-status" + err "$kind already has a pending upgrade; inspect it with upgrade-status" } -assert_pending_profile_upgrade() { - local profile_id="$1" expected_version="$2" expected_hash="$3" +assert_pending_upgrade() { + local kind="$1" id="$2" expected_version="$3" expected_hash="$4" local pending actual_version actual_hash - pending="$(profile_read "$profile_id" get_pending_upgrade)" || \ - err "could not read pending profile upgrade" - [ "$pending" != "null" ] || err "no profile upgrade is pending" + pending="$(contract_read "$id" get_pending_upgrade)" || \ + err "could not read the pending $kind upgrade" + [ "$pending" != "null" ] || err "no $kind upgrade is pending" actual_version="$(printf '%s' "$pending" | jq -er '.new_version')" || \ - err "pending profile upgrade did not contain new_version" + err "pending $kind upgrade did not contain new_version" actual_hash="$(printf '%s' "$pending" | jq -er '.wasm_hash')" || \ - err "pending profile upgrade did not contain wasm_hash" + err "pending $kind upgrade did not contain wasm_hash" [ "$actual_version" = "$expected_version" ] || \ - err "pending profile version mismatch: expected $expected_version, got $actual_version" + err "pending $kind version mismatch: expected $expected_version, got $actual_version" [ "$actual_hash" = "$expected_hash" ] || \ - err "pending profile wasm mismatch: expected $expected_hash, got $actual_hash" + err "pending $kind wasm mismatch: expected $expected_hash, got $actual_hash" } -assert_pending_events_version() { - local events_id="$1" expected_version="$2" - local pending actual_version - pending="$(events_read "$events_id" get_pending_upgrade)" || \ - err "could not read pending events upgrade" - [ "$pending" != "null" ] || err "no events upgrade is pending" - actual_version="$(printf '%s' "$pending" | jq -er '.new_version')" || \ - err "pending events upgrade did not contain new_version" - [ "$actual_version" = "$expected_version" ] || \ - err "pending events version mismatch: expected $expected_version, got $actual_version" -} - -assert_profile_sdk27_staged_or_applied() { - local profile_id="$1" - local version pending - version="$(profile_read "$profile_id" version | read_json_string)" || \ - err "could not read the current profile contract version" - pending="$(profile_read "$profile_id" get_pending_upgrade)" || \ - err "could not read pending profile upgrade" - assert_profile_paused "$profile_id" - - if [ "$version" = "1.1.0" ]; then - assert_pending_profile_upgrade \ - "$profile_id" "$SDK27_PROFILE_VERSION" "$SDK27_PROFILE_WASM_HASH" - elif [ "$version" = "1.2.0" ]; then - [ "$pending" = "null" ] || \ - err "profile is 1.2.0 but still has a pending upgrade" - assert_profile_sdk27_wasm_live "$profile_id" - else - err "profile must be staged at 1.1.0 or applied at 1.2.0; got $version" - fi -} - -assert_profile_sdk27_applied_paused() { - local profile_id="$1" - assert_profile_version "$profile_id" "1.2.0" - assert_profile_sdk27_wasm_live "$profile_id" - assert_profile_paused "$profile_id" - assert_no_pending_profile_upgrade "$profile_id" -} - -assert_profile_sdk27_migrated_paused() { - local profile_id="$1" - assert_profile_sdk27_applied_paused "$profile_id" - assert_profile_migrated_version "$profile_id" "1.2.0" +assert_live_hash() { + local kind="$1" id="$2" expected="$3" actual + actual="$(deployed_wasm_hash "$id")" || err "could not read the deployed $kind wasm hash" + [ "$actual" = "$expected" ] || \ + err "deployed $kind wasm mismatch: expected $expected, got $actual" } -assert_profile_sdk27_ready() { - local profile_id="$1" - assert_profile_version "$profile_id" "1.2.0" - assert_profile_sdk27_wasm_live "$profile_id" - assert_profile_migrated_version "$profile_id" "1.2.0" - assert_no_pending_profile_upgrade "$profile_id" - assert_profile_unpaused "$profile_id" +migrated_version() { + contract_read "$1" get_migrated_to_version | jq -r '. // empty' } -assert_no_events_exist() { - local events_id="$1" - local base first_event_id output status - - base="$(events_read "$events_id" id_base)" || \ - err "could not read events.id_base" - [[ "$base" =~ ^[0-9]+$ ]] || \ - err "events.id_base returned a non-numeric value: $base" - first_event_id=$((base + 1)) - +# Simulates migrate() to learn whether it would stamp. Prints ready, +# incomplete (events: migrate_events still has rows to convert, error 97) or +# applied (already stamped: events 69, profile 43). +migrate_state() { + local kind="$1" id="$2" output status applied_code + case "$kind" in + events) applied_code=69 ;; + profile) applied_code=43 ;; + esac set +e - output="$(events_read "$events_id" get_event --event_id "$first_event_id" 2>&1)" + output="$(contract_read "$id" migrate 2>&1)" status=$? set -e - if [ "$status" -eq 0 ]; then - err "event $first_event_id exists; abort the upgrade and implement a legacy-total migration" - fi - if [[ "$output" != *"Error(Contract, #30)"* ]]; then - err "could not prove event $first_event_id is absent; refusing to continue: $output" + echo ready + elif [ "$kind" = events ] && [[ "$output" == *"Error(Contract, #97)"* ]]; then + echo incomplete + elif [[ "$output" == *"Error(Contract, #$applied_code)"* ]]; then + echo applied + else + printf '%s\n' "$output" >&2 + return 1 fi - - ok "Zero-event guard confirmed: $first_event_id returned EventNotFound." -} - -is_zero_event_guarded_target() { - [ "$1" = "$SDK27_EVENTS_VERSION" ] -} - -assert_migrated_version() { - local events_id="$1" expected="$2" - local migrated_version - migrated_version="$(events_read "$events_id" get_migrated_to_version | read_json_string)" || \ - err "could not read migrated-to version" - [ "$migrated_version" = "$expected" ] || \ - err "migration marker mismatch: expected $expected, got $migrated_version" -} - -assert_profile_migrated_version() { - local profile_id="$1" expected="$2" - local migrated_version - migrated_version="$(profile_read "$profile_id" get_migrated_to_version | read_json_string)" || \ - err "could not read profile migrated-to version" - [ "$migrated_version" = "$expected" ] || \ - err "profile migration marker mismatch: expected $expected, got $migrated_version" } -prepare_events_invoke() { - local events_id="$1" output_file="$2" - shift 2 - local output_dir +prepare_invoke() { + local kind="$1" id="$2" output_file="$3" + shift 3 + local output_dir tmp output_dir="$(dirname "$output_file")" [ -d "$output_dir" ] || err "XDR output directory does not exist: $output_dir" - assert_admin_source_matches "$events_id" + assert_admin_source_matches "$kind" "$id" - local tmp tmp="$(mktemp)" if ! stellar contract invoke \ --network "$NETWORK" \ --source-account "$ADMIN_SOURCE" \ - --id "$events_id" \ + --id "$id" \ --build-only \ -- "$@" \ | stellar tx simulate \ @@ -538,86 +362,45 @@ prepare_events_invoke() { --source-account "$ADMIN_SOURCE" \ > "$tmp"; then rm -f "$tmp" - err "could not build and simulate the multisig transaction" + err "could not build and simulate the $kind $1 transaction" fi [ -s "$tmp" ] || { rm -f "$tmp" - err "prepared transaction was empty" + err "prepared $kind transaction was empty" } mv "$tmp" "$output_file" - ok "Prepared simulated XDR: $output_file" - echo "Sign it sequentially with the required multi-sig quorum, then submit with:" + ok "Prepared simulated XDR for $kind $1: $output_file" + echo "Decode it, sign it sequentially with the multisig quorum, then submit with:" echo " stellar tx send \"\" --network $NETWORK" } -prepare_profile_invoke() { - local profile_id="$1" output_file="$2" - shift 2 - local output_dir - output_dir="$(dirname "$output_file")" - [ -d "$output_dir" ] || err "XDR output directory does not exist: $output_dir" - assert_profile_admin_source_matches "$profile_id" - - local tmp - tmp="$(mktemp)" - if ! stellar contract invoke \ - --network "$NETWORK" \ - --source-account "$ADMIN_SOURCE" \ - --id "$profile_id" \ - --build-only \ - -- "$@" \ - | stellar tx simulate \ - --network "$NETWORK" \ - --source-account "$ADMIN_SOURCE" \ - > "$tmp"; then - rm -f "$tmp" - err "could not build and simulate the profile multisig transaction" +upgrade_ids() { + events_id="$(contract_id events)" + profile_id="$(contract_id profile)" + assert_governance_stable "$profile_id" "$events_id" + if [ "$1" = events ]; then + target_id="$events_id" + else + target_id="$profile_id" fi - [ -s "$tmp" ] || { - rm -f "$tmp" - err "prepared profile transaction was empty" - } - mv "$tmp" "$output_file" - ok "Prepared simulated XDR: $output_file" - echo "Sign it sequentially with the required multi-sig quorum, then submit with:" - echo " stellar tx send \"\" --network $NETWORK" -} - -append_upgrade_log() { - local action="$1" version="$2" wasm_hash="${3:-}" - local upgrades_log="$DEPLOYMENTS_DIR/mainnet-upgrades.jsonl" - jq -nc \ - --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - --arg action "$action" \ - --arg version "$version" \ - --arg wasm_hash "$wasm_hash" \ - '{timestamp: $timestamp, action: $action, version: $version, wasm_hash: $wasm_hash}' \ - >> "$upgrades_log" -} - -cmd_build_release() { - info "Building contracts in release mode..." - stellar contract build --locked - ok "Build complete." } cmd_deploy_profile() { require_env INITIAL_ADMIN_KEY require_cli - confirm_mainnet ensure_deployments_dir - if [ -n "$(deployment_get profile_contract)" ]; then err "profile contract already deployed: $(deployment_get profile_contract). Refusing to re-deploy." fi + confirm_mainnet - cmd_build_release - - local wasm="target/wasm32v1-none/release/boundless_profile.wasm" + info "Building boundless-profile without the testnet feature..." + (cd "$REPO_ROOT" && stellar contract build --locked --package boundless-profile) + local wasm="$REPO_ROOT/target/wasm32v1-none/release/boundless_profile.wasm" [ -f "$wasm" ] || err "missing wasm: $wasm" info "Deploying boundless-profile..." - local profile_id + local profile_id live_hash profile_id=$(stellar contract deploy \ --network "$NETWORK" \ --source "$INITIAL_ADMIN_KEY" \ @@ -626,34 +409,39 @@ cmd_deploy_profile() { --admin "$(stellar keys address "$INITIAL_ADMIN_KEY")") ok "profile_contract=$profile_id" + live_hash="$(deployed_wasm_hash "$profile_id")" || err "could not read the deployed profile wasm hash" deployment_set profile_contract "$profile_id" - deployment_set profile_wasm_hash "$(hash_wasm "$wasm")" + deployment_set profile_wasm_hash "$live_hash" deployment_set deployed_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + [ "$live_hash" = "$(hash_wasm "$wasm")" ] || \ + err "deployed profile wasm $live_hash differs from the local build; investigate before continuing" } cmd_deploy_events() { require_env INITIAL_ADMIN_KEY require_env FEE_ACCOUNT require_env INITIAL_GLOBAL_FEE_BPS + [[ "$FEE_ACCOUNT" =~ ^G[A-Z2-7]{55}$ ]] || err "FEE_ACCOUNT must be a G... address" + [[ "$INITIAL_GLOBAL_FEE_BPS" =~ ^[0-9]+$ ]] && [ "$INITIAL_GLOBAL_FEE_BPS" -le 1000 ] || \ + err "INITIAL_GLOBAL_FEE_BPS must be 0..1000 (the contract caps fees at 10%)" require_cli - confirm_mainnet ensure_deployments_dir local profile_id profile_id="$(deployment_get profile_contract)" [ -n "$profile_id" ] || err "deploy-profile first; profile_contract not set" - if [ -n "$(deployment_get events_contract)" ]; then - err "events contract already deployed: $(deployment_get events_contract). Refusing to re-deploy. Use the guarded upgrade flow in the mainnet runbook." + err "events contract already deployed: $(deployment_get events_contract). Refusing to re-deploy; upgrades follow docs/upgrade-runbook.md." fi + confirm_mainnet - cmd_build_release - - local wasm="target/wasm32v1-none/release/boundless_events.wasm" + info "Building boundless-events without the testnet feature..." + (cd "$REPO_ROOT" && stellar contract build --locked --package boundless-events) + local wasm="$REPO_ROOT/target/wasm32v1-none/release/boundless_events.wasm" [ -f "$wasm" ] || err "missing wasm: $wasm" info "Deploying boundless-events..." - local events_id + local events_id live_hash events_id=$(stellar contract deploy \ --network "$NETWORK" \ --source "$INITIAL_ADMIN_KEY" \ @@ -665,8 +453,9 @@ cmd_deploy_events() { --profile_contract "$profile_id") ok "events_contract=$events_id" + live_hash="$(deployed_wasm_hash "$events_id")" || err "could not read the deployed events wasm hash" deployment_set events_contract "$events_id" - deployment_set events_wasm_hash "$(hash_wasm "$wasm")" + deployment_set events_wasm_hash "$live_hash" deployment_set fee_account "$FEE_ACCOUNT" deployment_set_raw initial_fee_bps "$INITIAL_GLOBAL_FEE_BPS" @@ -678,18 +467,34 @@ cmd_deploy_events() { -- set_events_contract \ --new_addr "$events_id" ok "profile.events_contract = $events_id" + [ "$live_hash" = "$(hash_wasm "$wasm")" ] || \ + err "deployed events wasm $live_hash differs from the local build; investigate before continuing" } cmd_register_token() { - local token="${1:-}" - [ -n "$token" ] || err "usage: register-token " + local token="${1:-}" asset="${2:-}" + [ -n "$token" ] && [ -n "$asset" ] || \ + err "usage: register-token " + [[ "$token" =~ ^C[A-Z2-7]{55}$ ]] || err "token must be a C... contract address" require_env INITIAL_ADMIN_KEY require_cli - confirm_mainnet - local events_id + local events_id fee_account wrapped admin deployer events_id="$(deployment_get events_contract)" + fee_account="$(deployment_get fee_account)" [ -n "$events_id" ] || err "events contract not deployed" + [ -n "$fee_account" ] || err "fee_account missing from deployments/mainnet.json" + + deployer="$(stellar keys address "$INITIAL_ADMIN_KEY")" + admin="$(ADMIN_SOURCE="$deployer" contract_read "$events_id" get_admin | read_json_string)" || \ + err "could not read the events admin" + [ "$admin" = "$deployer" ] || \ + err "admin is now $admin; register tokens through the multisig (docs/contract-ops-runbook.md)" + + wrapped="$(stellar contract id asset --asset "$asset" --network "$NETWORK")" + [ "$wrapped" = "$token" ] || err "$asset is wrapped by $wrapped, not $token" + "$REPO_ROOT/scripts/admin/verify-fee-trustline.sh" mainnet "$fee_account" "$asset" + confirm_mainnet info "Registering token $token..." stellar contract invoke \ @@ -700,20 +505,13 @@ cmd_register_token() { --token "$token" local supported - supported=$(stellar contract invoke \ - --network "$NETWORK" \ - --source "$INITIAL_ADMIN_KEY" \ - --id "$events_id" \ - -- is_supported_token \ - --token "$token") + supported="$(ADMIN_SOURCE="$deployer" contract_read "$events_id" is_supported_token --token "$token")" [ "$supported" = "true" ] || err "token registration not confirmed; got: $supported" - ok "Token registered: $token" - # Append to registered_tokens array. local tmp tmp="$(mktemp)" - jq ".registered_tokens = ((.registered_tokens // []) + [\"$token\"] | unique)" \ + jq --arg t "$token" '.registered_tokens = ((.registered_tokens // []) + [$t] | unique)' \ "$DEPLOYMENT_FILE" > "$tmp" mv "$tmp" "$DEPLOYMENT_FILE" } @@ -723,7 +521,6 @@ cmd_rotate_admin() { [ -n "$new_admin" ] || err "usage: rotate-admin " require_env INITIAL_ADMIN_KEY require_cli - confirm_mainnet local events_id profile_id events_id="$(deployment_get events_contract)" @@ -731,6 +528,9 @@ cmd_rotate_admin() { [ -n "$events_id" ] || err "events contract not deployed" [ -n "$profile_id" ] || err "profile contract not deployed" + "$REPO_ROOT/scripts/admin/verify-multisig.sh" "$new_admin" mainnet + confirm_mainnet + info "Setting pending admin on events..." stellar contract invoke \ --network "$NETWORK" --source "$INITIAL_ADMIN_KEY" --id "$events_id" \ @@ -742,716 +542,340 @@ cmd_rotate_admin() { -- set_admin --new_admin "$new_admin" echo "" - echo -e "${BOLD}Now the new admin ($new_admin) must call accept_admin on both contracts.${NC}" - echo "This step requires the multi-sig quorum to sign. After both accept ops" - echo "land, run 'verify' to confirm get_admin returns the new address." + echo -e "${BOLD}$new_admin must now call accept_admin on both contracts.${NC}" + echo "Build, simulate and sign each accept per docs/DEPLOYMENT.md, then run" + echo "'verify' with ADMIN_SOURCE=$new_admin." } -cmd_upload_profile_wasm() { - local wasm="${1:-}" - [ -n "$wasm" ] || err "usage: upload-profile-wasm " - [ -f "$wasm" ] || err "wasm file not found: $wasm" +cmd_upload_wasm() { + local kind="$1" wasm="${2:-}" version="${3:-}" + [ -n "$wasm" ] && [ -n "$version" ] || \ + err "usage: upload-$kind-wasm " + require_version_arg "$version" require_env UPLOAD_SOURCE require_env ADMIN_SOURCE require_cli - confirm_mainnet - ensure_deployments_dir + assert_wasm_artifact "$kind" "$wasm" "$version" - local profile_id expected_hash uploaded_hash - profile_id="$(profile_contract_id)" - assert_profile_version "$profile_id" "1.1.0" - assert_profile_admin_source_matches "$profile_id" + local id expected_hash uploaded_hash live + id="$(contract_id "$kind")" + live="$(contract_read "$id" version | read_json_string)" || \ + err "could not read the $kind version" + [ "$live" != "$version" ] || err "$kind already reports $version" expected_hash="$(hash_wasm "$wasm")" - assert_local_wasm_hash "profile SDK 27" "$wasm" "$SDK27_PROFILE_WASM_HASH" + confirm_mainnet + uploaded_hash="$(stellar contract upload \ --source-account "$UPLOAD_SOURCE" \ --network "$NETWORK" \ --optimize=false \ --wasm "$wasm")" [ "$uploaded_hash" = "$expected_hash" ] || \ - err "uploaded profile wasm hash mismatch: expected $expected_hash, got $uploaded_hash" + err "uploaded $kind wasm hash mismatch: expected $expected_hash, got $uploaded_hash" - append_upgrade_log "profile-uploaded" "" "$uploaded_hash" - ok "Uploaded exact profile WASM: $uploaded_hash" + append_upgrade_log "uploaded" "$kind" "$version" "$uploaded_hash" + ok "Uploaded the exact $kind $version wasm: $uploaded_hash" } -cmd_prepare_pause_profile() { - local output_file="${1:-}" - [ -n "$output_file" ] || err "usage: prepare-pause-profile " +cmd_prepare_pause() { + local kind="$1" output_file="${2:-}" + [ -n "$output_file" ] || err "usage: prepare-pause-$kind " require_env ADMIN_SOURCE require_cli - local profile_id events_id - profile_id="$(profile_contract_id)" - events_id="$(events_contract_id)" - assert_upgrade_governance_stable "$profile_id" "$events_id" - assert_events_version "$events_id" "1.1.0" - assert_events_paused "$events_id" - assert_no_events_exist "$events_id" - assert_no_pending_events_upgrade "$events_id" - assert_profile_version "$profile_id" "1.1.0" - assert_profile_unpaused "$profile_id" - assert_no_pending_profile_upgrade "$profile_id" - prepare_profile_invoke "$profile_id" "$output_file" pause + local events_id profile_id target_id + upgrade_ids "$kind" + assert_unpaused "$kind" "$target_id" + prepare_invoke "$kind" "$target_id" "$output_file" pause } -cmd_prepare_propose_upgrade_profile() { - local wasm="${1:-}" new_version="${2:-}" output_file="${3:-}" - [ -n "$wasm" ] || \ - err "usage: prepare-propose-upgrade-profile " - [ -n "$new_version" ] || \ - err "usage: prepare-propose-upgrade-profile " - [ -n "$output_file" ] || \ - err "usage: prepare-propose-upgrade-profile " - [ -f "$wasm" ] || err "wasm file not found: $wasm" - assert_version_argument "profile upgrade" "$new_version" "$SDK27_PROFILE_VERSION" +cmd_prepare_propose_upgrade() { + local kind="$1" wasm="${2:-}" version="${3:-}" output_file="${4:-}" + [ -n "$wasm" ] && [ -n "$version" ] && [ -n "$output_file" ] || \ + err "usage: prepare-propose-upgrade-$kind " + require_version_arg "$version" require_env ADMIN_SOURCE require_cli + assert_wasm_artifact "$kind" "$wasm" "$version" - local profile_id events_id wasm_hash - profile_id="$(profile_contract_id)" - events_id="$(events_contract_id)" - assert_upgrade_governance_stable "$profile_id" "$events_id" + local events_id profile_id target_id wasm_hash live live_hash + upgrade_ids "$kind" wasm_hash="$(hash_wasm "$wasm")" - assert_sdk27_profile_artifact "$wasm" "$new_version" - assert_events_version "$events_id" "1.1.0" - assert_events_paused "$events_id" - assert_no_events_exist "$events_id" - assert_no_pending_events_upgrade "$events_id" - assert_no_pending_profile_upgrade "$profile_id" - assert_profile_paused "$profile_id" - if [ "$new_version" = "1.2.0" ]; then - assert_profile_version "$profile_id" "1.1.0" - fi - - prepare_profile_invoke "$profile_id" "$output_file" propose_upgrade \ + assert_no_pending_upgrade "$kind" "$target_id" + live="$(contract_read "$target_id" version | read_json_string)" || \ + err "could not read the $kind version" + [ "$live" != "$version" ] || \ + err "$kind already reports $version; a proposal must name a new version" + live_hash="$(deployed_wasm_hash "$target_id")" || \ + err "could not read the deployed $kind wasm hash" + [ "$live_hash" != "$wasm_hash" ] || err "this wasm is already live on $kind" + + prepare_invoke "$kind" "$target_id" "$output_file" propose_upgrade \ --new_wasm_hash "$wasm_hash" \ - --new_version "$new_version" - echo "Expected profile version: $new_version" - echo "Expected profile WASM: $wasm_hash" -} - -cmd_verify_proposed_upgrade_profile() { - local wasm="${1:-}" expected_version="${2:-}" - [ -n "$wasm" ] || \ - err "usage: verify-proposed-upgrade-profile " - [ -n "$expected_version" ] || \ - err "usage: verify-proposed-upgrade-profile " - [ -f "$wasm" ] || err "wasm file not found: $wasm" - assert_version_argument "profile upgrade" "$expected_version" "$SDK27_PROFILE_VERSION" - require_env ADMIN_SOURCE - require_cli - ensure_deployments_dir - - local profile_id events_id expected_hash - profile_id="$(profile_contract_id)" - events_id="$(events_contract_id)" - assert_upgrade_governance_stable "$profile_id" "$events_id" - expected_hash="$(hash_wasm "$wasm")" - assert_sdk27_profile_artifact "$wasm" "$expected_version" - assert_events_version "$events_id" "1.1.0" - assert_events_paused "$events_id" - assert_no_events_exist "$events_id" - assert_no_pending_events_upgrade "$events_id" - assert_pending_profile_upgrade "$profile_id" "$expected_version" "$expected_hash" - assert_profile_paused "$profile_id" - if [ "$expected_version" = "1.2.0" ]; then - assert_profile_version "$profile_id" "1.1.0" - fi - - append_upgrade_log "profile-proposal-verified" "$expected_version" "$expected_hash" - ok "Queued profile upgrade matches the expected version and exact WASM." -} - -cmd_prepare_apply_upgrade_profile() { - local wasm="${1:-}" expected_version="${2:-}" output_file="${3:-}" - [ -n "$wasm" ] || \ - err "usage: prepare-apply-upgrade-profile " - [ -n "$expected_version" ] || \ - err "usage: prepare-apply-upgrade-profile " - [ -n "$output_file" ] || \ - err "usage: prepare-apply-upgrade-profile " - [ -f "$wasm" ] || err "wasm file not found: $wasm" - assert_version_argument "profile upgrade" "$expected_version" "$SDK27_PROFILE_VERSION" - require_env ADMIN_SOURCE - require_cli - - local profile_id events_id expected_hash - profile_id="$(profile_contract_id)" - events_id="$(events_contract_id)" - assert_upgrade_governance_stable "$profile_id" "$events_id" - expected_hash="$(hash_wasm "$wasm")" - assert_sdk27_profile_artifact "$wasm" "$expected_version" - assert_events_version "$events_id" "1.1.0" - assert_events_paused "$events_id" - assert_no_events_exist "$events_id" - assert_pending_events_upgrade \ - "$events_id" "$SDK27_EVENTS_VERSION" "$SDK27_EVENTS_WASM_HASH" - assert_pending_profile_upgrade "$profile_id" "$expected_version" "$expected_hash" - assert_profile_paused "$profile_id" - if [ "$expected_version" = "1.2.0" ]; then - assert_profile_version "$profile_id" "1.1.0" - fi - - prepare_profile_invoke "$profile_id" "$output_file" apply_upgrade -} - -cmd_prepare_migrate_upgrade_profile() { - local expected_version="${1:-}" output_file="${2:-}" - [ -n "$expected_version" ] || \ - err "usage: prepare-migrate-upgrade-profile " - [ -n "$output_file" ] || \ - err "usage: prepare-migrate-upgrade-profile " - assert_version_argument "profile upgrade" "$expected_version" "$SDK27_PROFILE_VERSION" - require_env ADMIN_SOURCE - require_cli - - local profile_id events_id migrated_version - profile_id="$(profile_contract_id)" - events_id="$(events_contract_id)" - assert_upgrade_governance_stable "$profile_id" "$events_id" - assert_events_version "$events_id" "1.5.0" - assert_events_sdk27_wasm_live "$events_id" - assert_events_paused "$events_id" - assert_no_pending_events_upgrade "$events_id" - assert_profile_version "$profile_id" "$expected_version" - if [ "$expected_version" = "$SDK27_PROFILE_VERSION" ]; then - assert_profile_sdk27_wasm_live "$profile_id" - fi - assert_profile_paused "$profile_id" - assert_no_pending_profile_upgrade "$profile_id" - - migrated_version="$(profile_read "$profile_id" get_migrated_to_version | jq -r '. // empty')" || \ - err "could not read profile migrated-to version" - [ "$migrated_version" != "$expected_version" ] || \ - err "profile migration marker is already $expected_version; do not replay migrate" - prepare_profile_invoke "$profile_id" "$output_file" migrate + --new_version "$version" + echo "Live $kind: $live ($live_hash)" + echo "Proposed $kind: $version ($wasm_hash)" } -cmd_prepare_cancel_upgrade_profile() { - local output_file="${1:-}" - [ -n "$output_file" ] || \ - err "usage: prepare-cancel-upgrade-profile " +cmd_verify_proposed_upgrade() { + local kind="$1" wasm="${2:-}" version="${3:-}" + [ -n "$wasm" ] && [ -n "$version" ] || \ + err "usage: verify-proposed-upgrade-$kind " + require_version_arg "$version" require_env ADMIN_SOURCE require_cli + assert_wasm_artifact "$kind" "$wasm" "$version" - local profile_id events_id pending_version - profile_id="$(profile_contract_id)" - events_id="$(events_contract_id)" - assert_upgrade_governance_stable "$profile_id" "$events_id" - pending_version="$(profile_read "$profile_id" get_pending_upgrade | jq -r '.new_version // empty')" || \ - err "could not read pending profile upgrade" - [ -n "$pending_version" ] || err "no profile upgrade is pending" - if [ "$pending_version" = "1.2.0" ]; then - assert_profile_version "$profile_id" "1.1.0" - assert_profile_paused "$profile_id" - assert_events_version "$events_id" "1.1.0" - assert_events_paused "$events_id" - assert_no_events_exist "$events_id" - fi - prepare_profile_invoke "$profile_id" "$output_file" cancel_pending_upgrade - echo "Cancelling queued profile version: $pending_version" - echo "This transaction does not unpause the profile contract." -} - -cmd_prepare_unpause_profile() { - local expected_version="${1:-}" output_file="${2:-}" - [ -n "$expected_version" ] || \ - err "usage: prepare-unpause-profile " - [ -n "$output_file" ] || \ - err "usage: prepare-unpause-profile " - assert_unpause_version_argument "profile" "$expected_version" "$SDK27_PROFILE_VERSION" - require_env ADMIN_SOURCE - require_cli - - local profile_id events_id - profile_id="$(profile_contract_id)" - events_id="$(events_contract_id)" - assert_upgrade_governance_stable "$profile_id" "$events_id" - assert_profile_version "$profile_id" "$expected_version" - assert_profile_paused "$profile_id" - assert_no_pending_profile_upgrade "$profile_id" - if [ "$expected_version" = "1.2.0" ]; then - assert_profile_sdk27_wasm_live "$profile_id" - assert_profile_migrated_version "$profile_id" "$expected_version" - assert_events_version "$events_id" "1.5.0" - assert_events_sdk27_wasm_live "$events_id" - assert_events_paused "$events_id" - assert_no_pending_events_upgrade "$events_id" - assert_migrated_version "$events_id" "1.5.0" - elif [ "$expected_version" = "1.1.0" ]; then - assert_events_version "$events_id" "1.1.0" - assert_events_paused "$events_id" - assert_no_pending_events_upgrade "$events_id" - assert_no_events_exist "$events_id" - fi - prepare_profile_invoke "$profile_id" "$output_file" unpause -} - -cmd_verify_upgrade_profile() { - local wasm="${1:-}" expected_version="${2:-}" - [ -n "$wasm" ] || \ - err "usage: verify-upgrade-profile " - [ -n "$expected_version" ] || \ - err "usage: verify-upgrade-profile " - [ -f "$wasm" ] || err "wasm file not found: $wasm" - assert_version_argument "profile upgrade" "$expected_version" "$SDK27_PROFILE_VERSION" - require_env ADMIN_SOURCE - require_cli - ensure_deployments_dir - - local profile_id events_id expected_hash - profile_id="$(profile_contract_id)" - events_id="$(events_contract_id)" - assert_upgrade_governance_stable "$profile_id" "$events_id" - expected_hash="$(hash_wasm "$wasm")" - assert_sdk27_profile_artifact "$wasm" "$expected_version" - assert_profile_version "$profile_id" "$expected_version" - if [ "$expected_version" = "$SDK27_PROFILE_VERSION" ]; then - assert_profile_sdk27_wasm_live "$profile_id" - fi - assert_profile_migrated_version "$profile_id" "$expected_version" - assert_no_pending_profile_upgrade "$profile_id" - assert_profile_unpaused "$profile_id" - assert_events_version "$events_id" "1.5.0" - assert_events_sdk27_wasm_live "$events_id" - assert_migrated_version "$events_id" "1.5.0" - assert_no_pending_events_upgrade "$events_id" - assert_events_unpaused "$events_id" - - deployment_set profile_contract "$profile_id" - deployment_set profile_wasm_hash "$expected_hash" - deployment_set profile_version "$expected_version" - append_upgrade_log "profile-upgrade-verified" "$expected_version" "$expected_hash" - ok "Profile $expected_version is migrated, has no pending upgrade, and is unpaused." -} - -cmd_upload_events_wasm() { - local wasm="${1:-}" - [ -n "$wasm" ] || err "usage: upload-events-wasm " - [ -f "$wasm" ] || err "wasm file not found: $wasm" - require_env UPLOAD_SOURCE - require_env ADMIN_SOURCE - require_cli - confirm_mainnet - ensure_deployments_dir - - local events_id expected_hash uploaded_hash - events_id="$(events_contract_id)" - assert_events_version "$events_id" "1.1.0" - assert_admin_source_matches "$events_id" - expected_hash="$(hash_wasm "$wasm")" - assert_local_wasm_hash "events SDK 27" "$wasm" "$SDK27_EVENTS_WASM_HASH" - uploaded_hash="$(stellar contract upload \ - --source-account "$UPLOAD_SOURCE" \ - --network "$NETWORK" \ - --optimize=false \ - --wasm "$wasm")" - [ "$uploaded_hash" = "$expected_hash" ] || \ - err "uploaded wasm hash mismatch: expected $expected_hash, got $uploaded_hash" - - append_upgrade_log "uploaded" "" "$uploaded_hash" - ok "Uploaded exact events WASM: $uploaded_hash" -} - -cmd_prepare_pause_events() { - local output_file="${1:-}" - [ -n "$output_file" ] || err "usage: prepare-pause-events " - require_env ADMIN_SOURCE - require_cli + local events_id profile_id target_id wasm_hash + upgrade_ids "$kind" + wasm_hash="$(hash_wasm "$wasm")" + assert_pending_upgrade "$kind" "$target_id" "$version" "$wasm_hash" - local events_id profile_id - events_id="$(events_contract_id)" - profile_id="$(profile_contract_id)" - assert_upgrade_governance_stable "$profile_id" "$events_id" - assert_profile_version "$profile_id" "1.1.0" - assert_profile_unpaused "$profile_id" - assert_no_pending_profile_upgrade "$profile_id" - assert_events_version "$events_id" "1.1.0" - assert_events_unpaused "$events_id" - assert_no_pending_events_upgrade "$events_id" - assert_no_events_exist "$events_id" - prepare_events_invoke "$events_id" "$output_file" pause + contract_read "$target_id" get_pending_upgrade \ + | jq -r '"available_at_ledger: \(.available_at_ledger)\nexpires_at_ledger: \(.expires_at_ledger)"' + append_upgrade_log "proposal-verified" "$kind" "$version" "$wasm_hash" + ok "The queued $kind upgrade matches $version and the exact wasm." } -cmd_prepare_propose_upgrade_events() { - local wasm="${1:-}" new_version="${2:-}" output_file="${3:-}" - [ -n "$wasm" ] || \ - err "usage: prepare-propose-upgrade-events " - [ -n "$new_version" ] || \ - err "usage: prepare-propose-upgrade-events " - [ -n "$output_file" ] || \ - err "usage: prepare-propose-upgrade-events " - [ -f "$wasm" ] || err "wasm file not found: $wasm" - assert_version_argument "events upgrade" "$new_version" "$SDK27_EVENTS_VERSION" +cmd_prepare_apply_upgrade() { + local kind="$1" wasm="${2:-}" version="${3:-}" output_file="${4:-}" + [ -n "$wasm" ] && [ -n "$version" ] && [ -n "$output_file" ] || \ + err "usage: prepare-apply-upgrade-$kind " + require_version_arg "$version" require_env ADMIN_SOURCE require_cli + assert_wasm_artifact "$kind" "$wasm" "$version" - local events_id profile_id wasm_hash - events_id="$(events_contract_id)" - profile_id="$(profile_contract_id)" - assert_upgrade_governance_stable "$profile_id" "$events_id" + local events_id profile_id target_id wasm_hash + upgrade_ids "$kind" wasm_hash="$(hash_wasm "$wasm")" - assert_sdk27_events_artifact "$wasm" "$new_version" - assert_no_pending_events_upgrade "$events_id" - - if is_zero_event_guarded_target "$new_version"; then - assert_events_version "$events_id" "1.1.0" - assert_events_paused "$events_id" - assert_no_events_exist "$events_id" - if [ "$new_version" = "1.5.0" ]; then - assert_profile_sdk27_staged_or_applied "$profile_id" - fi + assert_pending_upgrade "$kind" "$target_id" "$version" "$wasm_hash" + # Applying events can change the record layout; keep users out until + # migrate has converted every row. + if [ "$kind" = events ]; then + assert_paused events "$events_id" fi - prepare_events_invoke "$events_id" "$output_file" propose_upgrade \ - --new_wasm_hash "$wasm_hash" \ - --new_version "$new_version" - echo "Expected version: $new_version" - echo "Expected WASM: $wasm_hash" + prepare_invoke "$kind" "$target_id" "$output_file" apply_upgrade } -cmd_verify_proposed_upgrade_events() { - local wasm="${1:-}" expected_version="${2:-}" - [ -n "$wasm" ] || \ - err "usage: verify-proposed-upgrade-events " - [ -n "$expected_version" ] || \ - err "usage: verify-proposed-upgrade-events " - [ -f "$wasm" ] || err "wasm file not found: $wasm" - assert_version_argument "events upgrade" "$expected_version" "$SDK27_EVENTS_VERSION" +cmd_prepare_migrate_events_page() { + local max_events="${1:-}" output_file="${2:-}" + [ -n "$max_events" ] && [ -n "$output_file" ] || \ + err "usage: prepare-migrate-events-page " + [[ "$max_events" =~ ^[1-9][0-9]*$ ]] || err "max-events must be a positive integer" require_env ADMIN_SOURCE require_cli - ensure_deployments_dir - local events_id profile_id expected_hash - events_id="$(events_contract_id)" - profile_id="$(profile_contract_id)" - assert_upgrade_governance_stable "$profile_id" "$events_id" - expected_hash="$(hash_wasm "$wasm")" - assert_sdk27_events_artifact "$wasm" "$expected_version" - assert_pending_events_upgrade "$events_id" "$expected_version" "$expected_hash" - - if is_zero_event_guarded_target "$expected_version"; then - assert_events_version "$events_id" "1.1.0" - assert_events_paused "$events_id" - assert_no_events_exist "$events_id" - if [ "$expected_version" = "1.5.0" ]; then - assert_profile_sdk27_staged_or_applied "$profile_id" - fi - fi - - append_upgrade_log "proposal-verified" "$expected_version" "$expected_hash" - ok "Queued events upgrade matches the expected version and exact WASM." -} - -cmd_prepare_apply_upgrade_events() { - local wasm="${1:-}" expected_version="${2:-}" output_file="${3:-}" - [ -n "$wasm" ] || \ - err "usage: prepare-apply-upgrade-events " - [ -n "$expected_version" ] || \ - err "usage: prepare-apply-upgrade-events " - [ -n "$output_file" ] || \ - err "usage: prepare-apply-upgrade-events " - [ -f "$wasm" ] || err "wasm file not found: $wasm" - assert_version_argument "events upgrade" "$expected_version" "$SDK27_EVENTS_VERSION" + local events_id profile_id target_id live migrated state remaining + upgrade_ids events + assert_no_pending_upgrade events "$events_id" + assert_paused events "$events_id" + live="$(contract_read "$events_id" version | read_json_string)" || \ + err "could not read the events version" + migrated="$(migrated_version "$events_id")" || err "could not read the events migration marker" + [ "$migrated" != "$live" ] || err "events is already migrated to $live; there is nothing to page" + state="$(migrate_state events "$events_id")" || err "could not simulate events migrate" + [ "$state" = incomplete ] || \ + err "migrate_events has nothing left to convert; prepare-migrate-upgrade-events is next" + remaining="$(contract_read "$events_id" migrate_events --max_events "$max_events" | tr -d '"')" || \ + err "could not simulate migrate_events" + [[ "$remaining" =~ ^[0-9]+$ ]] || err "migrate_events simulation returned '$remaining'" + + prepare_invoke events "$events_id" "$output_file" migrate_events --max_events "$max_events" + echo "Simulated: $remaining event(s) will remain after this page (the contract converts at most 8 per call)." +} + +cmd_prepare_migrate_upgrade() { + local kind="$1" version="${2:-}" output_file="${3:-}" + [ -n "$version" ] && [ -n "$output_file" ] || \ + err "usage: prepare-migrate-upgrade-$kind " + require_version_arg "$version" require_env ADMIN_SOURCE require_cli - local events_id profile_id expected_hash - events_id="$(events_contract_id)" - profile_id="$(profile_contract_id)" - assert_upgrade_governance_stable "$profile_id" "$events_id" - expected_hash="$(hash_wasm "$wasm")" - assert_sdk27_events_artifact "$wasm" "$expected_version" - assert_pending_events_upgrade "$events_id" "$expected_version" "$expected_hash" - - if is_zero_event_guarded_target "$expected_version"; then - assert_events_version "$events_id" "1.1.0" - assert_events_paused "$events_id" - assert_no_events_exist "$events_id" - if [ "$expected_version" = "1.5.0" ]; then - assert_profile_sdk27_applied_paused "$profile_id" - fi - fi + local events_id profile_id target_id migrated state + upgrade_ids "$kind" + assert_version "$kind" "$target_id" "$version" + assert_no_pending_upgrade "$kind" "$target_id" + if [ "$kind" = events ]; then + assert_paused events "$events_id" + fi + migrated="$(migrated_version "$target_id")" || err "could not read the $kind migration marker" + [ "$migrated" != "$version" ] || \ + err "$kind is already migrated to $version; migrate cannot be replayed" + state="$(migrate_state "$kind" "$target_id")" || err "could not simulate $kind migrate" + case "$state" in + incomplete) + err "migrate_events still has events to convert; prepare and submit prepare-migrate-events-page until it reports 0 remaining" ;; + applied) + err "$kind migrate reports it has already run" ;; + esac - prepare_events_invoke "$events_id" "$output_file" apply_upgrade + prepare_invoke "$kind" "$target_id" "$output_file" migrate } -cmd_prepare_migrate_upgrade_events() { - local expected_version="${1:-}" output_file="${2:-}" - [ -n "$expected_version" ] || \ - err "usage: prepare-migrate-upgrade-events " - [ -n "$output_file" ] || \ - err "usage: prepare-migrate-upgrade-events " - assert_version_argument "events upgrade" "$expected_version" "$SDK27_EVENTS_VERSION" +cmd_prepare_cancel_upgrade() { + local kind="$1" output_file="${2:-}" + [ -n "$output_file" ] || err "usage: prepare-cancel-upgrade-$kind " require_env ADMIN_SOURCE require_cli - local events_id profile_id migrated_version - events_id="$(events_contract_id)" - profile_id="$(profile_contract_id)" - assert_upgrade_governance_stable "$profile_id" "$events_id" - assert_events_version "$events_id" "$expected_version" - if [ "$expected_version" = "$SDK27_EVENTS_VERSION" ]; then - assert_events_sdk27_wasm_live "$events_id" - fi - assert_no_pending_events_upgrade "$events_id" - assert_events_paused "$events_id" - - migrated_version="$(events_read "$events_id" get_migrated_to_version | jq -r '. // empty')" || \ - err "could not read migrated-to version" - [ "$migrated_version" != "$expected_version" ] || \ - err "migration marker is already $expected_version; do not replay migrate" - if [ "$expected_version" = "1.5.0" ]; then - assert_profile_sdk27_migrated_paused "$profile_id" - fi - prepare_events_invoke "$events_id" "$output_file" migrate + local events_id profile_id target_id pending_version + upgrade_ids "$kind" + pending_version="$(contract_read "$target_id" get_pending_upgrade | jq -r '.new_version // empty')" || \ + err "could not read the pending $kind upgrade" + [ -n "$pending_version" ] || err "no $kind upgrade is pending" + prepare_invoke "$kind" "$target_id" "$output_file" cancel_pending_upgrade + echo "Cancelling queued $kind version $pending_version. The pause flag is unchanged." } -cmd_prepare_cancel_upgrade_events() { - local output_file="${1:-}" - [ -n "$output_file" ] || \ - err "usage: prepare-cancel-upgrade-events " +cmd_prepare_unpause() { + local kind="$1" version="${2:-}" output_file="${3:-}" + [ -n "$version" ] && [ -n "$output_file" ] || \ + err "usage: prepare-unpause-$kind " + require_version_arg "$version" require_env ADMIN_SOURCE require_cli - local events_id profile_id pending_version - events_id="$(events_contract_id)" - profile_id="$(profile_contract_id)" - assert_upgrade_governance_stable "$profile_id" "$events_id" - pending_version="$(events_read "$events_id" get_pending_upgrade | jq -r '.new_version // empty')" || \ - err "could not read pending events upgrade" - [ -n "$pending_version" ] || err "no events upgrade is pending" - if [ "$pending_version" = "1.5.0" ]; then - assert_events_version "$events_id" "1.1.0" - assert_events_paused "$events_id" - assert_no_events_exist "$events_id" - assert_profile_version "$profile_id" "1.1.0" - assert_profile_paused "$profile_id" + local events_id profile_id target_id migrated + upgrade_ids "$kind" + assert_version "$kind" "$target_id" "$version" + assert_paused "$kind" "$target_id" + assert_no_pending_upgrade "$kind" "$target_id" + migrated="$(migrated_version "$target_id")" || err "could not read the $kind migration marker" + if [ "$migrated" != "$version" ] && [ "${UNPAUSE_UNMIGRATED:-}" != 1 ]; then + err "$kind $version is not migrated (marker: ${migrated:-none}). Finish the migration, or set UNPAUSE_UNMIGRATED=1 only when abandoning an upgrade that was never applied." fi - prepare_events_invoke "$events_id" "$output_file" cancel_pending_upgrade - echo "Cancelling queued version: $pending_version" - echo "This transaction does not unpause the contract." + prepare_invoke "$kind" "$target_id" "$output_file" unpause } -cmd_prepare_unpause_events() { - local expected_version="${1:-}" output_file="${2:-}" - [ -n "$expected_version" ] || \ - err "usage: prepare-unpause-events " - [ -n "$output_file" ] || \ - err "usage: prepare-unpause-events " - assert_unpause_version_argument "events" "$expected_version" "$SDK27_EVENTS_VERSION" +cmd_verify_upgrade() { + local kind="$1" wasm="${2:-}" version="${3:-}" + [ -n "$wasm" ] && [ -n "$version" ] || \ + err "usage: verify-upgrade-$kind " + require_version_arg "$version" require_env ADMIN_SOURCE require_cli + assert_wasm_artifact "$kind" "$wasm" "$version" - local events_id profile_id - events_id="$(events_contract_id)" - profile_id="$(profile_contract_id)" - assert_upgrade_governance_stable "$profile_id" "$events_id" - assert_events_version "$events_id" "$expected_version" - if [ "$expected_version" = "$SDK27_EVENTS_VERSION" ]; then - assert_events_sdk27_wasm_live "$events_id" - fi - assert_events_paused "$events_id" - assert_no_pending_events_upgrade "$events_id" - if is_zero_event_guarded_target "$expected_version"; then - assert_migrated_version "$events_id" "$expected_version" - fi - if [ "$expected_version" = "1.5.0" ]; then - assert_profile_sdk27_ready "$profile_id" - elif [ "$expected_version" = "1.1.0" ]; then - assert_no_events_exist "$events_id" - assert_profile_version "$profile_id" "1.1.0" - assert_profile_unpaused "$profile_id" - assert_no_pending_profile_upgrade "$profile_id" - fi - prepare_events_invoke "$events_id" "$output_file" unpause -} + local events_id profile_id target_id wasm_hash migrated + upgrade_ids "$kind" + wasm_hash="$(hash_wasm "$wasm")" + assert_version "$kind" "$target_id" "$version" + assert_live_hash "$kind" "$target_id" "$wasm_hash" + migrated="$(migrated_version "$target_id")" || err "could not read the $kind migration marker" + [ "$migrated" = "$version" ] || \ + err "$kind migration marker is ${migrated:-none}, expected $version" + assert_no_pending_upgrade "$kind" "$target_id" + assert_unpaused "$kind" "$target_id" -cmd_verify_upgrade_events() { - local wasm="${1:-}" expected_version="${2:-}" - [ -n "$wasm" ] || \ - err "usage: verify-upgrade-events " - [ -n "$expected_version" ] || \ - err "usage: verify-upgrade-events " - [ -f "$wasm" ] || err "wasm file not found: $wasm" - assert_version_argument "events upgrade" "$expected_version" "$SDK27_EVENTS_VERSION" - require_env ADMIN_SOURCE - require_cli ensure_deployments_dir + deployment_set "${kind}_contract" "$target_id" + deployment_set "${kind}_wasm_hash" "$wasm_hash" + deployment_set "${kind}_version" "$version" + deployment_set "${kind}_migrated_to_version" "$version" + deployment_set last_upgraded_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + append_upgrade_log "upgrade-verified" "$kind" "$version" "$wasm_hash" + ok "$kind $version is live, migrated, has no pending upgrade and is unpaused." +} - local events_id profile_id expected_hash - events_id="$(events_contract_id)" - profile_id="$(profile_contract_id)" - assert_upgrade_governance_stable "$profile_id" "$events_id" - expected_hash="$(hash_wasm "$wasm")" - assert_sdk27_events_artifact "$wasm" "$expected_version" - assert_events_version "$events_id" "$expected_version" - if [ "$expected_version" = "$SDK27_EVENTS_VERSION" ]; then - assert_events_sdk27_wasm_live "$events_id" - fi - assert_migrated_version "$events_id" "$expected_version" - assert_no_pending_events_upgrade "$events_id" - assert_events_unpaused "$events_id" - if [ "$expected_version" = "1.5.0" ]; then - assert_profile_sdk27_ready "$profile_id" - fi - - deployment_set events_contract "$events_id" - deployment_set events_wasm_hash "$expected_hash" - deployment_set events_version "$expected_version" - append_upgrade_log "upgrade-verified" "$expected_version" "$expected_hash" - ok "Events $expected_version is migrated, has no pending upgrade, and is unpaused." +show_read() { + local label="$1" id="$2" + shift 2 + printf '%s: %s\n' "$label" "$(contract_read "$id" "$@" 2>/dev/null || echo '(read failed)')" } cmd_upgrade_status() { require_env ADMIN_SOURCE require_cli - local events_id profile_id - events_id="$(events_contract_id)" - profile_id="$(profile_contract_id)" - - echo "events.version:" - events_read "$events_id" version - echo "events.wasm_hash:" - deployed_wasm_hash "$events_id" - echo "events.is_paused:" - events_read "$events_id" is_paused - echo "events.get_pending_upgrade:" - events_read "$events_id" get_pending_upgrade - echo "events.get_migrated_to_version:" - events_read "$events_id" get_migrated_to_version - echo "profile.version:" - profile_read "$profile_id" version - echo "profile.wasm_hash:" - deployed_wasm_hash "$profile_id" - echo "profile.is_paused:" - profile_read "$profile_id" is_paused - echo "profile.get_pending_upgrade:" - profile_read "$profile_id" get_pending_upgrade - echo "profile.get_events_contract:" - profile_read "$profile_id" get_events_contract - echo "profile.get_pending_events_contract:" - profile_read "$profile_id" get_pending_events_contract - echo "profile.get_migrated_to_version:" - profile_read "$profile_id" get_migrated_to_version + local events_id profile_id kind id state + events_id="$(contract_id events)" + profile_id="$(contract_id profile)" + + for kind in events profile; do + if [ "$kind" = events ]; then id="$events_id"; else id="$profile_id"; fi + echo "$kind: $id" + show_read " version" "$id" version + printf ' wasm_hash: %s\n' "$(deployed_wasm_hash "$id" 2>/dev/null || echo '(read failed)')" + show_read " migrated_to_version" "$id" get_migrated_to_version + state="$(migrate_state "$kind" "$id" 2>/dev/null)" || state="unknown (simulation failed)" + printf ' migrate (simulated): %s\n' "$state" + show_read " pending_upgrade" "$id" get_pending_upgrade + show_read " is_paused" "$id" is_paused + show_read " admin" "$id" get_admin + done + show_read "events release_validator" "$events_id" get_release_validator + show_read "events pending_release_validator" "$events_id" get_pending_release_validator + show_read "profile events_contract" "$profile_id" get_events_contract + show_read "profile pending_events_contract" "$profile_id" get_pending_events_contract } cmd_verify() { - require_env INITIAL_ADMIN_KEY # for the read-source; any key works for invoke-as-read + require_env ADMIN_SOURCE require_cli local events_id profile_id - events_id="$(deployment_get events_contract)" - profile_id="$(deployment_get profile_contract)" - [ -n "$events_id" ] || err "events contract not in deployment file" - [ -n "$profile_id" ] || err "profile contract not in deployment file" - - info "Verifying mainnet state..." - - echo "events.get_admin:" - stellar contract invoke --network "$NETWORK" --source "$INITIAL_ADMIN_KEY" --id "$events_id" -- get_admin - - echo "events.get_fee_bps:" - stellar contract invoke --network "$NETWORK" --source "$INITIAL_ADMIN_KEY" --id "$events_id" -- get_fee_bps - - echo "events.get_fee_account:" - stellar contract invoke --network "$NETWORK" --source "$INITIAL_ADMIN_KEY" --id "$events_id" -- get_fee_account - - echo "events.is_paused:" - stellar contract invoke --network "$NETWORK" --source "$INITIAL_ADMIN_KEY" --id "$events_id" -- is_paused - - echo "profile.get_admin:" - stellar contract invoke --network "$NETWORK" --source "$INITIAL_ADMIN_KEY" --id "$profile_id" -- get_admin - - ok "Verification reads complete. Compare against the runbook expectations." + events_id="$(contract_id events)" + profile_id="$(contract_id profile)" + + info "Mainnet state (compare with deployments/mainnet.json and docs/DEPLOYMENT.md)" + echo "events: $events_id" + show_read " version" "$events_id" version + show_read " migrated_to_version" "$events_id" get_migrated_to_version + show_read " pending_upgrade" "$events_id" get_pending_upgrade + show_read " is_paused" "$events_id" is_paused + show_read " admin" "$events_id" get_admin + show_read " fee_account" "$events_id" get_fee_account + show_read " fee_bps" "$events_id" get_fee_bps + show_read " profile_contract" "$events_id" get_profile_contract + show_read " release_validator" "$events_id" get_release_validator + show_read " pending_release_validator" "$events_id" get_pending_release_validator + show_read " supported_token_count" "$events_id" supported_token_count + echo "profile: $profile_id" + show_read " version" "$profile_id" version + show_read " migrated_to_version" "$profile_id" get_migrated_to_version + show_read " pending_upgrade" "$profile_id" get_pending_upgrade + show_read " is_paused" "$profile_id" is_paused + show_read " admin" "$profile_id" get_admin + show_read " events_contract" "$profile_id" get_events_contract + show_read " pending_events_contract" "$profile_id" get_pending_events_contract } case "$ACTION" in - "deploy-profile") - cmd_deploy_profile - ;; - "deploy-events") - cmd_deploy_events - ;; - "register-token") - cmd_register_token "$@" - ;; - "rotate-admin") - cmd_rotate_admin "$@" - ;; - "upload-profile-wasm") - cmd_upload_profile_wasm "$@" - ;; - "upload-events-wasm") - cmd_upload_events_wasm "$@" - ;; - "prepare-pause-profile") - cmd_prepare_pause_profile "$@" - ;; - "prepare-pause-events") - cmd_prepare_pause_events "$@" - ;; - "prepare-propose-upgrade-profile") - cmd_prepare_propose_upgrade_profile "$@" - ;; - "prepare-propose-upgrade-events") - cmd_prepare_propose_upgrade_events "$@" - ;; - "verify-proposed-upgrade-profile") - cmd_verify_proposed_upgrade_profile "$@" - ;; - "verify-proposed-upgrade-events") - cmd_verify_proposed_upgrade_events "$@" - ;; - "prepare-apply-upgrade-profile") - cmd_prepare_apply_upgrade_profile "$@" - ;; - "prepare-apply-upgrade-events") - cmd_prepare_apply_upgrade_events "$@" - ;; - "prepare-migrate-upgrade-profile") - cmd_prepare_migrate_upgrade_profile "$@" - ;; - "prepare-migrate-upgrade-events") - cmd_prepare_migrate_upgrade_events "$@" - ;; - "prepare-cancel-upgrade-profile") - cmd_prepare_cancel_upgrade_profile "$@" - ;; - "prepare-cancel-upgrade-events") - cmd_prepare_cancel_upgrade_events "$@" - ;; - "prepare-unpause-profile") - cmd_prepare_unpause_profile "$@" - ;; - "prepare-unpause-events") - cmd_prepare_unpause_events "$@" - ;; - "verify-upgrade-profile") - cmd_verify_upgrade_profile "$@" - ;; - "verify-upgrade-events") - cmd_verify_upgrade_events "$@" - ;; - "upgrade-status") - cmd_upgrade_status - ;; - "upgrade-events") - err "upgrade-events is removed; use the guarded prepare/sign/send flow in mainnet-deploy-runbook.md" - ;; - "propose-upgrade-events"|"apply-upgrade-events"|"migrate-upgrade-events"|"cancel-upgrade-events"|"unpause-events"|\ - "propose-upgrade-profile"|"apply-upgrade-profile"|"migrate-upgrade-profile"|"cancel-upgrade-profile"|"unpause-profile") - err "$ACTION cannot safely submit as the 2-of-3 admin; use the matching prepare-* action, sign sequentially, and send" - ;; - "verify") - cmd_verify + deploy-profile) cmd_deploy_profile ;; + deploy-events) cmd_deploy_events ;; + register-token) cmd_register_token "$@" ;; + rotate-admin) cmd_rotate_admin "$@" ;; + upload-profile-wasm) cmd_upload_wasm profile "$@" ;; + upload-events-wasm) cmd_upload_wasm events "$@" ;; + prepare-pause-profile) cmd_prepare_pause profile "$@" ;; + prepare-pause-events) cmd_prepare_pause events "$@" ;; + prepare-propose-upgrade-profile) cmd_prepare_propose_upgrade profile "$@" ;; + prepare-propose-upgrade-events) cmd_prepare_propose_upgrade events "$@" ;; + verify-proposed-upgrade-profile) cmd_verify_proposed_upgrade profile "$@" ;; + verify-proposed-upgrade-events) cmd_verify_proposed_upgrade events "$@" ;; + prepare-apply-upgrade-profile) cmd_prepare_apply_upgrade profile "$@" ;; + prepare-apply-upgrade-events) cmd_prepare_apply_upgrade events "$@" ;; + prepare-migrate-events-page) cmd_prepare_migrate_events_page "$@" ;; + prepare-migrate-upgrade-profile) cmd_prepare_migrate_upgrade profile "$@" ;; + prepare-migrate-upgrade-events) cmd_prepare_migrate_upgrade events "$@" ;; + prepare-cancel-upgrade-profile) cmd_prepare_cancel_upgrade profile "$@" ;; + prepare-cancel-upgrade-events) cmd_prepare_cancel_upgrade events "$@" ;; + prepare-unpause-profile) cmd_prepare_unpause profile "$@" ;; + prepare-unpause-events) cmd_prepare_unpause events "$@" ;; + verify-upgrade-profile) cmd_verify_upgrade profile "$@" ;; + verify-upgrade-events) cmd_verify_upgrade events "$@" ;; + upgrade-status) cmd_upgrade_status ;; + verify) cmd_verify ;; + propose-upgrade-*|apply-upgrade-*|migrate-*|cancel-upgrade-*|pause-*|unpause-*|upgrade-*) + err "$ACTION cannot submit as the 2-of-3 admin; use the matching prepare-* action, sign sequentially, and send" ;; "") - err "no action specified. See mainnet-deploy-runbook.md for usage." + err "no action specified; see the usage at the top of this script and docs/upgrade-runbook.md" ;; *) - err "unknown action: $ACTION. See mainnet-deploy-runbook.md for usage." + err "unknown action: $ACTION; see the usage at the top of this script" ;; esac diff --git a/scripts/admin/verify-fee-trustline.sh b/scripts/admin/verify-fee-trustline.sh new file mode 100755 index 0000000..9f057e8 --- /dev/null +++ b/scripts/admin/verify-fee-trustline.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# +# verify-fee-trustline.sh: confirm the fee account can receive a token. +# +# The contract moves the platform fee to the fee account inside create_event +# and add_funds. Without an authorized trustline for the token, every such +# call reverts. Run before whitelisting a token and before rotating the fee +# account. +# +# Usage: +# ./scripts/admin/verify-fee-trustline.sh +# +# ASSET is CODE:ISSUER, or "native" for XLM. +# +# FEE_TRUSTLINE_ACCOUNT_FILE reads the Horizon account JSON from a file instead +# of the network (used by scripts/test-admin-scripts.sh). + +set -euo pipefail + +NETWORK=${1:-""} +FEE_ACCOUNT=${2:-""} +ASSET=${3:-""} + +if [[ -z "$NETWORK" || -z "$FEE_ACCOUNT" || -z "$ASSET" ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi +case "$NETWORK" in + testnet) HORIZON="https://horizon-testnet.stellar.org" ;; + mainnet) HORIZON="https://horizon.stellar.org" ;; + *) echo "Unknown network '$NETWORK'." >&2; exit 1 ;; +esac +if [[ ! "$FEE_ACCOUNT" =~ ^G[A-Z2-7]{55}$ ]]; then + echo "error: fee account must be a G... address (got: $FEE_ACCOUNT)" >&2 + exit 1 +fi + +if [[ "$ASSET" == "native" ]]; then + echo "PASS: every account holds XLM." + exit 0 +fi +if [[ ! "$ASSET" =~ ^([A-Za-z0-9]{1,12}):(G[A-Z2-7]{55})$ ]]; then + echo "error: asset must be CODE:ISSUER or native (got: $ASSET)" >&2 + exit 1 +fi +CODE="${BASH_REMATCH[1]}" +ISSUER="${BASH_REMATCH[2]}" + +if [[ -n "${FEE_TRUSTLINE_ACCOUNT_FILE:-}" ]]; then + ACCOUNT_JSON=$(cat "$FEE_TRUSTLINE_ACCOUNT_FILE") +else + ACCOUNT_JSON=$(curl -sf "$HORIZON/accounts/$FEE_ACCOUNT" || true) +fi +if [[ -z "$ACCOUNT_JSON" ]]; then + echo "FAIL: fee account $FEE_ACCOUNT does not exist on $NETWORK." >&2 + exit 1 +fi + +LINE=$(echo "$ACCOUNT_JSON" | jq -c --arg c "$CODE" --arg i "$ISSUER" \ + '[.balances[] | select(.asset_code == $c and .asset_issuer == $i)] | first // empty') +if [[ -z "$LINE" ]]; then + echo "FAIL: $FEE_ACCOUNT has no trustline for $CODE:$ISSUER." >&2 + exit 1 +fi +if [[ "$(echo "$LINE" | jq -r '.is_authorized')" != "true" ]]; then + echo "FAIL: the $CODE trustline on $FEE_ACCOUNT is not authorized by the issuer." >&2 + exit 1 +fi + +echo "PASS: $FEE_ACCOUNT holds an authorized $CODE trustline." diff --git a/scripts/admin/verify-multisig.sh b/scripts/admin/verify-multisig.sh index 4dbcab5..7148591 100755 --- a/scripts/admin/verify-multisig.sh +++ b/scripts/admin/verify-multisig.sh @@ -6,16 +6,24 @@ # asserts the live signer configuration matches docs/admin-custody-policy.md: # # - exactly 3 signers + master key disabled +# - every signer is an ed25519 account key (no pre-auth tx or hash-x +# signers, which would let a single transaction or preimage stand in +# for a person) # - each signer has weight 1 # - low threshold = 0 (no read-only signers allowed) # - med + high thresholds = 2 (2-of-3 quorum) # # Run on testnet first (the practice multisig). After mainnet provisioning, -# run with --network mainnet against the production multi-sig BEFORE the -# `set_admin` call. +# run it with `mainnet` against the production multi-sig BEFORE the +# `set_admin` call (deploy_mainnet.sh rotate-admin runs it for you). # # Usage: # ./scripts/admin/verify-multisig.sh [testnet|mainnet] +# +# EXPECTED_SIGNERS (comma-separated G-addresses) additionally requires the +# signer set to be exactly that roster. MULTISIG_ACCOUNT_FILE reads the +# Horizon account JSON from a file instead of the network (used by +# scripts/test-admin-scripts.sh). set -euo pipefail @@ -35,6 +43,10 @@ if [[ -z "$MULTISIG" ]]; then echo "Usage: $0 [testnet|mainnet]" >&2 exit 1 fi +if [[ ! "$MULTISIG" =~ ^G[A-Z2-7]{55}$ ]]; then + echo "Error: '$MULTISIG' is not a G... account address." >&2 + exit 1 +fi case "$NETWORK" in testnet) HORIZON="https://horizon-testnet.stellar.org" ;; @@ -46,7 +58,11 @@ GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m' echo -e "${YELLOW}Verifying multi-sig ${MULTISIG} on ${NETWORK}...${NC}" -ACCOUNT_JSON=$(curl -sf "$HORIZON/accounts/$MULTISIG") +if [[ -n "${MULTISIG_ACCOUNT_FILE:-}" ]]; then + ACCOUNT_JSON=$(cat "$MULTISIG_ACCOUNT_FILE") +else + ACCOUNT_JSON=$(curl -sf "$HORIZON/accounts/$MULTISIG" || true) +fi if [[ -z "$ACCOUNT_JSON" ]]; then echo -e "${RED}Account not found on $NETWORK.${NC}" >&2 exit 1 @@ -89,6 +105,21 @@ assert "non-zero-weight signer count" "$SIGNERS_COUNT" "3" WEIGHTS=$(echo "$ACCOUNT_JSON" | jq -r '[.signers[] | select(.weight > 0) | .weight] | join(",")') assert "signer weights" "$WEIGHTS" "1,1,1" +# A pre-auth transaction or hash-x signer counts toward the quorum without a +# person behind it. +NON_KEY=$(echo "$ACCOUNT_JSON" | jq '[.signers[] | select(.weight > 0 and .type != "ed25519_public_key")] | length') +assert "non-key signers (pre-auth tx, hash-x, signed payload)" "$NON_KEY" "0" + +BAD_KEYS=$(echo "$ACCOUNT_JSON" | \ + jq --arg id "$MULTISIG" '[.signers[] | select(.weight > 0) | .key | select((test("^G[A-Z2-7]{55}$") | not) or . == $id)] | length') +assert "signers that are not distinct G-addresses" "$BAD_KEYS" "0" + +if [[ -n "${EXPECTED_SIGNERS:-}" ]]; then + ACTUAL=$(echo "$ACCOUNT_JSON" | jq -r '[.signers[] | select(.weight > 0) | .key] | sort | join(",")') + WANTED=$(echo "$EXPECTED_SIGNERS" | tr ',' '\n' | sed '/^$/d' | sort | paste -sd, -) + assert "signer roster" "$ACTUAL" "$WANTED" +fi + echo echo -e "${YELLOW}Signer addresses on file:${NC}" echo "$ACCOUNT_JSON" | jq -r '.signers[] | select(.weight > 0) | " " + .key + " (weight " + (.weight|tostring) + ", type " + .type + ")"' @@ -105,4 +136,4 @@ echo echo "Next steps:" echo " 1. Compare the printed signer addresses against the founder's roster." echo " 2. Confirm each signer can sign a test transaction (drill)." -echo " 3. Only then run set_admin per docs/mainnet-deploy-runbook.md §2.7." +echo " 3. Only then rotate admin per docs/DEPLOYMENT.md (Rotate admin to the multisig)." diff --git a/scripts/capture-mainnet-compat-snapshot.sh b/scripts/capture-mainnet-compat-snapshot.sh index 51f97a2..2cf6b40 100755 --- a/scripts/capture-mainnet-compat-snapshot.sh +++ b/scripts/capture-mainnet-compat-snapshot.sh @@ -1,13 +1,25 @@ #!/usr/bin/env bash +# Captures the mainnet storage of both contracts from the history archive, for +# the storage-compatibility replay. Read-only: it downloads archive buckets and +# never touches the network otherwise. +# +# capture-mainnet-compat-snapshot.sh +# Recaptures the ledger pinned in the manifest and fails unless the result +# is byte-identical to the reviewed fixture. +# +# capture-mainnet-compat-snapshot.sh +# Captures a new ledger (an archive checkpoint) and prints the values to +# record in the manifest. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" FIXTURES="$ROOT/contracts/compatibility/fixtures" MANIFEST="$FIXTURES/manifest.json" OUTPUT="${1:-}" +NEW_LEDGER="${2:-}" [ -n "$OUTPUT" ] || { - echo "usage: $0 " >&2 + echo "usage: $0 []" >&2 exit 1 } [ ! -e "$OUTPUT" ] || { @@ -23,18 +35,20 @@ command -v jq >/dev/null 2>&1 || { echo "jq not on PATH" >&2 exit 1 } -stellar --version | head -n 1 | grep -q '^stellar 27\.0\.0 ' || { - echo "Stellar CLI 27.0.0 is required" >&2 +# The fixture is only reproducible byte for byte with the CLI that wrote it. +stellar --version | head -n 1 | grep -q '^stellar 28\.1\.0 ' || { + echo "Stellar CLI 28.1.0 is required" >&2 exit 1 } -LEDGER="$(jq -r '.snapshot.ledger' "$MANIFEST")" -TIMESTAMP="$(jq -r '.snapshot.timestamp' "$MANIFEST")" -PROTOCOL="$(jq -r '.snapshot.protocol' "$MANIFEST")" NETWORK_ID="$(jq -r '.snapshot.network_id' "$MANIFEST")" EVENTS_ID="$(jq -r '.snapshot.events_contract' "$MANIFEST")" PROFILE_ID="$(jq -r '.snapshot.profile_contract' "$MANIFEST")" -EXPECTED_HASH="$(jq -r '.snapshot.sha256' "$MANIFEST")" +if [ -n "$NEW_LEDGER" ]; then + LEDGER="$NEW_LEDGER" +else + LEDGER="$(jq -r '.snapshot.ledger' "$MANIFEST")" +fi ARCHIVE_URL="${STELLAR_ARCHIVE_URL:-https://history.stellar.org/prd/core-live/core_live_001/}" NETWORK_PASSPHRASE="Public Global Stellar Network ; September 2015" @@ -51,6 +65,8 @@ stellar snapshot create \ --address "$EVENTS_ID" \ --address "$PROFILE_ID" +# Keep contract data only. The deployed code is pinned separately as wasm +# fixtures, and uploading those is what makes the captured instances runnable. jq \ --arg events "$EVENTS_ID" \ --arg profile "$PROFILE_ID" \ @@ -70,17 +86,12 @@ jq \ jq -e \ --argjson ledger "$LEDGER" \ - --argjson timestamp "$TIMESTAMP" \ - --argjson protocol "$PROTOCOL" \ --arg network_id "$NETWORK_ID" \ --arg events "$EVENTS_ID" \ --arg profile "$PROFILE_ID" \ ' .sequence_number == $ledger - and .timestamp == $timestamp - and .protocol_version == $protocol and .network_id == $network_id - and (.ledger_entries | length == 10) and (.ledger_entries | all( .live_until == 4294967295 and (.entry.data.contract_data.contract == $events @@ -90,7 +101,7 @@ jq -e \ | select(.entry.data.contract_data.key == "ledger_key_contract_instance")] | length == 2) ' "$normalized_snapshot" >/dev/null || { - echo "captured ledger does not match the pinned snapshot selection" >&2 + echo "captured ledger does not match the expected selection" >&2 exit 1 } @@ -102,10 +113,18 @@ else actual_hash="$(shasum -a 256 "$OUTPUT" | awk '{print $1}')" fi -echo "snapshot: $OUTPUT" -echo "sha256: $actual_hash" -[ "$actual_hash" = "$EXPECTED_HASH" ] || { - echo "expected: $EXPECTED_HASH" >&2 - echo "the regenerated snapshot differs from the reviewed fixture" >&2 - exit 1 -} +echo "snapshot: $OUTPUT" +echo "ledger: $(jq -r '.sequence_number' "$OUTPUT")" +echo "timestamp: $(jq -r '.timestamp' "$OUTPUT")" +echo "protocol: $(jq -r '.protocol_version' "$OUTPUT")" +echo "entries: $(jq -r '.ledger_entries | length' "$OUTPUT")" +echo "sha256: $actual_hash" + +if [ -z "$NEW_LEDGER" ]; then + expected_hash="$(jq -r '.snapshot.sha256' "$MANIFEST")" + [ "$actual_hash" = "$expected_hash" ] || { + echo "expected: $expected_hash" >&2 + echo "the regenerated snapshot differs from the reviewed fixture" >&2 + exit 1 + } +fi diff --git a/scripts/check-versions.sh b/scripts/check-versions.sh new file mode 100755 index 0000000..8531e76 --- /dev/null +++ b/scripts/check-versions.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# +# check-versions.sh: each contract must report one version everywhere. +# +# The deployed wasm carries contractmeta "version", the constructor stamps +# INITIAL_VERSION, and Cargo.toml names the crate version. When they drift, +# an upgrade proposal can name a version the code does not report. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FAIL=0 + +for contract in events profile; do + dir="$ROOT/contracts/$contract" + cargo=$(sed -nE 's/^version = "([^"]+)"/\1/p' "$dir/Cargo.toml" | head -1) + initial=$(sed -nE 's/.*INITIAL_VERSION: &str = "([^"]+)".*/\1/p' "$dir/src/admin.rs" | head -1) + meta=$(sed -nE 's/.*contractmeta!\(key = "version", val = "([^"]+)"\).*/\1/p' "$dir/src/lib.rs" | head -1) + + echo "$contract: Cargo.toml=$cargo INITIAL_VERSION=$initial contractmeta=${meta:-none}" + if [[ -z "$cargo" || "$cargo" != "$initial" ]]; then + echo "::error::$contract Cargo.toml ($cargo) and INITIAL_VERSION ($initial) differ" >&2 + FAIL=1 + fi + if [[ -n "$meta" && "$meta" != "$initial" ]]; then + echo "::error::$contract contractmeta ($meta) and INITIAL_VERSION ($initial) differ" >&2 + FAIL=1 + fi +done + +exit "$FAIL" diff --git a/scripts/deploy/deploy.sh b/scripts/deploy/deploy.sh index 4f408f1..1cfedf0 100755 --- a/scripts/deploy/deploy.sh +++ b/scripts/deploy/deploy.sh @@ -1,41 +1,34 @@ #!/usr/bin/env bash -# deploy.sh — deploy boundless-events + boundless-profile to a Stellar network. +# deploy.sh: deploy boundless-profile and boundless-events to testnet or +# futurenet, wire them together, and write deployments/.json. # # Usage: -# ./scripts/deploy/deploy.sh +# ./scripts/deploy/deploy.sh # -# where is one of: testnet, futurenet, mainnet. -# -# Required env (loaded from .env.deploy or shell): -# ADMIN_IDENTITY stellar CLI identity name with admin authority +# Required env (loaded from .env.deploy or the shell): +# ADMIN_IDENTITY stellar CLI identity that deploys and holds admin # FEE_ACCOUNT Stellar G-address that receives fees # FEE_BPS platform fee in basis points (e.g. 250 = 2.5%) # -# Contract 1.0.0 also required BOOTSTRAP_CREDITS; the 1.1.0 credit-removal -# upgrade (2026-06) moved credits off-chain and the profile constructor now -# takes only --admin. -# -# Spec: boundless-platform-contract-prd.md Section 12.2. +# Mainnet deploys use ./deploy_mainnet.sh instead (docs/DEPLOYMENT.md). set -euo pipefail NETWORK="${1:-}" if [[ -z "$NETWORK" ]]; then - echo "usage: $0 " >&2 + echo "usage: $0 " >&2 exit 1 fi -if [[ "$NETWORK" != "testnet" && "$NETWORK" != "futurenet" && "$NETWORK" != "mainnet" ]]; then - echo "error: network must be one of testnet, futurenet, mainnet (got: $NETWORK)" >&2 +if [[ "$NETWORK" == "mainnet" ]]; then + echo "error: mainnet deploys go through ./deploy_mainnet.sh deploy-profile and deploy-events (docs/DEPLOYMENT.md)" >&2 exit 1 fi - -# Load .env.deploy if present (and not already exported). SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -if [[ -f "$REPO_ROOT/.env.deploy" ]]; then - # shellcheck disable=SC1091 - set -a; source "$REPO_ROOT/.env.deploy"; set +a -fi +# shellcheck source=../lib/deploy-env.sh +source "$SCRIPT_DIR/../lib/deploy-env.sh" +load_env_deploy "$REPO_ROOT/.env.deploy" +require_network "$NETWORK" require() { local name="$1" @@ -47,12 +40,15 @@ require() { require ADMIN_IDENTITY require FEE_ACCOUNT require FEE_BPS +require_g_address FEE_ACCOUNT "$FEE_ACCOUNT" +if [[ ! "$FEE_BPS" =~ ^[0-9]+$ ]]; then + echo "error: FEE_BPS must be a whole number of basis points (got: $FEE_BPS)" >&2 + exit 1 +fi -# Soroban host on Stellar testnet accepts WebAssembly emitted with the -# reference-types extension; stellar-cli's local simulator only validates that -# extension from 26.0.0 onward. Earlier CLIs reject Rust 1.90.0 wasm32v1-none -# output at simulation time with a "reference-types not enabled" error. -MIN_CLI_MAJOR=26 +# CI builds with stellar CLI 28.1.0 against soroban-sdk 28; older CLIs are +# untested with these contracts. +MIN_CLI_MAJOR=28 CLI_VERSION_LINE="$(stellar --version 2>/dev/null | head -1)" CLI_MAJOR="$(printf '%s' "$CLI_VERSION_LINE" | grep -oE '[0-9]+' | head -1)" if [[ -z "${CLI_MAJOR:-}" ]]; then @@ -60,24 +56,14 @@ if [[ -z "${CLI_MAJOR:-}" ]]; then exit 1 fi if (( CLI_MAJOR < MIN_CLI_MAJOR )); then - cat >&2 <= $MIN_CLI_MAJOR.0.0 so the local simulator can validate - reference-types wasm emitted by Rust 1.90.0. - -Upgrade: - brew upgrade stellar/tap/stellar-cli - # or - cargo install --locked stellar-cli@26.1.0 -EOF + echo "error: stellar CLI $CLI_VERSION_LINE is too old; CI uses 28.1.0 (cargo install --locked stellar-cli@28.1.0)" >&2 exit 1 fi -# Sanity check fee_bps. Contract enforces MAX_FEE_BPS = 1000 (10%) per -# 2026-06 Stellar-skill audit L4; values above this will deploy fine and -# then revert at the first create_event call. +# The constructor accepts any value, but every create_event reverts above the +# contract's MAX_FEE_BPS of 1000 (10%). if (( FEE_BPS < 0 || FEE_BPS > 1000 )); then - echo "error: FEE_BPS must be in [0, 1000] (contract caps at 1000 = 10% per audit L4); got $FEE_BPS" >&2 + echo "error: FEE_BPS must be in [0, 1000] (the contract caps fees at 10%); got $FEE_BPS" >&2 exit 1 fi @@ -90,18 +76,11 @@ echo " fee account: $FEE_ACCOUNT" echo " fee bps: $FEE_BPS" echo -# 1. Build both contracts. Testnet/futurenet enable the `testnet` feature → -# zero upgrade timelock for fast iteration. Mainnet MUST build WITHOUT it -# (default = full audit-mandated timelock; fail-safe). -echo "==> building contracts" -BUILD_FEATURES="" -if [[ "$NETWORK" == "testnet" || "$NETWORK" == "futurenet" ]]; then - BUILD_FEATURES="--features testnet" -fi -# shellcheck disable=SC2086 -( cd "$REPO_ROOT/contracts/events" && stellar contract build --locked $BUILD_FEATURES ) -# shellcheck disable=SC2086 -( cd "$REPO_ROOT/contracts/profile" && stellar contract build --locked $BUILD_FEATURES ) +# The testnet feature zeroes the upgrade timelock so test upgrades can apply at +# once; mainnet builds (deploy_mainnet.sh) leave it off. +echo "==> building contracts with --features testnet" +( cd "$REPO_ROOT" && stellar contract build --locked --package boundless-events --features testnet ) +( cd "$REPO_ROOT" && stellar contract build --locked --package boundless-profile --features testnet ) EVENTS_WASM="$REPO_ROOT/target/wasm32v1-none/release/boundless_events.wasm" PROFILE_WASM="$REPO_ROOT/target/wasm32v1-none/release/boundless_profile.wasm" @@ -110,7 +89,7 @@ for f in "$EVENTS_WASM" "$PROFILE_WASM"; do [[ -f "$f" ]] || { echo "error: wasm not found at $f" >&2; exit 1; } done -# 2. Deploy boundless-profile first (events constructor needs its address). +# Profile first: the events constructor takes its address. echo echo "==> deploying boundless-profile" PROFILE_ID=$(stellar contract deploy \ @@ -121,7 +100,6 @@ PROFILE_ID=$(stellar contract deploy \ --admin "$ADMIN_ADDR") echo " profile contract id: $PROFILE_ID" -# 3. Deploy boundless-events pointing at the profile contract. echo echo "==> deploying boundless-events" EVENTS_ID=$(stellar contract deploy \ @@ -135,7 +113,6 @@ EVENTS_ID=$(stellar contract deploy \ --profile_contract "$PROFILE_ID") echo " events contract id: $EVENTS_ID" -# 4. Wire profile to recognize the events contract. echo echo "==> wiring profile.set_events_contract" stellar contract invoke \ @@ -146,7 +123,6 @@ stellar contract invoke \ set_events_contract \ --new_addr "$EVENTS_ID" -# 5. Persist deployment record. DEPLOY_RECORD="$REPO_ROOT/deployments/$NETWORK.json" mkdir -p "$(dirname "$DEPLOY_RECORD")" cat > "$DEPLOY_RECORD" < "$DEPLOY_RECORD" < done. summary written to $DEPLOY_RECORD" +echo "==> done. local record written to $DEPLOY_RECORD" echo -echo "set these in the nestjs deployment env:" +echo "set these in the boundless-nestjs deployment env:" echo " BOUNDLESS_EVENTS_CONTRACT_ADDRESS=$EVENTS_ID" echo " BOUNDLESS_PROFILE_CONTRACT_ADDRESS=$PROFILE_ID" echo -echo "next: ./scripts/deploy/register_token.sh $NETWORK " +echo "next: ./scripts/deploy/register_token.sh $NETWORK " diff --git a/scripts/deploy/register_token.sh b/scripts/deploy/register_token.sh index 35a5e06..ecee24d 100755 --- a/scripts/deploy/register_token.sh +++ b/scripts/deploy/register_token.sh @@ -1,36 +1,33 @@ #!/usr/bin/env bash -# register_token.sh — whitelist a token on boundless-events. +# register_token.sh: whitelist a token on boundless-events while the deployer +# identity still holds admin (before the multisig rotation). # # Usage: -# ./scripts/deploy/register_token.sh +# ./scripts/deploy/register_token.sh # -# Prerequisites (admin's responsibility): -# The fee_account must hold an active trustline for the token. The contract -# does NOT pre-flight this; a missing trustline will surface at the first -# deposit attempt as a transaction failure. -# -# To verify the trustline first (run before this script): -# stellar account get --account "$FEE_ACCOUNT" --network "$NETWORK" -# # confirm the asset appears in balances[] -# -# Spec: boundless-platform-contract-prd.md Section 8. +# The asset must be the one the token contract wraps, and the fee account must +# hold an authorized trustline for it: the contract checks neither, and a +# missing trustline makes every create_event and add_funds for the token +# revert. Both are verified before anything is signed. set -euo pipefail NETWORK="${1:-}" TOKEN="${2:-}" +ASSET="${3:-}" -if [[ -z "$NETWORK" || -z "$TOKEN" ]]; then - echo "usage: $0 " >&2 +if [[ -z "$NETWORK" || -z "$TOKEN" || -z "$ASSET" ]]; then + echo "usage: $0 " >&2 exit 1 fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -if [[ -f "$REPO_ROOT/.env.deploy" ]]; then - # shellcheck disable=SC1091 - set -a; source "$REPO_ROOT/.env.deploy"; set +a -fi +# shellcheck source=../lib/deploy-env.sh +source "$SCRIPT_DIR/../lib/deploy-env.sh" +load_env_deploy "$REPO_ROOT/.env.deploy" +require_network "$NETWORK" +require_c_address token "$TOKEN" DEPLOY_RECORD="$REPO_ROOT/deployments/$NETWORK.json" if [[ ! -f "$DEPLOY_RECORD" ]]; then @@ -39,8 +36,10 @@ if [[ ! -f "$DEPLOY_RECORD" ]]; then fi # Pull the events contract id and fee account from the deployment record. -EVENTS_ID=$(node -e "console.log(require('$DEPLOY_RECORD').events_contract)") -FEE_ACCOUNT=$(node -e "console.log(require('$DEPLOY_RECORD').fee_account)") +EVENTS_ID=$(record_field "$DEPLOY_RECORD" events_contract) +FEE_ACCOUNT=$(record_field "$DEPLOY_RECORD" fee_account) +require_c_address events_contract "$EVENTS_ID" +require_g_address fee_account "$FEE_ACCOUNT" require() { local name="$1" @@ -54,15 +53,16 @@ require ADMIN_IDENTITY echo "==> registering token on boundless-events" echo " network: $NETWORK" echo " events: $EVENTS_ID" -echo " fee account: $FEE_ACCOUNT (operator: verify trustline exists)" +echo " fee account: $FEE_ACCOUNT" echo " token contract: $TOKEN" echo -read -r -p "fee account holds an active trustline for this token? [y/N] " confirm -if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then - echo "aborted. add the trustline first; the contract does not enforce it." >&2 +WRAPPED=$(stellar contract id asset --asset "$ASSET" --network "$NETWORK") +if [[ "$WRAPPED" != "$TOKEN" ]]; then + echo "error: $ASSET is wrapped by $WRAPPED, not $TOKEN" >&2 exit 1 fi +"$SCRIPT_DIR/../admin/verify-fee-trustline.sh" "$NETWORK" "$FEE_ACCOUNT" "$ASSET" stellar contract invoke \ --id "$EVENTS_ID" \ @@ -74,12 +74,8 @@ stellar contract invoke \ echo "==> done. token $TOKEN is now whitelisted." -# Append the token to the deployment record's supported_tokens array. TMP=$(mktemp) -node -e " -const fs = require('fs'); -const path = '$DEPLOY_RECORD'; -const rec = require(path); -rec.supported_tokens = Array.from(new Set([...(rec.supported_tokens || []), '$TOKEN'])); -fs.writeFileSync(path, JSON.stringify(rec, null, 2) + '\n'); -" +jq --arg t "$TOKEN" \ + '.supported_tokens = ((.supported_tokens // []) | if index($t) then . else . + [$t] end)' \ + "$DEPLOY_RECORD" > "$TMP" +mv "$TMP" "$DEPLOY_RECORD" diff --git a/scripts/deploy/verify.sh b/scripts/deploy/verify.sh index 86ab5bf..eff0047 100755 --- a/scripts/deploy/verify.sh +++ b/scripts/deploy/verify.sh @@ -1,17 +1,18 @@ #!/usr/bin/env bash -# verify.sh — read-only post-deployment checks. +# verify.sh: read-only post-deployment checks. # # Usage: -# ./scripts/deploy/verify.sh +# ./scripts/deploy/verify.sh # -# Reads each contract's admin / fee account / profile binding and prints them -# alongside the deployment record so an operator can confirm they match. +# Prints the local deployment record next to each contract's version, admin, +# fee settings, bindings, pause flag and release validator so an operator can +# confirm they match. set -euo pipefail NETWORK="${1:-}" if [[ -z "$NETWORK" ]]; then - echo "usage: $0 " >&2 + echo "usage: $0 " >&2 exit 1 fi @@ -19,12 +20,11 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" DEPLOY_RECORD="$REPO_ROOT/deployments/$NETWORK.json" -# Load .env.deploy for ADMIN_IDENTITY (the CLI requires --source-account even -# on read-only invokes; the call is unsigned simulate-only). -if [[ -f "$REPO_ROOT/.env.deploy" ]]; then - # shellcheck disable=SC1091 - set -a; source "$REPO_ROOT/.env.deploy"; set +a -fi +# The CLI wants a source account even for a simulated read; nothing is signed. +# shellcheck source=../lib/deploy-env.sh +source "$SCRIPT_DIR/../lib/deploy-env.sh" +require_network "$NETWORK" +load_env_deploy "$REPO_ROOT/.env.deploy" SOURCE="${ADMIN_IDENTITY:-default}" if [[ ! -f "$DEPLOY_RECORD" ]]; then @@ -32,22 +32,35 @@ if [[ ! -f "$DEPLOY_RECORD" ]]; then exit 1 fi -EVENTS_ID=$(node -e "console.log(require('$DEPLOY_RECORD').events_contract)") -PROFILE_ID=$(node -e "console.log(require('$DEPLOY_RECORD').profile_contract)") +EVENTS_ID=$(record_field "$DEPLOY_RECORD" events_contract) +PROFILE_ID=$(record_field "$DEPLOY_RECORD" profile_contract) +require_c_address events_contract "$EVENTS_ID" +require_c_address profile_contract "$PROFILE_ID" echo "==> deployment record" cat "$DEPLOY_RECORD" echo +read_call() { + local id="$1" + shift + stellar contract invoke --id "$id" --source-account "$SOURCE" --network "$NETWORK" --send no -- "$@" +} + echo "==> on-chain events contract state" -echo " admin: $(stellar contract invoke --id "$EVENTS_ID" --source-account "$SOURCE" --network "$NETWORK" -- get_admin)" -echo " fee_account: $(stellar contract invoke --id "$EVENTS_ID" --source-account "$SOURCE" --network "$NETWORK" -- get_fee_account)" -echo " fee_bps: $(stellar contract invoke --id "$EVENTS_ID" --source-account "$SOURCE" --network "$NETWORK" -- get_fee_bps)" -echo " profile_contract: $(stellar contract invoke --id "$EVENTS_ID" --source-account "$SOURCE" --network "$NETWORK" -- get_profile_contract)" -echo " is_paused: $(stellar contract invoke --id "$EVENTS_ID" --source-account "$SOURCE" --network "$NETWORK" -- is_paused)" +echo " version: $(read_call "$EVENTS_ID" version)" +echo " migrated_to: $(read_call "$EVENTS_ID" get_migrated_to_version)" +echo " admin: $(read_call "$EVENTS_ID" get_admin)" +echo " fee_account: $(read_call "$EVENTS_ID" get_fee_account)" +echo " fee_bps: $(read_call "$EVENTS_ID" get_fee_bps)" +echo " profile_contract: $(read_call "$EVENTS_ID" get_profile_contract)" +echo " release_validator: $(read_call "$EVENTS_ID" get_release_validator)" +echo " is_paused: $(read_call "$EVENTS_ID" is_paused)" echo echo "==> on-chain profile contract state" -echo " admin: $(stellar contract invoke --id "$PROFILE_ID" --source-account "$SOURCE" --network "$NETWORK" -- get_admin)" -echo " events_contract: $(stellar contract invoke --id "$PROFILE_ID" --source-account "$SOURCE" --network "$NETWORK" -- get_events_contract)" -echo " is_paused: $(stellar contract invoke --id "$PROFILE_ID" --source-account "$SOURCE" --network "$NETWORK" -- is_paused)" +echo " version: $(read_call "$PROFILE_ID" version)" +echo " migrated_to: $(read_call "$PROFILE_ID" get_migrated_to_version)" +echo " admin: $(read_call "$PROFILE_ID" get_admin)" +echo " events_contract: $(read_call "$PROFILE_ID" get_events_contract)" +echo " is_paused: $(read_call "$PROFILE_ID" is_paused)" diff --git a/scripts/deploy_and_upgrade.sh b/scripts/deploy_and_upgrade.sh deleted file mode 100755 index bab5278..0000000 --- a/scripts/deploy_and_upgrade.sh +++ /dev/null @@ -1,148 +0,0 @@ -#!/bin/bash - -# Script for deploying and upgrading the Boundless contract -# Usage: ./deploy_and_upgrade.sh [deploy|upgrade] [network] [source_account] - -# Default values -ACTION=${1:-"deploy"} -NETWORK=${2:-"testnet"} -SOURCE_ACCOUNT=${3:-"admin"} -# CONTRACT_DIR="boundless/contracts/boundless" - -# Colors for output -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -# Check if stellar CLI is installed -if ! command -v stellar &> /dev/null; then - echo -e "${RED}Error: stellar CLI is not installed.${NC}" - echo "Please install it from https://soroban.stellar.org/docs/getting-started/setup" - exit 1 -fi - -# Function to deploy the contract -deploy_contract() { - echo -e "${YELLOW}Deploying Boundless contract to ${NETWORK}...${NC}" - - # Build the contract with reference-types enabled - echo "Building contract..." - cd $CONTRACT_DIR || { echo -e "${RED}Failed to change directory to $CONTRACT_DIR${NC}"; exit 1; } - - # Use stellar contract build which handles the reference-types issue - stellar contract build --locked - - # Deploy the contract - echo "Deploying contract..." - CONTRACT_ID=$(stellar contract deploy \ - --wasm target/wasm32v1-none/release/boundless.wasm \ - --source $SOURCE_ACCOUNT \ - --network $NETWORK) - - if [ $? -ne 0 ]; then - echo -e "${RED}Deployment failed!${NC}" - exit 1 - fi - - echo -e "${GREEN}Contract deployed successfully!${NC}" - echo "Contract ID: $CONTRACT_ID" - - # Create .stellar directory if it doesn't exist - mkdir -p .stellar - - # Save the contract ID to a file for future reference - echo $CONTRACT_ID > .stellar/contract_id_${NETWORK}.txt - echo "Contract ID saved to .stellar/contract_id_${NETWORK}.txt" - - # Get the admin address - ADMIN_ADDRESS=$(stellar keys address $SOURCE_ACCOUNT) - echo "Admin address: $ADMIN_ADDRESS" - - # Initialize the contract - echo "Initializing contract..." - stellar contract invoke \ - --id $CONTRACT_ID \ - --source $SOURCE_ACCOUNT \ - --network $NETWORK \ - -- \ - initialize \ - --admin $ADMIN_ADDRESS - - if [ $? -ne 0 ]; then - echo -e "${RED}Initialization failed!${NC}" - exit 1 - fi - - echo -e "${GREEN}Contract initialized successfully!${NC}" -} - -# Function to upgrade the contract -upgrade_contract() { - echo -e "${YELLOW}Upgrading Boundless contract on ${NETWORK}...${NC}" - - # Check if contract ID file exists - if [ ! -f .stellar/contract_id_${NETWORK}.txt ]; then - echo -e "${RED}Contract ID file not found. Please deploy the contract first.${NC}" - exit 1 - fi - - # Read the contract ID - CONTRACT_ID=$(cat .stellar/contract_id_${NETWORK}.txt) - - # Build the contract with reference-types enabled - echo "Building contract..." - cd $CONTRACT_DIR || { echo -e "${RED}Failed to change directory to $CONTRACT_DIR${NC}"; exit 1; } - - # Use stellar contract build which handles the reference-types issue - stellar contract build --locked - - # Install the new WASM - echo "Installing new WASM..." - WASM_HASH=$(stellar contract install \ - --source-account $SOURCE_ACCOUNT \ - --wasm target/wasm32v1-none/release/boundless.wasm \ - --network $NETWORK) - - if [ $? -ne 0 ]; then - echo -e "${RED}WASM installation failed!${NC}" - exit 1 - fi - - echo "WASM hash: $WASM_HASH" - - # Upgrade the contract - echo "Upgrading contract..." - stellar contract invoke \ - --id $CONTRACT_ID \ - --source $SOURCE_ACCOUNT \ - --network $NETWORK \ - -- \ - upgrade \ - --new_wasm_hash $WASM_HASH - - if [ $? -ne 0 ]; then - echo -e "${RED}Upgrade failed!${NC}" - exit 1 - fi - - echo -e "${GREEN}Contract upgraded successfully!${NC}" - echo "Contract ID: $CONTRACT_ID" -} - -# Main script logic -case $ACTION in - "deploy") - deploy_contract - ;; - "upgrade") - upgrade_contract - ;; - *) - echo -e "${RED}Invalid action. Use 'deploy' or 'upgrade'.${NC}" - echo "Usage: ./deploy_and_upgrade.sh [deploy|upgrade] [network] [source_account]" - exit 1 - ;; -esac - -echo -e "${GREEN}Operation completed successfully!${NC}" diff --git a/scripts/lib/deploy-env.sh b/scripts/lib/deploy-env.sh new file mode 100644 index 0000000..cdd283f --- /dev/null +++ b/scripts/lib/deploy-env.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Helpers shared by the deploy scripts. Source this file; it defines functions +# only. + +# Reads KEY=VALUE lines from a .env.deploy file without executing it, so a +# tampered file cannot run commands. One level of surrounding quotes is +# stripped; nothing is expanded. +load_env_deploy() { + local file=$1 line key value n=0 + [[ -f "$file" ]] || return 0 + while IFS= read -r line || [[ -n "$line" ]]; do + n=$((n + 1)) + line="${line%$'\r'}" + [[ -z "${line//[[:space:]]/}" || "$line" =~ ^[[:space:]]*# ]] && continue + if [[ ! "$line" =~ ^(export[[:space:]]+)?([A-Z_][A-Z0-9_]*)=(.*)$ ]]; then + echo "error: $file:$n is not KEY=VALUE" >&2 + return 1 + fi + key="${BASH_REMATCH[2]}" + value="${BASH_REMATCH[3]}" + if [[ "$value" =~ ^\"(.*)\"$ || "$value" =~ ^\'(.*)\'$ ]]; then + value="${BASH_REMATCH[1]}" + fi + export "$key=$value" + done < "$file" +} + +require_network() { + case "$1" in + testnet | futurenet | mainnet) ;; + *) + echo "error: network must be one of testnet, futurenet, mainnet (got: $1)" >&2 + exit 1 + ;; + esac +} + +require_g_address() { + if [[ ! "$2" =~ ^G[A-Z2-7]{55}$ ]]; then + echo "error: $1 must be a G... account address (got: $2)" >&2 + exit 1 + fi +} + +require_c_address() { + if [[ ! "$2" =~ ^C[A-Z2-7]{55}$ ]]; then + echo "error: $1 must be a C... contract address (got: $2)" >&2 + exit 1 + fi +} + +# Prints one top-level string field of a deployment record. +record_field() { + jq -er --arg k "$2" '.[$k] | strings' "$1" +} diff --git a/scripts/test-admin-scripts.sh b/scripts/test-admin-scripts.sh new file mode 100755 index 0000000..375e613 --- /dev/null +++ b/scripts/test-admin-scripts.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Runs the admin pre-flight checks against recorded Horizon fixtures, so a +# check that silently passes a bad configuration fails CI. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DATA="$ROOT/scripts/testdata/horizon" +# shellcheck source=testdata/horizon/ids.env +source "$DATA/ids.env" +FAILED=0 + +expect() { + local want=$1 label=$2 + shift 2 + if "$@" >/dev/null 2>&1; then got=pass; else got=fail; fi + if [[ "$got" == "$want" ]]; then + echo "PASS $label ($got)" + else + echo "FAIL $label: expected $want, got $got" + FAILED=1 + fi +} + +multisig() { + MULTISIG_ACCOUNT_FILE="$DATA/$1" "$ROOT/scripts/admin/verify-multisig.sh" "$MULTISIG" testnet +} +roster() { + EXPECTED_SIGNERS=$2 MULTISIG_ACCOUNT_FILE="$DATA/$1" \ + "$ROOT/scripts/admin/verify-multisig.sh" "$MULTISIG" testnet +} +fee() { + FEE_TRUSTLINE_ACCOUNT_FILE="$DATA/$1" \ + "$ROOT/scripts/admin/verify-fee-trustline.sh" testnet "$FEE" "USDC:$ISSUER" +} + +expect pass "2-of-3 of account keys" multisig multisig-ok.json +expect fail "pre-auth tx signer in the quorum" multisig multisig-preauth.json +expect fail "hash-x signer in the quorum" multisig multisig-hashx.json +expect pass "signer roster matches" roster multisig-ok.json "$SIGNERS" +expect fail "signer roster differs" roster multisig-ok.json "${SIGNERS%,*},$OTHER" +expect fail "malformed multisig address" \ + "$ROOT/scripts/admin/verify-multisig.sh" 'GBAD$(id)' testnet + +expect pass "fee account trusts the token" fee fee-ok.json +expect fail "fee account has no trustline" fee fee-missing.json +expect fail "fee trustline not authorized" fee fee-unauthorized.json +expect pass "native needs no trustline" \ + "$ROOT/scripts/admin/verify-fee-trustline.sh" testnet "$FEE" native + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +printf 'ADMIN_IDENTITY=alice\nFEE_BPS="250"\nINJECT=$(touch %s/pwned)\n' "$TMP" > "$TMP/env" +( + # shellcheck source=lib/deploy-env.sh + source "$ROOT/scripts/lib/deploy-env.sh" + load_env_deploy "$TMP/env" + [[ "$ADMIN_IDENTITY" == alice && "$FEE_BPS" == 250 && ! -e "$TMP/pwned" ]] +) && echo "PASS .env.deploy is parsed, not executed" || { + echo "FAIL .env.deploy is parsed, not executed" + FAILED=1 +} + +exit "$FAILED" diff --git a/scripts/test-mainnet-upgrade-guards.sh b/scripts/test-mainnet-upgrade-guards.sh index 2b61371..ee78b24 100755 --- a/scripts/test-mainnet-upgrade-guards.sh +++ b/scripts/test-mainnet-upgrade-guards.sh @@ -1,15 +1,18 @@ #!/usr/bin/env bash +# Exercises deploy_mainnet.sh against scripts/testdata/mock-stellar, so a guard +# that stops refusing an unsafe upgrade step fails CI. No network is used. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/boundless-mainnet-guards.XXXXXX")" FAKE_BIN="$TEST_ROOT/bin" SCRIPT="$TEST_ROOT/deploy_mainnet.sh" -EVENTS_WASM="$ROOT/contracts/compatibility/fixtures/events-1.5.0-sdk27.wasm" -PROFILE_WASM="$ROOT/contracts/compatibility/fixtures/profile-1.2.0-sdk27.wasm" XDR_DIR="$TEST_ROOT/xdr" -OLD_EVENTS_HASH="2a8789bf5dd392b91ddfe6591072a0cb9fae70045abf379fd1dbbc2db4af1de3" -OLD_PROFILE_HASH="b9e3500cfb559781e68359655f37775c78afffc5a8436daca9d1d1f8e3569b02" +EVENTS_WASM="$TEST_ROOT/events-2.0.0.wasm" +PROFILE_WASM="$TEST_ROOT/profile-1.2.1.wasm" +MISLABELLED_WASM="$TEST_ROOT/events-mislabelled.wasm" +OLD_EVENTS_HASH="b87365c16102f7242a8fc3e770f615991841741c40673bb241cb254130be4c26" +OLD_PROFILE_HASH="6b4804920a4068dfdcaaa711fac6390850e0a92f4b5ed4e46259694b61cbae12" WRONG_HASH="0000000000000000000000000000000000000000000000000000000000000000" cleanup() { @@ -21,12 +24,16 @@ mkdir -p "$FAKE_BIN" "$XDR_DIR" cp "$ROOT/scripts/testdata/mock-stellar" "$FAKE_BIN/stellar" cp "$ROOT/deploy_mainnet.sh" "$SCRIPT" chmod +x "$FAKE_BIN/stellar" "$SCRIPT" +printf 'kind=events\nversion=2.0.0\n' > "$EVENTS_WASM" +printf 'kind=profile\nversion=1.2.1\n' > "$PROFILE_WASM" +printf 'kind=events\nversion=2.0.1\n' > "$MISLABELLED_WASM" export PATH="$FAKE_BIN:$PATH" export STELLAR_NETWORK=mainnet-review export ADMIN_SOURCE=GADMIN export EVENTS_ID=EVENTS export PROFILE_ID=PROFILE +unset STELLAR_RPC_URL STELLAR_NETWORK_PASSPHRASE UNPAUSE_UNMIGRATED hash_file() { if command -v sha256sum >/dev/null 2>&1; then @@ -36,187 +43,217 @@ hash_file() { fi } -events_wasm_hash="$(hash_file "$EVENTS_WASM")" -profile_wasm_hash="$(hash_file "$PROFILE_WASM")" -events_pending="{\"new_version\":\"1.5.0\",\"wasm_hash\":\"$events_wasm_hash\"}" -profile_pending="{\"new_version\":\"1.2.0\",\"wasm_hash\":\"$profile_wasm_hash\"}" -bad_events_pending="{\"new_version\":\"1.5.0\",\"wasm_hash\":\"$WRONG_HASH\"}" -bad_profile_pending="{\"new_version\":\"1.2.0\",\"wasm_hash\":\"$WRONG_HASH\"}" +events_hash="$(hash_file "$EVENTS_WASM")" +profile_hash="$(hash_file "$PROFILE_WASM")" +events_pending="{\"new_version\":\"2.0.0\",\"wasm_hash\":\"$events_hash\",\"available_at_ledger\":100,\"expires_at_ledger\":518500}" +profile_pending="{\"new_version\":\"1.2.1\",\"wasm_hash\":\"$profile_hash\",\"available_at_ledger\":100,\"expires_at_ledger\":518500}" +bad_events_pending="{\"new_version\":\"2.0.0\",\"wasm_hash\":\"$WRONG_HASH\"}" -set_state() { +# Mainnet as of the 2.0.0 upgrade: events 1.7.0 stamped at 1.6.0 with rows +# still in the old layout, profile 1.2.0 never stamped. +set_events() { export FAKE_EVENTS_VERSION="$1" export FAKE_EVENTS_PAUSED="$2" export FAKE_EVENTS_PENDING="$3" export FAKE_EVENTS_MIGRATED="$4" - export FAKE_PROFILE_VERSION="$5" - export FAKE_PROFILE_PAUSED="$6" - export FAKE_PROFILE_PENDING="$7" - export FAKE_PROFILE_MIGRATED="$8" - export FAKE_PROFILE_EVENTS_CONTRACT=EVENTS - export FAKE_PROFILE_EVENTS_PENDING=null - if [ "$FAKE_EVENTS_VERSION" = "1.5.0" ]; then - export FAKE_EVENTS_LIVE_HASH="$events_wasm_hash" + export FAKE_EVENTS_MIGRATION_REMAINING="${5:-0}" + if [ "$1" = "2.0.0" ]; then + export FAKE_EVENTS_LIVE_HASH="$events_hash" else export FAKE_EVENTS_LIVE_HASH="$OLD_EVENTS_HASH" fi - if [ "$FAKE_PROFILE_VERSION" = "1.2.0" ]; then - export FAKE_PROFILE_LIVE_HASH="$profile_wasm_hash" +} + +set_profile() { + export FAKE_PROFILE_VERSION="$1" + export FAKE_PROFILE_PAUSED="$2" + export FAKE_PROFILE_PENDING="$3" + export FAKE_PROFILE_MIGRATED="$4" + if [ "$1" = "1.2.1" ]; then + export FAKE_PROFILE_LIVE_HASH="$profile_hash" else export FAKE_PROFILE_LIVE_HASH="$OLD_PROFILE_HASH" fi } +reset() { + unset FAKE_CLI_VERSION FAKE_ADMIN UNPAUSE_UNMIGRATED + export FAKE_PROFILE_EVENTS_CONTRACT=EVENTS + export FAKE_PROFILE_EVENTS_PENDING=null + set_events 1.7.0 false null '"1.6.0"' 8 + set_profile 1.2.0 false null null +} + expect_ok() { - local label="$1" + local label="$1" log shift - "$@" >/dev/null + log="$TEST_ROOT/last.log" + if ! "$@" >"$log" 2>&1; then + printf 'FAIL %s\n' "$label" >&2 + cat "$log" >&2 + exit 1 + fi printf 'PASS %s\n' "$label" } expect_fail() { - local label="$1" + local label="$1" log shift - if "$@" >/dev/null 2>&1; then + log="$TEST_ROOT/last.log" + if "$@" >/dev/null 2>"$log"; then printf 'FAIL %s unexpectedly succeeded\n' "$label" >&2 exit 1 fi - printf 'PASS %s failed closed\n' "$label" + printf 'PASS %s failed closed: %s\n' "$label" "$(tail -1 "$log" | sed 's/\x1b\[[0-9;]*m//g')" } -set_state 1.1.0 false null null 1.1.0 false null null -export FAKE_PROFILE_EVENTS_PENDING='{"target":"ROTATED","proposed_at":100}' -expect_fail pause-events-with-pending-profile-rotation \ - "$SCRIPT" prepare-pause-events "$XDR_DIR/pending-rotation-pause-events.xdr" - -set_state 1.1.0 false null null 1.1.0 false null null -export FAKE_PROFILE_EVENTS_CONTRACT=OTHER_EVENTS -expect_fail pause-events-with-profile-binding-drift \ - "$SCRIPT" prepare-pause-events "$XDR_DIR/binding-drift-pause-events.xdr" +x() { + printf '%s/%s.xdr' "$XDR_DIR" "$1" +} -set_state 1.1.0 false null null 1.1.0 false null null -expect_ok pause-events "$SCRIPT" prepare-pause-events "$XDR_DIR/pause-events.xdr" -expect_fail pause-profile-before-events \ - "$SCRIPT" prepare-pause-profile "$XDR_DIR/early-pause-profile.xdr" +reset +expect_ok upgrade-status "$SCRIPT" upgrade-status +expect_ok verify "$SCRIPT" verify +export FAKE_CLI_VERSION=27.0.0 +expect_fail old-cli "$SCRIPT" upgrade-status +reset +export STELLAR_RPC_URL=https://rpc.example.invalid +expect_fail explicit-rpc-with-named-network "$SCRIPT" upgrade-status +unset STELLAR_RPC_URL +expect_fail direct-submit-action "$SCRIPT" propose-upgrade-events "$EVENTS_WASM" 2.0.0 +expect_fail unknown-action "$SCRIPT" launch-everything -set_state 1.1.0 true null null 1.1.0 false null null -expect_ok pause-profile "$SCRIPT" prepare-pause-profile "$XDR_DIR/pause-profile.xdr" +reset +export FAKE_PROFILE_EVENTS_PENDING='{"target":"ROTATED","proposed_at":100}' +expect_fail pause-with-pending-profile-rotation "$SCRIPT" prepare-pause-events "$(x a)" +reset +export FAKE_PROFILE_EVENTS_CONTRACT=OTHER_EVENTS +expect_fail pause-with-profile-binding-drift "$SCRIPT" prepare-pause-events "$(x b)" +reset +export FAKE_ADMIN=GOTHER +expect_fail pause-with-admin-mismatch "$SCRIPT" prepare-pause-events "$(x c)" -set_state 1.1.0 true null null 1.1.0 true null null -expect_ok propose-profile \ - "$SCRIPT" prepare-propose-upgrade-profile "$PROFILE_WASM" 1.2.0 "$XDR_DIR/propose-profile.xdr" -expect_fail propose-profile-with-events-artifact \ - "$SCRIPT" prepare-propose-upgrade-profile "$EVENTS_WASM" 1.2.0 "$XDR_DIR/wrong-profile-artifact.xdr" -expect_fail propose-profile-with-unpinned-version \ - "$SCRIPT" prepare-propose-upgrade-profile "$PROFILE_WASM" 9.9.9 "$XDR_DIR/wrong-profile-version.xdr" -expect_fail propose-events-before-profile-staged \ - "$SCRIPT" prepare-propose-upgrade-events "$EVENTS_WASM" 1.5.0 "$XDR_DIR/early-propose-events.xdr" +reset +expect_ok pause-events "$SCRIPT" prepare-pause-events "$(x pause-events)" +[ -s "$(x pause-events)" ] || { echo "FAIL pause-events wrote no XDR" >&2; exit 1; } +expect_fail pause-events-missing-output-dir "$SCRIPT" prepare-pause-events "$TEST_ROOT/missing/out.xdr" +set_events 1.7.0 true null '"1.6.0"' 8 +expect_fail pause-events-twice "$SCRIPT" prepare-pause-events "$(x d)" -set_state 1.1.0 true null null 1.1.0 true "$profile_pending" null -expect_ok verify-profile-proposal \ - "$SCRIPT" verify-proposed-upgrade-profile "$PROFILE_WASM" 1.2.0 expect_ok propose-events \ - "$SCRIPT" prepare-propose-upgrade-events "$EVENTS_WASM" 1.5.0 "$XDR_DIR/propose-events.xdr" + "$SCRIPT" prepare-propose-upgrade-events "$EVENTS_WASM" 2.0.0 "$(x propose-events)" expect_fail propose-events-with-profile-artifact \ - "$SCRIPT" prepare-propose-upgrade-events "$PROFILE_WASM" 1.5.0 "$XDR_DIR/wrong-events-artifact.xdr" -expect_fail propose-events-with-unpinned-version \ - "$SCRIPT" prepare-propose-upgrade-events "$EVENTS_WASM" 9.9.9 "$XDR_DIR/wrong-events-version.xdr" -set_state 1.1.0 true null null 1.1.0 true "$bad_profile_pending" null -expect_fail propose-events-with-wrong-pending-profile-wasm \ - "$SCRIPT" prepare-propose-upgrade-events "$EVENTS_WASM" 1.5.0 "$XDR_DIR/wrong-profile-pending-propose-events.xdr" - -set_state 1.1.0 true "$events_pending" null 1.1.0 true "$profile_pending" null + "$SCRIPT" prepare-propose-upgrade-events "$PROFILE_WASM" 1.2.1 "$(x e)" +expect_fail propose-events-version-not-in-contractmeta \ + "$SCRIPT" prepare-propose-upgrade-events "$EVENTS_WASM" 2.0.1 "$(x f)" +expect_fail propose-events-mislabelled-artifact \ + "$SCRIPT" prepare-propose-upgrade-events "$MISLABELLED_WASM" 2.0.0 "$(x g)" +expect_fail propose-events-malformed-version \ + "$SCRIPT" prepare-propose-upgrade-events "$EVENTS_WASM" 2.0 "$(x h)" +expect_fail propose-events-missing-wasm \ + "$SCRIPT" prepare-propose-upgrade-events "$TEST_ROOT/none.wasm" 2.0.0 "$(x i)" +set_events 1.7.0 true "$events_pending" '"1.6.0"' 8 +expect_fail propose-events-while-pending \ + "$SCRIPT" prepare-propose-upgrade-events "$EVENTS_WASM" 2.0.0 "$(x j)" +set_events 2.0.0 false null '"2.0.0"' 0 +expect_fail propose-events-same-version \ + "$SCRIPT" prepare-propose-upgrade-events "$EVENTS_WASM" 2.0.0 "$(x k)" + +set_events 1.7.0 true "$events_pending" '"1.6.0"' 8 expect_ok verify-events-proposal \ - "$SCRIPT" verify-proposed-upgrade-events "$EVENTS_WASM" 1.5.0 -expect_ok apply-profile \ - "$SCRIPT" prepare-apply-upgrade-profile "$PROFILE_WASM" 1.2.0 "$XDR_DIR/apply-profile.xdr" -expect_fail apply-events-before-profile-applied \ - "$SCRIPT" prepare-apply-upgrade-events "$EVENTS_WASM" 1.5.0 "$XDR_DIR/early-apply-events.xdr" -set_state 1.1.0 true "$bad_events_pending" null 1.1.0 true "$profile_pending" null -expect_fail apply-profile-with-wrong-pending-events-wasm \ - "$SCRIPT" prepare-apply-upgrade-profile "$PROFILE_WASM" 1.2.0 "$XDR_DIR/wrong-events-pending-apply-profile.xdr" - -set_state 1.1.0 true "$events_pending" null 1.2.0 true null null + "$SCRIPT" verify-proposed-upgrade-events "$EVENTS_WASM" 2.0.0 +expect_fail verify-events-proposal-wrong-version \ + "$SCRIPT" verify-proposed-upgrade-events "$MISLABELLED_WASM" 2.0.1 +set_events 1.7.0 true "$bad_events_pending" '"1.6.0"' 8 +expect_fail verify-events-proposal-wrong-wasm \ + "$SCRIPT" verify-proposed-upgrade-events "$EVENTS_WASM" 2.0.0 + +set_events 1.7.0 true "$events_pending" '"1.6.0"' 8 expect_ok apply-events \ - "$SCRIPT" prepare-apply-upgrade-events "$EVENTS_WASM" 1.5.0 "$XDR_DIR/apply-events.xdr" -export FAKE_PROFILE_LIVE_HASH="$WRONG_HASH" -expect_fail apply-events-with-wrong-live-profile-wasm \ - "$SCRIPT" prepare-apply-upgrade-events "$EVENTS_WASM" 1.5.0 "$XDR_DIR/wrong-profile-live-apply-events.xdr" + "$SCRIPT" prepare-apply-upgrade-events "$EVENTS_WASM" 2.0.0 "$(x apply-events)" +set_events 1.7.0 false "$events_pending" '"1.6.0"' 8 +expect_fail apply-events-unpaused \ + "$SCRIPT" prepare-apply-upgrade-events "$EVENTS_WASM" 2.0.0 "$(x l)" +set_events 1.7.0 true "$bad_events_pending" '"1.6.0"' 8 +expect_fail apply-events-wrong-pending-wasm \ + "$SCRIPT" prepare-apply-upgrade-events "$EVENTS_WASM" 2.0.0 "$(x m)" +set_events 1.7.0 true null '"1.6.0"' 8 +expect_fail apply-events-nothing-pending \ + "$SCRIPT" prepare-apply-upgrade-events "$EVENTS_WASM" 2.0.0 "$(x n)" -set_state 1.5.0 true null null 1.2.0 true null null -expect_ok migrate-profile \ - "$SCRIPT" prepare-migrate-upgrade-profile 1.2.0 "$XDR_DIR/migrate-profile.xdr" -export FAKE_EVENTS_LIVE_HASH="$WRONG_HASH" -expect_fail migrate-profile-with-wrong-live-events-wasm \ - "$SCRIPT" prepare-migrate-upgrade-profile 1.2.0 "$XDR_DIR/wrong-events-live-migrate-profile.xdr" -set_state 1.5.0 true null null 1.2.0 true null null -export FAKE_PROFILE_LIVE_HASH="$WRONG_HASH" -expect_fail migrate-profile-with-wrong-live-profile-wasm \ - "$SCRIPT" prepare-migrate-upgrade-profile 1.2.0 "$XDR_DIR/wrong-profile-live-migrate-profile.xdr" -set_state 1.5.0 true null null 1.2.0 true null null -expect_fail migrate-events-before-profile-migrated \ - "$SCRIPT" prepare-migrate-upgrade-events 1.5.0 "$XDR_DIR/early-migrate-events.xdr" +set_events 2.0.0 true null '"1.6.0"' 2 +expect_ok migrate-events-page \ + "$SCRIPT" prepare-migrate-events-page 8 "$(x migrate-events-page)" +expect_fail migrate-events-page-zero "$SCRIPT" prepare-migrate-events-page 0 "$(x o)" +expect_fail migrate-events-page-not-a-number "$SCRIPT" prepare-migrate-events-page eight "$(x p)" +expect_fail migrate-before-pages-finish \ + "$SCRIPT" prepare-migrate-upgrade-events 2.0.0 "$(x q)" +set_events 2.0.0 false null '"1.6.0"' 2 +expect_fail migrate-events-page-unpaused "$SCRIPT" prepare-migrate-events-page 8 "$(x r)" +set_events 2.0.0 true "$events_pending" '"1.6.0"' 2 +expect_fail migrate-events-page-with-pending-upgrade \ + "$SCRIPT" prepare-migrate-events-page 8 "$(x s)" +set_events 2.0.0 true null '"1.6.0"' 0 +expect_fail migrate-events-page-when-done "$SCRIPT" prepare-migrate-events-page 8 "$(x t)" -set_state 1.5.0 true null null 1.2.0 true null '"1.2.0"' expect_ok migrate-events \ - "$SCRIPT" prepare-migrate-upgrade-events 1.5.0 "$XDR_DIR/migrate-events.xdr" + "$SCRIPT" prepare-migrate-upgrade-events 2.0.0 "$(x migrate-events)" +expect_fail migrate-events-wrong-version \ + "$SCRIPT" prepare-migrate-upgrade-events 1.9.9 "$(x u)" +set_events 2.0.0 false null '"1.6.0"' 0 +expect_fail migrate-events-unpaused \ + "$SCRIPT" prepare-migrate-upgrade-events 2.0.0 "$(x v)" +set_events 2.0.0 true null '"2.0.0"' 0 +expect_fail migrate-events-replay \ + "$SCRIPT" prepare-migrate-upgrade-events 2.0.0 "$(x w)" +expect_fail migrate-events-page-after-stamp "$SCRIPT" prepare-migrate-events-page 8 "$(x y)" -set_state 1.5.0 true null '"1.5.0"' 1.2.0 true null '"1.2.0"' -expect_ok unpause-profile \ - "$SCRIPT" prepare-unpause-profile 1.2.0 "$XDR_DIR/unpause-profile.xdr" -expect_fail unpause-profile-with-unpinned-version \ - "$SCRIPT" prepare-unpause-profile 9.9.9 "$XDR_DIR/wrong-profile-unpause-version.xdr" -export FAKE_PROFILE_LIVE_HASH="$WRONG_HASH" -expect_fail unpause-profile-with-wrong-live-profile-wasm \ - "$SCRIPT" prepare-unpause-profile 1.2.0 "$XDR_DIR/wrong-profile-live-unpause-profile.xdr" -set_state 1.5.0 true null '"1.5.0"' 1.2.0 true null '"1.2.0"' -export FAKE_EVENTS_LIVE_HASH="$WRONG_HASH" -expect_fail unpause-profile-with-wrong-live-events-wasm \ - "$SCRIPT" prepare-unpause-profile 1.2.0 "$XDR_DIR/wrong-events-live-unpause-profile.xdr" -set_state 1.5.0 true null '"1.5.0"' 1.2.0 true null '"1.2.0"' -expect_fail unpause-events-before-profile-unpaused \ - "$SCRIPT" prepare-unpause-events 1.5.0 "$XDR_DIR/early-unpause-events.xdr" - -set_state 1.5.0 true null '"1.5.0"' 1.2.0 false null '"1.2.0"' -expect_ok unpause-events \ - "$SCRIPT" prepare-unpause-events 1.5.0 "$XDR_DIR/unpause-events.xdr" -expect_fail unpause-events-with-unpinned-version \ - "$SCRIPT" prepare-unpause-events 9.9.9 "$XDR_DIR/wrong-events-unpause-version.xdr" -export FAKE_EVENTS_LIVE_HASH="$WRONG_HASH" -expect_fail unpause-events-with-wrong-live-events-wasm \ - "$SCRIPT" prepare-unpause-events 1.5.0 "$XDR_DIR/wrong-events-live-unpause-events.xdr" -set_state 1.5.0 true null '"1.5.0"' 1.2.0 false null '"1.2.0"' -export FAKE_PROFILE_LIVE_HASH="$WRONG_HASH" -expect_fail unpause-events-with-wrong-live-profile-wasm \ - "$SCRIPT" prepare-unpause-events 1.5.0 "$XDR_DIR/wrong-profile-live-unpause-events.xdr" +expect_ok unpause-events "$SCRIPT" prepare-unpause-events 2.0.0 "$(x unpause-events)" +expect_fail unpause-events-wrong-version "$SCRIPT" prepare-unpause-events 1.7.0 "$(x z)" +set_events 2.0.0 true null '"1.6.0"' 0 +expect_fail unpause-events-unmigrated "$SCRIPT" prepare-unpause-events 2.0.0 "$(x aa)" +set_events 1.7.0 true null '"1.6.0"' 8 +export UNPAUSE_UNMIGRATED=1 +expect_ok abandon-unpause-events "$SCRIPT" prepare-unpause-events 1.7.0 "$(x abandon-events)" +unset UNPAUSE_UNMIGRATED +set_events 1.7.0 true "$events_pending" '"1.6.0"' 8 +export UNPAUSE_UNMIGRATED=1 +expect_fail unpause-events-with-pending-upgrade "$SCRIPT" prepare-unpause-events 1.7.0 "$(x ab)" +unset UNPAUSE_UNMIGRATED +set_events 2.0.0 false null '"2.0.0"' 0 +expect_fail unpause-events-not-paused "$SCRIPT" prepare-unpause-events 2.0.0 "$(x ac)" -set_state 1.5.0 false null '"1.5.0"' 1.2.0 false null '"1.2.0"' -expect_ok upgrade-status "$SCRIPT" upgrade-status -expect_ok verify-profile "$SCRIPT" verify-upgrade-profile "$PROFILE_WASM" 1.2.0 -expect_ok verify-events "$SCRIPT" verify-upgrade-events "$EVENTS_WASM" 1.5.0 +expect_ok verify-events "$SCRIPT" verify-upgrade-events "$EVENTS_WASM" 2.0.0 export FAKE_EVENTS_LIVE_HASH="$WRONG_HASH" -expect_fail verify-events-with-wrong-live-wasm \ - "$SCRIPT" verify-upgrade-events "$EVENTS_WASM" 1.5.0 -set_state 1.5.0 false null '"1.5.0"' 1.2.0 false null '"1.2.0"' +expect_fail verify-events-wrong-live-wasm "$SCRIPT" verify-upgrade-events "$EVENTS_WASM" 2.0.0 +set_events 2.0.0 true null '"2.0.0"' 0 +expect_fail verify-events-still-paused "$SCRIPT" verify-upgrade-events "$EVENTS_WASM" 2.0.0 +set_events 2.0.0 false null '"1.6.0"' 0 +expect_fail verify-events-unmigrated "$SCRIPT" verify-upgrade-events "$EVENTS_WASM" 2.0.0 + +reset +set_events 2.0.0 false null '"2.0.0"' 0 +expect_ok propose-profile \ + "$SCRIPT" prepare-propose-upgrade-profile "$PROFILE_WASM" 1.2.1 "$(x propose-profile)" +expect_fail propose-profile-with-events-artifact \ + "$SCRIPT" prepare-propose-upgrade-profile "$EVENTS_WASM" 2.0.0 "$(x ad)" +set_profile 1.2.0 false "$profile_pending" null +expect_ok verify-profile-proposal \ + "$SCRIPT" verify-proposed-upgrade-profile "$PROFILE_WASM" 1.2.1 +expect_ok apply-profile \ + "$SCRIPT" prepare-apply-upgrade-profile "$PROFILE_WASM" 1.2.1 "$(x apply-profile)" +expect_ok cancel-profile "$SCRIPT" prepare-cancel-upgrade-profile "$(x cancel-profile)" +set_profile 1.2.0 false null null +expect_fail cancel-profile-nothing-pending "$SCRIPT" prepare-cancel-upgrade-profile "$(x ae)" +set_profile 1.2.1 false null null +expect_ok migrate-profile "$SCRIPT" prepare-migrate-upgrade-profile 1.2.1 "$(x migrate-profile)" +expect_fail migrate-profile-wrong-version \ + "$SCRIPT" prepare-migrate-upgrade-profile 1.2.0 "$(x af)" +set_profile 1.2.1 false null '"1.2.1"' +expect_fail migrate-profile-replay "$SCRIPT" prepare-migrate-upgrade-profile 1.2.1 "$(x ag)" +expect_ok verify-profile "$SCRIPT" verify-upgrade-profile "$PROFILE_WASM" 1.2.1 export FAKE_PROFILE_LIVE_HASH="$WRONG_HASH" -expect_fail verify-profile-with-wrong-live-wasm \ - "$SCRIPT" verify-upgrade-profile "$PROFILE_WASM" 1.2.0 - -set_state 1.1.0 true "$events_pending" null 1.1.0 true "$profile_pending" null -expect_ok cancel-profile \ - "$SCRIPT" prepare-cancel-upgrade-profile "$XDR_DIR/cancel-profile.xdr" -expect_fail abandon-profile-before-events-cancel \ - "$SCRIPT" prepare-unpause-profile 1.1.0 "$XDR_DIR/early-abandon-profile.xdr" - -set_state 1.1.0 true "$events_pending" null 1.1.0 true null null -expect_ok cancel-events \ - "$SCRIPT" prepare-cancel-upgrade-events "$XDR_DIR/cancel-events.xdr" - -set_state 1.1.0 true null null 1.1.0 true null null -expect_ok abandon-unpause-profile \ - "$SCRIPT" prepare-unpause-profile 1.1.0 "$XDR_DIR/abandon-profile.xdr" -expect_fail abandon-events-before-profile-unpaused \ - "$SCRIPT" prepare-unpause-events 1.1.0 "$XDR_DIR/early-abandon-events.xdr" - -set_state 1.1.0 true null null 1.1.0 false null null -expect_ok abandon-unpause-events \ - "$SCRIPT" prepare-unpause-events 1.1.0 "$XDR_DIR/abandon-events.xdr" +expect_fail verify-profile-wrong-live-wasm "$SCRIPT" verify-upgrade-profile "$PROFILE_WASM" 1.2.1 + +set_events 2.0.0 true "$events_pending" '"1.6.0"' 0 +expect_ok cancel-events "$SCRIPT" prepare-cancel-upgrade-events "$(x cancel-events)" diff --git a/scripts/test-sdk27-compat.sh b/scripts/test-storage-compat.sh similarity index 72% rename from scripts/test-sdk27-compat.sh rename to scripts/test-storage-compat.sh index 4f2194a..30fdc57 100755 --- a/scripts/test-sdk27-compat.sh +++ b/scripts/test-storage-compat.sh @@ -28,8 +28,8 @@ rustc --version | grep -q '^rustc 1\.93\.0 ' || { echo "Rust 1.93.0 is required" >&2 exit 1 } -stellar --version | head -n 1 | grep -q '^stellar 27\.0\.0 ' || { - echo "Stellar CLI 27.0.0 is required" >&2 +stellar --version | head -n 1 | grep -q '^stellar 28\.1\.0 ' || { + echo "Stellar CLI 28.1.0 is required" >&2 exit 1 } @@ -86,31 +86,7 @@ jq -e \ exit 1 } -verify_build="${VERIFY_SDK27_BUILD:-0}" -case "$verify_build" in - 0) ;; - 1) - EVENTS_WASM="$ROOT/target/wasm32v1-none/release/boundless_events.wasm" - PROFILE_WASM="$ROOT/target/wasm32v1-none/release/boundless_profile.wasm" - [ -f "$EVENTS_WASM" ] || { - echo "missing $EVENTS_WASM; run stellar contract build --locked first" >&2 - exit 1 - } - [ -f "$PROFILE_WASM" ] || { - echo "missing $PROFILE_WASM; run stellar contract build --locked first" >&2 - exit 1 - } - - events_hash="$(jq -r '.wasm[] | select(.file == "events-1.5.0-sdk27.wasm") | .sha256' "$MANIFEST")" - profile_hash="$(jq -r '.wasm[] | select(.file == "profile-1.2.0-sdk27.wasm") | .sha256' "$MANIFEST")" - check_hash "$EVENTS_WASM" "$events_hash" - check_hash "$PROFILE_WASM" "$profile_hash" - ;; - *) - echo "VERIFY_SDK27_BUILD must be 0 or 1" >&2 - exit 1 - ;; -esac - +# The replay upgrades into the release build of this tree, so build it first. cd "$ROOT" -cargo test --locked -p boundless-storage-compatibility +stellar contract build --locked +cargo test --locked -p boundless-storage-compatibility -- --include-ignored diff --git a/scripts/testdata/horizon/fee-missing.json b/scripts/testdata/horizon/fee-missing.json new file mode 100644 index 0000000..b896c6a --- /dev/null +++ b/scripts/testdata/horizon/fee-missing.json @@ -0,0 +1,9 @@ +{ + "id": "GVFZ5ZFKKIBJ5J6WJIBAGI3MNBQNSPUQ2IDW52IJB4LAJLJ6HDU6GDPM", + "balances": [ + { + "asset_type": "native", + "balance": "10.0000000" + } + ] +} \ No newline at end of file diff --git a/scripts/testdata/horizon/fee-ok.json b/scripts/testdata/horizon/fee-ok.json new file mode 100644 index 0000000..159f260 --- /dev/null +++ b/scripts/testdata/horizon/fee-ok.json @@ -0,0 +1,16 @@ +{ + "id": "GVFZ5ZFKKIBJ5J6WJIBAGI3MNBQNSPUQ2IDW52IJB4LAJLJ6HDU6GDPM", + "balances": [ + { + "asset_type": "credit_alphanum4", + "asset_code": "USDC", + "asset_issuer": "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + "balance": "0.0000000", + "is_authorized": true + }, + { + "asset_type": "native", + "balance": "10.0000000" + } + ] +} \ No newline at end of file diff --git a/scripts/testdata/horizon/fee-unauthorized.json b/scripts/testdata/horizon/fee-unauthorized.json new file mode 100644 index 0000000..7c15242 --- /dev/null +++ b/scripts/testdata/horizon/fee-unauthorized.json @@ -0,0 +1,16 @@ +{ + "id": "GVFZ5ZFKKIBJ5J6WJIBAGI3MNBQNSPUQ2IDW52IJB4LAJLJ6HDU6GDPM", + "balances": [ + { + "asset_type": "credit_alphanum4", + "asset_code": "USDC", + "asset_issuer": "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + "balance": "0.0000000", + "is_authorized": false + }, + { + "asset_type": "native", + "balance": "10.0000000" + } + ] +} \ No newline at end of file diff --git a/scripts/testdata/horizon/ids.env b/scripts/testdata/horizon/ids.env new file mode 100644 index 0000000..629355d --- /dev/null +++ b/scripts/testdata/horizon/ids.env @@ -0,0 +1,5 @@ +MULTISIG=GUJZDEGXDNCF32EPF3DHODZDOCIS2JHTLGMXGEDN73U55XTPLPFT7V4S +SIGNERS=GEH2KVJ72CEUVW75EFR6EDT4SYWB5WKH7DNSIPZZ7FK4ZRI3R2WYOJFL,GJOOA7LQSAJ2XUID5ZZZZG6ZDMEN4KHVDGAJGXBENYJQWX6HH7566TFJ,GGVQ6KBNXJBTFQXKWOVOMPZOM7WBBR6QMW4WXFOGO6MVN6A6WFHYM6L3 +OTHER=GRCG4BE4UMR46PQM4I2HZ4UEP3ENTHJXJQI5OGZ7KOK3ZV2MWUFXBV54 +FEE=GVFZ5ZFKKIBJ5J6WJIBAGI3MNBQNSPUQ2IDW52IJB4LAJLJ6HDU6GDPM +ISSUER=GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 diff --git a/scripts/testdata/horizon/multisig-hashx.json b/scripts/testdata/horizon/multisig-hashx.json new file mode 100644 index 0000000..67ace3b --- /dev/null +++ b/scripts/testdata/horizon/multisig-hashx.json @@ -0,0 +1,30 @@ +{ + "id": "GUJZDEGXDNCF32EPF3DHODZDOCIS2JHTLGMXGEDN73U55XTPLPFT7V4S", + "thresholds": { + "low_threshold": 0, + "med_threshold": 2, + "high_threshold": 2 + }, + "signers": [ + { + "key": "GEH2KVJ72CEUVW75EFR6EDT4SYWB5WKH7DNSIPZZ7FK4ZRI3R2WYOJFL", + "weight": 1, + "type": "ed25519_public_key" + }, + { + "key": "GJOOA7LQSAJ2XUID5ZZZZG6ZDMEN4KHVDGAJGXBENYJQWX6HH7566TFJ", + "weight": 1, + "type": "ed25519_public_key" + }, + { + "key": "XBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "weight": 1, + "type": "sha256_hash" + }, + { + "key": "GUJZDEGXDNCF32EPF3DHODZDOCIS2JHTLGMXGEDN73U55XTPLPFT7V4S", + "weight": 0, + "type": "ed25519_public_key" + } + ] +} \ No newline at end of file diff --git a/scripts/testdata/horizon/multisig-ok.json b/scripts/testdata/horizon/multisig-ok.json new file mode 100644 index 0000000..299e125 --- /dev/null +++ b/scripts/testdata/horizon/multisig-ok.json @@ -0,0 +1,30 @@ +{ + "id": "GUJZDEGXDNCF32EPF3DHODZDOCIS2JHTLGMXGEDN73U55XTPLPFT7V4S", + "thresholds": { + "low_threshold": 0, + "med_threshold": 2, + "high_threshold": 2 + }, + "signers": [ + { + "key": "GEH2KVJ72CEUVW75EFR6EDT4SYWB5WKH7DNSIPZZ7FK4ZRI3R2WYOJFL", + "weight": 1, + "type": "ed25519_public_key" + }, + { + "key": "GJOOA7LQSAJ2XUID5ZZZZG6ZDMEN4KHVDGAJGXBENYJQWX6HH7566TFJ", + "weight": 1, + "type": "ed25519_public_key" + }, + { + "key": "GGVQ6KBNXJBTFQXKWOVOMPZOM7WBBR6QMW4WXFOGO6MVN6A6WFHYM6L3", + "weight": 1, + "type": "ed25519_public_key" + }, + { + "key": "GUJZDEGXDNCF32EPF3DHODZDOCIS2JHTLGMXGEDN73U55XTPLPFT7V4S", + "weight": 0, + "type": "ed25519_public_key" + } + ] +} \ No newline at end of file diff --git a/scripts/testdata/horizon/multisig-preauth.json b/scripts/testdata/horizon/multisig-preauth.json new file mode 100644 index 0000000..51ab4e1 --- /dev/null +++ b/scripts/testdata/horizon/multisig-preauth.json @@ -0,0 +1,30 @@ +{ + "id": "GUJZDEGXDNCF32EPF3DHODZDOCIS2JHTLGMXGEDN73U55XTPLPFT7V4S", + "thresholds": { + "low_threshold": 0, + "med_threshold": 2, + "high_threshold": 2 + }, + "signers": [ + { + "key": "GEH2KVJ72CEUVW75EFR6EDT4SYWB5WKH7DNSIPZZ7FK4ZRI3R2WYOJFL", + "weight": 1, + "type": "ed25519_public_key" + }, + { + "key": "GJOOA7LQSAJ2XUID5ZZZZG6ZDMEN4KHVDGAJGXBENYJQWX6HH7566TFJ", + "weight": 1, + "type": "ed25519_public_key" + }, + { + "key": "TAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "weight": 1, + "type": "preauth_tx" + }, + { + "key": "GUJZDEGXDNCF32EPF3DHODZDOCIS2JHTLGMXGEDN73U55XTPLPFT7V4S", + "weight": 0, + "type": "ed25519_public_key" + } + ] +} \ No newline at end of file diff --git a/scripts/testdata/mock-stellar b/scripts/testdata/mock-stellar index 299fb54..4fa75e9 100644 --- a/scripts/testdata/mock-stellar +++ b/scripts/testdata/mock-stellar @@ -2,9 +2,28 @@ set -euo pipefail # Test-only CLI mock for test-mainnet-upgrade-guards.sh. It never uses a network. +# Fake wasm files are text with "kind=" and "version=" lines; contract state +# comes from FAKE_* variables set by the test. + +arg_after() { + local flag="$1" previous="" arg + shift + for arg in "$@"; do + if [ "$previous" = "$flag" ]; then + printf '%s\n' "$arg" + return 0 + fi + previous="$arg" + done + return 1 +} + +wasm_field() { + sed -n "s/^$2=//p" "$1" | head -1 +} if [ "${1:-}" = "--version" ]; then - printf 'stellar 27.0.0 (test)\n' + printf 'stellar %s (test)\n' "${FAKE_CLI_VERSION:-28.1.0}" exit 0 fi @@ -17,17 +36,34 @@ EOF exit 0 fi -if [ "${1:-}" = "contract" ] && [ "${2:-}" = "info" ] && [ "${3:-}" = "hash" ]; then - contract_id="" - previous="" - for arg in "$@"; do - if [ "$previous" = "--contract-id" ] || [ "$previous" = "--id" ]; then - contract_id="$arg" - break - fi - previous="$arg" - done +if [ "${1:-}" = "contract" ] && [ "${2:-}" = "info" ]; then + if wasm="$(arg_after --wasm "$@")"; then + kind="$(wasm_field "$wasm" kind)" + version="$(wasm_field "$wasm" version)" + case "${3:-}" in + meta) + if [ -n "$version" ]; then + printf '[{"sc_meta_v0":{"key":"version","val":"%s"}}]\n' "$version" + else + printf '[]\n' + fi + ;; + interface) + case "$kind" in + events) printf '[{"function_v0":{"name":"create_event"}},{"function_v0":{"name":"migrate_events"}}]\n' ;; + profile) printf '[{"function_v0":{"name":"register_earnings"}}]\n' ;; + *) printf '[]\n' ;; + esac + ;; + *) + printf 'unsupported fake info command: %s\n' "$*" >&2 + exit 1 + ;; + esac + exit 0 + fi + contract_id="$(arg_after --contract-id "$@" || arg_after --id "$@" || true)" case "$contract_id" in EVENTS) printf '%s\n' "${FAKE_EVENTS_LIVE_HASH:?missing FAKE_EVENTS_LIVE_HASH}" ;; PROFILE) printf '%s\n' "${FAKE_PROFILE_LIVE_HASH:?missing FAKE_PROFILE_LIVE_HASH}" ;; @@ -47,11 +83,8 @@ if [ "${1:-}" = "tx" ] && [ "${2:-}" = "simulate" ]; then fi if [ "${1:-}" = "contract" ] && [ "${2:-}" = "invoke" ]; then - contract_id="" function_name="" after_separator=false - previous="" - for arg in "$@"; do if [ "$arg" = "--build-only" ]; then printf 'BUILT-XDR\n' @@ -65,44 +98,53 @@ if [ "${1:-}" = "contract" ] && [ "${2:-}" = "invoke" ]; then after_separator=true fi done - - for arg in "$@"; do - if [ "$previous" = "--id" ]; then - contract_id="$arg" - break - fi - previous="$arg" - done + contract_id="$(arg_after --id "$@" || true)" if [ "$contract_id" = "EVENTS" ]; then - version="${FAKE_EVENTS_VERSION:-1.1.0}" + version="${FAKE_EVENTS_VERSION:-1.7.0}" paused="${FAKE_EVENTS_PAUSED:-false}" pending="${FAKE_EVENTS_PENDING:-null}" migrated="${FAKE_EVENTS_MIGRATED:-null}" + applied_code=69 elif [ "$contract_id" = "PROFILE" ]; then - version="${FAKE_PROFILE_VERSION:-1.1.0}" + version="${FAKE_PROFILE_VERSION:-1.2.0}" paused="${FAKE_PROFILE_PAUSED:-false}" pending="${FAKE_PROFILE_PENDING:-null}" migrated="${FAKE_PROFILE_MIGRATED:-null}" - events_contract="${FAKE_PROFILE_EVENTS_CONTRACT:-EVENTS}" - pending_events_contract="${FAKE_PROFILE_EVENTS_PENDING:-null}" + applied_code=43 else printf 'unknown fake contract id: %s\n' "$contract_id" >&2 exit 1 fi + remaining="${FAKE_EVENTS_MIGRATION_REMAINING:-0}" case "$function_name" in version) printf '"%s"\n' "$version" ;; - get_admin) printf '"GADMIN"\n' ;; + get_admin) printf '"%s"\n' "${FAKE_ADMIN:-GADMIN}" ;; is_paused) printf '%s\n' "$paused" ;; get_pending_upgrade) printf '%s\n' "$pending" ;; get_migrated_to_version) printf '%s\n' "$migrated" ;; - get_events_contract) printf '"%s"\n' "$events_contract" ;; - get_pending_events_contract) printf '%s\n' "$pending_events_contract" ;; - id_base) printf '271539957145796608\n' ;; - get_event) - printf 'HostError: Error(Contract, #30)\n' >&2 - exit 1 + get_events_contract) printf '"%s"\n' "${FAKE_PROFILE_EVENTS_CONTRACT:-EVENTS}" ;; + get_pending_events_contract) printf '%s\n' "${FAKE_PROFILE_EVENTS_PENDING:-null}" ;; + get_release_validator) printf '%s\n' "${FAKE_EVENTS_VALIDATOR:-null}" ;; + migrate) + if [ "$contract_id" = "EVENTS" ] && [ "$remaining" -gt 0 ]; then + printf 'error: transaction simulation failed: HostError: Error(Contract, #97)\n' >&2 + exit 1 + fi + if [ "$migrated" = "\"$version\"" ]; then + printf 'error: transaction simulation failed: HostError: Error(Contract, #%s)\n' "$applied_code" >&2 + exit 1 + fi + ;; + migrate_events) + max="$(arg_after --max_events "$@")" + if [ "$max" -eq 0 ] || [ "$max" -gt 8 ]; then + max=8 + fi + left=$((remaining - max)) + [ "$left" -gt 0 ] || left=0 + printf '%s\n' "$left" ;; *) printf 'null\n' ;; esac diff --git a/scripts/testnet/grant-scenarios.ts b/scripts/testnet/grant-scenarios.ts new file mode 100644 index 0000000..c1afc76 --- /dev/null +++ b/scripts/testnet/grant-scenarios.ts @@ -0,0 +1,824 @@ +/** + * Runs the grant lifecycle against the deployed events contract on testnet, + * with fresh accounts and the BGT test asset, and checks every balance the + * contract moves. Writes a report with each transaction. + * + * cd scripts/testnet && npm install && npm run grants + * + * Needs the stellar CLI identities `boundless-grant-test-issuer` (issues BGT, + * revocable; funds the run) and the events contract to have BGT whitelisted + * with the fee account trusting it. EVENTS_CONTRACT overrides the contract id. + */ +import { execFileSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + Account, + Address, + Asset, + BASE_FEE, + Contract, + Horizon, + Keypair, + Networks, + Operation, + TransactionBuilder, + authorizeEntry, + nativeToScVal, + rpc, + scValToNative, + xdr, +} from '@stellar/stellar-sdk'; + +const RPC_URL = process.env.STELLAR_RPC_URL ?? 'https://soroban-testnet.stellar.org'; +const HORIZON_URL = 'https://horizon-testnet.stellar.org'; +const PASSPHRASE = Networks.TESTNET; +const EVENTS = + process.env.EVENTS_CONTRACT ?? 'CBEODVJGUYCIYTVXD7KI5UG3BJ2UE4T7AGI2TGY3T4Q5GQRFGTRYVTZP'; +const ISSUER_IDENTITY = process.env.ISSUER_IDENTITY ?? 'boundless-grant-test-issuer'; +const VALIDATOR_IDENTITY = + process.env.VALIDATOR_IDENTITY ?? 'boundless-release-validator-testnet'; +const CAPACITY = Number(process.env.CAPACITY_RECIPIENTS ?? 40); +const ONLY = process.env.ONLY?.split(',').map((s: string) => s.trim()); +const wanted = (name: string) => !ONLY || ONLY.some((o: string) => name.startsWith(o)); + +const server = new rpc.Server(RPC_URL); +const horizon = new Horizon.Server(HORIZON_URL); +const U = 10_000_000n; +const bgt = (units: number | bigint) => BigInt(units) * U; + +// ---------------------------------------------------------------- reporting + +type Check = { scenario: string; label: string; ok: boolean; detail?: string }; +type TxLog = { scenario: string; step: string; hash: string; fee: string; ok: boolean }; +const checks: Check[] = []; +const txs: TxLog[] = []; +let scenario = 'setup'; + +function check(ok: boolean, label: string, detail?: string) { + checks.push({ scenario, label, ok, detail }); + console.log(`${ok ? 'PASS' : 'FAIL'} [${scenario}] ${label}${detail ? ` (${detail})` : ''}`); +} + +function eq(actual: unknown, expected: unknown, label: string) { + const a = typeof actual === 'bigint' ? actual.toString() : JSON.stringify(actual); + const e = typeof expected === 'bigint' ? expected.toString() : JSON.stringify(expected); + check(a === e, label, a === e ? undefined : `got ${a}, expected ${e}`); +} + +// ---------------------------------------------------------------- encoding + +const sv = { + u32: (n: number) => nativeToScVal(n, { type: 'u32' }), + u64: (n: bigint) => nativeToScVal(n, { type: 'u64' }), + i128: (n: bigint) => nativeToScVal(n, { type: 'i128' }), + addr: (a: string) => new Address(a).toScVal(), + op: () => xdr.ScVal.scvBytes(randomBytes(32)), + sym: (s: string) => xdr.ScVal.scvSymbol(s), + struct(fields: Record) { + return xdr.ScVal.scvMap( + Object.keys(fields) + .sort() + .map((k) => new xdr.ScMapEntry({ key: sv.sym(k), val: fields[k] })), + ); + }, +}; + +type Award = { to: string; position: number; amount: bigint; bump?: number }; + +function winners(awards: Award[]) { + return xdr.ScVal.scvVec( + awards.map((a) => + sv.struct({ + amount: sv.i128(a.amount), + position: sv.u32(a.position), + recipient: sv.addr(a.to), + reputation_bump: sv.u32(a.bump ?? 10), + }), + ), + ); +} + +// ---------------------------------------------------------------- chain + +const stroopsText = (n: bigint) => `${n / U}.${(n % U).toString().padStart(7, '0')}`; + +async function classic(source: Keypair, ops: xdr.Operation[], signers: Keypair[], step: string) { + const account = await horizon.loadAccount(source.publicKey()); + const tx = new TransactionBuilder(account, { + fee: (Number(BASE_FEE) * 10).toString(), + networkPassphrase: PASSPHRASE, + }); + ops.forEach((op) => tx.addOperation(op)); + const built = tx.setTimeout(120).build(); + built.sign(source, ...signers); + const res = await horizon.submitTransaction(built); + txs.push({ scenario, step, hash: res.hash, fee: built.fee, ok: true }); +} + +type Outcome = + | { ok: true; value: unknown; hash: string; fee: string } + | { ok: false; code?: number; detail: string; hash?: string }; + +/** The address an auth entry needs a signature from, or null for source credentials. */ +function signerOf(entry: xdr.SorobanAuthorizationEntry): string | null { + const c = entry.credentials; + if (c.type === 'sorobanCredentialsAddress') { + return Address.fromScAddress(c.address.address).toString(); + } + if (c.type === 'sorobanCredentialsAddressV2') { + return Address.fromScAddress(c.addressV2.address).toString(); + } + return null; +} + +const contractCode = (text: string) => { + const m = /Error\(Contract, #(\d+)\)/.exec(text); + return m ? Number(m[1]) : undefined; +}; + +type InvokeOpts = { signers?: Keypair[]; simulate?: boolean; step?: string }; + +// The RPC sometimes serves an account a ledger behind, so a transaction built +// right after the previous one confirms can carry a stale sequence. Those +// rejections never reach the contract; rebuild and resend. +async function invoke( + contractId: string, + source: Keypair, + method: string, + args: xdr.ScVal[], + opts: InvokeOpts = {}, +): Promise { + for (let attempt = 1; ; attempt++) { + const outcome = await invokeOnce(contractId, source, method, args, opts); + const transient = + !outcome.ok && /send (TRY_AGAIN_LATER|ERROR: txBadSeq)/.test(outcome.detail); + if (!transient || attempt === 4) return outcome; + console.log(`retry ${method} after ${outcome.detail}`); + await new Promise((r) => setTimeout(r, 3000)); + } +} + +async function invokeOnce( + contractId: string, + source: Keypair, + method: string, + args: xdr.ScVal[], + opts: InvokeOpts, +): Promise { + const loaded = await server.getAccount(source.publicKey()); + // build() advances the account's sequence, and a call that signs another + // party's auth entry builds twice, so each build starts from the same one. + const build = (op: xdr.Operation) => + new TransactionBuilder(new Account(loaded.accountId(), loaded.sequenceNumber()), { + fee: BASE_FEE, + networkPassphrase: PASSPHRASE, + }) + .addOperation(op) + .setTimeout(300) + .build(); + + let tx = build(new Contract(contractId).call(method, ...args)); + let sim = await server.simulateTransaction(tx); + if (rpc.Api.isSimulationError(sim)) { + return { ok: false, code: contractCode(sim.error), detail: sim.error.split('\n')[0] }; + } + if (opts.simulate) { + return { + ok: true, + value: sim.result?.retval ? scValToNative(sim.result.retval) : undefined, + hash: '', + fee: sim.minResourceFee, + }; + } + + // Address-credential entries need a signature from that address; sign the + // ones we hold and leave the rest unsigned, so a missing signer fails on chain. + const auth = sim.result?.auth ?? []; + const needsSigning = auth.some((e) => signerOf(e) !== null); + if (needsSigning) { + const { sequence } = await server.getLatestLedger(); + const signed = await Promise.all( + auth.map(async (entry) => { + const who = signerOf(entry); + if (!who) return entry; + const signer = (opts.signers ?? []).find((k) => k.publicKey() === who); + return signer ? authorizeEntry(entry, signer, sequence + 100, PASSPHRASE) : entry; + }), + ); + const func = (tx.operations[0] as Operation.InvokeHostFunction).func; + tx = build(Operation.invokeHostFunction({ func, auth: signed })); + sim = await server.simulateTransaction(tx); + if (rpc.Api.isSimulationError(sim)) { + return { ok: false, code: contractCode(sim.error), detail: sim.error.split('\n')[0] }; + } + } + + const assembled = rpc.assembleTransaction(tx, sim).build(); + assembled.sign(source); + const sent = await server.sendTransaction(assembled); + if (sent.status === 'ERROR' || sent.status === 'TRY_AGAIN_LATER') { + const code = sent.errorResult?.result.type; + const detail = `send ${sent.status}${code ? `: ${code}` : ''}`; + txs.push({ scenario, step: opts.step ?? method, hash: sent.hash, fee: assembled.fee, ok: false }); + return { ok: false, detail, hash: sent.hash }; + } + for (let i = 0; i < 90; i++) { + const got = await server.getTransaction(sent.hash); + if (got.status === rpc.Api.GetTransactionStatus.NOT_FOUND) { + await new Promise((r) => setTimeout(r, 1000)); + continue; + } + const ok = got.status === rpc.Api.GetTransactionStatus.SUCCESS; + txs.push({ scenario, step: opts.step ?? method, hash: sent.hash, fee: assembled.fee, ok }); + if (!ok) return { ok: false, detail: 'transaction failed on chain', hash: sent.hash }; + return { + ok: true, + value: got.returnValue ? scValToNative(got.returnValue) : undefined, + hash: sent.hash, + fee: assembled.fee, + }; + } + return { ok: false, detail: 'not confirmed in 90s', hash: sent.hash }; +} + +const events = (source: Keypair, method: string, args: xdr.ScVal[], opts?: InvokeOpts) => + invoke(EVENTS, source, method, args, opts); + +async function must(outcome: Promise, label: string): Promise { + const o = await outcome; + check(o.ok, label, o.ok ? undefined : `${o.detail}${o.hash ? ` tx ${o.hash}` : ''}`); + if (!o.ok) throw new Error(`${scenario}: ${label} failed`); + return o.value; +} + +async function refused(outcome: Promise, code: number | 'auth' | 'any', label: string) { + const o = await outcome; + if (o.ok) return check(false, label, 'call succeeded'); + if (code === 'any') return check(true, label, o.detail); + if (code === 'auth') return check(o.code === undefined, label, o.detail); + check(o.code === code, label, o.code === code ? undefined : `got ${o.code ?? o.detail}`); +} + +// ---------------------------------------------------------------- setup + +const secretOf = (identity: string) => + execFileSync('stellar', ['keys', 'show', identity], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + +const issuer = Keypair.fromSecret(secretOf(ISSUER_IDENTITY)); +const asset = new Asset('BGT', issuer.publicKey()); +const TOKEN = asset.contractId(PASSPHRASE); +let feeAccount = ''; +let feeBps = 0n; + +async function balance(who: string): Promise { + const o = await invoke(TOKEN, issuer, 'balance', [sv.addr(who)], { simulate: true }); + if (!o.ok) throw new Error(`balance of ${who}: ${o.detail}`); + return BigInt(o.value as bigint); +} + +async function provision(count: number, mint: bigint, xlm = '5'): Promise { + const out: Keypair[] = []; + for (let start = 0; start < count; start += 15) { + const batch = Array.from({ length: Math.min(15, count - start) }, () => Keypair.random()); + const ops: xdr.Operation[] = []; + for (const k of batch) { + ops.push(Operation.createAccount({ destination: k.publicKey(), startingBalance: xlm })); + ops.push(Operation.changeTrust({ asset, source: k.publicKey() })); + if (mint > 0n) { + ops.push( + Operation.payment({ destination: k.publicKey(), asset, amount: stroopsText(mint) }), + ); + } + } + await classic(issuer, ops, batch, `provision ${batch.length} accounts`); + out.push(...batch); + } + return out; +} + +async function setFrozen(who: Keypair, frozen: boolean) { + await classic( + issuer, + [Operation.setTrustLineFlags({ trustor: who.publicKey(), asset, flags: { authorized: !frozen } })], + [], + frozen ? 'freeze' : 'unfreeze', + ); +} + +const fee = (amount: bigint) => (amount * feeBps) / 10_000n; +let feesExpected = 0n; + +type GrantSpec = { + budget: bigint; + milestones: number; + floors: [number, bigint][]; + manager?: string; + pillar?: 'Grant' | 'Crowdfunding'; +}; + +async function createGrant(owner: Keypair, g: GrantSpec): Promise { + const floors = xdr.ScVal.scvMap( + [...g.floors] + .sort((a, b) => a[0] - b[0]) + .map(([p, v]) => new xdr.ScMapEntry({ key: sv.u32(p), val: sv.i128(v) })), + ); + const params = sv.struct({ + content_uri: xdr.ScVal.scvString('https://api.boundless.fi/grants/testnet-run/content'), + deadline: sv.u64(BigInt(Math.floor(Date.now() / 1000) + 30 * 86_400)), + fee_bps_override: xdr.ScVal.scvVoid(), + manager: g.manager ? sv.addr(g.manager) : xdr.ScVal.scvVoid(), + owner: sv.addr(owner.publicKey()), + pillar: xdr.ScVal.scvVec([sv.sym(g.pillar ?? 'Grant')]), + prize_floors: floors, + release_kind: xdr.ScVal.scvVec([sv.sym('Multi'), sv.u32(g.milestones)]), + title: xdr.ScVal.scvString(`Testnet grant run: ${scenario}`), + token: sv.addr(TOKEN), + total_budget: sv.i128(g.budget), + }); + const before = await balance(owner.publicKey()); + const id = BigInt( + (await must(events(owner, 'create_event', [params, sv.op()]), 'publish the grant')) as bigint, + ); + // Crowdfunding starts at zero escrow and takes its fee at release. + const paid = g.pillar === 'Crowdfunding' ? 0n : g.budget + fee(g.budget); + eq(before - (await balance(owner.publicKey())), paid, 'owner paid budget plus fee'); + if (g.pillar !== 'Crowdfunding') feesExpected += fee(g.budget); + return id; +} + +async function event(id: bigint): Promise> { + const o = await events(issuer, 'get_event', [sv.u64(id)], { simulate: true }); + if (!o.ok) throw new Error(`get_event ${id}: ${o.detail}`); + return o.value as Record; +} + +const status = async (id: bigint) => ((await event(id)).status as string[])[0]; + +async function release(owner: Keypair, id: bigint, to: Keypair | string, m: number, expected: bigint) { + const who = typeof to === 'string' ? to : to.publicKey(); + const before = await balance(who); + await must( + events(owner, 'claim_milestone', [sv.u64(id), sv.addr(who), sv.u32(m), sv.u32(10), sv.op()]), + `release milestone ${m} to ${who.slice(0, 6)}`, + ); + eq((await balance(who)) - before, expected, `milestone ${m} paid exactly`); +} + +async function forfeit(owner: Keypair, id: bigint, to: Keypair, m: number) { + const before = await balance(to.publicKey()); + await must( + events(owner, 'forfeit_milestone', [sv.u64(id), sv.addr(to.publicKey()), sv.u32(m), sv.op()]), + `forfeit milestone ${m}`, + ); + eq(await balance(to.publicKey()), before, 'a forfeit pays nothing'); +} + +async function contribute(id: bigint, from: Keypair, amount: bigint) { + const before = await balance(from.publicKey()); + await must( + events(from, 'add_funds', [sv.u64(id), sv.addr(from.publicKey()), sv.i128(amount), sv.op()]), + `partner adds ${stroopsText(amount)}`, + ); + eq(before - (await balance(from.publicKey())), amount + fee(amount), 'partner paid amount plus fee'); + feesExpected += fee(amount); +} + +async function cancel(manager: Keypair, id: bigint) { + await must(events(manager, 'start_cancel', [sv.u64(id), sv.op()]), 'start cancel'); + if ((await status(id)) === 'Cancelling') { + for (;;) { + const left = await must( + events(manager, 'process_cancel_batch', [sv.u64(id), sv.u32(15), sv.op()]), + 'refund batch', + ); + if (Number(left) === 0) break; + } + await must(events(manager, 'finalize_cancel', [sv.u64(id), sv.op()]), 'finalize cancel'); + } + eq(await status(id), 'Cancelled', 'grant cancelled'); +} + +// ---------------------------------------------------------------- scenarios + +async function main() { + feeAccount = (await events(issuer, 'get_fee_account', [], { simulate: true }) as { value: string }).value; + feeBps = BigInt((await events(issuer, 'get_fee_bps', [], { simulate: true }) as { value: number }).value); + const version = (await events(issuer, 'version', [], { simulate: true }) as { value: string }).value; + const listed = await events(issuer, 'is_supported_token', [sv.addr(TOKEN)], { simulate: true }); + check(listed.ok && listed.value === true, `BGT ${TOKEN} is whitelisted on ${EVENTS} (${version})`); + const feeStart = await balance(feeAccount); + + // Owners sign every publish and release, and each release pays rent on a new row. + const [owner, ownerB] = await provision(2, bgt(2_000), '200'); + const partners = await provision(4, bgt(100)); + const people = await provision(CAPACITY + 12, 0n); + const [manager, managerB, stranger] = await provision(3, 0n); + const r = (i: number) => people[i]; + + scenario = 'micro grant'; + if (wanted(scenario)) { + const id = await createGrant(owner, { + budget: bgt(30), + milestones: 1, + floors: [[1, bgt(10)], [2, bgt(10)], [3, bgt(10)]], + }); + await must( + events(owner, 'select_winners', [ + sv.u64(id), + winners([1, 2, 3].map((p) => ({ to: r(p - 1).publicKey(), position: p, amount: bgt(10) }))), + sv.op(), + ]), + 'select three recipients', + ); + for (const i of [0, 1, 2]) await release(owner, id, r(i), 0, bgt(10)); + eq(await status(id), 'Completed', 'completes when the last award is paid'); + } + + scenario = 'tiered, rounding, any order'; + if (wanted(scenario)) { + const [a, b, c] = [bgt(50) + 1n, bgt(30) + 2n, bgt(20)]; + const id = await createGrant(owner, { + budget: a + b + c, + milestones: 3, + floors: [[1, a], [2, b], [3, c]], + }); + await must( + events(owner, 'select_winners', [ + sv.u64(id), + winners([ + { to: r(3).publicKey(), position: 1, amount: a }, + { to: r(4).publicKey(), position: 2, amount: b }, + { to: r(5).publicKey(), position: 3, amount: c }, + ]), + sv.op(), + ]), + 'select three tiers', + ); + const part = (x: bigint) => x / 3n; + const last = (x: bigint) => x - 2n * part(x); + await release(owner, id, r(4), 2, part(b)); + await release(owner, id, r(3), 0, part(a)); + await release(owner, id, r(5), 1, part(c)); + await release(owner, id, r(3), 2, part(a)); + await release(owner, id, r(4), 0, part(b)); + await release(owner, id, r(5), 0, part(c)); + await release(owner, id, r(3), 1, last(a)); + await release(owner, id, r(4), 1, last(b)); + await release(owner, id, r(5), 2, last(c)); + eq(await balance(r(3).publicKey()), a, 'tier 1 received its whole award'); + eq(await balance(r(4).publicKey()), b, 'tier 2 received its whole award'); + eq(await status(id), 'Completed', 'completed'); + } + + scenario = 'forfeit and the cancel guard'; + if (wanted(scenario)) { + const id = await createGrant(owner, { budget: bgt(30), milestones: 3, floors: [[1, bgt(30)]] }); + await must( + events(owner, 'select_winners', [ + sv.u64(id), + winners([{ to: r(6).publicKey(), position: 1, amount: bgt(30) }]), + sv.op(), + ]), + 'select one recipient', + ); + await release(owner, id, r(6), 0, bgt(10)); + await forfeit(owner, id, r(6), 1); + await refused( + events(owner, 'start_cancel', [sv.u64(id), sv.op()], { simulate: true }), + 96, + 'cancel refused while an award is owed (AwardsOutstanding)', + ); + await release(owner, id, r(6), 2, bgt(10)); + const before = await balance(owner.publicKey()); + await cancel(owner, id); + eq((await balance(owner.publicKey())) - before, bgt(10), 'forfeited share returned to the owner'); + } + + scenario = 'partners, pro rata'; + if (wanted(scenario)) { + const [p1, p2] = partners; + const id = await createGrant(owner, { budget: bgt(50), milestones: 2, floors: [[1, bgt(10)]] }); + await contribute(id, p1, bgt(20)); + await must( + events(owner, 'select_winners', [ + sv.u64(id), + winners([{ to: r(7).publicKey(), position: 1, amount: bgt(60) }]), + sv.op(), + ]), + 'award more than the budget, funded by the partner', + ); + await release(owner, id, r(7), 0, bgt(30)); + await contribute(id, p2, bgt(15)); + await release(owner, id, r(7), 1, bgt(30)); + eq(await status(id), 'Active', 'the late top-up keeps the grant open'); + const [b1, b2, bo] = [p1, p2, owner].map((k) => k.publicKey()); + const before = await Promise.all([b1, b2, bo].map(balance)); + await must(events(owner, 'start_cancel', [sv.u64(id), sv.op()]), 'start cancel'); + await refused( + events(owner, 'process_cancel_batch', [sv.u64(id), sv.u32(0), sv.op()], { simulate: true }), + 94, + 'an empty refund batch is refused (InvalidBatchSize)', + ); + await must(events(stranger, 'process_cancel_batch', [sv.u64(id), sv.u32(15), sv.op()]), 'anyone can crank'); + await must(events(stranger, 'finalize_cancel', [sv.u64(id), sv.op()]), 'anyone can finalize'); + const remaining = bgt(25); + const total = bgt(35); + eq((await balance(b1)) - before[0], (bgt(20) * remaining) / total, 'partner 1 refunded pro rata'); + eq((await balance(b2)) - before[1], (bgt(15) * remaining) / total, 'partner 2 refunded pro rata'); + eq((await balance(bo)) - before[2], 0n, 'owner gets nothing when partners are short'); + } + + scenario = 'frozen partner'; + if (wanted(scenario)) { + const [, , p3, p4] = partners; + const id = await createGrant(owner, { budget: bgt(20), milestones: 1, floors: [[1, bgt(20)]] }); + await contribute(id, p3, bgt(10)); + await contribute(id, p4, bgt(10)); + await setFrozen(p4, true); + const before = await Promise.all([p3, p4, owner].map((k) => balance(k.publicKey()))); + await cancel(owner, id); + eq((await balance(p3.publicKey())) - before[0], bgt(10), 'other partners are refunded'); + eq((await balance(owner.publicKey())) - before[2], bgt(20), 'owner residual paid'); + const held = await events(issuer, 'get_unclaimed_refund', [sv.u64(id), sv.addr(p4.publicKey())], { + simulate: true, + }); + eq(held.ok ? BigInt(held.value as bigint) : -1n, bgt(10), 'frozen partner refund held'); + await refused( + events(p4, 'claim_refund', [sv.u64(id), sv.addr(p4.publicKey()), sv.op()]), + 'any', + 'a frozen account cannot take its refund yet', + ); + await setFrozen(p4, false); + await must( + events(p4, 'claim_refund', [sv.u64(id), sv.addr(p4.publicKey()), sv.op()]), + 'refund claimed after unfreezing', + ); + eq((await balance(p4.publicKey())) - before[1], bgt(10), 'frozen partner made whole'); + await refused( + events(p4, 'claim_refund', [sv.u64(id), sv.addr(p4.publicKey()), sv.op()], { simulate: true }), + 95, + 'a second claim is refused (NoRefundOwed)', + ); + } + + scenario = 'refusals'; + if (wanted(scenario)) { + const id = await createGrant(owner, { budget: bgt(100), milestones: 4, floors: [[1, 1n]] }); + const sel = (awards: Award[]) => + events(owner, 'select_winners', [sv.u64(id), winners(awards), sv.op()], { simulate: true }); + const x = r(8).publicKey(); + await refused( + sel([ + { to: x, position: 1, amount: bgt(10) }, + { to: x, position: 2, amount: bgt(10) }, + ]), + 92, + 'one recipient, two awards (DuplicateRecipient)', + ); + await refused(sel([{ to: x, position: 1, amount: 3n }]), 34, 'award smaller than its milestones'); + await refused( + sel([{ to: x, position: 1, amount: bgt(10), bump: 101 }]), + 93, + 'reputation bump over the cap', + ); + await refused( + sel(people.slice(0, CAPACITY + 1).map((k, i) => ({ to: k.publicKey(), position: i + 1, amount: 4n }))), + 51, + `more than ${CAPACITY} recipients`, + ); + await refused(sel([{ to: x, position: 1, amount: bgt(101) }]), 56, 'awards beyond the escrow'); + await must( + events(owner, 'select_winners', [sv.u64(id), winners([{ to: x, position: 1, amount: bgt(100) }]), sv.op()]), + 'select', + ); + await refused(sel([{ to: r(9).publicKey(), position: 2, amount: 4n }]), 90, 'a second selection'); + const claim = (to: string, m: number, op = sv.op(), simulate = true) => + events(owner, 'claim_milestone', [sv.u64(id), sv.addr(to), sv.u32(m), sv.u32(10), op], { simulate }); + await refused(claim(x, 4), 55, 'milestone out of range'); + await refused(claim(r(9).publicKey(), 0), 50, 'someone never selected'); + const op = sv.op(); + await release(owner, id, r(8), 0, bgt(25)); + await refused(claim(x, 0), 54, 'the same milestone twice'); + await must(claim(x, 1, op, false), 'release with an op id'); + await refused(claim(x, 2, op), 60, 'the op id replayed'); + await refused( + events(owner, 'claim_milestone', [sv.u64(id), sv.addr(x), sv.u32(2), sv.u32(101), sv.op()], { simulate: true }), + 93, + 'reputation bump over the cap on release', + ); + await refused( + events(owner, 'claim_prize', [sv.u64(id), sv.u32(1), sv.op()], { simulate: true }), + 33, + 'claim_prize is not a grant path', + ); + await refused( + events(owner, 'start_cancel', [sv.u64(id), sv.op()], { simulate: true }), + 96, + 'cancel refused while owed', + ); + await forfeit(owner, id, r(8), 2); + await forfeit(owner, id, r(8), 3); + await cancel(owner, id); + } + + scenario = 'signatures'; + if (wanted(scenario)) { + const id = await createGrant(owner, { budget: bgt(10), milestones: 1, floors: [[1, bgt(10)]] }); + const x = r(10); + await refused( + events(stranger, 'select_winners', [ + sv.u64(id), + winners([{ to: stranger.publicKey(), position: 1, amount: bgt(10) }]), + sv.op(), + ]), + 'auth', + 'a stranger cannot select', + ); + await must( + events(owner, 'select_winners', [sv.u64(id), winners([{ to: x.publicKey(), position: 1, amount: bgt(10) }]), sv.op()]), + 'owner selects', + ); + await refused( + events(x, 'claim_milestone', [sv.u64(id), sv.addr(x.publicKey()), sv.u32(0), sv.u32(10), sv.op()]), + 'auth', + 'a recipient cannot release their own milestone', + ); + eq(await balance(x.publicKey()), 0n, 'nothing moved'); + await release(owner, id, x, 0, bgt(10)); + } + + scenario = 'manager handover'; + if (wanted(scenario)) { + const id = await createGrant(ownerB, { + budget: bgt(20), + milestones: 2, + floors: [[1, bgt(20)]], + manager: manager.publicKey(), + }); + await must(events(manager, 'accept_manager', [sv.u64(id)]), 'manager accepts'); + const m = await events(issuer, 'get_manager', [sv.u64(id)], { simulate: true }); + eq(m.ok ? m.value : '', manager.publicKey(), 'manager recorded'); + const awards = winners([{ to: r(11).publicKey(), position: 1, amount: bgt(20) }]); + await refused(events(ownerB, 'select_winners', [sv.u64(id), awards, sv.op()]), 'auth', 'the owner no longer selects'); + await must(events(manager, 'select_winners', [sv.u64(id), awards, sv.op()]), 'the manager selects'); + await refused( + events(ownerB, 'reclaim_management', [sv.u64(id)], { simulate: true }), + 90, + 'no reclaim after selection', + ); + await refused( + events(manager, 'claim_milestone', [sv.u64(id), sv.addr(r(11).publicKey()), sv.u32(0), sv.u32(10), sv.op()]), + 'auth', + 'the manager cannot release', + ); + await release(ownerB, id, r(11), 0, bgt(10)); + await release(ownerB, id, r(11), 1, bgt(10)); + + const other = await createGrant(ownerB, { + budget: bgt(20), + milestones: 1, + floors: [[1, bgt(20)]], + manager: managerB.publicKey(), + }); + await must(events(managerB, 'accept_manager', [sv.u64(other)]), 'second manager accepts'); + await must(events(ownerB, 'reclaim_management', [sv.u64(other)]), 'owner takes management back'); + const back = await events(issuer, 'get_manager', [sv.u64(other)], { simulate: true }); + eq(back.ok ? back.value : '', ownerB.publicKey(), 'the owner manages again'); + await must( + events(ownerB, 'select_winners', [ + sv.u64(other), + winners([{ to: r(11).publicKey(), position: 1, amount: bgt(20) }]), + sv.op(), + ]), + 'owner selects after reclaiming', + ); + await release(ownerB, other, r(11), 0, bgt(20)); + } + + scenario = `${CAPACITY} recipients`; + if (wanted(scenario)) { + const award = bgt(2); + const id = await createGrant(owner, { + budget: award * BigInt(CAPACITY), + milestones: 2, + floors: [[1, award]], + }); + const crowd = people.slice(12, 12 + CAPACITY); + await must( + events(owner, 'select_winners', [ + sv.u64(id), + winners(crowd.map((k, i) => ({ to: k.publicKey(), position: i + 1, amount: award }))), + sv.op(), + ]), + `select ${CAPACITY} recipients in one transaction`, + ); + for (const m of [0, 1]) { + for (const k of crowd) await release(owner, id, k, m, bgt(1)); + } + eq(await status(id), 'Completed', 'completed'); + const page = await events(issuer, 'get_winners_page', [sv.u64(id), sv.u32(0), sv.u32(90)], { simulate: true }); + eq(page.ok ? (page.value as unknown[]).length : -1, 90, 'a full page of winners reads in one call'); + const rest = await events(issuer, 'get_winners_page', [sv.u64(id), sv.u32(90), sv.u32(90)], { simulate: true }); + eq(rest.ok ? (rest.value as unknown[]).length : -1, CAPACITY * 3 - 90, 'the rest on the next page'); + } + + scenario = 'crowdfunding release validator'; + if (wanted(scenario)) { + const validator = Keypair.fromSecret(secretOf(VALIDATOR_IDENTITY)); + const appointed = await events(issuer, 'get_release_validator', [], { simulate: true }); + eq(appointed.ok ? appointed.value : null, validator.publicKey(), 'the validator is appointed on chain'); + + const [creator] = await provision(1, 0n, '20'); + const [backer] = partners; + const id = await createGrant(creator, { + budget: bgt(100), + milestones: 2, + floors: [[1, bgt(100)]], + pillar: 'Crowdfunding', + }); + const backerBefore = await balance(backer.publicKey()); + await must( + events(backer, 'add_funds', [sv.u64(id), sv.addr(backer.publicKey()), sv.i128(bgt(60)), sv.op()]), + 'a backer pledges 60', + ); + eq(backerBefore - (await balance(backer.publicKey())), bgt(60), 'backers pay no fee'); + + const release = (source: Keypair, signers: Keypair[], m: number) => + events( + source, + 'claim_milestone', + [sv.u64(id), sv.addr(creator.publicKey()), sv.u32(m), sv.u32(10), sv.op()], + { signers }, + ); + await refused(release(creator, [], 0), 'auth', 'the creator alone cannot release'); + + // The backend's shape: the validator is the source and signs the envelope, + // the creator's auth entry is signed with their own key. + for (const m of [0, 1]) { + const creatorBefore = await balance(creator.publicKey()); + const feeBefore = await balance(feeAccount); + await must(release(validator, [creator], m), `release ${m} co-signed by the validator`); + const share = bgt(30); + eq((await balance(creator.publicKey())) - creatorBefore, share - fee(share), 'creator paid net of fee'); + eq((await balance(feeAccount)) - feeBefore, fee(share), 'fee withheld at release'); + feesExpected += fee(share); + } + eq(await status(id), 'Completed', 'campaign completes on its last release'); + } + + scenario = 'fees'; + eq((await balance(feeAccount)) - feeStart, feesExpected, 'fee account received exactly the fees charged'); +} + +// ---------------------------------------------------------------- report + +function report(error?: unknown) { + const here = dirname(fileURLToPath(import.meta.url)); + const out = join(here, '..', '..', 'docs', 'audit'); + mkdirSync(out, { recursive: true }); + const failed = checks.filter((c) => !c.ok); + const scenarios = [...new Set(checks.map((c) => c.scenario))]; + const fees = txs.filter((t) => t.ok).map((t) => Number(t.fee)); + const lines = [ + '# Testnet grant run', + '', + `Run ${new Date().toISOString()} against \`${EVENTS}\` with the BGT test asset \`${TOKEN}\`.`, + `${checks.length - failed.length} of ${checks.length} checks passed across ${txs.length} transactions` + + (error ? `; the run stopped early: ${String(error)}` : '.'), + fees.length ? `Highest fee paid by one transaction: ${Math.max(...fees)} stroops.` : '', + '', + ...scenarios.flatMap((s) => [ + `## ${s}`, + '', + ...checks + .filter((c) => c.scenario === s) + .map((c) => `- ${c.ok ? 'pass' : '**FAIL**'}: ${c.label}${c.detail ? ` (${c.detail})` : ''}`), + '', + ...txs + .filter((t) => t.scenario === s) + .map( + (t) => + ` - [${t.step}](https://stellar.expert/explorer/testnet/tx/${t.hash})${t.ok ? '' : ' (failed, as expected or not: see checks)'}`, + ), + '', + ]), + ]; + writeFileSync(join(out, 'testnet-grant-run.md'), lines.join('\n')); + console.log(`\n${checks.length - failed.length}/${checks.length} checks passed; report in docs/audit/testnet-grant-run.md`); + if (failed.length || error) process.exitCode = 1; +} + +main().then( + () => report(), + (error) => { + console.error(error); + report(error); + }, +); diff --git a/scripts/testnet/package-lock.json b/scripts/testnet/package-lock.json new file mode 100644 index 0000000..9377ca5 --- /dev/null +++ b/scripts/testnet/package-lock.json @@ -0,0 +1,997 @@ +{ + "name": "boundless-testnet-scenarios", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "boundless-testnet-scenarios", + "dependencies": { + "@stellar/stellar-sdk": "^17.2.1", + "tsx": "^4.20.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@noble/ed25519": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.2.0.tgz", + "integrity": "sha512-criDgRlnUA09hchYrTy/JUWPIEap5rZxQe6wDWzRx51oWWpDRcUpuNzlgPxDJaOK6AsW9c0wKcj3rKRv6t+bPQ==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@stellar/js-xdr": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-5.0.0.tgz", + "integrity": "sha512-HBDNKnxr+ecdaEmbZ0mcKkirOF8tXXEbWSw34P3wT40Tn3g+u+cCH17xAWZymzscq8cojHB8340pR8QNVaD32w==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.0.0", + "pnpm": ">=10.0.0" + } + }, + "node_modules/@stellar/stellar-sdk": { + "version": "17.2.1", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-17.2.1.tgz", + "integrity": "sha512-kIyfnZ2zCOqV8SUJcsk9buBOvhH1wsLU7KAzqAJyY7AgmD+KdvLTMpI1gcYdhGSakuDG5s6xSO4EN9HZTxpm8g==", + "license": "Apache-2.0", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "@noble/ed25519": "^3.1.0", + "@noble/hashes": "^2.2.0", + "@stellar/js-xdr": "^5.0.0", + "@types/json-schema": "^7.0.15", + "axios": "1.20.0", + "bignumber.js": "^11.1.4", + "commander": "^14.0.3", + "eventsource": "^4.1.0", + "feaxios": "^0.0.23", + "smol-toml": "^1.6.1", + "uint8array-extras": "^1.5.0" + }, + "bin": { + "stellar-contract-bindings-typescript": "bin/stellar-contract-bindings-typescript", + "stellar-js": "bin/stellar-js", + "stellar-sdk": "bin/stellar-js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/bignumber.js": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.5.tgz", + "integrity": "sha512-6WmzCNtUnfKpbozq+hOgWaZMMzORmYBwF1xZScyoIX3QRYWeKTtxxwDOW5tIz7C9BdjkIYHGTcelCLkXg0mndw==", + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/eventsource": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.1.tgz", + "integrity": "sha512-D6bTRWh6KahHTK/m4WnjPQyEinNPf9eFLEZSEoj7d6fTibspnAVYfzHvirL7u/aoX5d9YYfIkBVAhmigUELk9w==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/feaxios": { + "version": "0.0.23", + "resolved": "https://registry.npmjs.org/feaxios/-/feaxios-0.0.23.tgz", + "integrity": "sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==", + "license": "MIT", + "dependencies": { + "is-retry-allowed": "^3.0.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-retry-allowed": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-3.0.0.tgz", + "integrity": "sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/smol-toml": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz", + "integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/uint8array-extras": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.6.0.tgz", + "integrity": "sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/scripts/testnet/package.json b/scripts/testnet/package.json new file mode 100644 index 0000000..46d5724 --- /dev/null +++ b/scripts/testnet/package.json @@ -0,0 +1,12 @@ +{ + "name": "boundless-testnet-scenarios", + "private": true, + "type": "module", + "scripts": { + "grants": "tsx grant-scenarios.ts" + }, + "dependencies": { + "@stellar/stellar-sdk": "^17.2.1", + "tsx": "^4.20.0" + } +} diff --git a/signing/mainnet-apply-events-1.6.0.xdr b/signing/mainnet-apply-events-1.6.0.xdr deleted file mode 100644 index 0732e6f..0000000 --- a/signing/mainnet-apply-events-1.6.0.xdr +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 21b0420..0000000 --- a/signing/mainnet-migrate-events-1.6.0.xdr +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index d1ba28f..0000000 --- a/signing/mainnet-propose-events-1.6.0-v2.xdr +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 1cd93bd..0000000 --- a/signing/mainnet-propose-events-1.6.0.xdr +++ /dev/null @@ -1 +0,0 @@ -AAAAAgAAAACqr+kenVNRznjlFU8yE+QTqegANttFtJQbOeXujoYHJwAGAegDxLFqAAAADQAAAAAAAAAAAAAAAQAAAAAAAAAYAAAAAAAAAAGLUhnQSRnPriBNNLORXFTX2uG/A42dHPozTgZeaFtYxAAAAA9wcm9wb3NlX3VwZ3JhZGUAAAAAAgAAAA0AAAAgQ7g65OWuaJ8AQnj5NqhhairdXgm7ECTogwx/Jm9GSWMAAAAOAAAABTEuNi4wAAAAAAAAAQAAAAAAAAAAAAAAAYtSGdBJGc+uIE00s5FcVNfa4b8DjZ0c+jNOBl5oW1jEAAAAD3Byb3Bvc2VfdXBncmFkZQAAAAACAAAADQAAACBDuDrk5a5onwBCePk2qGFqKt1eCbsQJOiDDH8mb0ZJYwAAAA4AAAAFMS42LjAAAAAAAAAAAAAAAQAAAAAAAAABAAAAByqHib9d05K5Hd/mWRByoMufrnAEWr83n9HbvC20rx3jAAAAAQAAAAYAAAABi1IZ0EkZz64gTTSzkVxU19rhvwONnRz6M04GXmhbWMQAAAAUAAAAAQAsF0cAAAAAAAAFGAAAAAAABgGEAAAAAA== From 4f342343d793ae2bb2f52cac521f5ea1ff054a32 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Sat, 3 Oct 2026 18:16:47 +0100 Subject: [PATCH 18/20] docs: rewrite the threat model for audit and consolidate the ops docs - Threat model 1.2: 76 threats (62 mitigated, 10 accepted, 4 retired, none open) in plainer language, retired threats in their own table, and a pending-deployment section for the mainnet upgrade. - Scout re-scan of the 2.0.0 source (0.3.17 on SDK 28): no critical findings; the one real medium (Tamp.19) is fixed. - New upgrade runbook with the mainnet 1.7.0 to 2.0.0 steps, a storage compatibility guide, and docs/history for finished procedures. - One deploy doc; the multisig preflight is folded into the multisig guide. - Drop credit and on-chain participation leftovers and fix the mainnet USDC address in the deploy docs. --- .all-contributorsrc | 39 - BACKLOG.md | 86 +- CLAUDE.md | 29 +- CONTRIBUTING.md | 8 +- CONTRIBUTORS.md | 14 +- README.md | 201 ++--- docs/ARCHITECTURE.md | 71 +- docs/DEPLOYMENT.md | 355 ++++----- docs/admin-custody-policy.md | 79 +- docs/audit/testnet-grant-run.md | 524 +++++++++++++ docs/contract-ops-runbook.md | 289 ++++--- docs/dune-analytics.md | 157 +++- docs/dune-queries/01_tvl_current.sql | 2 +- docs/dune-queries/03_bounties_funded.sql | 2 +- docs/dune-queries/05_unique_participants.sql | 4 +- .../09_crowdfunding_contributions.sql | 2 +- .../10_event_created_decode_test.sql | 6 +- .../threat-model-figure-1-system-dfd.svg | 2 +- ...threat-model-figure-2-escrow-lifecycle.svg | 45 +- .../audit-2026-06-stellar-skill.md | 71 +- docs/history/mainnet-sdk27-upgrade-2026-07.md | 335 ++++++++ .../mainnet-upgrade-runbook-2026-08.md | 49 +- docs/mainnet-deploy-runbook.md | 693 ---------------- docs/multisig-guide.md | 536 +++---------- docs/multisig-preflight.md | 131 ---- docs/push-payouts-feasibility.md | 14 +- docs/scout-audit-report.md | 114 ++- docs/sdk27-compatibility-fixtures.md | 43 - docs/storage-compatibility.md | 70 ++ docs/threat-model.md | 742 +++++++++--------- docs/upgrade-runbook.md | 376 +++++++++ 31 files changed, 2724 insertions(+), 2365 deletions(-) delete mode 100644 .all-contributorsrc create mode 100644 docs/audit/testnet-grant-run.md rename docs/{ => history}/audit-2026-06-stellar-skill.md (90%) create mode 100644 docs/history/mainnet-sdk27-upgrade-2026-07.md rename docs/{ => history}/mainnet-upgrade-runbook-2026-08.md (82%) delete mode 100644 docs/mainnet-deploy-runbook.md delete mode 100644 docs/multisig-preflight.md delete mode 100644 docs/sdk27-compatibility-fixtures.md create mode 100644 docs/storage-compatibility.md create mode 100644 docs/upgrade-runbook.md diff --git a/.all-contributorsrc b/.all-contributorsrc deleted file mode 100644 index f129ad3..0000000 --- a/.all-contributorsrc +++ /dev/null @@ -1,39 +0,0 @@ -{ - "projectName": "boundless-contract", - "projectOwner": "0xdevcollins", - "repoType": "github", - "repoHost": "https://github.com", - "files": [ - "CONTRIBUTORS.md" - ], - "imageSize": 100, - "commit": true, - "commitConvention": "none", - "contributors": [ - { - "login": "0xdevcollins", - "name": "Collins Ikechukwu", - "avatar_url": "https://github.com/0xdevcollins.png", - "profile": "https://github.com/0xdevcollins", - "contributions": [ - "code", - "doc", - "maintenance", - "projectManagement" - ] - } - ], - "contributorsPerLine": 7, - "skipCi": true, - "contributorsSortAlphabetically": false, - "badgeTemplate": "[![All Contributors](https://img.shields.io/github/all-contributors/<%= projectOwner %>/<%= projectName %>?color=ee8449&style=flat-square)](#contributors)", - "contributorTemplate": "\">\" width=\"<%= options.imageSize %>px;\" alt=\"\"/>
<%= contributor.name %>
", - "types": { - "custom": { - "symbol": "🔭", - "description": "A custom contribution type.", - "link": "[<%= symbol %>](<%= url %> \"<%= description %>\")" - } - }, - "linkToUsage": true -} diff --git a/BACKLOG.md b/BACKLOG.md index 20eb482..f2a38d1 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -6,52 +6,18 @@ Legend: `[ ]` open, `[~]` in flight, `[x]` done. --- -## P0 (blocking mainnet) +## P0 -- [x] **SDK 23 to SDK 27 storage compatibility.** Exact mainnet and synthetic SDK 23 state upgrades through H6 to pinned SDK 27 artifacts. Tests cover every persisted layout; CI pins fixtures and snapshots; mainnet tooling verifies versions, admins, bindings, pause state, queued hashes, and live executable hashes. -- [ ] Third-party security audit (events + profile). Cost ~$30-80k for a Soroban-experienced firm. Schedule blocker for the mainnet date. -- [x] Non-USDC token end-to-end smoke. 2026-06-05: registered the testnet native XLM SAC (`CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC`) on the new events contract; ran `scripts/smoke/contract-non-usdc-token.ts`. Bounty published in XLM, alice applied + submitted + won + got paid; payout tx `a6c8daad…`. Smoke updated to tolerate native-asset tx fees (~143k stroops / 0.014 XLM for 3 alice-signed txs); for non-native tokens the assertion still demands exact-budget delta. -- [ ] Mainnet admin multi-sig provisioned per `docs/admin-custody-policy.md` (3 signers, 2-of-3). 2026-06-05: launch baseline updated to software multi-sig (Freighter on 3 isolated machines) with a hardware-upgrade trigger codified in `admin-custody-policy.md` §10 (TVL $250k OR daily volume $50k/day for 7 days OR first signer-machine incident → mandatory Yubikey upgrade). Pre-flight gate codified for both paths — see `docs/multisig-preflight.md` (Section 1.A hardware, Section 1.B Freighter; everything else identical) and `scripts/admin/verify-multisig.sh` (chain-state gate, hardware-agnostic). The verify script was sanity-tested against a single-sig account and correctly fails with the per-check breakdown. -- [ ] Mainnet deploy executed per `docs/mainnet-deploy-runbook.md`. Result committed to `deployments/mainnet.json`. -- [~] **On-chain credit removal: 1.0.0 -> 1.1.0 in-place upgrade (both contracts).** 2026-07-03: APPLIED on mainnet. Both contracts now report `version() == "1.1.0"`. propose (events tx `03b1b30b`, profile `531ff5a2`, 2026-06-29) -> ~1-day timelock -> apply (events tx `b2c84d7c` ledger 63315871, profile `663085fc` ledger 63315890, 2026-07-03). Live wasm: events `2a8789bf`, profile `b9e3500c`. Recorded in `deployments/mainnet.json` + `deployments/mainnet-upgrades.jsonl`. Remaining: `migrate()` no-op stamp on both (audit-trail marker), then deploy the credit-free `boundless-nestjs`. Original notes below. 2026-06-28: dropped the on-chain credit surface from both contracts (source + tests green, 41 profile / 150 events; wasm builds). Profile struct went `{ bootstrapped_at, credits, reputation }` -> `{ bootstrapped_at, reputation }`; `spend_credits` / `earn_credits` / `refund_credits` / `admin_grant_credits` / `set_default_bootstrap_credits` removed; events contract dropped `application_credit_cost` (EventRecord / CreateEventParams) and `credit_earn` (WinnerSpec). Credits are off-chain only now (see `boundless-credits-reputation-prd.md` §2). Versioned as a minor bump: `INITIAL_VERSION` + `contractmeta` are now `1.1.0` on both. **No data migration required:** the live contracts were freshly deployed with ZERO bootstrapped profiles and ZERO events, so there are no `Profile` / `EventRecord` rows in the old shape to rewrite. The dropped-field structs only ever have to deserialize rows that do not exist. **Timelock kept as-is** (audited H6 control; cheap insurance as real escrow lands; revisit only if operationally painful). Upgrade sequence (multisig governance op per `docs/mainnet-deploy-runbook.md` + `docs/admin-custody-policy.md`, NOT yet executed): build + commit the 1.1.0 wasm -> `propose_upgrade(new_wasm_hash, "1.1.0")` -> wait out `UPGRADE_TIMELOCK_LEDGERS` -> `apply_upgrade()` -> `migrate()` (no-op stamp; emits the `Migrated` audit event) -> verify `version()` reads `1.1.0` -> cut backend over to confirm the credit-free contract surface. Do BOTH contracts. Re-confirm zero on-chain profiles/events at upgrade time (the no-migration assumption rests on it). +- [ ] Third-party security audit (events + profile). Cost ~$30-80k for a Soroban-experienced firm. Scope it to the 2.0.0 / 1.2.1 tree. +- [ ] **Upgrade mainnet to events 2.0.0 + profile 1.2.1** (`docs/upgrade-runbook.md`, Section 8). Carries every threat-model fix (`docs/threat-model.md` v1.1, Section 4.1.2), restores the 17,280-ledger upgrade timelock (EoP.10: the deployed 1.7.0 / 1.2.0 builds have none), and finishes the 1.7.0 migration that never ran: events still reports a 1.6.0 marker, and events 1 and 2 (`…609`, `…610`, both Completed with zero escrow) are still in the old layout and fail to decode. Rehearsed on testnet 2026-10-02 (events `CBEODVJG…` at 2.0.0, 185 events through `migrate_events`, `migrate` stamped). Backend first: read pages of 90, refund cranks of 15, hackathon selections split at 20 per call, `forfeit_milestone` on milestone rejection, `claim_refund` for held refunds, and no calls to the removed participation entry points. Then profile propose / apply / migrate, and events pause / propose / apply / one `migrate_events` page / `migrate` / unpause, all under the deployed zero timelock. `deploy_mainnet.sh` now refuses to unpause events until `migrate` has stamped, the gate the August upgrade lacked. -### From the 2026-06 Stellar-skill audit +## P1 -See `docs/audit-2026-06-stellar-skill.md` for full findings. - -- [x] **H1 + H2** — Admin/config moved to `instance()` storage; persistent reads of event-scoped data bump `extend_ttl`. Both contracts. (2026-06-04) -- [x] **H3 + H4** — Per-event `Vec
` and `Map` storage replaced with per-element keys (`*Count` + `*At(idx)` + `*Slot(addr)` for O(1) membership). Submissions keyed per-(event, applicant). Caps lifted to 5,000 each after paged cancel landed. New errors `TooManyApplicants`, `TooManyContributors`. Paged read APIs `get_*_count` + `get_*_at` alongside the capped snapshot reads. (2026-06-04) -- [x] **Paged cancel_event** — Replaces single-tx cancel with `start_cancel` + `process_cancel_batch` + `finalize_cancel`. `start_cancel` snapshots the refund math and flips `EventStatus::Cancelling`; OwnerOnly branch settles inline. Batches refund up to `MAX_REFUNDS_PER_BATCH = 25` partners per tx. Finalize pays owner residual on the FullPartnerThenResidual branch and flips Cancelled. Lifts the contributor cap to 5,000. (2026-06-04) -- [x] **H5** — Two-step rotation for `set_events_contract` on profile contract. First-set stays single-step (deploy bootstrap); rotation is `propose_events_contract` + `accept_events_contract` with a ~1-day timelock and a 7-day expiry. `cancel_pending_events_contract` to prune. (2026-06-04) -- [x] **H6** — Timelocked upgrade flow (`propose_upgrade` + `apply_upgrade` + `cancel_pending_upgrade` with ~1-day timelock, 30-day expiry); on-chain `version()` view (initial `0.2.0`); `migrate()` admin one-shot per version guarded by `MigratedToVersion`. Both contracts. (2026-06-04) -- [x] **Storage layout change shipped post-testnet-deploy.** 2026-06-05: testnet redeployed fresh at 0.2.0; the 0.1.x rows held nothing precious. Mainnet has not deployed yet, so the 0.1.0→0.2.0 path will never run in production. `migrate()` exists per H6 with a documented per-`(from, to)` dispatch pattern in `contracts/events/src/admin.rs` + the profile mirror, ready for the first real storage-layout upgrade after mainnet goes live. -- [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) -- [ ] Per-event prize claim window: `PRIZE_CLAIM_WINDOW_SECS` (90 days) is a module constant for now because adding a field to `EventRecord` requires a storage migration. Move it onto `EventRecord` at the next real migration window, per the per-event-config rule in CLAUDE.md. -- [ ] `Error` enum is at the 50-case XDR spec cap. The next error needs consolidation of an existing variant; plan this before the audit freeze. -- [ ] Measure real `select_winners` batch headroom with `simulateTransaction` on testnet (entry writes per winner: row + award key). The 50/call cap is conservative; raise it only from measured numbers. +- [ ] Per-event prize claim window: `PRIZE_CLAIM_WINDOW_SECS` (90 days) is a module constant because adding a field to `EventRecord` requires a storage migration. Move it onto `EventRecord` at the next real migration window, per the per-event-config rule in `CLAUDE.md`. +- [ ] `Error` enum headroom: events uses 42 of the 50-case `contracterror` cap after 2.0.0, and retired codes are never reused. Plan a consolidation before the cap, not when the next variant fails to compile. - [ ] Grant committee multi-sig primitive (dedicated signer set + quorum at the contract level vs the current address-level multi-sig). -- [ ] Bounty Showdown participation badge on the boundless-profile contract for non-winning finalists. - [ ] Multi-token support audit: verify the whitelist mechanism handles tokens with non-Stellar 7-decimal scales (currently assumed uniform). -- [x] **M1** — `select_winners` percent math now uses `remaining_escrow` snapshotted at select time so partner top-ups via `add_funds` flow to winners instead of getting trapped until cancel. (2026-06-04) -- [x] **M2** — Documented operational policy: contract-layer trustline checks aren't reliably possible in Soroban today (SAC `balance()` collapses "no trustline" and "0 balance"). Admin verifies off-chain before `set_fee_account` / `register_supported_token`; `FeeAccountUpdated` + `TokenRegistered` events are the off-chain monitoring hooks. (2026-06-04) -- [x] **M4** — Dropped `wins_count`, `submissions_count`, `applications_count`, `milestones_completed` from `Profile`. Off-chain indexer derives counters from emitted events. (2026-06-04) -- [x] **M5** — Crowdfunding `claim_milestone` requires `admin.require_auth()` in addition to `event.owner.require_auth()`. Grants stay single-auth (event owner is the grant org). (2026-06-04) -- [x] **L1** — Audit recommendation targeted the pre-H1 persistent layout. After H1 the Pending* keys live in instance storage which auto-extends and is the right home for them; the temporary-storage swap is no longer a net improvement. Decision recorded inline in storage.rs. -- [x] **L2** — `Error::NotInitialized` added to both error enums. (Shipped with H1+H2.) -- [x] **L3** — `panic_with_error!(NotInitialized)` replaces `.expect("admin not configured")`. (Shipped with H1+H2.) -- [x] **L4** — `MAX_FEE_BPS` tightened from 5000 (50%) to 1000 (10%). Per-event overrides still respect the cap. (2026-06-04) -- [x] **L5** — `EventCreated` event carries `title`. Indexers can populate listings without a follow-up `get_event` read. (2026-06-04) -- [x] **L6** — `__link_keep` removed; `evt::EVENTS_LINK_KEEP` constant gone. The events module is widely consumed (37+ `evt::` references across operation modules), so the keep-alive trick is no longer needed. (2026-06-04) -- [x] **L7** — Crowdfunding `validate_create` rejects nonzero `application_credit_cost` (reuses `Error::InvalidPillar` to stay inside the contracterror 50-variant cap). (2026-06-04) +- [ ] Decide whether to appoint a crowdfunding release validator on mainnet once 2.0.0 is live, and who holds the key (`docs/admin-custody-policy.md` §4.1). Until one accepts, the admin multisig co-signs every crowdfunding release. ## P2 (roadmap) @@ -63,32 +29,18 @@ See `docs/audit-2026-06-stellar-skill.md` for full findings. ## Done -- [x] 2026-07-17 — Cancellation liveness hardening. `add_funds` now maintains an O(1) per-event non-owner contribution total, `start_cancel` no longer scans up to 5,000 contributor rows, and `process_cancel_batch` / `finalize_cancel` are permissionless after cancellation starts. The original guarded events 1.1.0 → 1.2.0 plan introduced the pause and zero-event checks; the current coordinated SDK 27 flow carries those checks forward to events 1.5.0 and profile 1.2.0. Older zero-contributor rows initialize the missing total lazily; a missing total with existing contributors fails closed. Tests cover a 220-contributor constant-footprint start and exact third-party-driven payout deltas. -- [x] 2026-06-03 — `fee_bps_override` per-event field + `effective_fee_bps` resolver. -- [x] 2026-06-03 — `WinnersAlreadySelected` replay lock on `select_winners`. -- [x] 2026-06-03 — Grant last-milestone sweep (G4). -- [x] 2026-06-03 — Contract upgrade primitive verified end to end on testnet (upgrade tx 50a6ab65). -- [x] 2026-06-04 — Stellar-skill audit landed (`docs/audit-2026-06-stellar-skill.md`). -- [x] 2026-06-04 — H1+H2 storage migration: admin/config → `instance()`, persistent reads bump TTL, both contracts. Tests green (events 64/64, profile 2/2). Adds `Error::NotInitialized` to both error enums; replaces `.expect()` with `panic_with_error!` on the admin getter (L2/L3). -- [x] 2026-06-04 — H5 two-step events-contract rotation on the profile contract: `propose` + `accept` with a ~1-day timelock and a 7-day expiry; `cancel_pending_events_contract` prunes expired or unwanted proposals. First-set unchanged. Tests green (profile 9/9). -- [x] 2026-06-04 — H3+H4 per-element storage layout for applicants, contributors, winners; per-(event, addr) submission entries. Soft caps at 100 per list to keep cancel_event single-tx. New paged read APIs (`get_*_count` + `get_*_at`). Tests green (events 66/66, profile 9/9). -- [x] 2026-06-04 — Paged cancel: `start_cancel` + `process_cancel_batch` + `finalize_cancel`. `EventStatus::Cancelling` intermediate state. `CancellationState` snapshots refund math (non_owner_total, remaining_at_start, count_at_start, next_idx, branch). OwnerOnly branch settles inline. Lifts contributor + applicant caps to 5,000. Tests green (events 67/67, profile 9/9). -- [x] 2026-06-04 — H6 timelocked upgrade + on-chain version + migrate. `propose_upgrade(wasm_hash, new_version)` + `apply_upgrade()` (after ~1 day, before 30 days) + `cancel_pending_upgrade()` + `migrate()` (one-shot per version). `version()` view returns the on-chain semver string (initial `0.2.0`). Both contracts. Tests green (events 74/74, profile 13/13). Closes audit recommendation; the prior immediate-upgrade path is gone, callers must rotate through the timelock. -- [x] 2026-06-04 — M1 + M2 + M4 + M5. `select_winners` Single math switched to `remaining_escrow` (partner top-ups flow to winners). Operational trustline policy documented in `token_whitelist.rs` + `admin.rs` (M2). `Profile` counter fields dropped (M4). Crowdfunding `claim_milestone` requires admin auth on top of builder auth (M5). Tests green (events 76/76, profile 13/13). -- [x] 2026-06-04 — L4 + L5 + L6 + L7. `MAX_FEE_BPS` tightened to 10% (L4). `EventCreated` event carries `title` for indexer convenience (L5). `__link_keep` + `EVENTS_LINK_KEEP` removed (L6). Crowdfunding rejects nonzero `application_credit_cost` (L7). L1 skipped per inline note: post-H1 the Pending* keys are already in instance storage, so the temporary swap is no longer a net improvement. Tests green (events 76/76, profile 13/13). -- [x] 2026-06-04 — Off-chain catch-up. `deploy_and_upgrade.sh` rewritten for the timelocked upgrade flow (`propose-upgrade` + `apply-upgrade` + `cancel-pending-upgrade` + `migrate` + `status`); the old `upgrade` action errors out with migration instructions. boundless-nestjs: `EscrowContractClient` exposes `buildStartCancel` / `buildProcessCancelBatch` / `buildFinalizeCancel` + the H6 upgrade builders; orchestrator gains `beginStartCancel` + `beginProcessCancelBatch` + `beginFinalizeCancel` + `beginProposeUpgrade` + `beginApplyUpgrade` + `beginCancelPendingUpgrade` + `beginMigrate`. Paged read APIs (`get_applicant_count` / `get_applicant_at` / `get_winner_count` / `get_winner_at` / `get_contributor_count` / `get_contributor_at`). Smoke scripts updated. New `EscrowOpKind` values (START_CANCEL, PROCESS_CANCEL_BATCH, FINALIZE_CANCEL, PROPOSE_UPGRADE, APPLY_UPGRADE, CANCEL_PENDING_UPGRADE, MIGRATE) shipped in Prisma migration `20260604100000_paged_cancel_upgrade_op_kinds`. Profile.ts loses the four counter fields (M4). EventStatus gains `Cancelling`. `tsc --noEmit` clean; escrow-contract jest 125/125 green. -- [x] 2026-06-05 — Orchestrator-path testnet smokes against the new wasm (`escrow-cancel.ts`, `escrow-multi-contributor-cancel.ts`, `escrow-select-winners.ts`, `grant-publish-and-claim.ts`, `hackathon-publish.ts`). All confirm the new contract surface flows cleanly through `EscrowContractClient` → orchestrator → reconcile → subscriber. Two smokes surfaced real off-chain integration gaps (recorded above as audit follow-ups): domain subscribers don't yet recognize the new cancel kinds, and the crowdfunding `claim_milestone` orchestrator path needs admin co-signing. Both are downstream of the audit, not in the audit itself. -- [x] 2026-06-05 — H5 admin rotation flow on testnet. `propose_events_contract` → `accept_events_contract` returning contract error 17 (`PendingEventsContractTimelock`) before the ~1-day timelock elapses → `cancel_pending_events_contract` (emits `EventsRotationCancelled`) → `get_pending_events_contract` returns null. Verified the events binding stays unchanged when the rotation is aborted. -- [x] 2026-06-05 — Testnet redeploy + smoke battery against the new wasm. - - profile: `CDSURDCUHB5GU64METASSZW6SZ7IIKA7RABBYPZTNM45GANGN72K22MA` (wasm hash `bf9fd1a7…`) - - events: `CDKZ5N2D4HYU7BAGNUJAB7WT5EYWVCRNUBJA54G6AVXXRLMAQFUEVMXH` (wasm hash `d9b0b9a1…`) - - both `version()` return `"0.2.0"`. - - TUSD registered (`CC2OZCF4HDJJEOTQJ6QMRXR5MZRALRTSVJHGTUFBBGDMHKHQJWCR7O7N`). - - **Surface verification**: legacy `upgrade` + `cancel_event` confirmed absent from the contract spec (CLI parser rejects them); all 13 new methods listed by `--help` (propose_upgrade, apply_upgrade, cancel_pending_upgrade, migrate, start_cancel, process_cancel_batch, finalize_cancel, get_applicant_count, get_winner_count, get_contributor_count, version, get_pending_upgrade, get_migrated_to_version). - - **OwnerOnly cancel smoke** (event 12552982520397825, hackathon, 100 TUSD budget, 0 contributors): `start_cancel` inline-flipped to `Cancelled`, full 100 TUSD refunded to admin. Tx `8c4390de5b…`. `EventCreated.title` field present in the event payload (L5 live). - - **Paged cancel smoke** (event 12552982520397826, hackathon, 50 TUSD budget + Alice 20 TUSD): `start_cancel` flipped to `Cancelling` (tx `7b3df18945…`); `process_cancel_batch(max_refunds=25)` refunded Alice 20 TUSD and returned remaining=0 (tx `288aeaa94d…`); `finalize_cancel` refunded owner 50 TUSD residual and flipped to `Cancelled` (tx `e6c1049fc7…`). Final state: `status: "Cancelled"`, `remaining_escrow: "0"`. - - **H6 migrate smoke**: first `migrate()` emitted `Migrated(from "0.0.0", to "0.2.0")`; replay returned contract error 69 = `MigrationAlreadyApplied`; `get_migrated_to_version` returns `"0.2.0"`. - - All txes recorded in `deployments/testnet.json`. +- [x] 2026-10-02: measured batch headroom on the test host, which enforces mainnet limits (100 footprint entries, 50 writes): single-release selection 19 + 4 per award, grant selection 15 + 2 per award, refund crank 11 + 5 per refund, read page 3 + 1 per row. Caps are now 20 single-release awards per call, 40 per grant, 15 refunds per crank and 90-row pages, each tested at its maximum (`grant_scenarios::limits`). +- [x] 2026-10-02: upgrade timelock restored in source (events 2.0.0, profile 1.2.1): 17,280 ledgers on mainnet builds, 0 with `--features testnet`; tests assert `UpgradeTimelockNotElapsed`. +- [x] 2026-09-28: on-chain participation removed in events 2.0.0 (`apply`, `apply_to_bounty`, `remove_applicant`, `submit` and their registries); entries live in boundless-nestjs. +- [x] 2026-07-30: mainnet events 1.1.0 to 1.6.0 (propose, apply, migrate). +- [x] 2026-07-24: SDK 23 to SDK 27 storage compatibility suite (`docs/history/mainnet-sdk27-upgrade-2026-07.md`); now the storage compatibility suite for 2.0.0 (`docs/storage-compatibility.md`). +- [x] 2026-07-18: `select_winners` batchable for single-release events (1.3.0, #61). Each position is awardable once (the `EventPrizeAward` key is the replay lock) and winners collect with `claim_prize`. +- [x] 2026-07-17: cancellation liveness. `add_funds` keeps an O(1) per-event non-owner contribution total, `start_cancel` no longer scans contributors, and `process_cancel_batch` / `finalize_cancel` are permissionless once cancellation starts. A missing total with existing contributors fails closed. +- [x] 2026-07-03: on-chain credits removed (events and profile 1.0.0 to 1.1.0 on mainnet). Credits are an off-chain ledger in boundless-nestjs. +- [x] 2026-06-27: mainnet deployed (events `CCFVEGOQ…`, profile `CD3KH4OE…`). Admin of both is the 2-of-3 multisig `GCVK72I6…` (thresholds 0/2/2, master weight 0), checked by `scripts/admin/verify-multisig.sh`. +- [x] 2026-06-05: testnet redeploy and smoke battery against the audited surface (paged cancel, H6 migrate replay, token enumeration), plus a non-USDC end-to-end smoke with the testnet native XLM SAC. +- [x] 2026-06-04: June 2026 Stellar-skill audit fixed: H1 + H2 (instance storage, TTL bumps), H3 + H4 (per-element storage for per-event lists), paged cancel, H5 (two-step events-contract rotation on profile), H6 (timelocked upgrade, `version()`, `migrate()`), M1, M2 (trustline check is operational, not on chain), M4, L1 to L6. Report: `docs/history/audit-2026-06-stellar-skill.md`. +- [x] 2026-06-03: `fee_bps_override` per event, `WinnersAlreadySelected` replay lock, grant last-milestone sweep (G4). --- diff --git a/CLAUDE.md b/CLAUDE.md index f6a2538..e7c1774 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,26 +18,33 @@ Source: ## Hard rules - **No `unwrap()` on host-returned `Option`/`Result` in contract code.** Return a typed `Error` instead. Existing patterns: `Error::EventNotFound`, `Error::InsufficientEscrow`, `Error::WinnersAlreadySelected`. -- **Storage layout is stable.** Adding a field to `EventRecord` or any persisted struct must extend, never reorder, and must ship with a corresponding migration story (see `docs/mainnet-deploy-runbook.md` and the `upgrade()` admin function). +- **Storage layout is stable.** Adding a field to `EventRecord` or any persisted struct must extend, never reorder, and must ship with a corresponding migration story (see `docs/upgrade-runbook.md`: `propose_upgrade` / `apply_upgrade`, then `migrate_events` and `migrate`), proven by the mainnet replay in `docs/storage-compatibility.md`. - **Per-event configuration over global constants.** Anything sales might want to vary per program (fees, windows, caps) belongs on `EventRecord` or its variant payload, not in module constants. - **Tests cover the math.** Every payout split (single + multi-position + sweep) has a test that asserts both the recipient and the fee account deltas. -- **Snapshots are inspection tooling, not history.** `test_snapshots/` is gitignored: snapshots are derived artifacts that `cargo test` regenerates from any commit, and committing them made PR diffs so large that the security scanners skipped them. When reviewing a storage-layout or auth change, regenerate locally and inspect the snapshot diff — but never commit snapshot files. -- **Comment sparingly.** A comment earns its place only by stating a constraint the code cannot: an invariant, a security ordering, a compatibility trap, a non-obvious "why". Do not narrate what the code does, restate the function name, tag changes with version/PR numbers, or leave banners over self-evident blocks. When in doubt, delete it — dense explanatory comments read as AI-generated and make review harder, not easier. Match the density of the surrounding file. +- **Snapshots are inspection tooling, not history.** `test_snapshots/` is gitignored: snapshots are derived artifacts that `cargo test` regenerates from any commit, and committing them made PR diffs so large that the security scanners skipped them. When reviewing a storage-layout or auth change, regenerate locally and inspect the snapshot diff, but never commit snapshot files. +- **Comment sparingly.** A comment earns its place only by stating a constraint the code cannot: an invariant, a security ordering, a compatibility trap, a non-obvious "why". Do not narrate what the code does, restate the function name, tag changes with version/PR numbers, or leave banners over self-evident blocks. When in doubt, delete it: dense explanatory comments read as AI-generated and make review harder, not easier. Match the density of the surrounding file. ## Build, test, deploy ```bash -# Build -cd contracts/events && cargo build --target wasm32v1-none --release +# Build (soroban-sdk 28 refuses a plain `cargo build --target wasm32v1-none`) +stellar contract build --locked # Test (host target) cargo test -p boundless-events +cargo test -p boundless-profile -# Deploy / upgrade testnet -./deploy_and_upgrade.sh +# Replay the next mainnet upgrade against captured mainnet storage +./scripts/test-storage-compat.sh + +# Fresh deploy (testnet) +scripts/deploy/deploy.sh + +# Upgrade testnet +./deploy_and_upgrade.sh testnet # Deploy / upgrade mainnet -./deploy_mainnet.sh # see docs/mainnet-deploy-runbook.md +./deploy_mainnet.sh # see docs/DEPLOYMENT.md and docs/upgrade-runbook.md ``` Mainnet admin operations live behind the multi-sig defined in `docs/admin-custody-policy.md`. Never touch mainnet without confirming the runbook prerequisites first. @@ -45,8 +52,10 @@ Mainnet admin operations live behind the multi-sig defined in `docs/admin-custod ## Before opening a PR ```bash -cargo test -p boundless-events -cargo build --release --target wasm32v1-none +cargo fmt --check +cargo clippy --locked --all-targets -- -D warnings +cargo test --workspace +./scripts/test-storage-compat.sh ``` Update `BACKLOG.md` if your PR closes one of the entries there. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3a4c543..98ee446 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,8 +24,8 @@ Pull requests are the best way to propose changes to the codebase. We actively w ## Any contributions you make will be under the MIT Software License In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. -## Report bugs using Github's [issue tracker](https://github.com/0xdevcollins/boundless/issues) -We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/0xdevcollins/boundless/issues/new); it's that easy! +## Report bugs using Github's [issue tracker](https://github.com/boundlessfi/boundless-contract/issues) +We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/boundlessfi/boundless-contract/issues/new); it's that easy! ## Write bug reports with detail, background, and sample code @@ -43,8 +43,8 @@ We use GitHub issues to track public bugs. Report a bug by [opening a new issue] 1. Clone the repository ```bash -git clone https://github.com/0xdevcollins/boundless.git -cd boundless +git clone https://github.com/boundlessfi/boundless-contract.git +cd boundless-contract ``` 2. Create a new branch diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 2e42fdf..0b6c7d1 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -1,15 +1,11 @@ # Contributors -## Automated Contributors List -### Top Contributors -![GitHub Contributors Image](https://contrib.rocks/image?repo=0xdevcollins/boundless-contract) +![GitHub contributors](https://contrib.rocks/image?repo=boundlessfi/boundless-contract) - -### Recent Contributors - - + + -## Want to Contribute? +## Want to contribute? -Check out our [CONTRIBUTING.md](CONTRIBUTING.md) guide to get started! +See [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/README.md b/README.md index b5c0a1f..729d2b5 100644 --- a/README.md +++ b/README.md @@ -1,116 +1,141 @@ # boundless-contract -Soroban smart contracts that anchor the Boundless platform on Stellar. - -## What is in this repo - -Two contracts in one workspace. +The Soroban contracts behind Boundless on Stellar: escrow and records for +hackathons, bounties, grants and crowdfunding, plus builder reputation and +earnings. | Contract | Path | Purpose | |----------|------|---------| -| `boundless-events` | `contracts/events` | Event records for the four pillars (hackathon, bounty, grant, crowdfunding) with inlined escrow. Multi-token whitelist. Idempotent operations. Per-pillar dispatch on a single canonical `create_event`. Paged cancellation and timelocked upgrades. | -| `boundless-profile` | `contracts/profile` | Per-user reputation and per-token earnings. Lazy profile bootstrap. Mutated almost exclusively by the events contract. | +| `boundless-events` | `contracts/events` | Events for the four pillars with escrow held in the contract. Whitelisted tokens, prize floors, winner awards and claims, milestone releases, paged cancellation and refunds, idempotent operations, timelocked upgrades. | +| `boundless-profile` | `contracts/profile` | Per-user reputation and per-token earnings, written by the events contract. | ## Deployments | Network | Contract | Address | Version | |---------|----------|---------|---------| -| Mainnet | `boundless-events` | `CCFVEGOQJEM47LRAJU2LHEK4KTL5VYN7AOGZ2HH2GNHAMXTILNMMJGQZ` | `1.1.0` | -| Mainnet | `boundless-profile` | `CD3KH4OE7HDHHHUYFX3U4L7NLIILMXAY6HM5FEH2UH6UBOKX4HDNE3PC` | `1.1.0` | -| Testnet | `boundless-events` | `CBEODVJGUYCIYTVXD7KI5UG3BJ2UE4T7AGI2TGY3T4Q5GQRFGTRYVTZP` | `1.0.0` | -| Testnet | `boundless-profile` | `CCA3OAIBOZBUPHPRI5GI6N5PDTE7RTNLKAEID4JTC2YZIHIZNDX5Q6T3` | `1.0.0` | - -Both contracts expose an on-chain `version()` view; the versions above are live reads. - -## Architecture in one paragraph - -The events contract is the on-chain anchor of event existence, key terms, escrow custody, submission anchors, and winner records. Funds enter escrow at creation (or through top-ups), fees are taken at deposit, and payouts release through `select_winners` or per-milestone claims. The profile contract is the on-chain anchor of reputation scores and per-token earnings. The events contract calls into the profile contract to bootstrap profiles on first touch, bump reputation on wins and milestones, and register earnings on payout. Every state-mutating operation carries an idempotency key, admin surfaces are pausable, and upgrades are timelocked. - -## Prerequisites - -- Rust 1.90.0 (`rust-toolchain.toml` pins this). -- Soroban SDK 23.5.x (workspace-pinned in `Cargo.toml`). -- `stellar` CLI for building WASM (`brew install stellar/tap/stellar-cli`). -- `wasm32v1-none` target: `rustup target add wasm32v1-none`. +| Mainnet | `boundless-events` | `CCFVEGOQJEM47LRAJU2LHEK4KTL5VYN7AOGZ2HH2GNHAMXTILNMMJGQZ` | `1.7.0` | +| Mainnet | `boundless-profile` | `CD3KH4OE7HDHHHUYFX3U4L7NLIILMXAY6HM5FEH2UH6UBOKX4HDNE3PC` | `1.2.0` | +| Testnet | `boundless-events` | `CBEODVJGUYCIYTVXD7KI5UG3BJ2UE4T7AGI2TGY3T4Q5GQRFGTRYVTZP` | `2.0.0` | +| Testnet | `boundless-profile` | `CCA3OAIBOZBUPHPRI5GI6N5PDTE7RTNLKAEID4JTC2YZIHIZNDX5Q6T3` | `1.2.0` | + +This tree builds events `2.0.0` and profile `1.2.1`. Mainnet moves to them +through the procedure in `docs/upgrade-runbook.md` (Section 8); until then it +runs the versions above. Both contracts answer `version()`, so read it rather +than trusting this table. + +On mainnet both contracts are administered by the 2-of-3 multisig +`GCVK72I6TVJVDTTY4UKU6MQT4QJ2T2AAG3NULNEUDM46L3UOQYDSO4O2`. Fees go to +`GADSIP2HPINWTMEUNMVYA5NJRL372OV52HDEJTRTLDZXMHADIPQNYH55` at 250 bps, and the +whitelisted tokens are USDC (`CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75`) +and native XLM (`CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA`). + +## How it fits together + +An owner creates an event with `create_event`; the budget moves into the +contract and the platform fee goes to the fee account in the same call +(crowdfunding starts empty and is charged at release instead). Anyone can top +an event up with `add_funds`. The owner, or a manager the owner has delegated +to, records awards with `select_winners`: hackathons and bounties in batches of +at most 20, grants once with at most 40 recipients. No award may fall below the +event's published prize floor for its position. +Hackathon and bounty winners collect with `claim_prize`; grant awards and +crowdfunding funds are paid per milestone with `claim_milestone`, and an owner +can `forfeit_milestone` a grant milestone it rejects. A crowdfunding release +also needs the release validator's signature, or the admin's while none is +appointed. Cancellation is paged (`start_cancel`, `process_cancel_batch` with at +most 15 refunds per call, `finalize_cancel`), and a refund an account cannot +receive is held for it to collect with `claim_refund`. + +Applying and submitting are off chain since 2.0.0; the contract keeps what +money depends on. The profile contract accepts reputation bumps and earnings +only from the events contract it is bound to; the admin can lower a +reputation with `admin_slash_reputation`. Every mutating call carries an +`op_id` for idempotency, both contracts can be paused, and upgrades are +timelocked: `propose_upgrade`, then `apply_upgrade` 17,280 ledgers later on +mainnet builds, then `migrate_events` (events) and `migrate`. More in +`docs/ARCHITECTURE.md`. + +## Toolchain + +- Rust 1.93.0 with the `wasm32v1-none` target (`rust-toolchain.toml`). +- soroban-sdk 28.0.0 (workspace `Cargo.toml`). +- Stellar CLI 28.1.0 (`cargo install --locked stellar-cli@28.1.0`). + +Build wasm with `stellar contract build`; SDK 28 refuses a plain +`cargo build --target wasm32v1-none`. ## Build and test ```sh -# Unit tests, host target -cargo test --all +cargo test --workspace -# WASM build, both contracts -cd contracts/events && make build && cd ../.. -cd contracts/profile && make build && cd ../.. +# Release wasm as mainnet runs it (17,280-ledger upgrade timelock) +stellar contract build --locked --package boundless-events +stellar contract build --locked --package boundless-profile -# Check WASM sizes against the 64 KB Soroban ceiling +# Testnet wasm (no upgrade timelock) +stellar contract build --locked --package boundless-events --features testnet + +# Size against the 64 KB ceiling cd contracts/events && make size && cd ../.. -cd contracts/profile && make size && cd ../.. + +./scripts/check-versions.sh # Cargo.toml, INITIAL_VERSION and contractmeta agree +./scripts/test-admin-scripts.sh # admin pre-flight scripts against fixtures +./scripts/test-mainnet-upgrade-guards.sh # deploy_mainnet.sh guards against a mock CLI +./scripts/test-storage-compat.sh # replays the mainnet upgrade on real storage ``` -Test snapshots under `contracts/*/test_snapshots/` are the audit trail. When fixtures change shape, regenerate snapshots intentionally and commit them in the same PR. +Test snapshots (`test_snapshots/`) are gitignored. `cargo test` regenerates +them; inspect the diff locally when reviewing a storage or auth change, but +never commit them. ## Repo layout ``` boundless-contract/ -├── Cargo.toml # workspace +├── Cargo.toml # workspace ├── rust-toolchain.toml -├── deploy_and_upgrade.sh # testnet deploy / upgrade helper -├── deploy_mainnet.sh # mainnet cold deploy -├── .github/workflows/ # verify-build, rustfmt, deploy +├── deploy_mainnet.sh # mainnet deploy and upgrade tool (prepares multisig XDRs) +├── deploy_and_upgrade.sh # testnet upgrade helper +├── .env.deploy.example # parameters for scripts/deploy/deploy.sh ├── contracts/ -│ ├── events/ -│ │ ├── Cargo.toml -│ │ ├── Makefile -│ │ └── src/ -│ │ ├── lib.rs # contract entry, public surface -│ │ ├── types.rs # EventRecord, Submission, Winner, enums -│ │ ├── errors.rs # error code enum -│ │ ├── events.rs # contract event emissions -│ │ ├── storage.rs # persistent / temporary key helpers -│ │ ├── admin.rs # init, rotation, pause, timelocked upgrade -│ │ ├── token_whitelist.rs # admin-managed supported-tokens set -│ │ ├── escrow.rs # fee math, token transfer helpers -│ │ ├── event_ops.rs # canonical create / submit / select_winners -│ │ ├── hackathon.rs # pillar-specific validation -│ │ ├── bounty.rs # pillar-specific validation + apply / withdraw -│ │ ├── grant.rs # pillar-specific validation + claim_milestone -│ │ ├── crowdfunding.rs # pillar-specific validation -│ │ ├── profile_client.rs # cross-contract interface to boundless-profile -│ │ ├── idempotency.rs # OpSeen helpers + deployment-epoch ID base -│ │ └── tests/ # per-area integration tests -│ └── profile/ -│ ├── Cargo.toml -│ ├── Makefile -│ └── src/ -│ ├── lib.rs # contract entry, public surface -│ ├── types.rs # Profile, PendingAdmin, PendingUpgrade -│ ├── errors.rs -│ ├── events.rs -│ ├── storage.rs -│ ├── admin.rs # init, two-step rotations, pause, timelocked upgrade -│ ├── bootstrap.rs # lazy profile creation (bootstrap / bootstrap_self) -│ ├── reputation.rs # bump, slash, admin_slash -│ ├── earnings.rs # per-token earnings registration -│ ├── idempotency.rs # OpSeen helpers -│ └── tests/ -└── scripts/ - ├── admin/ # verify-multisig.sh - └── deploy/ # deploy.sh, register_token.sh +│ ├── events/src/ # lib.rs, admin.rs, event_ops.rs, grant.rs, escrow.rs, +│ │ # token_whitelist.rs, idempotency.rs, profile_client.rs, +│ │ # storage.rs, types.rs, errors.rs, events.rs, tests/ +│ ├── profile/src/ # lib.rs, admin.rs, bootstrap.rs, reputation.rs, earnings.rs, +│ │ # idempotency.rs, storage.rs, types.rs, errors.rs, events.rs, tests/ +│ └── compatibility/ # storage compatibility suite and its fixtures +├── scripts/ +│ ├── deploy/ # deploy.sh, register_token.sh, verify.sh (testnet, futurenet) +│ ├── admin/ # verify-multisig.sh, verify-fee-trustline.sh +│ ├── lib/deploy-env.sh # shared helpers for the deploy scripts +│ ├── testnet/ # grant-scenarios.ts, a testnet grant lifecycle run +│ ├── testdata/ # Horizon fixtures and the mock stellar CLI +│ ├── check-versions.sh +│ ├── test-admin-scripts.sh +│ ├── test-mainnet-upgrade-guards.sh +│ ├── test-storage-compat.sh +│ └── capture-mainnet-compat-snapshot.sh +├── docs/ # see below +└── deployments/ # local deploy records, gitignored ``` -## Deployment and upgrades - -Fresh deploy sequence for a new network: - -1. Deploy `boundless-profile` with the admin address. -2. Deploy `boundless-events` with the profile contract's address, admin, fee account, and fee bps. -3. Call `profile.set_events_contract(events_addr)` (first-set-only; later rotation is a timelocked two-step). -4. Register supported tokens on `boundless-events`. - -Upgrades are timelocked three-step operations: `propose_upgrade(wasm_hash, version)`, wait the timelock (~1 day on mainnet), `apply_upgrade()`, then `migrate()` to stamp the migration marker. A pending proposal can be inspected with `get_pending_upgrade()` and withdrawn with `cancel_pending_upgrade()`. - -## Contributing - -See `CONTRIBUTING.md`. +## Docs + +| Doc | What it is for | +|-----|----------------| +| `docs/ARCHITECTURE.md` | What lives on chain and why, roles, cross-contract calls | +| `docs/DEPLOYMENT.md` | Fresh deploy on testnet or mainnet, token registration, admin rotation | +| `docs/upgrade-runbook.md` | Upgrading live contracts, including mainnet 1.7.0 to 2.0.0 | +| `docs/contract-ops-runbook.md` | Building, simulating, signing and submitting admin transactions | +| `docs/multisig-guide.md` | Plain-English guide for signers; provisioning the multisig | +| `docs/admin-custody-policy.md` | Custody policy: signers, thresholds, rotation, release validator | +| `docs/threat-model.md` | Threat model for the 2.0.0 contracts | +| `docs/scout-audit-report.md` | Static analysis report | +| `docs/storage-compatibility.md` | The storage compatibility suite | +| `docs/dune-analytics.md` | Emitted events and Dune queries (`docs/dune-queries/`) | +| `docs/push-payouts-feasibility.md` | Proposal: paying prizes at selection again | +| `docs/audit/testnet-grant-run.md` | Latest testnet grant scenario run | +| `docs/history/` | Finished procedures and the June 2026 audit, kept for the record | + +Follow-ups live in `BACKLOG.md`. Contributing: `CONTRIBUTING.md`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dfa9806..cd715f3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,23 +4,24 @@ There are two coherent ways to build a contract for a platform like Boundless: -**Custody-only on-chain.** Contract holds money. Everything else (event records, submissions, winners, reputation) lives off-chain in Postgres. The platform is the source of truth. Cheap to audit; useless for transparency; reintroduces all the trust we're trying to remove. +**Custody-only on-chain.** Contract holds money. Everything else (event records, winners, reputation) lives off-chain in Postgres. The platform is the source of truth. Cheap to audit; useless for transparency; reintroduces all the trust we're trying to remove. -**Custody plus the records that matter on-chain.** Contract holds money, event existence, key terms, submission anchors, winner records, and reputation. The platform is the source of truth for everything that benefits from iteration speed (descriptions, KYC, role policies, AI) but cannot edit the things that demand transparency. +**Custody plus the records that matter on-chain.** Contract holds money, event existence, the terms that decide who is owed what, winner awards, and reputation. The platform is the source of truth for everything that benefits from iteration speed (descriptions, applications, KYC, role policies, AI) but cannot edit the things that demand transparency. We took the second path. This document explains the boundary. -The boundary has moved once: through contract version 1.0.0, per-user credit balances also lived on-chain in the profile contract. The 1.0.0 → 1.1.0 upgrade (2026-06) removed them. Credits are a product-pricing lever, not a settlement promise — they were being retuned at product speed, which is exactly the iteration-speed test above. They now live in an off-chain ledger in boundless-nestjs; the profile contract holds only reputation scores and per-token earnings. +The boundary has moved twice, both times toward the platform. Through 1.0.0 the profile contract held per-user credit balances; 1.1.0 removed them, because credits are a pricing lever that product retunes often, which is exactly the iteration-speed test above. Through 1.7.0 the events contract also held applicant and submission records (`apply`, `apply_to_bounty`, `submit` and their registries); 2.0.0 removed them, because no payout ever read them: what the chain enforces is the award recorded at selection. Both now live in boundless-nestjs. ## What goes on-chain | Family | Lives in | Why | |--------|----------|-----| -| Event existence (id, owner, pillar, token, total_budget) | events contract | Organizers cannot quietly delete events that went badly. | -| Event key terms (release_kind, deadline, winner_distribution) | events contract | These define what is owed to whom; mutating them off-chain would let the platform rewrite outcomes. | -| Submission anchors (applicant, content_uri, timestamp) | events contract | Tamper-proof participation history. | -| Winner records (recipient, position, amount, milestone, paid_at) | events contract | The promise of "you won this much" has to be enforced by the chain. | -| Escrow balance and fee withholding | events contract | The platform cannot hold the keys to event funds. | +| Event existence (id, pillar, owner, token, total budget, title, content URI) | events contract | Organizers cannot quietly delete events that went badly. | +| Terms that decide payouts (release kind, prize floors, fee override) | events contract | A published prize table is a guarantee: `select_winners` may pay above a position's floor but never below it. Mutating these off-chain would let the platform rewrite outcomes. The deadline is stored for reference and not enforced. | +| Winner awards, the grant roster, and payment state (recipient, position, amount, milestone, paid_at) | events contract | The promise of "you won this much" has to be enforced by the chain. | +| Contributors and their amounts | events contract | Refunds on cancellation are computed from them. | +| Escrow balance, owed total, and fee withholding | events contract | The platform cannot hold the keys to event funds. | +| Event manager delegation | events contract | Selection and cancellation authority must be provable. | | Token whitelist | events contract | Admin-managed; on-chain so the whitelist is auditable. | | Reputation scores | profile contract | The platform cannot inflate or deflate reputation. | | Per-token earnings | profile contract | The promise of "you've earned this much" matches the chain's record of releases. | @@ -30,42 +31,44 @@ The boundary has moved once: through contract version 1.0.0, per-user credit bal | Family | Why | |--------|-----| +| Applications, submissions and entries | Moved off chain in 2.0.0. Judging works from them, but no payout reads them; the award recorded at selection is what the chain enforces. | | Rich content (descriptions, banners, write-ups, comments) | Stored in S3, referenced by content_uri. Cheap to mutate; on-chain would burn storage. | | Draft state | Drafts never hit the contract. Only published events do. | | KYC tiers and verification details | PII. | | Role policies and per-event reviewer assignments | Iterate rapidly without contract upgrades. | -| Credit balances and credit charges (application costs, refunds) | Off-chain ledger since the 1.1.0 upgrade (2026-06). Credits are pricing policy, not settlement; keeping them on-chain froze product tuning behind contract upgrades. | -| Tier brackets and credit-discount rules | Off-chain policy that interprets on-chain scores; lets product tune without audit. | +| Credit balances and charges | Off-chain ledger since 1.1.0. Credits are pricing policy, not settlement. | +| Tier brackets and other rules that interpret on-chain scores | Lets product tune without an audit. | | AI features (organizer assist, judging assist, similarity detection) | Out of scope for any contract. | | Moderation queue, notifications, analytics, exports | None of these benefit from immutability. | -| Indexed event queries | Linear-scan getters in the contract burn cost; off-chain mirror serves them cheaply. | +| Indexed event queries | Linear-scan getters in the contract burn cost; an off-chain mirror serves them cheaply. | ## Why two contracts and not one -Soroban's compiled WASM size limit is 64 KB. The platform contract that holds events + escrow + multi-token + idempotency lands around 50 KB once all operation bodies are implemented. Adding reputation and earnings operations would push it over. +Soroban's compiled WASM size limit is 64 KB. The events contract, with escrow, multi-token support, idempotency and every pillar's flows, already sits close to it. Adding reputation and earnings would push it over. -The natural split line: events are per-event; profile is per-user. Cross-family operations (apply bootstraps the applicant's profile; select_winners bumps reputation and registers earnings) happen at the events-to-profile boundary, where the events contract calls the profile contract with explicit auth. +The natural split line: events are per-event; profile is per-user. Cross-family operations (a payout bumps the winner's reputation and registers the earnings) happen at the events-to-profile boundary, where the events contract calls the profile contract with explicit auth. Three contracts would have been over-engineering. Two contracts split is the smallest cut that keeps both under the ceiling while keeping cross-contract calls bounded. ## The cross-contract dance ``` -event owner require_auth() in events contract (select_winners) - events contract holds the binding for the profile contract address - events contract calls profile.bump_reputation(winner, delta, "win", child_op_id) - events contract calls profile.register_earnings(winner, token, amount, child_op_id) +winner signs claim_prize (or the owner signs claim_milestone) in the events contract + events contract pays the recipient from escrow + events contract calls profile.bootstrap(recipient, child_op_id) + events contract calls profile.bump_reputation(recipient, delta, "win", child_op_id) + events contract calls profile.register_earnings(recipient, token, amount, child_op_id) profile contract verifies events_contract.require_auth() profile contract mutates reputation / earnings, emits events ``` -Users cannot call profile mutations directly. The profile contract verifies the caller is the registered events contract address (or admin for direct grants and slashes). The `events_contract` binding in the profile contract is two-step rotatable so the events contract can be upgraded or replaced without re-deploying profile. +The profile calls are best-effort: the payment is final, so a profile failure never reverts it. Users cannot call the profile mutations directly; the profile contract only accepts them from its bound events contract, and the admin can only lower a score (`admin_slash_reputation`, with a reason). A user may create their own empty profile with `bootstrap_self`. The binding is set once at deploy and later rotated through a timelocked two-step (`propose_events_contract`, `accept_events_contract`), so the events contract can be replaced without redeploying profile. ## Multichain readiness without multichain code -v1 ships Stellar-only. The Chain Abstraction ADR (`boundless-chain-abstraction-adr.md`) defines what the off-chain orchestrator does to keep the doors open: `chain_id` everywhere in nestjs schema, `ChainAdapter` interface with `StellarAdapter` as the only implementation, per-chain wallets and fee accounts. +v1 ships Stellar-only. The off-chain orchestrator keeps the doors open: chain ids throughout the backend schema, a chain adapter interface with Stellar as its only implementation, and per-chain wallets and fee accounts. -The contracts themselves do nothing for multichain. The only hook is the `profile_contract` binding on the events contract: when profile relocates to a cross-chain-aware deployment (someday), the events contract swaps the address. No event-contract code change. +The contracts themselves do nothing for multichain. The only hook is the `profile_contract` binding on the events contract: if profile ever relocates to a cross-chain-aware deployment, the events contract swaps the address. No events-contract code change. ## Deployment-epoch IDs @@ -78,35 +81,39 @@ fn id_base(env: &Env) -> u64 { } ``` -The off-chain orchestrator's `EventReference` type includes the contract address so a single u64 alone is never enough to point at an event globally. The chain prefix from the ADR completes the global namespacing. +The off-chain orchestrator's `EventReference` type includes the contract address so a single u64 alone is never enough to point at an event globally. ## Idempotency -Every state-mutating op accepts an `op_id: BytesN<32>` derived deterministically off-chain. The contract refuses to replay. The orchestrator interprets the replay error as success-from-prior-attempt. `OpSeen` markers use temporary storage, which auto-expires (Soroban TTL). The orchestrator caps reconciliation at 14 days to stay inside that window. +Every state-mutating op accepts an `op_id: BytesN<32>` derived deterministically off-chain. The contract refuses to replay. The orchestrator interprets the replay error as success-from-prior-attempt. `OpSeen` markers are keyed by the authorizing caller and the `op_id`, so a permissionless call cannot squat a privileged caller's id, and live in temporary storage, which expires on its own (Soroban TTL). The orchestrator caps reconciliation at 14 days to stay inside that window. ## Token whitelist and multi-token -The events contract supports any SAC-compatible token, but only tokens admin-registered with `register_supported_token`. The whitelist is on-chain so the registry is auditable. The pre-flight check on registration verifies the fee account has a trustline for the new token by attempting a zero-amount self-transfer; missing trustline reverts. +The events contract supports any SAC-compatible token, but only tokens the admin registers with `register_supported_token`. The whitelist is on-chain so the registry is auditable. -Launch whitelist: USDC. Phase 2: XLM. Adding a token after launch is documented in the platform contract PRD Section 8.4. +The contract does not check that the fee account can receive a token: a Stellar Asset Contract's `balance()` cannot tell a missing trustline from a zero balance. A missing trustline makes every deposit in that token revert, so the operator checks it off chain before registering a token or rotating the fee account (`scripts/admin/verify-fee-trustline.sh`, which `register_token.sh` and `deploy_mainnet.sh register-token` run). Mainnet whitelists USDC and native XLM. ## Authority model -Three roles, no overlap: +| Role | Who | Can | +|------|-----|-----| +| Admin | The platform's 2-of-3 multisig | Rotate the admin (two-step), set the fee rate and fee account, register and deregister tokens, change the profile binding, pause and unpause, upgrade through the timelock, appoint or clear the release validator. Cannot move escrow. | +| Event owner | The organizer, set at `create_event` | Fund the event, release grant and crowdfunding milestones (`claim_milestone`), forfeit a grant milestone it rejects, take management back before the first award (`reclaim_management`). Manages the event itself until it delegates. | +| Event manager | Optional, per event | Whoever currently manages (the owner at first) proposes a successor with `propose_manager`, which takes over once it accepts within 17,280 ledgers. Selects winners, starts a cancellation, delegates onward. Never holds custody: refunds route to the owner and contributors by construction. | +| Release validator | Optional, platform-wide | Co-signs every crowdfunding milestone release with the campaign owner. The admin proposes it and the key itself accepts within 120,960 ledgers; the admin can clear it at once, and co-signs itself while none is appointed. Decides when a release happens, never where the money goes or how much. | +| Winner | A recorded award's recipient | Collect its own prize with `claim_prize`. | +| Contributor | Anyone who adds funds | `add_funds`, and `claim_refund` for a refund the contract had to hold back. | +| Anyone | No signature | Drive a started cancellation forward (`process_cancel_batch`, `finalize_cancel`), so an absent owner or manager cannot strand it. | -- **Admin.** Platform deployment multisig. Can rotate admin, change fee bps, change fee account, register and deregister tokens, change profile contract binding, pause and unpause, upgrade. All admin ops require `admin.require_auth()`. -- **Event owner.** The organizer, set at create_event. Can cancel their event, select winners, claim milestones. `owner.require_auth()` on every owner op. -- **Applicant.** Per user. Can apply, submit, withdraw application, withdraw submission for themselves. `applicant.require_auth()` on every applicant op. - -Admin rotations are two-step (set_admin then accept_admin within 7 days at testnet cadence). Same pattern for profile-contract binding rotation. Prevents accidental key changes. +Every role-gated call uses `require_auth()` on that role's address. Admin rotations are two-step (set_admin then accept_admin within 120,960 ledgers), the same pattern as the profile binding and the release validator, so a typo or a key nobody holds can never take over. ## Pause -Admin can pause the entire events contract or the entire profile contract. Pause blocks every state-mutating op except admin ops. Reads continue. Designed for emergency response, not routine operation. +The admin can pause the events contract or the profile contract. Pause blocks every event-lifecycle call; reads and admin calls continue, so an upgrade can run while paused. Designed for emergency response and upgrade windows, not routine operation. ## Upgrade -Standard Soroban `update_current_contract_wasm`. Storage layout migrations are the caller's responsibility. Soroban does not move data automatically; we plan storage shapes to be additive across upgrades. +Upgrades are timelocked. The admin proposes a wasm hash and version (`propose_upgrade`), and `apply_upgrade` swaps the code no earlier than 17,280 ledgers (about a day) later on mainnet builds; testnet builds compile the wait to zero. The proposal is public for the whole window and can be cancelled. Storage shapes are planned to be additive; when a layout has to change, as it did for prize floors in 1.7.0, `migrate_events` converts old records in pages and `migrate` refuses to stamp the new version until none remain. The procedure is `docs/upgrade-runbook.md`. ## The 64 KB discipline diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 234b447..ad404e9 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,275 +1,242 @@ -# Deployment runbook +# Deploying the contracts -Deploys the `boundless-events` and `boundless-profile` Soroban contracts to a -Stellar network, wires them together, and emits the env values the nestjs -orchestrator needs. +This covers a fresh deployment of `boundless-profile` and `boundless-events`: +build, deploy, wire the two together, register tokens, verify, and on mainnet +hand admin to the multisig. Upgrading a live deployment is in +`docs/upgrade-runbook.md`; how a multisig transaction is built, signed and +submitted is in `docs/contract-ops-runbook.md`; who may sign what is in +`docs/admin-custody-policy.md`. -Scripts: `scripts/deploy/{deploy,register_token,verify}.sh`. +Testnet and futurenet deploy with `scripts/deploy/*.sh`. Mainnet deploys with +`./deploy_mainnet.sh`, which adds a typed confirmation, checks the CLI network +config, and refuses to deploy over an existing record. ---- +## 1. What a deployment is made of -## 0. Decisions to lock before deploying +| Parameter | Where it goes | +|---|---| +| Admin | Constructor of both contracts. A single CLI identity on testnet. On mainnet a throwaway deployer key that hands admin to the 2-of-3 multisig straight after the deploy. | +| Fee account | Events constructor (`fee_account`). Receives the platform fee on every deposit and crowdfunding release, so it needs an authorized trustline for every registered token. Keep it separate from the admin. | +| Fee bps | Events constructor (`fee_bps`). 250 is 2.5%. The contract refuses fees above 1000 (10%). | +| Profile binding | Events constructor (`profile_contract`), then `profile.set_events_contract(events)`. The second call works once; later changes go through the timelocked rotation in `docs/upgrade-runbook.md`. | +| Tokens | `register_supported_token` on events, one call per token. | -Each of these is a per-network parameter the contracts are constructed with. -Pick once per environment and document. +The profile constructor takes only `--admin`. The events constructor takes +`--admin --fee_account --fee_bps --profile_contract`, so profile is always +deployed first. -| Parameter | Where it goes | What it does | -|---|---|---| -| **Network** | `stellar --network` | testnet / futurenet / mainnet. | -| **Admin identity** | both contracts' `admin` | controls `set_admin`, `set_fee_bps`, `pause`, `upgrade`, `register_supported_token`. **Use a Stellar multisig account for mainnet.** Testnet can be a single key. | -| **Fee account** | events `fee_account` | G-address that receives the platform fee on every deposit. Must hold a trustline for every registered token. **Should be a separately-keyed account from admin.** | -| **Fee bps** | events `fee_bps` | Basis points. 100 = 1%, 250 = 2.5%. Contract caps at 1000 (10%) per audit L4. | -| **USDC asset address** | events `register_supported_token` | Token contract (SAC) address. See Section 4. | +Each deploy writes a local record to `deployments/.json` (and +mainnet upgrades append to `deployments/mainnet-upgrades.jsonl`). The +`deployments/` directory is gitignored: the records live on the operator's +machine, so keep a copy with the team's ops notes. Published contract ids are +in the README. -> Contract versions up to 1.0.0 also took a `default_bootstrap_credits` constructor parameter on the profile contract. The 1.1.0 upgrade (2026-06) removed on-chain credits — they are now an off-chain ledger in boundless-nestjs — so the profile constructor takes only `admin`. +## 2. Toolchain -Production values land in `deployments/.json` after `deploy.sh` runs. +- Rust 1.93.0 with the `wasm32v1-none` target (`rust-toolchain.toml` pins it). +- soroban-sdk 28.0.0 (workspace `Cargo.toml`). +- Stellar CLI 28.1.0, the version CI pins; `deploy_mainnet.sh` refuses any + other. `cargo install --locked stellar-cli@28.1.0`. +- `jq`. ---- +Build with `stellar contract build --locked`. A plain +`cargo build --target wasm32v1-none` is refused by SDK 28. Testnet and +futurenet builds add `--features testnet`, which sets the upgrade timelock to +0 ledgers; mainnet builds leave it off and carry the 17,280-ledger timelock. +The scripts choose the right build for the network. -## 1. Prerequisites (one-time per workstation) +## 3. Testnet and futurenet -```sh -# stellar CLI — version 26.0.0 or newer required. -# Older CLIs reject Rust 1.90.0 wasm32v1-none output with -# "reference-types not enabled" at simulation time. -brew install stellar/tap/stellar-cli # or cargo install --locked stellar-cli@26.1.0 -stellar --version # confirm >= 26.0.0 - -# rust toolchain -rustup target add wasm32v1-none - -# verify build still passes locally -cd contracts/events && stellar contract build --locked && cd ../.. -cd contracts/profile && stellar contract build --locked && cd ../.. -``` - -### Create the admin identity (per network) +### 3.1 Identities ```sh -# Generates and funds a testnet admin identity via friendbot. stellar keys generate boundless-admin --network testnet --fund - -# Show its G-address. -stellar keys address boundless-admin -``` - -For mainnet, generate without `--fund`, send XLM manually, and convert the -account to a multisig before any contract deploy. See the wallet runbook in -`boundless-infra/`. - -### Create or pick the fee account - -```sh stellar keys generate boundless-fee --network testnet --fund -stellar keys address boundless-fee # -> G... ; this goes into FEE_ACCOUNT +stellar keys address boundless-fee # this is FEE_ACCOUNT ``` -The fee account does NOT need to be a CLI identity (it never signs contract -calls). Treat the value as data; what matters is that it has trustlines for -every token before that token is registered. - ---- +The fee account never signs a contract call. It only needs to exist and hold +trustlines. -## 2. Configure deployment parameters +### 3.2 Parameters ```sh cp .env.deploy.example .env.deploy -$EDITOR .env.deploy +$EDITOR .env.deploy # ADMIN_IDENTITY, FEE_ACCOUNT, FEE_BPS ``` -Required values: +`.env.deploy` is gitignored and is parsed as plain `KEY=VALUE` lines, never +executed. -``` -ADMIN_IDENTITY=boundless-admin -FEE_ACCOUNT=G... # output of `stellar keys address boundless-fee` -FEE_BPS=250 # 2.5% -``` - -`.env.deploy` is gitignored. Do not commit. - ---- - -## 3. Deploy + wire +### 3.3 Deploy and wire ```sh ./scripts/deploy/deploy.sh testnet ``` -The script does, in order: - -1. `stellar contract build` on both contracts. -2. Deploys `boundless-profile` first (events constructor needs its address). -3. Deploys `boundless-events` with `fee_account`, `fee_bps`, and the profile contract id. -4. `profile.set_events_contract(events_id)` so profile recognizes the events contract for cross-contract auth. -5. Writes the deployment record to `deployments/testnet.json`. -6. Prints the env-var lines for the nestjs side. - -Expected output tail: - -``` -==> done. summary written to deployments/testnet.json - -set these in the nestjs deployment env: - BOUNDLESS_EVENTS_CONTRACT_ADDRESS=C... - BOUNDLESS_PROFILE_CONTRACT_ADDRESS=C... +The script builds both contracts with `--features testnet`, deploys profile, +deploys events against it, calls `profile.set_events_contract`, and writes +`deployments/testnet.json` (including `deployer_identity`, which +`./deploy_and_upgrade.sh` uses as its default signer). It prints the two +contract ids for the backend environment. -next: ./scripts/deploy/register_token.sh testnet -``` - ---- - -## 4. Add the fee-account trustline + register the token - -USDC on testnet uses the Circle test issuer. The token contract address -depends on whether you wrap the Stellar Classic asset or use Circle's Soroban -deployment directly. Check Stellar's developer portal for the current testnet -USDC SAC address before running this step. - -### 4a. Add the trustline (off-chain) +### 3.4 Register tokens -The contract's `register_supported_token` does NOT verify the trustline (a -Soroban contract cannot authorize a deeper-than-root call). The admin runbook -covers it instead: +Give the fee account a trustline first, then register: ```sh -# Where boundless-fee is the CLI identity that owns the FEE_ACCOUNT key. -# (If FEE_ACCOUNT is a separate non-CLI account, run this on that account's -# signing setup.) -stellar tx new change-trust \ - --source boundless-fee \ - --network testnet \ +stellar tx new change-trust --source boundless-fee --network testnet \ --line USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 -# Verify: -stellar contract invoke \ - --id CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75 \ - --network testnet \ - -- balance --id "$(stellar keys address boundless-fee)" +./scripts/deploy/register_token.sh testnet \ + CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA \ + USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 ``` -(`CCW67TSZV3SSS...` is the testnet USDC SAC address at the time of writing; -verify against current docs.) +`CBIELTK6…` is the testnet USDC SAC. The script checks that the asset you name +is the one the token contract wraps (`stellar contract id asset`) and runs +`scripts/admin/verify-fee-trustline.sh` before it signs; the contract checks +neither, and a missing trustline makes every `create_event` and `add_funds` in +that token revert. For native XLM pass `native` as the asset and the id that +`stellar contract id asset --asset native --network testnet` prints. -### 4b. Register on the events contract +### 3.5 Verify ```sh -./scripts/deploy/register_token.sh testnet +./scripts/deploy/verify.sh testnet ``` -The script prompts to confirm the trustline exists, then invokes -`register_supported_token`. The record updates `deployments/testnet.json` with -the supported token. - ---- +It prints the record next to each contract's version, migration marker, admin, +fee account, fee bps, bindings, release validator and pause flag. Both admins +should be the deployer, the bindings should point at each other, and neither +contract should be paused. -## 5. Verify +## 4. Mainnet -```sh -./scripts/deploy/verify.sh testnet -``` +### 4.1 Before you start -Cross-checks the deployment record against on-chain state. Both contracts -should report the expected admin, the events contract should report the -profile contract id, the profile contract should report the events contract -address, and both should be `is_paused == false`. +- The release has been through the third-party audit, and the same commit has + been deployed and exercised on testnet. +- The admin multisig is provisioned and passes + `scripts/admin/verify-multisig.sh` with the signer roster pinned + (`docs/multisig-guide.md`, Part E). +- The fee account exists, is funded, and holds an authorized trustline for + every token you will register. +- Two people who can stop the deploy are present for the whole window. -If anything looks wrong, treat it as a deploy regression and re-deploy fresh. -Soroban storage costs are real but minimal at this scale. +### 4.2 Network and environment ---- +Configure a named network once and use only that name. `deploy_mainnet.sh` +checks that it carries the public passphrase and a real RPC URL, and refuses +to run while `STELLAR_RPC_URL` or `STELLAR_NETWORK_PASSPHRASE` is set (mixing +the two breaks RPC resolution; see `docs/contract-ops-runbook.md`, rule 5). -## 6. Update nestjs orchestrator env +```sh +stellar network add boundless-mainnet \ + --rpc-url \ + --network-passphrase "Public Global Stellar Network ; September 2015" +export STELLAR_NETWORK=boundless-mainnet +unset STELLAR_RPC_URL STELLAR_NETWORK_PASSPHRASE -Take the `BOUNDLESS_EVENTS_CONTRACT_ADDRESS` and -`BOUNDLESS_PROFILE_CONTRACT_ADDRESS` lines from `deploy.sh`'s output and put -them in the nestjs deployment environment (Railway, etc.): +stellar keys generate boundless-deployer # throwaway initial admin +stellar keys address boundless-deployer # fund it with about 10 XLM +export INITIAL_ADMIN_KEY=boundless-deployer +export FEE_ACCOUNT=G... # the fee account +export INITIAL_GLOBAL_FEE_BPS=250 ``` -STELLAR_RPC_URL=https://soroban-rpc.testnet.stellar.org -STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015 -BOUNDLESS_EVENTS_CONTRACT_ADDRESS=C... -BOUNDLESS_PROFILE_CONTRACT_ADDRESS=C... -``` -The env schema (`src/config/env-schema.ts`) validates both addresses are -56-char strings starting with `C`. Mistakes surface at app boot. +The deployer key exists only to deploy and rotate. It is destroyed in 4.6. + +### 4.3 Deploy and wire ---- +```sh +./deploy_mainnet.sh deploy-profile +./deploy_mainnet.sh deploy-events +``` -## 7. Apply the Prisma migration +Each command asks you to type `mainnet`, builds its contract without the +testnet feature, deploys it, and records the id and the deployed wasm hash in +`deployments/mainnet.json`. It stops if the deployed hash differs from the +local build. `deploy-events` also calls `profile.set_events_contract`. Either +command refuses to run if the record already names that contract. -Against the active database (dev / staging / production): +### 4.4 Register tokens ```sh -cd ../boundless-nestjs -npx prisma migrate deploy +./deploy_mainnet.sh register-token \ + CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75 \ + USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN ``` -Creates the `escrow_op` table + three enums. Migration file: -`prisma/migrations/20260601213000_add_escrow_op/migration.sql`. +`CCW67TSZ…` is the mainnet USDC SAC. As on testnet, the asset must be the one +the contract wraps and the fee account trustline must pass before anything is +signed. For native XLM pass `native` and the id from +`stellar contract id asset --asset native --network "$STELLAR_NETWORK"`. +`register-token` only works while the deployer is still admin; after the +rotation, register through the multisig (`docs/contract-ops-runbook.md`, +Section 5) and run `verify-fee-trustline.sh` first. -Verify: +### 4.5 Verify, smoke, rotate admin ```sh -npx prisma db pull --print 2>&1 | grep -A 8 "^model EscrowOp" -# or, on the DB directly: -psql "$DATABASE_URL" -c '\d escrow_op' +ADMIN_SOURCE=$(stellar keys address boundless-deployer) ./deploy_mainnet.sh verify ``` ---- +Before rotating, run one small end-to-end flow against the new contracts with +the backend's smoke scripts (see the boundless-nestjs repo, which also owns +the env values, migrations and orchestrator checks that follow a deploy). If +anything is wrong, the deployer can still `pause` both contracts on its own. -## 8. Smoke-test the orchestrator end-to-end - -After the nestjs deployment picks up the new env vars and the migration is -applied: +Then hand admin to the multisig: ```sh -# From a node REPL or a small script in the nestjs repo: -import { EscrowOrchestratorService } from './modules/escrow-contract/...'; - -// Build an unsigned create_event XDR. No tokens move; this only assembles -// the transaction and stores an EscrowOp row. -const op = await orchestrator.beginCreateEvent({ - entityKind: 'HACKATHON', - entityId: 'hck_smoke_test', - params: { /* CreateEventParams */ }, -}); - -console.log(op.opId, op.status, op.unsignedXdr); +EXPECTED_SIGNERS=,, ./deploy_mainnet.sh rotate-admin ``` -The expected state is `PENDING_SIGN` with a non-empty `unsignedXdr`. If the -RPC call fails, check the contract address env vars and that the deployment -record matches what's on chain. +`rotate-admin` runs `scripts/admin/verify-multisig.sh` against the multisig +(and the roster, when `EXPECTED_SIGNERS` is set), asks for the typed +confirmation, and calls `set_admin` on both contracts from the deployer. The +multisig then accepts on each contract. Wait about ten seconds after +`set_admin` so the nomination is in a closed ledger, then build and simulate: ---- +```sh +EVENTS_ID=$(jq -r .events_contract deployments/mainnet.json) +stellar contract invoke --network "$STELLAR_NETWORK" \ + --source-account --id "$EVENTS_ID" --build-only \ + -- accept_admin \ + | stellar tx simulate --network "$STELLAR_NETWORK" \ + --source-account > events-accept-admin.xdr +``` -## 9. Rollback +Sign it sequentially with two signers and submit it with `stellar tx send` +(`docs/contract-ops-runbook.md`, Section 5). Repeat for the profile contract. +Then confirm both admins changed and record the four transaction hashes: -The contract layer has no migration story for breaking changes; a bad deploy -means deploying fresh and pointing the orchestrator at the new addresses. +```sh +ADMIN_SOURCE= ./deploy_mainnet.sh verify +jq --arg a '.admin = $a' deployments/mainnet.json > tmp.json \ + && mv tmp.json deployments/mainnet.json +``` -For the orchestrator, every `EscrowOp` row carries the contract addresses -it targeted (via `signer_hint` + the deployment record); a redeploy creates -new event_ids and a new namespace. +Prove the signing path once with a no-op admin call, for example +`set_fee_bps` to its current value. -Migration rollback: +### 4.6 Destroy the deployer key ```sh -# Reverse the schema migration manually if needed. -psql "$DATABASE_URL" -c 'DROP TABLE escrow_op; DROP TYPE "EscrowOpKind"; DROP TYPE "EscrowOpStatus"; DROP TYPE "EscrowOpEntityKind";' -# Then delete the migration directory and re-generate the Prisma client. +stellar keys rm boundless-deployer ``` -Or, more cleanly, write a follow-up migration that drops the table. +The account keeps any leftover XLM but has no authority over either contract. +Sweep it first if you want the balance back. ---- +## 5. If a deploy goes wrong -## Cross-references +Before the rotation, the deployer pauses either contract directly +(`stellar contract invoke ... -- pause`). After it, pausing is a 2-of-3 +multisig call: `./deploy_mainnet.sh prepare-pause-events `. -- `scripts/deploy/deploy.sh`, `register_token.sh`, `verify.sh` -- `boundless-platform-contract-prd.md` Section 12 (deployment) -- `boundless-payout-prd.md` Section 9.1 (orchestrator integration) -- `boundless-credits-reputation-prd.md` Section 9 (credit policy — implemented as an off-chain ledger in boundless-nestjs since the 1.1.0 upgrade) -- `.env.deploy.example` (per-network parameter template) +A fresh deploy has no in-place undo. If the contracts are misconfigured in a +way the admin setters cannot fix, deploy a new pair, point the backend at the +new ids, and leave the old pair paused. diff --git a/docs/admin-custody-policy.md b/docs/admin-custody-policy.md index 35176cc..0f99fd3 100644 --- a/docs/admin-custody-policy.md +++ b/docs/admin-custody-policy.md @@ -10,8 +10,8 @@ The admin key controls the most sensitive operations on the Boundless contracts: Two phases. Both are 2-of-3 multi-sig; they differ in how the per-signer keys are stored. -- **Launch baseline (current):** software signers — each signer runs Freighter (or an equivalent Stellar wallet) on their own machine, with a strong unique passphrase and a paper-only backup. This ships now. -- **Target state:** hardware-isolated signers per the original policy text below — Yubikey, Ledger, or air-gapped machine. **Hardware upgrade trigger:** when total escrow TVL crosses the threshold set in section 10 below. +- **Launch baseline (current):** software signers. Each signer runs Freighter (or an equivalent Stellar wallet) on their own machine, with a strong unique passphrase and a paper-only backup. This ships now. +- **Target state:** hardware-isolated signers per the original policy text below: Yubikey, Ledger, or air-gapped machine. **Hardware upgrade trigger:** when total escrow TVL crosses the threshold set in section 10 below. Everything else in this document (thresholds, rotation, drills, operational hygiene) applies to **both phases identically.** The verify-multisig script does not care whether keys are hardware or software; it only checks the on-chain signer config. @@ -21,16 +21,19 @@ Section 3 describes the target-state hardware procedure. Section 3-bis describes ## 1. What the admin can do -The admin authority on `boundless-events` (and equivalently on `boundless-profile`) gates: +The admin authority on `boundless-events` gates: -- `set_fee_bps(new_bps)` — change the global default fee rate. -- `set_fee_account(new_account)` — change the address that receives fee revenue. -- `set_profile_contract(new_addr)` — change which profile contract events writes to. -- `set_admin(new_admin)` — rotate the admin authority (two-step with `accept_admin`). -- `pause()` — emergency stop on all write ops. -- `unpause()` — resume write ops. -- `upgrade(new_wasm_hash)` — replace contract logic. -- `register_supported_token(token)` / `deregister_supported_token(token)` — token whitelist. +- `set_fee_bps(new_bps)`: change the global default fee rate. +- `set_fee_account(new_account)`: change the address that receives fee revenue. +- `set_profile_contract(new_addr)`: change which profile contract events writes to. +- `set_admin(new_admin)`: rotate the admin authority (two-step with `accept_admin`). +- `pause()`: emergency stop on every event-lifecycle call. Admin calls keep working. +- `unpause()`: resume them. +- `propose_upgrade(wasm_hash, version)`, `apply_upgrade()`, `cancel_pending_upgrade()`: replace contract logic. Mainnet builds from events 2.0.0 and profile 1.2.1 make `apply_upgrade` wait 17,280 ledgers (about a day) after the proposal. `migrate_events(max_events)` and `migrate()` finish an upgrade (`docs/upgrade-runbook.md`). +- `register_supported_token(token)` / `deregister_supported_token(token)`: token whitelist. +- `propose_release_validator(target)`, `cancel_pending_release_validator()`, `clear_release_validator()`: appoint, withdraw a proposal for, or remove the co-signer for crowdfunding milestone releases (from events 2.0.0; see §4.1). The proposed key must accept with its own signature. While none is appointed, the admin co-signs those releases itself. + +The same multisig is admin of `boundless-profile`, where it holds `set_admin`, `pause` / `unpause`, the same upgrade calls, the timelocked rotation of the profile's events-contract binding (`propose_events_contract`, `accept_events_contract`, `cancel_pending_events_contract`), and `admin_slash_reputation(user, delta, reason)`. There is no other authority. The admin cannot move funds out of escrow directly; the contract enforces that. The admin can only change the rules of future operations and pause current ones. @@ -64,7 +67,7 @@ Three is the smallest set that supports 2-of-N. As the team grows, expand to 5 ( --- -## 3. Key generation and storage (target state — hardware) +## 3. Key generation and storage (target state: hardware) Each signer generates their own key. We do not let any single person see another signer's key. The procedure per signer: @@ -75,9 +78,9 @@ Each signer generates their own key. We do not let any single person see another We do not have a "platform-held" backup. Loss of a signer's key without their personal recovery is a real operational risk; that is why the threshold is 2 of 3 (we can lose one and still recover). -## 3-bis. Key generation and storage (launch baseline — Freighter) +## 3-bis. Key generation and storage (launch baseline: Freighter) -Per signer, in isolation. **The threat model here is "compromised machine" — software keys are extractable from any machine that gets owned.** The hygiene below is what makes this safe-enough to ship and is required, not optional. +Per signer, in isolation. **The threat model here is "compromised machine": software keys are extractable from any machine that gets owned.** The hygiene below is what makes this safe-enough to ship and is required, not optional. 1. **Dedicated browser profile** on a personal machine (not a shared workstation, not a CI runner). No other browser extensions installed in that profile beyond Freighter. No untrusted tabs while signing. 2. **Generate the keypair in Freighter** with a strong, unique passphrase. Recommended: 6-word [Diceware](https://en.wikipedia.org/wiki/Diceware) or equivalent, never reused for any other purpose. Not your 1Password master, not your email password. @@ -115,17 +118,55 @@ The standard threshold is 2 of 3. The following operations have different requir |---|---|---| | `pause()` | **2 of 3 (lower bar)** | Emergency. Cap damage first; recover later. | | `unpause()` | 2 of 3 | Resume after fix is in place. | -| `upgrade(new_wasm_hash)` | 2 of 3 | Standard. Requires both pre-deploy review + audit refresh. | +| `propose_upgrade`, `apply_upgrade`, `cancel_pending_upgrade`, `migrate_events`, `migrate` | 2 of 3 | Standard. A proposal requires pre-deploy review and an audit refresh. | | `set_fee_bps(new_bps)` | 2 of 3 | Standard. | | `set_fee_account(new_account)` | **3 of 3 (higher bar)** | Most-sensitive op. Wrong address sends fees somewhere we cannot retrieve. | | `set_admin(new_admin)` | **3 of 3** | Rotating the admin itself. Highest-stakes change. | | `set_profile_contract` | 2 of 3 | Standard. | | `register_supported_token` / `deregister_supported_token` | 2 of 3 | Routine policy. | +| `propose_release_validator` | **3 of 3 (higher bar)** | Hands a routine co-signing role to a key outside this multi-sig (§4.1). | +| `clear_release_validator`, `cancel_pending_release_validator` | 2 of 3 | Removing or withdrawing returns co-signing to this multi-sig; it must be quick when a validator key is suspect. | The Stellar multi-sig itself does not support per-operation thresholds directly; we enforce these by signer convention with logged sign-off. Tools we build (admin signing portal) will enforce them programmatically. --- +### 4.1 Crowdfunding release validator + +Every crowdfunding milestone release needs the campaign owner's signature and a +co-signature. Before events 2.0.0 the co-signer was this multi-sig, which put +the keys that can upgrade the contract into a routine payout flow. From 2.0.0 +the admin can appoint a release validator to co-sign instead. + +- **What it can do.** Co-sign a release the campaign owner has also signed. The + contract computes the amount and always pays the owner, so the validator + decides when a release happens, never where the money goes or how much. +- **What it cannot do.** Anything else: it holds no admin power and cannot + move escrow on its own. +- **Custody.** A key held by none of the three admin signers. It may be its own + multi-sig (for example the operations team) or a backend-held key under the + same envelope encryption as managed wallets. A backend-held validator means a + backend compromise can approve releases early for campaigns whose owner is + also a managed wallet; choose accordingly. +- **Rotation.** Two steps, like the admin itself. The multi-sig calls + `propose_release_validator(target)` (`ReleaseValidatorProposed`), and the new + key calls `accept_release_validator` with its own signature within 120,960 + ledgers, about 7 days (`ReleaseValidatorUpdated`). Until it accepts, the + current validator, or the admin, keeps co-signing, so a typo or a key nobody + holds can never take over. A new proposal replaces a pending one, and + `cancel_pending_release_validator` withdraws it (`ValidatorProposalCancelled` + in both cases). +- **Removal.** `clear_release_validator` takes effect at once and returns + co-signing to the admin. That is the response to a validator key that is + lost or suspect; do not wait for a replacement to accept first. +- **Backend key.** When the validator key is held by the backend + (`RELEASE_VALIDATOR_SECRET_KEY`, or `_FILE`), the admin portal releases an + approved milestone in one call; otherwise whoever holds the key signs the + built release at the Lab. Accept the appointment with the same key the + backend is configured with. +- **Monitoring.** Alert on every `ReleaseValidatorProposed`, + `ReleaseValidatorUpdated` and `ValidatorProposalCancelled`. + ## 5. Signer rotation ### 5.1 Planned rotation (signer leaves the team) @@ -137,7 +178,7 @@ The Stellar multi-sig itself does not support per-operation thresholds directly; 5. New multi-sig accepts via `accept_admin`. 6. Verify on-chain via `get_admin`. 7. Outgoing signer destroys their key copy. -8. Document the rotation in `deployments/admin-rotations.jsonl`. +8. Document the rotation in the operator's local log, `deployments/admin-rotations.jsonl` (`deployments/` is gitignored; keep a copy with the team's ops notes). ### 5.2 Emergency rotation (signer key compromised or lost) @@ -183,9 +224,9 @@ For every admin operation: - [ ] Written request in `#ops-admin-requests` Slack channel with the proposed operation and reasoning. - [ ] Each signer confirms review in the same channel before signing. -- [ ] Signers coordinate on a Soroban CLI invocation; or use an admin signing portal (when built). +- [ ] The founder prepares a simulated transaction with the Stellar CLI (`docs/contract-ops-runbook.md`, Section 5) or the admin signing portal (when built), and every signer decodes it before signing. - [ ] On-chain operation is verified by the requester before the channel thread is closed. -- [ ] Operation logged in `deployments/admin-operations.jsonl`. +- [ ] Operation logged in the operator's local `deployments/admin-operations.jsonl`. This is not paranoia; it is a paper trail. Every fee change, every upgrade, every signer rotation has an auditable record. @@ -230,7 +271,7 @@ When the trigger fires, the upgrade procedure is: 3. Create a NEW multi-sig account with the three NEW hardware-backed addresses; verify with `./scripts/admin/verify-multisig.sh mainnet`. 4. Run the rotation per section 5.1 (`set_admin(new_multisig) → accept_admin`). 5. Destroy the software keys on each signer machine. Burn the paper backups. -6. Run the testnet drill on the new hardware multi-sig per `docs/multisig-preflight.md` §4. +6. Run the testnet drill on the new hardware multi-sig per `docs/multisig-guide.md` §E.10. 7. Log the rotation in `deployments/admin-rotations.jsonl` with both old and new multi-sig addresses + the threshold that triggered. The thresholds above are starting points. Review them at the same cadence as the annual policy review, or sooner if the team's risk tolerance changes. diff --git a/docs/audit/testnet-grant-run.md b/docs/audit/testnet-grant-run.md new file mode 100644 index 0000000..20eb256 --- /dev/null +++ b/docs/audit/testnet-grant-run.md @@ -0,0 +1,524 @@ +# Testnet grant run + +Run 2026-10-03T09:54:26.273Z against `CBEODVJGUYCIYTVXD7KI5UG3BJ2UE4T7AGI2TGY3T4Q5GQRFGTRYVTZP` with the BGT test asset `CBEOUEMDTM56NRTOOSXCPGMD33RPD5YWEKJ542HAEVREAYCC4QIZ3XIU`. +317 of 317 checks passed across 154 transactions. +Highest fee paid by one transaction: 9960118 stroops. + +## setup + +- pass: BGT CBEOUEMDTM56NRTOOSXCPGMD33RPD5YWEKJ542HAEVREAYCC4QIZ3XIU is whitelisted on CBEODVJGUYCIYTVXD7KI5UG3BJ2UE4T7AGI2TGY3T4Q5GQRFGTRYVTZP (2.0.0) + + - [provision 2 accounts](https://stellar.expert/explorer/testnet/tx/43303068e112738cf13833744066d00d229a70e5c25c51f502226416039ada3a) + - [provision 4 accounts](https://stellar.expert/explorer/testnet/tx/002146647b62635b45fb6f35cdee157d54c93fd8abbe4a4539b5e51c07f720f7) + - [provision 15 accounts](https://stellar.expert/explorer/testnet/tx/554d2ba1cefa32940a2d7151a747a585298f750f20f51ddfb37fb9f6e553273b) + - [provision 15 accounts](https://stellar.expert/explorer/testnet/tx/5c0f71439b56b697d412fc30dac58486cec7045a652864e6ced75e152153a6f9) + - [provision 15 accounts](https://stellar.expert/explorer/testnet/tx/5300389140df301654a3d58a810899819c71288f8c465b915eecf132689ebe0a) + - [provision 7 accounts](https://stellar.expert/explorer/testnet/tx/742b0face71117cc9d21727840dcfaf37352cc7f038f606aa64d0760b788713d) + - [provision 3 accounts](https://stellar.expert/explorer/testnet/tx/daa6c6ba64cf718074bdce4e62480376b94fc63b48667de2503135cf6d60b23b) + +## micro grant + +- pass: publish the grant +- pass: owner paid budget plus fee +- pass: select three recipients +- pass: release milestone 0 to GBLCHA +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GAIEF4 +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GANEUH +- pass: milestone 0 paid exactly +- pass: completes when the last award is paid + + - [create_event](https://stellar.expert/explorer/testnet/tx/c78aaee5c34906b7fa8ed87b43c9af097423dd4653db3929dd86c5fb45a15757) + - [select_winners](https://stellar.expert/explorer/testnet/tx/9d7c855a6d47a18d332a326b8422d45bdf2de7f1dab54e225ae6c7fc9b7117ad) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/b00670f6823f92661ffbd429ba12fcd14ef59065edc60c3afd7cce59d8240ce0) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/5297057a05fca836240612f38e82c6ec32ce40447fcb0666ab80d28de4770845) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/9c1754c5c4fee7a0b6fb776d2ad393f30c0e0ca771d5ffc5f45fe3a63b07c795) + +## tiered, rounding, any order + +- pass: publish the grant +- pass: owner paid budget plus fee +- pass: select three tiers +- pass: release milestone 2 to GALEW6 +- pass: milestone 2 paid exactly +- pass: release milestone 0 to GB6AIZ +- pass: milestone 0 paid exactly +- pass: release milestone 1 to GCYQYI +- pass: milestone 1 paid exactly +- pass: release milestone 2 to GB6AIZ +- pass: milestone 2 paid exactly +- pass: release milestone 0 to GALEW6 +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GCYQYI +- pass: milestone 0 paid exactly +- pass: release milestone 1 to GB6AIZ +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GALEW6 +- pass: milestone 1 paid exactly +- pass: release milestone 2 to GCYQYI +- pass: milestone 2 paid exactly +- pass: tier 1 received its whole award +- pass: tier 2 received its whole award +- pass: completed + + - [create_event](https://stellar.expert/explorer/testnet/tx/b00550c3c7ca97ee60501bfd868513fb38c97699f9669abce3dd1d164e9e693d) + - [select_winners](https://stellar.expert/explorer/testnet/tx/d881b76e9dabefe4ea05f37ab701935be769165efda12a2f3eb3a981c5f50108) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/e109535e089af6a1c9cf81792554aab640bf4c2843bcf0357dc1c1596f2c3ba2) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/b756afc0925d0d67f9fefefa8b9284c06d30c2eff13c9f14fc3bf09b764e916e) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/0f62f243970658b4da8b42a06d1e82b180a5d031d4b1ec1168ab6606d741e415) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/5cbb43cf4f5f72243323197a7c2ef88adaa0a142a02bb38db646d5769a075dc7) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/d135646c70434dee93b2e08f19ce6ac7cf9021f4ac8150d576654e30d2602990) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/c649fe3cfb2d5df62cf56f2399fb0948bfd4fc67f951b22ef6ce104a85849f1b) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/c0a600718528b2464814d3dd57d3479336aeb0f43e48f513648cd5b8b4e79d7c) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/754a43cfd99536d09432f7993dd8a871273eeee07372f27306047a3c9959ddff) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/f37c7bee0109eabd5c3c7f834af3ac694677e2ebbf99a3096efa32d6d3b4b67b) + +## forfeit and the cancel guard + +- pass: publish the grant +- pass: owner paid budget plus fee +- pass: select one recipient +- pass: release milestone 0 to GCJZKP +- pass: milestone 0 paid exactly +- pass: forfeit milestone 1 +- pass: a forfeit pays nothing +- pass: cancel refused while an award is owed (AwardsOutstanding) +- pass: release milestone 2 to GCJZKP +- pass: milestone 2 paid exactly +- pass: start cancel +- pass: grant cancelled +- pass: forfeited share returned to the owner + + - [create_event](https://stellar.expert/explorer/testnet/tx/77003b2f998fa3cf955e76d01700569077bdaabe5c8257a8dbfc4cc8c83ab876) + - [select_winners](https://stellar.expert/explorer/testnet/tx/6af3ca56e39cb1a97040437e61ccc363c9462c7ac7ae51f6acfc616295afbfac) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/5eccc98f1df853ad1f2e1b56933e8547fc4cf6f030d650e2fa0c9585c23a5e52) + - [forfeit_milestone](https://stellar.expert/explorer/testnet/tx/2beb87c625d7fa2de5fe4a417a759fdadf7e63959962175dea54e64e260cc192) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/fde369b7583129da470cc337e8ff7524fb9bf2dffde6718bc73c5bf2203aef8b) + - [start_cancel](https://stellar.expert/explorer/testnet/tx/c61138719114b9641ed630e8ec0c73c016a4341a836807e9ce0afc9af58c41ca) + +## partners, pro rata + +- pass: publish the grant +- pass: owner paid budget plus fee +- pass: partner adds 20.0000000 +- pass: partner paid amount plus fee +- pass: award more than the budget, funded by the partner +- pass: release milestone 0 to GDZSRB +- pass: milestone 0 paid exactly +- pass: partner adds 15.0000000 +- pass: partner paid amount plus fee +- pass: release milestone 1 to GDZSRB +- pass: milestone 1 paid exactly +- pass: the late top-up keeps the grant open +- pass: start cancel +- pass: an empty refund batch is refused (InvalidBatchSize) +- pass: anyone can crank +- pass: anyone can finalize +- pass: partner 1 refunded pro rata +- pass: partner 2 refunded pro rata +- pass: owner gets nothing when partners are short + + - [create_event](https://stellar.expert/explorer/testnet/tx/d3d47c20da243c51c3b3278024b82bd0d4444085e61ac2d3dc038ab74b942989) + - [add_funds](https://stellar.expert/explorer/testnet/tx/cc75425875de191ddd8580e1edced083a529954f12b855f68d8e253334b7cdf3) + - [select_winners](https://stellar.expert/explorer/testnet/tx/c02a6528a76c8168a4ead1fa0d4ba87144e1ad7fd170e4b7c745986b31802648) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/c55d83171df79dcc30f0ca4eb225656193bf5fac7fc0254c20bfc923ff214c5c) + - [add_funds](https://stellar.expert/explorer/testnet/tx/7b85bffe6de0c88e0df70e97787e3486bb7ad4adfa9dfaf6dd7a015c9067703e) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/439bf8db026b93b4a1b821985a3899146f3c5ef956c2e60ae6e1999db377b084) + - [start_cancel](https://stellar.expert/explorer/testnet/tx/a81c1a0ad8cb87c17e0d5cbae268d692ad0d699cce4a8de3a45a5b0bcc2eb290) + - [process_cancel_batch](https://stellar.expert/explorer/testnet/tx/d8db627f692452c126ba4b0add8eb7f1c2154949991c679da59e00d93ab0f55a) + - [finalize_cancel](https://stellar.expert/explorer/testnet/tx/44416739a3d8f722722586abd78722174acbc00c823700f5e86f2de2a9d2a2ca) + +## frozen partner + +- pass: publish the grant +- pass: owner paid budget plus fee +- pass: partner adds 10.0000000 +- pass: partner paid amount plus fee +- pass: partner adds 10.0000000 +- pass: partner paid amount plus fee +- pass: start cancel +- pass: refund batch +- pass: finalize cancel +- pass: grant cancelled +- pass: other partners are refunded +- pass: owner residual paid +- pass: frozen partner refund held +- pass: a frozen account cannot take its refund yet (HostError: Error(Contract, #11)) +- pass: refund claimed after unfreezing +- pass: frozen partner made whole +- pass: a second claim is refused (NoRefundOwed) + + - [create_event](https://stellar.expert/explorer/testnet/tx/16a9363b0b6c8c783e6d7a185fed67965505b943d354d2feed492c20ec218004) + - [add_funds](https://stellar.expert/explorer/testnet/tx/ce27b401a530afba374f8852a76ada34146239181c7be39fbe5225c6177461c9) + - [add_funds](https://stellar.expert/explorer/testnet/tx/41cb91b5fc940f953e813ba422fd697a90418b82dcd35d5bfd60f4296f808eaa) + - [freeze](https://stellar.expert/explorer/testnet/tx/bc6b9e9167e162ab615f64eb6ed7989c986d830b27780306365b8c8880a5473d) + - [start_cancel](https://stellar.expert/explorer/testnet/tx/64f07cb54e3516be8628bd0019702c4f47389d844df751cabd09181b6a6f0a50) + - [process_cancel_batch](https://stellar.expert/explorer/testnet/tx/83059189b58b0fc9d60ab710d549967b496e015ceead6ab2f3d8a30ba29c6ddc) + - [finalize_cancel](https://stellar.expert/explorer/testnet/tx/abc9c8514cda57bf11bb1789dba56c60d8cd3eff948024512cdd722a597dbf5a) + - [unfreeze](https://stellar.expert/explorer/testnet/tx/8d3bf81c3c92e6e4432cfa19d458021241cd0060cf0a690c0c00908b2a865ffa) + - [claim_refund](https://stellar.expert/explorer/testnet/tx/2a5beecc43a8abaec0fa01ab31e49d0dea6b70cda95ccbc377b286aa341891df) + +## refusals + +- pass: publish the grant +- pass: owner paid budget plus fee +- pass: one recipient, two awards (DuplicateRecipient) +- pass: award smaller than its milestones +- pass: reputation bump over the cap +- pass: more than 40 recipients +- pass: awards beyond the escrow +- pass: select +- pass: a second selection +- pass: milestone out of range +- pass: someone never selected +- pass: release milestone 0 to GDVTCZ +- pass: milestone 0 paid exactly +- pass: the same milestone twice +- pass: release with an op id +- pass: the op id replayed +- pass: reputation bump over the cap on release +- pass: claim_prize is not a grant path +- pass: cancel refused while owed +- pass: forfeit milestone 2 +- pass: a forfeit pays nothing +- pass: forfeit milestone 3 +- pass: a forfeit pays nothing +- pass: start cancel +- pass: grant cancelled + + - [create_event](https://stellar.expert/explorer/testnet/tx/7e028af44aae9cd078f926107acdc1b33c27cefda7a74275ae9b919d055ef886) + - [select_winners](https://stellar.expert/explorer/testnet/tx/342b6b2f998ff324e296d72f00afa05e667646c081da8162b41df687a8157879) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/ca8c1cc89ab2b408e37639c6ce0c396c9e51bb10963a18afb7c7d59dc0135df4) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/2741ae25686d1e147a9775ddac68139d86c0c516815aace0485d9c5138326b5b) + - [forfeit_milestone](https://stellar.expert/explorer/testnet/tx/373f3be1be07819fb54874a3d5a4c5d49e9833260b4314eb10def97ee278615b) + - [forfeit_milestone](https://stellar.expert/explorer/testnet/tx/0c4997179369b379ad780f637e2eddab38a69f004ac619f6947e89033abc59d3) + - [start_cancel](https://stellar.expert/explorer/testnet/tx/c09ee7faac9cef813205ee2feb4e750eb1efb319d93587fd1dc6c4b3401fc5bc) + +## signatures + +- pass: publish the grant +- pass: owner paid budget plus fee +- pass: a stranger cannot select (HostError: Error(Auth, InvalidAction)) +- pass: owner selects +- pass: a recipient cannot release their own milestone (HostError: Error(Auth, InvalidAction)) +- pass: nothing moved +- pass: release milestone 0 to GDRPDG +- pass: milestone 0 paid exactly + + - [create_event](https://stellar.expert/explorer/testnet/tx/2b71a63beb441c4b34246d0eb965bea12a46e4a5b801fb7f44a5e32389d46944) + - [select_winners](https://stellar.expert/explorer/testnet/tx/05feda61bb92c1421e4346cbb5cafdc26709ea0f26a56a591b26177cc4abc290) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/a31ff6bb819736552d8ab7efa435ca415782760281b3d9eb7eab8c13eb467d1d) + +## manager handover + +- pass: publish the grant +- pass: owner paid budget plus fee +- pass: manager accepts +- pass: manager recorded +- pass: the owner no longer selects (HostError: Error(Auth, InvalidAction)) +- pass: the manager selects +- pass: no reclaim after selection +- pass: the manager cannot release (HostError: Error(Auth, InvalidAction)) +- pass: release milestone 0 to GBK2R7 +- pass: milestone 0 paid exactly +- pass: release milestone 1 to GBK2R7 +- pass: milestone 1 paid exactly +- pass: publish the grant +- pass: owner paid budget plus fee +- pass: second manager accepts +- pass: owner takes management back +- pass: the owner manages again +- pass: owner selects after reclaiming +- pass: release milestone 0 to GBK2R7 +- pass: milestone 0 paid exactly + + - [create_event](https://stellar.expert/explorer/testnet/tx/9502d33390c6cbeda86184dbceb57850720f9ecdd411d799403acd00aa7fda55) + - [accept_manager](https://stellar.expert/explorer/testnet/tx/e5a5bd071a6f181d78402a3e3548ec112ef4d9af44cc36da9ebd3c21ac099db0) + - [select_winners](https://stellar.expert/explorer/testnet/tx/a93f3c8a13e5431b26fa0a03cb8d4024513b476084fe8e871ea599f3509d01d6) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/3bb77fa810e1b64e6acfdd72ef6b517b7389aeab6ae0f96eadb90f8cb916d72b) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/88449cc8546fa17cb4fe00b5030f31c31f6319f838dd308743013a1fdcc02b78) + - [create_event](https://stellar.expert/explorer/testnet/tx/6888610da9fd60ebfb59601fd258742058420f677b86a1a85d41c620966910a8) + - [accept_manager](https://stellar.expert/explorer/testnet/tx/d34fbe4bec22e23c2c0394cce001ece3bf88f81ea4d11ef964e5274d40e0fcfe) + - [reclaim_management](https://stellar.expert/explorer/testnet/tx/18d6ad962b6bac60c23b40b84e4f283b9a17c5d1dc1d6c9f901e4dff9e335644) + - [select_winners](https://stellar.expert/explorer/testnet/tx/4e8cb52a087f28a6cb895087df441a5c9d197821bb26b46f9f3ee776666eb871) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/0034e09ad229e94d1d20be7508f8d7f1ca3656f125a6412cb6ab2a9a6f934527) + +## 40 recipients + +- pass: publish the grant +- pass: owner paid budget plus fee +- pass: select 40 recipients in one transaction +- pass: release milestone 0 to GAKMUH +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GBD6NQ +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GD7YBV +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GCCSIY +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GCCSTJ +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GDAOQ7 +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GAB5YH +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GBO2RD +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GBEDOP +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GAFVE4 +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GDI35S +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GCNFJU +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GBEFLB +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GAYNV3 +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GCDMZN +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GDQXCV +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GBLZK6 +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GBW24G +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GDBJBB +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GC6ZPO +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GCIHKL +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GCVCHE +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GBBVFG +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GBYLFZ +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GD7QSB +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GALB4H +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GD3M5F +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GBB7PT +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GD76RN +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GBAX6W +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GAMOO2 +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GBICRD +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GDTZXJ +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GBNKH7 +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GBTQI5 +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GAOFJB +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GBHTHY +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GABOR6 +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GCRIDA +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GDONJ3 +- pass: milestone 0 paid exactly +- pass: release milestone 1 to GAKMUH +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GBD6NQ +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GD7YBV +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GCCSIY +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GCCSTJ +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GDAOQ7 +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GAB5YH +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GBO2RD +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GBEDOP +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GAFVE4 +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GDI35S +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GCNFJU +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GBEFLB +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GAYNV3 +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GCDMZN +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GDQXCV +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GBLZK6 +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GBW24G +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GDBJBB +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GC6ZPO +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GCIHKL +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GCVCHE +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GBBVFG +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GBYLFZ +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GD7QSB +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GALB4H +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GD3M5F +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GBB7PT +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GD76RN +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GBAX6W +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GAMOO2 +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GBICRD +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GDTZXJ +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GBNKH7 +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GBTQI5 +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GAOFJB +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GBHTHY +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GABOR6 +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GCRIDA +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GDONJ3 +- pass: milestone 1 paid exactly +- pass: completed +- pass: a full page of winners reads in one call +- pass: the rest on the next page + + - [create_event](https://stellar.expert/explorer/testnet/tx/39f055782bff602de073ad7012f7386b809edfeebcdaf2d77ce58118498dbbc3) + - [select_winners](https://stellar.expert/explorer/testnet/tx/d0bedebfec73df2726d52d80d57b8f20cb9e22dd633d102c85f22b50ee2a1248) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/7d24da85a0952d04c4e7e5736614e867d63360100aa019af67ffd8ee84a64656) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/624638c8eaba72fe939387e9f8c501147c0a352b1ef116c2ef716c5fd7856081) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/2855ceb466fd0297fcdd55752b32a053f7ea7ecc948fbe2a122c168f017b3753) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/4193002a990e474aeace68a1670201a986bc37d897f46f829390da1a2381cc26) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/1ece78e6d0dd990e11e3ad7aa9a48e17ca52c029360b98aebf80c306a6fc4bb3) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/cb090dfda26bc52a2efda3a7a5197a731fea5f7b056f68088fb1bcae38177141) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/e6ab0fd69d13513a437499bcc951475200c07043961658a6fe9495cc9e5d8f24) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/c127f9d957faf3fbbca155d00b6a9c0175451d1f815979eabdc4e87b54f12bf2) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/b40ed526d727b7ddcbff9c0909bedb2413fbe967d7177036516c512674a9a264) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/7ec5eb862ed0b5574a738ffe185e27db381e55c37c6165ac93686aff8e257b5c) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/d7a8821d2a826c09aafa89189d7e55f86e205632fc981ffb1c03a5e58264b4b3) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/13db432c3f74a8e4c18d708d30be33695569ca14f0f5b02f9f708b34d9201c41) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/73cc1f3ee092bc49706cff503212e6849b61941fcd8174a2aeba7faf30dcdb62) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/263d04242bd7eab5a46129b7aa66725b870e46f4e1cab0dfcd5853fa537c27d0) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/450084e817e393a57de11e8e82c60384fb690545a3729c940bd3c56a3669399d) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/c9c12539914a5f10466848441fcaca7759cf668cd8a88e773c7b0e6889a1c0fa) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/8a257ed77e58e07e8155c4e71c8bf3bce5fde09c77ff285e87e192b3ca05334f) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/37a561a4e9bbcdbab194ae8c0eb239217d779824410117ec47db27592ccb5c19) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/b89f04a5010a11d332c2f1bf95aa41a960444987c98b76d36dd4a9f69c26e5d4) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/79382996bfd877f8d60e7eac75be0bf134954513d9d48505a2fefcc1cda251f4) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/bc101f28428850835d57fb468c4d96fa612d0d2465817a0ad9455394cc2ae9f8) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/ee0a0320e15f2a878517df445df9bdfb02b5692190a8ee314bca6315bb1b608d) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/2618e9c0ac839da5b4a05b08e79d0a68a831f968302a2ac08dd9273b4550e541) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/37207648a3ccedf6f01283c140c44ce038bd3e9e7a59d39cd72e41c48dd74d59) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/79662d23cd467760236b213f0c266ca0b8f4e17e29d0cd555e558fcd5bd53f2f) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/916eaf4628b701dc1f002ad5f35cd30480eb57f97b3a056a8cacf05e6de183a4) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/fe2240ea2333acf3728ccde8df69546d28915e7108cc0d495a80d103372af823) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/9e5b85468e8a3ebcd9a895e8c50f3de1d775ddfe08a7acb15e542dc9aced94d7) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/6648715962606750261969920b3ca265d90f965d3b60799bb8c0ca3d75ad37d0) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/2650c15c684b6f5a66d5d649cb631757d74c6d8436a7c2b9067c1a450a3d3d1c) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/3b52bb83cf88a0c7940ee03694d6e5c311ed19e151c15bba5559370d8095c265) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/2580089a31de777b0ff8a56b68f9a2c1a03be2c86e512a8a03834badfb85b2f0) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/304efea234093a453a5d8bff24c50f108e2812afe8bb980f57df5d5adde31347) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/888109b134317ea37d43288100a0d4f8f9c57b448044ddaf84c0c3516f0795a9) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/f5a2f96d1160648e89bcd36d246c15d166ff14fec431e02fac2b6a993a6f7ce7) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/75b289cd61d96c19c3fb26dd2f139b65143da87b6088b7895500f867fe886ac9) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/497f4f47d7ea7e4233d548a0c827286dfdcc3bc20a78cf54b154892df36dd6e3) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/e93292b30a53db1205c923236fb5d62bc17a0b807e2aef39ae6a2b46a44f5a63) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/69895c4b96f8eaf2708461a21c89a905d5a5306ad7b42a089eecd079bb6574cc) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/dd0139a91682a729731f6d6aa101268016658ad4a7edd6bf7923a7c2a176ae19) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/08fa620e5c54c14cc651374f3f74348038fa2d2ae2b5478405851e75be509eb1) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/d478fbee228e9dde9c59d9c0712fba8d5627db7ce7f5bdcdc9206b9736d48ffa) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/928a70371745ee82c1016c971736c8419b13bc00581be363d3aa452b649c6859) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/2f1827409f29bef2416017d6fa3e9839252858558dc72f65e740be388a43a2e6) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/069ecab5d29a6f49b62f1f4b8122637f3d42ea72d189a8911f3c54fae3ebfd45) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/bc1ad2494ee930516c10acf9d88396930307cb17c39559106cafb817a6080725) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/5fde924f77309d39f9cb5759b4e544e91a15d401267fc8fe012d1fba5548de73) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/07679b46c1dbb795361d67287f710435e7cc9ef4818f70daa67909a7f52d735d) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/9b94c712fdb2c7cde69dfc53a0c7b422c9f87529991b86ff7cf4279bdb15c833) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/84064bd42348fc95f845f1ac9c9a16976ddacfab468a90a762986e1ca1b6cc84) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/3021f51f15d092b44b2e654ca8236cf5ccbbd8b851a715255688045ce342a93e) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/cc71ac298197e1c3872a151523d74ff6bb5ef7254cfc7e7421d2698c52b937ee) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/48c6f5243503096dbfdde142db0f939901bd52b803cf6c3ce5d11f94596fece6) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/fcf2d01a896eca17bd3d1b068f20f86b6fb465832f57477e63226716c6456b12) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/2aa6590ea339cf29d32ef9af7cef8e9437892276604f0d21aed3bbc403dfd03e) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/6a1332a7d84a24f4462e8411c71cb5b7c8d50b398912cb9edd3e355574624dcb) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/c2dd0e228dfbcc22056c325e0a60408ab5d5d67996474108f4c49a3f6495f9d7) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/1e89f9838ae13b6d6e692aab11196e9068b526577a17aaa159e4ea0307e244b2) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/2aec80d040106fa6b7e8583063d00b55e6ee9656966675c7ce3130ac3ed7527a) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/56db084e505fa38834f3644231841b69942f091245df52ac636fbcccc6eaa223) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/78a0716bb2c49a388ccfe58d5dad53afcb4f7e45d9cf2ce5e72f1db44485c7eb) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/817cc5d37c79eefd5194de9d7a397782fca7fb87ba166fe8b04af538db6d69fa) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/88ef8bbcb2b6751b664fabd17b42f53c6d309ed32040a063027e16670064e845) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/c9964c27ae249cc37084e961ae6fa9689bc42a59c4690dc1c56618d2e8a68150) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/7e7caeac35ef57067cfd2d61c1bbe22f930f74bf385587dc5945caa21d81efb4) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/2233e2f44be51cecd41a24dd8452f1b821b777b58b2c6cfca38859c25c71200f) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/2547359703b37e6731c30a8bf7c59ad81e3f6f422aca7c2d50bdf3dbf0d0dfb1) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/87cf4f5a3fb4c7ad7916d1e18538b810911dafe61d5abc9de982f4eda692916d) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/4e3480fa348a61355b5ab0c80fc390bdd07566a377e105320f8d7ef9a0be2209) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/d4c11647e8d08ade736d3b34818898bd7d78e4f01d901bfe6e5e5d617394bb52) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/7b75718e76591f37de3b952e1495109c91ebd9b107d5b1fc3f23d43bc436350d) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/e8b569a895e9abc23e9104869758aa3700fcc6d955eb1a519a8a723861600fa0) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/2c7986cb7b190d29384084a2f0805288b0e2c402e37ed761524472025b024ce5) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/d4313699d0144672ef40b1b196da9c1b7843669e8578b96b72e6086d123fbeeb) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/428f3ea10e426c588587949f6f21950c027ec6029077db1aab261fdd6294fa1c) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/6e695162e4df6b031ebb7f7ab88ae6f28aaec4d34f3cdd3a70fa8e2c80e7343a) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/002af34286d49890e7aa10153dbb5872d560f7f814449208cf7cec4e55ec8601) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/19a65f9cc74e87236578da5e917fd1f077e80dfe7640b1ecbfef54186c21692d) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/fbefc5e44b6297a2614db67895b381f2643011ea9167b0e938ced308bf2d5999) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/8a5d9c64a40aaf548ab8cb19c8c4c1262303a5b7c817b4cc8e1756ba7e106839) + +## crowdfunding release validator + +- pass: the validator is appointed on chain +- pass: publish the grant +- pass: owner paid budget plus fee +- pass: a backer pledges 60 +- pass: backers pay no fee +- pass: the creator alone cannot release (HostError: Error(Auth, InvalidAction)) +- pass: release 0 co-signed by the validator +- pass: creator paid net of fee +- pass: fee withheld at release +- pass: release 1 co-signed by the validator +- pass: creator paid net of fee +- pass: fee withheld at release +- pass: campaign completes on its last release + + - [provision 1 accounts](https://stellar.expert/explorer/testnet/tx/aa0dcce2f72b8e642a0cf76d83da9fd933d9ce7ce9acc74f53fc151f0d983942) + - [create_event](https://stellar.expert/explorer/testnet/tx/68adb492b228b0b9590d6ad163e1b0e43b3a1325905c7a33130df1752f0f7024) + - [add_funds](https://stellar.expert/explorer/testnet/tx/13216bdefec6754b37ffebe5042baca9406c0a1debdc3345cd71539f069f74d6) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/9c59bbf88a6282be59b193dd0c11c896847c14229ddcd7ef14f88bb96cd44251) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/97ed9a410eac75b13ca2f565eaa96c07eff9af43fbf52901f9993aaa14bf5dde) + +## fees + +- pass: fee account received exactly the fees charged + diff --git a/docs/contract-ops-runbook.md b/docs/contract-ops-runbook.md index 6bd52d4..8bc654d 100644 --- a/docs/contract-ops-runbook.md +++ b/docs/contract-ops-runbook.md @@ -1,90 +1,142 @@ -# Boundless contract operations runbook (testnet drill + multisig ops) +# Contract operations runbook -**For:** the contracts engineer and the multisig signers. -**Companion docs:** `multisig-guide.md` (plain-English signer setup), `admin-custody-policy.md` (policy), `mainnet-deploy-runbook.md` (mainnet cold deploy). -**Why this exists:** the mainnet runbook tells you *what* to run. This runbook tells you *how it actually behaves* — the silent-failure modes we hit during the testnet dress rehearsal and the exact fixes. Read Section 1 before you touch any multisig op. +For the contracts engineer and the multisig signers. This is the canonical +description of how an admin transaction is built, simulated, signed and +submitted, written from the testnet dress rehearsal and the mainnet upgrades. +What to run for a deploy is in `docs/DEPLOYMENT.md`, for an upgrade in +`docs/upgrade-runbook.md`; signer setup is in `docs/multisig-guide.md` and +the policy in `docs/admin-custody-policy.md`. Read Section 1 before you touch +any multisig operation. --- -## 1. Hard-won rules (read this first) +## 1. Rules learned the hard way -These are the things that cost us hours on testnet. Every one of them fails **silently or confusingly** if you get it wrong. +Each of these fails silently or confusingly when you get it wrong. -1. **A Soroban contract call must be SIMULATED before it can be signed.** - `--build-only` produces a transaction with **no footprint and no resource fee** (`ext = 0`, fee = base only). Submitting it gives `TxMalformed`. You MUST pipe it through `stellar tx simulate` to attach the Soroban data: +1. **A Soroban contract call must be simulated before it can be signed.** + `--build-only` produces a transaction with no footprint and no resource fee + (`ext = 0`, base fee only). Submitting it gives `TxMalformed`. Pipe it + through `stellar tx simulate` to attach the Soroban data: ```bash stellar contract invoke --id --source-account --network testnet --build-only -- \ | stellar tx simulate --source-account --network testnet ``` - The output of `tx simulate` is what you sign. This applies to **every** contract call signed offline/multisig (`accept_admin`, `pause`, `set_fee_bps`, `register_supported_token`, `propose_upgrade`, …). - -2. **Native account operations do NOT need simulate.** Building the multisig itself (`stellar tx new set-options` to add signers / set thresholds) is classic Stellar — the CLI signs and submits it directly. Only **Soroban contract invokes** need the simulate step. - -3. **Multisig signing is sequential: each signer signs the PREVIOUS signer's output.** - Signer 1 signs the prepared XDR → gets XDR-A. Signer 2 signs **XDR-A** (not the original) → gets XDR-B with both signatures. Two separate one-signature XDRs **do not combine** — you'll get `tx_bad_auth` / below threshold. - -4. **Submit with the CLI, not Stellar Lab, while operating.** - `stellar tx send "" --network ` prints `SUCCESS` or the **exact** error. Lab's "Submit" can report success-looking states while the tx never lands. Use Lab only for *signing* (it talks to Freighter); submit from the CLI so failures are loud. + The output of `tx simulate` is what you sign. This applies to every + contract call signed offline or by the multisig: `accept_admin`, `pause`, + `set_fee_bps`, `register_supported_token`, `propose_release_validator`, + `propose_upgrade`, `apply_upgrade`, `migrate_events`, `migrate`, and the + rest. The `prepare-*` commands in `deploy_mainnet.sh` do this for you. + +2. **Native account operations do not need simulate.** Building the multisig + itself (`stellar tx new set-options` to add signers or set thresholds) is + classic Stellar: the CLI signs and submits it directly. Only Soroban + contract invokes need the simulate step. + +3. **Multisig signing is sequential: each signer signs the previous signer's + output.** Signer 1 signs the prepared XDR and gets XDR-A. Signer 2 signs + XDR-A, not the original, and gets XDR-B with both signatures. Two separate + one-signature XDRs do not combine; you get `tx_bad_auth` or below + threshold. + +4. **Submit with the CLI, not Stellar Lab.** + `stellar tx send "" --network ` prints `SUCCESS` or the + exact error. Lab's Submit can report a success-looking state while the + transaction never lands. Use Lab only for signing (it talks to Freighter). 5. **Never mix `--network ` with `--network-passphrase`.** - Doing both makes the CLI stop resolving the RPC URL → `error: network passphrase is used but rpc-url is missing`. Pick ONE: - - `--network testnet` (carries passphrase + RPC), **or** - - `--rpc-url https://soroban-testnet.stellar.org --network-passphrase "Test SDF Network ; September 2015"` (no `--network`). - -6. **`--source-account` accepts a public key (G-address) when `--build-only` is set.** - That's how you build a transaction sourced from the multisig (which has no secret key locally). Without `--build-only`, the CLI tries to sign with that key and fails. - -7. **Don't run `set_admin` and `accept_admin` instantly back-to-back.** - `accept_admin` simulates against the latest closed ledger. If the `set_admin` nomination isn't in a closed ledger yet, you get `Error(Contract, #6)` = `PendingAdminMismatch`. Wait a few seconds (or run them as separate steps). On mainnet this never happens — the two halves are done by different people at different times. - -8. **Simulate → sign → send promptly.** The prepared XDR has time bounds. If you sit on it, `tx send` returns `txTooLate`. Just regenerate (re-run the build-only | simulate) and sign again. - -9. **The explorer defaults to mainnet.** Testnet accounts/contracts only show under the `/testnet/` path: `https://stellar.expert/explorer/testnet/account/` or `/contract/`. "Account not found" on the default explorer almost always means you're on the wrong network, not that the tx failed. - -10. **Key/contract-id drift is real.** A keystore alias (e.g. `boundless-deployer`) can resolve to a different address than you remember if the local `.stellar` config was replaced by the global config. Always `stellar keys address ` to confirm *which* key you're about to sign with, and keep deployed contract IDs written down (shell vars don't survive a new terminal). + Doing both makes the CLI stop resolving the RPC URL: + `error: network passphrase is used but rpc-url is missing`. Pick one: + - `--network testnet` (carries passphrase and RPC), or + - `--rpc-url https://soroban-testnet.stellar.org --network-passphrase "Test SDF Network ; September 2015"` with no `--network`. + + On mainnet use one named network (`docs/DEPLOYMENT.md`, 4.2); + `deploy_mainnet.sh` refuses to run while `STELLAR_RPC_URL` or + `STELLAR_NETWORK_PASSPHRASE` is set. + +6. **`--source-account` accepts a public key (G-address) with `--build-only`.** + That is how you build a transaction sourced from the multisig, which has no + secret key locally. Without `--build-only` the CLI tries to sign with that + key and fails. + +7. **Do not run `set_admin` and `accept_admin` back to back.** + `accept_admin` simulates against the latest closed ledger. If the + `set_admin` nomination is not in a closed ledger yet, simulation fails with + no pending rotation (events `#12`, profile `#6`). Wait a few seconds. On + mainnet the two halves are done by different people at different times. + +8. **Simulate, sign and send promptly.** The prepared XDR has time bounds. If + you sit on it, `tx send` returns `txTooLate`. Rebuild and sign again. + +9. **Build the next envelope only after the previous one lands.** Every + envelope uses the multisig account's current sequence number, so two + envelopes prepared together carry the same sequence and the second fails + with `tx_bad_seq`. + +10. **The explorer defaults to mainnet.** Testnet accounts and contracts only + show under `/testnet/`: `https://stellar.expert/explorer/testnet/account/` + or `/contract/`. "Account not found" usually means the wrong + network, not a failed transaction. + +11. **Know which key you are about to sign with.** CLI 28 reads identities and + networks only from its global config (`~/.config/stellar`, or + `--config-dir`); a `.stellar` directory in the repo is ignored. An alias + such as `boundless-deployer` can therefore resolve to a different key on + another machine. Run `stellar keys address ` before every signing + session, and keep contract ids in the local deployment record rather than + in shell variables. --- ## 2. Prerequisites -- `stellar` CLI ≥ 23.x (`stellar --version`). The `.stellar config migrate` / "new release" warnings are harmless noise. +- Stellar CLI 28.1.0 (`stellar --version`), the version CI and + `deploy_mainnet.sh` require. - `jq` and `curl`. -- Each signer has Freighter set up on their own machine in a dedicated browser profile, set to the right network, and has sent you their G-address (see `multisig-guide.md` Part D). +- Each signer has Freighter set up in a dedicated browser profile on their own + machine, on the right network, and has sent you their G-address + (`docs/multisig-guide.md`, Part D). --- ## 3. The two transaction shapes -| Shape | Examples | How you build + sign | +| Shape | Examples | Build and sign | |---|---|---| -| **Native account op** | `set-options` (add signer, set thresholds), payment | `stellar tx new --source-account --network ` — CLI signs + submits if the source key is local. For multisig: `--build-only`, then sign in Lab, then `stellar tx send`. **No simulate.** | -| **Soroban contract call** | `accept_admin`, `pause`, `set_fee_bps`, `register_supported_token`, `propose_upgrade`, `apply_upgrade`, `migrate` | `stellar contract invoke … --build-only -- ` **then `| stellar tx simulate …`**, then sign in Lab, then `stellar tx send`. **Simulate is mandatory.** | +| Native account operation | `set-options` (add signer, set thresholds), payment | `stellar tx new --source-account --network `; the CLI signs and submits if the key is local. For the multisig: `--build-only`, sign in Lab, `stellar tx send`. No simulate. | +| Soroban contract call | `accept_admin`, `pause`, `set_fee_bps`, `register_supported_token`, `propose_upgrade`, `apply_upgrade`, `migrate_events`, `migrate` | `stellar contract invoke ... --build-only -- `, piped through `stellar tx simulate`, signed in Lab, sent with `stellar tx send`. Simulate is mandatory. | + +`accept_release_validator` is the one admin-flow call the multisig does not +sign: the proposed validator key authorizes it. Build it with +`--source-account ` and have whoever holds that key sign +it. --- -## 4. Full testnet dress rehearsal +## 4. Testnet dress rehearsal -This mirrors the mainnet sequence end to end on throwaway testnet contracts. Worked reference values from our drill are shown in `‹comments›`. +This mirrors the mainnet sequence on throwaway testnet contracts. Reference +values from our drill are in `‹comments›`. ### 4.1 Build ```bash cd boundless-contract -stellar contract build --locked -# → target/wasm32v1-none/release/boundless_{events,profile}.wasm +stellar contract build --locked --package boundless-events +stellar contract build --locked --package boundless-profile +# target/wasm32v1-none/release/boundless_{events,profile}.wasm ``` ### 4.2 Deploy the profile contract (events depends on it) ```bash -DEPLOYER=$(stellar keys address boundless-deployer) # confirm WHICH key this is +DEPLOYER=$(stellar keys address boundless-deployer) # confirm which key this is PROFILE_ID=$(stellar contract deploy \ --wasm target/wasm32v1-none/release/boundless_profile.wasm \ --source boundless-deployer --network testnet \ -- \ --admin "$DEPLOYER") -echo "PROFILE_ID=$PROFILE_ID" # WRITE THIS DOWN ‹drill: CA63ATN2…› +echo "PROFILE_ID=$PROFILE_ID" # write this down ‹drill: CA63ATN2…› ``` -> Since the 1.1.0 credit-removal upgrade (2026-06) the profile constructor takes only `--admin`. Contract 1.0.0 additionally required `--default_bootstrap_credits`; credits now live in an off-chain ledger in boundless-nestjs. See Section 7. ### 4.3 Deploy the events contract ```bash @@ -98,10 +150,10 @@ EVENTS_ID=$(stellar contract deploy \ --fee_account "$FEE_ACCOUNT" \ --fee_bps 250 \ --profile_contract "$PROFILE_ID") -echo "EVENTS_ID=$EVENTS_ID" # WRITE THIS DOWN ‹drill: CDP55GFH…› +echo "EVENTS_ID=$EVENTS_ID" # write this down ‹drill: CDP55GFH…› ``` -### 4.4 Wire profile → events (first-set-only) +### 4.4 Wire profile to events (first set only) ```bash stellar contract invoke --id "$PROFILE_ID" --source boundless-deployer --network testnet \ -- set_events_contract --new_addr "$EVENTS_ID" @@ -116,137 +168,160 @@ stellar contract invoke --id "$EVENTS_ID" --source boundless-deployer --network ### 4.6 Verify the deploy (reads, no signing) ```bash -for fn in version get_admin get_fee_bps get_fee_account get_profile_contract is_paused supported_token_count ; do +for fn in version get_admin get_fee_bps get_fee_account get_profile_contract is_paused supported_token_count get_release_validator; do echo -n "$fn: "; stellar contract invoke --id "$EVENTS_ID" --source-account "$DEPLOYER" --network testnet --send no -- $fn done stellar contract invoke --id "$EVENTS_ID" --source-account "$DEPLOYER" --network testnet --send no \ -- is_supported_token --token "$USDC" ``` -Expect `get_admin` = deployer, `is_supported_token` = `true`, `supported_token_count` = `1`, `is_paused` = `false`. +Expect `get_admin` = deployer, `is_supported_token` = `true`, +`supported_token_count` = `1`, `is_paused` = `false`, +`get_release_validator` = `null`. ### 4.7 Build the 2-of-3 multisig -(Signers are: you, your co-founder, and a **cold recovery key** held offline. Thresholds 0/2/2, master disabled.) +Signers are you, your co-founder, and a cold recovery key held offline. +Thresholds 0/2/2, master disabled. ```bash stellar keys generate boundless-multisig-bootstrap --network testnet BOOT=$(stellar keys address boundless-multisig-bootstrap) # ‹drill: GDLEW45L…› curl "https://friendbot.stellar.org/?addr=$BOOT" -# Add the 3 signers (NATIVE op, no simulate) — one tx each +# Add the three signers (native op, no simulate), one transaction each for G in G_YOU... G_COFOUNDER... G_COLD... ; do stellar tx new set-options --source-account boundless-multisig-bootstrap \ --signer "$G" --signer-weight 1 --network testnet done -# Lock it: 2-of-3 + disable the bootstrap master key (the critical flag) +# Lock it: 2-of-3 and disable the bootstrap master key (the critical flag) stellar tx new set-options --source-account boundless-multisig-bootstrap \ --low-threshold 0 --med-threshold 2 --high-threshold 2 --master-weight 0 \ --network testnet -./scripts/admin/verify-multisig.sh "$BOOT" testnet # must print: PASS: all 6 checks passed +./scripts/admin/verify-multisig.sh "$BOOT" testnet # must print: PASS: all 8 checks passed ``` -Read the 3 signer addresses the script prints and confirm them by eye. +Read the three signer addresses the script prints and confirm them by eye. +With `EXPECTED_SIGNERS=,,` set, the script also pins the roster +and runs a ninth check. -### 4.8 Rotate admin: deployer → multisig (the heart of it) -Do this for **both** `$EVENTS_ID` and `$PROFILE_ID`. +### 4.8 Rotate admin: deployer to multisig +Do this for both `$EVENTS_ID` and `$PROFILE_ID`. ```bash -# Step A — current admin (deployer) nominates the multisig. CLI signs + submits. +# Step A: the current admin (deployer) nominates the multisig. CLI signs and submits. stellar contract invoke --id "$EVENTS_ID" --source boundless-deployer --network testnet \ -- set_admin --new_admin "$BOOT" ``` -**Wait ~10 seconds** (Rule 7), then: +Wait about ten seconds (Rule 7), then: ```bash -# Step B — build + SIMULATE the accept_admin (multisig as source) +# Step B: build and simulate accept_admin with the multisig as source stellar contract invoke --id "$EVENTS_ID" --source-account "$BOOT" --network testnet --build-only -- accept_admin \ | stellar tx simulate --source-account "$BOOT" --network testnet ``` -Take that prepared XDR → **Lab sign (you → co-founder)** → submit: +Sign the prepared XDR in Lab (you, then co-founder) and submit: ```bash stellar tx send "" --network testnet ``` Verify: ```bash stellar contract invoke --id "$EVENTS_ID" --source-account "$BOOT" --network testnet --send no -- get_admin -# → $BOOT +# $BOOT ``` -Repeat A→B for `$PROFILE_ID`. +Repeat A and B for `$PROFILE_ID`. -### 4.9 Operate as the multisig (and see the failure mode) -The deployer now has zero admin power. Prove the multisig can run ops: +### 4.9 Operate as the multisig, and see the failure mode +The deployer now has no admin power. Prove the multisig can run operations: ```bash -# pause (simulate is mandatory) stellar contract invoke --id "$EVENTS_ID" --source-account "$BOOT" --network testnet --build-only -- pause \ | stellar tx simulate --source-account "$BOOT" --network testnet ``` -→ Lab 2-of-3 → `stellar tx send` → `is_paused` should be `true`. Then `unpause` the same way. +Sign 2-of-3 in Lab, `stellar tx send`, and `is_paused` reads `true`. Then +`unpause` the same way. -**The "1-of-3 must fail" drill:** build a `pause`, submit with **only one** signature → `tx send` rejects it (`tx_bad_auth`). Seeing this on purpose is the point. +The 1-of-3 drill: build a `pause`, submit it with one signature, and +`tx send` rejects it (`tx_bad_auth`). Seeing this on purpose is the point. --- -## 5. Day-to-day multisig operation (the canonical flow) +## 5. Day-to-day multisig operation -Any time the multisig needs to do a contract op (`set_fee_bps`, `pause`, `register_supported_token`, an upgrade step, …): +Any time the multisig makes a contract call (`set_fee_bps`, `pause`, +`register_supported_token`, a release-validator change, an upgrade step): ```bash -# 1. BUILD + SIMULATE → prepared, unsigned XDR +# 0. Confirm the admin account is still the multisig on file +EXPECTED_SIGNERS=,, ./scripts/admin/verify-multisig.sh "$BOOT" + +# 1. Build and simulate: a prepared, unsigned XDR stellar contract invoke --id --source-account "$BOOT" --network --build-only -- [--arg val ...] \ - | stellar tx simulate --source-account "$BOOT" --network + | stellar tx simulate --source-account "$BOOT" --network > op.xdr + +# 2. Every signer decodes it and checks source, contract, function and arguments +stellar tx decode --output json-formatted < op.xdr -# 2. SIGN (Lab, in order) -# signer 1 signs the prepared XDR → XDR-A -# signer 2 signs XDR-A → XDR-B (2 of 3 — pull the cold key only for set_fee_account) +# 3. Sign in Lab, in order +# signer 1 signs the prepared XDR -> XDR-A +# signer 2 signs XDR-A -> XDR-B -# 3. SUBMIT (CLI, so errors are visible) +# 4. Submit from the CLI, so errors are visible stellar tx send "" --network -# 4. VERIFY with a read +# 5. Verify with a read stellar contract invoke --id --source-account "$BOOT" --network --send no -- ``` -`set_fee_account` is the only op the policy keeps at **3-of-3** — and that's a *process* rule (collect all three signatures, including the cold key), not enforced on-chain. See `admin-custody-policy.md` §4. +For upgrades, pause and unpause on mainnet, `deploy_mainnet.sh prepare-*` +replaces step 1 and adds the state checks in `docs/upgrade-runbook.md`. + +An emergency `pause` is the one operation that may go ahead before the paper +trail. Pause first, then write the incident record in `#ops-admin-requests`: +what was seen, who signed, the transaction hash, and what has to be true +before anyone prepares the `unpause`. + +The policy keeps `set_fee_account`, `set_admin` and +`propose_release_validator` at 3-of-3 (`docs/admin-custody-policy.md`, +Section 4). That is a process rule: collect all three signatures, including +the cold key. The chain only enforces 2-of-3. --- -## 6. Troubleshooting (error → cause → fix) +## 6. Troubleshooting | Symptom | Cause | Fix | |---|---|---| -| `network passphrase is used but rpc-url is missing` | Mixed `--network ` with `--network-passphrase` | Use `--network testnet` alone, or go fully explicit with `--rpc-url … --network-passphrase …` (Rule 5) | -| `TxMalformed` on submit of a contract call | The XDR was `--build-only` and never simulated (no footprint/resource fee) | Pipe through `stellar tx simulate` and sign that output (Rule 1) | -| `tx_bad_auth` / `op_low_threshold` | Not enough signatures combined — usually the 2nd signer signed the original, not the 1st signer's output | Re-sign in order: each signer signs the previous XDR (Rule 3) | -| `Error(Contract, #6)` (`PendingAdminMismatch`) on `accept_admin` | `set_admin` not yet in a closed ledger | Wait a few seconds and re-run the simulate (Rule 7). If still failing, re-run `set_admin` | -| `Error(Contract, #5)` (`NotAdmin`) | The source isn't the current admin (e.g. already rotated to the multisig) | Check `get_admin`; act as the real admin | -| `Error(Contract, #7)` (`PendingAdminExpired`) | The nomination window lapsed | Re-run `set_admin`, then accept promptly | -| `txTooLate` | Time bound elapsed between simulate and submit | Regenerate the prepared XDR and sign quickly (Rule 8) | -| "Account/contract not found" in explorer | Looking at mainnet | Use the `/testnet/` URL (Rule 9) | -| Lab says submitted but admin unchanged / no tx on the account | Submit silently failed in Lab | Submit from the CLI with `stellar tx send` to see the real error (Rule 4) | - -**Diagnosis tools (read-only):** +| `network passphrase is used but rpc-url is missing` | Mixed `--network ` with `--network-passphrase` | Use the named network alone, or go fully explicit with `--rpc-url ... --network-passphrase ...` (Rule 5) | +| `TxMalformed` when submitting a contract call | The XDR was `--build-only` and never simulated | Pipe through `stellar tx simulate` and sign that output (Rule 1) | +| `tx_bad_auth` / `op_low_threshold` | Not enough signatures combined, usually because the second signer signed the original instead of the first signer's output | Re-sign in order (Rule 3) | +| `tx_bad_seq` | Two envelopes were prepared against the same sequence | Rebuild the second after the first lands (Rule 9) | +| `Error(Contract, #12)` on events or `#6` on profile from `accept_admin` | No pending rotation yet: `set_admin` is not in a closed ledger | Wait a few seconds and simulate again (Rule 7); if it persists, re-run `set_admin` | +| `Error(Contract, #13)` on events or `#7` on profile | The nomination expired (120,960 ledgers) | Re-run `set_admin`, then accept promptly | +| Submit fails with an auth error although simulation passed | The source is not the current admin, so the recorded authorization belongs to an address nobody signed for | Check `get_admin` and build as the real admin | +| `txTooLate` | Time bounds passed between simulate and submit | Rebuild and sign quickly (Rule 8) | +| "Account/contract not found" in the explorer | Looking at mainnet | Use the `/testnet/` URL (Rule 10) | +| Lab says submitted but nothing changed | Lab's submit failed silently | Submit with `stellar tx send` to see the real error (Rule 4) | + +Upgrade-specific errors are listed in `docs/upgrade-runbook.md`, Section 6. + +Read-only diagnosis: ```bash -# Did a tx actually land on an account? +# Did a transaction land on an account? curl -s "https://horizon-testnet.stellar.org/accounts//transactions?order=desc&limit=5" \ | jq -r '._embedded.records[] | "\(.created_at) successful=\(.successful) \(.hash)"' -# Multisig config sanity +# Multisig configuration curl -s "https://horizon-testnet.stellar.org/accounts/" | jq '{signers,thresholds}' ``` --- -## 7. Pre-mainnet code fixes (found during the drill) - -1. ~~**`deploy_mainnet.sh` deploy-profile uses `--bootstrap_credits`**, but the profile constructor arg is **`--default_bootstrap_credits`**.~~ Overtaken by the 1.1.0 credit-removal upgrade (2026-06): the profile constructor no longer takes any credits argument (credits are an off-chain ledger in boundless-nestjs). Deploy scripts must pass only `--admin` when deploying profile from current source. -2. **`INITIAL_VERSION` is still `0.2.0`** while the code now includes the supported-token enumeration. A fresh mainnet deploy would stamp `0.2.0` for a contract that differs from the audited `0.2.0` surface. Bump it (e.g. `1.0.0`) and update the upgrade-test fixtures + the runbook's expected-version checks. -3. **Re-audit the supported-token enumeration** — it's new contract code added after the last audit; the mainnet pre-flight gate ("all critical/high resolved") must cover it. - ---- - -## 8. Mainnet deltas - -Everything in Section 4–5 is identical on mainnet except: -- Build **without** `--features testnet` (full upgrade timelock). `deploy_mainnet.sh` already does this. -- `--network mainnet` (or explicit `--rpc-url --network-passphrase "Public Global Stellar Network ; September 2015"`). -- The bootstrap is funded with **real XLM** (≥5), not friendbot. -- The cold recovery key actually lives in a safe; the daily signers are you + co-founder. -- After rotation, **destroy the initial deploy key** (`shred -u`); it has no power post-rotation but leave nothing lying around. -- The enumerable token index is complete **from genesis** — register USDC at deploy time and state enumeration is authoritative forever (no import-by-address needed, unlike the in-place-upgraded testnet contract). +## 7. Mainnet differences + +Sections 4 and 5 hold on mainnet except: +- Build without `--features testnet`, so the 17,280-ledger upgrade timelock is + compiled in. `deploy_mainnet.sh` does this. +- Use the named mainnet network from `docs/DEPLOYMENT.md` 4.2. +- Fund the bootstrap with real XLM (at least 5), not friendbot. +- The cold recovery key lives in a safe; the daily signers are you and your + co-founder. +- After the rotation, destroy the deployer key (`stellar keys rm`); it has no + power left, but leave nothing lying around. +- Register USDC at deploy time. The token index is then complete from genesis, + so `supported_token_count` and `supported_token_at` are authoritative. diff --git a/docs/dune-analytics.md b/docs/dune-analytics.md index 1ceff3d..5e709d6 100644 --- a/docs/dune-analytics.md +++ b/docs/dune-analytics.md @@ -1,4 +1,4 @@ -# Boundless On-chain Analytics — Dune Integration Guide +# Boundless on-chain analytics: Dune integration guide This document covers everything needed to build and maintain the Boundless Dune dashboard: the complete emitted-event schema (topic names, ScVal layout, field @@ -44,17 +44,17 @@ Each row in `history_contract_events` has: | `contract_id` | `VARCHAR` | Contract address (StrKey) | | `topics_decoded` | `VARCHAR` | JSON **array of ScVal objects**. The event name is at `$[0].symbol`, e.g. `[{"symbol":"EventCreated"}]` | | `data_decoded` | `VARCHAR` | JSON **ScVal map**: `{"map":[{"key":{"symbol":"id"},"val":{"u64":"7"}}, ...]}`. Each value is wrapped by its ScVal type | -| `closed_at_date` | `DATE` | **Partition column** — always filter it to avoid full-table scans | +| `closed_at_date` | `DATE` | **Partition column**; always filter it to avoid full-table scans | | `topics` / `data` | `VARCHAR` | Raw XDR (use the `*_decoded` columns instead) | | `type_string` | `VARCHAR` | Event type string (`"contract"` for Soroban events) | -| `transaction_hash` | `VARBINARY` | Transaction hash — wrap with `to_hex()` for a readable string | +| `transaction_hash` | `VARBINARY` | Transaction hash; wrap with `to_hex()` for a readable string | | `successful` | `BOOLEAN` | Whether the transaction succeeded | **Decoding, correctly (verified against live `stellar.history_contract_events`):** -1. **Event name** is *not* `$[0]` — that element is an object. Use: +1. **Event name** is *not* `$[0]`; that element is an object. Use: `JSON_EXTRACT_SCALAR(topics_decoded, '$[0].symbol')`. -2. **Fields** are *not* flat `$.field` — they live inside a positional `map`, +2. **Fields** are *not* flat `$.field`; they live inside a positional `map`, each value wrapped by its ScVal type. Rebuild the map into `MAP(field_name → ScVal JSON)`, then read each field by type: @@ -75,7 +75,7 @@ map_from_entries( The canonical, Dune-tested queries live in [`docs/dune-queries/`](./dune-queries); start with `10_event_created_decode_test.sql`. The snippets in §4 below illustrate -intent — treat the `.sql` files as the source of truth. +intent; treat the `.sql` files as the source of truth. --- @@ -100,7 +100,7 @@ Emitted by `create_event` for every pillar. | `title` | `String` | Human label; omit from aggregates | **TVL note.** For all pillars except Crowdfunding, the **full** `total_budget` -is escrowed at create time — the protocol fee is charged *on top* (the owner +is escrowed at create time. The protocol fee is charged *on top* (the owner pays `budget + fee`; the fee is forwarded to the fee account, and escrow is credited the full `total_budget`). So `total_budget` is exactly the escrow inflow; use it directly. For Crowdfunding, escrow starts at 0 and grows via @@ -108,10 +108,12 @@ inflow; use it directly. For Crowdfunding, escrow starts at 0 and grows via (non-Crowdfunding) + `FundsAdded.amount`, debiting `WinnerPaid` + `MilestoneClaimed` + `ContributorRefunded` + `OwnerResidualRefunded`. -> **Fee revenue is not derivable from these events.** The fee is transferred to -> the fee account with no dedicated event and is not embedded in any event -> `amount`. For protocol-fee analytics, read `stellar.history_transactions` / -> effects for transfers to the fee account, not `history_contract_events`. +> **Deposit fees are not derivable from these events.** The fee charged on top +> of a deposit is transferred to the fee account with no dedicated event and is +> not embedded in any event `amount`. For protocol-fee analytics, read +> `stellar.history_transactions` / effects for transfers to the fee account, +> not `history_contract_events`. The one exception is the fee withheld from a +> crowdfunding release, which `MilestoneFeeCharged` reports from 2.0.0. #### `EventCancelled` Emitted at the end of the cancel flow (`start_cancel` fast-path or @@ -134,14 +136,39 @@ fee-free, with the fee taken later at `claim_milestone`). | `amount` | `I128` | Amount credited to escrow | | `new_remaining` | `I128` | `remaining_escrow` after this deposit | +#### `CancellationStarted` +From 2.0.0. Emitted by `start_cancel`, before any refund moves, with the +arithmetic the refunds will follow. + +| Field | ScVal type | Notes | +|-------|-----------|-------| +| `event_id` | `U64` | | +| `branch` | enum string (`"OwnerOnly"`, `"FullPartnerThenResidual"`, `"ProRataPartners"`) | How the pool is split | +| `remaining` | `I128` | `remaining_escrow` when cancellation began | +| `non_owner_total` | `I128` | Sum of partner contributions still refundable | + #### `ContributorRefunded` -Emitted once per contributor per batch during paged cancel. +Emitted once per contributor per batch during paged cancel, and again when a +held refund is collected with `claim_refund`. From 2.0.0 a contributor whose +pro-rata share rounds down to nothing gets one with `amount = 0`. | Field | ScVal type | Notes | |-------|-----------|-------| | `event_id` | `U64` | | | `contributor` | `Address` | | -| `amount` | `I128` | Refunded amount | +| `amount` | `I128` | Refunded amount; may be 0 | + +#### `RefundDeferred` +From 2.0.0. The contributor's account could not take its refund (frozen, or no +trustline), so the amount stays in the contract for them to collect later with +`claim_refund`, which emits `ContributorRefunded`. No tokens move with this +event: count the outflow at the later `ContributorRefunded`, not here. + +| Field | ScVal type | Notes | +|-------|-----------|-------| +| `event_id` | `U64` | | +| `contributor` | `Address` | | +| `amount` | `I128` | Held for the contributor | #### `OwnerResidualRefunded` Emitted when the event owner receives their escrow residual at cancel time. @@ -156,6 +183,9 @@ Emitted when the event owner receives their escrow residual at cancel time. ### 2.2 Participation events +**Removed in 2.0.0.** Applying and submitting moved off chain, so these events +stop at the 2.0.0 upgrade and appear only in history from earlier versions. + #### `Applied` Emitted when a builder applies to a Bounty or Grant. @@ -174,7 +204,7 @@ Emitted when a builder withdraws their application before submitting. #### `Submitted` Emitted on every `submit` call (first submit and re-submits). Hackathon, Bounty, -Grant only — Crowdfunding has no submission step. +Grant only; Crowdfunding has no submission step. | Field | ScVal type | Notes | |-------|-----------|-------| @@ -202,17 +232,32 @@ Emitted once at the end of `select_winners`, regardless of pillar. | `event_id` | `U64` | | | `count` | `U32` | Number of winners chosen | +#### `WinnerAwarded` +From 2.0.0. One per award in a `select_winners` call, emitted just before +`WinnersSelected`. Nothing is paid yet: prizes are paid at `WinnerPaid`, grant +awards milestone by milestone at `MilestoneClaimed`. + +| Field | ScVal type | Notes | +|-------|-----------|-------| +| `event_id` | `U64` | | +| `recipient` | `Address` | | +| `position` | `U32` | | +| `amount` | `I128` | The whole award | + #### `WinnerPaid` -**Single-release pillars only** (Hackathon, Bounty). One event per winner. -Grant and Crowdfunding winners receive funds via `MilestoneClaimed`. +Emitted by `claim_prize` when a winner collects a prize, so **single-release +pillars only** (Hackathon, Bounty), one event per award. It is not emitted by +`select_winners`, which only records awards (`WinnerAwarded`); contracts before +1.3.0 paid at selection and emitted it from there, so older history mixes both. +Grant and Crowdfunding recipients are paid through `MilestoneClaimed`. | Field | ScVal type | Notes | |-------|-----------|-------| | `event_id` | `U64` | | | `recipient` | `Address` | Winner wallet | -| `position` | `U32` | 1st, 2nd, … | +| `position` | `U32` | 1st, 2nd, and so on | | `amount` | `I128` | Amount transferred (stroops) | -| `milestone` | `U32` or `NULL` | Always `NULL` when emitted by `select_winners` | +| `milestone` | `U32` or `NULL` | Always `NULL` from `claim_prize` | #### `MilestoneClaimed` Grant and Crowdfunding payouts. One event per (recipient, milestone). @@ -222,7 +267,31 @@ Grant and Crowdfunding payouts. One event per (recipient, milestone). | `event_id` | `U64` | | | `recipient` | `Address` | | | `milestone` | `U32` | 0-based milestone index | -| `amount` | `I128` | Payout amount | +| `amount` | `I128` | Gross amount that left escrow. For Crowdfunding the creator received `amount - fee`; see `MilestoneFeeCharged` | + +#### `MilestoneFeeCharged` +From 2.0.0. Crowdfunding only, emitted right after `MilestoneClaimed` when the +release withheld a fee. + +| Field | ScVal type | Notes | +|-------|-----------|-------| +| `event_id` | `U64` | | +| `recipient` | `Address` | | +| `milestone` | `U32` | | +| `fee` | `I128` | Sent to the fee account | + +#### `MilestoneForfeited` +From 2.0.0. Grants only. The owner closed a milestone without paying it. No +tokens move: the share stays in escrow, no longer owed to anyone, and leaves +with the rest of the pool at cancellation (`OwnerResidualRefunded` / +`ContributorRefunded`). + +| Field | ScVal type | Notes | +|-------|-----------|-------| +| `event_id` | `U64` | | +| `recipient` | `Address` | | +| `milestone` | `U32` | | +| `amount` | `I128` | The share released from the award | --- @@ -241,7 +310,11 @@ These are useful for governance dashboards but not for TVL/payout metrics. | `TokenDeregistered` | `token: Address` | Token removed from whitelist | | `ManagerProposed` | `event_id: U64`, `target: Address`, `expires_at_ledger: U32` | Two-step manager delegation proposed for an event | | `ManagerChanged` | `event_id: U64`, `new_manager: Address` | Manager delegation accepted (authority transferred) | -| `PendingManagerCancelled` | `event_id: U64` | Pending manager proposal vetoed | +| `PendingManagerCancelled` | `event_id: U64` | Pending manager proposal withdrawn, or replaced by a new one (from 2.0.0) | +| `ManagementReclaimed` | `event_id: U64`, `owner: Address` | From 2.0.0. The owner took management back before the first award | +| `ReleaseValidatorProposed` | `target: Address`, `expires_at_ledger: U32` | From 2.0.0. A crowdfunding release co-signer proposed; inactive until it accepts | +| `ValidatorProposalCancelled` | _(no fields)_ | From 2.0.0. A pending co-signer proposal withdrawn, replaced or cleared | +| `ReleaseValidatorUpdated` | `validator: Address` or `NULL` | From 2.0.0. Co-signer accepted, or cleared (back to the admin) | | `Paused` | _(no fields)_ | Contract paused | | `Unpaused` | _(no fields)_ | Contract unpaused | @@ -251,7 +324,7 @@ These are useful for governance dashboards but not for TVL/payout metrics. | Event name | Key fields | Notes | |-----------|-----------|-------| -| `PendingUpgradeProposed` | `wasm_hash, new_version, available_at_ledger, expires_at_ledger` | Upgrade queued (timelocked ~1 day) | +| `PendingUpgradeProposed` | `wasm_hash, new_version, available_at_ledger, expires_at_ledger` | Upgrade queued; it can apply from `available_at_ledger` | | `PendingUpgradeCancelled` | `cancelled_at_ledger: U32` | Upgrade cancelled before apply | | `UpgradeApplied` | `wasm_hash, new_version: String` | Preferred event; new wasm is live | | `Upgraded` | `new_wasm_hash` | Legacy alias for `UpgradeApplied`; indexers should prefer `UpgradeApplied` | @@ -264,18 +337,18 @@ These are useful for governance dashboards but not for TVL/payout metrics. TVL (Total Value Locked) = escrow currently held by the contract. **Inflows (+)** -- `EventCreated.total_budget` — for Hackathon, Bounty, Grant (Crowdfunding starts at 0) -- `FundsAdded.amount` — every partner / community top-up +- `EventCreated.total_budget`: for Hackathon, Bounty, Grant (Crowdfunding starts at 0) +- `FundsAdded.amount`: every partner / community top-up **Outflows (−)** -- `WinnerPaid.amount` — immediate payout at `select_winners` (Single pillars) -- `MilestoneClaimed.amount` — milestone payout (Grant + Crowdfunding) -- `ContributorRefunded.amount` — partner refund during cancel -- `OwnerResidualRefunded.amount` — owner residual refund during cancel +- `WinnerPaid.amount`: a prize collected with `claim_prize` (Single pillars) +- `MilestoneClaimed.amount`: milestone payout (Grant + Crowdfunding) +- `ContributorRefunded.amount`: partner refund during cancel, or a held refund collected with `claim_refund` +- `OwnerResidualRefunded.amount`: owner residual refund during cancel **Protocol fee** is charged on top of the deposited amount (the payer sends `amount + fee`; the fee is forwarded to the fee account). Every event `amount` -therefore equals exactly what escrow was credited or released — use them +therefore equals exactly what escrow was credited or released; use them directly, no fee adjustment in SQL. The fee itself is *not* in any event; see the fee-revenue note in §2.1. @@ -302,7 +375,7 @@ Amounts are in stroops (7 decimal places); divide by `1e7` for human units. > Canonical, Dune-tested query: [`docs/dune-queries/04_total_payouts.sql`](./dune-queries/04_total_payouts.sql) -### 4.5 Unique participants (applicants + recipients) +### 4.5 Unique participants (recipients, plus applicants before 2.0.0) > Canonical, Dune-tested query: [`docs/dune-queries/05_unique_participants.sql`](./dune-queries/05_unique_participants.sql) @@ -335,17 +408,17 @@ dashboard: | Row | Panel | Query | |-----|-------|-------| -| 1 | **TVL (current)** — single number | §4.1 | -| 1 | **Total paid out** — single number | §4.4 (sum only) | -| 1 | **Events created** — single number | §4.3 (count only) | -| 1 | **Unique builders** — single number | §4.5 | -| 2 | **TVL over time** — area chart | §4.2 | -| 2 | **Monthly payouts** — bar chart | §4.4 | -| 3 | **Events by pillar** — donut chart | §4.3 (count, no time) | -| 3 | **Funded vs completed vs cancelled** — bar | §4.7 | -| 4 | **Average budget & time-to-payout** — table | §4.8 | -| 4 | **Payout volume by token** — table | §4.9 | -| 5 | **Crowdfunding daily contributions** — line chart | §4.10 | +| 1 | **TVL (current)**, single number | §4.1 | +| 1 | **Total paid out**, single number | §4.4 (sum only) | +| 1 | **Events created**, single number | §4.3 (count only) | +| 1 | **Unique builders**, single number | §4.5 | +| 2 | **TVL over time**, area chart | §4.2 | +| 2 | **Monthly payouts**, bar chart | §4.4 | +| 3 | **Events by pillar**, donut chart | §4.3 (count, no time) | +| 3 | **Funded vs completed vs cancelled**, bar | §4.7 | +| 4 | **Average budget & time-to-payout**, table | §4.8 | +| 4 | **Payout volume by token**, table | §4.9 | +| 5 | **Crowdfunding daily contributions**, line chart | §4.10 | --- @@ -364,7 +437,7 @@ dashboard: ## 7. Maintenance notes -- **Contract upgrades.** After `apply_upgrade` + `migrate`, verify the new +- **Contract upgrades.** After `apply_upgrade`, `migrate_events` and `migrate`, verify the new version's events still decode correctly. The `Migrated` event in Dune confirms the on-chain migration ran; cross-check with `UpgradeApplied`. - **New tokens.** When `TokenRegistered` appears for a new address, add it @@ -372,7 +445,7 @@ dashboard: - **Fee changes.** The protocol fee is charged on top of the deposited amount, so event `amount` fields already equal the escrow credit/release. No SQL adjustment is needed if the fee rate changes. (Fee revenue itself is not in - these events — see §2.1.) + these events; see §2.1.) - **Crowdfunding vs other pillars.** `EventCreated.total_budget` is a *funding goal* for Crowdfunding, not an escrow deposit. Exclude `pillar = 'Crowdfunding'` from inflow sums based on `EventCreated` and diff --git a/docs/dune-queries/01_tvl_current.sql b/docs/dune-queries/01_tvl_current.sql index 423918d..f1e5f97 100644 --- a/docs/dune-queries/01_tvl_current.sql +++ b/docs/dune-queries/01_tvl_current.sql @@ -7,7 +7,7 @@ -- fields -> rebuild data_decoded '$.map' into MAP(name -> ScVal JSON), -- then read each field by its ScVal type ($.i128, $.u64, ...). -- --- Inflows: EventCreated.total_budget (non-Crowdfunding — escrowed at creation) +-- Inflows: EventCreated.total_budget (non-Crowdfunding, escrowed at creation) -- FundsAdded.amount (partner top-ups + crowdfunding contributions) -- Outflows: WinnerPaid.amount, MilestoneClaimed.amount, -- ContributorRefunded.amount, OwnerResidualRefunded.amount diff --git a/docs/dune-queries/03_bounties_funded.sql b/docs/dune-queries/03_bounties_funded.sql index 09b064a..f6c82b9 100644 --- a/docs/dune-queries/03_bounties_funded.sql +++ b/docs/dune-queries/03_bounties_funded.sql @@ -1,4 +1,4 @@ --- Boundless On-chain: Events created — count and budget by pillar and month +-- Boundless On-chain: Events created: count and budget by pillar and month -- Panel type: grouped bar chart x=month y=events_created color=pillar -- -- Decoding: see 10_event_created_decode_test.sql. diff --git a/docs/dune-queries/05_unique_participants.sql b/docs/dune-queries/05_unique_participants.sql index ae6027b..75d66b9 100644 --- a/docs/dune-queries/05_unique_participants.sql +++ b/docs/dune-queries/05_unique_participants.sql @@ -19,7 +19,9 @@ WITH ev AS ( AND JSON_EXTRACT_SCALAR(topics_decoded, '$[0].symbol') IN ('Applied', 'WinnerPaid', 'MilestoneClaimed', 'EventCreated') ) --- Builders: applied to or received a payout from any event +-- Builders: applied to or received a payout from any event. `Applied` covers +-- only history from before 2.0.0, when applying moved off chain; from 2.0.0 a +-- builder is counted here once paid. SELECT 'builders' AS role, COUNT(DISTINCT addr) AS unique_wallets diff --git a/docs/dune-queries/09_crowdfunding_contributions.sql b/docs/dune-queries/09_crowdfunding_contributions.sql index d4559b2..0ae3466 100644 --- a/docs/dune-queries/09_crowdfunding_contributions.sql +++ b/docs/dune-queries/09_crowdfunding_contributions.sql @@ -1,4 +1,4 @@ --- Boundless On-chain: Contribution inflows — daily volume +-- Boundless On-chain: Contribution inflows, daily volume -- Panel type: line chart x=day y=total_contributed_display -- -- FundsAdded fires for every add_funds call (crowdfunding contributions AND diff --git a/docs/dune-queries/10_event_created_decode_test.sql b/docs/dune-queries/10_event_created_decode_test.sql index 921e817..f85dc58 100644 --- a/docs/dune-queries/10_event_created_decode_test.sql +++ b/docs/dune-queries/10_event_created_decode_test.sql @@ -4,12 +4,12 @@ -- Real stellar.history_contract_events shapes (verified against live data): -- topics_decoded : JSON array of ScVal objects. Event name is at $[0].symbol -- (e.g. [{"symbol":"EventCreated"}]), NOT $[0]. --- data_decoded : ScVal map — {"map":[{"key":{"symbol":"id"},"val":{"u64":"7"}}, ...]}. +-- data_decoded : ScVal map: {"map":[{"key":{"symbol":"id"},"val":{"u64":"7"}}, ...]}. -- Each field's value is wrapped by its ScVal type; there is no -- flat $.id. We rebuild it into a MAP(field_name -> ScVal JSON) -- with map_from_entries(), then read each field by its type. --- closed_at_date : PARTITION column — always filter it (avoids full scans). --- transaction_hash : varbinary — to_hex() for a readable hash. +-- closed_at_date : PARTITION column; always filter it (avoids full scans). +-- transaction_hash : varbinary; to_hex() for a readable hash. WITH ev AS ( SELECT diff --git a/docs/figures/threat-model-figure-1-system-dfd.svg b/docs/figures/threat-model-figure-1-system-dfd.svg index 8912642..5ccc758 100644 --- a/docs/figures/threat-model-figure-1-system-dfd.svg +++ b/docs/figures/threat-model-figure-1-system-dfd.svg @@ -1,5 +1,5 @@ -Figure 1 — Boundless system data-flow diagram with trust boundaries +Figure 1. Boundless system data-flow diagram with trust boundaries diff --git a/docs/figures/threat-model-figure-2-escrow-lifecycle.svg b/docs/figures/threat-model-figure-2-escrow-lifecycle.svg index aaf7030..ad4078b 100644 --- a/docs/figures/threat-model-figure-2-escrow-lifecycle.svg +++ b/docs/figures/threat-model-figure-2-escrow-lifecycle.svg @@ -1,5 +1,5 @@ -Figure 2 — Escrow lifecycle and authorization: who signs each boundless-events transition, how the escrow pool moves, and which ledger accounts are credited +Figure 2. Escrow lifecycle and authorization: who signs each boundless-events transition, how the escrow pool moves, and which ledger accounts are credited