Skip to content

feat: emergency admin recovery path for abandoned campaigns - #52

Merged
JamesVictor-O merged 1 commit into
Ads-Bazaar:mainfrom
olathedev:feat/48-emergency-admin-recovery
Jul 27, 2026
Merged

feat: emergency admin recovery path for abandoned campaigns#52
JamesVictor-O merged 1 commit into
Ads-Bazaar:mainfrom
olathedev:feat/48-emergency-admin-recovery

Conversation

@olathedev

Copy link
Copy Markdown
Contributor

Problem

Closes #48.

cancel_campaign, expire_campaign, and reclaim_surplus all require business.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):

  • Admin-only, via the existing require_admin helper (same auth model as resolve_dispute, update_fee_bps, etc.).
  • Deliberately harder to reach than expire_campaign. Gated on a new EMERGENCY_RECOVERY_GRACE_PERIOD constant (180 days, ~6 months) added on top of completion_deadline already having passed — not a separate, shorter timer. This means the path is reachable only many months after the point at which expire_campaign already becomes callable, so it can never substitute for the normal expiry flow or be used to casually route around business consent.
  • Respects committed_payouts exactly like the other three exit paths. Only ever sweeps escrow_balance - committed_payouts — the same pattern already used by cancel_campaign / expire_campaign / reclaim_surplus. A payout already committed to an approved, unpaid creator stays reserved and claimable via claim_payment regardless of how long the business has been unreachable.
  • Emits a distinct EmergencyRecovery event (not folded into CampaignCancelled), so this path is trivially greppable/auditable on-chain, exactly as the issue asks — this operation should never be quiet.
  • Reuses CampaignStatus::Cancelled for 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 — the EmergencyRecovery event 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 to business. 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_surplus today. 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 to Cancelled.
  • emergency_recover_preserves_committed_payout — a committed-but-unclaimed creator payout survives recovery and is still claimable afterward (mirrors the existing cancel_preserves_committed_payout / expire_preserves_committed_payout / reclaim_preserves_committed_payout regression tests).
  • emergency_recover_before_grace_period_fails — rejected both before completion_deadline and after it (at the point expire_campaign would 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 in campaign-escrow / dispute-resolution (4 new tests added)
  • cargo clippy --workspace --all-targets -- -D warnings — clean
  • cargo fmt --all -- --check — clean
  • cargo build --workspace --target wasm32v1-none --release — builds

…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
@JamesVictor-O

Copy link
Copy Markdown
Contributor

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: emergency admin recovery path for campaigns abandoned long past every deadline

2 participants