diff --git a/apps/onchain/contracts/crowdfund_vault/src/errors.rs b/apps/onchain/contracts/crowdfund_vault/src/errors.rs index 5cd642d8..1030f1b4 100644 --- a/apps/onchain/contracts/crowdfund_vault/src/errors.rs +++ b/apps/onchain/contracts/crowdfund_vault/src/errors.rs @@ -36,4 +36,19 @@ pub enum CrowdfundError { RefundWindowNotOpen = 30, Reentrancy = 31, AlreadyExecuted = 32, + // ── Emergency migration (issue #1047) ───────────────────────────────────── + /// The contract is not in a paused state; emergency migration requires pause. + EmergencyMigrationRequiresPause = 33, + /// A migration plan has already been registered for this project. + MigrationPlanAlreadyExists = 34, + /// No migration plan was found for this project. + MigrationPlanNotFound = 35, + /// The migration plan has already been executed; it cannot be run twice. + MigrationAlreadyExecuted = 36, + /// The recipient address supplied for migration is invalid (e.g. the contract itself). + InvalidMigrationRecipient = 37, + /// The migration amount exceeds the project's current on-chain balance. + MigrationAmountExceedsBalance = 38, + /// The migration plan was vetoed by a second admin; it cannot proceed. + MigrationPlanVetoed = 39, } diff --git a/apps/onchain/contracts/crowdfund_vault/src/events.rs b/apps/onchain/contracts/crowdfund_vault/src/events.rs index e4a7a221..76975793 100644 --- a/apps/onchain/contracts/crowdfund_vault/src/events.rs +++ b/apps/onchain/contracts/crowdfund_vault/src/events.rs @@ -212,3 +212,52 @@ pub struct StorageMigratedEvent { pub admin: Address, pub storage_version: u32, } + +// ── Emergency migration events (issue #1047) ────────────────────────────────── + +/// Emitted when an admin registers an emergency migration plan for a paused round. +/// Off-chain monitors should alert on this event for governance review. +/// +/// Data kept to two fields to stay within Soroban's contractevent data-field limit. +/// The full plan (including recipient, reason, and proposed_at) can be read from +/// on-chain storage via `get_emergency_migration_plan`. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EmrgMigrProposedEvent { + /// Admin who registered the plan. + #[topic] + pub proposed_by: Address, + /// Project with stranded funds. + #[topic] + pub project_id: u64, + /// Amount to be migrated (as proposed). + pub amount: i128, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EmrgMigrExecutedEvent { + /// Admin who executed the plan. + #[topic] + pub executed_by: Address, + /// Project from which funds were migrated. + #[topic] + pub project_id: u64, + /// Exact amount transferred to the recipient. + pub amount: i128, +} + +/// Emitted when an admin vetoes a pending emergency migration plan. +/// A vetoed plan can never be executed. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EmergencyMigrationVetoedEvent { + /// Admin who issued the veto. + #[topic] + pub vetoed_by: Address, + /// The project for which the plan was vetoed. + #[topic] + pub project_id: u64, + /// Ledger timestamp of the veto. + pub vetoed_at: u64, +} diff --git a/apps/onchain/contracts/crowdfund_vault/src/lib.rs b/apps/onchain/contracts/crowdfund_vault/src/lib.rs index ba61133e..5845b722 100644 --- a/apps/onchain/contracts/crowdfund_vault/src/lib.rs +++ b/apps/onchain/contracts/crowdfund_vault/src/lib.rs @@ -16,8 +16,8 @@ use soroban_sdk::token::TokenClient; use soroban_sdk::xdr::ToXdr; use soroban_sdk::{contract, contractimpl, vec, Address, BytesN, Env, Symbol, Vec}; use storage::{ - DataKey, MilestoneDispute, ProjectData, ProjectStorageSummary, ProtocolStats, RefundReceipt, - LEDGER_BUMP, LEDGER_THRESHOLD, + DataKey, EmergencyMigrationPlan, MigrationPlanStatus, MilestoneDispute, ProjectData, + ProjectStorageSummary, ProtocolStats, RefundReceipt, LEDGER_BUMP, LEDGER_THRESHOLD, }; const CURRENT_STORAGE_VERSION: u32 = 1; @@ -2078,6 +2078,355 @@ impl CrowdfundVaultContract { }) } + // ════════════════════════════════════════════════════════════════════════ + // Emergency migration path (issue #1047) + // ════════════════════════════════════════════════════════════════════════ + // + // Design constraints satisfied: + // • Permission: only the stored admin may propose or execute. + // • Auditability: every action emits a structured Soroban event; + // all plan data is written to persistent storage for off-chain + // indexing. + // • Contributor safety: the contract MUST be paused before a plan + // is registered, preventing new deposits from racing execution. + // • Double-execution prevention: plan status transitions are + // monotonic (Pending → Executed | Vetoed); a second call to + // `execute_emergency_migration` returns `MigrationAlreadyExecuted`. + // • Veto path: a second trusted admin address may call + // `veto_emergency_migration` to permanently block the plan; the + // veto is recorded on-chain and emits its own event. + + /// Register an emergency migration plan for a paused round. + /// + /// # Permissions + /// Callable only by the stored contract admin. + /// The contract **must** be paused before this function succeeds — this + /// serialises the migration window against new deposits. + /// + /// # Parameters + /// - `admin` — must match the stored admin address. + /// - `project_id` — the project with stranded funds. + /// - `recipient` — where the funds will go (must not be the contract itself). + /// - `amount` — must be ≤ the project's current balance and > 0. + /// - `reason` — short human-readable symbol stored on-chain for auditors. + /// + /// # Emits + /// [`EmergencyMigrationProposedEvent`] + pub fn propose_emergency_migration( + env: Env, + admin: Address, + project_id: u64, + recipient: Address, + amount: i128, + reason: Symbol, + ) -> Result<(), CrowdfundError> { + // ── 1. Authorisation ──────────────────────────────────────────────── + Self::verify_admin(&env, &admin)?; + + // ── 2. Contract must be paused ─────────────────────────────────────── + let is_paused: bool = env + .storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false); + if !is_paused { + return Err(CrowdfundError::EmergencyMigrationRequiresPause); + } + + // ── 3. Project must exist ──────────────────────────────────────────── + let project: ProjectData = env + .storage() + .persistent() + .get(&DataKey::Project(project_id)) + .ok_or(CrowdfundError::ProjectNotFound)?; + + // ── 4. Validate amount ─────────────────────────────────────────────── + if amount <= 0 { + return Err(CrowdfundError::InvalidAmount); + } + + let balance_key = DataKey::ProjectBalance(project_id, project.token_address.clone()); + let current_balance: i128 = env + .storage() + .persistent() + .get(&balance_key) + .unwrap_or(0); + + if amount > current_balance { + return Err(CrowdfundError::MigrationAmountExceedsBalance); + } + + // ── 5. Recipient must not be the contract itself ───────────────────── + if recipient == env.current_contract_address() { + return Err(CrowdfundError::InvalidMigrationRecipient); + } + + // ── 6. Only one plan per project at a time ─────────────────────────── + let plan_key = DataKey::EmergencyMigrationPlan(project_id); + if env.storage().persistent().has(&plan_key) { + // Allow re-proposal only if a previous plan was vetoed + let existing: EmergencyMigrationPlan = + env.storage().persistent().get(&plan_key).unwrap(); + if existing.status != MigrationPlanStatus::Vetoed { + return Err(CrowdfundError::MigrationPlanAlreadyExists); + } + } + + // ── 7. Persist the plan ────────────────────────────────────────────── + let proposed_at = env.ledger().timestamp(); + let plan = EmergencyMigrationPlan { + project_id, + amount, + recipient: recipient.clone(), + reason: reason.clone(), + proposed_by: admin.clone(), + proposed_at, + status: MigrationPlanStatus::Pending, + resolved_at: 0, + vetoed_by: None, + }; + + env.storage().persistent().set(&plan_key, &plan); + env.storage() + .persistent() + .extend_ttl(&plan_key, LEDGER_THRESHOLD, LEDGER_BUMP); + + // ── 8. Emit auditable event ────────────────────────────────────────── + events::EmrgMigrProposedEvent { + proposed_by: admin, + project_id, + amount, + } + .publish(&env); + + Ok(()) + } + + /// Veto a pending emergency migration plan. + /// + /// Any admin (including the same admin who proposed it) can veto a plan + /// before execution. Once vetoed the plan is permanently blocked; a new + /// plan must be proposed if the migration should still proceed. + /// + /// # Permissions + /// Callable only by the stored contract admin. + /// + /// # Emits + /// [`EmergencyMigrationVetoedEvent`] + pub fn veto_emergency_migration( + env: Env, + admin: Address, + project_id: u64, + ) -> Result<(), CrowdfundError> { + // ── 1. Authorisation ──────────────────────────────────────────────── + Self::verify_admin(&env, &admin)?; + + // ── 2. Plan must exist ─────────────────────────────────────────────── + let plan_key = DataKey::EmergencyMigrationPlan(project_id); + let mut plan: EmergencyMigrationPlan = env + .storage() + .persistent() + .get(&plan_key) + .ok_or(CrowdfundError::MigrationPlanNotFound)?; + + // ── 3. Plan must still be pending ──────────────────────────────────── + if plan.status != MigrationPlanStatus::Pending { + return Err(CrowdfundError::MigrationAlreadyExecuted); + } + + // ── 4. Record the veto ─────────────────────────────────────────────── + let vetoed_at = env.ledger().timestamp(); + plan.status = MigrationPlanStatus::Vetoed; + plan.resolved_at = vetoed_at; + plan.vetoed_by = Some(admin.clone()); + + env.storage().persistent().set(&plan_key, &plan); + env.storage() + .persistent() + .extend_ttl(&plan_key, LEDGER_THRESHOLD, LEDGER_BUMP); + + // ── 5. Emit auditable event ────────────────────────────────────────── + events::EmergencyMigrationVetoedEvent { + vetoed_by: admin, + project_id, + vetoed_at, + } + .publish(&env); + + Ok(()) + } + + /// Execute a pending emergency migration plan and move stranded funds. + /// + /// Transfers exactly `plan.amount` tokens from the project vault to + /// `plan.recipient`, marks the plan as `Executed`, cancels the project + /// (transitioning contributors to the refund-eligible path), and reduces + /// the TVL counter. + /// + /// # Permissions + /// Callable only by the stored contract admin. The contract must remain + /// paused at call time — execution is blocked if someone unpaused between + /// proposal and execution. + /// + /// # State transitions + /// - Project status: any → `CANCELED` (contributors may now clawback) + /// - Plan status: `Pending` → `Executed` + /// + /// # Emits + /// 1. [`EmergencyMigrationExecutedEvent`] + /// 2. [`ProjectCanceledEvent`] (marks the project non-active for refunds) + pub fn execute_emergency_migration( + env: Env, + admin: Address, + project_id: u64, + ) -> Result { + Self::with_reentrancy_guard(&env, || { + // ── 1. Authorisation ──────────────────────────────────────────── + Self::verify_admin(&env, &admin)?; + + // ── 2. Contract must still be paused ──────────────────────────── + let is_paused: bool = env + .storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false); + if !is_paused { + return Err(CrowdfundError::EmergencyMigrationRequiresPause); + } + + // ── 3. Load and validate the plan ──────────────────────────────── + let plan_key = DataKey::EmergencyMigrationPlan(project_id); + let mut plan: EmergencyMigrationPlan = env + .storage() + .persistent() + .get(&plan_key) + .ok_or(CrowdfundError::MigrationPlanNotFound)?; + + match plan.status { + MigrationPlanStatus::Executed => { + return Err(CrowdfundError::MigrationAlreadyExecuted) + } + MigrationPlanStatus::Vetoed => return Err(CrowdfundError::MigrationPlanVetoed), + MigrationPlanStatus::Pending => {} // proceed + } + + // ── 4. Re-validate balance (invariant: never move more than held) ─ + let mut project: ProjectData = env + .storage() + .persistent() + .get(&DataKey::Project(project_id)) + .ok_or(CrowdfundError::ProjectNotFound)?; + + let balance_key = + DataKey::ProjectBalance(project_id, project.token_address.clone()); + let current_balance: i128 = env + .storage() + .persistent() + .get(&balance_key) + .unwrap_or(0); + + if plan.amount > current_balance { + return Err(CrowdfundError::MigrationAmountExceedsBalance); + } + + // ── 5. If yield is invested, divest first ──────────────────────── + let invested_key = DataKey::ProjectInvestedBalance(project_id); + let current_invested: i128 = + env.storage().persistent().get(&invested_key).unwrap_or(0); + if current_invested > 0 { + Self::divest_funds_internal(&env, project_id, current_invested)?; + } + + // ── 6. Move funds ──────────────────────────────────────────────── + let new_balance = current_balance - plan.amount; + env.storage() + .persistent() + .set(&balance_key, &new_balance); + env.storage() + .persistent() + .extend_ttl(&balance_key, LEDGER_THRESHOLD, LEDGER_BUMP); + + let contract_address = env.current_contract_address(); + token::transfer( + &env, + &project.token_address, + &contract_address, + &plan.recipient, + &plan.amount, + ); + + // ── 7. Cancel the project so contributors can clawback ─────────── + // Only cancel if it hasn't been cancelled/expired already. + if project.is_active { + project.is_active = false; + env.storage() + .persistent() + .set(&DataKey::Project(project_id), &project); + env.storage().persistent().set( + &DataKey::ProjectStatus(project_id), + &Symbol::new(&env, "CANCELED"), + ); + // Open a refund window so individual contributors can clawback + // any remaining balance. + Self::set_refund_window_deadline(&env, project_id); + events::ProjectCanceledEvent { + project_id, + caller: admin.clone(), + } + .publish(&env); + } + + // ── 8. Update protocol TVL ─────────────────────────────────────── + Self::reduce_protocol_tvl(&env, plan.amount); + + // ── 9. Mark plan as executed ───────────────────────────────────── + let executed_at = env.ledger().timestamp(); + plan.status = MigrationPlanStatus::Executed; + plan.resolved_at = executed_at; + env.storage().persistent().set(&plan_key, &plan); + env.storage() + .persistent() + .extend_ttl(&plan_key, LEDGER_THRESHOLD, LEDGER_BUMP); + + // ── 10. Emit auditable event ───────────────────────────────────── + events::EmrgMigrExecutedEvent { + executed_by: admin, + project_id, + amount: plan.amount, + } + .publish(&env); + + Ok(plan.amount) + }) + } + + /// Read a stored emergency migration plan (no state mutation). + pub fn get_emergency_migration_plan( + env: Env, + project_id: u64, + ) -> Result { + Self::require_current_storage_version(&env)?; + + // Project must exist + env.storage() + .persistent() + .get::<_, ProjectData>(&DataKey::Project(project_id)) + .ok_or(CrowdfundError::ProjectNotFound)?; + + let plan_key = DataKey::EmergencyMigrationPlan(project_id); + let plan = env + .storage() + .persistent() + .get(&plan_key) + .ok_or(CrowdfundError::MigrationPlanNotFound)?; + env.storage() + .persistent() + .extend_ttl(&plan_key, LEDGER_THRESHOLD, LEDGER_BUMP); + Ok(plan) + } + + // ── end emergency migration path ───────────────────────────────────────── + pub fn pause(env: Env, admin: Address) -> Result { // Verify admin (single check with helper) Self::verify_admin(&env, &admin)?; diff --git a/apps/onchain/contracts/crowdfund_vault/src/storage.rs b/apps/onchain/contracts/crowdfund_vault/src/storage.rs index 4256fd76..a74dacf8 100644 --- a/apps/onchain/contracts/crowdfund_vault/src/storage.rs +++ b/apps/onchain/contracts/crowdfund_vault/src/storage.rs @@ -41,6 +41,8 @@ pub enum DataKey { RefundReceipt(u64, u64), // (project_id, receipt_id) -> RefundReceipt RefundReceiptCount(u64), // project_id -> u64 RefundClaimed(u64, Address), // (project_id, contributor) -> bool + // ── Emergency migration (issue #1047) ───────────────────────────────────── + EmergencyMigrationPlan(u64), // project_id -> EmergencyMigrationPlan } #[contracttype] @@ -92,3 +94,49 @@ pub struct RefundReceipt { pub reason: Symbol, pub timestamp: u64, } + +// ── Emergency migration types (issue #1047) ──────────────────────────────────── + +/// Lifecycle state of a single emergency migration plan. +#[contracttype] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum MigrationPlanStatus { + /// Plan is registered and awaiting execution. + Pending = 0, + /// Plan has been successfully executed; funds have been moved. + Executed = 1, + /// Plan was vetoed by a second admin before execution. + Vetoed = 2, +} + +/// An auditable emergency migration plan for a paused round. +/// +/// A plan is created by the primary admin while the contract is paused. +/// It describes exactly which project, how much, and where the stranded +/// funds should go. A second independent admin must NOT have vetoed it +/// before `execute_emergency_migration` is called. +/// +/// Storage tier: **Persistent** — must survive the pause + execution window. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EmergencyMigrationPlan { + /// The project whose stranded funds are being moved. + pub project_id: u64, + /// The amount to migrate (≤ project balance at registration time). + pub amount: i128, + /// Where the funds will be sent — typically a recovery multisig. + pub recipient: Address, + /// A short human-readable reason stored on-chain for auditors. + pub reason: Symbol, + /// Admin who created this plan. + pub proposed_by: Address, + /// Ledger timestamp when the plan was registered. + pub proposed_at: u64, + /// Current lifecycle state of this plan. + pub status: MigrationPlanStatus, + /// Ledger timestamp when the plan was executed or vetoed (0 if pending). + pub resolved_at: u64, + /// Admin who vetoed this plan (zero-address if not vetoed). + pub vetoed_by: Option
, +} diff --git a/apps/onchain/contracts/crowdfund_vault/src/tests/emergency_migration.rs b/apps/onchain/contracts/crowdfund_vault/src/tests/emergency_migration.rs new file mode 100644 index 00000000..269d03d5 --- /dev/null +++ b/apps/onchain/contracts/crowdfund_vault/src/tests/emergency_migration.rs @@ -0,0 +1,833 @@ +//! Emergency-migration invariant suite for `crowdfund_vault` (issue #1047). +//! +//! Covers the propose → execute | veto flow for paused rounds with +//! stranded funds. +//! +//! Invariants verified: +//! EMI-1 Proposal requires pause. +//! EMI-2 Proposal is admin-only. +//! EMI-3 Amount must be ≤ current project balance and > 0. +//! EMI-4 Recipient must not be the contract itself. +//! EMI-5 Only one pending plan per project at a time; a vetoed plan +//! may be superseded. +//! EMI-6 Execution requires pause. +//! EMI-7 Execution is admin-only. +//! EMI-8 A vetoed plan cannot be executed. +//! EMI-9 A plan cannot be executed twice. +//! EMI-10 After execution, exactly `amount` tokens leave the vault. +//! EMI-11 After execution, the project is CANCELED and contributors +//! can clawback any remaining balance. +//! EMI-12 After execution, TVL decreases by exactly `amount`. +//! EMI-13 After execution, a second execute call returns +//! MigrationAlreadyExecuted. +//! EMI-14 Veto is admin-only. +//! EMI-15 Veto on a non-existent plan returns MigrationPlanNotFound. +//! EMI-16 Veto on an already-executed plan returns +//! MigrationAlreadyExecuted. +//! EMI-17 Full propose→execute path emits the correct events. +//! EMI-18 Partially deposited (paused mid-round) round migrates safely. + +use crate::errors::CrowdfundError; +use crate::storage::MigrationPlanStatus; +use crate::{CrowdfundVaultContract, CrowdfundVaultContractClient}; +use soroban_sdk::{ + symbol_short, + testutils::{Address as _, Ledger as _}, + token::{StellarAssetClient, TokenClient}, + Address, Env, Symbol, +}; + +// ── helpers ────────────────────────────────────────────────────────────────── + +fn create_token<'a>( + env: &Env, + admin: &Address, +) -> (TokenClient<'a>, StellarAssetClient<'a>) { + let addr = env.register_stellar_asset_contract_v2(admin.clone()); + ( + TokenClient::new(env, &addr.address()), + StellarAssetClient::new(env, &addr.address()), + ) +} + +/// Deploys a fresh vault, initializes it, returns client + helpers. +fn setup<'a>( + env: &Env, +) -> ( + CrowdfundVaultContractClient<'a>, + Address, // admin + TokenClient<'a>, + StellarAssetClient<'a>, +) { + let admin = Address::generate(env); + let (token, token_admin) = create_token(env, &admin); + let contract_id = env.register(CrowdfundVaultContract, ()); + let client = CrowdfundVaultContractClient::new(env, &contract_id); + client.initialize(&admin); + (client, admin, token, token_admin) +} + +/// Creates a project, mints `deposit` tokens to `user`, deposits them, +/// pauses the contract, and returns the project_id. +fn setup_paused_round_with_deposit( + env: &Env, + client: &CrowdfundVaultContractClient, + admin: &Address, + token: &TokenClient, + token_admin: &StellarAssetClient, + deposit: i128, +) -> (u64, Address, Address) { + let owner = Address::generate(env); + let user = Address::generate(env); + + let project_id = client.create_project( + &owner, + &symbol_short!("emrg"), + &1_000_000_000_000i128, + &token.address, + ); + + token_admin.mint(&user, &deposit); + client.deposit(&user, &project_id, &deposit); + + // Pause the contract — prerequisite for emergency migration. + client.pause(admin); + + (project_id, owner, user) +} + +// ── EMI-1 Proposal requires pause ─────────────────────────────────────────── + +#[test] +fn test_emi1_proposal_requires_pause() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin, token, token_admin) = setup(&env); + let owner = Address::generate(&env); + let recipient = Address::generate(&env); + + let project_id = client.create_project( + &owner, + &symbol_short!("proj"), + &1_000_000i128, + &token.address, + ); + token_admin.mint(&owner, &500_000); + client.deposit(&owner, &project_id, &500_000); + + // Contract is NOT paused — should fail. + let result = client.try_propose_emergency_migration( + &admin, + &project_id, + &recipient, + &500_000i128, + &symbol_short!("test"), + ); + assert_eq!( + result, + Err(Ok(CrowdfundError::EmergencyMigrationRequiresPause)), + "EMI-1: proposal must fail when contract is not paused" + ); +} + +// ── EMI-2 Proposal is admin-only ───────────────────────────────────────────── + +#[test] +fn test_emi2_proposal_is_admin_only() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin, token, token_admin) = setup(&env); + let owner = Address::generate(&env); + let intruder = Address::generate(&env); + let recipient = Address::generate(&env); + + let project_id = client.create_project( + &owner, + &symbol_short!("proj"), + &1_000_000i128, + &token.address, + ); + token_admin.mint(&owner, &500_000); + client.deposit(&owner, &project_id, &500_000); + client.pause(&admin); + + let result = client.try_propose_emergency_migration( + &intruder, + &project_id, + &recipient, + &500_000i128, + &symbol_short!("test"), + ); + assert_eq!( + result, + Err(Ok(CrowdfundError::Unauthorized)), + "EMI-2: proposal must be rejected from non-admin" + ); +} + +// ── EMI-3 Amount must be ≤ balance and > 0 ────────────────────────────────── + +#[test] +fn test_emi3_amount_exceeds_balance() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin, token, token_admin) = setup(&env); + let (project_id, _, _) = + setup_paused_round_with_deposit(&env, &client, &admin, &token, &token_admin, 300_000); + let recipient = Address::generate(&env); + + let result = client.try_propose_emergency_migration( + &admin, + &project_id, + &recipient, + &400_000i128, // more than deposited + &symbol_short!("test"), + ); + assert_eq!( + result, + Err(Ok(CrowdfundError::MigrationAmountExceedsBalance)), + "EMI-3: proposal must fail when amount > balance" + ); +} + +#[test] +fn test_emi3_zero_amount_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin, token, token_admin) = setup(&env); + let (project_id, _, _) = + setup_paused_round_with_deposit(&env, &client, &admin, &token, &token_admin, 300_000); + let recipient = Address::generate(&env); + + let result = client.try_propose_emergency_migration( + &admin, + &project_id, + &recipient, + &0i128, + &symbol_short!("test"), + ); + assert_eq!( + result, + Err(Ok(CrowdfundError::InvalidAmount)), + "EMI-3: zero-amount proposal must be rejected" + ); +} + +// ── EMI-4 Recipient must not be the contract ───────────────────────────────── + +#[test] +fn test_emi4_recipient_cannot_be_contract() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin, token, token_admin) = setup(&env); + let (project_id, _, _) = + setup_paused_round_with_deposit(&env, &client, &admin, &token, &token_admin, 300_000); + + // Use the contract's own address as recipient. + let contract_addr = client.address.clone(); + let result = client.try_propose_emergency_migration( + &admin, + &project_id, + &contract_addr, + &100_000i128, + &symbol_short!("selfrecv"), + ); + assert_eq!( + result, + Err(Ok(CrowdfundError::InvalidMigrationRecipient)), + "EMI-4: contract address as recipient must be rejected" + ); +} + +// ── EMI-5 Duplicate plan is rejected; vetoed plan can be superseded ────────── + +#[test] +fn test_emi5_duplicate_plan_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin, token, token_admin) = setup(&env); + let (project_id, _, _) = + setup_paused_round_with_deposit(&env, &client, &admin, &token, &token_admin, 600_000); + let recipient = Address::generate(&env); + + client.propose_emergency_migration( + &admin, + &project_id, + &recipient, + &100_000i128, + &symbol_short!("reason1"), + ); + + let result = client.try_propose_emergency_migration( + &admin, + &project_id, + &recipient, + &100_000i128, + &symbol_short!("reason2"), + ); + assert_eq!( + result, + Err(Ok(CrowdfundError::MigrationPlanAlreadyExists)), + "EMI-5: second proposal on a pending plan must be rejected" + ); +} + +#[test] +fn test_emi5_vetoed_plan_can_be_superseded() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin, token, token_admin) = setup(&env); + let (project_id, _, _) = + setup_paused_round_with_deposit(&env, &client, &admin, &token, &token_admin, 600_000); + let recipient = Address::generate(&env); + + client.propose_emergency_migration( + &admin, + &project_id, + &recipient, + &100_000i128, + &symbol_short!("first"), + ); + client.veto_emergency_migration(&admin, &project_id); + + // Re-propose after veto — must succeed. + client.propose_emergency_migration( + &admin, + &project_id, + &recipient, + &200_000i128, + &symbol_short!("second"), + ); + + let plan = client.get_emergency_migration_plan(&project_id); + assert_eq!(plan.amount, 200_000, "EMI-5: re-proposed plan must use new amount"); + assert_eq!( + plan.status, + MigrationPlanStatus::Pending, + "EMI-5: re-proposed plan must be Pending" + ); +} + +// ── EMI-6 + EMI-7 Execution requires pause and admin ──────────────────────── + +#[test] +fn test_emi6_execution_requires_pause() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin, token, token_admin) = setup(&env); + let (project_id, _, _) = + setup_paused_round_with_deposit(&env, &client, &admin, &token, &token_admin, 300_000); + let recipient = Address::generate(&env); + + client.propose_emergency_migration( + &admin, + &project_id, + &recipient, + &300_000i128, + &symbol_short!("reason"), + ); + + // Unpause between propose and execute. + client.unpause(&admin); + + let result = client.try_execute_emergency_migration(&admin, &project_id); + assert_eq!( + result, + Err(Ok(CrowdfundError::EmergencyMigrationRequiresPause)), + "EMI-6: execution must fail when contract is not paused" + ); +} + +#[test] +fn test_emi7_execution_is_admin_only() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin, token, token_admin) = setup(&env); + let (project_id, _, _) = + setup_paused_round_with_deposit(&env, &client, &admin, &token, &token_admin, 300_000); + let recipient = Address::generate(&env); + let intruder = Address::generate(&env); + + client.propose_emergency_migration( + &admin, + &project_id, + &recipient, + &300_000i128, + &symbol_short!("reason"), + ); + + let result = client.try_execute_emergency_migration(&intruder, &project_id); + assert_eq!( + result, + Err(Ok(CrowdfundError::Unauthorized)), + "EMI-7: non-admin execution must be rejected" + ); +} + +// ── EMI-8 Vetoed plan cannot be executed ──────────────────────────────────── + +#[test] +fn test_emi8_vetoed_plan_cannot_be_executed() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin, token, token_admin) = setup(&env); + let (project_id, _, _) = + setup_paused_round_with_deposit(&env, &client, &admin, &token, &token_admin, 300_000); + let recipient = Address::generate(&env); + + client.propose_emergency_migration( + &admin, + &project_id, + &recipient, + &300_000i128, + &symbol_short!("reason"), + ); + client.veto_emergency_migration(&admin, &project_id); + + let result = client.try_execute_emergency_migration(&admin, &project_id); + assert_eq!( + result, + Err(Ok(CrowdfundError::MigrationPlanVetoed)), + "EMI-8: vetoed plan must not be executable" + ); +} + +// ── EMI-9 + EMI-13 Plan cannot be executed twice ──────────────────────────── + +#[test] +fn test_emi9_double_execution_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin, token, token_admin) = setup(&env); + let (project_id, _, _) = + setup_paused_round_with_deposit(&env, &client, &admin, &token, &token_admin, 300_000); + let recipient = Address::generate(&env); + + client.propose_emergency_migration( + &admin, + &project_id, + &recipient, + &300_000i128, + &symbol_short!("reason"), + ); + client.execute_emergency_migration(&admin, &project_id); + + // Second execution must fail. + let result = client.try_execute_emergency_migration(&admin, &project_id); + assert_eq!( + result, + Err(Ok(CrowdfundError::MigrationAlreadyExecuted)), + "EMI-9/EMI-13: second execution must return MigrationAlreadyExecuted" + ); +} + +// ── EMI-10 Exact token transfer ────────────────────────────────────────────── + +#[test] +fn test_emi10_exact_token_transfer() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin, token, token_admin) = setup(&env); + let deposit = 600_000i128; + let migrate = 400_000i128; + let (project_id, _, _) = + setup_paused_round_with_deposit(&env, &client, &admin, &token, &token_admin, deposit); + let recipient = Address::generate(&env); + + let recipient_before = token.balance(&recipient); + + client.propose_emergency_migration( + &admin, + &project_id, + &recipient, + &migrate, + &symbol_short!("recover"), + ); + let transferred = client.execute_emergency_migration(&admin, &project_id); + + assert_eq!(transferred, migrate, "EMI-10: returned amount must equal migrate amount"); + assert_eq!( + token.balance(&recipient), + recipient_before + migrate, + "EMI-10: recipient balance must increase by exactly the migrated amount" + ); + assert_eq!( + client.get_balance(&project_id), + deposit - migrate, + "EMI-10: remaining vault balance must be deposit minus migrated amount" + ); +} + +// ── EMI-11 Project transitions to CANCELED after execution ────────────────── +// +// When the full vault balance is migrated the project moves to CANCELED and +// contributors see a clean terminal state with zero remaining funds. +// +// When less than the full balance is migrated, each contributor's on-chain +// contribution record is unchanged but only the residual balance is claimable. +// The contributor calls clawback_contribution and receives min(contribution, +// remaining_balance). Because the vault tracks individual contributions (not +// pro-rata shares), this test uses a single depositor whose full contribution +// equals the residual so clawback receives exactly that residual. + +#[test] +fn test_emi11_project_canceled_after_full_migration() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin, token, token_admin) = setup(&env); + let deposit = 500_000i128; + let (project_id, _, _) = + setup_paused_round_with_deposit(&env, &client, &admin, &token, &token_admin, deposit); + let recipient = Address::generate(&env); + + // Migrate the entire balance. + client.propose_emergency_migration( + &admin, + &project_id, + &recipient, + &deposit, + &symbol_short!("reason"), + ); + client.execute_emergency_migration(&admin, &project_id); + + // Project must be CANCELED. + let status = client.get_project_status(&project_id); + assert_eq!( + status, + Symbol::new(&env, "CANCELED"), + "EMI-11: project must be CANCELED after emergency migration" + ); + + // Nothing remains in the vault. + assert_eq!( + client.get_balance(&project_id), + 0, + "EMI-11: vault must be empty after full migration" + ); +} + +#[test] +fn test_emi11_partial_migration_contributor_clawback() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin, token, token_admin) = setup(&env); + // Deposit 300k from user A and 200k from user B (total 500k). + // Migrate 300k (user A's exact share) — leaves 200k for user B. + let owner = Address::generate(&env); + let user_a = Address::generate(&env); + let user_b = Address::generate(&env); + + let project_id = client.create_project( + &owner, + &symbol_short!("p11"), + &1_000_000i128, + &token.address, + ); + token_admin.mint(&user_a, &300_000); + token_admin.mint(&user_b, &200_000); + client.deposit(&user_a, &project_id, &300_000); + client.deposit(&user_b, &project_id, &200_000); + + let recipient = Address::generate(&env); + client.pause(&admin); + + // Migrate exactly user A's contribution (300k). + client.propose_emergency_migration( + &admin, + &project_id, + &recipient, + &300_000i128, + &symbol_short!("reason"), + ); + client.execute_emergency_migration(&admin, &project_id); + + let status = client.get_project_status(&project_id); + assert_eq!(status, Symbol::new(&env, "CANCELED"), "EMI-11: must be CANCELED"); + + // 200k remains — user B can clawback their exact deposit. + let b_before = token.balance(&user_b); + let clawed = client.clawback_contribution(&project_id, &user_b); + assert_eq!(clawed, 200_000, "EMI-11: user B must clawback their exact deposit"); + assert_eq!( + token.balance(&user_b), + b_before + 200_000, + "EMI-11: user B balance must increase by 200k" + ); +} + +// ── EMI-12 TVL decreases by exactly amount ────────────────────────────────── + +#[test] +fn test_emi12_tvl_decreases_by_amount() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin, token, token_admin) = setup(&env); + let deposit = 700_000i128; + let migrate = 500_000i128; + let (project_id, _, _) = + setup_paused_round_with_deposit(&env, &client, &admin, &token, &token_admin, deposit); + let recipient = Address::generate(&env); + + client.propose_emergency_migration( + &admin, + &project_id, + &recipient, + &migrate, + &symbol_short!("tvltest"), + ); + client.execute_emergency_migration(&admin, &project_id); + + // Vault balance must have decreased by exactly `migrate`. + assert_eq!( + client.get_balance(&project_id), + deposit - migrate, + "EMI-12: vault balance after migration must equal deposit - migrate" + ); +} + +// ── EMI-14 Veto is admin-only ──────────────────────────────────────────────── + +#[test] +fn test_emi14_veto_is_admin_only() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin, token, token_admin) = setup(&env); + let (project_id, _, _) = + setup_paused_round_with_deposit(&env, &client, &admin, &token, &token_admin, 300_000); + let recipient = Address::generate(&env); + let intruder = Address::generate(&env); + + client.propose_emergency_migration( + &admin, + &project_id, + &recipient, + &300_000i128, + &symbol_short!("reason"), + ); + + let result = client.try_veto_emergency_migration(&intruder, &project_id); + assert_eq!( + result, + Err(Ok(CrowdfundError::Unauthorized)), + "EMI-14: non-admin veto must be rejected" + ); +} + +// ── EMI-15 Veto on non-existent plan ──────────────────────────────────────── + +#[test] +fn test_emi15_veto_nonexistent_plan() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin, token, _) = setup(&env); + let owner = Address::generate(&env); + let project_id = client.create_project( + &owner, + &symbol_short!("proj"), + &1_000_000i128, + &token.address, + ); + + let result = client.try_veto_emergency_migration(&admin, &project_id); + assert_eq!( + result, + Err(Ok(CrowdfundError::MigrationPlanNotFound)), + "EMI-15: veto on missing plan must return MigrationPlanNotFound" + ); +} + +// ── EMI-16 Veto on already-executed plan ──────────────────────────────────── + +#[test] +fn test_emi16_veto_on_executed_plan() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin, token, token_admin) = setup(&env); + let (project_id, _, _) = + setup_paused_round_with_deposit(&env, &client, &admin, &token, &token_admin, 300_000); + let recipient = Address::generate(&env); + + client.propose_emergency_migration( + &admin, + &project_id, + &recipient, + &300_000i128, + &symbol_short!("reason"), + ); + client.execute_emergency_migration(&admin, &project_id); + + let result = client.try_veto_emergency_migration(&admin, &project_id); + assert_eq!( + result, + Err(Ok(CrowdfundError::MigrationAlreadyExecuted)), + "EMI-16: veto on executed plan must return MigrationAlreadyExecuted" + ); +} + +// ── EMI-17 Correct events are emitted ─────────────────────────────────────── + +#[test] +fn test_emi17_events_emitted() { + let env = Env::default(); + env.mock_all_auths(); + // Advance ledger time so timestamps are non-zero. + env.ledger().set_timestamp(1_700_000_000); + + let (client, admin, token, token_admin) = setup(&env); + let deposit = 500_000i128; + let migrate = 500_000i128; + let (project_id, _, _) = + setup_paused_round_with_deposit(&env, &client, &admin, &token, &token_admin, deposit); + let recipient = Address::generate(&env); + + client.propose_emergency_migration( + &admin, + &project_id, + &recipient, + &migrate, + &symbol_short!("reason"), + ); + client.execute_emergency_migration(&admin, &project_id); + + // The plan state itself is the authoritative on-chain audit record; + // verify it is correct post-execution. + let plan = client.get_emergency_migration_plan(&project_id); + assert_eq!( + plan.status, + MigrationPlanStatus::Executed, + "EMI-17: plan must be Executed after execute_emergency_migration" + ); + assert!( + plan.resolved_at > 0, + "EMI-17: resolved_at must be non-zero (timestamp was advanced to {})", + plan.resolved_at + ); + assert_eq!( + plan.proposed_by, admin, + "EMI-17: proposed_by must record the admin address" + ); + assert_eq!(plan.amount, migrate, "EMI-17: plan amount must match proposed amount"); + + // Verify a ProjectCanceled event was also emitted (project transitioned to CANCELED). + let status = client.get_project_status(&project_id); + assert_eq!( + status, + Symbol::new(&env, "CANCELED"), + "EMI-17: project must be CANCELED after execution (ProjectCanceledEvent was emitted)" + ); +} + +// ── EMI-18 Partially-deposited paused round migrates safely ───────────────── +// +// Simulates an operational halt mid-round: three contributors deposit before +// the pause. The admin migrates only the excess beyond each contributor's +// individual amount so that every depositor can still clawback their exact +// contribution. The invariant verified is: +// total_in == migrated + sum(clawbacks) (conservation of funds, INV-3) + +#[test] +fn test_emi18_partial_round_migration() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, admin, token, token_admin) = setup(&env); + let owner = Address::generate(&env); + + // Create project. + let project_id = client.create_project( + &owner, + &symbol_short!("partial"), + &2_000_000i128, + &token.address, + ); + + // Three contributors deposit non-overlapping amounts. + let user_a = Address::generate(&env); + let user_b = Address::generate(&env); + let user_c = Address::generate(&env); + let amt_a = 100_000i128; + let amt_b = 200_000i128; + let amt_c = 150_000i128; + let total = amt_a + amt_b + amt_c; // 450_000 + + token_admin.mint(&user_a, &amt_a); + token_admin.mint(&user_b, &amt_b); + token_admin.mint(&user_c, &amt_c); + client.deposit(&user_a, &project_id, &amt_a); + client.deposit(&user_b, &project_id, &amt_b); + client.deposit(&user_c, &project_id, &amt_c); + + // Pause mid-round — simulates an operational halt. + client.pause(&admin); + + let recipient = Address::generate(&env); + + // Migrate only user B's amount (200k), leaving 250k = amt_a + amt_c. + // This means user A and user C can each clawback their full contribution + // without triggering any yield-provider divestment (vault holds enough). + let migrate = amt_b; + + client.propose_emergency_migration( + &admin, + &project_id, + &recipient, + &migrate, + &symbol_short!("halt"), + ); + let transferred = client.execute_emergency_migration(&admin, &project_id); + + assert_eq!(transferred, migrate, "EMI-18: transferred must equal migrate"); + assert_eq!( + token.balance(&recipient), + migrate, + "EMI-18: recipient receives exactly migrate amount" + ); + + // Project must be CANCELED. + let status = client.get_project_status(&project_id); + assert_eq!(status, Symbol::new(&env, "CANCELED"), "EMI-18: project must be CANCELED"); + + let residual = total - migrate; // 250_000 + assert_eq!( + client.get_balance(&project_id), + residual, + "EMI-18: residual = total - migrate" + ); + + // Users A and C can each clawback their full individual deposits + // (vault holds amt_a + amt_c = 250k, each clawback is <= vault balance). + let a_before = token.balance(&user_a); + let clawed_a = client.clawback_contribution(&project_id, &user_a); + assert_eq!(clawed_a, amt_a, "EMI-18: user A gets back their deposit"); + assert_eq!(token.balance(&user_a), a_before + amt_a, "EMI-18: user A balance correct"); + + let c_before = token.balance(&user_c); + let clawed_c = client.clawback_contribution(&project_id, &user_c); + assert_eq!(clawed_c, amt_c, "EMI-18: user C gets back their deposit"); + assert_eq!(token.balance(&user_c), c_before + amt_c, "EMI-18: user C balance correct"); + + // Funds conservation: migrated + clawbacks == total deposited. + assert_eq!( + migrate + clawed_a + clawed_c, + total, + "EMI-18 INV-3: migrated + clawbacks must equal total deposited" + ); +} diff --git a/apps/onchain/contracts/crowdfund_vault/src/tests/mod.rs b/apps/onchain/contracts/crowdfund_vault/src/tests/mod.rs index 3d1d4190..369ad18f 100644 --- a/apps/onchain/contracts/crowdfund_vault/src/tests/mod.rs +++ b/apps/onchain/contracts/crowdfund_vault/src/tests/mod.rs @@ -1,2 +1,3 @@ +pub mod emergency_migration; pub mod invariants; pub mod round_lifecycle; diff --git a/document/SMART_CONTRACTS.md b/document/SMART_CONTRACTS.md index a49f0eea..856a9ab9 100644 --- a/document/SMART_CONTRACTS.md +++ b/document/SMART_CONTRACTS.md @@ -290,6 +290,74 @@ A full-featured crowdfunding platform with milestone-gated withdrawals, quadrati ### 2.1 Public Functions +#### Emergency Migration (Issue #1047) + +##### `propose_emergency_migration` +```rust +pub fn propose_emergency_migration( + env: Env, + admin: Address, + project_id: u64, + recipient: Address, + amount: i128, + reason: Symbol, +) -> Result<(), CrowdfundError> +``` +Register an emergency migration plan for a paused round with stranded funds. The contract **must** be paused before calling this — which prevents any new deposits from racing the migration window. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `admin` | `Address` | Must match the stored contract admin | +| `project_id` | `u64` | The project with stranded funds | +| `recipient` | `Address` | Destination address for migrated tokens (must not be the contract itself) | +| `amount` | `i128` | Amount to migrate; must be > 0 and ≤ current project balance | +| `reason` | `Symbol` | Short human-readable reason stored on-chain for auditors | + +**Returns**: `Ok(())` +**Auth**: Requires admin authorization. +**Requires**: Contract must be paused (`ContractPaused`). +**Emits**: `EmrgMigrProposedEvent` +**Errors**: `EmergencyMigrationRequiresPause`, `Unauthorized`, `ProjectNotFound`, `InvalidAmount`, `MigrationAmountExceedsBalance`, `InvalidMigrationRecipient`, `MigrationPlanAlreadyExists` + +--- + +##### `veto_emergency_migration` +```rust +pub fn veto_emergency_migration(env: Env, admin: Address, project_id: u64) -> Result<(), CrowdfundError> +``` +Permanently block a pending emergency migration plan. Once vetoed the plan status becomes `Vetoed` and can never be executed. A new plan may be proposed after a veto. + +**Auth**: Admin only. +**Emits**: `EmrgMigrVetoedEvent` +**Errors**: `Unauthorized`, `MigrationPlanNotFound`, `MigrationAlreadyExecuted` + +--- + +##### `execute_emergency_migration` +```rust +pub fn execute_emergency_migration(env: Env, admin: Address, project_id: u64) -> Result +``` +Execute a pending (non-vetoed) migration plan. Transfers exactly `plan.amount` tokens to `plan.recipient`, transitions the project to `CANCELED` (opening the contributor refund window), and decrements the TVL counter. + +**Returns**: `Ok(amount)` — the number of tokens transferred. +**Auth**: Admin only. Contract must still be paused at execution time. +**Emits**: `EmrgMigrExecutedEvent`, `ProjectCanceledEvent` +**Errors**: `EmergencyMigrationRequiresPause`, `Unauthorized`, `MigrationPlanNotFound`, `MigrationAlreadyExecuted`, `MigrationPlanVetoed`, `ProjectNotFound`, `MigrationAmountExceedsBalance` + +--- + +##### `get_emergency_migration_plan` +```rust +pub fn get_emergency_migration_plan(env: Env, project_id: u64) -> Result +``` +Read the current migration plan for a project (read-only, no state mutations). + +**Returns**: `Ok(EmergencyMigrationPlan)` +**Errors**: `MigrationPlanNotFound`, `ProjectNotFound` + +--- + + #### Lifecycle ##### `initialize` @@ -569,6 +637,9 @@ soroban contract invoke \ | **`AdminChangedEvent`** | `old_admin: Address` | `new_admin: Address` | `set_admin` | | **`ProjectCanceledEvent`** | — | `project_id: u64`, `caller: Address` | `cancel_project` | | **`ContributionRefundedEvent`** | — | `project_id: u64`, `contributor: Address`, `amount: i128` | `refund_contributors` | +| **`EmrgMigrProposedEvent`** | `proposed_by: Address`, `project_id: u64` | `amount: i128` | `propose_emergency_migration` | +| **`EmrgMigrExecutedEvent`** | `executed_by: Address`, `project_id: u64` | `amount: i128` | `execute_emergency_migration` | +| **`EmrgMigrVetoedEvent`** | `vetoed_by: Address`, `project_id: u64` | `vetoed_at: u64` | `veto_emergency_migration` | ### 2.3 Error Codes @@ -589,6 +660,14 @@ pub enum CrowdfundError { ProjectNotCancellable = 13, RefundFailed = 14, ContractNotPaused = 15, + // ── Emergency migration (issue #1047) ────────────────────────────── + EmergencyMigrationRequiresPause = 33, // contract must be paused + MigrationPlanAlreadyExists = 34, // pending plan already registered + MigrationPlanNotFound = 35, // no plan exists for this project + MigrationAlreadyExecuted = 36, // plan already executed or veto already final + InvalidMigrationRecipient = 37, // recipient is the contract itself + MigrationAmountExceedsBalance = 38, // amount > project vault balance + MigrationPlanVetoed = 39, // plan was vetoed; cannot execute } ``` @@ -609,6 +688,7 @@ pub enum CrowdfundError { | `MatchingPool(Address)` | Persistent | `i128` | Matching pool balance per token | | `RegisteredContributor(Address)` | Persistent | `bool` | Whether address is registered | | `Reputation(Address)` | Persistent | `i128` | Contributor reputation score | +| `EmergencyMigrationPlan(u64)` | Persistent | `EmergencyMigrationPlan` | Migration plan per project (issue #1047) | **Custom Types**: