From aa5d69d4eb6d5d61cf888882b9bcf27ac9a1c39d Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Thu, 8 Oct 2026 05:37:46 +0100 Subject: [PATCH] feat(events): grant milestone splits (2.1.0) A grant can be published with ReleaseKind::Split: each milestone's share of every award in basis points, in milestone order, adding up to 10,000. Each claim or forfeit pays that milestone's share, rounded down, and whichever milestone settles last takes what is left, so an award pays exactly its amount in any order. create_event refuses a split that is empty, longer than 20, has a zero share or does not add up (InvalidMilestoneSplit, 98), or is not on a grant; selection refuses an award too small for its smallest share. The variant is appended, so stored records decode and existing callers are unaffected. Testnet runs 2.1.0 since 2026-10-08 (runbook Section 9; grant run 337/337 with a split grant). Mainnet still goes to 2.0.0 first and takes 2.1.0 as a separate upgrade. --- BACKLOG.md | 1 + Cargo.lock | 2 +- README.md | 17 +- contracts/compatibility/src/lib.rs | 50 +- contracts/events/Cargo.toml | 2 +- contracts/events/src/admin.rs | 2 +- contracts/events/src/errors.rs | 1 + contracts/events/src/event_ops.rs | 45 +- contracts/events/src/grant.rs | 61 +- contracts/events/src/lib.rs | 2 +- contracts/events/src/tests/admin.rs | 10 +- .../events/src/tests/grant_scenarios/mod.rs | 21 +- .../src/tests/grant_scenarios/randomized.rs | 36 +- .../events/src/tests/grant_scenarios/split.rs | 184 ++++++ contracts/events/src/types.rs | 16 +- docs/audit/testnet-grant-run-2.1.0.md | 557 ++++++++++++++++++ docs/storage-compatibility.md | 5 +- docs/threat-model.md | 32 +- docs/upgrade-runbook.md | 16 + scripts/testnet/grant-scenarios.ts | 62 +- 20 files changed, 1059 insertions(+), 63 deletions(-) create mode 100644 contracts/events/src/tests/grant_scenarios/split.rs create mode 100644 docs/audit/testnet-grant-run-2.1.0.md diff --git a/BACKLOG.md b/BACKLOG.md index f2a38d1..c0ee64d 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -13,6 +13,7 @@ Legend: `[ ]` open, `[~]` in flight, `[x]` done. ## P1 +- [ ] **Events 2.1.0 (grant milestone splits) to mainnet**, as its own upgrade after 2.0.0, waiting the 17,280-ledger timelock. Before it goes: rerun Scout on the 2.1.0 code and add it to `docs/threat-model.md` Section 6 (the scan covers 2.0.0; the testnet grant run already passed on 2.1.0, `docs/audit/testnet-grant-run-2.1.0.md`). Refresh the storage replay from 2.0.0 once mainnet runs it (`docs/storage-compatibility.md`). - [ ] 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). diff --git a/Cargo.lock b/Cargo.lock index 3e78079..7fea991 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -215,7 +215,7 @@ dependencies = [ [[package]] name = "boundless-events" -version = "2.0.0" +version = "2.1.0" dependencies = [ "boundless-profile", "soroban-sdk", diff --git a/README.md b/README.md index 5c9ba43..81abc2e 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,13 @@ earnings. |---------|----------|---------|---------| | 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` | +| Testnet | `boundless-events` | `CBEODVJGUYCIYTVXD7KI5UG3BJ2UE4T7AGI2TGY3T4Q5GQRFGTRYVTZP` | `2.1.0` | +| Testnet | `boundless-profile` | `CCA3OAIBOZBUPHPRI5GI6N5PDTE7RTNLKAEID4JTC2YZIHIZNDX5Q6T3` | `1.2.1` | -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 +This tree builds events `2.1.0` and profile `1.2.1`. Mainnet first moves to +events `2.0.0` and profile `1.2.1` through the procedure in +`docs/upgrade-runbook.md` (Section 8), and to `2.1.0` in a later upgrade; 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 @@ -40,7 +41,9 @@ 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 +can `forfeit_milestone` a grant milestone it rejects. A grant's milestones pay +even shares of each award, or the shares it was published with +(`ReleaseKind::Split`, in basis points). 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 @@ -134,7 +137,7 @@ boundless-contract/ | `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/threat-model.md` | Threat model for the 2.0.0 contracts and the 2.1.0 milestone split | | `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/`) | diff --git a/contracts/compatibility/src/lib.rs b/contracts/compatibility/src/lib.rs index 69b885b..c036a31 100644 --- a/contracts/compatibility/src/lib.rs +++ b/contracts/compatibility/src/lib.rs @@ -221,7 +221,7 @@ fn mainnet_upgrades_from_the_deployed_builds_to_this_tree() { 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.propose_upgrade(&events_hash, &String::from_str(&env, "2.1.0")); events_v1.apply_upgrade(); let events = EventsContractClient::new(&env, &events_id); assert!(events.is_paused()); @@ -233,11 +233,11 @@ fn mainnet_upgrades_from_the_deployed_builds_to_this_tree() { events.migrate(); events.unpause(); - assert_eq!(events.version(), String::from_str(&env, "2.0.0")); + assert_eq!(events.version(), String::from_str(&env, "2.1.0")); assert_eq!(profile.version(), String::from_str(&env, "1.2.1")); assert_eq!( events.get_migrated_to_version(), - Some(String::from_str(&env, "2.0.0")) + Some(String::from_str(&env, "2.1.0")) ); assert_eq!( profile.get_migrated_to_version(), @@ -371,4 +371,48 @@ fn run_a_grant( assert_eq!(events.get_event(&id).status, EventStatus::Cancelled); assert_eq!(token.balance(&owner), 200_0000000); assert_eq!(token.balance(&events.address), 0); + + // A grant published after the upgrade can pay milestones unequal shares. + let split_budget: i128 = 500_0000000; + token::StellarAssetClient::new(env, &token_id) + .mint(&owner, &(split_budget + split_budget * 250 / 10_000)); + let mut split_floors = Map::new(env); + split_floors.set(1, split_budget); + let split_id = events.create_event( + &CreateEventParams { + pillar: Pillar::Grant, + owner: owner.clone(), + token: token_id.clone(), + total_budget: split_budget, + release_kind: ReleaseKind::Split(vec![env, 2_000, 3_000, 5_000]), + content_uri: String::from_str(env, "https://boundlessfi.xyz/grants/replay-split"), + title: String::from_str(env, "Upgrade replay, split"), + deadline: None, + prize_floors: split_floors, + fee_bps_override: None, + manager: None, + }, + &BytesN::random(env), + ); + let c = Address::generate(env); + events.select_winners( + &split_id, + &vec![ + env, + WinnerSpec { + recipient: c.clone(), + position: 1, + amount: split_budget, + reputation_bump: 0, + }, + ], + &BytesN::random(env), + ); + let mut received = 0; + for (milestone, share) in [(0, 100_0000000), (1, 150_0000000), (2, 250_0000000)] { + events.claim_milestone(&split_id, &c, &milestone, &0, &BytesN::random(env)); + received += share; + assert_eq!(token.balance(&c), received); + } + assert_eq!(events.get_event(&split_id).status, EventStatus::Completed); } diff --git a/contracts/events/Cargo.toml b/contracts/events/Cargo.toml index 19f9b90..f0cf358 100644 --- a/contracts/events/Cargo.toml +++ b/contracts/events/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "boundless-events" -version = "2.0.0" +version = "2.1.0" edition = "2021" publish = false diff --git a/contracts/events/src/admin.rs b/contracts/events/src/admin.rs index a0b8fc8..55f959d 100644 --- a/contracts/events/src/admin.rs +++ b/contracts/events/src/admin.rs @@ -48,7 +48,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 = "2.0.0"; +pub const INITIAL_VERSION: &str = "2.1.0"; // ============================================================ // INITIALIZATION diff --git a/contracts/events/src/errors.rs b/contracts/events/src/errors.rs index 593b917..1e7585b 100644 --- a/contracts/events/src/errors.rs +++ b/contracts/events/src/errors.rs @@ -66,4 +66,5 @@ pub enum Error { NoRefundOwed = 95, AwardsOutstanding = 96, MigrationIncomplete = 97, + InvalidMilestoneSplit = 98, } diff --git a/contracts/events/src/event_ops.rs b/contracts/events/src/event_ops.rs index cf4fb6a..b3c4c3e 100644 --- a/contracts/events/src/event_ops.rs +++ b/contracts/events/src/event_ops.rs @@ -4,6 +4,7 @@ use crate::admin::{self, MAX_FEE_BPS}; use crate::errors::Error; use crate::escrow; use crate::events as evt; +use crate::grant; use crate::idempotency::{self, tag}; use crate::profile_client; use crate::storage; @@ -24,6 +25,10 @@ const MAX_TITLE_LEN: u32 = 120; pub const MAX_AWARDS_PER_SELECT: u32 = 20; pub const MAX_GRANT_AWARDS: u32 = 40; +// A split lives on the event record, so its length is bounded. +pub const MAX_SPLIT_MILESTONES: u32 = 20; +pub const SPLIT_BPS_TOTAL: u32 = 10_000; + const PENDING_MANAGER_TTL_LEDGERS: u32 = 17_280; // Anchored at selection time, not the event deadline (which usually passes @@ -67,6 +72,23 @@ fn get_or_init_non_owner_total(env: &Env, event_id: u64) -> Result } } +fn split_is_valid(shares: &Vec) -> bool { + if shares.is_empty() || shares.len() > MAX_SPLIT_MILESTONES { + return false; + } + let mut total: u32 = 0; + for share in shares.iter() { + if share == 0 { + return false; + } + total = match total.checked_add(share) { + Some(t) => t, + None => return false, + }; + } + total == SPLIT_BPS_TOTAL +} + pub fn create_event(env: &Env, params: CreateEventParams, op_id: BytesN<32>) -> Result { admin::require_not_paused(env)?; @@ -134,13 +156,22 @@ pub fn create_event(env: &Env, params: CreateEventParams, op_id: BytesN<32>) -> }; 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) + Pillar::Grant => { + matches!( + params.release_kind, + ReleaseKind::Multi(n) if n > 0 + ) || matches!(params.release_kind, ReleaseKind::Split(_)) } + Pillar::Crowdfunding => matches!(params.release_kind, ReleaseKind::Multi(n) if n > 0), }; if !release_kind_fits { return Err(Error::InvalidReleaseKind); } + if let ReleaseKind::Split(shares) = ¶ms.release_kind { + if !split_is_valid(shares) { + return Err(Error::InvalidMilestoneSplit); + } + } if !is_crowdfunding { escrow::deposit_with_fee_at( @@ -673,7 +704,7 @@ pub fn select_winners( // 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 { + if !matches!(event.release_kind, ReleaseKind::Single) && existing_count > 0 { return Err(Error::WinnersAlreadySelected); } @@ -682,7 +713,7 @@ pub fn select_winners( } let cap = match event.release_kind { ReleaseKind::Single => MAX_AWARDS_PER_SELECT, - ReleaseKind::Multi(_) => MAX_GRANT_AWARDS, + ReleaseKind::Multi(_) | ReleaseKind::Split(_) => MAX_GRANT_AWARDS, }; if winners.len() > cap { return Err(Error::InvalidWinnerPosition); @@ -710,7 +741,7 @@ pub fn select_winners( let now = admin::open_time(env); - match event.release_kind { + match &event.release_kind { ReleaseKind::Single => { // `remaining_escrow` only drops at claim time, so a prize named by // an earlier selection is still sitting in it. Reserving the owed @@ -786,7 +817,7 @@ pub fn select_winners( storage::set_prize_claim_expiry(env, event_id, expiry); } } - ReleaseKind::Multi(milestones) => { + ReleaseKind::Multi(_) | ReleaseKind::Split(_) => { // 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. @@ -796,7 +827,7 @@ pub fn select_winners( for spec in winners.iter() { // Every milestone must pay something, or the award can never // be released in full. - if spec.amount < milestones as i128 { + if !grant::every_milestone_pays(&event.release_kind, spec.amount) { return Err(Error::InvalidDistribution); } // Releases are tracked per recipient, so a second award to the diff --git a/contracts/events/src/grant.rs b/contracts/events/src/grant.rs index bde3db7..6257f54 100644 --- a/contracts/events/src/grant.rs +++ b/contracts/events/src/grant.rs @@ -3,7 +3,7 @@ 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::event_ops::{MAX_REPUTATION_BUMP, SPLIT_BPS_TOTAL}; use crate::events as evt; use crate::idempotency::{self, tag}; use crate::profile_client; @@ -29,16 +29,55 @@ fn standing(env: &Env, event_id: u64, recipient: &Address) -> Result Result { +/// One milestone's part of an award, rounded down: an even share for Multi, +/// its basis points for Split. +pub(crate) fn milestone_share( + kind: &ReleaseKind, + award: i128, + milestone: u32, +) -> Result { + match kind { + ReleaseKind::Multi(n) if *n > 0 => Ok(award / (*n as i128)), + ReleaseKind::Split(shares) => { + let bps = shares.get(milestone).ok_or(Error::InvalidMilestone)?; + award + .checked_mul(bps as i128) + .map(|v| v / SPLIT_BPS_TOTAL as i128) + .ok_or(Error::InvalidDistribution) + } + _ => Err(Error::InvalidReleaseKind), + } +} + +/// Whether every milestone of `award` pays something, so it can be released +/// in full. +pub(crate) fn every_milestone_pays(kind: &ReleaseKind, award: i128) -> bool { + match kind { + // Multi has no length cap, so its check stays a comparison. + ReleaseKind::Multi(n) => *n > 0 && award >= *n as i128, + ReleaseKind::Split(shares) => { + (0..shares.len()).all(|m| matches!(milestone_share(kind, award, m), Ok(a) if a > 0)) + } + ReleaseKind::Single => false, + } +} + +/// The milestone's share, or what is left of the award when this is the last +/// milestone the recipient has open. The remainder holds whichever order the +/// milestones settle in: it is this milestone's share plus every rounding. +fn share( + standing: &Standing, + kind: &ReleaseKind, + total_milestones: u32, + milestone: 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)) + milestone_share(kind, standing.award, milestone) } } @@ -91,8 +130,8 @@ pub fn claim_milestone( return Err(Error::EventNotActive); } - let total_milestones = match event.release_kind { - ReleaseKind::Multi(n) if n > 0 => n, + let total_milestones = match event.release_kind.milestones() { + Some(n) if n > 0 => n, _ => return Err(Error::InvalidReleaseKind), }; @@ -126,7 +165,7 @@ pub fn claim_milestone( 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)? + share(standing, &event.release_kind, total_milestones, milestone)? } else { let claimed_count = storage::get_crowdfunding_milestones_claimed(env, event_id); let remaining_milestones = total_milestones.saturating_sub(claimed_count); @@ -240,8 +279,8 @@ pub fn forfeit_milestone( if !matches!(event.pillar, Pillar::Grant) { return Err(Error::InvalidPillar); } - let total_milestones = match event.release_kind { - ReleaseKind::Multi(n) if n > 0 => n, + let total_milestones = match event.release_kind.milestones() { + Some(n) if n > 0 => n, _ => return Err(Error::InvalidReleaseKind), }; if milestone >= total_milestones { @@ -256,7 +295,7 @@ pub fn forfeit_milestone( } let standing = standing(env, event_id, &recipient)?; - let amount = share(&standing, total_milestones)?; + let amount = share(&standing, &event.release_kind, total_milestones, milestone)?; if amount <= 0 { return Err(Error::InvalidDistribution); } diff --git a/contracts/events/src/lib.rs b/contracts/events/src/lib.rs index 5bd7ec2..50fadc0 100644 --- a/contracts/events/src/lib.rs +++ b/contracts/events/src/lib.rs @@ -21,7 +21,7 @@ mod tests; use crate::errors::Error; use crate::types::*; -contractmeta!(key = "version", val = "2.0.0"); +contractmeta!(key = "version", val = "2.1.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 92bb7ea..25dc195 100644 --- a/contracts/events/src/tests/admin.rs +++ b/contracts/events/src/tests/admin.rs @@ -21,7 +21,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!(!ctx.client.is_paused()); - assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "2.0.0")); + assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "2.1.0")); assert_eq!(ctx.client.get_pending_upgrade(), None); assert_eq!(ctx.client.get_migrated_to_version(), None); } @@ -138,7 +138,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, "2.0.0")); + assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "2.1.0")); } #[test] @@ -264,7 +264,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, "2.0.0")) + Some(String::from_str(&ctx.env, "2.1.0")) ); } @@ -381,7 +381,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, "2.0.0")) + Some(String::from_str(&ctx.env, "2.1.0")) ); } @@ -393,7 +393,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, "2.0.0")) + Some(String::from_str(&ctx.env, "2.1.0")) ); let err = ctx diff --git a/contracts/events/src/tests/grant_scenarios/mod.rs b/contracts/events/src/tests/grant_scenarios/mod.rs index f7aed3c..52b0146 100644 --- a/contracts/events/src/tests/grant_scenarios/mod.rs +++ b/contracts/events/src/tests/grant_scenarios/mod.rs @@ -19,6 +19,7 @@ mod pause; mod randomized; mod refusing_token; mod selection; +mod split; use soroban_sdk::{ testutils::{ @@ -109,6 +110,7 @@ pub fn setup<'a>() -> G<'a> { pub struct Spec { pub budget: i128, pub milestones: u32, + pub split: Option>, pub floors: std::vec::Vec<(u32, i128)>, pub fee_bps_override: Option, pub manager: Option
, @@ -119,6 +121,7 @@ impl Spec { Spec { budget, milestones, + split: None, floors: std::vec![(1, budget)], fee_bps_override: None, manager: None, @@ -130,6 +133,13 @@ impl Spec { self } + /// Pays milestones unequal shares, in basis points. + pub fn split(mut self, shares: &[u32]) -> Self { + self.milestones = shares.len() as u32; + self.split = Some(shares.to_vec()); + self + } + pub fn fee_override(mut self, bps: u32) -> Self { self.fee_bps_override = Some(bps); self @@ -168,7 +178,16 @@ impl<'a> G<'a> { owner: self.owner.clone(), token: self.token.address.clone(), total_budget: spec.budget, - release_kind: ReleaseKind::Multi(spec.milestones), + release_kind: match &spec.split { + Some(shares) => { + let mut v = Vec::new(&self.env); + for share in shares.iter() { + v.push_back(*share); + } + ReleaseKind::Split(v) + } + None => 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), diff --git a/contracts/events/src/tests/grant_scenarios/randomized.rs b/contracts/events/src/tests/grant_scenarios/randomized.rs index e4eb505..58614a2 100644 --- a/contracts/events/src/tests/grant_scenarios/randomized.rs +++ b/contracts/events/src/tests/grant_scenarios/randomized.rs @@ -1,5 +1,5 @@ -//! Seeded random grants: budgets, milestone counts, tiers, partner money -//! before and after selection, releases and forfeits in any order, then the +//! Seeded random grants: budgets, milestone counts and splits, 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::*; @@ -45,6 +45,25 @@ fn run(seed: u64) { 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 split: Option> = if n > 1 && rng.chance(50) { + let raw: std::vec::Vec = (0..n).map(|_| 1 + rng.below(100) as u32).collect(); + let total: u32 = raw.iter().sum(); + let mut shares: std::vec::Vec = raw.iter().map(|w| w * 10_000 / total).collect(); + let short = 10_000 - shares.iter().sum::(); + let largest = (0..shares.len()).max_by_key(|&i| shares[i]).unwrap(); + shares[largest] += short; + Some(shares) + } else { + None + }; + // The smallest award whose every milestone pays at least one unit. + let min_award: i128 = match &split { + Some(shares) => { + let smallest = *shares.iter().min().unwrap() as i128; + (10_000 + smallest - 1) / smallest + } + None => n as i128, + }; let early: std::vec::Vec<(Address, i128)> = (0..rng.below(4)) .map(|_| (g.someone(), usdc(10 + rng.below(500) as i128))) @@ -57,7 +76,7 @@ fn run(seed: u64) { 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)) + .map(|w| (share * w / weight_sum).max(min_award)) .collect(); assert!( awards.iter().sum::() <= pool, @@ -69,7 +88,11 @@ fn run(seed: u64) { .enumerate() .map(|(i, a)| (i as u32 + 1, (a / 4).max(1))) .collect(); - let id = g.create(Spec::new(budget, n).floors(&floors)); + let spec = Spec::new(budget, n).floors(&floors); + let id = g.create(match &split { + Some(shares) => spec.split(shares), + None => spec, + }); for (partner, amount) in early.iter() { g.contribute(id, partner, *amount); } @@ -106,7 +129,10 @@ fn run(seed: u64) { let expected = if count[*r] == n { awards[*r] - settled[*r] } else { - awards[*r] / n as i128 + match &split { + Some(shares) => awards[*r] * shares[*m as usize] as i128 / 10_000, + None => awards[*r] / n as i128, + } }; if *forfeit { g.forfeit(id, &people[*r], *m, expected); diff --git a/contracts/events/src/tests/grant_scenarios/split.rs b/contracts/events/src/tests/grant_scenarios/split.rs new file mode 100644 index 0000000..79a6316 --- /dev/null +++ b/contracts/events/src/tests/grant_scenarios/split.rs @@ -0,0 +1,184 @@ +use soroban_sdk::vec; + +use super::*; +use crate::event_ops::MAX_SPLIT_MILESTONES; + +fn refused(g: &G, spec: Spec, error: Error) { + g.token_admin.mint(&g.owner, &usdc(2_000)); + fails_with(g.events.try_create_event(&g.params(&spec), &g.op()), error); +} + +#[test] +fn a_split_is_stored_on_the_grant() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 0).split(&[3_000, 4_000, 3_000])); + assert_eq!( + g.event(id).release_kind, + ReleaseKind::Split(vec![&g.env, 3_000, 4_000, 3_000]) + ); +} + +#[test] +fn a_split_must_add_up_to_the_whole_award() { + let g = setup(); + refused( + &g, + Spec::new(usdc(1_000), 0).split(&[3_000, 4_000, 2_999]), + Error::InvalidMilestoneSplit, + ); + refused( + &g, + Spec::new(usdc(1_000), 0).split(&[3_000, 4_000, 3_001]), + Error::InvalidMilestoneSplit, + ); +} + +#[test] +fn every_milestone_in_a_split_pays_something() { + let g = setup(); + refused( + &g, + Spec::new(usdc(1_000), 0).split(&[10_000, 0]), + Error::InvalidMilestoneSplit, + ); + refused( + &g, + Spec::new(usdc(1_000), 0).split(&[]), + Error::InvalidMilestoneSplit, + ); +} + +#[test] +fn a_split_is_bounded() { + let g = setup(); + let mut shares = std::vec![1u32; MAX_SPLIT_MILESTONES as usize]; + shares[0] = 10_000 - (MAX_SPLIT_MILESTONES - 1); + g.create(Spec::new(usdc(1_000), 0).split(&shares)); + + let mut over = std::vec![1u32; MAX_SPLIT_MILESTONES as usize + 1]; + over[0] = 10_000 - MAX_SPLIT_MILESTONES; + refused( + &g, + Spec::new(usdc(1_000), 0).split(&over), + Error::InvalidMilestoneSplit, + ); +} + +#[test] +fn a_split_is_for_grants_only() { + let g = setup(); + g.token_admin.mint(&g.owner, &usdc(2_000)); + for pillar in [Pillar::Crowdfunding, Pillar::Bounty, Pillar::Hackathon] { + let mut params = g.params(&Spec::new(usdc(1_000), 0).split(&[5_000, 5_000])); + params.pillar = pillar; + fails_with( + g.events.try_create_event(¶ms, &g.op()), + Error::InvalidReleaseKind, + ); + } +} + +#[test] +fn each_milestone_pays_its_share() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 0).split(&[3_000, 4_000, 3_000])); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(1_000))]); + + g.pay(id, &a, 0, usdc(300)); + g.pay(id, &a, 1, usdc(400)); + g.pay(id, &a, 2, usdc(300)); + assert_eq!(g.status(id), EventStatus::Completed); +} + +#[test] +fn awards_of_different_sizes_share_one_split() { + let g = setup(); + let id = g.create( + Spec::new(usdc(1_500), 0) + .split(&[2_000, 8_000]) + .floors(&[(1, 1), (2, 1)]), + ); + let a = g.someone(); + let b = g.someone(); + g.select( + id, + &[(a.clone(), 1, usdc(1_000)), (b.clone(), 2, usdc(500))], + ); + + g.pay(id, &a, 0, usdc(200)); + g.pay(id, &b, 0, usdc(100)); + g.pay(id, &b, 1, usdc(400)); + g.pay(id, &a, 1, usdc(800)); + assert_eq!(g.status(id), EventStatus::Completed); +} + +#[test] +fn the_last_milestone_settled_takes_the_rounding_in_any_order() { + let g = setup(); + // 1_000_001 units: 30% and 40% round down, and the remainder carries the dust. + let award = 1_000_001; + let id = g.create( + Spec::new(award, 0) + .split(&[3_000, 4_000, 3_000]) + .floors(&[(1, award)]), + ); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, award)]); + + g.pay(id, &a, 1, 400_000); + g.pay(id, &a, 2, 300_000); + // Milestone 0 settles last: its 300_000 plus the one unit of rounding. + g.pay(id, &a, 0, 300_001); + assert_eq!(g.status(id), EventStatus::Completed); +} + +#[test] +fn a_forfeit_releases_that_milestones_share() { + let g = setup(); + let id = g.create(Spec::new(usdc(1_000), 0).split(&[2_500, 5_000, 2_500])); + let a = g.someone(); + g.select(id, &[(a.clone(), 1, usdc(1_000))]); + + g.pay(id, &a, 0, usdc(250)); + g.forfeit(id, &a, 1, usdc(500)); + g.pay(id, &a, 2, usdc(250)); + assert_eq!(g.owed(id), 0); +} + +#[test] +fn selection_refuses_an_award_too_small_for_its_smallest_share() { + let g = setup(); + let id = g.create( + Spec::new(usdc(1_000), 0) + .split(&[1, 9_999]) + .floors(&[(1, 1)]), + ); + let a = g.someone(); + // 9_999 units leave the 1 bps milestone with nothing. + fails_with( + g.events + .try_select_winners(&id, &g.specs(&[(a.clone(), 1, 9_999)]), &g.op()), + Error::InvalidDistribution, + ); + // 10_000 units pay it exactly one. + g.select(id, &[(a.clone(), 1, 10_000)]); + g.pay(id, &a, 0, 1); + g.pay(id, &a, 1, 9_999); +} + +#[test] +fn a_split_grant_selects_once() { + let g = setup(); + let id = g.create( + Spec::new(usdc(1_000), 0) + .split(&[5_000, 5_000]) + .floors(&[(1, 1), (2, 1)]), + ); + g.select(id, &[(g.someone(), 1, usdc(400))]); + fails_with( + g.events + .try_select_winners(&id, &g.specs(&[(g.someone(), 2, usdc(400))]), &g.op()), + Error::WinnersAlreadySelected, + ); +} diff --git a/contracts/events/src/types.rs b/contracts/events/src/types.rs index 3056eb0..57b2b43 100644 --- a/contracts/events/src/types.rs +++ b/contracts/events/src/types.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracttype, Address, BytesN, Map, String}; +use soroban_sdk::{contracttype, Address, BytesN, Map, String, Vec}; // ============================================================ // PILLAR @@ -53,6 +53,20 @@ pub struct CancellationState { pub enum ReleaseKind { Single, Multi(u32), + /// Grants only: each milestone's share of an award in basis points, in + /// milestone order, summing to 10_000. Appended so stored records decode. + Split(Vec), +} + +impl ReleaseKind { + /// How many milestones a multi-release event pays across. + pub fn milestones(&self) -> Option { + match self { + ReleaseKind::Single => None, + ReleaseKind::Multi(n) => Some(*n), + ReleaseKind::Split(shares) => Some(shares.len()), + } + } } // ============================================================ diff --git a/docs/audit/testnet-grant-run-2.1.0.md b/docs/audit/testnet-grant-run-2.1.0.md new file mode 100644 index 0000000..e69c8e7 --- /dev/null +++ b/docs/audit/testnet-grant-run-2.1.0.md @@ -0,0 +1,557 @@ +# Testnet grant run, events 2.1.0 + +Run 2026-10-08T04:37:15.647Z against `CBEODVJGUYCIYTVXD7KI5UG3BJ2UE4T7AGI2TGY3T4Q5GQRFGTRYVTZP` with the BGT test asset `CBEOUEMDTM56NRTOOSXCPGMD33RPD5YWEKJ542HAEVREAYCC4QIZ3XIU`. +337 of 337 checks passed across 163 transactions. +Highest fee paid by one transaction: 10569590 stroops. + +## setup + +- pass: BGT CBEOUEMDTM56NRTOOSXCPGMD33RPD5YWEKJ542HAEVREAYCC4QIZ3XIU is whitelisted on CBEODVJGUYCIYTVXD7KI5UG3BJ2UE4T7AGI2TGY3T4Q5GQRFGTRYVTZP (2.1.0) + + - [provision 2 accounts](https://stellar.expert/explorer/testnet/tx/9298e9835ae5a493f0aab66f2a210acc0551b5f032993d21595aa02ddae8c85f) + - [provision 4 accounts](https://stellar.expert/explorer/testnet/tx/71f593423785cf5d9138b33d7dc7d8e77214b516194a54967deaffca6f0978cc) + - [provision 15 accounts](https://stellar.expert/explorer/testnet/tx/85b76ddf167c4a1d60ce197293e92961da2b928cc6bdbca5fb118f6f942e53bd) + - [provision 15 accounts](https://stellar.expert/explorer/testnet/tx/c812a8e1bf74300bf449d41a074418107f0356d6bcbc3ab6aef3c2e833e2d432) + - [provision 15 accounts](https://stellar.expert/explorer/testnet/tx/bf12a4961f0abdc62f60ceb0e080ef61032fb46eb39b436a9fae64070f3f31cd) + - [provision 7 accounts](https://stellar.expert/explorer/testnet/tx/87101d1c623c0845d20127838795b27de19eabd767f46ea895f188e2109fcd71) + - [provision 3 accounts](https://stellar.expert/explorer/testnet/tx/637658e5fa741ba6bb6a478ce56d18f3b3809e35d0a7eecc1714cb90c44d9c8e) + +## micro grant + +- pass: publish the grant +- pass: owner paid budget plus fee +- pass: select three recipients +- pass: release milestone 0 to GBYPFH +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GB5H52 +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GBT7XO +- pass: milestone 0 paid exactly +- pass: completes when the last award is paid + + - [create_event](https://stellar.expert/explorer/testnet/tx/c7fa85248d94d07c056c9bd55124821708f4adadcb07669b6d29d15bd9d90994) + - [select_winners](https://stellar.expert/explorer/testnet/tx/10ff28736c6415ad7c6a5839823a8dd91d2f575ffbf365406352c23a74d6e1bd) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/cc496f52831dc6740c91426fcb43d1592a92afd62c4d00f15ddb6102ef687e10) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/8f4b54167dee3041505b5a77eea08d1d25227b2f5bf687afbe0e31cdb7e57fec) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/d71e3647f9fa42e8cd64d74472be96db5b42f5ac509ad88d27546b154d75a1d0) + +## tiered, rounding, any order + +- pass: publish the grant +- pass: owner paid budget plus fee +- pass: select three tiers +- pass: release milestone 2 to GC6YTP +- pass: milestone 2 paid exactly +- pass: release milestone 0 to GB72DO +- pass: milestone 0 paid exactly +- pass: release milestone 1 to GD6H3Z +- pass: milestone 1 paid exactly +- pass: release milestone 2 to GB72DO +- pass: milestone 2 paid exactly +- pass: release milestone 0 to GC6YTP +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GD6H3Z +- pass: milestone 0 paid exactly +- pass: release milestone 1 to GB72DO +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GC6YTP +- pass: milestone 1 paid exactly +- pass: release milestone 2 to GD6H3Z +- 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/1bd70598369abe4b724d57eb5c38d4c016d083c3ef9dc83178831cf4eefdeacb) + - [select_winners](https://stellar.expert/explorer/testnet/tx/5107ed6edc330d3135c1fc84be0ae92b780c36abc09dbc833f520f825bdcb093) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/696fc666ef35d03ce0889dc4272911f1694cff03951105856af10bcca3f8f735) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/8d0729307fe2cfe9469a82534fbbdb06a8e5b398ae970f0d26e8eac4f8143bee) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/cd41ecc27bf284f8142ab61a97bf7c992db25791725eec0c6853e7db97d0d2c6) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/05512a32bf0db5931af7498258b3e14c517014ac6d3de51557a407c19f9d9459) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/1682e202d5de4f515ac001191d9e265026aad653b1c73e1f18ac9f1b706ccc99) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/78b159a5fd65856f0d143240c07b31f4cfa1904bf5cf47cf72e0460a449f38ac) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/16082e1ece2f103ff76228da56322550dfc62bb5f87f6919dfac37b67a22282a) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/7c77f3c1d8b9c518130e04e301aaefb9acbb83abef97f90968ff9d6b49a7ee95) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/30ccba82d0b02a2ac9eb99bf9e08c6bddda8d201f36845864da1936f23e70576) + +## milestone split, any order + +- pass: a split that does not add up to the whole award +- pass: publish the grant +- pass: owner paid budget plus fee +- pass: the split is stored on the grant +- pass: select two awards of different sizes +- pass: release milestone 2 to GDGKLG +- pass: milestone 2 paid exactly +- pass: release milestone 0 to GAFPD2 +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GDGKLG +- pass: milestone 0 paid exactly +- pass: release milestone 1 to GAFPD2 +- pass: milestone 1 paid exactly +- pass: release milestone 2 to GAFPD2 +- pass: milestone 2 paid exactly +- pass: release milestone 1 to GDGKLG +- pass: milestone 1 paid exactly +- pass: the larger award was paid exactly +- pass: the smaller award was paid exactly +- pass: completes when the last share is paid + + - [provision 2 accounts](https://stellar.expert/explorer/testnet/tx/e3a7dd09d1ccccb596ecf37fc8b278f37d88f204dba66b111a3b05ec754fb102) + - [create_event](https://stellar.expert/explorer/testnet/tx/2988397b493a06c7fc2b5599d88b2f88db4be0d56eb11b4f796343d10ae03b09) + - [select_winners](https://stellar.expert/explorer/testnet/tx/641cebbd867cfa01df7b0d046445563ee5931eb9c165acb4b2c47526333073dd) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/692df50d77694169c00aaf0e28e5cca27ca58900ae89f73f6e05d57113963ac2) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/bcdba5746da342db99316c38fe1e99e0537f7cfba6843d827e1fc5b1afe3375a) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/7aaa5a560ef83054e80906b62c2c5401305d48dfd58712c0a244a61ef1a3770b) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/ea1db9034654679388184af27f4f28b65cc5d2058b00f66d2765b93aebb0e54d) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/39aa7104f09888f32908bcd409eb40931eda321254947822cdcf71918e6b1d64) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/21e3f0982a198def0d16a5a986873f9d6d9256766e3402c02a439fc37e2d66be) + +## forfeit and the cancel guard + +- pass: publish the grant +- pass: owner paid budget plus fee +- pass: select one recipient +- pass: release milestone 0 to GAYYPP +- 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 GAYYPP +- 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/0484888898b6e7e791d18c1d663d231bbcc93d577b8dbb75c23b773bdac72dec) + - [select_winners](https://stellar.expert/explorer/testnet/tx/1e6f237d19aa3e807888082f858399739b09bccb07d67a52903a461ceb9388ce) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/4f4d4b64eded01895535b40772ba757bde584c829c3c4c575ef624b14c9ba8d9) + - [forfeit_milestone](https://stellar.expert/explorer/testnet/tx/3992159ddef33536b3127cb7fd2d598398263362d8bdf7398c7b19849edfbfad) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/6af71c0f3dd23ede021f319a412f5d6713586e56722c22e1554b8ab835f912b9) + - [start_cancel](https://stellar.expert/explorer/testnet/tx/1d0d3d7bf16f7254aff13a80465a3d99bc1115b57d82fb00a2f8d04c95b26ae5) + +## 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 GBQ6R5 +- pass: milestone 0 paid exactly +- pass: partner adds 15.0000000 +- pass: partner paid amount plus fee +- pass: release milestone 1 to GBQ6R5 +- 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/233b0e061a89baa4d7cfd9be9665f6a5cf77c9ff1168f4d40c7bb6269515fb06) + - [add_funds](https://stellar.expert/explorer/testnet/tx/8ad5aa4f2ae13f25f5387a7984e2c57821ebee646238c98f81419f4f279ee2af) + - [select_winners](https://stellar.expert/explorer/testnet/tx/04131c1b121dc4a7331ed637013430da9b5f743d742b5e1385e5e06690e82f95) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/e398e24f7016b22e32ef390d1cfe011b8b671d93d55886d9f1912a0c48f03e67) + - [add_funds](https://stellar.expert/explorer/testnet/tx/dbadb3c1020e11fa49083033c4423d677dbbe51cf64bc0b1a76ef863632b832e) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/79388634bd48fdfae8dea9b605986c98c120e2753b424804908c8234486f9f0a) + - [start_cancel](https://stellar.expert/explorer/testnet/tx/bfe19444fcd5d413e7255794f9a27f624bb1bb341daad66bae7011cbde5b2749) + - [process_cancel_batch](https://stellar.expert/explorer/testnet/tx/a567132975aaa6f6afaa73621394e57d39932681b5200c4781b317c6ef2a8d20) + - [finalize_cancel](https://stellar.expert/explorer/testnet/tx/e434e2df69fbbeec55281cf05a8a4f82151b912a5deff8223219e26e4b503281) + +## 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/6f0957d5f2ad594562e5a417d48f7e10ff49333ca94955e3eb974ef78219dc9f) + - [add_funds](https://stellar.expert/explorer/testnet/tx/ceb403cf8fc340f6ccf077d01f3841f8b014d656ebc7aa12457d2629c6a5b394) + - [add_funds](https://stellar.expert/explorer/testnet/tx/8cc13e703a33564bd850cb0840d488ed0dd5196897771ee48fcb0aad908d621f) + - [freeze](https://stellar.expert/explorer/testnet/tx/ac5521bdca4f3b78b3b2d111524c907b5cec150ebd4b08774e8a288f214e3b58) + - [start_cancel](https://stellar.expert/explorer/testnet/tx/21030978f54baaf5482d50a0fc16f7bbc9a8e577e9ebe78ae55d924e3b08eafd) + - [process_cancel_batch](https://stellar.expert/explorer/testnet/tx/55a9086545ca7214261c3d61dd58658054d6f6ff27fe37d902d2c77b522233f4) + - [finalize_cancel](https://stellar.expert/explorer/testnet/tx/7ce9009150a3a8e9cc2f4b973613432ae88ce0350f40f3092b8525b1630accdd) + - [unfreeze](https://stellar.expert/explorer/testnet/tx/9b5618debaf3a5f1249428dc920d3576b70273282e4f0b15d595cfad0f9fd5ce) + - [claim_refund](https://stellar.expert/explorer/testnet/tx/2e8b543c562beaf16204110611ed0f1cf4dbec64a991f8e5299a5a5face3ef89) + +## 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 GDLWRQ +- 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/c5b47b3929573b5aca0212e8ae994cc096e6d8996f82d6a87931f61218c37350) + - [select_winners](https://stellar.expert/explorer/testnet/tx/8bb222337abcbe641feceab0e6cc0a42df9f39f0bfc8fafd8ef0940ecf6bee8a) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/f2ea3b23fc5c877a286bda283fe74f41a89d9bef5a70838143eec2a1a8a19b5e) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/fa5e9402260f73ce57502d9c19f261a6aa754d3e8d3de3bc166577897abc3ecb) + - [forfeit_milestone](https://stellar.expert/explorer/testnet/tx/eddc904b593ed5fb8a086e7b72d077a2819f251be350a397f744f653f8f7d371) + - [forfeit_milestone](https://stellar.expert/explorer/testnet/tx/4631e11cca40e28e4f7f3d1af94b9beac96da77ad79ec5e0547e7efe50935bfe) + - [start_cancel](https://stellar.expert/explorer/testnet/tx/a78f16ad7749b6162ec74fcd00d6ccaabc40251d967b861992ecfbe109c33d66) + +## 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 GCKYHA +- pass: milestone 0 paid exactly + + - [create_event](https://stellar.expert/explorer/testnet/tx/52ec37ae2bbf5374fc8f7ac3bbcf438b06e68d4539a2e8a33a9811350a207184) + - [select_winners](https://stellar.expert/explorer/testnet/tx/89b27a60e009efbefdc39a95d43ee2e2fc401f19c753779cc376c604f231f36e) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/4c16e2307023ba6a7b1a1c4869d48d0f18f859465525f96cc975d050055dc83e) + +## 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 GDNZ7R +- pass: milestone 0 paid exactly +- pass: release milestone 1 to GDNZ7R +- 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 GDNZ7R +- pass: milestone 0 paid exactly + + - [create_event](https://stellar.expert/explorer/testnet/tx/1d7d450183739e20df6b1be17f4e6356d4078c71c39ee48b41d79db30748717f) + - [accept_manager](https://stellar.expert/explorer/testnet/tx/8d38e99993dbb6ccafb8bc846f35245dfa42dff3198e5d81c1c8d165f02dc51c) + - [select_winners](https://stellar.expert/explorer/testnet/tx/360cc3f1833600ed76f02bfd2cab152b63f7c15283322e5b9c931e274caaab1c) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/5f02aaba4a5ae4f1780ed1ed3d21dba8ceb0c729709a04c55529d8085074a4cf) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/34db0f3636da24a5bcb8593b6cb26a92c7b9a17fae345f18dda2486d60a83e71) + - [create_event](https://stellar.expert/explorer/testnet/tx/4e683964f079e1d429e216286be2b9ca120c57b3a730c03c357c0c0d8811befe) + - [accept_manager](https://stellar.expert/explorer/testnet/tx/474890e99ca2f1c46b3a38d56c061e94d278f34254abf9bb0bb6ed400f8aa5dd) + - [reclaim_management](https://stellar.expert/explorer/testnet/tx/aff14140bb4633c3313b2870e3bc7ab78e664607159ea21ec80fe4b64169ce54) + - [select_winners](https://stellar.expert/explorer/testnet/tx/b2b830153c18e8cc3eb3adfdb2794041787fb758e8803362013aecc1773d48d6) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/73635994c2e7fe49b39d21802d9d9867c81bcddf54800bb1e57d7b9af4a8f261) + +## 40 recipients + +- pass: publish the grant +- pass: owner paid budget plus fee +- pass: select 40 recipients in one transaction +- pass: release milestone 0 to GBCMCO +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GD44J4 +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GATWN5 +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GCBKBB +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GBUOSW +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GAN2GJ +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GA263D +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GA7CSV +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GAX3V6 +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GAUHOK +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GBY53F +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GC6LKY +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GA6NGO +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GCBYZH +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GCDIFV +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GCSXBP +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GAN3I6 +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GD2NAN +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GDK6G4 +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GA4GFW +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GBCBMF +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GCASCB +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GANYTM +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GBWPKC +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GDVF35 +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GBY34X +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GAZNDW +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GCSEPR +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GAGNPF +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GCMW2N +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GAIYQO +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GBLFCZ +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GADYCO +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GCERD6 +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GD7PKX +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GBBJ72 +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GB3LJI +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GCEODO +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GBHR7W +- pass: milestone 0 paid exactly +- pass: release milestone 0 to GDN6OG +- pass: milestone 0 paid exactly +- pass: release milestone 1 to GBCMCO +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GD44J4 +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GATWN5 +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GCBKBB +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GBUOSW +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GAN2GJ +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GA263D +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GA7CSV +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GAX3V6 +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GAUHOK +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GBY53F +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GC6LKY +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GA6NGO +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GCBYZH +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GCDIFV +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GCSXBP +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GAN3I6 +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GD2NAN +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GDK6G4 +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GA4GFW +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GBCBMF +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GCASCB +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GANYTM +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GBWPKC +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GDVF35 +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GBY34X +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GAZNDW +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GCSEPR +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GAGNPF +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GCMW2N +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GAIYQO +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GBLFCZ +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GADYCO +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GCERD6 +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GD7PKX +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GBBJ72 +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GB3LJI +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GCEODO +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GBHR7W +- pass: milestone 1 paid exactly +- pass: release milestone 1 to GDN6OG +- 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/e4be5c6f9d78667f6692fa8c88d6bb07d271ad95fd5bc6a2a959d2d15e520585) + - [select_winners](https://stellar.expert/explorer/testnet/tx/cc425a97bdea34ac4ba598732f796bb376ec09fcdee64b4e126271b7f14783e9) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/8f7420f18531c736cbb8eae8b156c598743b17477463df5801fca86142f8e629) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/89a5f74f1f8ce59b7a83a5d7015564a89bf1e953faf26174bc902ebf53955d6a) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/95cad232dbeec0fbcf28746277a6234df3121d44c313981403ec78b51743671c) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/08b652d0e8b131d28950ddc28938a7cf687bcd3d1e17b3a1c43a9c137d3c6c83) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/0bbe13f5051d60208daaad29a0efd0a67713344e7f607269a34bcaa07f59e2a7) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/c39aaba1a32955115a6949b3e2886d4c788be89ac91d8ded8217f9ce0473e1b7) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/c6ede712ae141d32b5ba68f44b2e24f428a01203e0452394ec1f8742a306d5ce) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/1e9ed4231796963b7ee403eb7fc7dab8a1b44960ed0f505379c7e23c15a67e5f) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/a44097e41b9ca93ec11d62c1b711e6dbeb0bda340159635c58ca7b811c1fa942) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/cc394a81a313af273af6cdd78c28c6a3df5c6d02a15db4f3a0180dab4e343910) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/eec39b5d19dd9c4a9e64ca3cb141e14de359512041d415becc2a22d5127fc75f) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/89af808570c33a27e946e417873f8f30c394ebf861c68b3a72c1ea9f7c9d29c2) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/6ae5e4b1e2a5667e96abca614c331b6ebd17158cb66c4c0a316fa6e6bae7f9fb) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/95e2f92bc7d4280ac40c54bd81abd4e95781c696663287e8ba6f79eff784f07c) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/6a395043baca5fc4d59b65a780f26a6c20a2c0436839caf5294bd8b617b8b5d9) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/433ec596f05ddf1c74de83d5021417cdcb580cd66b25929ea3c1dceb656fe35c) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/771c4926b3e24100d874c4876e788bedf696f60eebcd4e3e035e64f27a783eb4) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/a164215722d5ee1a9471a8377e16baacf8e559929f8115d9910c293c4217a231) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/9184da4e7d63ff2e45e1f1826038c49179b66eae6252ba9e21acdea1720019a6) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/3bec99d55d34530aebf9ffa9ade3dceff5593e55c0cd0542c092b71c1d989a59) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/dbd9a6cfdff6609a1853e16e37b8f710f5baa66be19404101a9c9afd83d03f55) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/e229b5db3849fd670c0c5aaabd497060e02a3f8f55819698e776707011eb14bc) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/898d96f24d298a8b207a25d92012caae5406d43c674402cc1ebeaa9dff5a502b) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/a63f1d35566136a448788fa5c92f98ed519f8b15a0859f09a66fec199b69777e) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/dbb0955f6ac1a5e49d3c4008b36352993d1bb020e7f06e4f8ca85a5dddcd528c) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/8be9de3c2f7670e42b0177af00701028ed2b533c661142b96615761fc644b928) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/aa97c88f7172ef996ddf3e5a8954acaec62d06b31d9476f7c7e7fe3f8d962f8b) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/bfd21b6446ecfa35ff7f384a7fafdaf303065b542fe46170689eaf1fff4674de) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/720f1ae1011629be8f732a2789d7e9c7c6eaf9be59c1314d2048dc9243732878) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/2c6ddf6e3d8f6f4b925276239fbbe84db9793d962ecae4e33b33397b4bcf31c3) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/0e70202fb188f7e8d9ff70718867069da992be288fefbc37103c83d507616575) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/1cd47f44747d43b8102adc09253c47662ba95117aa8e79c30bbe24b6de26a5b9) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/3b0c563329068af8db27bb4367d7a00311c2de3fcfe526440d10a19894a294da) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/a24b294b0541abd99f102505c81e06605acef6227cfcdc2a2658e1d3c2eee500) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/1192068375a439115d4fc3de35c1d61b3033074674a04623e21244ce3502469d) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/b0669d5606a0fd80b87c41b2393ef2f5756dff8718643fd0c447ae644786b992) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/d43e3093042feefea227500c01d5ca9d78ac172526e712629cc1640b3202d9d1) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/623d556799d8b1ad746a797dd87e8d0cac84184dc205ec7179ae566e87e31c69) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/4a0a6ec128d4c0557a7c020368737782b3e9a742a42f6921343e7c41ebd2d379) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/0bc4d59a3254996be7f16ea7442d1d078fde587f1c36c568c2315a4d83072f86) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/bf75116941d8b5d9a0093855e990f86e84280e82d168a555d03e9b2946af6708) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/072ec4a7327af95445cc03c1e35ec74e12bef08c4259993ba92175d0b63dfd98) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/43e01892e03dd0859541ed818eb1a6492668f530d2d6a67bbd9c120466b46277) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/057f07aad13f6c7e2267e07eaaa92f4a75da8397b52f04104b016f3e149f103b) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/bcd512c5c05e84a42bf1b7e697db6673be92e3b034e82f9d3146224234506459) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/1f8bcce6a548c7cb1abd594b9f9cde41e2ad9d701b88f56dcd69e6a7e8b9d949) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/bb0f994441a3029a433f9e5fe3925a49830d87063693f228ae2a7f759c67103b) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/38db46a638c18551f2387b13b78d14b03c51efc2f03987806783ab780f584ef0) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/45db69e1f2ca18766d8169d598992ccda93543156ed0183c8c3543da719a211e) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/51c6ee92cfe4b031a50a033da8cf4a33096e7c36f148bcae8f9d93735c9d9ba2) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/85623d3340b3a82be84ff41b1f3e93da601eae4072c86351aa77f5057d3934e5) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/8ed6fedef9dcd9c32a6e3be15a29bb715303132e5a169665d0867827845d0e49) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/47c359f3d09dfd15b10e88faf40297af0590ca9c731376c3ce9de334dbaad6e7) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/5279e9499b57063d866250aed37dfe48b95ddea15f95eb59a9c1e4a8e7420f72) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/6323583e3cfaa1b65fe9c4fdab1b90a4309a9ac88702528840536f880f9a4127) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/1aa6d2908b32288fa7b0785e9da94555d38172bc2db8c3adc6227a78a619cb39) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/09aeccdd2ac50a2cb62f71b98b9e2c424f4128cce575af66544c0f452ffc3044) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/ce16267f65638e973d6a2979c32a38d55ce61acb9a5f7dd0b999ab9833de8e14) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/dd443e6b3dcfa42c28bdbca18f306b853629657d3bd597325b34b6b986ec9db4) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/23008c7c3f4fdcf2c2cda2ccd0d23a9cda9d43de1b1a938845f4fb5cb4daedc2) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/af6b6dd105d1cc3a00d0c45d1d8a4175e7538c19848358f0886196f64fdc9aa4) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/95157cd2849cad9e19af6728428a218c805ff8f9c80cb9beac4472c37fa819b8) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/ca0b6ff1380a37d682f0e08fd4eb385c7af3703afeb5b6e4bc972d63d105ce53) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/629155e6eb22dc9273622b14e7ffac03c254e6dd02835152b6a1924c27401f72) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/9a4b2ea770431e13c9aed2f7141f7e71fead59a469d147dc560e11a2b3bb7331) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/5963df5d2749365d8d11af3978ad1e5b73f1ea011ed9bb4f5b4f289c1c6e475a) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/00c9a40db36e0d0a653ed8a468616d08499fea727eb494c15e3557b9527bd2c5) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/a3b22acd80aeb7d44a018d61ae39ed1b337c0506613e772a89e85da150ce8476) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/eb04dcdb1fd78621961aab5b53545f099167ab149ea5131b9c5c78fc13bc94b6) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/ae11fac683c51fcd105c07b96e83aa27ccebcc202bfc60b1b0601f0d99b1152f) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/5644fa09eb446a71e274695a20a8ba80402acec3d7865892b7a75690068c1a2a) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/f7ef6c5c0eb8b539bdf140b548e9005708a446fe320dd4072ed3e966c1348747) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/d408529208c25f55d57b08984410986a2d0569e8039af0751ec396701d918797) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/0dbdff654e957da7ddcced6dd7d6a7d3165538c6f612bb945f3ea01aa79b47a0) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/e6794ba95b074fa2acf88192d46bf91010363ca40cd1f8c1d634941e14c0b969) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/3bb64aae59bb131c897a01f72a4ad34e99e841b7d2933abbffeedbe536b88970) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/82f8ee770495db7a77ba1980479ee39abda480896c888686e2e8b60d6cad81b8) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/f73fd87258335672c62d30e41c952c6f589af0b9f2738aad28b299dfe3707735) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/8bf0a1e8b632cd0e0e2f035af8785017bec7ce85bd5346c87368ad3d14bd5dc8) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/3597c86c885b31224ae3450461249b0bb275f864dc62a917e7c478cb3672a5a4) + +## 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/453e73dc6472c73cd0772804db0293d5f696a514463ec23b84bfd72be2a56d94) + - [create_event](https://stellar.expert/explorer/testnet/tx/5a181104d0f181a00c5ee092412b72aca0d75429eedab8cd33d550f961f0cb78) + - [add_funds](https://stellar.expert/explorer/testnet/tx/580e6f1b46991856acfb48981ea919f364a88b9f9fe2e55acae06533a9d83ad1) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/4082e3ee6f17ff257f00f19af75ae829917140a85ecb3a7eca8fe29b507a2d5c) + - [claim_milestone](https://stellar.expert/explorer/testnet/tx/051b276bbf8b5fdb60cc9ec9c6ec7db831474f1b26c35940a6cd019efa05a038) + +## fees + +- pass: fee account received exactly the fees charged + diff --git a/docs/storage-compatibility.md b/docs/storage-compatibility.md index 161348a..7189fd7 100644 --- a/docs/storage-compatibility.md +++ b/docs/storage-compatibility.md @@ -14,12 +14,13 @@ of this tree the way `docs/upgrade-runbook.md` does it on chain: upgrade timelock), page `migrate_events`, check that `migrate` refuses with `MigrationIncomplete` before the conversion and stamps after it, and unpause. -3. Read everything again through events 2.0.0 and profile 1.2.1 and require the +3. Read everything again through events 2.1.0 and profile 1.2.1 and require the same values. The two converted records must carry prize floors equal to what each position was paid. 4. Run a grant through the upgraded pair on a fresh token (selection, releases, a forfeit, the close that returns the forfeited share) and check that event - ids continue from the deployed counter. + ids continue from the deployed counter, then a grant published with a + milestone split, paid in full. The test is marked `#[ignore]` because it needs the release wasm, which a plain `cargo test` does not build. Run it with: diff --git a/docs/threat-model.md b/docs/threat-model.md index 56c008b..0bcc071 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -1,11 +1,12 @@ # Boundless STRIDE Threat Model -**Version:** 1.2 -**Date:** 3 October 2026 +**Version:** 1.3 +**Date:** 8 October 2026 **Prepared for:** Stellar Development Foundation, Soroban Security Audit Bank **Prepared by:** Boundless Engineering -**Contracts under review:** `boundless-events` 2.0.0 and `boundless-profile` 1.2.1. Mainnet runs 1.7.0 and 1.2.0 until the upgrade in Section 4.4. +**Contracts under review:** `boundless-events` 2.0.0 and `boundless-profile` 1.2.1, plus the grant milestone split that `boundless-events` 2.1.0 adds (Tamp.20), which reaches mainnet in a later upgrade. Mainnet runs 1.7.0 and 1.2.0 until the upgrade in Section 4.4. **Repository:** github.com/boundlessfi/boundless-contract +**Changes in 1.3:** events 2.1.0 lets a grant pay its milestones fixed, unequal shares of each award (`ReleaseKind::Split`), adding Tamp.20. The testnet grant run was repeated on 2.1.0 with a split grant; the static analysis covers 2.0.0 and is due again for 2.1.0. **Changes in 1.2:** reworded for clarity, retired threats moved to their own table, flow steps renumbered after the participation removal, statuses limited to four defined values, and Section 4 reorganized. --- @@ -14,18 +15,18 @@ This is the threat model for the two Soroban contracts behind Boundless: `boundless-events`, which holds prize money in escrow and runs hackathons, bounties, grants and crowdfunding campaigns, and `boundless-profile`, which records builder reputation and earnings. It follows the Stellar Development Foundation's four questions: what are we working on, what can go wrong, what are we going to do about it, and did we do a good job. -The model lists 76 threats across the six STRIDE categories, each tied to a step in the on-chain flow and to a trust boundary, and each rated for likelihood and impact. +The model lists 77 threats across the six STRIDE categories, each tied to a step in the on-chain flow and to a trust boundary, and each rated for likelihood and impact. | Status | Meaning | Count | |---|---|---| -| Mitigated | A control in the contracts or in operating procedure addresses the threat. Every contract-level control has a test that proves it fails safely. | 62 | +| Mitigated | A control in the contracts or in operating procedure addresses the threat. Every contract-level control has a test that proves it fails safely. | 63 | | Accepted | The remaining risk is carried on purpose, with the reason written down. | 10 | | Retired | The feature the threat described was removed, so the threat no longer applies. | 4 | | Open | No treatment yet. | 0 | All ten Priority 1 threats, whose failure would be catastrophic, are mitigated and tested. -**Pending deployment** describes code, not threats: every 2.0.0 fix is in the code under review but not yet on mainnet. Section 4.4 lists what the upgrade involves and what the backend must ship first. +**Pending deployment** describes code, not threats: every fix since 1.7.0 is in the code under review but not yet on mainnet. Section 4.4 lists what the upgrade involves and what the backend must ship first. ## 1. What are we working on? @@ -45,7 +46,7 @@ Joining an event and submitting work happen on the platform, not in the contract | Builder | An account that takes part in 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. It may appoint a release validator to co-sign crowdfunding payouts in its place. | | Single-release event | A hackathon or bounty. Each winner collects their prize with `claim_prize` within 90 days. | -| Multi-release event | A grant or crowdfunding campaign, paid per milestone with `claim_milestone`. A grant milestone the owner decides not to pay is closed with `forfeit_milestone`. | +| Multi-release event | A grant or crowdfunding campaign, paid per milestone with `claim_milestone`. A grant milestone the owner decides not to pay is closed with `forfeit_milestone`. A grant's milestones pay even shares of each award, or the basis-point shares it was published with (`ReleaseKind::Split`). | | Managed and external wallets | A managed wallet's key is created, stored encrypted and used by the platform for the user. An external wallet, such as Freighter, keeps its key on the user's device. | ### 1.2 Scope and assets @@ -93,7 +94,7 @@ The threat tables in Section 2 refer to these steps by number. 4. **Top up.** Contributors call `add_funds` with at least 10 tokens. Outside crowdfunding the fee is paid on deposit; crowdfunding takes its fee at payout. The contract keeps a running total of money from contributors other than the owner, for refunds. 5. **Select.** The manager calls `select_winners` with awards (recipient, position, amount, reputation bump): up to 20 per call for hackathons and bounties, over any number of calls, or one call of up to 40 for a grant. Each position is awarded once, at or above its floor, with a bump of at most 100, and the awards plus everything awarded but unpaid (`EventOwedTotal`) must fit within `remaining_escrow`. A grant gives each recipient one award of at least one stroop per milestone and records it in a grant roster. Hackathon and bounty selections open or extend a 90-day claim window. 6. **Claim a prize.** Each winner calls `claim_prize`. The contract records the payment, transfers the tokens, then updates the winner's profile on a best-effort basis. -7. **Settle a milestone.** For a grant, the owner pays a milestone with `claim_milestone` or closes it unpaid with `forfeit_milestone`, which releases that share but leaves it in the pool. Each milestone is settled once and is worth an even share of the award; the last takes the rounding remainder. The contract records, transfers, then updates the profile best-effort. Crowdfunding also needs the release validator's signature (the admin's while none is appointed), pays the owner `remaining_escrow / milestones_left`, and takes the fee at payout. +7. **Settle a milestone.** For a grant, the owner pays a milestone with `claim_milestone` or closes it unpaid with `forfeit_milestone`, which releases that share but leaves it in the pool. Each milestone is settled once and is worth its share of the award, even or as published in a split; whichever settles last takes the rounding remainder. The contract records, transfers, then updates the profile best-effort. Crowdfunding also needs the release validator's signature (the admin's while none is appointed), pays the owner `remaining_escrow / milestones_left`, and takes the fee at payout. 8. **Cancel.** The manager calls `start_cancel`, which is refused while a hackathon or bounty prize is in its claim window or a grant award is owed. Without outside contributors the event settles at once; otherwise it enters `Cancelling`, anyone may run `process_cancel_batch` (up to 15 refunds per call) and `finalize_cancel` returns the owner's remainder. A refund an account cannot receive is set aside for `claim_refund`. 9. **Govern.** The admin may pause and unpause, change the fee (at most 10%) and fee account, manage the token list, appoint the release validator, rotate the admin in two steps, and upgrade: `propose_upgrade`, `apply_upgrade`, `migrate_events` (up to 8 events per call) and `migrate` (a one-time version stamp). @@ -238,6 +239,7 @@ The four retired threats are in Section 4.3, not here. | Tamp.16 | (Step 5) A grant selection names one recipient at two positions, leaving one award reserved and unpayable. | `select_winners`, `claim_milestone` | L | M | P4 | | Tamp.18 | (Steps 5, 7) A grant award smaller than its number of milestones rounds every share but the last to zero, so the award can never be paid. | `select_winners`, `claim_milestone` | L | M | P4 | | Tamp.19 | (Steps 7, 9) A migration that recalculates what an event owes from its winner records misses forfeits, which write no record, and reserves the forfeited share again; the grant can then never be cancelled. Every upgrade migrates the events created since the last one, so live grants are exposed. | `migrate_events` | M | H | P2 | +| Tamp.20 | (Steps 1, 7) A grant published with milestone shares that do not add up to the whole award leaves part of every award unpayable, or lets early milestones pay out money owed to other recipients. | `create_event`, `claim_milestone` | L | M | P4 | #### Repudiation @@ -306,7 +308,7 @@ The four retired threats are in Section 4.3, not here. ### 2.3 Priority summary -Of the 72 active threats, 10 are P1, 23 are P2, 23 are P3 and 16 are P4. +Of the 73 active threats, 10 are P1, 23 are P2, 23 are P3 and 17 are P4. **Priority 1 (10).** The controls whose failure would be catastrophic, and the ones the audit should check first. All are mitigated and tested: 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 (migration correctness), Tamp.13 (replayed operations), DoS.2 (cancellations that cannot finish), DoS.12 (a migration too large for one transaction), EoP.1 (selection by someone other than the manager), EoP.6 (admin functions called by a non-admin), EoP.12 (migration called by a non-admin). @@ -345,8 +347,9 @@ Of the 72 active threats, 10 are P1, 23 are P2, 23 are P3 and 16 are P4. | **Tamp.14** | Derived ids hash (SHA-256) the caller's operation id, a tag, an index, the profile address and the original signer, so one id used by two callers yields unrelated derived ids. Profile calls are also best-effort (DoS.7), so a collision could cost a reputation update, never a payout. Tests: `someone_reusing_an_op_id_cannot_block_or_strip_another_payout`, `sha256_child_ids_differ_for_xor_colliding_parents`. | Mitigated | | **Tamp.15** | Cancelling before selection is a deliberate owner right: the pool is the owner's money until awarded, and the contracts have no entry deadline. The platform's terms and the public `EventCancelled` record are the controls. | Accepted | | **Tamp.16** | A grant naming a recipient twice is refused with `DuplicateRecipient` before anything is reserved. Test: `one_recipient_cannot_hold_two_awards_in_a_grant`. | Mitigated | -| **Tamp.18** | A grant award smaller than its number of milestones is refused with `InvalidDistribution`, so every milestone pays at least one stroop. Test: `an_award_too_small_to_split_is_refused`. | Mitigated | +| **Tamp.18** | A grant award too small for every milestone to pay at least one stroop (smaller than its number of milestones, or under a split, smaller than 10,000 divided by its smallest share) is refused with `InvalidDistribution`. Tests: `an_award_too_small_to_split_is_refused`, `selection_refuses_an_award_too_small_for_its_smallest_share`. | Mitigated | | **Tamp.19** | `migrate_events` converts the record layout and nothing else; it does not recalculate amounts owed or awards, which every live payout path keeps current. The only records awaiting conversion need neither. Test: `a_migration_pass_after_a_forfeit_keeps_it_released`. | Mitigated | +| **Tamp.20** | `create_event` accepts a split only on a grant, and refuses one that is empty, longer than 20 milestones, has a zero share or does not total exactly 10,000 basis points (`InvalidMilestoneSplit`). Each payout is the milestone's share of the award rounded down, and whichever milestone settles last takes what is left, so an award pays exactly its amount in total, in any order. Tests: the `split` group in `grant_scenarios`, the randomized run (which mixes splits, settlement orders and forfeits), and the upgrade replay. | Mitigated | | **Repud.1** | The selection, its winner records and its awards are written in the transaction the manager signed, which is public. Each award also emits `WinnerAwarded` with the event, recipient, position and amount. Test: `each_award_and_each_cancel_branch_is_on_the_record`. | Mitigated | | **Repud.2** | `WinnerPaid` (event, recipient, position, amount) is emitted with the transfer, and the payment time can be read with `get_winner_at`. | Mitigated | | **Repud.3** | An append-only audit log records the staff member, action, target, time and reason in the same database transaction as the change. | Mitigated | @@ -404,7 +407,7 @@ Yes. Drawing it made two boundaries explicit that the code alone did not: owner Yes, ten issues nobody had written down: a lost manager key stranding a pool (DoS.9), one undeliverable refund blocking the rest (DoS.14), cancellation of an awarded grant (EoP.14), unlimited reputation bumps (EoP.15), derived operation ids not tied to the signer (Tamp.14), a grant naming one recipient twice (Tamp.16), payouts depending on the profile contract (DoS.7), inconsistent payout ordering (Tamp.11), gaps in the event record (Repud.1, Repud.4), and payouts co-signed with the upgrade keys (Spoof.2). All are fixed in 2.0.0 (Section 4.2). It also made explicit that managed wallets extend a backend compromise to escrow (Spoof.10, accepted, Section 4.5) and that mainnet had no upgrade timelock and two records in the old layout (EoP.10, Tamp.10, resolved by the upgrade, Section 4.4). Tamp.17 and DoS.13 were retired rather than fixed (Section 4.3). **Did the treatments in Section 3 address the threats?** -For 62 of the 72 active threats, yes. Every contract-level control has a test: 386 pass (326 for `boundless-events`, 60 for `boundless-profile`), a further test replays the next mainnet upgrade against captured mainnet storage, and the grant lifecycle has run on testnet with exact balance checks (Section 5). Backend and process controls are covered by the backend's tests and procedures. Tamp.5 and EoP.13 depend on the upgrade timelock and are fully effective on mainnet only once EoP.10 is deployed. The other ten threats are accepted with written reasons. Auditors' views on the designs chosen for DoS.9, DoS.14 and EoP.14 are welcome. +For 63 of the 73 active threats, yes. Every contract-level control has a test: 397 pass (337 for `boundless-events`, 60 for `boundless-profile`), a further test replays the next mainnet upgrade against captured mainnet storage, and the grant lifecycle has run on testnet with exact balance checks (Section 5). Backend and process controls are covered by the backend's tests and procedures. Tamp.5 and EoP.13 depend on the upgrade timelock and are fully effective on mainnet only once EoP.10 is deployed. The other ten threats are accepted with written reasons. Auditors' views on the designs chosen for DoS.9, DoS.14 and EoP.14 are welcome. **Were further issues found after the model was written?** Yes, four, all fixed. Running every entry point at mainnet's per-transaction limits, which the test host now enforces, found DoS.16 (grant payouts whose cost grew until they no longer fit), DoS.17 (batches and pages larger than a transaction) and Tamp.18 (grant awards too small to split); earlier tests never reached those limits. Reviewing a Scout static-analysis scan then found Tamp.19 (a migration that could reserve a forfeited share again). The model will be revised after the audit report and whenever the architecture changes materially: a new event type, authorization flow or external service, or an upgrade that changes the storage layout. @@ -461,6 +464,7 @@ These threats concerned on-chain applications and submissions, which 2.0.0 remov | Mainnet upgrade | Mainnet runs events 1.7.0 and profile 1.2.0. Every fix in Section 4.2 takes effect only after the admin multisig upgrades both, to events 2.0.0 and profile 1.2.1, following `docs/upgrade-runbook.md` Section 8. The deployed builds have no timelock, so this upgrade applies immediately; every later one waits 17,280 ledgers (EoP.10). | | Old-layout records (Tamp.10) | Two mainnet event records cannot be read until converted. Both are completed events with no escrow, so no money is affected. Mainnet holds eight events, so one `migrate_events` call during the upgrade converts the two and leaves the others unchanged, and `migrate` then stamps the version. | | Backend changes that must ship first | Page winner and contributor reads at 90 or fewer and run refunds in batches of 15 or fewer (DoS.17); pages of 100 and batches of 25 already fail on the deployed build for large events. Split hackathon selections above 20 winners (DoS.17). Call `forfeit_milestone` when a grant milestone is rejected, or the grant can never be cancelled (EoP.14). Support `claim_refund` for refunds set aside (DoS.14). Stop calling the participation functions that 2.0.0 removed. | +| Events 2.1.0 | Grant milestone splits (Tamp.20) run on testnet from 8 October 2026 and reach mainnet in a separate upgrade after 2.0.0, which waits the 17,280-ledger timelock (`docs/upgrade-runbook.md` Section 9). | ### 4.5 Accepted risks @@ -485,7 +489,7 @@ DoS.9 is mitigated, but one case remains: if a manager's key is lost after the f | Layer | What is in place | When | |---|---|---| -| Contract tests | 386 tests. Payout splits check recipient and fee-account balances, admin authorization is checked as genuinely required, and cancellation is tested with 220 contributors. The grant scenario suite (`contracts/events/src/tests/grant_scenarios`) checks errors, signers, events and balances after every step, runs every cap at its maximum, and reconciles 48 random grants to the last token (`GRANT_SCENARIO_RUNS` raises the number). A separate test replays the next mainnet upgrade against storage from mainnet ledger 64,747,647 (`docs/storage-compatibility.md`). | Every commit, in CI. | +| Contract tests | 397 tests. Payout splits check recipient and fee-account balances, admin authorization is checked as genuinely required, and cancellation is tested with 220 contributors. The grant scenario suite (`contracts/events/src/tests/grant_scenarios`) checks errors, signers, events and balances after every step, runs every cap at its maximum, and reconciles 48 random grants to the last token (`GRANT_SCENARIO_RUNS` raises the number). A separate test replays the next mainnet upgrade against storage from mainnet ledger 64,747,647 (`docs/storage-compatibility.md`). | Every commit, in CI. | | Live-network tests | `scripts/testnet/grant-scenarios.ts` runs the grant lifecycle on testnet with a revocable test asset: rounding, payments in any order, forfeits, the cancellation guard, partner refunds, an issuer freeze, every refusal, manager handover and a 40-recipient grant, with exact balance checks. On 3 October 2026 it passed 317 of 317 checks against the final build (events `b63afc1f…`, profile `09110572…`); the report is `docs/audit/testnet-grant-run.md`. The backend's 21 testnet smoke scripts cover other paths through the platform. | Before every upgrade. | | Build integrity | `scripts/build-release.sh` remaps machine paths out of the wasm, and the mainnet build runs it in CI's Linux x86-64 environment (`scripts/build-release-linux.sh`), so anyone reproduces CI's hash for a commit from any machine. The runbook (`docs/upgrade-runbook.md`) requires a second person to reproduce the hash and every admin transaction to be simulated before signing. The mainnet 1.7.0 binaries predate this and embed the builder's local paths; rebuilt with CLI 27.0.0 on that machine, the 1.7.0 source reproduces both live hashes exactly. | Every upgrade. | | Static analysis | Scout (CoinFabrik) runs against the code under review; findings are recorded with a register of false positives (`docs/scout-audit-report.md`). | Every release. | @@ -535,9 +539,9 @@ DoS.9 is mitigated, but one case remains: if a manager's key is lost after the f | Item | Status | |---|---| | Tests | 386 passing (326 events, 60 profile), plus the mainnet upgrade replay (Section 5). | -| Live-network tests | Grant lifecycle on testnet against the final 2.0.0 build: 317 of 317 checks (Section 5). | +| Live-network tests | Grant lifecycle on testnet against the final 2.0.0 build: 317 of 317 checks (Section 5). Against 2.1.0, adding a split grant paid in mixed order and a malformed split refused: 337 of 337 checks across 163 transactions (`docs/audit/testnet-grant-run-2.1.0.md`). | | Continuous integration | Reproducible build (any machine can match CI's hash), size limit, formatting, version checks, admin and upgrade-guard script tests, the upgrade replay, clippy with warnings as errors, full test suite. | -| Static analysis | Scout 0.3.17 on the 2.0.0 code (`docs/scout-audit-report.md`): no critical findings; the real findings (Tamp.19, Tamp.7) are fixed and every other warning is a documented false positive. | +| Static analysis | Scout 0.3.17 on the 2.0.0 code (`docs/scout-audit-report.md`): no critical findings; the real findings (Tamp.19, Tamp.7) are fixed and every other warning is a documented false positive. Not yet run on 2.1.0. | | Upgrade path | Timelocked proposal and application, on-chain `version()`, paged migration, testnet rehearsal, the CI replay, and `docs/upgrade-runbook.md`. | | Admin custody | Custody policy (`docs/admin-custody-policy.md`), multisig setup checklist (`docs/multisig-guide.md` Part E), on-chain signer check script. | | On-chain monitoring | Events for every state change are in place; alerting, balance reconciliation and stuck-state detection are not yet, and are required before fieldwork. | diff --git a/docs/upgrade-runbook.md b/docs/upgrade-runbook.md index 74d29ef..32e874b 100644 --- a/docs/upgrade-runbook.md +++ b/docs/upgrade-runbook.md @@ -377,3 +377,19 @@ Rollback: before the events apply, cancel and unpause with `UNPAUSE_UNMIGRATED=1` (the marker was already 1.6.0). After the apply, a rollback to 1.7.0 waits the 17,280-ledger timelock that 2.0.0 carries, with events paused throughout; after the first page, fix forward. + +## 9. Events 2.1.0 (grant milestone splits) + +2.1.0 adds `ReleaseKind::Split`, which lets a grant pay its milestones fixed, +unequal shares of each award. It converts no records, so `migrate_events` only +pages past the events created since the last stamp. + +Testnet went from 2.0.0 to 2.1.0 on 2026-10-08 with `deploy_and_upgrade.sh`: +wasm `c742cb96…` uploaded and proposed (`fc713ba2…`), applied (`d3f1e8c0…`), +10 `migrate_events` pages, then `migrate` stamped 2.1.0 (`d91f1ddc…`). Grants +created on 2.0.0 read and pay as before. + +Mainnet takes 2.1.0 only after Section 8 has put it on 2.0.0, as a separate +upgrade that waits the 17,280-ledger timelock. The backend sends a split only +to a contract that reports 2.1.0 or later, so nothing has to ship first. + diff --git a/scripts/testnet/grant-scenarios.ts b/scripts/testnet/grant-scenarios.ts index c1afc76..789d212 100644 --- a/scripts/testnet/grant-scenarios.ts +++ b/scripts/testnet/grant-scenarios.ts @@ -318,18 +318,20 @@ let feesExpected = 0n; type GrantSpec = { budget: bigint; milestones: number; + /** Basis points per milestone, published as ReleaseKind::Split. */ + split?: number[]; floors: [number, bigint][]; manager?: string; pillar?: 'Grant' | 'Crowdfunding'; }; -async function createGrant(owner: Keypair, g: GrantSpec): Promise { +function grantParams(owner: Keypair, g: GrantSpec): xdr.ScVal { 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({ + return 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(), @@ -337,11 +339,17 @@ async function createGrant(owner: Keypair, g: GrantSpec): Promise { 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)]), + release_kind: g.split + ? xdr.ScVal.scvVec([sv.sym('Split'), xdr.ScVal.scvVec(g.split.map(sv.u32))]) + : 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), }); +} + +async function createGrant(owner: Keypair, g: GrantSpec): Promise { + const params = grantParams(owner, g); const before = await balance(owner.publicKey()); const id = BigInt( (await must(events(owner, 'create_event', [params, sv.op()]), 'publish the grant')) as bigint, @@ -477,6 +485,54 @@ async function main() { eq(await status(id), 'Completed', 'completed'); } + scenario = 'milestone split, any order'; + if (wanted(scenario)) { + const [sa, sb] = await provision(2, 0n); + const split = [2_000, 3_000, 5_000]; + const [a, b] = [bgt(40) + 3n, bgt(20)]; + await refused( + events(owner, 'create_event', [ + grantParams(owner, { budget: a, milestones: 3, split: [3_000, 3_000, 3_000], floors: [[1, 1n]] }), + sv.op(), + ]), + 98, + 'a split that does not add up to the whole award', + ); + const id = await createGrant(owner, { + budget: a + b, + milestones: 3, + split, + floors: [[1, 1n], [2, 1n]], + }); + eq( + JSON.stringify((await event(id)).release_kind), + JSON.stringify(['Split', split]), + 'the split is stored on the grant', + ); + await must( + events(owner, 'select_winners', [ + sv.u64(id), + winners([ + { to: sa.publicKey(), position: 1, amount: a }, + { to: sb.publicKey(), position: 2, amount: b }, + ]), + sv.op(), + ]), + 'select two awards of different sizes', + ); + const share = (x: bigint, m: number) => (x * BigInt(split[m])) / 10_000n; + await release(owner, id, sa, 2, share(a, 2)); + await release(owner, id, sb, 0, share(b, 0)); + await release(owner, id, sa, 0, share(a, 0)); + await release(owner, id, sb, 1, share(b, 1)); + await release(owner, id, sb, 2, b - share(b, 0) - share(b, 1)); + // Milestone 1 settles last for the larger award: its share plus the rounding. + await release(owner, id, sa, 1, a - share(a, 2) - share(a, 0)); + eq(await balance(sa.publicKey()), a, 'the larger award was paid exactly'); + eq(await balance(sb.publicKey()), b, 'the smaller award was paid exactly'); + eq(await status(id), 'Completed', 'completes when the last share is paid'); + } + scenario = 'forfeit and the cancel guard'; if (wanted(scenario)) { const id = await createGrant(owner, { budget: bgt(30), milestones: 3, floors: [[1, bgt(30)]] });