Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions contracts/campaign-escrow/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,15 @@ pub struct DisputeResolved {
pub creator_amount: i128,
pub business_amount: i128,
}

/// Emitted by `emergency_recover_campaign`. Kept distinct from
/// `CampaignCancelled` on purpose — this path bypasses the business's
/// consent entirely, so it must be trivially greppable/auditable on its own,
/// not blend in with routine cancellations.
#[contractevent]
#[derive(Clone, Debug)]
pub struct EmergencyRecovery {
#[topic]
pub campaign_id: CampaignId,
pub amount: i128,
}
79 changes: 79 additions & 0 deletions contracts/campaign-escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ use soroban_sdk::{contract, contractimpl, token, Address, BytesN, Env, String};
/// below.
const INITIAL_VERSION: &str = "0.1.0";

/// Extra grace period (on top of `completion_deadline` already having
/// passed) required before `emergency_recover_campaign` becomes callable.
/// Deliberately months-scale — far longer than the days/weeks relevant to
/// normal campaign operation — so this path can only ever apply to a
/// campaign that has been abandoned, never as a shortcut around
/// `expire_campaign`.
const EMERGENCY_RECOVERY_GRACE_PERIOD: u64 = 180 * 24 * 60 * 60; // ~6 months

/// Require that `admin` matches the address stored at `initialize` time.
/// Returns `Error::Unauthorized` for any other caller. Used by `pause` and
/// `unpause`.
Expand Down Expand Up @@ -629,6 +637,77 @@ impl CampaignEscrowContract {
Ok(())
}

/// Admin-only emergency recovery for a campaign abandoned long past every
/// normal deadline — e.g. the business's signing key is lost, rotated
/// away from, or otherwise unreachable, so none of `cancel_campaign` /
/// `expire_campaign` / `reclaim_surplus` (all gated on
/// `business.require_auth()`) can ever be called again for it.
///
/// Deliberately harder to reach than `expire_campaign`: gated on
/// `EMERGENCY_RECOVERY_GRACE_PERIOD` (months, not the days/weeks scale of
/// `completion_deadline`) *in addition to* `completion_deadline` having
/// already passed, so it can never substitute for the normal expiry path
/// and can't be used to casually bypass business consent.
///
/// Only ever sweeps the unallocated remainder (`escrow_balance -
/// committed_payouts`), exactly like `cancel_campaign` / `expire_campaign`
/// / `reclaim_surplus` — a payout already committed to an approved
/// creator stays reserved and claimable via `claim_payment` regardless of
/// how long the business has been unreachable.
///
/// Recovered funds go to `treasury`, not back to `business`: the whole
/// premise of this path is that the business's on-record address is
/// unreachable, so crediting funds there would just recreate the same
/// stuck-fund problem. Routing to `treasury` instead leaves them
/// reachable through a deliberate off-chain claims process (e.g. the
/// business proving ownership through some other channel), rather than
/// silently vanishing back into an address nobody can move funds out of.
pub fn emergency_recover_campaign(
env: Env,
admin: Address,
campaign_id: CampaignId,
) -> Result<(), Error> {
require_not_paused(&env)?;
require_admin(&env, &admin)?;

let mut campaign = storage::get_campaign(&env, campaign_id)?;
if campaign.status == CampaignStatus::Cancelled
|| campaign.status == CampaignStatus::Completed
{
return Err(Error::InvalidStatus);
}
let grace_period_end = campaign
.completion_deadline
.checked_add(EMERGENCY_RECOVERY_GRACE_PERIOD)
.ok_or(Error::InvalidAmount)?;
if env.ledger().timestamp() <= grace_period_end {
return Err(Error::DeadlineNotReached);
}

let token = token::Client::new(&env, &campaign.asset.token);
let contract = env.current_contract_address();
// Only the unallocated balance is recoverable; committed payouts stay
// reserved for approved creators who can still `claim_payment`.
let recovered = campaign
.escrow_balance
.checked_sub(campaign.committed_payouts)
.ok_or(Error::InvalidAmount)?;
if recovered > 0 {
token.transfer(&contract, &storage::get_treasury(&env)?, &recovered);
}
// Leave `committed_payouts` intact so approved-but-unpaid creators can
// still claim their payouts afterward.
campaign.escrow_balance = campaign.committed_payouts;
campaign.status = CampaignStatus::Cancelled;
storage::set_campaign(&env, &campaign);
events::EmergencyRecovery {
campaign_id,
amount: recovered,
}
.publish(&env);
Ok(())
}

/// Reclaim any unallocated (surplus) escrow back to the business. Surplus
/// is whatever escrow remains once committed payouts are excluded, so it
/// can be called while approved creators are still owed payment — those
Expand Down
107 changes: 106 additions & 1 deletion contracts/campaign-escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ mod test_initialize {

mod test_happy_path {
use super::test_helpers::*;
use crate::CampaignEscrowContractClient;
use crate::{CampaignEscrowContractClient, Error};
use soroban_sdk::testutils::Address as _;
use soroban_sdk::token::Client as TokenClient;
use soroban_sdk::{Address, Env};
Expand Down Expand Up @@ -486,6 +486,81 @@ mod test_happy_path {
assert_eq!(token_client.balance(&creator), creator_before + payout);
assert_eq!(token_client.balance(&contract_id), 0);
}

#[test]
fn emergency_recover_sweeps_unallocated_to_treasury() {
let (env, contract_id) = setup_env();
let (client, admin, _dispute, business, token) = bootstrap(&env, &contract_id, 50);
let token_client = TokenClient::new(&env, &token);

let budget: i128 = 10_000_000;
let id = create_funded_campaign(&env, &client, &business, &token, budget, 5);

// Long past both the content deadline and the emergency-recovery
// grace period — the business is treated as unreachable.
advance_time(&env, 604_800 + crate::EMERGENCY_RECOVERY_GRACE_PERIOD + 10);

// Treasury defaults to `admin` at `initialize`.
let treasury_before = token_client.balance(&admin);
let business_before = token_client.balance(&business);
client.emergency_recover_campaign(&admin, &id);

// Nothing was ever committed, so the whole budget is recovered —
// and it goes to treasury, not back to the unreachable business.
assert_eq!(token_client.balance(&admin), treasury_before + budget);
assert_eq!(token_client.balance(&business), business_before);
assert_eq!(token_client.balance(&contract_id), 0);

let campaign = client.get_campaign(&id);
assert_eq!(
campaign.status,
ads_bazaar_shared::CampaignStatus::Cancelled
);
}

#[test]
fn emergency_recover_preserves_committed_payout() {
let (env, contract_id) = setup_env();
let (client, admin, _dispute, business, token) = bootstrap(&env, &contract_id, 0);
let token_client = TokenClient::new(&env, &token);

let payout: i128 = 1_000_000;
let budget: i128 = payout * 5;
let id = create_funded_campaign(&env, &client, &business, &token, budget, 5);

let creator = Address::generate(&env);
run_to_payable(&env, &client, &business, &creator, &id, payout);

advance_time(&env, 604_800 + crate::EMERGENCY_RECOVERY_GRACE_PERIOD + 10);

let treasury_before = token_client.balance(&admin);
client.emergency_recover_campaign(&admin, &id);
// Only the unallocated (budget - payout) portion goes to treasury.
assert_eq!(
token_client.balance(&admin),
treasury_before + budget - payout
);
assert_eq!(token_client.balance(&contract_id), payout);

// The approved creator can still claim their payout afterward —
// exactly as with cancel_campaign/expire_campaign/reclaim_surplus.
let creator_before = token_client.balance(&creator);
client.claim_payment(&creator, &id);
assert_eq!(token_client.balance(&creator), creator_before + payout);
assert_eq!(token_client.balance(&contract_id), 0);
}

#[test]
fn emergency_recover_rejects_already_cancelled_campaign() {
let (env, contract_id) = setup_env();
let (client, admin, _dispute, business, token) = bootstrap(&env, &contract_id, 50);
let id = create_funded_campaign(&env, &client, &business, &token, 10_000_000, 5);
client.cancel_campaign(&business, &id);

advance_time(&env, 604_800 + crate::EMERGENCY_RECOVERY_GRACE_PERIOD + 10);
let result = client.try_emergency_recover_campaign(&admin, &id);
assert_eq!(result, Err(Ok(Error::InvalidStatus)));
}
}

mod test_protocol_config {
Expand Down Expand Up @@ -695,6 +770,19 @@ mod test_auth_failures {
let result = client.try_approve_creator(&business, &id, &creator, &1_000_000);
assert_eq!(result, Err(Ok(Error::AlreadySelected)));
}

#[test]
fn non_admin_cannot_emergency_recover() {
let (env, contract_id) = setup_env();
let (client, _admin, _dispute, business, token) = bootstrap(&env, &contract_id, 50);
let id = create_funded_campaign(&env, &client, &business, &token, 10_000_000, 5);

advance_time(&env, 604_800 + crate::EMERGENCY_RECOVERY_GRACE_PERIOD + 10);

let stranger = Address::generate(&env);
let result = client.try_emergency_recover_campaign(&stranger, &id);
assert_eq!(result, Err(Ok(Error::Unauthorized)));
}
}

mod test_deadline_enforcement {
Expand Down Expand Up @@ -795,6 +883,23 @@ mod test_deadline_enforcement {
let result = client.try_expire_campaign(&business, &id);
assert_eq!(result, Err(Ok(Error::DeadlineNotReached)));
}

#[test]
fn emergency_recover_before_grace_period_fails() {
let (env, contract_id) = setup_env();
let (client, admin, _dispute, business, token) = bootstrap(&env, &contract_id, 50);
let id = create_funded_campaign(&env, &client, &business, &token, 10_000_000, 5);

// Not reachable at all before the content deadline.
let result = client.try_emergency_recover_campaign(&admin, &id);
assert_eq!(result, Err(Ok(Error::DeadlineNotReached)));

// Past the content deadline — enough for `expire_campaign` — but
// nowhere near the much longer emergency-recovery grace period.
advance_time(&env, 604_800 + 10);
let result = client.try_emergency_recover_campaign(&admin, &id);
assert_eq!(result, Err(Ok(Error::DeadlineNotReached)));
}
}

mod test_error_variants {
Expand Down