diff --git a/PR_BODY_360.md b/PR_BODY_360.md new file mode 100644 index 0000000..ec34c2e --- /dev/null +++ b/PR_BODY_360.md @@ -0,0 +1,166 @@ +# feat(crowdfund): financial penalties for malicious campaigns (#360) + +Closes #360. + +## Summary + +Implements an opt-in, self-imposed financial-penalty mechanism for the `crowdfund` +contract that lets backers recover a slice of a successful campaign's payout if +the organizer is later judged to have acted maliciously. The organizer +voluntarily commits to a slashable rate (≤ 50%) before any pledge lands; backers +can then open a 7-day, pledge-weighted governance vote to accuse the campaign; +on majority approval, the slashed slice is detained on-chain at the moment of +`execute_campaign` / `release_milestone` and distributed pro-rata to backers +under claim — with a 6-month unclaimed sweep as a fallback. + +## Why + +Successful crowdfund campaigns have no recourse today if the organizer +disappears, never delivers, or acts in bad faith. Existing commitment devices +(refunds on failed goals, milestone voting) only protect against *delivery* +risk, not *malice* risk. This PR adds a credible threat of partial recovery +that does not require a centralized arbiter. + +## Design highlights + +- **Organizer signal, not enforcement.** `PenaltyBps` is set voluntarily by the + organizer. It locks automatically after the first pledge lands so the + disclosed rate is guaranteed for the entire campaign. +- **Anti-griefing floor.** Opening a malice report requires the reporter to + hold at least 1 % of `Raised`, so a single sock-puppet cannot spam votes. +- **Front-running protection.** `execute_campaign` and `release_milestone` + refuse to run while a malice vote window is still open — the organizer + cannot race to withdraw funds before backers finalize the vote. +- **Majority rule, pledge-weighted.** Approval requires `votes > raised / 2`, + using each backer's pledge as weight. Same governance model as the existing + milestone voting. +- **Snapshot-based pro-rata.** `Raised` is frozen into `PenaltySnapshotRaised` + at resolution, so claim math stays correct even if the campaign somehow + accrues more pledges after the report. +- **Reentrancy-safe claims.** Storage is mutated *before* the cross-contract + token transfer in `claim_penalty_refund`. +- **Sweep fallback.** 6 months after resolution, unclaimed penalty can be + routed to a configurable recipient (treasury / governance); no funds are + permanently stuck. + +## Constants + +| Constant | Value | Reason | +|---|---|---| +| `MAX_PENALTY_BPS` | 5 000 bps (50 %) | Self-imposed cap so an organizer cannot accidentally over-commit. | +| `PENALTY_VOTE_WINDOW` | 604 800 s (7 days) | Long enough for global backers to vote; short enough to keep capital actionable. | +| `MIN_REPORTER_PLEDGE_BPS` | 100 bps (1 %) | Low griefing floor. | +| `PENALTY_SWEEP_AFTER_SECS` | 15 778 800 s (~6 months) | Comfortable margin for slow backers before unclaimed funds are reusable. | + +## Public API + +### Configuration & status +| Function | Notes | +|---|---| +| `set_penalty_bps(bps)` | Organizer-only. ≤ `MAX_PENALTY_BPS`. Locked after first pledge. | +| `get_penalty_bps()` | Current rate. | +| `penalty_locked()` | True once a pledge has landed. | +| `is_malice_report_active()` | True when a vote window is open. | +| `is_penalty_approved()` | True once `resolve_malice_report` resolved in favour of backers. | +| `penalty_pool_balance()` | On-chain slashed balance awaiting backer claims. | +| `penalty_snapshot_raised()` | `Raised` at resolution time — claim denominator. | +| `malice_vote_start() / deadline() / reporter() / reason()` | Active-proposal metadata. | +| `penalty_vote_counts()` → `(approval, rejection)` | Live tally. | +| `backer_penalty_claimed(backer)` | Per-backer bookkeeping. | +| `penalty_sweep_unlock()` | Timestamp after which sweep becomes permissible. | +| `max_penalty_bps() / penalty_vote_window() / penalty_sweep_after()` | Static config. | + +### Report, vote, resolution +| Function | Notes | +|---|---| +| `report_malicious(reporter, reason)` | Opens the 7-day vote window. Reporter must hold ≥ 1 % of `Raised` and may not be the organizer. | +| `vote_on_malice(voter, approve)` | One vote per backer per active window. Weight = backer pledge. | +| `resolve_malice_report()` | Callable by anyone once the window closes. Approved iff `approval > raised / 2`. Snapshots `Raised` and arms the sweep timer. | + +### Payout integration (existing flows now penalty-aware) +- `execute_campaign()` — `compute_and_lock_penalty` deducts the slashed slice + from the organizer payout when `is_penalty_approved` is true; no-op + otherwise. +- `release_milestone(index)` — same `compute_and_lock_penalty` application per + milestone slice, so a malicious milestone does not break the rest of the + release schedule. + +### Claim & sweep +| Function | Notes | +|---|---| +| `claim_penalty_refund(backer)` | Pro-rata transfer from the on-chain penalty pool. Idempotent per backer. | +| `sweep_unclaimed_penalty(recipient)` | Anyone may trigger after `penalty_sweep_unlock`. | + +## Events + +- `PenaltyConfiguredEvent { bps }` +- `MaliciousReportFiledEvent { reporter, reason, vote_deadline }` +- `MaliceVoteCastEvent { voter, approve, weight }` +- `MaliceReportResolvedEvent { approved, approval_weight, rejection_weight, snapshot_raised }` +- `PenaltySlashedEvent { amount, source, pool_balance }` +- `PenaltyRefundClaimedEvent { backer, amount, remaining_pool }` +- `PenaltySweptEvent { recipient, amount }` + +## Errors added (`contracts/crowdfund/src/errors.rs`) + +`PenaltyBpsInvalid`, `PenaltyLocked`, `MaliceReportActive`, `NoMaliceReport`, +`MaliceVoteWindowActive`, `MaliceVoteWindowExpired`, `PenaltyNotApproved`, +`InsufficientPenaltyPool`, `NotOrganizer`, `InsufficientReporterStake`, +`PenaltySweepTooEarly`, `NoPenaltyShareAvailable`, `PenaltyVoteAlreadyCast`. + +## Files touched + +``` +contracts/crowdfund/src/lib.rs +649 / -4 +contracts/crowdfund/src/errors.rs +26 +contracts/crowdfund/src/test.rs +412 +``` + +## Tests added (`contracts/crowdfund/src/test.rs`) + +Happy path: +- `test_set_penalty_bps_records_and_emits` +- `test_penalty_bps_locks_after_contribution` +- `test_report_malicious_opens_vote_window` +- `test_vote_on_malice_records_weight_once` +- `test_resolve_malice_approves_on_majority` +- `test_execute_campaign_keeps_full_payout_when_no_penalty` +- `test_execute_campaign_slashes_after_malice_approval` — full end-to-end flow including pro-rata backer claims +- `test_release_milestone_does_not_apply_penalty_when_unapproved` +- `test_release_milestone_slashes_after_malice_approval` +- `test_claim_penalty_refund_returns_no_share_when_pool_empty` +- `test_sweep_unclaimed_penalty_before_window_panics` + +Negative / guard paths: +- `test_set_penalty_bps_above_max_panics` +- `test_set_penalty_bps_blocked_after_first_pledge` +- `test_report_malicious_requires_min_stake` +- `test_report_malicious_cannot_self_report` +- `test_vote_on_malice_double_vote_panics` +- `test_vote_on_malice_after_window_panics` +- `test_resolve_malice_requires_window_close` +- `test_claim_penalty_refund_before_approval_panics` +- `test_execute_campaign_blocked_during_vote_window` + +## Security review notes + +- All pledge-weighted transitions use `saturating_*` arithmetic — no overflow. +- `compute_and_lock_penalty` is the single source of truth for slashing math + and is invoked at every payout site, so milestone mode and direct-execute + mode stay consistent. +- The penalty pool is funded implicitly from the payout transfer; no extra + `token::transfer` call is needed (the contract already holds the funds), + so there is no extra cross-contract surface. +- `sweep_unclaimed_penalty` zeroes the pool by setting it to + `PenaltyTotalClaimed`, not by resetting it to zero — this preserves the + invariant that `pool >= total_claimed` even after sweep. + +## Notes for reviewer + +- No contract-upgrade migration is needed beyond a fresh deploy; all storage + keys are additive under the existing `DataKey` enum. +- Snapshot of `Raised` taken at resolution (`PenaltySnapshotRaised`) keeps + pro-rata math stable even if more pledges accrue before `execute_campaign` + / `release_milestone` actually runs. +- Constants are exposed as view functions so off-chain tooling can read them + without recompilation. 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 +}