feat: emergency admin recovery path for abandoned campaigns - #52
Conversation
…paigns cancel_campaign, expire_campaign, and reclaim_surplus all require business.require_auth(), which is correct day-to-day (no hidden admin release path) but leaves escrow permanently stuck if a business's signing key is ever lost, rotated away from, or the account otherwise abandoned — nobody can move committed-but-unclaimed-aside funds ever again. Add emergency_recover_campaign: admin-only, gated on completion_deadline plus a new months-scale EMERGENCY_RECOVERY_GRACE_PERIOD constant (~6 months) so it's deliberately much harder to reach than expire_campaign and can never substitute for the normal expiry path. It only ever sweeps the unallocated remainder (escrow_balance - committed_payouts), mirroring the existing cancel/expire/reclaim pattern exactly — a payout already committed to an approved creator stays reserved and claimable regardless of how long the business has been unreachable. Recovered funds go to treasury rather than back to business: the 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. Treasury leaves them reachable through a deliberate off-chain claims process instead. Emits a distinct EmergencyRecovery event (not folded into CampaignCancelled) so this path stays trivially auditable/greppable on its own, per the issue's fund-safety concern. Closes Ads-Bazaar#48
|
Merged, nice work — the grace period stacked on top of completion_deadline (not replacing it) and reusing the escrow_balance - committed_payouts fund-safety pattern are exactly the right calls here, and the treasury-not-business routing is well justified in the write-up. Since your branch was cut just before #51 merged, there was a real textual overlap on freeze_for_dispute's doc comments and signature — confirmed via git merge-tree that the merge correctly kept #51's caller argument and updated docs while adding emergency_recover_campaign cleanly alongside it. No follow-up fix needed. Verified before merging: fmt, full workspace build, all 103 tests (including the new emergency-recovery test module), clippy -D warnings, and the wasm32v1-none release build. CI is green on main: https://github.com/Ads-Bazaar/ads-bazaar-contract/actions/runs/30230403166 |
Problem
Closes #48.
cancel_campaign,expire_campaign, andreclaim_surplusall requirebusiness.require_auth(). That's correct and intentional day-to-day — per this repo's escrow design principles (README.md): "No hidden admin release path — release/refund/dispute-resolution hooks are the only ways funds move." But it also means: if a business's signing key is ever lost, compromised-then-rotated-away-from, or the account otherwise abandoned, any escrowed funds still sitting in that campaign (whether fully unallocated, or partly committed to a creator) become permanently unrecoverable by anyone.Fix
Added
emergency_recover_campaign(env, admin, campaign_id):require_adminhelper (same auth model asresolve_dispute,update_fee_bps, etc.).expire_campaign. Gated on a newEMERGENCY_RECOVERY_GRACE_PERIODconstant (180 days, ~6 months) added on top ofcompletion_deadlinealready having passed — not a separate, shorter timer. This means the path is reachable only many months after the point at whichexpire_campaignalready becomes callable, so it can never substitute for the normal expiry flow or be used to casually route around business consent.committed_payoutsexactly like the other three exit paths. Only ever sweepsescrow_balance - committed_payouts— the same pattern already used bycancel_campaign/expire_campaign/reclaim_surplus. A payout already committed to an approved, unpaid creator stays reserved and claimable viaclaim_paymentregardless of how long the business has been unreachable.EmergencyRecoveryevent (not folded intoCampaignCancelled), so this path is trivially greppable/auditable on-chain, exactly as the issue asks — this operation should never be quiet.CampaignStatus::Cancelledfor the resulting state rather than introducing a new status variant, since the end state (no further business action possible, committed payouts still claimable) is identical to the existing exit paths — theEmergencyRecoveryevent is what distinguishes how the campaign got there, not a new enum value across both contracts.Design question: where do recovered funds go?
Routed to
treasury, not back tobusiness. The whole premise of this path is that the business's on-record address is unreachable — crediting funds back there would just recreate the exact same stuck-fund problem this issue is about. Treasury instead leaves the funds reachable through a deliberate off-chain claims process (e.g. the business proving ownership through some other channel, support ticket, legal process, etc.) rather than silently re-stranding them. Flagging this here in case maintainers want to revisit — it was the more defensible of the two options the issue raised, but it's a product decision as much as a technical one.Scope note
The issue's "Problem" section also gestures at eventually recovering committed-but-unclaimed creator funds once the creator side has "had every reasonable chance to claim" — but the acceptance criteria are explicit that committed payouts should be preserved unconditionally, matching
cancel_campaign/expire_campaign/reclaim_surplustoday. This PR implements exactly that (unallocated-only), leaving any future extension to a long-idle committed balance as a separate follow-up if the maintainers want it.Test coverage added
emergency_recover_sweeps_unallocated_to_treasury— happy path, full budget with no commitments goes to treasury, campaign moves toCancelled.emergency_recover_preserves_committed_payout— a committed-but-unclaimed creator payout survives recovery and is still claimable afterward (mirrors the existingcancel_preserves_committed_payout/expire_preserves_committed_payout/reclaim_preserves_committed_payoutregression tests).emergency_recover_before_grace_period_fails— rejected both beforecompletion_deadlineand after it (at the pointexpire_campaignwould already succeed), proving the extra grace period is enforced on top, not instead of, the existing deadline.emergency_recover_rejects_already_cancelled_campaign— can't be invoked twice / after a normal cancellation.non_admin_cannot_emergency_recover— a non-admin caller is rejected.Test plan
cargo test --workspace— 86 + 17 tests pass incampaign-escrow/dispute-resolution(4 new tests added)cargo clippy --workspace --all-targets -- -D warnings— cleancargo fmt --all -- --check— cleancargo build --workspace --target wasm32v1-none --release— builds