Skip to content
Open
2 changes: 2 additions & 0 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ See `docs/audit-2026-06-stellar-skill.md` for full findings.
- [x] **Domain subscribers ignore the new cancel kinds.** 2026-06-05: all four subscribers (`BountyEscrowSubscriber`, `HackathonEscrowSubscriber`, `GrantEscrowSubscriber`, `CrowdfundingEscrowSubscriber`) now handle `FINALIZE_CANCEL` (mark domain row CANCELLED) and the `START_CANCEL` OwnerOnly inline path (read on-chain status; if already Cancelled, mirror). Tests green (events + queues + escrow contract suites = 133/133).
- [x] **Crowdfunding `claim_milestone` orchestrator path needs admin co-sign.** 2026-06-05: new `AdminSorobanAuthSignerService` finds the SorobanAuthorizationEntry whose address matches the configured admin and signs it via `authorizeEntry()` from `@stellar/stellar-base`. Smoke helper exposes `adminPreSign: true` on `driveToCompletion`; the crowdfunding claim path passes that flag. Verified end-to-end on testnet — all three milestone claims confirmed with `signed 1 admin auth entry` in the log and the builder receiving the full 900 TUSD across three claim txs.

- [ ] **RESTORE the upgrade timelock before the first funded mainnet campaign.** `UPGRADE_TIMELOCK_LEDGERS` was set to 0 on both contracts on 2026-08-18 so the 1.7.0 rollout could iterate, while mainnet escrow was empty (both events `Completed`, `remaining_escrow` 0). This reverses audited control H6. With no window, a compromised 2-of-3 admin can `propose_upgrade` and `apply_upgrade` in one go and `cancel_pending_upgrade` never gets a chance to fire — the whole point of the control. Restore to `17_280` in `contracts/events/src/admin.rs` and `contracts/profile/src/admin.rs`, and flip the two `apply_upgrade_is_immediate_while_the_timelock_is_zero*` tests back to asserting `UpgradeTimelockNotElapsed`. The cfg split was kept so this is a single-value edit per contract. **The pending third-party audit will flag this if it is still 0 at audit time.**

## P1 (post-launch)

- [x] `select_winners` re-run semantics: 1.3.0 (#61) made Single-release selection batchable — each position is awardable exactly once (per-position `EventPrizeAward` key is the replay lock), amounts stay anchored to the baseline captured at the first batch, and pre-1.3.0 events remain one-shot. (2026-07-18)
Expand Down
196 changes: 192 additions & 4 deletions contracts/events/src/admin.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,52 @@
use soroban_sdk::{panic_with_error, Address, BytesN, Env, String};
use soroban_sdk::{contracttype, panic_with_error, Address, BytesN, Env, Map, String, Symbol, Val};

use crate::errors::Error;
use crate::events as evt;
use crate::idempotency;
use crate::storage;
use crate::types::{PendingAdmin, PendingUpgrade};
use crate::types::{
DataKey, EventRecord, EventStatus, PendingAdmin, PendingUpgrade, Pillar, ReleaseKind, Winner,
};

/// The pre-1.7.0 `EventRecord`, kept only so `migrate` can decode rows written
/// before prize floors replaced the percentage distribution. Nothing else may
/// read or write this shape.
#[contracttype]
#[derive(Clone)]
struct LegacyEventRecord {
pub id: u64,
pub pillar: Pillar,
pub owner: Address,
pub token: Address,
pub total_budget: i128,
pub remaining_escrow: i128,
pub release_kind: ReleaseKind,
pub status: EventStatus,
pub content_uri: String,
pub title: String,
pub created_at: u64,
pub deadline: Option<u64>,
pub winner_distribution: Map<u32, u32>,
pub fee_bps_override: Option<u32>,
}

const PENDING_ADMIN_TTL_LEDGERS: u32 = 120_960;

pub(crate) const MAX_FEE_BPS: u32 = 1_000;

// H6 (audit 2026-06) mandated 17_280 ledgers, ~1 day, on mainnet. Zeroed
// deliberately while mainnet escrow is empty so the 1.7.0 rollout can iterate.
// RESTORE to 17_280 before the first funded campaign: with no window, a
// compromised admin key can propose and apply a wasm swap in one go, and
// cancel_pending_upgrade never gets a chance to fire. Tracked in BACKLOG.
// The cfg split is kept so restoring is a single-value edit.
#[cfg(not(feature = "testnet"))]
const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280;
const UPGRADE_TIMELOCK_LEDGERS: u32 = 0;
#[cfg(feature = "testnet")]
const UPGRADE_TIMELOCK_LEDGERS: u32 = 0;
const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400;

pub const INITIAL_VERSION: &str = "1.6.0";
pub const INITIAL_VERSION: &str = "1.7.0";

// ============================================================
// INITIALIZATION
Expand Down Expand Up @@ -250,6 +281,16 @@ pub fn migrate(env: &Env) -> Result<(), Error> {
// ============================================================
// PER-(from -> to) MIGRATION DISPATCH
// ============================================================
// Refuse to stamp while any event is still unconverted. The rewrite is
// paged through `migrate_events` because one invocation may touch only 100
// ledger entries, and stamping early would leave the remainder undecodable
// with no way to resume: this is one-shot.
//
// Reuses EventIdOverflow rather than adding a variant — contracterror is at
// the 50-case cap. It means "events remain", not a counter fault.
if migration_remaining(env) > 0 {
return Err(Error::EventIdOverflow);
}

storage::set_migrated_to_version(env, &current);
storage::touch_instance(env);
Expand All @@ -261,6 +302,153 @@ pub fn migrate(env: &Env) -> Result<(), Error> {
Ok(())
}

/// How many events `migrate_events` has yet to convert.
fn migration_remaining(env: &Env) -> u64 {
let base = idempotency::id_base(env);
let first = base.saturating_add(1);
let next = storage::get_next_event_id(env, first);
let cursor = storage::get_migration_cursor(env).unwrap_or(first);
next.saturating_sub(cursor.max(first))
}

/// Converts up to `max_events` events from the pre-1.7.0 percentage layout,
/// advancing a stored cursor. Returns how many remain, so an operator can loop
/// until it reports zero and only then call `migrate`.
///
/// Paged rather than one-shot because an invocation may touch at most 100
/// ledger entries and write 50. A deployment with real history — testnet holds
/// over a hundred events — cannot be converted in a single transaction, and a
/// one-shot pass that aborts leaves every event undecodable.
pub fn migrate_events(env: &Env, max_events: u32) -> Result<u64, Error> {
require_admin(env)?;

// Each event costs a record read plus a record write, and a Multi event
// adds a read and a write per winner. Eight leaves headroom for the winner
// rewrites inside the write limit.
const MAX_PER_CALL: u32 = 8;
let budget = if max_events == 0 || max_events > MAX_PER_CALL {
MAX_PER_CALL
} else {
max_events
};

let base = idempotency::id_base(env);
let first = base.saturating_add(1);
let next = storage::get_next_event_id(env, first);
let mut cursor = storage::get_migration_cursor(env)
.unwrap_or(first)
.max(first);

let mut done: u32 = 0;
while cursor < next && done < budget {
migrate_one_event(env, cursor);
cursor = cursor.saturating_add(1);
done = done.saturating_add(1);
}

storage::set_migration_cursor(env, cursor);
storage::touch_instance(env);
Ok(next.saturating_sub(cursor))
}

/// Rewrites one event from the pre-1.7.0 percentage layout to prize floors.
/// `winner_distribution` and `prize_floors` differ in both name and value type,
/// so an old row cannot be decoded by the current struct at all.
///
/// Percentages were always taken against the escrow balance, so `total_budget *
/// percent / 100` reproduces exactly what each position would have been paid.
fn migrate_one_event(env: &Env, id: u64) {
let key = DataKey::Event(id);
// Decode defensively. `get::<LegacyEventRecord>` unwraps the conversion,
// and a missing field escalates to a host error rather than a catchable
// one, so a row already in the 1.7.0 layout would abort the whole
// invocation instead of being skipped. A contracttype struct is stored as
// a map keyed by field name, so the old layout is identified by the field
// that only it carries.
let fields: Option<Map<Symbol, Val>> = env.storage().persistent().get(&key);
let is_legacy = fields.is_some_and(|f| f.contains_key(Symbol::new(env, "winner_distribution")));
if is_legacy {
if let Some(old) = env
.storage()
.persistent()
.get::<DataKey, LegacyEventRecord>(&key)
{
let mut floors: Map<u32, i128> = Map::new(env);
for (position, percent) in old.winner_distribution.iter() {
let floor = old
.total_budget
.saturating_mul(percent as i128)
.saturating_div(100);
if floor > 0 {
floors.set(position, floor);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let migrated = EventRecord {
id: old.id,
pillar: old.pillar,
owner: old.owner,
token: old.token,
total_budget: old.total_budget,
remaining_escrow: old.remaining_escrow,
release_kind: old.release_kind,
status: old.status,
content_uri: old.content_uri,
title: old.title,
created_at: old.created_at,
deadline: old.deadline,
prize_floors: floors,
fee_bps_override: old.fee_bps_override,
};
env.storage().persistent().set(&key, &migrated);
}
}
migrate_winner_amounts(env, id);
}

/// Pre-1.7.0 `Multi` selections stored `amount: 0` on the anchor winner row,
/// because a grant milestone derived its payout from the percentage
/// distribution at claim time. `claim_milestone` now reads that amount, so an
/// unrewritten row would compute a payout of zero and revert on every claim,
/// with no way to re-select and no exit but cancelling the grant.
///
/// The floor for the winner's position is exactly what the old formula would
/// have produced, since both are `total_budget * percent / 100`.
fn migrate_winner_amounts(env: &Env, event_id: u64) {
let event = match storage::get_event(env, event_id) {
Some(e) => e,
None => return,
};
if !matches!(event.release_kind, ReleaseKind::Multi(_)) {
return;
}
let count = storage::winner_count(env, event_id);
for idx in 0..count {
let w = match storage::winner_at(env, event_id, idx) {
Some(w) => w,
None => continue,
};
// Milestone rows already carry what was actually paid; only the anchor
// was written with a placeholder amount.
if w.milestone.is_some() || w.amount != 0 {
continue;
}
if let Some(floor) = event.prize_floors.get(w.position) {
storage::set_winner_at(
env,
event_id,
idx,
&Winner {
recipient: w.recipient.clone(),
position: w.position,
amount: floor,
milestone: None,
paid_at: w.paid_at,
},
);
}
}
}

// ============================================================
// READS
// ============================================================
Expand Down
2 changes: 1 addition & 1 deletion contracts/events/src/bounty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ pub fn withdraw_application(
applicant.require_auth();
idempotency::require_unseen(env, &applicant, &op_id)?;

if storage::get_submission(env, bounty_id, &applicant).is_some() {
if storage::has_any_submission(env, bounty_id, &applicant) {
return Err(Error::SubmissionAlreadyExists);
}

Expand Down
13 changes: 2 additions & 11 deletions contracts/events/src/crowdfunding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,7 @@ pub fn validate_create(_env: &Env, record: &EventRecord, _owner: &Address) -> Re
_ => return Err(Error::InvalidReleaseKind),
}

if record.winner_distribution.len() != 1 {
return Err(Error::InvalidDistribution);
}
let percent = record
.winner_distribution
.get(1)
.ok_or(Error::InvalidDistribution)?;
if percent != 100 {
return Err(Error::DistributionMismatch);
}

// No floor check: crowdfunding pays milestones out of `remaining_escrow`
// divided by the milestones left, and never reads the prize floors.
Ok(())
}
Loading
Loading