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 @@ -55,6 +55,7 @@ See `docs/audit-2026-06-stellar-skill.md` for full findings.

## Done

- [x] 2026-07-17 — Cancellation liveness hardening. `add_funds` now maintains an O(1) per-event non-owner contribution total, `start_cancel` no longer scans up to 5,000 contributor rows, and `process_cancel_batch` / `finalize_cancel` are permissionless after cancellation starts. The guarded 1.1.0 → 1.2.0 mainnet flow pauses before proposal, proves the zero-event state, keeps it frozen through the timelock, and rechecks before apply. Older zero-contributor rows initialize the missing total lazily; a missing total with existing contributors fails closed. Tests cover a 220-contributor constant-footprint start and exact third-party-driven payout deltas.
- [x] 2026-06-03 — `fee_bps_override` per-event field + `effective_fee_bps` resolver.
- [x] 2026-06-03 — `WinnersAlreadySelected` replay lock on `select_winners`.
- [x] 2026-06-03 — Grant last-milestone sweep (G4).
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.

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 = "1.1.0"
version = "1.2.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 @@ -15,7 +15,7 @@ const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280;
const UPGRADE_TIMELOCK_LEDGERS: u32 = 0;
const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400;

pub const INITIAL_VERSION: &str = "1.1.0";
pub const INITIAL_VERSION: &str = "1.2.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 @@ -56,6 +56,7 @@ pub enum Error {
CancellationNotStarted = 62,
CancellationAlreadyStarted = 63,
CancellationNotFinished = 64,
CancellationTotalMissing = 66,

UpgradeNotProposed = 65,
UpgradeTimelockNotElapsed = 67,
Expand Down
61 changes: 41 additions & 20 deletions contracts/events/src/event_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ fn resolve_manager(env: &Env, event_id: u64, owner: &Address) -> Address {
storage::get_event_manager(env, event_id).unwrap_or_else(|| owner.clone())
}

fn get_or_init_non_owner_total(env: &Env, event_id: u64) -> Result<i128, Error> {
match storage::get_non_owner_contribution_total(env, event_id) {
Some(total) => Ok(total),
None if storage::contributor_count(env, event_id) == 0 => {
storage::set_non_owner_contribution_total(env, event_id, 0);
Ok(0)
}
None => Err(Error::CancellationTotalMissing),
}
}

pub fn create_event(env: &Env, params: CreateEventParams, op_id: BytesN<32>) -> Result<u64, Error> {
admin::require_not_paused(env)?;
idempotency::require_unseen(env, &op_id)?;
Expand Down Expand Up @@ -117,6 +128,7 @@ pub fn create_event(env: &Env, params: CreateEventParams, op_id: BytesN<32>) ->
let id = idempotency::next_event_id(env);
let record = EventRecord { id, ..provisional };
storage::set_event(env, id, &record);
storage::set_non_owner_contribution_total(env, id, 0);

if let Some(manager) = &params.manager {
storage::set_event_manager(env, id, manager);
Expand Down Expand Up @@ -191,8 +203,20 @@ pub fn add_funds(

from.require_auth();

if from != event.owner {
let prior = storage::get_contributor_amount(env, event_id, &from);
let is_non_owner = from != event.owner;
let prior_contribution = if is_non_owner {
storage::get_contributor_amount(env, event_id, &from)
} else {
0
};
let non_owner_total_before = if is_non_owner {
get_or_init_non_owner_total(env, event_id)?
} else {
0
};

if is_non_owner {
let prior = prior_contribution;
if prior == 0 {
storage::append_contributor(env, event_id, &from, MAX_CONTRIBUTORS_PER_EVENT)?;
}
Expand All @@ -206,10 +230,14 @@ pub fn add_funds(
};
event.remaining_escrow = event.remaining_escrow.saturating_add(credited);

if from != event.owner {
let prior = storage::get_contributor_amount(env, event_id, &from);
let new_total = prior.saturating_add(credited);
if is_non_owner {
let new_total = prior_contribution.saturating_add(credited);
storage::set_contributor_amount(env, event_id, &from, new_total);
storage::set_non_owner_contribution_total(
env,
event_id,
non_owner_total_before.saturating_add(credited),
);
}

storage::set_event(env, event_id, &event);
Expand Down Expand Up @@ -245,14 +273,7 @@ pub fn start_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<(), E

let remaining = event.remaining_escrow;
let count = storage::contributor_count(env, event_id);

let mut non_owner_total: i128 = 0;
for idx in 0..count {
if let Some(c) = storage::contributor_at(env, event_id, idx) {
non_owner_total =
non_owner_total.saturating_add(storage::get_contributor_amount(env, event_id, &c));
}
}
let non_owner_total = get_or_init_non_owner_total(env, event_id)?;

let branch = if non_owner_total <= 0 {
CancellationBranch::OwnerOnly
Expand All @@ -275,6 +296,7 @@ pub fn start_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<(), E
event.remaining_escrow = 0;
event.status = EventStatus::Cancelled;
storage::set_event(env, event_id, &event);
storage::set_non_owner_contribution_total(env, event_id, 0);
evt::EventCancelled { id: event_id }.publish(env);
idempotency::mark_seen(env, &op_id);
return Ok(());
Expand Down Expand Up @@ -308,20 +330,21 @@ pub fn process_cancel_batch(
if !matches!(event.status, EventStatus::Cancelling) {
return Err(Error::CancellationNotStarted);
}
resolve_manager(env, event_id, &event.owner).require_auth();

let mut state =
storage::get_cancellation_state(env, event_id).ok_or(Error::CancellationNotStarted)?;

let cap = if max_refunds > MAX_REFUNDS_PER_BATCH {
MAX_REFUNDS_PER_BATCH
} else {
max_refunds
};
let mut processed: u32 = 0;

let mut state =
storage::get_cancellation_state(env, event_id).ok_or(Error::CancellationNotStarted)?;

while processed < cap && state.next_idx < state.count_at_start {
let idx = state.next_idx;
state.next_idx = state.next_idx.saturating_add(1);
processed = processed.saturating_add(1);

let c = match storage::contributor_at(env, event_id, idx) {
Some(c) => c,
Expand Down Expand Up @@ -350,7 +373,6 @@ pub fn process_cancel_batch(
.publish(env);
}
storage::set_contributor_amount(env, event_id, &c, 0);
processed = processed.saturating_add(1);
}

storage::set_cancellation_state(env, event_id, &state);
Expand All @@ -368,8 +390,6 @@ pub fn finalize_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<()
if !matches!(event.status, EventStatus::Cancelling) {
return Err(Error::CancellationNotStarted);
}
resolve_manager(env, event_id, &event.owner).require_auth();

let state =
storage::get_cancellation_state(env, event_id).ok_or(Error::CancellationNotStarted)?;
if state.next_idx < state.count_at_start {
Expand All @@ -395,6 +415,7 @@ pub fn finalize_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<()
event.status = EventStatus::Cancelled;
storage::set_event(env, event_id, &event);
storage::clear_cancellation_state(env, event_id);
storage::set_non_owner_contribution_total(env, event_id, 0);

evt::EventCancelled { id: event_id }.publish(env);

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 @@ -24,7 +24,7 @@ mod tests;
use crate::errors::Error;
use crate::types::*;

contractmeta!(key = "version", val = "1.1.0");
contractmeta!(key = "version", val = "1.2.0");
contractmeta!(
key = "description",
val = "Boundless events contract: hackathon, bounty, grant + escrow"
Expand Down
15 changes: 15 additions & 0 deletions contracts/events/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,21 @@ pub fn set_contributor_amount(env: &Env, id: u64, contributor: &Address, amount:
touch_event_persistent(env, &key);
}

pub fn get_non_owner_contribution_total(env: &Env, id: u64) -> Option<i128> {
let key = DataKey::NonOwnerContributionTotal(id);
let total: Option<i128> = env.storage().persistent().get(&key);
if total.is_some() {
touch_event_persistent(env, &key);
}
total
}

pub fn set_non_owner_contribution_total(env: &Env, id: u64, total: i128) {
let key = DataKey::NonOwnerContributionTotal(id);
env.storage().persistent().set(&key, &total);
touch_event_persistent(env, &key);
}

pub fn contributor_count(env: &Env, id: u64) -> u32 {
let key = DataKey::ContributorCount(id);
let n: Option<u32> = env.storage().persistent().get(&key);
Expand Down
8 changes: 4 additions & 4 deletions contracts/events/src/tests/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ fn initializes_with_expected_config() {
assert_eq!(ctx.client.get_fee_bps(), 250);
assert_eq!(ctx.client.get_profile_contract(), ctx.profile_contract);
assert_eq!(ctx.client.is_paused(), false);
assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.1.0"));
assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.2.0"));
assert_eq!(ctx.client.get_pending_upgrade(), None);
assert_eq!(ctx.client.get_migrated_to_version(), None);
}
Expand Down Expand Up @@ -96,7 +96,7 @@ fn apply_upgrade_before_timelock_reverts() {
.expect("timelock blocks")
.unwrap();
assert_eq!(err, Error::UpgradeTimelockNotElapsed);
assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.1.0"));
assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.2.0"));
}

#[test]
Expand Down Expand Up @@ -130,7 +130,7 @@ fn cancel_pending_upgrade_clears_proposal() {

ctx.client.cancel_pending_upgrade();
assert_eq!(ctx.client.get_pending_upgrade(), None);
assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.1.0"));
assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.2.0"));
}

#[test]
Expand All @@ -152,7 +152,7 @@ fn migrate_marks_current_version_and_blocks_replay() {
ctx.client.migrate();
assert_eq!(
ctx.client.get_migrated_to_version(),
Some(String::from_str(&ctx.env, "1.1.0"))
Some(String::from_str(&ctx.env, "1.2.0"))
);

let err = ctx
Expand Down
Loading
Loading