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
100 changes: 0 additions & 100 deletions contracts/events/src/admin.rs
Original file line number Diff line number Diff line change
@@ -1,43 +1,20 @@
// boundless-events: admin operations.
//
// Spec: boundless-platform-contract-prd.md Section 6.1.

use soroban_sdk::{panic_with_error, Address, BytesN, Env, String};

use crate::errors::Error;
use crate::events as evt;
use crate::storage;
use crate::types::{PendingAdmin, PendingUpgrade};

// Two-step admin rotation TTL: 7 days at the mainnet 5-second ledger cadence.
// 7 * 24 * 60 * 60 / 5 = 120_960 ledgers.
const PENDING_ADMIN_TTL_LEDGERS: u32 = 120_960;

// Fee bps cap. 100% = 10_000 bps. L4 (2026-06 audit): tightened from 5_000
// (50%) to 1_000 (10%). 10% covers the full envelope of real Boundless
// pricing tiers; a config typo can no longer push the fee above operating
// range. Per-event overrides still respect this cap.
pub(crate) const MAX_FEE_BPS: u32 = 1_000;

// H6: timelocked upgrade windows.
//
// UPGRADE_TIMELOCK_LEDGERS earliest gap between propose and apply.
// ~1 day so off-chain monitors have a window
// to react before the new wasm lands.
// PENDING_UPGRADE_TTL_LEDGERS hard expiry on the proposal; ~30 days.
// Past this the admin must re-propose.
// Testnet builds (`--features testnet`) zero the upgrade timelock for fast
// iteration; the default build (mainnet + everything else) keeps the full
// ~1-day timelock. Fail-safe: omitting the flag yields the secure value, never 0.
#[cfg(not(feature = "testnet"))]
const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280;
#[cfg(feature = "testnet")]
const UPGRADE_TIMELOCK_LEDGERS: u32 = 0;
const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400;

// Initial contract version. Written by __constructor and bumped on
// apply_upgrade. Bump alongside any storage-layout or public-surface change
// that warrants a migration entrypoint.
pub const INITIAL_VERSION: &str = "1.1.0";

// ============================================================
Expand All @@ -50,8 +27,6 @@ pub fn initialize(
fee_bps: u32,
profile_contract: Address,
) {
// Refuse double-init by checking the admin key in instance storage (the
// new home for admin/config per the 2026-06 audit).
if env.storage().instance().has(&crate::types::DataKey::Admin) {
panic_with_error!(env, Error::AlreadyInitialized);
}
Expand Down Expand Up @@ -140,12 +115,6 @@ pub fn set_fee_bps(env: &Env, new_bps: u32) -> Result<(), Error> {

pub fn set_fee_account(env: &Env, new_account: Address) -> Result<(), Error> {
require_admin(env)?;
// M2 (2026-06 audit): we do not verify trustline existence at the
// contract layer because Soroban's SAC interface cannot reliably
// distinguish "no trustline" from "zero balance". Admin must verify
// off-chain BEFORE calling this; the FeeAccountUpdated event below is
// the signal off-chain monitors rely on to re-verify. See
// docs/audit-2026-06-stellar-skill.md M2.
storage::set_fee_account(env, &new_account);
storage::touch_instance(env);
evt::FeeAccountUpdated {
Expand Down Expand Up @@ -190,33 +159,13 @@ pub fn unpause(env: &Env) -> Result<(), Error> {

// ============================================================
// UPGRADE (timelocked; H6)
//
// Three steps:
// 1. propose_upgrade(wasm_hash, new_version) — admin-only; writes
// PendingUpgrade with proposed_at = now, available_at = now + TIMELOCK,
// expires_at = now + TTL. Off-chain monitors can see exactly which
// version + wasm is queued before it lands.
// 2. apply_upgrade() — admin-only; requires
// now in [available_at, expires_at]; swaps the wasm hash and bumps
// the on-chain version label.
// 3. cancel_pending_upgrade() — admin-only; prunes a stale
// or unwanted proposal so a fresh one can be queued.
//
// migrate(to_version) is a SEPARATE call that runs the one-shot data
// migration matched to the just-applied version. Guard via MigratedToVersion.
//
// Spec: docs/audit-2026-06-stellar-skill.md H6.
// ============================================================
pub fn propose_upgrade(
env: &Env,
new_wasm_hash: BytesN<32>,
new_version: String,
) -> Result<(), Error> {
require_admin(env)?;
// Empty version is rejected; reuse InvalidPillar to stay inside the
// soroban contracterror 50-variant cap (a dedicated InvalidVersion
// would push us over). Off-chain monitors should treat InvalidPillar
// on propose_upgrade as "bad version label."
if new_version.is_empty() {
return Err(Error::InvalidPillar);
}
Expand Down Expand Up @@ -262,7 +211,6 @@ pub fn apply_upgrade(env: &Env) -> Result<(), Error> {
new_version: pending.new_version.clone(),
}
.publish(env);
// Keep the legacy Upgraded event for indexers built against the old shape.
evt::Upgraded {
new_wasm_hash: pending.wasm_hash,
}
Expand All @@ -286,29 +234,6 @@ pub fn cancel_pending_upgrade(env: &Env) -> Result<(), Error> {

// ============================================================
// MIGRATE (post-upgrade one-shot; H6)
//
// Called once per version after apply_upgrade swaps the wasm. The shape
// is:
//
// 1. Read the current Version label (set by apply_upgrade) and the
// previously-applied migration marker (MigratedToVersion). If the
// marker already equals the current Version, reject as
// MigrationAlreadyApplied — a second invocation is always a
// misconfiguration.
// 2. Dispatch on (prev, current) and run the migration body. Bodies
// run cleanly inside the same tx as the marker write, so a failure
// reverts both — there is no half-migrated state to recover from.
// 3. Stamp MigratedToVersion = current and emit Migrated{}.
//
// Mainnet bootstrap: the first deploy lands the constructor with the
// current storage layout, so no migration body is needed. The first real
// migration body will land with the first storage-layout upgrade after
// mainnet goes live. We keep an empty match arm for the no-op case so the
// shape is stable and future contributors do not have to debate where
// the dispatch goes.
//
// NB: Soroban String only supports equality + length, no `as_str()` /
// pattern matching. The dispatch below uses `String::from_str` + equality.
// ============================================================
pub fn migrate(env: &Env) -> Result<(), Error> {
require_admin(env)?;
Expand All @@ -324,31 +249,8 @@ pub fn migrate(env: &Env) -> Result<(), Error> {

// ============================================================
// PER-(from -> to) MIGRATION DISPATCH
//
// Each future upgrade adds an `if` clause here with its migration body.
// Touch only persistent / instance entries that the new layout changes;
// anything the new code reads with backwards-compatible defaults can
// be left alone.
//
// Pattern:
//
// if from_version == String::from_str(env, "0.2.0")
// && current == String::from_str(env, "0.3.0")
// {
// migrate_0_2_0_to_0_3_0(env)?;
// }
//
// The corresponding private fn lives below the match block. Keep it
// small enough to read; if the migration is large, split it into named
// helpers and call from inside the body.
// ============================================================

// No-op for the 1.0.0 -> 1.1.0 credit-removal upgrade: the contracts hold
// no events yet, so there are no EventRecord rows to rewrite. __constructor
// populates storage in the current shape, so admin can call migrate() once
// just to stamp the marker and unlock the audit trail (the Migrated event
// signals off-chain runbooks that the post-upgrade cleanup ran).

storage::set_migrated_to_version(env, &current);
storage::touch_instance(env);
evt::Migrated {
Expand Down Expand Up @@ -404,8 +306,6 @@ pub fn require_admin(env: &Env) -> Result<(), Error> {
}

pub fn require_not_paused(env: &Env) -> Result<(), Error> {
// Every operation path runs this first, so this is the single spot to
// bump instance TTL on the hot path. Admin paths bump explicitly.
storage::touch_instance(env);
if storage::is_paused(env) {
return Err(Error::Paused);
Expand Down
15 changes: 0 additions & 15 deletions contracts/events/src/bounty.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
// boundless-events: bounty-specific behavior.
//
// Spec: boundless-platform-contract-prd.md Sections 6.3, 7.
//
// Bounties use ReleaseKind::Single. Credits (apply cost / refunds) are handled
// off-chain; the contract only records applicants and ensures their profile.

use soroban_sdk::{Address, BytesN, Env};

use crate::admin;
Expand Down Expand Up @@ -40,11 +33,8 @@ pub fn apply(

applicant.require_auth();

// append_applicant returns Err on duplicate or cap exceeded.
storage::append_applicant(env, bounty_id, &applicant, MAX_APPLICANTS_PER_EVENT)?;

// Cross-contract: ensure the applicant has a profile (idempotent). Credits
// are charged off-chain now, so there is no on-chain spend here.
let profile = profile_client::client(env);
let bootstrap_op = idempotency::derive_child(env, &op_id, tag::BOOTSTRAP);
profile.bootstrap(&applicant, &bootstrap_op);
Expand Down Expand Up @@ -76,17 +66,12 @@ pub fn withdraw_application(

applicant.require_auth();

// Reject withdrawal if the applicant already submitted.
if storage::get_submission(env, bounty_id, &applicant).is_some() {
return Err(Error::SubmissionAlreadyExists);
}

// Membership check + swap-remove. Slot lookup is O(1), so this avoids
// the prior O(n) linear scan even at the cap.
storage::remove_applicant(env, bounty_id, &applicant)?;

// Credits (including any withdrawal refund) are handled off-chain.

evt::ApplicationWithdrawn {
event_id: bounty_id,
applicant,
Expand Down
26 changes: 0 additions & 26 deletions contracts/events/src/crowdfunding.rs
Original file line number Diff line number Diff line change
@@ -1,25 +1,3 @@
// boundless-events: crowdfunding-specific behavior.
//
// Spec: boundless-crowdfunding-prd.md.
//
// Crowdfunding is a builder-led, community-funded pillar with these rules:
// - ReleaseKind::Multi(n>0): milestones drive release cadence, like grants.
// - deadline required: defines the funding window. Submitting/contributing
// after the deadline is rejected by the standard event-active checks.
// - Owner is the project builder. There is exactly one recipient, also
// the builder, registered as Winner at position 1 (100% of distribution).
// - No upfront owner deposit: create_event SKIPS escrow::deposit_with_fee
// for Pillar::Crowdfunding. Escrow starts at 0 and grows via add_funds
// from community backers.
// - winner_distribution MUST be a single entry at position 1 with 100%.
// - claim_milestone uses dynamic math:
// amount = remaining_escrow / (total_milestones - claimed_so_far)
// so each release pays a fair share of whatever the campaign actually
// raised. The first milestone takes 1/n of escrow, the next 1/(n-1) of
// what's left, ..., the last takes the entire remainder.
//
// The contract enforces only the on-chain shape. Off-chain layers add admin
// review, community voting, milestone validation, and pause semantics.
#![allow(dead_code)]

use soroban_sdk::{Address, Env};
Expand All @@ -28,19 +6,15 @@ use crate::errors::Error;
use crate::types::{EventRecord, ReleaseKind};

pub fn validate_create(_env: &Env, record: &EventRecord, _owner: &Address) -> Result<(), Error> {
// Multi(n) required.
match record.release_kind {
ReleaseKind::Multi(n) if n > 0 => {}
_ => return Err(Error::InvalidReleaseKind),
}

// Funding window required.
if record.deadline.is_none() {
return Err(Error::DeadlineRequired);
}

// Distribution must be exactly one entry at position 1 with 100%. The
// builder is the sole recipient; no co-recipient splits are supported.
if record.winner_distribution.len() != 1 {
return Err(Error::InvalidDistribution);
}
Expand Down
18 changes: 0 additions & 18 deletions contracts/events/src/errors.rs
Original file line number Diff line number Diff line change
@@ -1,32 +1,24 @@
// boundless-events: error codes.
//
// Spec: boundless-platform-contract-prd.md Section 14.

use soroban_sdk::contracterror;

#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
#[repr(u32)]
pub enum Error {
// Init
AlreadyInitialized = 1,
AdminCannotBeZero = 2,
FeeAccountCannotBeZero = 3,
ProfileContractCannotBeZero = 4,
InvalidFeeBps = 5,
NotInitialized = 6,

// Auth
Unauthorized = 10,
NotAdmin = 11,
PendingAdminMismatch = 12,
PendingAdminExpired = 13,

// Token
TokenNotSupported = 20,
FeeAccountMissingTrustline = 21,

// Event lifecycle
EventNotFound = 30,
EventNotActive = 31,
InvalidPillar = 32,
Expand All @@ -38,14 +30,11 @@ pub enum Error {
DeadlineMustBeFuture = 38,
TitleTooLong = 39,

// Participation
ApplicantAlreadyApplied = 40,
ApplicantNotApplied = 41,
SubmissionNotFound = 42,
SubmissionAlreadyExists = 43,
// 44 (InsufficientCredits) retired with on-chain credits; left as a gap.

// Winners
NoSubmissions = 50,
InvalidWinnerPosition = 51,
DuplicateWinnerPosition = 52,
Expand All @@ -55,20 +44,15 @@ pub enum Error {
InsufficientEscrow = 56,
WinnersAlreadySelected = 90,

// Contributions
BelowMinimumContribution = 57,
InvalidContributionAmount = 58,

// Capacity (per-event list caps; see MAX_*_PER_EVENT in event_ops)
TooManyApplicants = 59,

// Idempotency
OpAlreadySeen = 60,

// Capacity continued
TooManyContributors = 61,

// Paged cancellation flow
CancellationNotStarted = 62,
CancellationAlreadyStarted = 63,
CancellationNotFinished = 64,
Expand All @@ -78,9 +62,7 @@ pub enum Error {
UpgradeProposalExpired = 68,
MigrationAlreadyApplied = 69,

// Pause
Paused = 70,

// Cross-contract
ProfileCallFailed = 80,
}
12 changes: 0 additions & 12 deletions contracts/events/src/escrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,25 +46,13 @@ pub fn release(env: &Env, token_addr: &Address, recipient: &Address, amount: i12
client.transfer(&contract, recipient, &amount);
}

/// Deposit exactly `amount` into escrow with NO platform fee taken here.
///
/// Used by pillars (crowdfunding) that charge the fee at release instead, so the
/// funder pays exactly `amount` and a cancel refunds it in full. Returns the
/// amount credited to escrow (== `amount`).
pub fn deposit_no_fee(env: &Env, token_addr: &Address, from: &Address, amount: i128) -> i128 {
let contract = env.current_contract_address();
let client = token::Client::new(env, token_addr);
client.transfer(from, &contract, &amount);
amount
}

/// Release `amount` from escrow, taking the platform fee off the top: the
/// recipient receives `amount - fee` and the fee account receives `fee`.
///
/// Used by pillars (crowdfunding) where the RECIPIENT bears the fee. The funder
/// already deposited their full pledge via `deposit_no_fee`. The full `amount`
/// leaves escrow (net to recipient + fee to platform), so callers decrement
/// `remaining_escrow` by `amount`.
pub fn release_with_fee_at(
env: &Env,
token_addr: &Address,
Expand Down
Loading
Loading