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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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/`) |
Expand Down
50 changes: 47 additions & 3 deletions contracts/compatibility/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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(),
Expand Down Expand Up @@ -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);
}
2 changes: 1 addition & 1 deletion contracts/events/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "boundless-events"
version = "2.0.0"
version = "2.1.0"
edition = "2021"
publish = false

Expand Down
2 changes: 1 addition & 1 deletion contracts/events/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions contracts/events/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,5 @@ pub enum Error {
NoRefundOwed = 95,
AwardsOutstanding = 96,
MigrationIncomplete = 97,
InvalidMilestoneSplit = 98,
}
45 changes: 38 additions & 7 deletions contracts/events/src/event_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -67,6 +72,23 @@ fn get_or_init_non_owner_total(env: &Env, event_id: u64) -> Result<i128, Error>
}
}

fn split_is_valid(shares: &Vec<u32>) -> 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<u64, Error> {
admin::require_not_paused(env)?;

Expand Down Expand Up @@ -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) = &params.release_kind {
if !split_is_valid(shares) {
return Err(Error::InvalidMilestoneSplit);
}
}

if !is_crowdfunding {
escrow::deposit_with_fee_at(
Expand Down Expand Up @@ -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);
}

Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
61 changes: 50 additions & 11 deletions contracts/events/src/grant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,16 +29,55 @@ fn standing(env: &Env, event_id: u64, recipient: &Address) -> Result<Standing, E
})
}

/// An even share of the award, or what is left of it when this is the last
/// milestone the recipient has open.
fn share(standing: &Standing, total_milestones: u32) -> Result<i128, Error> {
/// 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<i128, Error> {
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<i128, Error> {
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)
}
}

Expand Down Expand Up @@ -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),
};

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
}
Expand Down
2 changes: 1 addition & 1 deletion contracts/events/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading