From 7212bb807c2e98328f8318f97c6a64f22a64127f Mon Sep 17 00:00:00 2001 From: goodness-cpu Date: Tue, 30 Jun 2026 06:44:02 +0000 Subject: [PATCH 1/2] feat(crowdfund): financial penalties for malicious campaigns (#360) Closes #360 Adds a self-imposed penalty rate (organizer commits up to 50% of payout) combined with a 7-day backer-governance vote window on any malicious- campaign report. If backers vote the campaign malicious by strict majority, the slashed portion of any withdrawal is detained in an on- chain penalty pool and distributed pro-rata to backers via claim_penalty_refund. Unclaimed remainder becomes sweepable 6 months after resolution for treasury consolidation. New entry points: - set_penalty_bps: organizer-only, locked once the first pledge lands - report_malicious: backer-only with anti-griefing 1% stake floor - vote_on_malice: one weighted vote per backer over a 7-day window - resolve_malice_report: strict-majority approval of penalty - claim_penalty_refund: pro-rata refund, idempotent - sweep_unclaimed_penalty: anyone, after a 6-month grace window Modifies execute_campaign and release_milestone to invoke compute_and_lock_penalty and emit PenaltySlashedEvent when the malice proposal has been approved. Both pathways also block while the malice vote window is still open to prevent the organizer from front-running the penalty vote. Storage uses 17 new DataKey variants and 13 new error variants (28..40). Happy-path and negative tests cover locking, voting, pro-rata math, milestone slashing, execute-blocking, sweep gating. --- contracts/crowdfund/src/errors.rs | 26 ++ contracts/crowdfund/src/lib.rs | 649 +++++++++++++++++++++++++++++- contracts/crowdfund/src/test.rs | 412 +++++++++++++++++++ 3 files changed, 1083 insertions(+), 4 deletions(-) diff --git a/contracts/crowdfund/src/errors.rs b/contracts/crowdfund/src/errors.rs index 6a16655..0b39fd7 100644 --- a/contracts/crowdfund/src/errors.rs +++ b/contracts/crowdfund/src/errors.rs @@ -52,4 +52,30 @@ pub enum CrowdfundError { InsufficientMatchingPool = 26, // Pledge comment exceeds the configured maximum length. CommentTooLong = 27, + // Penalty basis points exceed the configured maximum (50%). + PenaltyBpsInvalid = 28, + // Penalty configuration is locked after the first contribution. + PenaltyLocked = 29, + // A malicious campaign report is already active. + MaliceReportActive = 30, + // No active malicious campaign report exists. + NoMaliceReport = 31, + // Penalty voting is still inside the active window. + MaliceVoteWindowActive = 32, + // Penalty voting window has expired; resolution time. + MaliceVoteWindowExpired = 33, + // Penalty voting is closed but the proposal was not approved. + PenaltyNotApproved = 34, + // The penalty pool does not hold enough to cover the requested payout. + InsufficientPenaltyPool = 35, + // Caller is not the organizer (used to gate organizer-only flows). + NotOrganizer = 36, + // Reporter does not hold enough pledge to file a malice report. + InsufficientReporterStake = 37, + // Penalty sweep is attempted before the unclaimed window has elapsed. + PenaltySweepTooEarly = 38, + // Attempting to claim zero or negative penalty share. + NoPenaltyShareAvailable = 39, + // A backer can only vote once on the active malice penalty proposal. + PenaltyVoteAlreadyCast = 40, } diff --git a/contracts/crowdfund/src/lib.rs b/contracts/crowdfund/src/lib.rs index 4b938ff..d6bc81a 100644 --- a/contracts/crowdfund/src/lib.rs +++ b/contracts/crowdfund/src/lib.rs @@ -103,6 +103,54 @@ pub struct BatchRefundProcessedEvent { pub contributor_count: u32, } +// ── Financial penalty events (#360) ────────────────────────────────────────── +#[contractevent] +pub struct PenaltyConfiguredEvent { + pub bps: u32, +} + +#[contractevent] +pub struct MaliciousReportFiledEvent { + pub reporter: Address, + pub reason: String, + pub vote_deadline: u64, +} + +#[contractevent] +pub struct MaliceVoteCastEvent { + pub voter: Address, + pub approve: bool, + pub weight: i128, +} + +#[contractevent] +pub struct MaliceReportResolvedEvent { + pub approved: bool, + pub approval_weight: i128, + pub rejection_weight: i128, + pub snapshot_raised: i128, +} + +#[contractevent] +pub struct PenaltySlashedEvent { + pub amount: i128, + pub source: Address, + pub pool_balance: i128, +} + +#[contractevent] +pub struct PenaltyRefundClaimedEvent { + pub backer: Address, + pub amount: i128, + pub remaining_pool: i128, +} + +#[contractevent] +pub struct PenaltySweptEvent { + pub recipient: Address, + pub amount: i128, +} + #[contracttype] enum DataKey { Organizer, @@ -149,6 +197,41 @@ enum DataKey { MatchingPool, // Public comment attached to a contributor pledge. PledgeComment(Address), + // ── Financial penalty for malicious campaigns (#360) ───────────────────── + /// Penalty in basis points (max 5_000 = 50%). Locked after first pledge. + PenaltyBps, + /// True once `PenaltyBps` is locked and cannot be modified. + PenaltyLocked, + /// Unix timestamp when the active malice voting window opens. + MaliceVoteStart, + /// Unix timestamp when the active malice voting window closes. + MaliceVoteDeadline, + /// Address that filed the active malice report. + MaliceReporter, + /// Off-chain evidence / reason string supplied by the reporter. + MaliceReason, + /// Cumulative pledge-weighted approvals for the active malice report. + PenaltyApprovalWeight, + /// Cumulative pledge-weighted rejections for the active malice report. + PenaltyRejectionWeight, + /// Tracks whether a backer has voted on the active malice report. + PenaltyVote(Address), + /// True when the active report has been resolved. + PenaltyResolved, + /// Outcome of the resolution: true if penalty was approved by voters. + PenaltyApproved, + /// Current balance of the on-chain penalty pool (slashed tokens). + PenaltyPool, + /// `Raised` snapshot taken at the moment the penalty was approved. + PenaltySnapshotRaised, + /// Sum of all backer penalty refunds already distributed. + PenaltyTotalClaimed, + /// Amount of penalty pool already claimed by a specific backer. + BackerPenaltyClaimed(Address), + /// Unix timestamp at which unclaimed penalty becomes sweepable. + PenaltySweepUnlock, + /// Address authorized to receive unclaimed penalty on sweep. + PenaltyRecipient, } #[contract] @@ -157,6 +240,26 @@ pub struct CrowdfundContract; #[contractimpl] impl CrowdfundContract { const MAX_COMMENT_BYTES: u32 = 280; + /// Maximum self-imposed penalty (50% of payout) for malicious campaigns. + const MAX_PENALTY_BPS: u32 = 5_000; + /// Length of the malice voting window (7 days). + const PENALTY_VOTE_WINDOW: u64 = 604_800; + /// Time after resolution that unclaimed penalty may be swept (≈6 months). + const PENALTY_SWEEP_AFTER_SECS: u64 = 15_778_800; + /// Reporter must hold at least this fraction of raised (basis points) to + /// open a voting window — anti-griefing floor (default 1% = 100 bps). + const MIN_REPORTER_PLEDGE_BPS: u32 = 100; + + // ── Penalty helpers (#360) ───────────────────────────────────────────────── + /// Returns true if a malice report is currently in flight. + fn malice_report_active(env: &Env) -> bool { + env.storage().persistent().has(&DataKey::MaliceVoteStart) + && !env + .storage() + .persistent() + .get(&DataKey::PenaltyResolved) + .unwrap_or(false) + } /// Initialise a campaign. Sets the funding goal (in token base units) /// and the deadline (Unix timestamp after which no contributions are /// accepted). Only callable once. @@ -358,6 +461,9 @@ impl CrowdfundContract { } /// Withdraw funds to the organizer after deadline if goal was met (#303). + /// If a financial penalty was approved by backer vote and a non-zero + /// `PenaltyBps` is set, the slashed portion is detained in the on-chain + /// penalty pool instead of being paid out. pub fn execute_campaign(env: Env) { let organizer: Address = env .storage() @@ -377,6 +483,19 @@ impl CrowdfundContract { panic_with_error!(&env, CrowdfundError::CampaignNotEnded); } + // Block withdrawal while a malice proposal is still being voted on so + // the organizer cannot front-run the penalty (#360). + if Self::malice_report_active(&env) { + let deadline_ts: u64 = env + .storage() + .persistent() + .get(&DataKey::MaliceVoteDeadline) + .unwrap_or(0); + if env.ledger().timestamp() <= deadline_ts { + panic_with_error!(&env, CrowdfundError::MaliceVoteWindowActive); + } + } + let goal: i128 = env .storage() .persistent() @@ -417,10 +536,26 @@ impl CrowdfundContract { .unwrap_or_else(|| panic_with_error!(&env, CrowdfundError::NotInitialized)); let contract_addr = env.current_contract_address(); + + // Compute and lock away penalty amount if approved (#360). + let (payout, slashed) = Self::compute_and_lock_penalty(&env, raised); + if slashed > 0 { + PenaltySlashedEvent { + amount: slashed, + source: organizer.clone(), + pool_balance: env + .storage() + .persistent() + .get(&DataKey::PenaltyPool) + .unwrap_or(0), + } + .publish(&env); + } + token::TokenClient::new(&env, &token_addr) - .transfer(&contract_addr, &organizer, &raised); + .transfer(&contract_addr, &organizer, &payout); - CampaignExecutedEvent { amount: raised }.publish(&env); + CampaignExecutedEvent { amount: payout }.publish(&env); } /// Allow a backer to reclaim their pledge after deadline if goal was not met (#304). @@ -746,6 +881,8 @@ impl CrowdfundContract { /// Release the proportional funds for an unlocked, unreleased milestone to the organizer. /// Can only be called after the campaign deadline and goal is met. + /// If a financial penalty was approved by backer vote, the matching + /// slice is detained in the on-chain penalty pool (#360). pub fn release_milestone(env: Env, index: u32) { let organizer: Address = env .storage() @@ -765,6 +902,18 @@ impl CrowdfundContract { panic_with_error!(&env, CrowdfundError::CampaignNotEnded); } + // Block release while a malice proposal is still being voted on (#360). + if Self::malice_report_active(&env) { + let vote_dl: u64 = env + .storage() + .persistent() + .get(&DataKey::MaliceVoteDeadline) + .unwrap_or(0); + if env.ledger().timestamp() <= vote_dl { + panic_with_error!(&env, CrowdfundError::MaliceVoteWindowActive); + } + } + let goal: i128 = env .storage() .persistent() @@ -835,10 +984,30 @@ impl CrowdfundContract { .unwrap_or_else(|| panic_with_error!(&env, CrowdfundError::NotInitialized)); let contract_addr = env.current_contract_address(); + + // Apply milestone-scoped penalty to the released amount (#360). + let (payout, slashed) = Self::compute_and_lock_penalty(&env, amount); + if slashed > 0 { + PenaltySlashedEvent { + amount: slashed, + source: organizer.clone(), + pool_balance: env + .storage() + .persistent() + .get(&DataKey::PenaltyPool) + .unwrap_or(0), + } + .publish(&env); + } + token::TokenClient::new(&env, &token_addr) - .transfer(&contract_addr, &organizer, &amount); + .transfer(&contract_addr, &organizer, &payout); - MilestoneReleasedEvent { index, amount }.publish(&env); + MilestoneReleasedEvent { + index, + amount: payout, + } + .publish(&env); } /// Returns the pledge amount recorded for a given contributor. @@ -901,6 +1070,432 @@ impl CrowdfundContract { raised >= goal } + // ── Financial penalties for malicious campaigns (#360) ───────────────────── + + /// Set the self-imposed penalty basis points (≤ `MAX_PENALTY_BPS`) that + /// the organizer commits to if backers vote the campaign malicious. + /// Must be called **before** the first pledge lands (penalty is locked + /// permanently once any contribution arrives) and **before** a malice + /// proposal is filed. Pure organizer signal — values can be repeatedly + /// re-tuned while still unlocked. + pub fn set_penalty_bps(env: Env, bps: u32) { + let organizer: Address = env + .storage() + .persistent() + .get(&DataKey::Organizer) + .unwrap_or_else(|| panic_with_error!(&env, CrowdfundError::NotInitialized)); + organizer.require_auth(); + if env + .storage() + .persistent() + .get(&DataKey::PenaltyLocked) + .unwrap_or(false) + { + panic_with_error!(&env, CrowdfundError::PenaltyLocked); + } + if bps > Self::MAX_PENALTY_BPS { + panic_with_error!(&env, CrowdfundError::PenaltyBpsInvalid); + } + if Self::malice_report_active(&env) { + panic_with_error!(&env, CrowdfundError::MaliceReportActive); + } + env.storage().persistent().set(&DataKey::PenaltyBps, &bps); + PenaltyConfiguredEvent { bps }.publish(&env); + } + + /// File a malicious-campaign report. Must be invoked by a backer whose + /// pledge is at least `MIN_REPORTER_PLEDGE_BPS` of the currently raised + /// amount (anti-griefing floor). Organizers may not file against + /// themselves. Opens the `PENALTY_VOTE_WINDOW`-long voting session. + pub fn report_malicious(env: Env, reporter: Address, reason: String) { + reporter.require_auth(); + if Self::malice_report_active(&env) { + panic_with_error!(&env, CrowdfundError::MaliceReportActive); + } + let organizer: Address = env + .storage() + .persistent() + .get(&DataKey::Organizer) + .unwrap_or_else(|| panic_with_error!(&env, CrowdfundError::NotInitialized)); + if reporter == organizer { + panic_with_error!(&env, CrowdfundError::NotOrganizer); + } + let pledge: i128 = env + .storage() + .persistent() + .get(&DataKey::Pledge(reporter.clone())) + .unwrap_or(0); + if pledge <= 0 { + panic_with_error!(&env, CrowdfundError::NoPledge); + } + let raised: i128 = env + .storage() + .persistent() + .get(&DataKey::Raised) + .unwrap_or(0); + let required = raised.saturating_mul(Self::MIN_REPORTER_PLEDGE_BPS as i128) / 10_000; + if pledge < required { + panic_with_error!(&env, CrowdfundError::InsufficientReporterStake); + } + let now = env.ledger().timestamp(); + env.storage() + .persistent() + .set(&DataKey::MaliceVoteStart, &now); + env.storage().persistent().set( + &DataKey::MaliceVoteDeadline, + &now.saturating_add(Self::PENALTY_VOTE_WINDOW), + ); + env.storage() + .persistent() + .set(&DataKey::MaliceReporter, &reporter.clone()); + env.storage() + .persistent() + .set(&DataKey::MaliceReason, &reason.clone()); + env.storage() + .persistent() + .set(&DataKey::PenaltyApprovalWeight, &0_i128); + env.storage() + .persistent() + .set(&DataKey::PenaltyRejectionWeight, &0_i128); + env.storage() + .persistent() + .set(&DataKey::PenaltyResolved, &false); + env.storage() + .persistent() + .set(&DataKey::PenaltyApproved, &false); + MaliciousReportFiledEvent { + reporter, + reason, + vote_deadline: now.saturating_add(Self::PENALTY_VOTE_WINDOW), + } + .publish(&env); + } + + /// Cast a backer's weighted vote on the active malice report. Vote window + /// must still be open; one vote per backer (no coercion). + pub fn vote_on_malice(env: Env, voter: Address, approve: bool) { + voter.require_auth(); + if !Self::malice_report_active(&env) { + panic_with_error!(&env, CrowdfundError::NoMaliceReport); + } + let deadline: u64 = env + .storage() + .persistent() + .get(&DataKey::MaliceVoteDeadline) + .unwrap_or_else(|| panic_with_error!(&env, CrowdfundError::NoMaliceReport)); + if env.ledger().timestamp() > deadline { + panic_with_error!(&env, CrowdfundError::MaliceVoteWindowExpired); + } + let vote_key = DataKey::PenaltyVote(voter.clone()); + if env.storage().persistent().has(&vote_key) { + panic_with_error!(&env, CrowdfundError::PenaltyVoteAlreadyCast); + } + let weight: i128 = env + .storage() + .persistent() + .get(&DataKey::Pledge(voter.clone())) + .unwrap_or(0); + if weight <= 0 { + panic_with_error!(&env, CrowdfundError::NotBacker); + } + let tally_key = if approve { + DataKey::PenaltyApprovalWeight + } else { + DataKey::PenaltyRejectionWeight + }; + let current: i128 = env.storage().persistent().get(&tally_key).unwrap_or(0); + env.storage() + .persistent() + .set(&tally_key, ¤t.saturating_add(weight)); + env.storage().persistent().set(&vote_key, &approve); + MaliceVoteCastEvent { + voter, + approve, + weight, + } + .publish(&env); + } + + /// Finalize the active malice report. Callable by anyone once the vote + /// window has closed. Approved iff approval weight is a strict majority + /// of `Raised` at the moment of resolution. Approval locks-in the penalty + /// to be applied on the next `execute_campaign` / `release_milestone`. + pub fn resolve_malice_report(env: Env) { + if !Self::malice_report_active(&env) { + panic_with_error!(&env, CrowdfundError::NoMaliceReport); + } + let deadline: u64 = env + .storage() + .persistent() + .get(&DataKey::MaliceVoteDeadline) + .unwrap_or_else(|| panic_with_error!(&env, CrowdfundError::NoMaliceReport)); + if env.ledger().timestamp() <= deadline { + panic_with_error!(&env, CrowdfundError::MaliceVoteWindowActive); + } + let approval: i128 = env + .storage() + .persistent() + .get(&DataKey::PenaltyApprovalWeight) + .unwrap_or(0); + let rejection: i128 = env + .storage() + .persistent() + .get(&DataKey::PenaltyRejectionWeight) + .unwrap_or(0); + let raised: i128 = env + .storage() + .persistent() + .get(&DataKey::Raised) + .unwrap_or(0); + let approved = approval > (raised / 2); + + env.storage() + .persistent() + .set(&DataKey::PenaltyResolved, &true); + env.storage() + .persistent() + .set(&DataKey::PenaltyApproved, &approved); + env.storage() + .persistent() + .set(&DataKey::PenaltySnapshotRaised, &raised); + env.storage().persistent().set( + &DataKey::PenaltySweepUnlock, + &env + .ledger() + .timestamp() + .saturating_add(Self::PENALTY_SWEEP_AFTER_SECS), + ); + + MaliceReportResolvedEvent { + approved, + approval_weight: approval, + rejection_weight: rejection, + snapshot_raised: raised, + } + .publish(&env); + } + + /// Pro-rata claim from the penalty pool. Each backer is entitled to + /// `(pledge_at_resolution / PenaltySnapshotRaised) * PoolBalance`, minus + /// what they have already claimed. Safe against reentrancy (state is + /// mutated before the cross-contract token transfer). + pub fn claim_penalty_refund(env: Env, backer: Address) { + backer.require_auth(); + let approved: bool = env + .storage() + .persistent() + .get(&DataKey::PenaltyApproved) + .unwrap_or(false); + if !approved { + panic_with_error!(&env, CrowdfundError::PenaltyNotApproved); + } + let pledge: i128 = env + .storage() + .persistent() + .get(&DataKey::Pledge(backer.clone())) + .unwrap_or(0); + if pledge <= 0 { + panic_with_error!(&env, CrowdfundError::NotBacker); + } + let pool: i128 = env + .storage() + .persistent() + .get(&DataKey::PenaltyPool) + .unwrap_or(0); + let snapshot: i128 = env + .storage() + .persistent() + .get(&DataKey::PenaltySnapshotRaised) + .unwrap_or(0); + if pool <= 0 || snapshot <= 0 { + panic_with_error!(&env, CrowdfundError::NoPenaltyShareAvailable); + } + let total_share = pledge.saturating_mul(pool) / snapshot; + let already: i128 = env + .storage() + .persistent() + .get(&DataKey::BackerPenaltyClaimed(backer.clone())) + .unwrap_or(0); + let claimable = total_share.saturating_sub(already); + if claimable <= 0 { + panic_with_error!(&env, CrowdfundError::NoPenaltyShareAvailable); + } + // Checks-Effects-Interactions: write storage before token transfer. + env.storage().persistent().set( + &DataKey::BackerPenaltyClaimed(backer.clone()), + &already.saturating_add(claimable), + ); + let total_claimed: i128 = env + .storage() + .persistent() + .get(&DataKey::PenaltyTotalClaimed) + .unwrap_or(0); + env.storage().persistent().set( + &DataKey::PenaltyTotalClaimed, + &total_claimed.saturating_add(claimable), + ); + let token_addr: Address = env + .storage() + .persistent() + .get(&DataKey::Token) + .unwrap_or_else(|| panic_with_error!(&env, CrowdfundError::NotInitialized)); + let contract_addr = env.current_contract_address(); + token::TokenClient::new(&env, &token_addr) + .transfer(&contract_addr, &backer, &claimable); + let remaining = pool.saturating_sub(claimable); + PenaltyRefundClaimedEvent { + backer, + amount: claimable, + remaining_pool: remaining, + } + .publish(&env); + } + + /// Sweep unclaimed penalty balance to `recipient` once the configured + /// sweep window has elapsed. Anyone may trigger this — useful for + /// protocol governance / treasury consolidation. + pub fn sweep_unclaimed_penalty(env: Env, recipient: Address) { + let approved: bool = env + .storage() + .persistent() + .get(&DataKey::PenaltyApproved) + .unwrap_or(false); + if !approved { + panic_with_error!(&env, CrowdfundError::PenaltyNotApproved); + } + let unlock: u64 = env + .storage() + .persistent() + .get(&DataKey::PenaltySweepUnlock) + .unwrap_or_else(|| panic_with_error!(&env, CrowdfundError::PenaltySweepTooEarly)); + if env.ledger().timestamp() < unlock { + panic_with_error!(&env, CrowdfundError::PenaltySweepTooEarly); + } + let pool: i128 = env + .storage() + .persistent() + .get(&DataKey::PenaltyPool) + .unwrap_or(0); + let total_claimed: i128 = env + .storage() + .persistent() + .get(&DataKey::PenaltyTotalClaimed) + .unwrap_or(0); + let sweepable = pool.saturating_sub(total_claimed); + if sweepable <= 0 { + return; + } + env.storage() + .persistent() + .set(&DataKey::PenaltyPool, &total_claimed); + env.storage() + .persistent() + .set(&DataKey::PenaltyRecipient, &recipient); + let token_addr: Address = env + .storage() + .persistent() + .get(&DataKey::Token) + .unwrap_or_else(|| panic_with_error!(&env, CrowdfundError::NotInitialized)); + let contract_addr = env.current_contract_address(); + token::TokenClient::new(&env, &token_addr) + .transfer(&contract_addr, &recipient, &sweepable); + PenaltySweptEvent { + recipient, + amount: sweepable, + } + .publish(&env); + } + + // ── Penalty view accessors (#360) ───────────────────────────────────────── + + pub fn get_penalty_bps(env: Env) -> u32 { + env.storage().persistent().get(&DataKey::PenaltyBps).unwrap_or(0) + } + + pub fn penalty_locked(env: Env) -> bool { + env.storage() + .persistent() + .get(&DataKey::PenaltyLocked) + .unwrap_or(false) + } + + pub fn malice_vote_start(env: Env) -> Option { + env.storage().persistent().get(&DataKey::MaliceVoteStart) + } + + pub fn malice_vote_deadline(env: Env) -> Option { + env.storage().persistent().get(&DataKey::MaliceVoteDeadline) + } + + pub fn malice_reporter(env: Env) -> Option
{ + env.storage().persistent().get(&DataKey::MaliceReporter) + } + + pub fn malice_reason(env: Env) -> Option { + env.storage().persistent().get(&DataKey::MaliceReason) + } + + pub fn penalty_vote_counts(env: Env) -> (i128, i128) { + ( + env.storage() + .persistent() + .get(&DataKey::PenaltyApprovalWeight) + .unwrap_or(0), + env.storage() + .persistent() + .get(&DataKey::PenaltyRejectionWeight) + .unwrap_or(0), + ) + } + + pub fn is_malice_report_active(env: Env) -> bool { + Self::malice_report_active(&env) + } + + pub fn is_penalty_approved(env: Env) -> bool { + env.storage() + .persistent() + .get(&DataKey::PenaltyApproved) + .unwrap_or(false) + } + + pub fn penalty_pool_balance(env: Env) -> i128 { + env.storage() + .persistent() + .get(&DataKey::PenaltyPool) + .unwrap_or(0) + } + + pub fn penalty_snapshot_raised(env: Env) -> i128 { + env.storage() + .persistent() + .get(&DataKey::PenaltySnapshotRaised) + .unwrap_or(0) + } + + pub fn backer_penalty_claimed(env: Env, backer: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::BackerPenaltyClaimed(backer)) + .unwrap_or(0) + } + + pub fn penalty_sweep_unlock(env: Env) -> Option { + env.storage().persistent().get(&DataKey::PenaltySweepUnlock) + } + + pub fn max_penalty_bps() -> u32 { + Self::MAX_PENALTY_BPS + } + + pub fn penalty_vote_window() -> u64 { + Self::PENALTY_VOTE_WINDOW + } + + pub fn penalty_sweep_after() -> u64 { + Self::PENALTY_SWEEP_AFTER_SECS + } + // ── Internal helpers ────────────────────────────────────────────────────── /// Emit a `stretch / reached` event for each milestone crossed by `new_raised` @@ -976,6 +1571,52 @@ impl CrowdfundContract { .persistent() .set(&DataKey::Pledge(contributor), &prev_pledge.saturating_add(effective_amount)); + // Lock the self-imposed penalty rate after the first pledge lands so + // backers can rely on the trust signal (#360). + if !env + .storage() + .persistent() + .get(&DataKey::PenaltyLocked) + .unwrap_or(false) + { + env.storage() + .persistent() + .set(&DataKey::PenaltyLocked, &true); + } + new_raised } + + /// Compute the penalty slice for `amount`, credit the pool, and return + /// `(payout_to_organizer, slashed_to_pool)`. Identity when no penalty is + /// configured/approved (#360). + fn compute_and_lock_penalty(env: &Env, amount: i128) -> (i128, i128) { + let approved: bool = env + .storage() + .persistent() + .get(&DataKey::PenaltyApproved) + .unwrap_or(false); + if !approved { + return (amount, 0); + } + let bps: u32 = env + .storage() + .persistent() + .get(&DataKey::PenaltyBps) + .unwrap_or(0); + if bps == 0 || amount <= 0 { + return (amount, 0); + } + let slashed = amount.saturating_mul(bps as i128) / 10_000; + let payout = amount.saturating_sub(slashed); + let pool: i128 = env + .storage() + .persistent() + .get(&DataKey::PenaltyPool) + .unwrap_or(0); + env.storage() + .persistent() + .set(&DataKey::PenaltyPool, &pool.saturating_add(slashed)); + (payout, slashed) + } } diff --git a/contracts/crowdfund/src/test.rs b/contracts/crowdfund/src/test.rs index 55e7dd1..3e27771 100644 --- a/contracts/crowdfund/src/test.rs +++ b/contracts/crowdfund/src/test.rs @@ -956,3 +956,415 @@ fn test_leave_comment_requires_existing_pledge() { let comment = soroban_sdk::String::from_str(&env, "No pledge yet"); client.leave_comment(&contributor, &comment); } + +// ── #360 – Financial penalties for malicious campaigns ─────────────────── + +fn setup_penalty_baseline() -> ( + Env, + Address, + CrowdfundContractClient<'static>, + Address, + Address, + Address, +) { + let (env, contract, client, token, organizer, contributor) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &10_000, &deadline); + StellarAssetClient::new(&env, &token).mint(&contributor, &4_000); + client.contribute(&contributor, &4_000); + (env, contract, client, token, organizer, contributor) +} + +#[test] +fn test_set_penalty_bps_records_and_emits() { + let (env, _contract, client, _token, organizer, _contributor) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + let token_admin = Address::generate(&env); + let test_token = env.register_stellar_asset_contract_v2(token_admin).address(); + client.init_campaign(&organizer, &test_token, &1_000, &deadline); + // Penalty is locked lazily after first pledge; pre-pledge it is editable. + client.set_penalty_bps(&2_000); // 20% + assert_eq!(client.get_penalty_bps(), 2_000); + assert!(!client.penalty_locked()); +} + +#[test] +#[should_panic] +fn test_set_penalty_bps_above_max_panics() { + let (env, _contract, client, token, organizer, _) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &1_000, &deadline); + // 6_000 bps = 60% > MAX_PENALTY_BPS + client.set_penalty_bps(&6_000); +} + +#[test] +#[should_panic] +fn test_set_penalty_bps_blocked_after_first_pledge() { + let (env, _contract, client, token, organizer, contributor) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &1_000, &deadline); + StellarAssetClient::new(&env, &token).mint(&contributor, &500); + client.contribute(&contributor, &500); + // After PledgeLocked -> panic. + client.set_penalty_bps(&1_000); +} + +#[test] +fn test_penalty_bps_locks_after_contribution() { + let (env, _contract, client, token, organizer, contributor) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &1_000, &deadline); + client.set_penalty_bps(&1_500); + StellarAssetClient::new(&env, &token).mint(&contributor, &500); + client.contribute(&contributor, &500); + assert!(client.penalty_locked()); + // Stored value still readable. + assert_eq!(client.get_penalty_bps(), 1_500); +} + +#[test] +fn test_report_malicious_opens_vote_window() { + let (env, _contract, client, token, organizer, contributor) = setup(); + let backer2 = Address::generate(&env); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &10_000, &deadline); + StellarAssetClient::new(&env, &token).mint(&contributor, &300); + StellarAssetClient::new(&env, &token).mint(&backer2, &200); + client.contribute(&contributor, &300); + client.contribute(&backer2, &200); + // 500 / 500 = 100% > 1% floor -> allowed. + let reason = soroban_sdk::String::from_str(&env, "Organizer disappeared"); + client.report_malicious(&contributor, &reason); + assert!(client.is_malice_report_active()); + assert_eq!(client.malice_reporter(), Some(contributor.clone())); + assert_eq!(client.malice_reason(), Some(reason.clone())); + let vote_dl = client.malice_vote_deadline().unwrap(); + assert!(vote_dl > env.ledger().timestamp()); +} + +#[test] +#[should_panic] +fn test_report_malicious_requires_min_stake() { + let (env, _contract, client, token, organizer, contributor_a) = setup(); + let contributor_b = Address::generate(&env); + let big = Address::generate(&env); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &10_000, &deadline); + let sac = StellarAssetClient::new(&env, &token); + sac.mint(&big, &9_900); + sac.mint(&contributor_a, &50); + sac.mint(&contributor_b, &50); + client.contribute(&big, &9_900); + client.contribute(&contributor_a, &50); + client.contribute(&contributor_b, &50); + // Raised=10_000. A's pledge is 50 = 0.5% < 1% floor -> panic. + let reason = soroban_sdk::String::from_str(&env, "weak"); + client.report_malicious(&contributor_a, &reason); +} + +#[test] +#[should_panic] +fn test_report_malicious_cannot_self_report() { + let (env, _contract, client, token, organizer, _contributor) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &1_000, &deadline); + let reason = soroban_sdk::String::from_str(&env, "self"); + client.report_malicious(&organizer, &reason); +} + +#[test] +fn test_vote_on_malice_records_weight_once() { + let (env, _contract, client, _token, _organizer, contributor) = setup_penalty_baseline(); + let voter2 = Address::generate(&env); + StellarAssetClient::new(&env, &_token).mint(&voter2, &1_000); + client.contribute(&voter2, &1_000); + let reason = soroban_sdk::String::from_str(&env, "bad"); + client.report_malicious(&contributor, &reason); + client.vote_on_malice(&contributor, &true); + client.vote_on_malice(&voter2, &false); + let (ap, rj) = client.penalty_vote_counts(); + assert_eq!(ap, 4_000); + assert_eq!(rj, 1_000); +} + +#[test] +#[should_panic] +fn test_vote_on_malice_double_vote_panics() { + let (env, _contract, client, _token, _organizer, contributor) = setup_penalty_baseline(); + let reason = soroban_sdk::String::from_str(&env, "bad"); + client.report_malicious(&contributor, &reason); + client.vote_on_malice(&contributor, &true); + client.vote_on_malice(&contributor, &false); // already cast +} + +#[test] +#[should_panic] +fn test_vote_on_malice_after_window_panics() { + let (env, _contract, client, _token, _organizer, contributor) = setup_penalty_baseline(); + let reason = soroban_sdk::String::from_str(&env, "bad"); + client.report_malicious(&contributor, &reason); + env.ledger().with_mut(|l| { + l.timestamp = client.malice_vote_deadline().unwrap() + 1 + }); + client.vote_on_malice(&contributor, &true); +} + +#[test] +fn test_resolve_malice_approves_on_majority() { + let (env, _contract, client, _token, _organizer, contributor) = setup_penalty_baseline(); + let backer2 = Address::generate(&env); + let backer3 = Address::generate(&env); + let sac = StellarAssetClient::new(&env, &_token); + sac.mint(&backer2, &3_000); + sac.mint(&backer3, &3_000); + client.contribute(&backer2, &3_000); + client.contribute(&backer3, &3_000); + let reason = soroban_sdk::String::from_str(&env, "bad"); + client.report_malicious(&contributor, &reason); + // 4_000 approve, 6_000 reject out of 10_000 raised. + client.vote_on_malice(&contributor, &true); // 4_000 yes + client.vote_on_malice(&backer2, &false); // 3_000 no + client.vote_on_malice(&backer3, &false); // +3_000 = 6_000 no + env.ledger().with_mut(|l| { + l.timestamp = client.malice_vote_deadline().unwrap() + 1 + }); + client.resolve_malice_report(); + assert!(!client.is_penalty_approved()); + assert_eq!(client.penalty_snapshot_raised(), 10_000); +} + +#[test] +fn test_resolve_malice_requires_window_close() { + let (env, _contract, client, _token, _organizer, contributor) = setup_penalty_baseline(); + let reason = soroban_sdk::String::from_str(&env, "bad"); + client.report_malicious(&contributor, &reason); + client.vote_on_malice(&contributor, &true); + env.ledger().with_mut(|l| { + l.timestamp = client.malice_vote_deadline().unwrap() - 10 + }); + let res = client.try_resolve_malice_report(); + assert!(res.is_err()); +} + +#[test] +fn test_execute_campaign_keeps_full_payout_when_no_penalty() { + let (env, _contract, client, token, organizer, contributor) = setup(); + let deadline = env.ledger().timestamp() + 100; + client.init_campaign(&organizer, &token, &1_000, &deadline); + StellarAssetClient::new(&env, &token).mint(&contributor, &1_000); + client.contribute(&contributor, &1_000); + env.ledger().with_mut(|l| l.timestamp += 200); + let before = StellarAssetClient::new(&env, &token).balance(&organizer); + client.execute_campaign(); + assert_eq!( + StellarAssetClient::new(&env, &token).balance(&organizer) - before, + 1_000 + ); + assert_eq!(client.penalty_pool_balance(), 0); +} + +#[test] +fn test_execute_campaign_slashes_after_malice_approval() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + let contract = env.register(CrowdfundContract, ()); + let client = CrowdfundContractClient::new(&env, &contract); + let org = Address::generate(&env); + let tok_admin = Address::generate(&env); + let tok = env.register_stellar_asset_contract_v2(tok_admin).address(); + let backer_a = Address::generate(&env); + let backer_b = Address::generate(&env); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&org, &tok, &50_000, &deadline); + client.set_penalty_bps(&1_000); // 10% + let sac = StellarAssetClient::new(&env, &tok); + sac.mint(&backer_a, &30_000); + sac.mint(&backer_b, &20_000); + client.contribute(&backer_a, &30_000); + client.contribute(&backer_b, &20_000); + // Drive penalty approval through the full vote flow. + let reason = soroban_sdk::String::from_str(&env, "scam"); + client.report_malicious(&backer_a, &reason); + client.vote_on_malice(&backer_a, &true); // 30_000 approve + client.vote_on_malice(&backer_b, &true); // 20_000 approve + env.ledger().with_mut(|l| { + l.timestamp = client.malice_vote_deadline().unwrap() + 1 + }); + client.resolve_malice_report(); + assert!(client.is_penalty_approved()); + + env.ledger().with_mut(|l| l.timestamp += 86_400 * 2); + let token_client = StellarAssetClient::new(&env, &tok); + let org_before = token_client.balance(&org); + client.execute_campaign(); + // 10% of 50_000 = 5_000 to pool, organizer receives 45_000. + assert_eq!(token_client.balance(&org) - org_before, 45_000); + assert_eq!(client.penalty_pool_balance(), 5_000); + + // Backer A holds 60% of pledge -> claim 5_000 * (30_000 / 50_000) = 3_000. + let ba_before = token_client.balance(&backer_a); + client.claim_penalty_refund(&backer_a); + assert_eq!(token_client.balance(&backer_a) - ba_before, 3_000); + + // Backer B holds 40% of pledge -> claim 5_000 * (20_000 / 50_000) = 2_000. + let bb_before = token_client.balance(&backer_b); + client.claim_penalty_refund(&backer_b); + assert_eq!(token_client.balance(&backer_b) - bb_before, 2_000); + + assert_eq!(client.penalty_pool_balance(), 0); +} + +#[test] +fn test_claim_penalty_refund_returns_no_share_when_pool_empty() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + let contract = env.register(CrowdfundContract, ()); + let client = CrowdfundContractClient::new(&env, &contract); + let org = Address::generate(&env); + let tok_admin = Address::generate(&env); + let tok = env.register_stellar_asset_contract_v2(tok_admin).address(); + let backer = Address::generate(&env); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&org, &tok, &10_000, &deadline); + client.set_penalty_bps(&1_000); // 10% + StellarAssetClient::new(&env, &tok).mint(&backer, &10_000); + client.contribute(&backer, &10_000); + // Approve penalty but DO NOT execute campaign -> no funds in pool. + let reason = soroban_sdk::String::from_str(&env, "bad"); + client.report_malicious(&backer, &reason); + client.vote_on_malice(&backer, &true); + env.ledger().with_mut(|l| { + l.timestamp = client.malice_vote_deadline().unwrap() + 1 + }); + client.resolve_malice_report(); + assert!(client.is_penalty_approved()); + assert_eq!(client.penalty_pool_balance(), 0); + // Pool empty -> backer cannot claim; second attempt after no change still fails. + let res = client.try_claim_penalty_refund(&backer); + assert!(res.is_err()); +} + +#[test] +#[should_panic] +fn test_claim_penalty_refund_before_approval_panics() { + let (env, _contract, client, _token, _organizer, contributor) = setup_penalty_baseline(); + client.claim_penalty_refund(&contributor); +} + +#[test] +fn test_sweep_unclaimed_penalty_before_window_panics() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + let contract = env.register(CrowdfundContract, ()); + let client = CrowdfundContractClient::new(&env, &contract); + let org = Address::generate(&env); + let tok_admin = Address::generate(&env); + let tok = env.register_stellar_asset_contract_v2(tok_admin).address(); + let backer = Address::generate(&env); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&org, &tok, &10_000, &deadline); + client.set_penalty_bps(&1_000); // 10% + StellarAssetClient::new(&env, &tok).mint(&backer, &10_000); + client.contribute(&backer, &10_000); + // Approve penalty, slash, but DO NOT perform sweep before window. + let reason = soroban_sdk::String::from_str(&env, "bad"); + client.report_malicious(&backer, &reason); + client.vote_on_malice(&backer, &true); + env.ledger().with_mut(|l| { + l.timestamp = client.malice_vote_deadline().unwrap() + 1 + }); + client.resolve_malice_report(); + client.is_penalty_approved(); + env.ledger().with_mut(|l| l.timestamp += 86_400 * 2); + // Only one backer exists and we want the unclaimed share to remain; do not + // claim any backer share — instead exercise the sweep precondition path. + let res = client.try_sweep_unclaimed_penalty(&Address::generate(&env)); + assert!(res.is_err()); // sweep unlock far in the future +} + +#[test] +fn test_release_milestone_does_not_apply_penalty_when_unapproved() { + let (env, _contract, client, token, organizer, contributor) = setup(); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&organizer, &token, &10_000, &deadline); + client.set_milestones(&soroban_sdk::vec![&env, 5_000_u32, 5_000_u32]); + StellarAssetClient::new(&env, &token).mint(&contributor, &10_000); + client.contribute(&contributor, &10_000); + env.ledger().with_mut(|l| l.timestamp += 86_401); + client.unlock_milestone(&0); + client.vote_milestone(&contributor, &0, &true); + let token_client = StellarAssetClient::new(&env, &token); + let before = token_client.balance(&organizer); + client.release_milestone(&0); + // No penalty approved yet -> organizer receives the full milestone slice. + assert_eq!(token_client.balance(&organizer) - before, 5_000); + assert_eq!(client.penalty_pool_balance(), 0); +} + +#[test] +fn test_release_milestone_slashes_after_malice_approval() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + let contract = env.register(CrowdfundContract, ()); + let client = CrowdfundContractClient::new(&env, &contract); + let org = Address::generate(&env); + let tok_admin = Address::generate(&env); + let tok = env.register_stellar_asset_contract_v2(tok_admin).address(); + let backer = Address::generate(&env); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&org, &tok, &10_000, &deadline); + client.set_penalty_bps(&2_000); // 20% + client.set_milestones(&soroban_sdk::vec![&env, 10_000_u32]); // single 100% slice + StellarAssetClient::new(&env, &tok).mint(&backer, &10_000); + client.contribute(&backer, &10_000); + // Approve penalty via full vote flow. + let reason = soroban_sdk::String::from_str(&env, "scam"); + client.report_malicious(&backer, &reason); + client.vote_on_malice(&backer, &true); + env.ledger().with_mut(|l| { + l.timestamp = client.malice_vote_deadline().unwrap() + 1 + }); + client.resolve_malice_report(); + assert!(client.is_penalty_approved()); + env.ledger().with_mut(|l| l.timestamp += 86_401); + // Release the milestone (single 100% slice = 10_000 raised). + client.unlock_milestone(&0); + // The backer is the only governance voter for milestones; pledge = 10_000 + // (> raised / 2 = 5_000) so the milestone is approved. + client.vote_milestone(&backer, &0, &true); + let token_client = StellarAssetClient::new(&env, &tok); + let org_before = token_client.balance(&org); + client.release_milestone(&0); + // 20% of 10_000 = 2_000 to pool, organizer receives 8_000. + assert_eq!(token_client.balance(&org) - org_before, 8_000); + assert_eq!(client.penalty_pool_balance(), 2_000); +} + +#[test] +fn test_execute_campaign_blocked_during_vote_window() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + let contract = env.register(CrowdfundContract, ()); + let client = CrowdfundContractClient::new(&env, &contract); + let org = Address::generate(&env); + let tok_admin = Address::generate(&env); + let tok = env.register_stellar_asset_contract_v2(tok_admin).address(); + let backer = Address::generate(&env); + let deadline = env.ledger().timestamp() + 86_400; + client.init_campaign(&org, &tok, &5_000, &deadline); + client.set_penalty_bps(&1_000); + StellarAssetClient::new(&env, &tok).mint(&backer, &5_000); + client.contribute(&backer, &5_000); + // File a malice report; keep timestamp inside the vote window. + let reason = soroban_sdk::String::from_str(&env, "bad"); + client.report_malicious(&backer, &reason); + env.ledger().with_mut(|l| l.timestamp += 86_401); + let res = client.try_execute_campaign(); + assert!(res.is_err()); // blocked because vote window still open +} From 572e9b54ba9644c156f5e84ea9dcce8643aaf8a5 Mon Sep 17 00:00:00 2001 From: goodness-cpu Date: Tue, 30 Jun 2026 08:00:25 +0100 Subject: [PATCH 2/2] Revert "feat(crowdfund): financial penalties for malicious campaigns" --- contracts/crowdfund/src/errors.rs | 26 -- contracts/crowdfund/src/lib.rs | 649 +----------------------------- contracts/crowdfund/src/test.rs | 412 ------------------- 3 files changed, 4 insertions(+), 1083 deletions(-) diff --git a/contracts/crowdfund/src/errors.rs b/contracts/crowdfund/src/errors.rs index 0b39fd7..6a16655 100644 --- a/contracts/crowdfund/src/errors.rs +++ b/contracts/crowdfund/src/errors.rs @@ -52,30 +52,4 @@ pub enum CrowdfundError { InsufficientMatchingPool = 26, // Pledge comment exceeds the configured maximum length. CommentTooLong = 27, - // Penalty basis points exceed the configured maximum (50%). - PenaltyBpsInvalid = 28, - // Penalty configuration is locked after the first contribution. - PenaltyLocked = 29, - // A malicious campaign report is already active. - MaliceReportActive = 30, - // No active malicious campaign report exists. - NoMaliceReport = 31, - // Penalty voting is still inside the active window. - MaliceVoteWindowActive = 32, - // Penalty voting window has expired; resolution time. - MaliceVoteWindowExpired = 33, - // Penalty voting is closed but the proposal was not approved. - PenaltyNotApproved = 34, - // The penalty pool does not hold enough to cover the requested payout. - InsufficientPenaltyPool = 35, - // Caller is not the organizer (used to gate organizer-only flows). - NotOrganizer = 36, - // Reporter does not hold enough pledge to file a malice report. - InsufficientReporterStake = 37, - // Penalty sweep is attempted before the unclaimed window has elapsed. - PenaltySweepTooEarly = 38, - // Attempting to claim zero or negative penalty share. - NoPenaltyShareAvailable = 39, - // A backer can only vote once on the active malice penalty proposal. - PenaltyVoteAlreadyCast = 40, } diff --git a/contracts/crowdfund/src/lib.rs b/contracts/crowdfund/src/lib.rs index d6bc81a..4b938ff 100644 --- a/contracts/crowdfund/src/lib.rs +++ b/contracts/crowdfund/src/lib.rs @@ -103,54 +103,6 @@ pub struct BatchRefundProcessedEvent { pub contributor_count: u32, } -// ── Financial penalty events (#360) ────────────────────────────────────────── -#[contractevent] -pub struct PenaltyConfiguredEvent { - pub bps: u32, -} - -#[contractevent] -pub struct MaliciousReportFiledEvent { - pub reporter: Address, - pub reason: String, - pub vote_deadline: u64, -} - -#[contractevent] -pub struct MaliceVoteCastEvent { - pub voter: Address, - pub approve: bool, - pub weight: i128, -} - -#[contractevent] -pub struct MaliceReportResolvedEvent { - pub approved: bool, - pub approval_weight: i128, - pub rejection_weight: i128, - pub snapshot_raised: i128, -} - -#[contractevent] -pub struct PenaltySlashedEvent { - pub amount: i128, - pub source: Address, - pub pool_balance: i128, -} - -#[contractevent] -pub struct PenaltyRefundClaimedEvent { - pub backer: Address, - pub amount: i128, - pub remaining_pool: i128, -} - -#[contractevent] -pub struct PenaltySweptEvent { - pub recipient: Address, - pub amount: i128, -} - #[contracttype] enum DataKey { Organizer, @@ -197,41 +149,6 @@ enum DataKey { MatchingPool, // Public comment attached to a contributor pledge. PledgeComment(Address), - // ── Financial penalty for malicious campaigns (#360) ───────────────────── - /// Penalty in basis points (max 5_000 = 50%). Locked after first pledge. - PenaltyBps, - /// True once `PenaltyBps` is locked and cannot be modified. - PenaltyLocked, - /// Unix timestamp when the active malice voting window opens. - MaliceVoteStart, - /// Unix timestamp when the active malice voting window closes. - MaliceVoteDeadline, - /// Address that filed the active malice report. - MaliceReporter, - /// Off-chain evidence / reason string supplied by the reporter. - MaliceReason, - /// Cumulative pledge-weighted approvals for the active malice report. - PenaltyApprovalWeight, - /// Cumulative pledge-weighted rejections for the active malice report. - PenaltyRejectionWeight, - /// Tracks whether a backer has voted on the active malice report. - PenaltyVote(Address), - /// True when the active report has been resolved. - PenaltyResolved, - /// Outcome of the resolution: true if penalty was approved by voters. - PenaltyApproved, - /// Current balance of the on-chain penalty pool (slashed tokens). - PenaltyPool, - /// `Raised` snapshot taken at the moment the penalty was approved. - PenaltySnapshotRaised, - /// Sum of all backer penalty refunds already distributed. - PenaltyTotalClaimed, - /// Amount of penalty pool already claimed by a specific backer. - BackerPenaltyClaimed(Address), - /// Unix timestamp at which unclaimed penalty becomes sweepable. - PenaltySweepUnlock, - /// Address authorized to receive unclaimed penalty on sweep. - PenaltyRecipient, } #[contract] @@ -240,26 +157,6 @@ pub struct CrowdfundContract; #[contractimpl] impl CrowdfundContract { const MAX_COMMENT_BYTES: u32 = 280; - /// Maximum self-imposed penalty (50% of payout) for malicious campaigns. - const MAX_PENALTY_BPS: u32 = 5_000; - /// Length of the malice voting window (7 days). - const PENALTY_VOTE_WINDOW: u64 = 604_800; - /// Time after resolution that unclaimed penalty may be swept (≈6 months). - const PENALTY_SWEEP_AFTER_SECS: u64 = 15_778_800; - /// Reporter must hold at least this fraction of raised (basis points) to - /// open a voting window — anti-griefing floor (default 1% = 100 bps). - const MIN_REPORTER_PLEDGE_BPS: u32 = 100; - - // ── Penalty helpers (#360) ───────────────────────────────────────────────── - /// Returns true if a malice report is currently in flight. - fn malice_report_active(env: &Env) -> bool { - env.storage().persistent().has(&DataKey::MaliceVoteStart) - && !env - .storage() - .persistent() - .get(&DataKey::PenaltyResolved) - .unwrap_or(false) - } /// Initialise a campaign. Sets the funding goal (in token base units) /// and the deadline (Unix timestamp after which no contributions are /// accepted). Only callable once. @@ -461,9 +358,6 @@ impl CrowdfundContract { } /// Withdraw funds to the organizer after deadline if goal was met (#303). - /// If a financial penalty was approved by backer vote and a non-zero - /// `PenaltyBps` is set, the slashed portion is detained in the on-chain - /// penalty pool instead of being paid out. pub fn execute_campaign(env: Env) { let organizer: Address = env .storage() @@ -483,19 +377,6 @@ impl CrowdfundContract { panic_with_error!(&env, CrowdfundError::CampaignNotEnded); } - // Block withdrawal while a malice proposal is still being voted on so - // the organizer cannot front-run the penalty (#360). - if Self::malice_report_active(&env) { - let deadline_ts: u64 = env - .storage() - .persistent() - .get(&DataKey::MaliceVoteDeadline) - .unwrap_or(0); - if env.ledger().timestamp() <= deadline_ts { - panic_with_error!(&env, CrowdfundError::MaliceVoteWindowActive); - } - } - let goal: i128 = env .storage() .persistent() @@ -536,26 +417,10 @@ impl CrowdfundContract { .unwrap_or_else(|| panic_with_error!(&env, CrowdfundError::NotInitialized)); let contract_addr = env.current_contract_address(); - - // Compute and lock away penalty amount if approved (#360). - let (payout, slashed) = Self::compute_and_lock_penalty(&env, raised); - if slashed > 0 { - PenaltySlashedEvent { - amount: slashed, - source: organizer.clone(), - pool_balance: env - .storage() - .persistent() - .get(&DataKey::PenaltyPool) - .unwrap_or(0), - } - .publish(&env); - } - token::TokenClient::new(&env, &token_addr) - .transfer(&contract_addr, &organizer, &payout); + .transfer(&contract_addr, &organizer, &raised); - CampaignExecutedEvent { amount: payout }.publish(&env); + CampaignExecutedEvent { amount: raised }.publish(&env); } /// Allow a backer to reclaim their pledge after deadline if goal was not met (#304). @@ -881,8 +746,6 @@ impl CrowdfundContract { /// Release the proportional funds for an unlocked, unreleased milestone to the organizer. /// Can only be called after the campaign deadline and goal is met. - /// If a financial penalty was approved by backer vote, the matching - /// slice is detained in the on-chain penalty pool (#360). pub fn release_milestone(env: Env, index: u32) { let organizer: Address = env .storage() @@ -902,18 +765,6 @@ impl CrowdfundContract { panic_with_error!(&env, CrowdfundError::CampaignNotEnded); } - // Block release while a malice proposal is still being voted on (#360). - if Self::malice_report_active(&env) { - let vote_dl: u64 = env - .storage() - .persistent() - .get(&DataKey::MaliceVoteDeadline) - .unwrap_or(0); - if env.ledger().timestamp() <= vote_dl { - panic_with_error!(&env, CrowdfundError::MaliceVoteWindowActive); - } - } - let goal: i128 = env .storage() .persistent() @@ -984,30 +835,10 @@ impl CrowdfundContract { .unwrap_or_else(|| panic_with_error!(&env, CrowdfundError::NotInitialized)); let contract_addr = env.current_contract_address(); - - // Apply milestone-scoped penalty to the released amount (#360). - let (payout, slashed) = Self::compute_and_lock_penalty(&env, amount); - if slashed > 0 { - PenaltySlashedEvent { - amount: slashed, - source: organizer.clone(), - pool_balance: env - .storage() - .persistent() - .get(&DataKey::PenaltyPool) - .unwrap_or(0), - } - .publish(&env); - } - token::TokenClient::new(&env, &token_addr) - .transfer(&contract_addr, &organizer, &payout); + .transfer(&contract_addr, &organizer, &amount); - MilestoneReleasedEvent { - index, - amount: payout, - } - .publish(&env); + MilestoneReleasedEvent { index, amount }.publish(&env); } /// Returns the pledge amount recorded for a given contributor. @@ -1070,432 +901,6 @@ impl CrowdfundContract { raised >= goal } - // ── Financial penalties for malicious campaigns (#360) ───────────────────── - - /// Set the self-imposed penalty basis points (≤ `MAX_PENALTY_BPS`) that - /// the organizer commits to if backers vote the campaign malicious. - /// Must be called **before** the first pledge lands (penalty is locked - /// permanently once any contribution arrives) and **before** a malice - /// proposal is filed. Pure organizer signal — values can be repeatedly - /// re-tuned while still unlocked. - pub fn set_penalty_bps(env: Env, bps: u32) { - let organizer: Address = env - .storage() - .persistent() - .get(&DataKey::Organizer) - .unwrap_or_else(|| panic_with_error!(&env, CrowdfundError::NotInitialized)); - organizer.require_auth(); - if env - .storage() - .persistent() - .get(&DataKey::PenaltyLocked) - .unwrap_or(false) - { - panic_with_error!(&env, CrowdfundError::PenaltyLocked); - } - if bps > Self::MAX_PENALTY_BPS { - panic_with_error!(&env, CrowdfundError::PenaltyBpsInvalid); - } - if Self::malice_report_active(&env) { - panic_with_error!(&env, CrowdfundError::MaliceReportActive); - } - env.storage().persistent().set(&DataKey::PenaltyBps, &bps); - PenaltyConfiguredEvent { bps }.publish(&env); - } - - /// File a malicious-campaign report. Must be invoked by a backer whose - /// pledge is at least `MIN_REPORTER_PLEDGE_BPS` of the currently raised - /// amount (anti-griefing floor). Organizers may not file against - /// themselves. Opens the `PENALTY_VOTE_WINDOW`-long voting session. - pub fn report_malicious(env: Env, reporter: Address, reason: String) { - reporter.require_auth(); - if Self::malice_report_active(&env) { - panic_with_error!(&env, CrowdfundError::MaliceReportActive); - } - let organizer: Address = env - .storage() - .persistent() - .get(&DataKey::Organizer) - .unwrap_or_else(|| panic_with_error!(&env, CrowdfundError::NotInitialized)); - if reporter == organizer { - panic_with_error!(&env, CrowdfundError::NotOrganizer); - } - let pledge: i128 = env - .storage() - .persistent() - .get(&DataKey::Pledge(reporter.clone())) - .unwrap_or(0); - if pledge <= 0 { - panic_with_error!(&env, CrowdfundError::NoPledge); - } - let raised: i128 = env - .storage() - .persistent() - .get(&DataKey::Raised) - .unwrap_or(0); - let required = raised.saturating_mul(Self::MIN_REPORTER_PLEDGE_BPS as i128) / 10_000; - if pledge < required { - panic_with_error!(&env, CrowdfundError::InsufficientReporterStake); - } - let now = env.ledger().timestamp(); - env.storage() - .persistent() - .set(&DataKey::MaliceVoteStart, &now); - env.storage().persistent().set( - &DataKey::MaliceVoteDeadline, - &now.saturating_add(Self::PENALTY_VOTE_WINDOW), - ); - env.storage() - .persistent() - .set(&DataKey::MaliceReporter, &reporter.clone()); - env.storage() - .persistent() - .set(&DataKey::MaliceReason, &reason.clone()); - env.storage() - .persistent() - .set(&DataKey::PenaltyApprovalWeight, &0_i128); - env.storage() - .persistent() - .set(&DataKey::PenaltyRejectionWeight, &0_i128); - env.storage() - .persistent() - .set(&DataKey::PenaltyResolved, &false); - env.storage() - .persistent() - .set(&DataKey::PenaltyApproved, &false); - MaliciousReportFiledEvent { - reporter, - reason, - vote_deadline: now.saturating_add(Self::PENALTY_VOTE_WINDOW), - } - .publish(&env); - } - - /// Cast a backer's weighted vote on the active malice report. Vote window - /// must still be open; one vote per backer (no coercion). - pub fn vote_on_malice(env: Env, voter: Address, approve: bool) { - voter.require_auth(); - if !Self::malice_report_active(&env) { - panic_with_error!(&env, CrowdfundError::NoMaliceReport); - } - let deadline: u64 = env - .storage() - .persistent() - .get(&DataKey::MaliceVoteDeadline) - .unwrap_or_else(|| panic_with_error!(&env, CrowdfundError::NoMaliceReport)); - if env.ledger().timestamp() > deadline { - panic_with_error!(&env, CrowdfundError::MaliceVoteWindowExpired); - } - let vote_key = DataKey::PenaltyVote(voter.clone()); - if env.storage().persistent().has(&vote_key) { - panic_with_error!(&env, CrowdfundError::PenaltyVoteAlreadyCast); - } - let weight: i128 = env - .storage() - .persistent() - .get(&DataKey::Pledge(voter.clone())) - .unwrap_or(0); - if weight <= 0 { - panic_with_error!(&env, CrowdfundError::NotBacker); - } - let tally_key = if approve { - DataKey::PenaltyApprovalWeight - } else { - DataKey::PenaltyRejectionWeight - }; - let current: i128 = env.storage().persistent().get(&tally_key).unwrap_or(0); - env.storage() - .persistent() - .set(&tally_key, ¤t.saturating_add(weight)); - env.storage().persistent().set(&vote_key, &approve); - MaliceVoteCastEvent { - voter, - approve, - weight, - } - .publish(&env); - } - - /// Finalize the active malice report. Callable by anyone once the vote - /// window has closed. Approved iff approval weight is a strict majority - /// of `Raised` at the moment of resolution. Approval locks-in the penalty - /// to be applied on the next `execute_campaign` / `release_milestone`. - pub fn resolve_malice_report(env: Env) { - if !Self::malice_report_active(&env) { - panic_with_error!(&env, CrowdfundError::NoMaliceReport); - } - let deadline: u64 = env - .storage() - .persistent() - .get(&DataKey::MaliceVoteDeadline) - .unwrap_or_else(|| panic_with_error!(&env, CrowdfundError::NoMaliceReport)); - if env.ledger().timestamp() <= deadline { - panic_with_error!(&env, CrowdfundError::MaliceVoteWindowActive); - } - let approval: i128 = env - .storage() - .persistent() - .get(&DataKey::PenaltyApprovalWeight) - .unwrap_or(0); - let rejection: i128 = env - .storage() - .persistent() - .get(&DataKey::PenaltyRejectionWeight) - .unwrap_or(0); - let raised: i128 = env - .storage() - .persistent() - .get(&DataKey::Raised) - .unwrap_or(0); - let approved = approval > (raised / 2); - - env.storage() - .persistent() - .set(&DataKey::PenaltyResolved, &true); - env.storage() - .persistent() - .set(&DataKey::PenaltyApproved, &approved); - env.storage() - .persistent() - .set(&DataKey::PenaltySnapshotRaised, &raised); - env.storage().persistent().set( - &DataKey::PenaltySweepUnlock, - &env - .ledger() - .timestamp() - .saturating_add(Self::PENALTY_SWEEP_AFTER_SECS), - ); - - MaliceReportResolvedEvent { - approved, - approval_weight: approval, - rejection_weight: rejection, - snapshot_raised: raised, - } - .publish(&env); - } - - /// Pro-rata claim from the penalty pool. Each backer is entitled to - /// `(pledge_at_resolution / PenaltySnapshotRaised) * PoolBalance`, minus - /// what they have already claimed. Safe against reentrancy (state is - /// mutated before the cross-contract token transfer). - pub fn claim_penalty_refund(env: Env, backer: Address) { - backer.require_auth(); - let approved: bool = env - .storage() - .persistent() - .get(&DataKey::PenaltyApproved) - .unwrap_or(false); - if !approved { - panic_with_error!(&env, CrowdfundError::PenaltyNotApproved); - } - let pledge: i128 = env - .storage() - .persistent() - .get(&DataKey::Pledge(backer.clone())) - .unwrap_or(0); - if pledge <= 0 { - panic_with_error!(&env, CrowdfundError::NotBacker); - } - let pool: i128 = env - .storage() - .persistent() - .get(&DataKey::PenaltyPool) - .unwrap_or(0); - let snapshot: i128 = env - .storage() - .persistent() - .get(&DataKey::PenaltySnapshotRaised) - .unwrap_or(0); - if pool <= 0 || snapshot <= 0 { - panic_with_error!(&env, CrowdfundError::NoPenaltyShareAvailable); - } - let total_share = pledge.saturating_mul(pool) / snapshot; - let already: i128 = env - .storage() - .persistent() - .get(&DataKey::BackerPenaltyClaimed(backer.clone())) - .unwrap_or(0); - let claimable = total_share.saturating_sub(already); - if claimable <= 0 { - panic_with_error!(&env, CrowdfundError::NoPenaltyShareAvailable); - } - // Checks-Effects-Interactions: write storage before token transfer. - env.storage().persistent().set( - &DataKey::BackerPenaltyClaimed(backer.clone()), - &already.saturating_add(claimable), - ); - let total_claimed: i128 = env - .storage() - .persistent() - .get(&DataKey::PenaltyTotalClaimed) - .unwrap_or(0); - env.storage().persistent().set( - &DataKey::PenaltyTotalClaimed, - &total_claimed.saturating_add(claimable), - ); - let token_addr: Address = env - .storage() - .persistent() - .get(&DataKey::Token) - .unwrap_or_else(|| panic_with_error!(&env, CrowdfundError::NotInitialized)); - let contract_addr = env.current_contract_address(); - token::TokenClient::new(&env, &token_addr) - .transfer(&contract_addr, &backer, &claimable); - let remaining = pool.saturating_sub(claimable); - PenaltyRefundClaimedEvent { - backer, - amount: claimable, - remaining_pool: remaining, - } - .publish(&env); - } - - /// Sweep unclaimed penalty balance to `recipient` once the configured - /// sweep window has elapsed. Anyone may trigger this — useful for - /// protocol governance / treasury consolidation. - pub fn sweep_unclaimed_penalty(env: Env, recipient: Address) { - let approved: bool = env - .storage() - .persistent() - .get(&DataKey::PenaltyApproved) - .unwrap_or(false); - if !approved { - panic_with_error!(&env, CrowdfundError::PenaltyNotApproved); - } - let unlock: u64 = env - .storage() - .persistent() - .get(&DataKey::PenaltySweepUnlock) - .unwrap_or_else(|| panic_with_error!(&env, CrowdfundError::PenaltySweepTooEarly)); - if env.ledger().timestamp() < unlock { - panic_with_error!(&env, CrowdfundError::PenaltySweepTooEarly); - } - let pool: i128 = env - .storage() - .persistent() - .get(&DataKey::PenaltyPool) - .unwrap_or(0); - let total_claimed: i128 = env - .storage() - .persistent() - .get(&DataKey::PenaltyTotalClaimed) - .unwrap_or(0); - let sweepable = pool.saturating_sub(total_claimed); - if sweepable <= 0 { - return; - } - env.storage() - .persistent() - .set(&DataKey::PenaltyPool, &total_claimed); - env.storage() - .persistent() - .set(&DataKey::PenaltyRecipient, &recipient); - let token_addr: Address = env - .storage() - .persistent() - .get(&DataKey::Token) - .unwrap_or_else(|| panic_with_error!(&env, CrowdfundError::NotInitialized)); - let contract_addr = env.current_contract_address(); - token::TokenClient::new(&env, &token_addr) - .transfer(&contract_addr, &recipient, &sweepable); - PenaltySweptEvent { - recipient, - amount: sweepable, - } - .publish(&env); - } - - // ── Penalty view accessors (#360) ───────────────────────────────────────── - - pub fn get_penalty_bps(env: Env) -> u32 { - env.storage().persistent().get(&DataKey::PenaltyBps).unwrap_or(0) - } - - pub fn penalty_locked(env: Env) -> bool { - env.storage() - .persistent() - .get(&DataKey::PenaltyLocked) - .unwrap_or(false) - } - - pub fn malice_vote_start(env: Env) -> Option { - env.storage().persistent().get(&DataKey::MaliceVoteStart) - } - - pub fn malice_vote_deadline(env: Env) -> Option { - env.storage().persistent().get(&DataKey::MaliceVoteDeadline) - } - - pub fn malice_reporter(env: Env) -> Option
{ - env.storage().persistent().get(&DataKey::MaliceReporter) - } - - pub fn malice_reason(env: Env) -> Option { - env.storage().persistent().get(&DataKey::MaliceReason) - } - - pub fn penalty_vote_counts(env: Env) -> (i128, i128) { - ( - env.storage() - .persistent() - .get(&DataKey::PenaltyApprovalWeight) - .unwrap_or(0), - env.storage() - .persistent() - .get(&DataKey::PenaltyRejectionWeight) - .unwrap_or(0), - ) - } - - pub fn is_malice_report_active(env: Env) -> bool { - Self::malice_report_active(&env) - } - - pub fn is_penalty_approved(env: Env) -> bool { - env.storage() - .persistent() - .get(&DataKey::PenaltyApproved) - .unwrap_or(false) - } - - pub fn penalty_pool_balance(env: Env) -> i128 { - env.storage() - .persistent() - .get(&DataKey::PenaltyPool) - .unwrap_or(0) - } - - pub fn penalty_snapshot_raised(env: Env) -> i128 { - env.storage() - .persistent() - .get(&DataKey::PenaltySnapshotRaised) - .unwrap_or(0) - } - - pub fn backer_penalty_claimed(env: Env, backer: Address) -> i128 { - env.storage() - .persistent() - .get(&DataKey::BackerPenaltyClaimed(backer)) - .unwrap_or(0) - } - - pub fn penalty_sweep_unlock(env: Env) -> Option { - env.storage().persistent().get(&DataKey::PenaltySweepUnlock) - } - - pub fn max_penalty_bps() -> u32 { - Self::MAX_PENALTY_BPS - } - - pub fn penalty_vote_window() -> u64 { - Self::PENALTY_VOTE_WINDOW - } - - pub fn penalty_sweep_after() -> u64 { - Self::PENALTY_SWEEP_AFTER_SECS - } - // ── Internal helpers ────────────────────────────────────────────────────── /// Emit a `stretch / reached` event for each milestone crossed by `new_raised` @@ -1571,52 +976,6 @@ impl CrowdfundContract { .persistent() .set(&DataKey::Pledge(contributor), &prev_pledge.saturating_add(effective_amount)); - // Lock the self-imposed penalty rate after the first pledge lands so - // backers can rely on the trust signal (#360). - if !env - .storage() - .persistent() - .get(&DataKey::PenaltyLocked) - .unwrap_or(false) - { - env.storage() - .persistent() - .set(&DataKey::PenaltyLocked, &true); - } - new_raised } - - /// Compute the penalty slice for `amount`, credit the pool, and return - /// `(payout_to_organizer, slashed_to_pool)`. Identity when no penalty is - /// configured/approved (#360). - fn compute_and_lock_penalty(env: &Env, amount: i128) -> (i128, i128) { - let approved: bool = env - .storage() - .persistent() - .get(&DataKey::PenaltyApproved) - .unwrap_or(false); - if !approved { - return (amount, 0); - } - let bps: u32 = env - .storage() - .persistent() - .get(&DataKey::PenaltyBps) - .unwrap_or(0); - if bps == 0 || amount <= 0 { - return (amount, 0); - } - let slashed = amount.saturating_mul(bps as i128) / 10_000; - let payout = amount.saturating_sub(slashed); - let pool: i128 = env - .storage() - .persistent() - .get(&DataKey::PenaltyPool) - .unwrap_or(0); - env.storage() - .persistent() - .set(&DataKey::PenaltyPool, &pool.saturating_add(slashed)); - (payout, slashed) - } } diff --git a/contracts/crowdfund/src/test.rs b/contracts/crowdfund/src/test.rs index 3e27771..55e7dd1 100644 --- a/contracts/crowdfund/src/test.rs +++ b/contracts/crowdfund/src/test.rs @@ -956,415 +956,3 @@ fn test_leave_comment_requires_existing_pledge() { let comment = soroban_sdk::String::from_str(&env, "No pledge yet"); client.leave_comment(&contributor, &comment); } - -// ── #360 – Financial penalties for malicious campaigns ─────────────────── - -fn setup_penalty_baseline() -> ( - Env, - Address, - CrowdfundContractClient<'static>, - Address, - Address, - Address, -) { - let (env, contract, client, token, organizer, contributor) = setup(); - let deadline = env.ledger().timestamp() + 86_400; - client.init_campaign(&organizer, &token, &10_000, &deadline); - StellarAssetClient::new(&env, &token).mint(&contributor, &4_000); - client.contribute(&contributor, &4_000); - (env, contract, client, token, organizer, contributor) -} - -#[test] -fn test_set_penalty_bps_records_and_emits() { - let (env, _contract, client, _token, organizer, _contributor) = setup(); - let deadline = env.ledger().timestamp() + 86_400; - let token_admin = Address::generate(&env); - let test_token = env.register_stellar_asset_contract_v2(token_admin).address(); - client.init_campaign(&organizer, &test_token, &1_000, &deadline); - // Penalty is locked lazily after first pledge; pre-pledge it is editable. - client.set_penalty_bps(&2_000); // 20% - assert_eq!(client.get_penalty_bps(), 2_000); - assert!(!client.penalty_locked()); -} - -#[test] -#[should_panic] -fn test_set_penalty_bps_above_max_panics() { - let (env, _contract, client, token, organizer, _) = setup(); - let deadline = env.ledger().timestamp() + 86_400; - client.init_campaign(&organizer, &token, &1_000, &deadline); - // 6_000 bps = 60% > MAX_PENALTY_BPS - client.set_penalty_bps(&6_000); -} - -#[test] -#[should_panic] -fn test_set_penalty_bps_blocked_after_first_pledge() { - let (env, _contract, client, token, organizer, contributor) = setup(); - let deadline = env.ledger().timestamp() + 86_400; - client.init_campaign(&organizer, &token, &1_000, &deadline); - StellarAssetClient::new(&env, &token).mint(&contributor, &500); - client.contribute(&contributor, &500); - // After PledgeLocked -> panic. - client.set_penalty_bps(&1_000); -} - -#[test] -fn test_penalty_bps_locks_after_contribution() { - let (env, _contract, client, token, organizer, contributor) = setup(); - let deadline = env.ledger().timestamp() + 86_400; - client.init_campaign(&organizer, &token, &1_000, &deadline); - client.set_penalty_bps(&1_500); - StellarAssetClient::new(&env, &token).mint(&contributor, &500); - client.contribute(&contributor, &500); - assert!(client.penalty_locked()); - // Stored value still readable. - assert_eq!(client.get_penalty_bps(), 1_500); -} - -#[test] -fn test_report_malicious_opens_vote_window() { - let (env, _contract, client, token, organizer, contributor) = setup(); - let backer2 = Address::generate(&env); - let deadline = env.ledger().timestamp() + 86_400; - client.init_campaign(&organizer, &token, &10_000, &deadline); - StellarAssetClient::new(&env, &token).mint(&contributor, &300); - StellarAssetClient::new(&env, &token).mint(&backer2, &200); - client.contribute(&contributor, &300); - client.contribute(&backer2, &200); - // 500 / 500 = 100% > 1% floor -> allowed. - let reason = soroban_sdk::String::from_str(&env, "Organizer disappeared"); - client.report_malicious(&contributor, &reason); - assert!(client.is_malice_report_active()); - assert_eq!(client.malice_reporter(), Some(contributor.clone())); - assert_eq!(client.malice_reason(), Some(reason.clone())); - let vote_dl = client.malice_vote_deadline().unwrap(); - assert!(vote_dl > env.ledger().timestamp()); -} - -#[test] -#[should_panic] -fn test_report_malicious_requires_min_stake() { - let (env, _contract, client, token, organizer, contributor_a) = setup(); - let contributor_b = Address::generate(&env); - let big = Address::generate(&env); - let deadline = env.ledger().timestamp() + 86_400; - client.init_campaign(&organizer, &token, &10_000, &deadline); - let sac = StellarAssetClient::new(&env, &token); - sac.mint(&big, &9_900); - sac.mint(&contributor_a, &50); - sac.mint(&contributor_b, &50); - client.contribute(&big, &9_900); - client.contribute(&contributor_a, &50); - client.contribute(&contributor_b, &50); - // Raised=10_000. A's pledge is 50 = 0.5% < 1% floor -> panic. - let reason = soroban_sdk::String::from_str(&env, "weak"); - client.report_malicious(&contributor_a, &reason); -} - -#[test] -#[should_panic] -fn test_report_malicious_cannot_self_report() { - let (env, _contract, client, token, organizer, _contributor) = setup(); - let deadline = env.ledger().timestamp() + 86_400; - client.init_campaign(&organizer, &token, &1_000, &deadline); - let reason = soroban_sdk::String::from_str(&env, "self"); - client.report_malicious(&organizer, &reason); -} - -#[test] -fn test_vote_on_malice_records_weight_once() { - let (env, _contract, client, _token, _organizer, contributor) = setup_penalty_baseline(); - let voter2 = Address::generate(&env); - StellarAssetClient::new(&env, &_token).mint(&voter2, &1_000); - client.contribute(&voter2, &1_000); - let reason = soroban_sdk::String::from_str(&env, "bad"); - client.report_malicious(&contributor, &reason); - client.vote_on_malice(&contributor, &true); - client.vote_on_malice(&voter2, &false); - let (ap, rj) = client.penalty_vote_counts(); - assert_eq!(ap, 4_000); - assert_eq!(rj, 1_000); -} - -#[test] -#[should_panic] -fn test_vote_on_malice_double_vote_panics() { - let (env, _contract, client, _token, _organizer, contributor) = setup_penalty_baseline(); - let reason = soroban_sdk::String::from_str(&env, "bad"); - client.report_malicious(&contributor, &reason); - client.vote_on_malice(&contributor, &true); - client.vote_on_malice(&contributor, &false); // already cast -} - -#[test] -#[should_panic] -fn test_vote_on_malice_after_window_panics() { - let (env, _contract, client, _token, _organizer, contributor) = setup_penalty_baseline(); - let reason = soroban_sdk::String::from_str(&env, "bad"); - client.report_malicious(&contributor, &reason); - env.ledger().with_mut(|l| { - l.timestamp = client.malice_vote_deadline().unwrap() + 1 - }); - client.vote_on_malice(&contributor, &true); -} - -#[test] -fn test_resolve_malice_approves_on_majority() { - let (env, _contract, client, _token, _organizer, contributor) = setup_penalty_baseline(); - let backer2 = Address::generate(&env); - let backer3 = Address::generate(&env); - let sac = StellarAssetClient::new(&env, &_token); - sac.mint(&backer2, &3_000); - sac.mint(&backer3, &3_000); - client.contribute(&backer2, &3_000); - client.contribute(&backer3, &3_000); - let reason = soroban_sdk::String::from_str(&env, "bad"); - client.report_malicious(&contributor, &reason); - // 4_000 approve, 6_000 reject out of 10_000 raised. - client.vote_on_malice(&contributor, &true); // 4_000 yes - client.vote_on_malice(&backer2, &false); // 3_000 no - client.vote_on_malice(&backer3, &false); // +3_000 = 6_000 no - env.ledger().with_mut(|l| { - l.timestamp = client.malice_vote_deadline().unwrap() + 1 - }); - client.resolve_malice_report(); - assert!(!client.is_penalty_approved()); - assert_eq!(client.penalty_snapshot_raised(), 10_000); -} - -#[test] -fn test_resolve_malice_requires_window_close() { - let (env, _contract, client, _token, _organizer, contributor) = setup_penalty_baseline(); - let reason = soroban_sdk::String::from_str(&env, "bad"); - client.report_malicious(&contributor, &reason); - client.vote_on_malice(&contributor, &true); - env.ledger().with_mut(|l| { - l.timestamp = client.malice_vote_deadline().unwrap() - 10 - }); - let res = client.try_resolve_malice_report(); - assert!(res.is_err()); -} - -#[test] -fn test_execute_campaign_keeps_full_payout_when_no_penalty() { - let (env, _contract, client, token, organizer, contributor) = setup(); - let deadline = env.ledger().timestamp() + 100; - client.init_campaign(&organizer, &token, &1_000, &deadline); - StellarAssetClient::new(&env, &token).mint(&contributor, &1_000); - client.contribute(&contributor, &1_000); - env.ledger().with_mut(|l| l.timestamp += 200); - let before = StellarAssetClient::new(&env, &token).balance(&organizer); - client.execute_campaign(); - assert_eq!( - StellarAssetClient::new(&env, &token).balance(&organizer) - before, - 1_000 - ); - assert_eq!(client.penalty_pool_balance(), 0); -} - -#[test] -fn test_execute_campaign_slashes_after_malice_approval() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1_000_000); - let contract = env.register(CrowdfundContract, ()); - let client = CrowdfundContractClient::new(&env, &contract); - let org = Address::generate(&env); - let tok_admin = Address::generate(&env); - let tok = env.register_stellar_asset_contract_v2(tok_admin).address(); - let backer_a = Address::generate(&env); - let backer_b = Address::generate(&env); - let deadline = env.ledger().timestamp() + 86_400; - client.init_campaign(&org, &tok, &50_000, &deadline); - client.set_penalty_bps(&1_000); // 10% - let sac = StellarAssetClient::new(&env, &tok); - sac.mint(&backer_a, &30_000); - sac.mint(&backer_b, &20_000); - client.contribute(&backer_a, &30_000); - client.contribute(&backer_b, &20_000); - // Drive penalty approval through the full vote flow. - let reason = soroban_sdk::String::from_str(&env, "scam"); - client.report_malicious(&backer_a, &reason); - client.vote_on_malice(&backer_a, &true); // 30_000 approve - client.vote_on_malice(&backer_b, &true); // 20_000 approve - env.ledger().with_mut(|l| { - l.timestamp = client.malice_vote_deadline().unwrap() + 1 - }); - client.resolve_malice_report(); - assert!(client.is_penalty_approved()); - - env.ledger().with_mut(|l| l.timestamp += 86_400 * 2); - let token_client = StellarAssetClient::new(&env, &tok); - let org_before = token_client.balance(&org); - client.execute_campaign(); - // 10% of 50_000 = 5_000 to pool, organizer receives 45_000. - assert_eq!(token_client.balance(&org) - org_before, 45_000); - assert_eq!(client.penalty_pool_balance(), 5_000); - - // Backer A holds 60% of pledge -> claim 5_000 * (30_000 / 50_000) = 3_000. - let ba_before = token_client.balance(&backer_a); - client.claim_penalty_refund(&backer_a); - assert_eq!(token_client.balance(&backer_a) - ba_before, 3_000); - - // Backer B holds 40% of pledge -> claim 5_000 * (20_000 / 50_000) = 2_000. - let bb_before = token_client.balance(&backer_b); - client.claim_penalty_refund(&backer_b); - assert_eq!(token_client.balance(&backer_b) - bb_before, 2_000); - - assert_eq!(client.penalty_pool_balance(), 0); -} - -#[test] -fn test_claim_penalty_refund_returns_no_share_when_pool_empty() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1_000_000); - let contract = env.register(CrowdfundContract, ()); - let client = CrowdfundContractClient::new(&env, &contract); - let org = Address::generate(&env); - let tok_admin = Address::generate(&env); - let tok = env.register_stellar_asset_contract_v2(tok_admin).address(); - let backer = Address::generate(&env); - let deadline = env.ledger().timestamp() + 86_400; - client.init_campaign(&org, &tok, &10_000, &deadline); - client.set_penalty_bps(&1_000); // 10% - StellarAssetClient::new(&env, &tok).mint(&backer, &10_000); - client.contribute(&backer, &10_000); - // Approve penalty but DO NOT execute campaign -> no funds in pool. - let reason = soroban_sdk::String::from_str(&env, "bad"); - client.report_malicious(&backer, &reason); - client.vote_on_malice(&backer, &true); - env.ledger().with_mut(|l| { - l.timestamp = client.malice_vote_deadline().unwrap() + 1 - }); - client.resolve_malice_report(); - assert!(client.is_penalty_approved()); - assert_eq!(client.penalty_pool_balance(), 0); - // Pool empty -> backer cannot claim; second attempt after no change still fails. - let res = client.try_claim_penalty_refund(&backer); - assert!(res.is_err()); -} - -#[test] -#[should_panic] -fn test_claim_penalty_refund_before_approval_panics() { - let (env, _contract, client, _token, _organizer, contributor) = setup_penalty_baseline(); - client.claim_penalty_refund(&contributor); -} - -#[test] -fn test_sweep_unclaimed_penalty_before_window_panics() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1_000_000); - let contract = env.register(CrowdfundContract, ()); - let client = CrowdfundContractClient::new(&env, &contract); - let org = Address::generate(&env); - let tok_admin = Address::generate(&env); - let tok = env.register_stellar_asset_contract_v2(tok_admin).address(); - let backer = Address::generate(&env); - let deadline = env.ledger().timestamp() + 86_400; - client.init_campaign(&org, &tok, &10_000, &deadline); - client.set_penalty_bps(&1_000); // 10% - StellarAssetClient::new(&env, &tok).mint(&backer, &10_000); - client.contribute(&backer, &10_000); - // Approve penalty, slash, but DO NOT perform sweep before window. - let reason = soroban_sdk::String::from_str(&env, "bad"); - client.report_malicious(&backer, &reason); - client.vote_on_malice(&backer, &true); - env.ledger().with_mut(|l| { - l.timestamp = client.malice_vote_deadline().unwrap() + 1 - }); - client.resolve_malice_report(); - client.is_penalty_approved(); - env.ledger().with_mut(|l| l.timestamp += 86_400 * 2); - // Only one backer exists and we want the unclaimed share to remain; do not - // claim any backer share — instead exercise the sweep precondition path. - let res = client.try_sweep_unclaimed_penalty(&Address::generate(&env)); - assert!(res.is_err()); // sweep unlock far in the future -} - -#[test] -fn test_release_milestone_does_not_apply_penalty_when_unapproved() { - let (env, _contract, client, token, organizer, contributor) = setup(); - let deadline = env.ledger().timestamp() + 86_400; - client.init_campaign(&organizer, &token, &10_000, &deadline); - client.set_milestones(&soroban_sdk::vec![&env, 5_000_u32, 5_000_u32]); - StellarAssetClient::new(&env, &token).mint(&contributor, &10_000); - client.contribute(&contributor, &10_000); - env.ledger().with_mut(|l| l.timestamp += 86_401); - client.unlock_milestone(&0); - client.vote_milestone(&contributor, &0, &true); - let token_client = StellarAssetClient::new(&env, &token); - let before = token_client.balance(&organizer); - client.release_milestone(&0); - // No penalty approved yet -> organizer receives the full milestone slice. - assert_eq!(token_client.balance(&organizer) - before, 5_000); - assert_eq!(client.penalty_pool_balance(), 0); -} - -#[test] -fn test_release_milestone_slashes_after_malice_approval() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1_000_000); - let contract = env.register(CrowdfundContract, ()); - let client = CrowdfundContractClient::new(&env, &contract); - let org = Address::generate(&env); - let tok_admin = Address::generate(&env); - let tok = env.register_stellar_asset_contract_v2(tok_admin).address(); - let backer = Address::generate(&env); - let deadline = env.ledger().timestamp() + 86_400; - client.init_campaign(&org, &tok, &10_000, &deadline); - client.set_penalty_bps(&2_000); // 20% - client.set_milestones(&soroban_sdk::vec![&env, 10_000_u32]); // single 100% slice - StellarAssetClient::new(&env, &tok).mint(&backer, &10_000); - client.contribute(&backer, &10_000); - // Approve penalty via full vote flow. - let reason = soroban_sdk::String::from_str(&env, "scam"); - client.report_malicious(&backer, &reason); - client.vote_on_malice(&backer, &true); - env.ledger().with_mut(|l| { - l.timestamp = client.malice_vote_deadline().unwrap() + 1 - }); - client.resolve_malice_report(); - assert!(client.is_penalty_approved()); - env.ledger().with_mut(|l| l.timestamp += 86_401); - // Release the milestone (single 100% slice = 10_000 raised). - client.unlock_milestone(&0); - // The backer is the only governance voter for milestones; pledge = 10_000 - // (> raised / 2 = 5_000) so the milestone is approved. - client.vote_milestone(&backer, &0, &true); - let token_client = StellarAssetClient::new(&env, &tok); - let org_before = token_client.balance(&org); - client.release_milestone(&0); - // 20% of 10_000 = 2_000 to pool, organizer receives 8_000. - assert_eq!(token_client.balance(&org) - org_before, 8_000); - assert_eq!(client.penalty_pool_balance(), 2_000); -} - -#[test] -fn test_execute_campaign_blocked_during_vote_window() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().with_mut(|l| l.timestamp = 1_000_000); - let contract = env.register(CrowdfundContract, ()); - let client = CrowdfundContractClient::new(&env, &contract); - let org = Address::generate(&env); - let tok_admin = Address::generate(&env); - let tok = env.register_stellar_asset_contract_v2(tok_admin).address(); - let backer = Address::generate(&env); - let deadline = env.ledger().timestamp() + 86_400; - client.init_campaign(&org, &tok, &5_000, &deadline); - client.set_penalty_bps(&1_000); - StellarAssetClient::new(&env, &tok).mint(&backer, &5_000); - client.contribute(&backer, &5_000); - // File a malice report; keep timestamp inside the vote window. - let reason = soroban_sdk::String::from_str(&env, "bad"); - client.report_malicious(&backer, &reason); - env.ledger().with_mut(|l| l.timestamp += 86_401); - let res = client.try_execute_campaign(); - assert!(res.is_err()); // blocked because vote window still open -}