diff --git a/Cargo.lock b/Cargo.lock index 3e48b94..a861f3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,7 @@ dependencies = [ name = "ads-bazaar-dispute-resolution" version = "0.1.0" dependencies = [ + "ads-bazaar-campaign-escrow", "ads-bazaar-shared", "soroban-sdk", ] diff --git a/Cargo.toml b/Cargo.toml index 34d681d..6ec5450 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ repository = "https://github.com/ads-bazaar/ads-bazaar-contract" [workspace.dependencies] soroban-sdk = "27.0.0" ads-bazaar-shared = { path = "contracts/shared" } +ads-bazaar-campaign-escrow = { path = "contracts/campaign-escrow" } [profile.release] opt-level = "z" diff --git a/contracts/campaign-escrow/src/error.rs b/contracts/campaign-escrow/src/error.rs index ba2073d..cfa363a 100644 --- a/contracts/campaign-escrow/src/error.rs +++ b/contracts/campaign-escrow/src/error.rs @@ -43,4 +43,7 @@ pub enum Error { ApplicationsExist = 22, /// The metadata string must be non-empty. InvalidMetadata = 23, + /// The application is frozen pending dispute arbitration, so it can + /// neither be paid out nor have its proof state changed. + PayoutFrozen = 24, } diff --git a/contracts/campaign-escrow/src/events.rs b/contracts/campaign-escrow/src/events.rs index 2b2b4cd..a73bded 100644 --- a/contracts/campaign-escrow/src/events.rs +++ b/contracts/campaign-escrow/src/events.rs @@ -137,6 +137,17 @@ pub struct CampaignMetadataUpdated { pub new_metadata: String, } +/// Emitted by `freeze_for_dispute` when the `dispute-resolution` contract +/// locks a creator's payout for arbitration. +#[contractevent] +#[derive(Clone, Debug)] +pub struct DisputeFrozen { + #[topic] + pub campaign_id: CampaignId, + #[topic] + pub creator: Address, +} + #[contractevent] #[derive(Clone, Debug)] pub struct DisputeResolved { diff --git a/contracts/campaign-escrow/src/lib.rs b/contracts/campaign-escrow/src/lib.rs index dc9177f..d91a15d 100644 --- a/contracts/campaign-escrow/src/lib.rs +++ b/contracts/campaign-escrow/src/lib.rs @@ -51,6 +51,17 @@ fn require_not_paused(env: &Env) -> Result<(), Error> { Ok(()) } +/// Reject any change to an application whose payout is frozen for dispute +/// arbitration. This covers more than payout: the proof state is the evidence +/// the arbiter is reviewing, so `reject_submission` clearing `proof_uri` +/// mid-dispute would destroy it. +fn require_not_frozen(application: &Application) -> Result<(), Error> { + if application.frozen { + return Err(Error::PayoutFrozen); + } + Ok(()) +} + #[contract] pub struct CampaignEscrowContract; @@ -265,6 +276,7 @@ impl CampaignEscrowContract { proof_uri: None, payout_amount: 0, proof_approved: false, + frozen: false, status: ApplicationStatus::Pending, }; storage::set_application(&env, &application); @@ -347,6 +359,7 @@ impl CampaignEscrowContract { } let mut application = storage::get_application(&env, campaign_id, &creator)?; + require_not_frozen(&application)?; if application.status != ApplicationStatus::Approved && application.status != ApplicationStatus::Rejected { @@ -380,6 +393,7 @@ impl CampaignEscrowContract { } let mut application = storage::get_application(&env, campaign_id, &creator)?; + require_not_frozen(&application)?; if application.status != ApplicationStatus::ProofSubmitted { return Err(Error::InvalidStatus); } @@ -404,6 +418,7 @@ impl CampaignEscrowContract { } let mut application = storage::get_application(&env, campaign_id, &creator)?; + require_not_frozen(&application)?; if application.status != ApplicationStatus::ProofSubmitted { return Err(Error::InvalidStatus); } @@ -429,6 +444,7 @@ impl CampaignEscrowContract { let mut campaign = storage::get_campaign(&env, campaign_id)?; let mut application = storage::get_application(&env, campaign_id, &creator)?; + require_not_frozen(&application)?; if application.status != ApplicationStatus::ProofSubmitted { return Err(Error::SubmissionNotPayable); } @@ -616,23 +632,61 @@ impl CampaignEscrowContract { Ok(()) } - /// Freeze a campaign's escrow so funds cannot be released while a + /// Freeze one creator's escrowed payout so it cannot be claimed while a /// dispute is under review. Callable only by the trusted /// `dispute-resolution` contract set at `initialize`. /// - /// TODO(contributors): implement once `dispute-resolution`'s call - /// interface is finalized. This should be an authenticated - /// contract-to-contract call (verify `env.current_contract_address()` - /// caller via `require_auth` on the dispute contract's own invocation, - /// or restrict by checking `get_dispute_contract` matches the invoker). - #[allow(unused_variables)] + /// The freeze is scoped to a single application, not the whole campaign — + /// a dispute with one creator must not stall payouts to every other + /// creator working the same brief. It therefore does not move the + /// campaign into `CampaignStatus::Disputed`; that status stays reserved + /// for a campaign-wide halt. + /// + /// Only an application that is still settleable can be frozen: one that + /// was approved at some point (nonzero `payout_amount`) and has not + /// already been paid. That is deliberately the *only* time bound on + /// raising a dispute — see `dispute-resolution::raise_dispute`. + /// + /// TODO(contributors): `resolve_dispute_payout` must clear `frozen` when + /// it settles, otherwise a resolved application stays locked forever. pub fn freeze_for_dispute( env: Env, campaign_id: CampaignId, creator: Address, ) -> Result<(), Error> { require_not_paused(&env)?; - todo!("design + implement dispute freeze hook — see doc comment above") + // Satisfied implicitly when the dispute contract is the direct + // invoker, so no separate caller argument is needed. + storage::get_dispute_contract(&env)?.require_auth(); + + let campaign = storage::get_campaign(&env, campaign_id)?; + if campaign.status == CampaignStatus::Cancelled { + return Err(Error::InvalidStatus); + } + + let mut application = storage::get_application(&env, campaign_id, &creator)?; + require_not_frozen(&application)?; + if application.status == ApplicationStatus::Paid || application.payout_amount <= 0 { + return Err(Error::SubmissionNotPayable); + } + + application.frozen = true; + storage::set_application(&env, &application); + events::DisputeFrozen { + campaign_id, + creator, + } + .publish(&env); + Ok(()) + } + + /// Read-only lookup of the business that owns `campaign_id`. + /// + /// Exists so `dispute-resolution` can authorize a business-raised dispute + /// with a single cross-contract read, without having to know this + /// contract's full `Campaign` type. + pub fn get_campaign_business(env: Env, campaign_id: CampaignId) -> Result
{ + Ok(storage::get_campaign(&env, campaign_id)?.business) } /// Apply a dispute outcome (from `dispute-resolution`) by releasing or @@ -720,6 +774,9 @@ impl CampaignEscrowContract { } application.status = ApplicationStatus::Paid; + // Settling here overrides any arbiter freeze, so drop it rather than + // leaving a paid application marked frozen. + application.frozen = false; storage::set_application(&env, &application); campaign.escrow_balance -= payout_amount; diff --git a/contracts/campaign-escrow/src/test.rs b/contracts/campaign-escrow/src/test.rs index a510498..7191daf 100644 --- a/contracts/campaign-escrow/src/test.rs +++ b/contracts/campaign-escrow/src/test.rs @@ -1311,3 +1311,210 @@ mod test_resolve_dispute { ); } } + +mod test_freeze_for_dispute { + use super::test_helpers::*; + use crate::{CampaignEscrowContractClient, Error}; + use soroban_sdk::testutils::{Address as _, MockAuth, MockAuthInvoke}; + use soroban_sdk::{Address, IntoVal, String}; + + /// Take a creator all the way to a business-approved, immediately + /// claimable submission on a freshly funded campaign. + fn payable_application( + env: &soroban_sdk::Env, + client: &CampaignEscrowContractClient, + business: &Address, + campaign_id: u64, + payout: i128, + ) -> Address { + let creator = Address::generate(env); + client.apply_to_campaign(&creator, &campaign_id, &String::from_str(env, "pitch")); + client.approve_creator(business, &campaign_id, &creator, &payout); + client.submit_proof(&creator, &campaign_id, &String::from_str(env, "proof")); + client.approve_submission(business, &campaign_id, &creator); + creator + } + + #[test] + fn freeze_marks_application_and_blocks_claim() { + 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); + let creator = payable_application(&env, &client, &business, id, 1_000_000); + + assert!(!client.get_application(&id, &creator).frozen); + client.freeze_for_dispute(&id, &creator); + assert!(client.get_application(&id, &creator).frozen); + + let result = client.try_claim_payment(&creator, &id); + assert_eq!(result, Err(Ok(Error::PayoutFrozen))); + } + + #[test] + fn freeze_blocks_claim_after_auto_approval_deadline() { + 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); + + let creator = Address::generate(&env); + client.apply_to_campaign(&creator, &id, &String::from_str(&env, "pitch")); + client.approve_creator(&business, &id, &creator, &1_000_000); + client.submit_proof(&creator, &id, &String::from_str(&env, "proof")); + client.freeze_for_dispute(&id, &creator); + + // Past the content deadline the creator would otherwise be + // auto-approved — this is the case a business raises a dispute for. + advance_time(&env, 604_801); + let result = client.try_claim_payment(&creator, &id); + assert_eq!(result, Err(Ok(Error::PayoutFrozen))); + } + + #[test] + fn freeze_preserves_proof_against_business_edits() { + 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); + + let creator = Address::generate(&env); + client.apply_to_campaign(&creator, &id, &String::from_str(&env, "pitch")); + client.approve_creator(&business, &id, &creator, &1_000_000); + client.submit_proof(&creator, &id, &String::from_str(&env, "proof")); + client.freeze_for_dispute(&id, &creator); + + assert_eq!( + client.try_reject_submission(&business, &id, &creator), + Err(Ok(Error::PayoutFrozen)) + ); + assert_eq!( + client.try_approve_submission(&business, &id, &creator), + Err(Ok(Error::PayoutFrozen)) + ); + assert_eq!( + client.try_submit_proof(&creator, &id, &String::from_str(&env, "proof2")), + Err(Ok(Error::PayoutFrozen)) + ); + assert_eq!( + client.get_application(&id, &creator).proof_uri, + Some(String::from_str(&env, "proof")) + ); + } + + #[test] + fn freeze_leaves_other_creators_claimable() { + 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); + let disputed = payable_application(&env, &client, &business, id, 1_000_000); + let uncontested = payable_application(&env, &client, &business, id, 1_000_000); + + client.freeze_for_dispute(&id, &disputed); + + client.claim_payment(&uncontested, &id); + assert_eq!( + client.get_application(&id, &uncontested).status, + ads_bazaar_shared::ApplicationStatus::Paid + ); + } + + #[test] + fn freeze_rejects_already_paid_application() { + 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); + let creator = payable_application(&env, &client, &business, id, 1_000_000); + client.claim_payment(&creator, &id); + + let result = client.try_freeze_for_dispute(&id, &creator); + assert_eq!(result, Err(Ok(Error::SubmissionNotPayable))); + } + + #[test] + fn freeze_rejects_creator_with_no_application() { + 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); + + let stranger = Address::generate(&env); + let result = client.try_freeze_for_dispute(&id, &stranger); + assert_eq!(result, Err(Ok(Error::ApplicationNotFound))); + } + + #[test] + fn freeze_rejects_unapproved_applicant() { + 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); + + // Applied but never approved, so no payout is committed to freeze. + let creator = Address::generate(&env); + client.apply_to_campaign(&creator, &id, &String::from_str(&env, "pitch")); + + let result = client.try_freeze_for_dispute(&id, &creator); + assert_eq!(result, Err(Ok(Error::SubmissionNotPayable))); + } + + #[test] + fn freeze_twice_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); + let creator = payable_application(&env, &client, &business, id, 1_000_000); + + client.freeze_for_dispute(&id, &creator); + let result = client.try_freeze_for_dispute(&id, &creator); + assert_eq!(result, Err(Ok(Error::PayoutFrozen))); + } + + #[test] + fn freeze_rejects_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); + let creator = payable_application(&env, &client, &business, id, 1_000_000); + client.cancel_campaign(&business, &id); + + let result = client.try_freeze_for_dispute(&id, &creator); + assert_eq!(result, Err(Ok(Error::InvalidStatus))); + } + + #[test] + fn freeze_requires_dispute_contract_auth() { + 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); + let creator = payable_application(&env, &client, &business, id, 1_000_000); + + // Authorize somebody who is not the configured dispute contract. + let stranger = Address::generate(&env); + env.mock_auths(&[MockAuth { + address: &stranger, + invoke: &MockAuthInvoke { + contract: &contract_id, + fn_name: "freeze_for_dispute", + args: (id, creator.clone()).into_val(&env), + sub_invokes: &[], + }, + }]); + + assert!(client.try_freeze_for_dispute(&id, &creator).is_err()); + assert!(!client.get_application(&id, &creator).frozen); + } + + #[test] + fn admin_resolve_dispute_settles_and_clears_freeze() { + 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); + let creator = payable_application(&env, &client, &business, id, 1_000_000); + client.freeze_for_dispute(&id, &creator); + + client.resolve_dispute(&admin, &id, &creator, &crate::DisputeResolution::PayCreator); + + let application = client.get_application(&id, &creator); + assert!(!application.frozen); + assert_eq!( + application.status, + ads_bazaar_shared::ApplicationStatus::Paid + ); + } +} diff --git a/contracts/campaign-escrow/src/types.rs b/contracts/campaign-escrow/src/types.rs index 49bf8b1..4216bd6 100644 --- a/contracts/campaign-escrow/src/types.rs +++ b/contracts/campaign-escrow/src/types.rs @@ -46,6 +46,11 @@ pub struct Application { pub payout_amount: i128, /// Whether the business has accepted the submitted proof (making it payable). pub proof_approved: bool, + /// Set by `freeze_for_dispute` while the `dispute-resolution` contract is + /// arbitrating this payout. Freezing is per-application rather than per- + /// campaign so one contested creator doesn't block payouts to every other + /// creator on the same campaign. Cleared by `resolve_dispute_payout`. + pub frozen: bool, pub status: ApplicationStatus, } diff --git a/contracts/dispute-resolution/Cargo.toml b/contracts/dispute-resolution/Cargo.toml index 8599058..3720e9f 100644 --- a/contracts/dispute-resolution/Cargo.toml +++ b/contracts/dispute-resolution/Cargo.toml @@ -19,3 +19,8 @@ ads-bazaar-shared = { workspace = true } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } +# Dev-only on purpose: tests register a real campaign-escrow contract to +# exercise the cross-contract calls in `raise_dispute`. A regular dependency +# would link escrow's contract exports into this contract's wasm — see +# `src/escrow.rs` for why the client is declared by hand instead. +ads-bazaar-campaign-escrow = { workspace = true, features = ["testutils"] } diff --git a/contracts/dispute-resolution/src/error.rs b/contracts/dispute-resolution/src/error.rs index 43f4968..f449629 100644 --- a/contracts/dispute-resolution/src/error.rs +++ b/contracts/dispute-resolution/src/error.rs @@ -13,4 +13,8 @@ pub enum Error { Unauthorized = 3, DisputeNotFound = 4, InvalidStatus = 5, + /// A dispute over this `(campaign_id, creator)` payout is already open. + DisputeAlreadyRaised = 6, + /// `reason_uri` must be non-empty — an arbiter needs something to review. + InvalidReason = 7, } diff --git a/contracts/dispute-resolution/src/escrow.rs b/contracts/dispute-resolution/src/escrow.rs new file mode 100644 index 0000000..6cdb169 --- /dev/null +++ b/contracts/dispute-resolution/src/escrow.rs @@ -0,0 +1,23 @@ +//! Client for the narrow slice of `campaign-escrow` that this contract calls. +//! +//! Declared locally with `#[contractclient]` rather than depending on the +//! `ads-bazaar-campaign-escrow` crate: linking that crate into this one would +//! pull its `#[contractimpl]` exports into this contract's wasm, so both +//! contracts' entry points would ship in a single binary. Keep these +//! signatures in sync with `campaign-escrow/src/lib.rs`. +//! +//! Both methods are declared infallible even though the escrow contract +//! returns `Result<_, Error>`. The encoding is identical on success, and an +//! error from the callee traps the whole invocation — which is the behavior +//! we want, since neither a missing campaign nor a refused freeze leaves any +//! sensible way to continue raising the dispute. +#![allow(dead_code)] + +use ads_bazaar_shared::CampaignId; +use soroban_sdk::{contractclient, Address, Env}; + +#[contractclient(name = "CampaignEscrowClient")] +pub trait CampaignEscrow { + fn get_campaign_business(env: Env, campaign_id: CampaignId) -> Address; + fn freeze_for_dispute(env: Env, campaign_id: CampaignId, creator: Address); +} diff --git a/contracts/dispute-resolution/src/lib.rs b/contracts/dispute-resolution/src/lib.rs index 59d74d5..86fbe92 100644 --- a/contracts/dispute-resolution/src/lib.rs +++ b/contracts/dispute-resolution/src/lib.rs @@ -10,6 +10,7 @@ #![no_std] mod error; +mod escrow; mod events; mod storage; mod types; @@ -17,7 +18,7 @@ mod types; pub use error::Error; pub use types::Dispute; -use ads_bazaar_shared::{CampaignId, DisputeId, DisputeOutcome}; +use ads_bazaar_shared::{CampaignId, DisputeId, DisputeOutcome, DisputeStatus}; use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, String}; /// Version string stored at `initialize` time. `upgrade` swaps the WASM @@ -46,14 +47,29 @@ impl DisputeResolutionContract { Ok(()) } - /// Raise a dispute over a creator's payout on a given campaign. + /// Raise a dispute over a creator's payout on a given campaign, freezing + /// that payout in escrow so it can't be claimed mid-review. /// - /// TODO(contributors): implement. Should call - /// `campaign_escrow::Client::freeze_for_dispute` on the configured - /// escrow contract once that hook exists, so funds can't be released - /// mid-dispute. Decide who may raise a dispute (business, creator, or - /// both) and whether there's a time window after proof submission. - #[allow(unused_variables)] + /// **Who may raise.** Either side of the contested payout: the `creator` + /// themselves, or the campaign's business. Both need it — a creator + /// disputes a business that won't approve delivered work, and a business + /// disputes a creator about to be auto-approved past the content + /// deadline. Business ownership lives in `campaign-escrow`, so it is + /// verified with a cross-contract read rather than trusted from the + /// caller. `raised_by == creator` needs no such read: escrow's + /// `freeze_for_dispute` refuses a campaign/creator pair with no + /// settleable application, so a stranger can't name themselves creator on + /// a campaign they never worked. + /// + /// **Time window.** There is deliberately none. This contract can't see + /// proof-submission timestamps, and the bound that actually protects + /// funds is "before the payout is claimed" — which escrow already + /// enforces by refusing to freeze an already-`Paid` application. A + /// deadline expressed in days would only add a second, weaker rule. + /// + /// **Repeat disputes.** One open dispute per `(campaign_id, creator)`. + /// A second attempt fails with `Error::DisputeAlreadyRaised` rather than + /// re-freezing an already-frozen payout. pub fn raise_dispute( env: Env, raised_by: Address, @@ -62,7 +78,46 @@ impl DisputeResolutionContract { reason_uri: String, ) -> Result