Skip to content
Closed
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
4 changes: 4 additions & 0 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ 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.** 2026-09-11: the premise no longer holds — mainnet `version()` reads 1.7.0 on events / 1.2.0 on profile (wasm hashes match the 2026-08 runbook build) and three Active bounties hold 1,030 USDC (`…611`, `…612`, `…613`) under the zeroed timelock. Restore is now the next admin operation. Disclosed as EoP.10 in `docs/threat-model.md` v1.4.

- [ ] **Finish the 1.7.0 mainnet migration and record the apply.** 2026-09-11 live read: events `get_migrated_to_version()` = `1.6.0`, profile = `null`, `get_pending_upgrade` = null on both. `migrate_events` was never run, so the two pre-1.7.0 rows (`…609`, `…610`) trap on every read with `Error(Value, InvalidInput)` / `symbol mismatch: release_kind`; new-format events are unaffected and no escrow is at risk (both legacy rows were `Completed`, escrow 0). Steps: `migrate_events(max_events=8)` once (returns 0 remaining) → `migrate()` on events → `migrate()` on profile (stamps 1.2.0) → append the apply + migrate txs to `deployments/mainnet-upgrades.jsonl` and bump `deployments/mainnet.json`. Then add a runbook gate that refuses to sign `apply_upgrade` unless the migration steps are queued in the same signing session.

## P1 (post-launch)

- [x] `select_winners` re-run semantics: 1.3.0 (#61) made Single-release selection batchable — each position is awardable exactly once (per-position `EventPrizeAward` key is the replay lock), amounts stay anchored to the baseline captured at the first batch, and pre-1.3.0 events remain one-shot. (2026-07-18)
Expand Down
235 changes: 231 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,192 @@ 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);
migrate_owed_total(env, id);
}

/// Rebuild `EventOwedTotal` for a migrated event.
///
/// 1.7.0 reserves awarded-but-unclaimed prizes, because `remaining_escrow`
/// only drops at claim time and a second selection would otherwise see funds
/// an earlier winner is still entitled to. Pre-1.7.0 state has no such
/// counter, so a migrated event would report nothing reserved while a winner
/// was still owed, and the next batch could promise the same money twice.
///
/// Outstanding is the same quantity the live path maintains: award amounts
/// that have not been paid out, less whatever milestone rows already paid.
/// A Single award marks its anchor `paid_at` on claim; a grant leaves the
/// anchor open and appends one paid row per milestone.
fn migrate_owed_total(env: &Env, event_id: u64) {
let count = storage::winner_count(env, event_id);
let mut awarded: i128 = 0;
let mut paid: i128 = 0;
for idx in 0..count {
let w = match storage::winner_at(env, event_id, idx) {
Some(w) => w,
None => continue,
};
match w.milestone {
None => {
if w.paid_at.is_none() {
awarded = awarded.saturating_add(w.amount);
}
}
Some(_) => {
paid = paid.saturating_add(w.amount);
}
}
}
let owed = awarded.saturating_sub(paid);
if owed > 0 {
storage::set_owed_total(env, event_id, owed);
}
}

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

// ============================================================
// 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