diff --git a/contracts/events/src/admin.rs b/contracts/events/src/admin.rs index 9511923..91b15fb 100644 --- a/contracts/events/src/admin.rs +++ b/contracts/events/src/admin.rs @@ -1,7 +1,3 @@ -// 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; @@ -9,35 +5,16 @@ 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"; // ============================================================ @@ -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); } @@ -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 { @@ -190,22 +159,6 @@ 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, @@ -213,10 +166,6 @@ pub fn propose_upgrade( 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); } @@ -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, } @@ -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)?; @@ -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, ¤t); storage::touch_instance(env); evt::Migrated { @@ -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); diff --git a/contracts/events/src/bounty.rs b/contracts/events/src/bounty.rs index 9d34340..98e392e 100644 --- a/contracts/events/src/bounty.rs +++ b/contracts/events/src/bounty.rs @@ -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; @@ -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); @@ -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, diff --git a/contracts/events/src/crowdfunding.rs b/contracts/events/src/crowdfunding.rs index d41632c..0ca78d1 100644 --- a/contracts/events/src/crowdfunding.rs +++ b/contracts/events/src/crowdfunding.rs @@ -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}; @@ -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); } diff --git a/contracts/events/src/errors.rs b/contracts/events/src/errors.rs index ca38b93..26e080e 100644 --- a/contracts/events/src/errors.rs +++ b/contracts/events/src/errors.rs @@ -1,14 +1,9 @@ -// 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, @@ -16,17 +11,14 @@ pub enum Error { 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, @@ -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, @@ -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, @@ -78,9 +62,7 @@ pub enum Error { UpgradeProposalExpired = 68, MigrationAlreadyApplied = 69, - // Pause Paused = 70, - // Cross-contract ProfileCallFailed = 80, } diff --git a/contracts/events/src/escrow.rs b/contracts/events/src/escrow.rs index 4a7c23a..04e53b6 100644 --- a/contracts/events/src/escrow.rs +++ b/contracts/events/src/escrow.rs @@ -46,11 +46,6 @@ 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); @@ -58,13 +53,6 @@ pub fn deposit_no_fee(env: &Env, token_addr: &Address, from: &Address, amount: i 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, diff --git a/contracts/events/src/event_ops.rs b/contracts/events/src/event_ops.rs index b820751..74336d9 100644 --- a/contracts/events/src/event_ops.rs +++ b/contracts/events/src/event_ops.rs @@ -1,7 +1,3 @@ -// boundless-events: canonical event lifecycle operations. -// -// Spec: boundless-platform-contract-prd.md Sections 6.2, 6.4, 6.5, 7. - use soroban_sdk::{Address, BytesN, Env, String, Symbol, Vec}; use crate::admin::{self, MAX_FEE_BPS}; @@ -24,37 +20,16 @@ use crate::types::{ const MAX_TITLE_LEN: u32 = 120; const MAX_WINNERS_PER_SELECT: u32 = 50; -// Per-event list caps. Lifted from 100 to 5_000 once paged cancel landed: -// start_cancel / process_cancel_batch / finalize_cancel split the refund -// pass across multiple txs so the per-tx footprint never blows past -// MAX_REFUNDS_PER_BATCH contributors. -// -// Spec: docs/audit-2026-06-stellar-skill.md, H3/H4 + paged-cancel follow-up. pub const MAX_APPLICANTS_PER_EVENT: u32 = 5_000; pub const MAX_CONTRIBUTORS_PER_EVENT: u32 = 5_000; -// Max refunds per process_cancel_batch tx. Conservative; the actual ceiling -// depends on the token's transfer cost. 25 is well below Soroban's ~100-entry -// write footprint when each refund touches ContributorAmount + a token -// transfer (3-4 ledger entries). pub const MAX_REFUNDS_PER_BATCH: u32 = 25; -// Open-contribution floor: 10 USDC at 7 decimals. The check is denominated in -// stroops because every supported token on the whitelist uses Stellar's -// canonical 7-decimal scale. If a future token adopts a different scale the -// whitelist registration is the place to gate it; the contract floor stays -// uniform. -// -// Spec: boundless-partner-contributions-prd.md Section 6.1. -const MIN_CONTRIBUTION_STROOPS: i128 = 100_000_000_i128; // 10 * 10^7 +const MIN_CONTRIBUTION_STROOPS: i128 = 100_000_000_i128; // ============================================================ // CREATE EVENT // ============================================================ -/// The address authorized to manage an event (select winners, cancel). A -/// per-event manager override takes precedence; otherwise management falls back -/// to the event owner (legacy events created before manager support). This -/// decouples the funding source (owner) from the operating identity (manager). fn resolve_manager(env: &Env, event_id: u64, owner: &Address) -> Address { storage::get_event_manager(env, event_id).unwrap_or_else(|| owner.clone()) } @@ -65,20 +40,16 @@ pub fn create_event(env: &Env, params: CreateEventParams, op_id: BytesN<32>) -> params.owner.require_auth(); - // Token whitelist. token_whitelist::require_supported(env, ¶ms.token)?; - // Title length. if params.title.len() > MAX_TITLE_LEN { return Err(Error::TitleTooLong); } - // Budget. if params.total_budget <= 0 { return Err(Error::InvalidBudget); } - // Distribution: at least one entry, percents sum to 100. if params.winner_distribution.is_empty() { return Err(Error::InvalidDistribution); } @@ -90,7 +61,6 @@ pub fn create_event(env: &Env, params: CreateEventParams, op_id: BytesN<32>) -> return Err(Error::DistributionMismatch); } - // Deadline (if set) must be future. if let Some(deadline) = params.deadline { if deadline <= env.ledger().timestamp() { return Err(Error::DeadlineMustBeFuture); @@ -104,8 +74,6 @@ pub fn create_event(env: &Env, params: CreateEventParams, op_id: BytesN<32>) -> } let effective_bps = escrow::effective_fee_bps(env, params.fee_bps_override); - // Crowdfunding flips total_budget into a funding goal; escrow starts at 0 - // and grows only via add_funds. Every other pillar deposits at create. let is_crowdfunding = matches!(params.pillar, Pillar::Crowdfunding); let initial_escrow: i128 = if is_crowdfunding { 0 @@ -146,18 +114,14 @@ pub fn create_event(env: &Env, params: CreateEventParams, op_id: BytesN<32>) -> ); } - // Assign id and persist. let id = idempotency::next_event_id(env); let record = EventRecord { id, ..provisional }; storage::set_event(env, id, &record); - // Record the management authority override when the owner delegates it (so - // an org can fund from any wallet but keep management on its own wallet). if let Some(manager) = ¶ms.manager { storage::set_event_manager(env, id, manager); } - // Crowdfunding: pre-seat the builder as the sole winner at position 1. if is_crowdfunding { storage::append_winner( env, @@ -187,9 +151,6 @@ pub fn create_event(env: &Env, params: CreateEventParams, op_id: BytesN<32>) -> Ok(id) } -/// Re-assign (or set) the management authority for an event. Gated by the -/// current manager (the override if present, else the owner), so an org can -/// rotate its operating wallet but an outsider cannot hijack management. pub fn set_manager(env: &Env, event_id: u64, new_manager: Address) -> Result<(), Error> { admin::require_not_paused(env)?; let event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?; @@ -198,7 +159,6 @@ pub fn set_manager(env: &Env, event_id: u64, new_manager: Address) -> Result<(), Ok(()) } -/// The current management authority for an event (override if set, else owner). pub fn get_manager(env: &Env, event_id: u64) -> Result { let event = storage::get_event(env, event_id).ok_or(Error::EventNotFound)?; Ok(resolve_manager(env, event_id, &event.owner)) @@ -231,8 +191,6 @@ pub fn add_funds( from.require_auth(); - // First-time contributor? Reserve the slot BEFORE moving tokens so a - // cap-exceeded reverts the whole flow with no half-credited row. if from != event.owner { let prior = storage::get_contributor_amount(env, event_id, &from); if prior == 0 { @@ -240,15 +198,9 @@ pub fn add_funds( } } - // Fee model is per-pillar. Crowdfunding backers pay EXACTLY their pledge: - // the platform fee is borne by the builder and taken at claim_milestone, so - // a cancelled campaign refunds backers in full. Every other pillar charges - // the fee on top here (the funder is the program owner/sponsor). let credited = if matches!(event.pillar, Pillar::Crowdfunding) { escrow::deposit_no_fee(env, &event.token, &from, amount) } else { - // Rate snapshotted at publish so add_funds matches the program's quoted - // rate even if the contract default changes mid-flight. let effective_bps = escrow::effective_fee_bps(env, event.fee_bps_override); escrow::deposit_with_fee_at(env, &event.token, &from, amount, effective_bps) }; @@ -276,39 +228,6 @@ pub fn add_funds( // ============================================================ // PAGED CANCEL -// -// Three-step flow to keep cancel inside Soroban's per-tx footprint budget: -// -// 1. start_cancel(id) — flip Active → Cancelling, snapshot the -// refund math (non_owner_total, remaining, -// count, branch). For events with 0 -// contributors, also handles the owner -// refund inline. -// 2. process_cancel_batch(id, n) — refund up to n contributors at the -// cursor. Repeats until cursor == count. -// 3. finalize_cancel(id) — require cursor exhausted; pay owner -// residual on FullPartnerThenResidual; -// flip Cancelling → Cancelled; clear the -// state entry. -// -// Refund math (snapshotted at start_cancel; stable across batches because -// Cancelling status blocks add_funds + other contributor mutations): -// -// non_owner_total = sum(ContributorAmount(event_id, *)) -// remaining = event.remaining_escrow -// -// OwnerOnly: non_owner_total == 0; owner gets remaining. -// Settled inline at start_cancel. -// FullPartnerThenResidual: remaining >= non_owner_total; each partner -// gets full amount; owner residual paid at -// finalize_cancel. -// ProRataPartners: remaining < non_owner_total; partners get -// floor(amt * remaining / non_owner_total). -// Owner gets 0. Dust stays in contract. -// -// Spec: boundless-platform-contract-prd.md Section 6.2; -// boundless-partner-contributions-prd.md Section 7; -// docs/audit-2026-06-stellar-skill.md paged-cancel follow-up. // ============================================================ pub fn start_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<(), Error> { admin::require_not_paused(env)?; @@ -322,16 +241,11 @@ pub fn start_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<(), E return Err(Error::CancellationAlreadyStarted); } - // Management authority: the per-event manager override if set, else owner. resolve_manager(env, event_id, &event.owner).require_auth(); let remaining = event.remaining_escrow; let count = storage::contributor_count(env, event_id); - // Sum non-owner contributions once. Bounded by MAX_CONTRIBUTORS_PER_EVENT - // contributor_amount reads; for events with > MAX_REFUNDS_PER_BATCH - // contributors the caller will need to start_cancel on smaller events - // OR we accept this single read pass as the cost of snapshotting. let mut non_owner_total: i128 = 0; for idx in 0..count { if let Some(c) = storage::contributor_at(env, event_id, idx) { @@ -348,9 +262,6 @@ pub fn start_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<(), E CancellationBranch::ProRataPartners }; - // OwnerOnly shortcut: no partner refunds to page through; flip directly - // to Cancelled and pay owner residual inline. Saves the caller a round - // trip for the common "abandoned, no community contributions" case. if matches!(branch, CancellationBranch::OwnerOnly) { if remaining > 0 { escrow::release(env, &event.token, &event.owner, remaining); @@ -369,8 +280,6 @@ pub fn start_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<(), E return Ok(()); } - // Partner refund branches: persist the cursor + branch and flip to - // Cancelling. process_cancel_batch + finalize_cancel finish the work. let state = CancellationState { non_owner_total, remaining_at_start: remaining, @@ -399,13 +308,11 @@ pub fn process_cancel_batch( if !matches!(event.status, EventStatus::Cancelling) { return Err(Error::CancellationNotStarted); } - // Management authority: the per-event manager override if set, else owner. resolve_manager(env, event_id, &event.owner).require_auth(); let mut state = storage::get_cancellation_state(env, event_id).ok_or(Error::CancellationNotStarted)?; - // Anyone can call with a 0 batch_size if the cursor is at end; nothing to do. let cap = if max_refunds > MAX_REFUNDS_PER_BATCH { MAX_REFUNDS_PER_BATCH } else { @@ -430,7 +337,6 @@ pub fn process_cancel_batch( CancellationBranch::ProRataPartners => { amt.saturating_mul(state.remaining_at_start) / state.non_owner_total } - // OwnerOnly was settled inline at start_cancel. CancellationBranch::OwnerOnly => 0, }; @@ -462,7 +368,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); } - // Management authority: the per-event manager override if set, else owner. resolve_manager(env, event_id, &event.owner).require_auth(); let state = @@ -471,8 +376,6 @@ pub fn finalize_cancel(env: &Env, event_id: u64, op_id: BytesN<32>) -> Result<() return Err(Error::CancellationNotFinished); } - // Owner residual paid only on FullPartnerThenResidual; ProRataPartners - // intentionally pays the owner 0. if matches!(state.branch, CancellationBranch::FullPartnerThenResidual) { let owner_residual = state .remaining_at_start @@ -516,7 +419,6 @@ pub fn submit( if !matches!(event.status, EventStatus::Active) { return Err(Error::EventNotActive); } - // Crowdfunding has no submission concept: the builder is pre-seated. if matches!(event.pillar, Pillar::Crowdfunding) { return Err(Error::InvalidPillar); } @@ -530,8 +432,6 @@ pub fn submit( let existing = storage::get_submission(env, event_id, &applicant); - // Pillar-aware application gate (only enforced on first submission). - // O(1) lookup via the slot index. if existing.is_none() { let needs_application = matches!(event.pillar, Pillar::Bounty | Pillar::Grant); if needs_application && storage::applicant_slot(env, event_id, &applicant) == 0 { @@ -622,12 +522,8 @@ pub fn select_winners( return Err(Error::InvalidPillar); } - // Management authority: the per-event manager override if set, else owner. resolve_manager(env, event_id, &event.owner).require_auth(); - // One-shot per event: detect a prior selection by an anchor row - // (milestone == None). Crowdfunding's create_event seeds an anchor too, - // but we returned above for crowdfunding. let existing_count = storage::winner_count(env, event_id); for idx in 0..existing_count { if let Some(w) = storage::winner_at(env, event_id, idx) { @@ -644,7 +540,6 @@ pub fn select_winners( return Err(Error::InvalidWinnerPosition); } - // Validate each position exists in distribution and no duplicates. let mut seen_positions: Vec = Vec::new(env); for spec in winners.iter() { let mut already = false; @@ -669,15 +564,8 @@ pub fn select_winners( match event.release_kind { ReleaseKind::Single => { - // M1: percent math is against the live escrow at select time - // (snapshotted before the first refund), so partner top-ups via - // add_funds flow into winner payouts rather than getting - // trapped until cancel. Snapshot once so each winner gets the - // intended share regardless of the order of releases inside - // this same call. let escrow_at_select = event.remaining_escrow; - // First pass: compute per-winner amounts and verify total fits. let mut total_owed: i128 = 0; for spec in winners.iter() { let percent = event @@ -694,7 +582,6 @@ pub fn select_winners( return Err(Error::InsufficientEscrow); } - // Second pass: release per winner with all four profile-side calls. for (idx, spec) in winners.iter().enumerate() { let sub_idx = idx as u8; let percent = event @@ -749,7 +636,6 @@ pub fn select_winners( } } ReleaseKind::Multi(_) => { - // Grants: record winners but defer payment to claim_milestone. for spec in winners.iter() { storage::append_winner( env, @@ -781,10 +667,6 @@ pub fn select_winners( // ============================================================ // READS -// -// Aggregated reads (get_applicants, get_winners, get_contributors) cap at -// MAX_*_PER_EVENT entries. Callers expecting larger lists should use the -// paged accessors (*_count + *_at). // ============================================================ pub fn get_event(env: &Env, event_id: u64) -> Result { storage::get_event(env, event_id).ok_or(Error::EventNotFound) @@ -860,6 +742,5 @@ pub fn get_contributor_amount( Ok(storage::get_contributor_amount(env, event_id, &contributor)) } -// Silence unused-import warnings until the stubbed ops land. #[allow(dead_code)] const _MARK_USED: (Option,) = (None,); diff --git a/contracts/events/src/events.rs b/contracts/events/src/events.rs index 91d253d..4c675dc 100644 --- a/contracts/events/src/events.rs +++ b/contracts/events/src/events.rs @@ -1,9 +1,3 @@ -// boundless-events: contract event emissions. -// -// Spec: boundless-platform-contract-prd.md Section 13. -// -// dead_code allowed: event structs are emitted via .publish() calls from -// operation bodies that are stubbed in this pass. #![allow(dead_code)] use soroban_sdk::{contractevent, Address, BytesN, String}; @@ -18,8 +12,6 @@ pub struct EventCreated { pub token: Address, pub total_budget: i128, pub content_uri: String, - // L5 (2026-06 audit): title is part of the indexer payload so - // listings can be rendered without a follow-up get_event read. pub title: String, } diff --git a/contracts/events/src/grant.rs b/contracts/events/src/grant.rs index d4f12f2..e33b893 100644 --- a/contracts/events/src/grant.rs +++ b/contracts/events/src/grant.rs @@ -1,12 +1,3 @@ -// boundless-events: grant-specific behavior + shared milestone-claim entry. -// -// Spec: boundless-platform-contract-prd.md Sections 6.4, 7; -// boundless-crowdfunding-prd.md. -// -// Grants use ReleaseKind::Multi(n) and release per-milestone via -// claim_milestone. Crowdfunding reuses the same entry point but takes a -// different math path (dynamic, see comment in claim_milestone). - use soroban_sdk::{Address, BytesN, Env, Symbol}; use crate::admin; @@ -28,35 +19,6 @@ pub fn validate_create(_env: &Env, record: &EventRecord, _owner: &Address) -> Re // ============================================================ // CLAIM MILESTONE // ============================================================ -// -// Per-(recipient, milestone) idempotency via DataKey::MilestoneClaimed. -// -// Math depends on pillar: -// -// Grant (fixed): -// amount = total_budget * distribution[position] / 100 / total_milestones -// -// Crowdfunding (dynamic): -// amount = remaining_escrow / (total_milestones - already_claimed_count) -// -// The Crowdfunding path divides whatever escrow is actually present at -// release time evenly across the remaining milestones. The last milestone -// picks up any rounding remainder, so the total paid equals what was -// raised exactly. -// -// Each call: token release + bootstrap (idempotent) + bump_reputation + -// register_earnings. Marks the event Completed when the last milestone for -// the last recipient drains remaining_escrow. -// -// Auth: event.owner for grants (organization-controlled); for crowdfunding -// the owner is the builder, so the off-chain layer routes claim_milestone -// through an admin-signed transaction where the admin signs on the builder's -// behalf only after milestone validation. On-chain check is identical: -// require_auth on event.owner. Operationally that means the builder's -// abstracted-wallet key is used to sign, gated by admin approval upstream. -// -// Spec: boundless-platform-contract-prd.md Section 6.4, 8; -// boundless-crowdfunding-prd.md. pub fn claim_milestone( env: &Env, event_id: u64, @@ -84,13 +46,6 @@ pub fn claim_milestone( event.owner.require_auth(); - // M5 (2026-06 audit): crowdfunding claims require admin co-sign on top - // of the builder's auth. Today the abstracted-wallet model keeps the - // builder's key on the platform side, so this is operationally a - // no-op; the on-chain check is the defense if the wallet model ever - // changes to put the key in the builder's hands. Grants stay - // single-auth because the event owner is the grant org, which is the - // intended approver. See docs/audit-2026-06-stellar-skill.md M5. if matches!(event.pillar, Pillar::Crowdfunding) { let admin = storage::get_admin(env)?; admin.require_auth(); @@ -100,9 +55,6 @@ pub fn claim_milestone( return Err(Error::MilestoneAlreadyClaimed); } - // Locate the recipient's anchor row (milestone == None), and count any - // prior per-milestone payouts so the fixed-split last-milestone sweep - // can land exactly on total_share. let count = storage::winner_count(env, event_id); let mut winner_position: Option = None; let mut already_claimed_for_recipient: u32 = 0; @@ -127,8 +79,6 @@ pub fn claim_milestone( let is_crowdfunding = matches!(event.pillar, Pillar::Crowdfunding); let amount: i128 = if is_crowdfunding { - // Dynamic split: divide what's left evenly across remaining milestones; - // the final claim drains the remainder so no dust is stranded. let claimed_count = storage::get_crowdfunding_milestones_claimed(env, event_id); let remaining_milestones = total_milestones.saturating_sub(claimed_count); if remaining_milestones == 0 { @@ -143,9 +93,6 @@ pub fn claim_milestone( event.remaining_escrow / (remaining_milestones as i128) } } else { - // Fixed split with last-milestone sweep so the position share pays out - // exactly. Per-recipient progress was tallied during the anchor scan - // above. let percent = event .winner_distribution .get(position) @@ -166,10 +113,6 @@ pub fn claim_milestone( return Err(Error::InsufficientEscrow); } - // Move money. Crowdfunding recipients (builders) bear the platform fee: it - // is taken from each milestone payout here, because backers deposited their - // full pledge fee-free. Other pillars already took the fee at deposit, so - // they release the full amount. Either way the full `amount` leaves escrow. if is_crowdfunding { let bps = escrow::effective_fee_bps(env, event.fee_bps_override); escrow::release_with_fee_at(env, &event.token, &recipient, amount, bps); @@ -183,7 +126,6 @@ pub fn claim_milestone( storage::set_crowdfunding_milestones_claimed(env, event_id, claimed.saturating_add(1)); } - // Cross-contract profile mutations. Each call gets a unique child op_id. let profile = profile_client::client(env); let reason = Symbol::new(env, "milestone"); @@ -196,7 +138,6 @@ pub fn claim_milestone( let earnings_op = idempotency::derive_child(env, &op_id, tag::REGISTER_EARNINGS); profile.register_earnings(&recipient, &event.token, &amount, &earnings_op); - // Append per-milestone Winner row for the audit trail. storage::append_winner( env, event_id, diff --git a/contracts/events/src/hackathon.rs b/contracts/events/src/hackathon.rs index 6a45cf4..ca738d2 100644 --- a/contracts/events/src/hackathon.rs +++ b/contracts/events/src/hackathon.rs @@ -1,11 +1,3 @@ -// boundless-events: hackathon-specific behavior. -// -// Spec: boundless-platform-contract-prd.md Section 7. -// -// Hackathons use ReleaseKind::Single and require a submission deadline. -// Open submission model: no per-applicant credit charge. -// -// Wired by create_event dispatch. #![allow(dead_code)] use soroban_sdk::{Address, Env}; diff --git a/contracts/events/src/idempotency.rs b/contracts/events/src/idempotency.rs index 2e2c8aa..7c25305 100644 --- a/contracts/events/src/idempotency.rs +++ b/contracts/events/src/idempotency.rs @@ -1,8 +1,3 @@ -// boundless-events: idempotency helpers. -// -// Spec: boundless-platform-contract-prd.md Section 10. -// -// Wired by operation bodies as they land. #![allow(dead_code)] use soroban_sdk::{BytesN, Env}; @@ -21,10 +16,6 @@ pub fn mark_seen(env: &Env, op_id: &BytesN<32>) { storage::mark_op_seen(env, op_id); } -// Deployment-epoch ID base: upper 32 bits encode the ledger sequence at deploy -// time so that ids from different deployments never collide. -// -// Spec: boundless-platform-contract-prd.md Section 9. pub fn id_base(env: &Env) -> u64 { let seq = storage::get_deployment_seq(env); (seq as u64) << 32 @@ -37,8 +28,6 @@ pub fn next_event_id(env: &Env) -> u64 { id } -/// Tag constants for `derive_child`. One per cross-contract op kind so that -/// the events contract and the profile contract never share an OpSeen marker. pub mod tag { pub const BOOTSTRAP: u8 = 0xB0; pub const BUMP_REP: u8 = 0xD1; @@ -46,21 +35,12 @@ pub mod tag { pub const REGISTER_EARNINGS: u8 = 0xE1; } -/// Derive a child op_id from a parent so that cross-contract calls within a -/// single events-side operation each have a unique idempotency marker. -/// -/// XOR with a per-op tag in the first byte: cheap, deterministic, and the -/// orchestrator's sha256-based parent op_ids make collisions effectively -/// impossible. pub fn derive_child(env: &Env, parent: &BytesN<32>, op_tag: u8) -> BytesN<32> { let mut payload = parent.to_array(); payload[0] ^= op_tag; BytesN::from_array(env, &payload) } -/// Same as `derive_child` but also XORs a sub-index into the second byte, so -/// per-winner cross-contract calls within select_winners get unique op_ids -/// even when the same op_tag is reused across winners. pub fn derive_child_indexed(env: &Env, parent: &BytesN<32>, op_tag: u8, sub_idx: u8) -> BytesN<32> { let mut payload = parent.to_array(); payload[0] ^= op_tag; diff --git a/contracts/events/src/lib.rs b/contracts/events/src/lib.rs index 959db86..fa3fcf8 100644 --- a/contracts/events/src/lib.rs +++ b/contracts/events/src/lib.rs @@ -1,10 +1,4 @@ // SPDX-License-Identifier: MIT -// -// boundless-events -// -// On-chain event records (Hackathon, Bounty, Grant) plus inlined escrow. -// Companion: boundless-profile (credits + reputation). -// Spec: boundless-platform-contract-prd.md #![no_std] use soroban_sdk::{contract, contractimpl, contractmeta, Address, BytesN, Env, String, Vec}; @@ -121,14 +115,10 @@ impl EventsContract { token_whitelist::is_supported(&env, &token) } - /// Number of whitelisted tokens. Paged enumeration: read this, then - /// supported_token_at(0..count) to recover the full whitelist from state - /// (no dependence on ephemeral TokenRegistered/TokenDeregistered events). pub fn supported_token_count(env: Env) -> u32 { storage::supported_token_count(&env) } - /// Whitelisted token at `index`, or None when `index >= count`. pub fn supported_token_at(env: Env, index: u32) -> Option
{ storage::supported_token_at(&env, index) } @@ -339,7 +329,6 @@ impl EventsContract { admin::get_migrated_to_version(&env) } - // Internal helper exposed for off-chain inspection; emits no event. pub fn id_base(env: Env) -> u64 { idempotency::id_base(&env) } diff --git a/contracts/events/src/profile_client.rs b/contracts/events/src/profile_client.rs index e5c3f17..ab4b1d9 100644 --- a/contracts/events/src/profile_client.rs +++ b/contracts/events/src/profile_client.rs @@ -1,17 +1,5 @@ -// boundless-events: client for the boundless-profile contract. -// -// The events contract calls the profile contract for credit / reputation -// mutations triggered by event-side flows. We declare the profile interface as -// a trait and let Soroban generate a typed client. The actual implementation -// lives in the separately-deployed profile contract; this is just the call -// surface from the events side. -// -// Spec: boundless-platform-contract-prd.md Section 4 (cross-contract dance). - use soroban_sdk::{contractclient, Address, BytesN, Env, Symbol}; -// The trait body is consumed by the contractclient macro to generate -// ProfileClient. The trait itself has no other callers. #[allow(dead_code)] #[contractclient(name = "ProfileClient")] pub trait ProfileInterface { @@ -21,8 +9,6 @@ pub trait ProfileInterface { fn register_earnings(env: Env, user: Address, token: Address, amount: i128, op_id: BytesN<32>); } -/// Helper to build a typed ProfileClient pointing at the currently-configured -/// profile contract address. pub fn client<'a>(env: &Env) -> ProfileClient<'a> { let addr = crate::storage::get_profile_contract(env); ProfileClient::new(env, &addr) diff --git a/contracts/events/src/storage.rs b/contracts/events/src/storage.rs index 4aaa9ed..6cd8d4d 100644 --- a/contracts/events/src/storage.rs +++ b/contracts/events/src/storage.rs @@ -1,22 +1,3 @@ -// boundless-events: storage helpers. -// -// Storage layout (after the 2026-06 audit, H1-H4): -// -// instance() — admin + config + token whitelist + NextEventId. -// persistent() — per-event Event records, per-event paged lists -// (applicants, contributors, winners), and per-submission -// entries. Each persistent read/write bumps TTL. -// temporary() — OpSeen idempotency markers. -// -// Per-event lists are kept as count + indexed-entry pairs (e.g. -// EventApplicantCount + EventApplicantAt(idx) + EventApplicantSlot(addr)) -// so a single growable Vec never overflows the 64KB ledger-entry cap and -// presence checks stay O(1). -// -// Cap policy (event_ops::MAX_*_PER_EVENT) enforced at append time so that -// cancel_event refund passes stay inside Soroban's per-tx footprint budget. -// Paging cancel_event is a P1 follow-up before lifting the caps. - #![allow(dead_code)] use soroban_sdk::{Address, BytesN, Env, Vec}; @@ -185,10 +166,6 @@ pub fn set_token_supported(env: &Env, token: &Address, supported: bool) { .set(&DataKey::SupportedToken(token.clone()), &supported); } -// Enumerable whitelist index, kept in lockstep with the SupportedToken bool by -// register/deregister. Mirrors the applicant index (count + at + slot, append -// to tail, swap-with-last removal) but is global rather than per-event, so the -// full whitelist can be read from state instead of replaying events. pub fn supported_token_count(env: &Env) -> u32 { env.storage() .instance() @@ -209,8 +186,6 @@ pub fn supported_token_slot(env: &Env, token: &Address) -> u32 { .unwrap_or(0) } -/// Append a token to the enumerable index. Idempotent: a token already indexed -/// is left untouched (so re-registering does not duplicate it). pub fn append_supported_token(env: &Env, token: &Address) { if supported_token_slot(env, token) != 0 { return; @@ -228,8 +203,6 @@ pub fn append_supported_token(env: &Env, token: &Address) { .set(&DataKey::SupportedTokenCount, &slot); } -/// Remove a token from the enumerable index via swap-with-last. Idempotent: a -/// token not in the index is a no-op. pub fn remove_supported_token(env: &Env, token: &Address) { let slot = supported_token_slot(env, token); if slot == 0 { @@ -239,7 +212,6 @@ pub fn remove_supported_token(env: &Env, token: &Address) { let count = supported_token_count(env); let last_idx = count.saturating_sub(1); - // Move the last entry into the freed slot, unless we removed the tail. if idx != last_idx { if let Some(last) = supported_token_at(env, last_idx) { env.storage() @@ -299,7 +271,6 @@ pub fn set_event(env: &Env, id: u64, record: &EventRecord) { touch_event_persistent(env, &key); } -// Per-event management authority override (side map; absent => owner manages). pub fn get_event_manager(env: &Env, id: u64) -> Option
{ let key = DataKey::EventManager(id); let m: Option
= env.storage().persistent().get(&key); @@ -317,18 +288,6 @@ pub fn set_event_manager(env: &Env, id: u64, manager: &Address) { // ============================================================ // APPLICANTS (paged, persistent) -// -// applicant_count(id) -> number of applicants in [0, count). -// applicant_at(id, idx) -> address at slot idx (Some only when idx < count). -// applicant_slot(id, addr) -> 1-based slot. 0 means absent. Stored 1-based -// so a missing key (default 0) signals absence -// without an Option round trip. -// append_applicant(id, addr) -> appends to the tail. Returns the 1-based slot. -// Fails with TooManyApplicants if cap is hit. -// remove_applicant(id, addr) -> swap-with-last + decrement. -// applicants_snapshot(id, max) -> Vec view capped at `max`; older callers -// that read the whole list should migrate -// to paged reads. // ============================================================ pub fn applicant_count(env: &Env, id: u64) -> u32 { let key = DataKey::EventApplicantCount(id); @@ -389,7 +348,6 @@ pub fn remove_applicant(env: &Env, id: u64, addr: &Address) -> Result<(), Error> let count = applicant_count(env, id); let last_idx = count.checked_sub(1).ok_or(Error::EventNotFound)?; - // If not the last entry, swap the last applicant into the freed slot. if idx != last_idx { let last_addr = applicant_at(env, id, last_idx).ok_or(Error::EventNotFound)?; let at_key = DataKey::EventApplicantAt(id, idx); @@ -456,13 +414,6 @@ pub fn remove_submission(env: &Env, id: u64, applicant: &Address) { // ============================================================ // WINNERS (paged, persistent) -// -// winner_count(id) -> number of winner rows (anchors + per-milestone). -// winner_at(id, idx) -> Winner at slot idx. -// append_winner(id, w) -> append-only; select_winners and claim_milestone -// never rewrite existing rows. -// winners_snapshot(id,max) -> Vec view capped at `max`. Callers needing full -// iteration should use the paged API. // ============================================================ pub fn winner_count(env: &Env, id: u64) -> u32 { let key = DataKey::EventWinnerCount(id); @@ -553,7 +504,7 @@ pub fn contributor_slot(env: &Env, id: u64, addr: &Address) -> u32 { pub fn append_contributor(env: &Env, id: u64, addr: &Address, cap: u32) -> Result { if contributor_slot(env, id, addr) != 0 { - return Ok(0); // already present; caller treats 0 as "no-op" + return Ok(0); } let cur = contributor_count(env, id); if cur >= cap { diff --git a/contracts/events/src/tests/admin.rs b/contracts/events/src/tests/admin.rs index 20f2036..52f1b23 100644 --- a/contracts/events/src/tests/admin.rs +++ b/contracts/events/src/tests/admin.rs @@ -1,5 +1,3 @@ -// boundless-events: admin tests. - #![cfg(test)] use soroban_sdk::{ @@ -10,7 +8,6 @@ use soroban_sdk::{ use super::common::setup; use crate::errors::Error; -// Mirror the constants from admin.rs to keep timelock arithmetic readable. const UPGRADE_TIMELOCK_LEDGERS: u32 = 17_280; const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; @@ -40,7 +37,6 @@ fn pause_and_unpause_round_trip() { fn id_base_encodes_deployment_sequence() { let ctx = setup(250); let base = ctx.client.id_base(); - // id_base should be (seq << 32); lower 32 bits zero. assert_eq!(base & 0xFFFF_FFFF, 0); } @@ -83,7 +79,6 @@ fn propose_upgrade_rejects_empty_version() { .err() .expect("empty version rejected") .unwrap(); - // Reuse of InvalidPillar documented in admin.rs. assert_eq!(err, Error::InvalidPillar); } @@ -112,7 +107,6 @@ fn apply_upgrade_after_expiry_reverts() { let start = ctx.env.ledger().sequence(); ctx.client.propose_upgrade(&new_hash, &new_version); - // Past the expiry. ctx.env.ledger().with_mut(|li| { li.sequence_number = start + PENDING_UPGRADE_TTL_LEDGERS + 1; }); @@ -136,7 +130,6 @@ fn cancel_pending_upgrade_clears_proposal() { ctx.client.cancel_pending_upgrade(); assert_eq!(ctx.client.get_pending_upgrade(), None); - // Version unchanged. assert_eq!(ctx.client.version(), String::from_str(&ctx.env, "1.1.0")); } diff --git a/contracts/events/src/tests/bounty_pillar.rs b/contracts/events/src/tests/bounty_pillar.rs index a537984..a29332e 100644 --- a/contracts/events/src/tests/bounty_pillar.rs +++ b/contracts/events/src/tests/bounty_pillar.rs @@ -1,13 +1,3 @@ -// boundless-events: bounty pillar tests. -// -// Covers Pillar::Bounty paths: -// - validate_create (Single release only) -// - apply_to_bounty (profile bootstrap via cross-contract; credits off-chain) -// - withdraw_application (drops the applicant slot; credits off-chain) -// - auth, idempotency, pause, deadline, and lifecycle guards -// -// Spec: boundless-platform-contract-prd.md Sections 6.3, 7. - #![cfg(test)] use soroban_sdk::{ @@ -174,7 +164,6 @@ fn apply_bootstraps_profile_and_records_applicant() { ctx.events .apply_to_bounty(&bounty_id, &ctx.applicant, &op_id); - // Apply bootstraps the applicant's profile (credits are off-chain now). assert!(ctx.profile.get_profile(&ctx.applicant).is_some()); let applicants = ctx.events.get_applicants(&bounty_id); @@ -350,7 +339,6 @@ fn withdraw_removes_the_applicant() { ctx.events .withdraw_application(&bounty_id, &ctx.applicant, &op_wd); - // Credits (including any refund) are off-chain; on-chain just drops the slot. let applicants = ctx.events.get_applicants(&bounty_id); assert_eq!(applicants.len(), 0); } diff --git a/contracts/events/src/tests/cancel_refund.rs b/contracts/events/src/tests/cancel_refund.rs index 9ce2e25..628b5a2 100644 --- a/contracts/events/src/tests/cancel_refund.rs +++ b/contracts/events/src/tests/cancel_refund.rs @@ -1,12 +1,3 @@ -// boundless-events: cancel + refund batch tests (#28). -// -// Covers start_cancel / process_cancel_batch / finalize_cancel: -// - OwnerOnly branch settled inline. -// - FullPartnerThenResidual: partners full + owner residual. -// - ProRataPartners: remaining < non_owner_total. -// - Pagination across multiple batches. -// - Error variants: wrong state, replay, not finished. - #![cfg(test)] use soroban_sdk::{ @@ -241,9 +232,6 @@ fn paged_cancel_processes_in_batches() { #[test] fn cancel_prorata_splits_remaining_across_partners_no_owner_residual() { - // escrow = 2000 (owner 1000 + p1 500 + p2 500); select_winners pays - // pos1 60% (1200) leaving remaining = 800. non_owner_total = 1000. - // 800 < 1000 -> ProRata: each partner gets 500 * 800 / 1000 = 400; owner = 0. let ctx = setup(); let mut dist = Map::new(&ctx.env); dist.set(1, 60); diff --git a/contracts/events/src/tests/common.rs b/contracts/events/src/tests/common.rs index 8dfc709..653da2e 100644 --- a/contracts/events/src/tests/common.rs +++ b/contracts/events/src/tests/common.rs @@ -1,5 +1,3 @@ -// boundless-events: shared test setup. - #![cfg(test)] use soroban_sdk::{ @@ -11,11 +9,6 @@ use crate::event_ops::MAX_REFUNDS_PER_BATCH; use crate::types::EventStatus; use crate::{EventsContract, EventsContractClient}; -/// Drive a paged cancel end-to-end for the given event. Used by tests so -/// each call site does not have to thread start_cancel / process / -/// finalize manually. The OwnerOnly branch settles inside start_cancel, -/// so an event with no partner contributions skips the batch + finalize -/// steps. #[allow(dead_code)] pub fn drive_cancel<'a>(env: &Env, client: &EventsContractClient<'a>, id: u64) { let op_start = BytesN::random(env); diff --git a/contracts/events/src/tests/contributions.rs b/contracts/events/src/tests/contributions.rs index 2e79193..cb46fe8 100644 --- a/contracts/events/src/tests/contributions.rs +++ b/contracts/events/src/tests/contributions.rs @@ -1,29 +1,3 @@ -// boundless-events: partner contribution + refund tests. -// -// Covers the open `add_funds` path and the cancel-event refund matrix. -// -// Refund-policy reachability note: the contract has three cancel branches: -// -// 1. no contributors -> owner gets everything -// 2. remaining >= non_owner_tot -> partners in full + owner residual -// 3. remaining < non_owner_tot -> partners pro-rata, owner gets 0 -// -// Branch 3 is defensive code. The current contract enforces a hard -// invariant: total payouts <= total_budget (select_winners and -// claim_milestone both cap per-recipient amounts via total_budget * -// percent / 100). Therefore at cancel time: -// -// remaining = total_budget + non_owner_total - payouts -// >= non_owner_total -// -// so branch 3 is unreachable today. We still test the boundary -// remaining == non_owner_total to exercise the partner-pays-in-full path -// when owner residual is zero. Future op kinds (admin slashing, fee -// adjustments) could break the invariant; the branch is kept and covered -// by code review until then. -// -// Spec: boundless-partner-contributions-prd.md Sections 6 + 7. - #![cfg(test)] use soroban_sdk::{ @@ -39,10 +13,8 @@ use boundless_profile::{ProfileContract, ProfileContractClient}; const FEE_BPS: u32 = 250; -// 10 USDC at 7 decimals = 100_000_000 stroops. Mirrors the contract constant. const MIN_CONTRIB: i128 = 100_000_000_i128; -// Hackathon-shaped budget: 1000 USDC. const TOTAL_BUDGET: i128 = 1_000_0000000_i128; struct Ctx<'a> { @@ -162,7 +134,6 @@ fn anyone_can_top_up_an_active_event() { let amt = ctx.events.get_contributor_amount(&id, &partner); assert_eq!(amt, amount); - // Fee account got create-time fee plus this contribution's fee. let token = token::Client::new(&ctx.env, &ctx.token_addr); let initial_owner_fee = TOTAL_BUDGET * FEE_BPS as i128 / 10_000_i128; assert_eq!(token.balance(&ctx.fee_account), initial_owner_fee + fee); @@ -181,7 +152,6 @@ fn below_minimum_contribution_reverts() { let res = ctx.events.try_add_funds(&id, &partner, &too_small, &op); assert!(res.is_err(), "below-minimum contribution must revert"); - // Exactly the minimum succeeds. let op_ok = BytesN::random(&ctx.env); ctx.events.add_funds(&id, &partner, &MIN_CONTRIB, &op_ok); } @@ -353,17 +323,6 @@ fn cancel_with_partner_pool_refunds_partners_then_owner_residual() { #[test] fn cancel_at_boundary_pays_partners_full_no_owner_residual() { - // Drain enough escrow so remaining_escrow == non_owner_total at cancel. - // After M1 (select_winners pays against remaining_escrow), we cannot - // hit the boundary by paying out 100% of distribution — that drains - // the partner pool too. Instead we fill only one position (50%) so - // half the escrow is paid and half remains; with partner pool sized - // to that remainder, the cancel falls on the equality boundary. - // - // TOTAL_BUDGET = 1000, partner = 1000 (split 500 / 500), - // escrow_at_select = 2000, dist = 50% + 50% (positions 1 and 2), - // pay only position 1: paid = 1000, remaining = 1000 = partner pool. - let ctx = setup(); let mut dist = Map::new(&ctx.env); dist.set(1, 50); @@ -397,8 +356,6 @@ fn cancel_at_boundary_pays_partners_full_no_owner_residual() { let op2 = BytesN::random(&ctx.env); ctx.events.add_funds(&id, &p2, &c2, &op2); - // remaining = 1000 + 1000 = 2000. Pay only position 1 at 50% of escrow - // = 1000. remaining_after = 1000 = non_owner_total. Boundary case A. let winner_a = Address::generate(&ctx.env); let winners = soroban_sdk::vec![ &ctx.env, @@ -411,7 +368,6 @@ fn cancel_at_boundary_pays_partners_full_no_owner_residual() { let op_select = BytesN::random(&ctx.env); ctx.events.select_winners(&id, &winners, &op_select); - // The event isn't Completed because remaining (1000) != 0. let after_select = ctx.events.get_event(&id); assert_eq!(after_select.status, EventStatus::Active); assert_eq!(after_select.remaining_escrow, 1_000_0000000_i128); @@ -450,7 +406,6 @@ fn cancel_with_owner_top_up_keeps_owner_residual_correct() { let op_add = BytesN::random(&ctx.env); ctx.events.add_funds(&id, &partner, &pc, &op_add); - // remaining = 1000 + 200 + 300 = 1500. non_owner_total = 300. let token = token::Client::new(&ctx.env, &ctx.token_addr); let owner_before = token.balance(&ctx.owner); let partner_before = token.balance(&partner); @@ -458,7 +413,6 @@ fn cancel_with_owner_top_up_keeps_owner_residual_correct() { drive_cancel(&ctx.env, &ctx.events, id); assert_eq!(token.balance(&partner) - partner_before, pc); - // Owner residual = 1500 - 300 = 1200 (their original budget + top-up). assert_eq!(token.balance(&ctx.owner) - owner_before, 1_200_0000000_i128); let event = ctx.events.get_event(&id); @@ -475,8 +429,6 @@ fn add_funds_paged_storage_round_trip() { let ctx = setup(); let id = create_hackathon(&ctx); - // Three distinct partners; verify both the legacy snapshot read and - // the paged accessors agree on the layout. let partners: [Address; 3] = [ Address::generate(&ctx.env), Address::generate(&ctx.env), @@ -504,12 +456,6 @@ fn add_funds_paged_storage_round_trip() { #[test] fn paged_cancel_processes_in_batches() { - // Seed 5 partners then drive the cancel paged-flow with a batch size - // of 2. Confirms: - // - start_cancel flips status to Cancelling and persists the cursor - // - process_cancel_batch refunds the next N and advances the cursor - // - finalize_cancel pays owner residual and flips to Cancelled - // - all partners receive their original amount (branch A) let ctx = setup(); let id = create_hackathon(&ctx); @@ -539,14 +485,12 @@ fn paged_cancel_processes_in_batches() { ]; let owner_before = token.balance(&ctx.owner); - // start_cancel: Cancelling, owner unpaid yet. let op_start = BytesN::random(&ctx.env); ctx.events.start_cancel(&id, &op_start); let after_start = ctx.events.get_event(&id); assert_eq!(after_start.status, EventStatus::Cancelling); assert_eq!(token.balance(&ctx.owner) - owner_before, 0); - // Process in batches of 2. let batch = 2_u32; let op_b1 = BytesN::random(&ctx.env); let remaining = ctx.events.process_cancel_batch(&id, &batch, &op_b1); @@ -556,7 +500,6 @@ fn paged_cancel_processes_in_batches() { let remaining = ctx.events.process_cancel_batch(&id, &batch, &op_b2); assert_eq!(remaining, 1); - // Cannot finalize yet. let op_too_early = BytesN::random(&ctx.env); let r = ctx.events.try_finalize_cancel(&id, &op_too_early); assert!(r.is_err(), "finalize before cursor end must revert"); @@ -572,18 +515,14 @@ fn paged_cancel_processes_in_batches() { assert_eq!(event.status, EventStatus::Cancelled); assert_eq!(event.remaining_escrow, 0); - // Each partner received their original deposit. for (i, p) in partners.iter().enumerate() { assert_eq!(token.balance(p) - balances_before[i], per); } - // Owner residual = TOTAL_BUDGET (their original deposit; partners are paid in full). assert_eq!(token.balance(&ctx.owner) - owner_before, TOTAL_BUDGET); } #[test] fn paged_cancel_owner_only_settles_inside_start() { - // No partner contributions: start_cancel settles inline + flips Cancelled - // in one tx. process_cancel_batch / finalize_cancel must reject. let ctx = setup(); let id = create_hackathon(&ctx); @@ -597,7 +536,6 @@ fn paged_cancel_owner_only_settles_inside_start() { assert_eq!(event.status, EventStatus::Cancelled); assert_eq!(token.balance(&ctx.owner) - owner_before, TOTAL_BUDGET); - // No CancellationState left to process or finalize. let op_b = BytesN::random(&ctx.env); let r = ctx.events.try_process_cancel_batch(&id, &10_u32, &op_b); assert!(r.is_err()); diff --git a/contracts/events/src/tests/cross_contract.rs b/contracts/events/src/tests/cross_contract.rs index 8f9b6d6..6ada2a5 100644 --- a/contracts/events/src/tests/cross_contract.rs +++ b/contracts/events/src/tests/cross_contract.rs @@ -1,11 +1,3 @@ -// boundless-events: cross-contract integration test. -// -// Deploys boundless-events + a real boundless-profile, wires them together, -// and exercises cross-contract flows (select_winners, submit, cancel, grants). -// Bounty apply / withdraw coverage lives in tests/bounty_pillar.rs. -// -// Spec: boundless-platform-contract-prd.md Section 4; boundless-credits-reputation-prd.md Section 10.1. - #![cfg(test)] use soroban_sdk::{ @@ -17,10 +9,6 @@ use super::common::drive_cancel; use crate::types::{CreateEventParams, EventStatus, Pillar, ReleaseKind, WinnerSpec}; use crate::{EventsContract, EventsContractClient}; -// boundless-profile lives in its own crate. For the integration test we use -// the WASM artifact path that Soroban's testutils supports. Since we are in a -// host-target test (not wasm32v1-none), we import the profile contract crate -// directly and register it. use boundless_profile::{ProfileContract, ProfileContractClient}; const FEE_BPS: u32 = 250; @@ -37,16 +25,12 @@ struct Ctx<'a> { fn setup<'a>() -> Ctx<'a> { let env = Env::default(); - // Auth from non-root contract invocations is required (token transfers in - // create_event, cross-contract calls into the profile contract). env.mock_all_auths_allowing_non_root_auth(); - // Deploy profile contract. let profile_admin = Address::generate(&env); let profile_id = env.register(ProfileContract, (profile_admin.clone(),)); let profile = ProfileContractClient::new(&env, &profile_id); - // Deploy events contract pointing at profile. let events_admin = Address::generate(&env); let fee_account = Address::generate(&env); let events_id = env.register( @@ -60,23 +44,18 @@ fn setup<'a>() -> Ctx<'a> { ); let events = EventsContractClient::new(&env, &events_id); - // Wire profile to recognize the events contract. profile.set_events_contract(&events_id); - // Mock USDC via Stellar Asset Contract. let issuer = Address::generate(&env); let sac = env.register_stellar_asset_contract_v2(issuer); let token_addr = sac.address(); let token_admin = token::StellarAssetClient::new(&env, &token_addr); - // Touch fee_account's trustline (mint 0). token_admin.mint(&fee_account, &0); - // Mint USDC to the bounty owner. let owner = Address::generate(&env); token_admin.mint(&owner, &1_000_000_0000000_i128); - // Register USDC on events. events.register_supported_token(&token_addr); let applicant = Address::generate(&env); @@ -128,12 +107,10 @@ fn select_winners_pays_recipient_and_bumps_profile() { let ctx = setup(); let bounty_id = create_bounty(&ctx); - // Applicant applies, getting bootstrapped. let op_apply = BytesN::random(&ctx.env); ctx.events .apply_to_bounty(&bounty_id, &ctx.applicant, &op_apply); - // Owner picks applicant as the sole winner of position 1 (100% of budget). let winners = soroban_sdk::vec![ &ctx.env, WinnerSpec { @@ -145,26 +122,20 @@ fn select_winners_pays_recipient_and_bumps_profile() { let op_select = BytesN::random(&ctx.env); ctx.events.select_winners(&bounty_id, &winners, &op_select); - // Token: winner received the full budget (no second-layer fee on release). let token = token::Client::new(&ctx.env, &ctx.token_addr); assert_eq!(token.balance(&ctx.applicant), TOTAL_BUDGET); - // Fee account got the deposit-time fee. assert_eq!(token.balance(&ctx.fee_account), FEE_AMOUNT); - // Profile: the win bumps reputation (credits are off-chain now). let profile = ctx.profile.get_profile(&ctx.applicant).unwrap(); assert_eq!(profile.reputation, 50); - // Earnings registered against the event's token. let earnings = ctx.profile.get_earnings(&ctx.applicant, &ctx.token_addr); assert_eq!(earnings, TOTAL_BUDGET); - // Event completed. let event = ctx.events.get_event(&bounty_id); assert_eq!(event.status, EventStatus::Completed); assert_eq!(event.remaining_escrow, 0); - // Winner record stored. let winner_list = ctx.events.get_winners(&bounty_id); assert_eq!(winner_list.len(), 1); let recorded = winner_list.get(0).unwrap(); @@ -198,8 +169,6 @@ fn select_winners_requires_position_in_distribution() { #[test] fn select_winners_rejects_duplicate_position() { let ctx = setup(); - // Use a distribution split 60/40 across positions 1 and 2 so the test - // can supply a duplicate position 1 entry. let owner = ctx.owner.clone(); let token_addr = ctx.token_addr.clone(); let mut dist = Map::new(&ctx.env); @@ -258,7 +227,6 @@ fn select_winners_handles_multi_recipient_distribution() { title: String::from_str(&ctx.env, "Multi Winner"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), winner_distribution: dist, - // free for this test fee_bps_override: None, manager: None, }; @@ -350,7 +318,6 @@ fn cancel_already_cancelled_reverts() { drive_cancel(&ctx.env, &ctx.events, bounty_id); - // Second start_cancel on a Cancelled event must revert. let op_again = BytesN::random(&ctx.env); let res = ctx.events.try_start_cancel(&bounty_id, &op_again); assert!(res.is_err(), "second cancel should revert"); @@ -359,7 +326,6 @@ fn cancel_already_cancelled_reverts() { #[test] fn cancel_after_select_winners_refunds_only_remaining() { let ctx = setup(); - // 60/40 split so we can pay one winner and then cancel. let mut dist = Map::new(&ctx.env); dist.set(1, 60); dist.set(2, 40); @@ -379,7 +345,6 @@ fn cancel_after_select_winners_refunds_only_remaining() { let op_create = BytesN::random(&ctx.env); let bounty_id = ctx.events.create_event(¶ms, &op_create); - // Pay one winner (60%). let winner_a = Address::generate(&ctx.env); let winners = soroban_sdk::vec![ &ctx.env, @@ -392,7 +357,6 @@ fn cancel_after_select_winners_refunds_only_remaining() { let op_select = BytesN::random(&ctx.env); ctx.events.select_winners(&bounty_id, &winners, &op_select); - // Now cancel — owner should get the remaining 40%. let token = token::Client::new(&ctx.env, &ctx.token_addr); let owner_before = token.balance(&ctx.owner); @@ -436,7 +400,6 @@ fn select_grant_winner(ctx: &Ctx, grant_id: u64, recipient: &Address) { WinnerSpec { recipient: recipient.clone(), position: 1, - // ignored for Multi; payment-time bumps apply per milestone reputation_bump: 0, }, ]; @@ -458,19 +421,15 @@ fn claim_milestone_pays_per_milestone_amount() { ctx.events .claim_milestone(&grant_id, &recipient, &0_u32, &5_u32, &op_claim); - // Per-milestone amount: total_budget * 100% / 4 = TOTAL_BUDGET / 4 let per_milestone = TOTAL_BUDGET / 4; assert_eq!(token.balance(&recipient) - recipient_before, per_milestone); - // Profile: the milestone claim bumps reputation by 5 (credits off-chain). let profile = ctx.profile.get_profile(&recipient).unwrap(); assert_eq!(profile.reputation, 5); - // Earnings registered. let earnings = ctx.profile.get_earnings(&recipient, &ctx.token_addr); assert_eq!(earnings, per_milestone); - // Event still Active, remaining_escrow decremented. let event = ctx.events.get_event(&grant_id); assert_eq!(event.status, EventStatus::Active); assert_eq!(event.remaining_escrow, TOTAL_BUDGET - per_milestone); @@ -487,14 +446,12 @@ fn claim_milestone_idempotent_per_recipient_and_milestone() { ctx.events .claim_milestone(&grant_id, &recipient, &0_u32, &5_u32, &op1); - // Same milestone, different op_id — should still revert. let op2 = BytesN::random(&ctx.env); let res = ctx .events .try_claim_milestone(&grant_id, &recipient, &0_u32, &5_u32, &op2); assert!(res.is_err(), "same milestone twice should revert"); - // Different milestone — should succeed. let op3 = BytesN::random(&ctx.env); ctx.events .claim_milestone(&grant_id, &recipient, &1_u32, &5_u32, &op3); @@ -507,7 +464,6 @@ fn claim_milestone_invalid_milestone_index_reverts() { let grant_id = create_grant(&ctx, 4); select_grant_winner(&ctx, grant_id, &recipient); - // Milestone index 4 is out of range for a 4-milestone grant (valid: 0..=3). let op = BytesN::random(&ctx.env); let res = ctx .events @@ -531,9 +487,6 @@ fn claim_milestone_rejects_non_grant_events() { let op_select = BytesN::random(&ctx.env); ctx.events.select_winners(&bounty_id, &winners, &op_select); - // The bounty is Completed now anyway, but claim_milestone should reject - // even an Active Single-release event. Recreate a Single bounty without - // winners selected: let bounty_id2 = create_bounty(&ctx); let op = BytesN::random(&ctx.env); let res = ctx @@ -701,20 +654,10 @@ fn withdraw_submission_without_submission_reverts() { // ============================================================ // Per-event fee_bps_override -// -// Sales-side discount / comp / waiver lives on the event record, not the -// global admin config. Tests below cover: -// 1. create_event with Some(override) charges the override rate. -// 2. add_funds reads the override from the event (not the live default). -// 3. waiver (Some(0)) costs the owner exactly total_budget with no fee. -// 4. an admin change to the global default does not retroactively re-price -// in-flight events that snapshotted an override. -// 5. publish rejects override > MAX_FEE_BPS. // ============================================================ #[test] fn create_event_charges_override_rate_when_provided() { let ctx = setup(); - // Override to 1.5% (Hackathon launch rate) while the contract default is 2.5%. let override_bps: u32 = 150; let total_budget: i128 = 100_000_0000000_i128; // 100k USDC let expected_fee = total_budget * (override_bps as i128) / 10_000; @@ -739,15 +682,12 @@ fn create_event_charges_override_rate_when_provided() { let op = BytesN::random(&ctx.env); let id = ctx.events.create_event(¶ms, &op); - // Owner paid total_budget + override_fee. let owner_after = token.balance(&ctx.owner); assert_eq!(owner_before - owner_after, total_budget + expected_fee); - // Fee account received exactly the override fee. let fee_after = token.balance(&ctx.fee_account); assert_eq!(fee_after - fee_before, expected_fee); - // Event record snapshotted the override. let event = ctx.events.get_event(&id); assert_eq!(event.fee_bps_override, Some(override_bps)); assert_eq!(event.remaining_escrow, total_budget); @@ -756,7 +696,6 @@ fn create_event_charges_override_rate_when_provided() { #[test] fn add_funds_uses_event_override_not_global() { let ctx = setup(); - // Publish at a 0.5% promo rate. let override_bps: u32 = 50; let total_budget: i128 = 10_000_0000000_i128; @@ -776,13 +715,8 @@ fn add_funds_uses_event_override_not_global() { let op_create = BytesN::random(&ctx.env); let id = ctx.events.create_event(¶ms, &op_create); - // Admin bumps global default upward; in-flight event must not re-price. ctx.events.set_fee_bps(&500); - // Partner contributes 1_000 USDC. The fee should be at 0.5% (override), - // not 5% (new global). Mint 2_000 USDC so the partner balance comfortably - // covers amount + fee in either branch (so the test fails on the math - // assertion if the override is ignored, not on a balance error). let partner = Address::generate(&ctx.env); let token_admin = token::StellarAssetClient::new(&ctx.env, &ctx.token_addr); token_admin.mint(&partner, &2_000_0000000_i128); @@ -847,7 +781,6 @@ fn create_event_rejects_override_above_max_fee_bps() { title: String::from_str(&ctx.env, "Bad rate"), deadline: Some(ctx.env.ledger().timestamp() + 86_400), winner_distribution: one_winner_distribution(&ctx.env), - // 60% is above the MAX_FEE_BPS = 1000 cap (post L4 audit fix). fee_bps_override: Some(6000), manager: None, }; @@ -883,18 +816,12 @@ fn create_event_omitted_override_falls_back_to_global_default() { let fee_after = token.balance(&ctx.fee_account); assert_eq!(fee_after - fee_before, expected_fee); - // Event keeps None so future add_funds also pulls the live default. let event = ctx.events.get_event(&id); assert_eq!(event.fee_bps_override, None); } // ============================================================ // select_winners replay lock -// -// Calling select_winners twice would silently overwrite the anchor winner -// records. The contract now rejects the second call so that downstream -// claim_milestone reads and off-chain audits read a stable winner set. -// Tested on Grant (Multi); the same code path serves Single releases. // ============================================================ #[test] fn select_winners_rejects_second_call_winners_already_selected() { @@ -903,7 +830,6 @@ fn select_winners_rejects_second_call_winners_already_selected() { let grant_id = create_grant(&ctx, 2); select_grant_winner(&ctx, grant_id, &r1); - // Second call (different recipient, fresh op_id) must be rejected. let r2 = Address::generate(&ctx.env); let winners = soroban_sdk::vec![ &ctx.env, @@ -917,7 +843,6 @@ fn select_winners_rejects_second_call_winners_already_selected() { let res = ctx.events.try_select_winners(&grant_id, &winners, &op); assert!(res.is_err(), "second select_winners must revert"); - // First selection is untouched. let recorded = ctx.events.get_winners(&grant_id); assert_eq!(recorded.len(), 1); assert_eq!(recorded.get(0).unwrap().recipient, r1); @@ -925,27 +850,18 @@ fn select_winners_rejects_second_call_winners_already_selected() { // ============================================================ // Grant last-milestone sweep -// -// Per-milestone math floors total_budget * percent / 100 / n_milestones. With -// a budget that does not divide evenly across milestones the floored amount -// strands a small residue in escrow. The last milestone for the recipient -// now sweeps that residue so total paid equals their full position share. // ============================================================ #[test] fn grant_last_milestone_sweeps_rounding_residue() { let ctx = setup(); let recipient = Address::generate(&ctx.env); - // 100k / 3 milestones = 33,333.3333333 USDC each at 7 decimals. - // Floored per-milestone: floor(100_000_0000000 / 3) = 33_333_3333333 stroops. - // Residue: 100_000_0000000 - 3 * 33_333_3333333 = 1 stroop. let grant_id = create_grant(&ctx, 3); select_grant_winner(&ctx, grant_id, &recipient); let token = token::Client::new(&ctx.env, &ctx.token_addr); let before = token.balance(&recipient); - // Claim m0 + m1 at floored rate. let floored = TOTAL_BUDGET / 3; ctx.events.claim_milestone( &grant_id, @@ -964,7 +880,6 @@ fn grant_last_milestone_sweeps_rounding_residue() { let after_two = token.balance(&recipient); assert_eq!(after_two - before, floored * 2); - // Last claim: sweep so the recipient ends up with the full position share. ctx.events.claim_milestone( &grant_id, &recipient, @@ -979,7 +894,6 @@ fn grant_last_milestone_sweeps_rounding_residue() { "last milestone must sweep residue: recipient receives full position share" ); - // Event drained completely and marks Completed. let event = ctx.events.get_event(&grant_id); assert_eq!(event.remaining_escrow, 0); assert_eq!(event.status, EventStatus::Completed); @@ -991,10 +905,6 @@ fn grant_last_milestone_sweeps_rounding_residue() { #[test] fn select_winners_pays_against_remaining_escrow_including_top_ups() { - // Owner deposits TOTAL_BUDGET. Partner tops up another 5_000 USDC. - // Single winner at 100% should receive owner_budget + partner_top_up - // (net of fees). Pre-audit this was capped at TOTAL_BUDGET and the - // top-up would have stayed trapped until cancel. let ctx = setup(); let bounty_id = create_bounty(&ctx); @@ -1007,11 +917,9 @@ fn select_winners_pays_against_remaining_escrow_including_top_ups() { let op_add = BytesN::random(&ctx.env); ctx.events.add_funds(&bounty_id, &partner, &top_up, &op_add); - // Confirm the event escrow grew. let event_pre = ctx.events.get_event(&bounty_id); assert_eq!(event_pre.remaining_escrow, TOTAL_BUDGET + top_up); - // Single winner at 100%. let winners = soroban_sdk::vec![ &ctx.env, WinnerSpec { @@ -1024,11 +932,8 @@ fn select_winners_pays_against_remaining_escrow_including_top_ups() { ctx.events.select_winners(&bounty_id, &winners, &op_select); let token = token::Client::new(&ctx.env, &ctx.token_addr); - // Winner receives the full live escrow at select time, not the - // original total_budget. assert_eq!(token.balance(&ctx.applicant), TOTAL_BUDGET + top_up); - // Event drained, status Completed, no orphaned partner top-up. let event_post = ctx.events.get_event(&bounty_id); assert_eq!(event_post.remaining_escrow, 0); assert_eq!(event_post.status, EventStatus::Completed); @@ -1059,11 +964,9 @@ fn create_bounty_with_manager(ctx: &Ctx, manager: &Address) -> u64 { fn manager_defaults_to_owner_and_override_is_recorded() { let ctx = setup(); - // No override: management falls back to the funder/owner (legacy behavior). let default_id = create_bounty(&ctx); assert_eq!(ctx.events.get_manager(&default_id), ctx.owner); - // Override: management is the org wallet, distinct from the funder. let manager = Address::generate(&ctx.env); let managed_id = create_bounty_with_manager(&ctx, &manager); assert_eq!(ctx.events.get_manager(&managed_id), manager); @@ -1103,7 +1006,6 @@ fn manager_override_can_select_winners() { let op_select = BytesN::random(&ctx.env); ctx.events.select_winners(&bounty_id, &winners, &op_select); - // Managed event settled via the manager authority. let event = ctx.events.get_event(&bounty_id); assert_eq!(event.status, EventStatus::Completed); } diff --git a/contracts/events/src/tests/crowdfunding.rs b/contracts/events/src/tests/crowdfunding.rs index 1b79d4f..7c281a3 100644 --- a/contracts/events/src/tests/crowdfunding.rs +++ b/contracts/events/src/tests/crowdfunding.rs @@ -1,16 +1,3 @@ -// boundless-events: crowdfunding tests. -// -// Covers the Pillar::Crowdfunding paths: -// - create_event with zero owner deposit, builder auto-registered as Winner -// - validate_create rejects single-release / missing-deadline / wrong dist -// - add_funds drives escrow from 0 up to whatever was raised -// - claim_milestone dynamic math: amount = remaining / (total - claimed) -// - last milestone drains remainder including rounding dust -// - select_winners and submit are rejected for Crowdfunding -// - cancel_event refunds all contributors (no owner deposit to refund) -// -// Spec: boundless-crowdfunding-prd.md. - #![cfg(test)] use soroban_sdk::{ @@ -26,7 +13,6 @@ use boundless_profile::{ProfileContract, ProfileContractClient}; const FEE_BPS: u32 = 250; -// Builder's stated funding goal (informational only on-chain). const FUNDING_GOAL: i128 = 1_000_0000000_i128; struct Ctx<'a> { @@ -69,7 +55,6 @@ fn setup<'a>() -> Ctx<'a> { let token_admin = token::StellarAssetClient::new(&env, &token_addr); token_admin.mint(&fee_account, &0); - // Builder pays nothing upfront, so no mint needed for them. let builder = Address::generate(&env); events.register_supported_token(&token_addr); @@ -133,7 +118,6 @@ fn create_with_zero_owner_deposit_and_auto_registered_winner() { let id = create_campaign(&ctx, 3); - // No deposit pulled from builder. Builder balance unchanged. assert_eq!(token.balance(&ctx.builder), builder_before); let event = ctx.events.get_event(&id); @@ -144,7 +128,6 @@ fn create_with_zero_owner_deposit_and_auto_registered_winner() { "crowdfunding starts with empty escrow" ); - // Builder auto-registered as Winner at position 1, milestone=None. let winners = ctx.events.get_winners(&id); assert_eq!(winners.len(), 1); let w = winners.get(0).unwrap(); @@ -243,9 +226,6 @@ fn community_top_ups_raise_escrow_from_zero() { #[test] fn builder_top_up_does_not_appear_in_contributor_list() { - // The builder is allowed to add funds (matching the owner-top-up rule). - // Their entry shouldn't appear in ContributorList so cancel_event treats - // them as residual, not partner. let ctx = setup(); let id = create_campaign(&ctx, 3); @@ -267,12 +247,6 @@ fn builder_top_up_does_not_appear_in_contributor_list() { #[test] fn claim_milestone_splits_evenly_and_charges_fee_at_release() { - // 3 milestones, escrow raised = 900 USDC. The builder bears the 2.5% fee, - // taken from each payout (backers deposited their full pledge fee-free): - // m0 -> 900/3 = 300 gross; builder +292.5, fee_account +7.5; leaves 600. - // m1 -> 600/2 = 300 gross; builder +292.5, fee_account +7.5; leaves 300. - // m2 -> 300 gross (final); builder +292.5, fee_account +7.5; leaves 0. - // Builder nets 877.5 (= 900 * 0.975); platform collects 22.5 (= 900 * .025). let ctx = setup(); let id = create_campaign(&ctx, 3); let backer = Address::generate(&ctx.env); @@ -313,10 +287,6 @@ fn claim_milestone_splits_evenly_and_charges_fee_at_release() { #[test] fn claim_milestone_last_drains_dust_with_fee() { - // 3 milestones, raised 100_000_001 stroops (just above MIN_CONTRIB and not - // divisible by 3). The builder nets the raised amount minus the 2.5% fee; - // the fee account collects the fee; together they drain escrow exactly so - // no dust is stranded, even with per-milestone rounding. let ctx = setup(); let id = create_campaign(&ctx, 3); let backer = Address::generate(&ctx.env); @@ -392,10 +362,6 @@ fn claim_milestone_with_empty_escrow_reverts() { #[test] fn backer_pays_exactly_pledge_and_creator_bears_fee() { - // Regression for the fee-on-top bug: a backer holding EXACTLY their pledge - // must be able to fund. The old model pulled pledge + fee from the backer - // and reverted for a wallet that held only the pledge. Now the fee is borne - // by the builder and taken at release, so deposit pulls exactly the pledge. let ctx = setup(); let id = create_campaign(&ctx, 1); @@ -409,7 +375,6 @@ fn backer_pays_exactly_pledge_and_creator_bears_fee() { let op = BytesN::random(&ctx.env); ctx.events.add_funds(&id, &backer, &pledge, &op); - // Backer paid exactly their pledge; no fee taken at deposit. assert_eq!(token.balance(&backer), 0, "backer pays exactly the pledge"); assert_eq!( token.balance(&ctx.fee_account), @@ -418,7 +383,6 @@ fn backer_pays_exactly_pledge_and_creator_bears_fee() { ); assert_eq!(ctx.events.get_event(&id).remaining_escrow, pledge); - // Single milestone: the builder claims and the fee is taken from the payout. let claim = BytesN::random(&ctx.env); ctx.events .claim_milestone(&id, &ctx.builder, &0_u32, &0, &claim); @@ -518,10 +482,6 @@ fn cancel_with_no_contributions_just_marks_cancelled() { #[test] fn cancel_after_partial_claim_pro_rates_remaining() { - // Builder claimed m0 already, then campaign is cancelled. Remaining < non_owner_total. - // 3 milestones, raised 900, claim m0 -> 300 to builder, remaining 600. - // Cancel: non_owner_total still 900 (we don't subtract from contributor ledger - // on claim), remaining = 600 < 900 -> case B pro-rata. let ctx = setup(); let id = create_campaign(&ctx, 3); @@ -540,9 +500,6 @@ fn cancel_after_partial_claim_pro_rates_remaining() { drive_cancel(&ctx.env, &ctx.events, id); - // remaining = 600, non_owner_total = 900. - // p1 share = 300 * 600 / 900 = 200. - // p2 share = 600 * 600 / 900 = 400. assert_eq!(token.balance(&p1) - p1_before, 200_0000000_i128); assert_eq!(token.balance(&p2) - p2_before, 400_0000000_i128); @@ -557,10 +514,6 @@ fn cancel_after_partial_claim_pro_rates_remaining() { #[test] fn crowdfunding_claim_milestone_requires_admin_auth() { - // Verify the admin's address is among the required auths for a - // crowdfunding claim. mock_all_auths_allowing_non_root_auth makes the - // call succeed, but env.auths() records the addresses whose auth was - // demanded, which is the audit-relevant observation. let ctx = setup(); let id = create_campaign(&ctx, 2); let p = Address::generate(&ctx.env); diff --git a/contracts/events/src/tests/escrow_fee_math.rs b/contracts/events/src/tests/escrow_fee_math.rs index 502d76a..750c022 100644 --- a/contracts/events/src/tests/escrow_fee_math.rs +++ b/contracts/events/src/tests/escrow_fee_math.rs @@ -1,14 +1,3 @@ -// boundless-events: escrow fee + release math tests. -// -// Covers every function in escrow.rs (effective_fee_bps, compute_fee_at, -// compute_fee, deposit_with_fee_at, deposit_with_fee, release) plus the -// payout-split math in select_winners (Single) and claim_milestone (Multi / -// Crowdfunding). Tests verify rounding, override bps, zero-fee edges, -// auth rejection, and idempotency where relevant. -// -// Spec: boundless-platform-contract-prd.md Sections 6.2, 7, 8; -// issue #31. - #![cfg(test)] use soroban_sdk::{ @@ -252,7 +241,6 @@ fn override_at_max_bps_boundary_succeeds() { #[test] fn fee_rounds_down_non_divisible_amount() { - // 1 stroop * 250 bps / 10_000 = 0.025 → truncates to 0. let ctx = setup(); let token = token::Client::new(&ctx.env, &ctx.token_addr); @@ -275,14 +263,12 @@ fn fee_rounds_down_non_divisible_amount() { ctx.events.create_event(¶ms, &op); let owner_after = token.balance(&ctx.owner); - // fee = 1 * 250 / 10_000 = 0 (truncated) assert_eq!(owner_before - owner_after, tiny_budget); assert_eq!(token.balance(&ctx.fee_account), 0); } #[test] fn fee_rounding_on_odd_amounts() { - // 333 stroops at 250 bps: 333 * 250 / 10_000 = 8.325 → 8 stroops. let ctx = setup(); let token = token::Client::new(&ctx.env, &ctx.token_addr); @@ -482,20 +468,16 @@ fn three_way_33_33_34_split_rounding() { let token = token::Client::new(&ctx.env, &ctx.token_addr); let escrow = TOTAL_BUDGET; - // 33% of 1000_0000000 = 330_0000000 (exact) - // 34% of 1000_0000000 = 340_0000000 (exact) assert_eq!(token.balance(&w1), escrow * 33 / 100); assert_eq!(token.balance(&w2), escrow * 33 / 100); assert_eq!(token.balance(&w3), escrow * 34 / 100); let event = ctx.events.get_event(&id); - // 330 + 330 + 340 = 1000. Exact, no dust. assert_eq!(event.remaining_escrow, 0); } #[test] fn partial_position_fill_leaves_residual_escrow() { - // Fill only 1 of 2 positions → event stays Active with residual escrow. let ctx = setup(); let mut dist = Map::new(&ctx.env); @@ -585,21 +567,16 @@ fn grant_milestone_pays_floored_per_milestone() { let token = token::Client::new(&ctx.env, &ctx.token_addr); - // total_share = TOTAL_BUDGET * 100 / 100 = TOTAL_BUDGET - // per_milestone_floored = TOTAL_BUDGET / 3 = 333_3333333 (floor) let per_milestone = TOTAL_BUDGET / milestones as i128; - // Milestone 0 let op_m0 = BytesN::random(&ctx.env); ctx.events.claim_milestone(&id, &recipient, &0, &50, &op_m0); assert_eq!(token.balance(&recipient), per_milestone); - // Milestone 1 let op_m1 = BytesN::random(&ctx.env); ctx.events.claim_milestone(&id, &recipient, &1, &50, &op_m1); assert_eq!(token.balance(&recipient), per_milestone * 2); - // Milestone 2 (last): sweep — pays total_share - already_paid let op_m2 = BytesN::random(&ctx.env); ctx.events.claim_milestone(&id, &recipient, &2, &50, &op_m2); assert_eq!( @@ -632,7 +609,6 @@ fn grant_milestone_double_claim_rejected() { let op_m0 = BytesN::random(&ctx.env); ctx.events.claim_milestone(&id, &recipient, &0, &50, &op_m0); - // Same milestone again → MilestoneAlreadyClaimed let op_m0_dup = BytesN::random(&ctx.env); let res = ctx .events @@ -658,7 +634,6 @@ fn grant_milestone_out_of_range_rejected() { let op_sel = BytesN::random(&ctx.env); ctx.events.select_winners(&id, &winners, &op_sel); - // Milestone 2 is out-of-range for Multi(2) (valid: 0, 1) let op = BytesN::random(&ctx.env); let res = ctx .events @@ -693,7 +668,6 @@ fn crowdfunding_dynamic_milestone_split() { let op_create = BytesN::random(&ctx.env); let id = ctx.events.create_event(¶ms, &op_create); - // Fund from a backer — crowdfunding backers pay NO fee on deposit (deposit_no_fee). let backer = Address::generate(&ctx.env); let raised = 900_0000000_i128; fund(&ctx, &backer, raised); @@ -703,19 +677,14 @@ fn crowdfunding_dynamic_milestone_split() { let token = token::Client::new(&ctx.env, &ctx.token_addr); let owner_before = token.balance(&ctx.owner); - // Crowdfunding claim_milestone uses release_with_fee_at: builder bears - // the fee at release. Each milestone amount = remaining / remaining_milestones; - // builder receives amount - fee. let milestone_amount = raised / 3; // 300_0000000 let milestone_fee = milestone_amount * FEE_BPS as i128 / 10_000; let net_per_milestone = milestone_amount - milestone_fee; - // M0: amount = 900 / 3 = 300, net = 300 - fee let op_m0 = BytesN::random(&ctx.env); ctx.events.claim_milestone(&id, &ctx.owner, &0, &50, &op_m0); assert_eq!(token.balance(&ctx.owner) - owner_before, net_per_milestone); - // M1: remaining = 600 / 2 = 300, net = 300 - fee let op_m1 = BytesN::random(&ctx.env); ctx.events.claim_milestone(&id, &ctx.owner, &1, &50, &op_m1); assert_eq!( @@ -723,7 +692,6 @@ fn crowdfunding_dynamic_milestone_split() { net_per_milestone * 2 ); - // M2: remaining = 300 / 1 = 300, net = 300 - fee let op_m2 = BytesN::random(&ctx.env); ctx.events.claim_milestone(&id, &ctx.owner, &2, &50, &op_m2); assert_eq!( @@ -737,11 +705,6 @@ fn crowdfunding_dynamic_milestone_split() { #[test] fn crowdfunding_dynamic_rounding_no_dust() { - // Raise 1_000_0000001 stroops and split across 3 milestones. - // 1_000_0000001 / 3 = 333_3333333 (floor), remaining = 666_6666668 - // 666_6666668 / 2 = 333_3333334 (floor), remaining = 333_3333334 - // last milestone takes entire remainder = 333_3333334 - // Total paid = 333_3333333 + 333_3333334 + 333_3333334 = 1_000_0000001 ✓ let ctx = setup(); let milestones = 3_u32; @@ -953,7 +916,6 @@ fn select_winners_twice_reverts() { let op1 = BytesN::random(&ctx.env); ctx.events.select_winners(&id, &winners, &op1); - // Second selection on the same event → WinnersAlreadySelected let op2 = BytesN::random(&ctx.env); let res = ctx.events.try_select_winners(&id, &winners, &op2); assert!( @@ -1003,10 +965,8 @@ fn mid_flight_global_bps_change_does_not_affect_override_event() { let override_bps: u32 = 100; let id = create_hackathon_with_override(&ctx, override_bps); - // Admin changes the global fee bps ctx.events.set_fee_bps(&500); - // Partner add_funds should still use the event's override (100), not the new global (500) let partner = Address::generate(&ctx.env); let contrib = 500_0000000_i128; let partner_fee = contrib * override_bps as i128 / 10_000; diff --git a/contracts/events/src/tests/grant_pillar.rs b/contracts/events/src/tests/grant_pillar.rs index f2adab5..40db2a0 100644 --- a/contracts/events/src/tests/grant_pillar.rs +++ b/contracts/events/src/tests/grant_pillar.rs @@ -1,9 +1,3 @@ -// boundless-events: grant pillar tests (#32). -// -// Covers validate_create (Multi release required) + claim_milestone -// fixed-split math, last-milestone dust sweep, credit/rep side-effects, -// and error variants. - #![cfg(test)] use soroban_sdk::{ diff --git a/contracts/events/src/tests/hackathon_pillar.rs b/contracts/events/src/tests/hackathon_pillar.rs index ab6d21a..4935cc5 100644 --- a/contracts/events/src/tests/hackathon_pillar.rs +++ b/contracts/events/src/tests/hackathon_pillar.rs @@ -1,24 +1,3 @@ -// boundless-events: hackathon pillar tests. -// -// Covers the Pillar::Hackathon paths end-to-end against a real -// boundless-profile so the cross-contract reputation / earnings side-effects -// of select_winners are exercised, not mocked: -// -// - create_event validation: Hackathon requires ReleaseKind::Single and a -// future deadline; the full budget is escrowed (fee taken at deposit). -// - submit: open submission model with no prior apply. -// deadline gate, re-submit, idempotency, withdraw. -// - select_winners distribution: single-recipient sweep and multi-position -// split. Each split test asserts BOTH recipient and fee-account deltas -// (CLAUDE.md hard rule), plus profile bumps and the stored winner rows. -// - select_winners rejections: empty set, position not in distribution, -// duplicate position, replay (WinnersAlreadySelected), missing event, -// already-completed event, and owner-auth requirement. -// - claim_milestone is rejected for a Single-release hackathon. -// -// Spec: boundless-platform-contract-prd.md Section 7. Template: -// src/tests/crowdfunding.rs and src/tests/cross_contract.rs. - #![cfg(test)] use soroban_sdk::{ @@ -33,7 +12,6 @@ use boundless_profile::{ProfileContract, ProfileContractClient}; const FEE_BPS: u32 = 250; -// 10k USDC at 7 decimals. const TOTAL_BUDGET: i128 = 10_000_0000000_i128; const FEE_AMOUNT: i128 = (TOTAL_BUDGET * FEE_BPS as i128) / 10_000_i128; @@ -50,8 +28,6 @@ struct Ctx<'a> { fn setup<'a>() -> Ctx<'a> { let env = Env::default(); - // Non-root auth needed for token transfers and the cross-contract calls - // into the profile contract during select_winners. env.mock_all_auths_allowing_non_root_auth(); let profile_admin = Address::generate(&env); @@ -77,7 +53,6 @@ fn setup<'a>() -> Ctx<'a> { let token_addr = sac.address(); let token_admin = token::StellarAssetClient::new(&env, &token_addr); - // Touch fee_account's trustline (mint 0) and fund the owner. token_admin.mint(&fee_account, &0); let owner = Address::generate(&env); token_admin.mint(&owner, &1_000_000_0000000_i128); @@ -104,7 +79,6 @@ fn single_winner_dist(env: &Env) -> Map { m } -// 50 / 30 / 20 across positions 1..=3. fn three_way_dist(env: &Env) -> Map { let mut m = Map::new(env); m.set(1, 50); @@ -156,7 +130,6 @@ fn create_deposits_full_budget_and_takes_fee_at_deposit() { "hackathon escrows the full budget at create" ); - // Owner paid budget + fee; fee account received the deposit-time fee. assert_eq!( owner_before - token.balance(&ctx.owner), TOTAL_BUDGET + FEE_AMOUNT @@ -209,7 +182,6 @@ fn create_rejects_missing_deadline() { #[test] fn create_rejects_past_deadline() { let ctx = setup(); - // try_ variant so we observe the error instead of panicking on the host. let params = CreateEventParams { pillar: Pillar::Hackathon, owner: ctx.owner.clone(), @@ -218,7 +190,6 @@ fn create_rejects_past_deadline() { release_kind: ReleaseKind::Single, content_uri: String::from_str(&ctx.env, "uri"), title: String::from_str(&ctx.env, "Hackathon"), - // Equal-to-now is not in the future; create_event rejects it. deadline: Some(ctx.env.ledger().timestamp()), winner_distribution: single_winner_dist(&ctx.env), fee_bps_override: None, @@ -272,7 +243,6 @@ fn submit_after_deadline_reverts() { let ctx = setup(); let id = create_hackathon(&ctx); - // Jump the ledger past the 1-day submission deadline. ctx.env.ledger().with_mut(|li| { li.timestamp += 2 * 86_400; }); @@ -336,12 +306,9 @@ fn select_winners_single_recipient_sweeps_escrow() { let op = BytesN::random(&ctx.env); ctx.events.select_winners(&id, &winners, &op); - // Recipient delta: full budget. Fee account delta: 0 — the fee was taken - // at deposit, never a second time on release. assert_eq!(token.balance(&ctx.applicant) - winner_before, TOTAL_BUDGET); assert_eq!(token.balance(&ctx.fee_account) - fee_before, 0); - // Profile: fresh winner is bootstrapped then bumped for the win. let p = ctx.profile.get_profile(&ctx.applicant).unwrap(); assert_eq!(p.reputation, 50); assert_eq!( @@ -349,7 +316,6 @@ fn select_winners_single_recipient_sweeps_escrow() { TOTAL_BUDGET ); - // Escrow drained -> Completed; winner row recorded. let event = ctx.events.get_event(&id); assert_eq!(event.status, EventStatus::Completed); assert_eq!(event.remaining_escrow, 0); @@ -402,14 +368,11 @@ fn select_winners_multi_position_splits_by_distribution() { let amt_2 = TOTAL_BUDGET * 30 / 100; let amt_3 = TOTAL_BUDGET * 20 / 100; - // Recipient deltas across the split. assert_eq!(token.balance(&first), amt_1); assert_eq!(token.balance(&second), amt_2); assert_eq!(token.balance(&third), amt_3); - // Fee account delta across the split: unchanged (no release-time fee). assert_eq!(token.balance(&ctx.fee_account) - fee_before, 0); - // Profile bumps per winner. let p1 = ctx.profile.get_profile(&first).unwrap(); let p2 = ctx.profile.get_profile(&second).unwrap(); let p3 = ctx.profile.get_profile(&third).unwrap(); @@ -417,7 +380,6 @@ fn select_winners_multi_position_splits_by_distribution() { assert_eq!(p2.reputation, 40); assert_eq!(p3.reputation, 20); - // 50 + 30 + 20 == 100 -> escrow fully drained -> Completed. let event = ctx.events.get_event(&id); assert_eq!(event.status, EventStatus::Completed); assert_eq!(event.remaining_escrow, 0); @@ -486,8 +448,6 @@ fn select_winners_duplicate_position_reverts() { fn select_winners_second_call_reverts_winners_already_selected() { let ctx = setup(); let dl = Some(ctx.env.ledger().timestamp() + 86_400); - // 50/30/20 so the first call pays only position 1 and leaves the event - // Active, isolating WinnersAlreadySelected from EventNotActive. let id = create_hackathon_with(&ctx, three_way_dist(&ctx.env), dl); let first_winner = soroban_sdk::vec![ @@ -501,7 +461,6 @@ fn select_winners_second_call_reverts_winners_already_selected() { let op1 = BytesN::random(&ctx.env); ctx.events.select_winners(&id, &first_winner, &op1); - // Event is still Active (60% escrow remains), but a prior anchor exists. assert_eq!(ctx.events.get_event(&id).status, EventStatus::Active); let second = Address::generate(&ctx.env); @@ -590,9 +549,6 @@ fn select_winners_on_completed_event_reverts() { #[test] fn select_winners_demands_owner_auth() { - // mock_all_auths_allowing_non_root_auth lets the call succeed, but - // env.auths() records which addresses had to authorize — the audit-relevant - // observation. select_winners requires the event owner. let ctx = setup(); let id = create_hackathon(&ctx); @@ -613,7 +569,6 @@ fn select_winners_demands_owner_auth() { owner_required, "select_winners must demand the event owner's auth" ); - // Sanity: a random non-owner address was never asked to authorize. assert!( !auths.iter().any(|(addr, _)| *addr == ctx.events_admin), "the events admin is not an authorizer of select_winners" diff --git a/contracts/events/src/tests/mod.rs b/contracts/events/src/tests/mod.rs index 3ae333b..0064ef6 100644 --- a/contracts/events/src/tests/mod.rs +++ b/contracts/events/src/tests/mod.rs @@ -1,9 +1,3 @@ -// boundless-events: test entry. Per-area test modules are mounted here. -// -// Tests are stubbed at the module level so that adding a new test file is a -// one-line change. Full test coverage matrix is documented in the -// boundless-platform-contract-prd.md Section 15. - #![cfg(test)] mod admin; diff --git a/contracts/events/src/tests/token_whitelist.rs b/contracts/events/src/tests/token_whitelist.rs index 9f1ba1b..f8736ca 100644 --- a/contracts/events/src/tests/token_whitelist.rs +++ b/contracts/events/src/tests/token_whitelist.rs @@ -1,8 +1,3 @@ -// boundless-events: token whitelist tests (#27). -// -// Covers register_supported_token / deregister_supported_token / -// is_supported_token + enforcement inside create_event. - #![cfg(test)] use soroban_sdk::{ diff --git a/contracts/events/src/token_whitelist.rs b/contracts/events/src/token_whitelist.rs index fd09708..9ea8a58 100644 --- a/contracts/events/src/token_whitelist.rs +++ b/contracts/events/src/token_whitelist.rs @@ -1,25 +1,3 @@ -// boundless-events: token whitelist management. -// -// Spec: boundless-platform-contract-prd.md Section 8. -// -// M2 (2026-06 audit): Trustline verification for the fee account is the -// admin's off-chain responsibility (runbook in boundless-infra). On-chain -// enforcement isn't reliable in Soroban today: Stellar Asset Contract's -// `balance(addr)` returns 0 for "no trustline" and 0 for "trustline at -// zero balance" indistinguishably, and a 0-amount `transfer` probe behaves -// inconsistently across SAC variants. Until a non-standard token interface -// extension lands, the chosen policy is: -// -// 1. Admin verifies trustline existence off-chain BEFORE calling -// register (this module) or set_fee_account (admin module). -// 2. On register/deregister we emit TokenRegistered/TokenDeregistered. -// Off-chain monitors check trustline state and surface alarms if a -// newly-registered token doesn't have a fee-account trustline. -// 3. If trustline is missing, the first add_funds / deposit on the token -// will revert inside SAC.transfer. The admin then rotates either the -// fee account (via set_fee_account) or deregisters the token. -// -// See docs/audit-2026-06-stellar-skill.md M2. #![allow(dead_code)] use soroban_sdk::{Address, Env}; diff --git a/contracts/events/src/types.rs b/contracts/events/src/types.rs index 99c2c6e..c936d5f 100644 --- a/contracts/events/src/types.rs +++ b/contracts/events/src/types.rs @@ -1,22 +1,7 @@ -// boundless-events: on-chain types. -// -// Spec: boundless-platform-contract-prd.md Sections 5.1 to 5.2. - use soroban_sdk::{contracttype, Address, BytesN, Map, String}; // ============================================================ // PILLAR -// -// Crowdfunding differs from the other three pillars: -// - The owner is the project builder, not an organization. -// - There is no upfront owner deposit; the escrow starts at 0 and grows -// via add_funds from community backers. -// - There is exactly one recipient (the builder, registered as Winner at -// position 1 at create time). -// - claim_milestone uses dynamic math: amount = remaining_escrow / -// remaining_milestones, so each release pays a fair share of whatever -// the campaign actually raised. -// Spec: boundless-crowdfunding-prd.md. // ============================================================ #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -29,12 +14,6 @@ pub enum Pillar { // ============================================================ // STATUS -// -// Cancelling is the intermediate state once start_cancel has snapshotted -// the contributor list. The event is frozen (no add_funds, submit, -// claim_milestone, select_winners) until finalize_cancel flips it to -// Cancelled. New variant added at the tail so prior on-chain variant -// indices stay stable. // ============================================================ #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -47,18 +26,6 @@ pub enum EventStatus { // ============================================================ // CANCELLATION -// -// Paged cancel: see docs/audit-2026-06-stellar-skill.md H3+H4 -// follow-up. start_cancel snapshots the refund math once so that -// concurrent contributor mutations (none possible while Cancelling, but -// belt-and-suspenders) can't bias the per-batch payouts. -// -// Branch: -// OwnerOnly - no partner contributions; owner gets all of remaining_at_start. -// FullPartnerThenResidual - remaining >= non_owner_total; each partner gets full -// amount, owner gets remaining - non_owner_total. -// ProRataPartners - remaining < non_owner_total; partners get -// floor(amt * remaining / non_owner_total); owner gets 0. // ============================================================ #[contracttype] #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -126,9 +93,6 @@ pub struct CreateEventParams { pub deadline: Option, pub winner_distribution: Map, pub fee_bps_override: Option, - // Optional management authority. None => owner manages (legacy behavior). - // When set, this address authorizes select_winners + cancel instead of the - // owner, so funding source and management identity can differ. pub manager: Option
, } @@ -145,12 +109,6 @@ pub struct Submission { // ============================================================ // CONTRIBUTION -// -// Partner / community top-up to an event's escrow. Owner deposits via -// create_event are NOT recorded as Contribution entries; they live in -// event.owner / event.total_budget. Contributions are recorded so that -// cancel_event refunds partners ahead of the owner per the policy in -// boundless-partner-contributions-prd.md. // ============================================================ #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -175,10 +133,6 @@ pub struct Winner { // ============================================================ // WINNER SELECTION SPEC -// -// Input to select_winners. The orchestrator computes reputation_bump off-chain -// per the policy tables in boundless-credits-reputation-prd.md Section 9; the -// contract just records. // ============================================================ #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -194,7 +148,6 @@ pub struct WinnerSpec { #[contracttype] #[derive(Clone, Debug)] pub enum DataKey { - // Admin / config Admin, PendingAdmin, FeeAccount, @@ -203,86 +156,39 @@ pub enum DataKey { DeploymentSeq, ProfileContract, - // Token whitelist SupportedToken(Address), - // Events NextEventId, Event(u64), - // Per-event management authority override. When present, this address - // (not event.owner) authorizes select_winners + cancel. Lets an org fund - // from any wallet (owner) while keeping management bound to its canonical - // wallet. Absent => management falls back to event.owner (legacy events). EventManager(u64), - // Per-event applicant list. Paged: ApplicantCount + ApplicantAt(idx). - // Slot index (1-based) for O(1) membership / O(1) swap-remove. Slot of - // 0 means absent. Caps at MAX_APPLICANTS_PER_EVENT to keep cancel-time - // refund passes inside Soroban's per-tx footprint budget. EventApplicantCount(u64), EventApplicantAt(u64, u32), EventApplicantSlot(u64, Address), - // Per-event submission, keyed by (event_id, applicant). No iteration - // surface — submissions are looked up by address only. EventSubmission(u64, Address), - // Per-event winner list. WinnerCount + WinnerAt(idx). No slot index - // because select_winners enforces uniqueness on position, and the - // claim_milestone path needs to enumerate anyway. EventWinnerCount(u64), EventWinnerAt(u64, u32), - // Partner contributions to an event's escrow. - // ContributorAmount(id, addr) -> i128 running total - // ContributorCount(id) -> u32 number of distinct contributors - // ContributorAt(id, idx) -> Address at slot idx - // ContributorSlot(id, addr) -> u32 1-based slot index; 0 absent - // Owner deposits do NOT appear here; they're tracked via event.owner / - // event.total_budget. ContributorAmount(u64, Address), ContributorCount(u64), ContributorAt(u64, u32), ContributorSlot(u64, Address), - // Grant milestone tracking: (event_id, recipient, milestone) -> bool MilestoneClaimed(u64, Address, u32), - // Crowdfunding: count of milestones already claimed against an event. - // Used by the dynamic-payout math (amount = remaining_escrow / - // (total_milestones - claimed_count)). Only written/read for - // Pillar::Crowdfunding; absent entries default to 0. CrowdfundingMilestonesClaimed(u64), - // Paged cancellation cursor; present iff event.status == Cancelling. CancellationState(u64), - // H6: contract semver, timelocked upgrade slot, last migrated-to version. - // - // Version -> String. Set by __constructor; bumped by - // apply_upgrade(). Exposed via version(). - // PendingUpgrade -> PendingUpgrade struct. Present between - // propose_upgrade and apply_upgrade / - // cancel_pending_upgrade. - // MigratedToVersion -> String. Records the last Version that - // successfully completed migrate(); guards - // against double-running the same migration. Version, PendingUpgrade, MigratedToVersion, - // Idempotency OpSeen(BytesN<32>), - // Enumerable whitelist index (parallels the SupportedToken bool above). - // Lets the full whitelist be read authoritatively from state via - // supported_token_count + supported_token_at, instead of replaying the - // ephemeral TokenRegistered / TokenDeregistered events (which the RPC only - // retains for a short window). Appended here to keep existing keys stable. - // SupportedTokenCount -> u32 number of whitelisted tokens - // SupportedTokenAt(idx) -> Address at slot idx, idx in [0, count) - // SupportedTokenSlot(addr)-> u32 1-based slot; 0 means absent SupportedTokenCount, SupportedTokenAt(u32), SupportedTokenSlot(Address), @@ -300,17 +206,6 @@ pub struct PendingAdmin { // ============================================================ // PENDING UPGRADE (timelocked wasm rotation, H6) -// -// propose_upgrade writes a row carrying the proposed wasm hash and the -// new_version label the contract will report after apply_upgrade. The -// proposal is timelocked: apply_upgrade can only fire after -// available_at_ledger and before expires_at_ledger. -// -// new_version is stored upfront so the apply step is purely mechanical; -// off-chain monitoring can see exactly which version the proposal upgrades -// to without inspecting the new wasm. -// -// Spec: docs/audit-2026-06-stellar-skill.md H6. // ============================================================ #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/contracts/profile/src/admin.rs b/contracts/profile/src/admin.rs index 2e43a97..59480cd 100644 --- a/contracts/profile/src/admin.rs +++ b/contracts/profile/src/admin.rs @@ -1,9 +1,3 @@ -// boundless-profile: admin operations. -// -// Two-step rotations for both admin and events_contract. -// -// Spec: boundless-credits-reputation-prd.md Sections 5.5, 6. - use soroban_sdk::{panic_with_error, Address, BytesN, Env, String}; use crate::errors::Error; @@ -11,12 +5,8 @@ use crate::events as evt; use crate::storage; use crate::types::{PendingAdmin, PendingEventsContract, PendingUpgrade}; -const PENDING_TTL_LEDGERS: u32 = 120_960; // 7 days at ~5 sec per ledger. +const PENDING_TTL_LEDGERS: u32 = 120_960; -// H6: timelocked upgrade windows. Match the events contract for consistency. -// 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")] @@ -25,15 +15,8 @@ const PENDING_UPGRADE_TTL_LEDGERS: u32 = 518_400; pub const INITIAL_VERSION: &str = "1.1.0"; -// Events-contract rotation timelock: minimum delay between propose and -// accept so off-chain monitoring has a window to react to a malicious -// proposal. ~1 day at 5 sec per ledger. -// -// Spec: docs/audit-2026-06-stellar-skill.md, H5. const EVENTS_CONTRACT_TIMELOCK_LEDGERS: u32 = 17_280; -// Maximum window between propose and accept. After this the proposal must -// be re-issued. Matches PENDING_TTL_LEDGERS for symmetry with admin rotation. const PENDING_EVENTS_CONTRACT_TTL_LEDGERS: u32 = 120_960; pub fn initialize(env: &Env, admin: Address) { @@ -88,15 +71,6 @@ pub fn accept_admin(env: &Env) -> Result<(), Error> { // ============================================================ // EVENTS CONTRACT BINDING -// -// First-set is single-step (deploy bootstrap; there's no live contract to -// rotate from). Subsequent rotations require propose + accept with a -// timelock window so off-chain monitors have time to react to a malicious -// or mistaken proposal before it lands. Closes audit finding H5 (the prior -// single-step rotation was the single soft point in the auth chain for -// every credit/reputation/earnings mutation). -// -// Spec: docs/audit-2026-06-stellar-skill.md, H5. // ============================================================ pub fn set_events_contract(env: &Env, new_addr: Address) -> Result<(), Error> { require_admin(env)?; @@ -138,14 +112,9 @@ pub fn accept_events_contract(env: &Env) -> Result<(), Error> { storage::get_pending_events_contract(env).ok_or(Error::PendingEventsContractMismatch)?; let now = env.ledger().sequence(); - // Late finalize: proposal expired. We do NOT clear here because the - // Err return reverts every storage write in this tx anyway; the expired - // entry stays put and admin must call cancel_pending_events_contract to - // prune it before re-proposing. if now > pending.expires_at_ledger { return Err(Error::PendingEventsContractExpired); } - // Early finalize: still inside the timelock window. let earliest = pending .proposed_at_ledger .saturating_add(EVENTS_CONTRACT_TIMELOCK_LEDGERS); @@ -203,7 +172,6 @@ pub fn propose_upgrade( ) -> Result<(), Error> { require_admin(env)?; if new_version.is_empty() { - // Reuse existing AdminCannotBeZero semantic for "empty input". return Err(Error::AdminCannotBeZero); } let now = env.ledger().sequence(); @@ -271,12 +239,6 @@ pub fn cancel_pending_upgrade(env: &Env) -> Result<(), Error> { // ============================================================ // MIGRATE (post-upgrade one-shot; H6) -// -// Mirror of the events contract's migrate(). See contracts/events/src/admin.rs -// for the full pattern + dispatch-block convention. The profile contract has -// a simpler storage layout, so most upgrades will not need a migration body -// here; the empty pass-through still stamps MigratedToVersion so off-chain -// runbooks see a Migrated event. // ============================================================ pub fn migrate(env: &Env) -> Result<(), Error> { require_admin(env)?; @@ -292,24 +254,8 @@ pub fn migrate(env: &Env) -> Result<(), Error> { // ============================================================ // PER-(from -> to) MIGRATION DISPATCH - // - // 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)?; - // } - // - // Soroban String only supports equality + length, so dispatch is via - // `String::from_str` + `==`. Keep bodies inline unless the migration - // grows past ~30 lines, then promote into a private fn below. // ============================================================ - // No-op for the 1.0.0 -> 1.1.0 credit-removal upgrade: no Profile rows have - // been bootstrapped yet, so there is nothing to rewrite for the dropped - // `credits` field. __constructor populates storage in the current shape; - // admin still calls migrate() once after the upgrade so the audit trail - // records that the post-upgrade cleanup ran. - storage::set_migrated_to_version(env, ¤t); storage::touch_instance(env); evt::Migrated { @@ -367,8 +313,6 @@ pub fn require_events_contract(env: &Env) -> Result<(), Error> { } pub fn require_not_paused(env: &Env) -> Result<(), Error> { - // Every state-mutating operation runs this first; single touchpoint for - // bumping instance TTL on the hot path. Admin paths bump explicitly. storage::touch_instance(env); if storage::is_paused(env) { return Err(Error::Paused); diff --git a/contracts/profile/src/bootstrap.rs b/contracts/profile/src/bootstrap.rs index d75846d..2f669eb 100644 --- a/contracts/profile/src/bootstrap.rs +++ b/contracts/profile/src/bootstrap.rs @@ -1,8 +1,3 @@ -// boundless-profile: profile lifecycle (bootstrap). -// -// Credits were removed (2026-06) and are now an off-chain ledger. Bootstrap -// only creates the per-user profile so reputation and earnings can attach. - use soroban_sdk::{Address, BytesN, Env}; use crate::admin; @@ -12,8 +7,6 @@ use crate::idempotency; use crate::storage; use crate::types::Profile; -/// Lazy bootstrap. Called by the events contract on a user's first touch. -/// Idempotent: a second call when the profile already exists is a no-op. pub fn bootstrap(env: &Env, user: Address, op_id: BytesN<32>) -> Result<(), Error> { admin::require_events_contract(env)?; admin::require_not_paused(env)?; @@ -29,10 +22,6 @@ pub fn bootstrap(env: &Env, user: Address, op_id: BytesN<32>) -> Result<(), Erro Ok(()) } -/// Self-service bootstrap. A user creates their OWN profile by authorizing the -/// call with their wallet (no admin key, no events-contract dependency). Used -/// at onboarding so every user has a profile before they participate. -/// Idempotent: a second call when the profile already exists is a no-op. pub fn bootstrap_self(env: &Env, user: Address, op_id: BytesN<32>) -> Result<(), Error> { user.require_auth(); admin::require_not_paused(env)?; diff --git a/contracts/profile/src/earnings.rs b/contracts/profile/src/earnings.rs index df0a692..8c5ca33 100644 --- a/contracts/profile/src/earnings.rs +++ b/contracts/profile/src/earnings.rs @@ -1,7 +1,3 @@ -// boundless-profile: per-token earnings registration. -// -// Spec: boundless-credits-reputation-prd.md Section 5.4. - use soroban_sdk::{Address, BytesN, Env}; use crate::admin; diff --git a/contracts/profile/src/errors.rs b/contracts/profile/src/errors.rs index e1f725c..12884d2 100644 --- a/contracts/profile/src/errors.rs +++ b/contracts/profile/src/errors.rs @@ -1,7 +1,3 @@ -// boundless-profile: error codes. -// -// Spec: boundless-credits-reputation-prd.md Section 8. - use soroban_sdk::contracterror; #[contracterror] @@ -22,14 +18,12 @@ pub enum Error { PendingEventsContractTimelock = 17, ProfileNotFound = 10, - // 11 (InsufficientCredits) retired with on-chain credits; left as a gap. InvalidAmount = 12, ReasonRequired = 13, OpAlreadySeen = 20, Paused = 30, - // H6: timelocked upgrade + migration UpgradeNotProposed = 40, UpgradeTimelockNotElapsed = 41, UpgradeProposalExpired = 42, diff --git a/contracts/profile/src/events.rs b/contracts/profile/src/events.rs index 92d60fe..0678616 100644 --- a/contracts/profile/src/events.rs +++ b/contracts/profile/src/events.rs @@ -1,9 +1,3 @@ -// boundless-profile: contract event emissions. -// -// Spec: boundless-credits-reputation-prd.md Section 7. -// -// dead_code allowed: event structs are emitted via .publish() calls. Some -// events are emitted only by ops still wiring up. #![allow(dead_code)] use soroban_sdk::{contractevent, Address, BytesN, String, Symbol}; diff --git a/contracts/profile/src/idempotency.rs b/contracts/profile/src/idempotency.rs index 296e0e5..1d4fd26 100644 --- a/contracts/profile/src/idempotency.rs +++ b/contracts/profile/src/idempotency.rs @@ -1,5 +1,3 @@ -// boundless-profile: idempotency helpers. - use soroban_sdk::{BytesN, Env}; use crate::errors::Error; diff --git a/contracts/profile/src/lib.rs b/contracts/profile/src/lib.rs index 0aa2f6d..11b40df 100644 --- a/contracts/profile/src/lib.rs +++ b/contracts/profile/src/lib.rs @@ -1,12 +1,4 @@ // SPDX-License-Identifier: MIT -// -// boundless-profile -// -// Per-user reputation + per-token earnings. Mutated almost exclusively by the -// events contract; admin can slash reputation directly with audited reasons. -// Credits were removed (2026-06) and now live in an off-chain ledger. -// -// Spec: boundless-credits-reputation-prd.md #![no_std] use soroban_sdk::{contract, contractimpl, contractmeta, Address, BytesN, Env, String, Symbol}; @@ -108,9 +100,6 @@ impl ProfileContract { bootstrap::bootstrap(&env, user, op_id) } - /// Self-service profile creation: the user authorizes their own bootstrap - /// (no admin key, no events-contract dependency). Called at onboarding so - /// every user has a profile before they participate. pub fn bootstrap_self(env: Env, user: Address, op_id: BytesN<32>) -> Result<(), Error> { bootstrap::bootstrap_self(&env, user, op_id) } diff --git a/contracts/profile/src/reputation.rs b/contracts/profile/src/reputation.rs index 2075e05..d87ae6f 100644 --- a/contracts/profile/src/reputation.rs +++ b/contracts/profile/src/reputation.rs @@ -1,7 +1,3 @@ -// boundless-profile: reputation operations. -// -// Spec: boundless-credits-reputation-prd.md Section 5.3. - use soroban_sdk::{Address, BytesN, Env, String, Symbol}; use crate::admin; diff --git a/contracts/profile/src/storage.rs b/contracts/profile/src/storage.rs index 7cb8aef..17ceedb 100644 --- a/contracts/profile/src/storage.rs +++ b/contracts/profile/src/storage.rs @@ -1,18 +1,3 @@ -// boundless-profile: storage helpers. -// -// Storage layout (after the 2026-06 audit): -// -// instance() — admin + config (events binding, bootstrap default, -// paused, deployment seq). Single bag, auto-extended -// when we call touch_instance(env). -// persistent() — Profile(user) + EarningsByToken(user, token). Each -// read/write bumps TTL via touch_profile_persistent so -// active users stay live indefinitely. -// temporary() — OpSeen idempotency markers. -// -// Pre-audit this module placed everything in persistent. Same fix as the -// events contract. - #![allow(dead_code)] use soroban_sdk::{Address, BytesN, Env}; diff --git a/contracts/profile/src/tests/admin.rs b/contracts/profile/src/tests/admin.rs index bcb2b0a..41c848e 100644 --- a/contracts/profile/src/tests/admin.rs +++ b/contracts/profile/src/tests/admin.rs @@ -1,5 +1,3 @@ -// boundless-profile: admin tests. - #![cfg(test)] use soroban_sdk::{ @@ -10,7 +8,6 @@ use soroban_sdk::{ use super::common::setup; use crate::errors::Error; -// Mirror the constants from admin.rs so the timelock tests stay readable. const EVENTS_CONTRACT_TIMELOCK_LEDGERS: u32 = 17_280; const PENDING_EVENTS_CONTRACT_TTL_LEDGERS: u32 = 120_960; @@ -83,7 +80,6 @@ fn propose_then_accept_after_timelock_swaps_events_contract() { assert_eq!(pending.target, events_b); assert_eq!(pending.proposed_at_ledger, start); - // Advance past the timelock window. ctx.env.ledger().with_mut(|li| { li.sequence_number = start + EVENTS_CONTRACT_TIMELOCK_LEDGERS + 1; }); @@ -108,7 +104,6 @@ fn accept_before_timelock_reverts() { .expect("expected timelock to block") .unwrap(); assert_eq!(err, Error::PendingEventsContractTimelock); - // Events contract unchanged. assert_eq!(ctx.client.get_events_contract(), Some(events_a)); } @@ -122,7 +117,6 @@ fn accept_after_expiry_reverts_and_admin_must_cancel_to_prune() { let start = ctx.env.ledger().sequence(); ctx.client.propose_events_contract(&events_b); - // Advance past expiry. ctx.env.ledger().with_mut(|li| { li.sequence_number = start + PENDING_EVENTS_CONTRACT_TTL_LEDGERS + 1; }); @@ -134,7 +128,6 @@ fn accept_after_expiry_reverts_and_admin_must_cancel_to_prune() { .unwrap(); assert_eq!(err, Error::PendingEventsContractExpired); - // The Err path reverts; the stale proposal stays put. Admin prunes it. assert!(ctx.client.get_pending_events_contract().is_some()); assert_eq!(ctx.client.get_events_contract(), Some(events_a)); diff --git a/contracts/profile/src/tests/bootstrap.rs b/contracts/profile/src/tests/bootstrap.rs index aec3264..5f12a37 100644 --- a/contracts/profile/src/tests/bootstrap.rs +++ b/contracts/profile/src/tests/bootstrap.rs @@ -1,10 +1,3 @@ -// boundless-profile: self-service bootstrap tests. -// -// `bootstrap_self` is the user-authorized profile-creation path used at -// platform onboarding. Unlike `bootstrap` (events-contract-gated) it requires -// NO admin key and NO events contract — the profile owner authorizes their own -// creation. These tests pin that security property plus idempotency. - #![cfg(test)] use soroban_sdk::{ @@ -30,10 +23,6 @@ fn bootstrap_self_creates_profile_for_caller() { #[test] fn bootstrap_self_demands_the_callers_own_auth_not_admin() { - // The security property the design rests on: bootstrap_self requires the - // USER's own authorization — no admin or other privileged key can create a - // profile on someone's behalf. mock_all_auths lets the call through, but - // env.auths() records whose auth the contract actually demanded. let ctx = setup(); let user = Address::generate(&ctx.env); let op_id = BytesN::random(&ctx.env); @@ -59,7 +48,6 @@ fn bootstrap_self_is_idempotent_for_existing_profile() { ctx.client.bootstrap_self(&user, &BytesN::random(&ctx.env)); let before = ctx.client.get_profile(&user).expect("profile created"); - // Second bootstrap (fresh op_id) when the profile already exists is a no-op. ctx.client.bootstrap_self(&user, &BytesN::random(&ctx.env)); assert_eq!(ctx.client.get_profile(&user), Some(before)); diff --git a/contracts/profile/src/tests/common.rs b/contracts/profile/src/tests/common.rs index 17480f6..8ef2c6f 100644 --- a/contracts/profile/src/tests/common.rs +++ b/contracts/profile/src/tests/common.rs @@ -1,5 +1,3 @@ -// boundless-profile: shared test setup. - #![cfg(test)] use soroban_sdk::{testutils::Address as _, Address, Env}; diff --git a/contracts/profile/src/tests/earnings.rs b/contracts/profile/src/tests/earnings.rs index 3393500..b413729 100644 --- a/contracts/profile/src/tests/earnings.rs +++ b/contracts/profile/src/tests/earnings.rs @@ -1,8 +1,3 @@ -// boundless-profile: earnings registration tests. -// -// Covers register_earnings() — guards, error variants, idempotency, -// auth rejection, saturating arithmetic. - #![cfg(test)] use soroban_sdk::{ @@ -25,10 +20,6 @@ fn token(env: &crate::Env) -> Address { Address::generate(env) } -// --------------------------------------------------------------------------- -// Happy path -// --------------------------------------------------------------------------- - #[test] fn register_earnings_succeeds() { let ctx = setup(); @@ -61,10 +52,6 @@ fn register_earnings_accumulates() { assert_eq!(ctx.client.get_earnings(&u, &t), 100); } -// --------------------------------------------------------------------------- -// Edge cases: multiple tokens / users -// --------------------------------------------------------------------------- - #[test] fn register_earnings_multiple_tokens() { let ctx = setup(); @@ -101,10 +88,6 @@ fn register_earnings_multiple_users() { assert_eq!(ctx.client.get_earnings(&u2, &t), 200); } -// --------------------------------------------------------------------------- -// Error: InvalidAmount (zero / negative) -// --------------------------------------------------------------------------- - #[test] fn register_earnings_rejects_zero() { let ctx = setup(); @@ -139,10 +122,6 @@ fn register_earnings_rejects_negative() { assert_eq!(err, Error::InvalidAmount); } -// --------------------------------------------------------------------------- -// Error: EventsContractNotConfigured -// --------------------------------------------------------------------------- - #[test] fn register_earnings_reverts_no_events_contract() { let ctx = setup(); @@ -158,10 +137,6 @@ fn register_earnings_reverts_no_events_contract() { assert_eq!(err, Error::EventsContractNotConfigured); } -// --------------------------------------------------------------------------- -// Error: Paused -// --------------------------------------------------------------------------- - #[test] fn register_earnings_reverts_when_paused() { let ctx = setup(); @@ -180,10 +155,6 @@ fn register_earnings_reverts_when_paused() { assert_eq!(err, Error::Paused); } -// --------------------------------------------------------------------------- -// Idempotency: duplicate op_id -// --------------------------------------------------------------------------- - #[test] fn register_earnings_rejects_duplicate_op_id() { let ctx = setup(); @@ -193,11 +164,9 @@ fn register_earnings_rejects_duplicate_op_id() { let t = token(&ctx.env); let op_id = BytesN::random(&ctx.env); - // First call succeeds. ctx.client.register_earnings(&u, &t, &100_i128, &op_id); assert_eq!(ctx.client.get_earnings(&u, &t), 100); - // Same op_id — idempotency guard. let err = ctx .client .try_register_earnings(&u, &t, &200_i128, &op_id) @@ -206,14 +175,9 @@ fn register_earnings_rejects_duplicate_op_id() { .unwrap(); assert_eq!(err, Error::OpAlreadySeen); - // Balance unchanged. assert_eq!(ctx.client.get_earnings(&u, &t), 100); } -// --------------------------------------------------------------------------- -// Edge: saturating arithmetic -// --------------------------------------------------------------------------- - #[test] fn register_earnings_saturating_add() { let ctx = setup(); @@ -222,21 +186,15 @@ fn register_earnings_saturating_add() { let u = user(&ctx.env); let t = token(&ctx.env); - // Push to i128::MAX - 1. ctx.client .register_earnings(&u, &t, &(i128::MAX - 1), &BytesN::random(&ctx.env)); assert_eq!(ctx.client.get_earnings(&u, &t), i128::MAX - 1); - // Add 100 — should saturate at i128::MAX, not overflow. ctx.client .register_earnings(&u, &t, &100_i128, &BytesN::random(&ctx.env)); assert_eq!(ctx.client.get_earnings(&u, &t), i128::MAX); } -// --------------------------------------------------------------------------- -// Auth rejection: caller is not the events contract. -// --------------------------------------------------------------------------- - #[test] fn register_earnings_auth_rejection() { let env = soroban_sdk::Env::default(); @@ -244,7 +202,6 @@ fn register_earnings_auth_rejection() { let contract_id = env.register(crate::ProfileContract, (admin.clone(),)); let client = crate::ProfileContractClient::new(&env, &contract_id); - // Set events contract directly in storage to bypass admin auth. env.as_contract(&contract_id, || { crate::storage::set_events_contract(&env, &events_addr(&env)); }); @@ -252,8 +209,6 @@ fn register_earnings_auth_rejection() { let u = user(&env); let t = token(&env); - // No mock_auths — the events address hasn't authorized this call, - // so require_auth() inside register_earnings should fail. let result = client.try_register_earnings(&u, &t, &100_i128, &BytesN::random(&env)); assert!( result.is_err(), diff --git a/contracts/profile/src/tests/mod.rs b/contracts/profile/src/tests/mod.rs index 2125798..5dd4e45 100644 --- a/contracts/profile/src/tests/mod.rs +++ b/contracts/profile/src/tests/mod.rs @@ -1,5 +1,3 @@ -// boundless-profile: test entry. - #![cfg(test)] mod admin; diff --git a/contracts/profile/src/tests/reputation.rs b/contracts/profile/src/tests/reputation.rs index 8274625..9ca44cf 100644 --- a/contracts/profile/src/tests/reputation.rs +++ b/contracts/profile/src/tests/reputation.rs @@ -1,11 +1,3 @@ -// boundless-profile: reputation tests. -// -// Covers reputation::bump, reputation::slash, reputation::admin_slash. -// Every function: happy path + each reachable Error variant + edge cases -// (saturating add/sub, zero delta) + auth-rejection + idempotency replay. -// -// Spec: boundless-credits-reputation-prd.md Section 5.3. - #![cfg(test)] use soroban_sdk::{ @@ -20,17 +12,14 @@ use crate::errors::Error; // Helpers // ============================================================ -/// A fresh, unique idempotency key. fn op_id(ctx: &TestCtx) -> BytesN<32> { BytesN::random(&ctx.env) } -/// bump/slash reason (Symbol). fn reason(ctx: &TestCtx) -> Symbol { Symbol::new(&ctx.env, "win") } -/// Current reputation for a user that is expected to have a profile. fn reputation_of(ctx: &TestCtx, user: &Address) -> u64 { ctx.client .get_profile(user) @@ -38,12 +27,6 @@ fn reputation_of(ctx: &TestCtx, user: &Address) -> u64 { .reputation } -/// setup() + wire an events contract + bootstrap one user so the -/// events-gated reputation ops have a profile to mutate. -/// -/// Returns the context plus the bootstrapped user. The events-contract -/// address is mocked-authed by `setup`, so subsequent bump/slash calls -/// satisfy `require_events_contract`. fn setup_with_user<'a>() -> (TestCtx<'a>, Address) { let ctx = setup(); let events = Address::generate(&ctx.env); @@ -83,8 +66,6 @@ fn bump_accumulates_across_calls() { #[test] fn bump_accepts_u32_max_delta_without_overflow() { - // delta is u32, reputation is u64. A single max-delta bump must widen - // cleanly into u64 and never overflow/panic. let (ctx, user) = setup_with_user(); ctx.client @@ -101,7 +82,6 @@ fn bump_zero_delta_is_noop_but_marks_seen() { ctx.client.bump_reputation(&user, &0, &reason(&ctx), &op); assert_eq!(reputation_of(&ctx, &user), 0); - // Replaying the same op_id is rejected even though the op was a no-op. let err = ctx .client .try_bump_reputation(&user, &0, &reason(&ctx), &op) @@ -113,8 +93,6 @@ fn bump_zero_delta_is_noop_but_marks_seen() { #[test] fn bump_reverts_when_events_contract_not_configured() { - // No set_events_contract: the events-contract auth guard is the first - // check and rejects before anything else. let ctx = setup(); let user = Address::generate(&ctx.env); @@ -147,7 +125,6 @@ fn bump_reverts_when_profile_not_found() { let events = Address::generate(&ctx.env); ctx.client.set_events_contract(&events); - // A user that was never bootstrapped has no profile. let ghost = Address::generate(&ctx.env); let err = ctx .client @@ -173,15 +150,11 @@ fn bump_is_idempotent_on_replay() { .expect("replay rejected") .unwrap(); assert_eq!(err, Error::OpAlreadySeen); - // Reputation unchanged: the replay did not double-apply. assert_eq!(reputation_of(&ctx, &user), 5); } #[test] fn bump_rejects_caller_without_events_contract_auth() { - // Genuine auth rejection: the events contract is configured, but no - // authorization is provided for the bump call, so events.require_auth() - // fails and the host aborts the invocation. let (ctx, user) = setup_with_user(); ctx.env.mock_auths(&[]); @@ -209,8 +182,6 @@ fn slash_happy_path_decrements_reputation() { #[test] fn slash_saturates_at_zero() { - // Slashing more than the current reputation floors at zero rather than - // underflowing (saturating_sub). let (ctx, user) = setup_with_user(); ctx.client .bump_reputation(&user, &5, &reason(&ctx), &op_id(&ctx)); @@ -312,7 +283,6 @@ fn slash_rejects_caller_without_events_contract_auth() { // admin_slash // ============================================================ -/// admin_slash reason is a String (audited free text), not a Symbol. fn admin_reason(ctx: &TestCtx) -> String { String::from_str(&ctx.env, "fraud") } @@ -357,7 +327,6 @@ fn admin_slash_reverts_on_empty_reason() { #[test] fn admin_slash_reverts_when_paused() { - // require_admin passes (mocked), then the pause guard fires. let (ctx, user) = setup_with_user(); ctx.client.pause(); @@ -373,7 +342,6 @@ fn admin_slash_reverts_when_paused() { #[test] fn admin_slash_reverts_when_profile_not_found() { let ctx = setup(); - // No events contract needed: admin_slash is admin-gated, not events-gated. let ghost = Address::generate(&ctx.env); let err = ctx @@ -408,8 +376,6 @@ fn admin_slash_is_idempotent_on_replay() { #[test] fn admin_slash_rejects_non_admin_caller() { - // Genuine auth rejection: no authorization provided, so admin.require_auth() - // fails and the host aborts the invocation. let (ctx, user) = setup_with_user(); ctx.env.mock_auths(&[]); diff --git a/contracts/profile/src/types.rs b/contracts/profile/src/types.rs index 6da7e2c..a0f4dc5 100644 --- a/contracts/profile/src/types.rs +++ b/contracts/profile/src/types.rs @@ -1,7 +1,3 @@ -// boundless-profile: types. -// -// Spec: boundless-credits-reputation-prd.md Section 4. - use soroban_sdk::{contracttype, Address, BytesN, String}; #[contracttype] @@ -20,16 +16,6 @@ impl Profile { } } -// M4 (2026-06 audit): dropped wins_count, submissions_count, -// applications_count, milestones_completed. They were never incremented -// anywhere in the contract. -// -// 2026-06: dropped `credits`. Credits are now an off-chain ledger -// (boundless-nestjs); the profile contract holds only reputation + earnings. -// Removing the field changes the persisted Profile layout: existing on-chain -// profiles must be re-bootstrapped or migrated before this version is applied. -// See the mainnet-deploy-runbook before upgrading. - #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct PendingAdmin { @@ -39,10 +25,6 @@ pub struct PendingAdmin { // ============================================================ // PENDING EVENTS CONTRACT (two-step rotation w/ timelock) -// -// proposed_at_ledger gates the early-finalize window; accept can fire only -// after proposed_at_ledger + EVENTS_CONTRACT_TIMELOCK_LEDGERS. expires_at_ledger -// gates the late-finalize window; after expiry the proposal must be re-issued. // ============================================================ #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -52,7 +34,6 @@ pub struct PendingEventsContract { pub expires_at_ledger: u32, } -// H6: timelocked wasm rotation. Mirrors the events contract's PendingUpgrade. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct PendingUpgrade { @@ -76,7 +57,6 @@ pub enum DataKey { Profile(Address), EarningsByToken(Address, Address), - // H6: contract semver, timelocked upgrade slot, last migrated-to version. Version, PendingUpgrade, MigratedToVersion,