From 7204b7ee30cfe141994258cb1c84780d644760a6 Mon Sep 17 00:00:00 2001 From: Anuoluwapo25 Date: Thu, 23 Jul 2026 19:55:50 +0100 Subject: [PATCH] feat: implement raise_dispute and the escrow freeze_for_dispute hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the dispute-resolution contract's entry point from a todo!() panic into a working flow, plus the campaign-escrow hook it depends on. dispute-resolution::raise_dispute now persists a Dispute record under a fresh DisputeId, publishes DisputeRaised, and freezes the contested payout in escrow so it can't be claimed mid-review. Design decisions settled in this PR (documented at the call site): - Who may raise: either side of the payout — the creator, or the campaign's business. Business ownership lives in campaign-escrow, so it's verified via a cross-contract get_campaign_business read rather than trusted from the caller. A stranger naming themselves creator is rejected because escrow refuses to freeze a campaign/creator pair with no settleable application. - Time window: none. The bound that protects funds is "before claim", which escrow already enforces by refusing to freeze an already-Paid application. This contract can't see proof-submission timestamps anyway. - Repeat disputes: one open dispute per (campaign_id, creator); a second attempt fails with DisputeAlreadyRaised. campaign-escrow::freeze_for_dispute is implemented as a per-application freeze (not a campaign-wide halt) so one contested creator doesn't stall payouts to everyone else on the same brief. It's authenticated to the configured dispute_contract, and the new `frozen` flag blocks claim_payment, submit_proof, approve_submission and reject_submission — the last three because the proof is the evidence the arbiter reviews. resolve_dispute clears the flag on settlement. The dispute contract declares a hand-written #[contractclient] for the two escrow methods it calls rather than depending on the escrow crate at build time, so escrow's contract exports don't leak into the dispute wasm; escrow is a dev-dependency only, for the cross-contract tests. resolve_dispute_payout remains todo!() — see its doc comment; it must clear `frozen` when it lands, as the admin resolve_dispute shortcut already does. --- Cargo.lock | 1 + Cargo.toml | 1 + contracts/campaign-escrow/src/error.rs | 3 + contracts/campaign-escrow/src/events.rs | 11 + contracts/campaign-escrow/src/lib.rs | 73 ++++- contracts/campaign-escrow/src/test.rs | 207 ++++++++++++ contracts/campaign-escrow/src/types.rs | 5 + contracts/dispute-resolution/Cargo.toml | 5 + contracts/dispute-resolution/src/error.rs | 4 + contracts/dispute-resolution/src/escrow.rs | 23 ++ contracts/dispute-resolution/src/lib.rs | 73 ++++- contracts/dispute-resolution/src/storage.rs | 33 +- contracts/dispute-resolution/src/test.rs | 332 ++++++++++++++++++-- docs/ARCHITECTURE.md | 10 +- 14 files changed, 742 insertions(+), 39 deletions(-) create mode 100644 contracts/dispute-resolution/src/escrow.rs 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 { raised_by.require_auth(); - todo!("design + implement dispute raising — see doc comment above") + if reason_uri.is_empty() { + return Err(Error::InvalidReason); + } + if storage::get_open_dispute(&env, campaign_id, &creator).is_some() { + return Err(Error::DisputeAlreadyRaised); + } + + let escrow = escrow::CampaignEscrowClient::new(&env, &storage::get_escrow_contract(&env)?); + if raised_by != creator && raised_by != escrow.get_campaign_business(&campaign_id) { + return Err(Error::Unauthorized); + } + + // Freeze first: if escrow refuses (no application, already paid) the + // whole invocation traps and no dispute record is left behind. + escrow.freeze_for_dispute(&campaign_id, &creator); + + let dispute_id = storage::next_dispute_id(&env); + storage::set_dispute( + &env, + dispute_id, + &Dispute { + campaign_id, + creator: creator.clone(), + raised_by: raised_by.clone(), + reason_uri, + arbiter: None, + status: DisputeStatus::Raised, + outcome: DisputeOutcome::Pending, + raised_at: env.ledger().timestamp(), + resolved_at: None, + }, + ); + storage::set_open_dispute(&env, campaign_id, &creator, dispute_id); + + events::DisputeRaised { + dispute_id, + raised_by, + } + .publish(&env); + Ok(dispute_id) } /// Assign an arbiter to review a raised dispute. diff --git a/contracts/dispute-resolution/src/storage.rs b/contracts/dispute-resolution/src/storage.rs index 221aff3..55e32cc 100644 --- a/contracts/dispute-resolution/src/storage.rs +++ b/contracts/dispute-resolution/src/storage.rs @@ -1,6 +1,6 @@ #![allow(dead_code)] -use ads_bazaar_shared::DisputeId; +use ads_bazaar_shared::{CampaignId, DisputeId}; use soroban_sdk::{contracttype, Address, Env, String}; use crate::error::Error; @@ -17,6 +17,9 @@ pub enum DataKey { Version, NextDisputeId, Dispute(DisputeId), + /// The open dispute over a given campaign/creator payout, if any. Keeps + /// `raise_dispute` from opening a second dispute over the same payout. + OpenDispute(CampaignId, Address), } pub fn is_initialized(env: &Env) -> bool { @@ -86,3 +89,31 @@ pub fn set_dispute(env: &Env, id: DisputeId, dispute: &Dispute) { PERSISTENT_BUMP_LEDGERS, ); } + +pub fn get_open_dispute( + env: &Env, + campaign_id: CampaignId, + creator: &Address, +) -> Option { + env.storage() + .persistent() + .get(&DataKey::OpenDispute(campaign_id, creator.clone())) +} + +pub fn set_open_dispute(env: &Env, campaign_id: CampaignId, creator: &Address, id: DisputeId) { + let key = DataKey::OpenDispute(campaign_id, creator.clone()); + env.storage().persistent().set(&key, &id); + env.storage().persistent().extend_ttl( + &key, + PERSISTENT_LIFETIME_THRESHOLD, + PERSISTENT_BUMP_LEDGERS, + ); +} + +/// Clear the open-dispute marker for a payout so a fresh dispute can be +/// raised over it later. Called once `resolve_dispute` is implemented. +pub fn clear_open_dispute(env: &Env, campaign_id: CampaignId, creator: &Address) { + env.storage() + .persistent() + .remove(&DataKey::OpenDispute(campaign_id, creator.clone())); +} diff --git a/contracts/dispute-resolution/src/test.rs b/contracts/dispute-resolution/src/test.rs index f5fcbd3..b7bba0e 100644 --- a/contracts/dispute-resolution/src/test.rs +++ b/contracts/dispute-resolution/src/test.rs @@ -1,12 +1,106 @@ -//! Baseline tests covering what's actually implemented so far -//! (`initialize` and `get_dispute`). Add tests alongside each `todo!()` as -//! it gets implemented in `lib.rs`. +//! Tests for the dispute-resolution contract. +//! +//! `raise_dispute` reads from and writes to `campaign-escrow` across a +//! contract boundary, so these tests register a real escrow contract rather +//! than a generated placeholder address and point the two at each other the +//! way a deployment would. Helpers live in `test_helpers`. #![cfg(test)] use super::*; use soroban_sdk::testutils::Address as _; use soroban_sdk::{BytesN, Env}; +mod test_helpers { + use crate::{DisputeResolutionContract, DisputeResolutionContractClient}; + use ads_bazaar_campaign_escrow::{CampaignEscrowContract, CampaignEscrowContractClient}; + use ads_bazaar_shared::PayoutAsset; + use soroban_sdk::testutils::{Address as _, Ledger as _}; + use soroban_sdk::token::StellarAssetClient; + use soroban_sdk::{Address, Env, String}; + + pub const BASE_TIME: u64 = 1_000_000; + pub const BUSINESS_FUNDS: i128 = 1_000_000_000; + pub const PAYOUT: i128 = 1_000_000; + pub const COMPLETION_WINDOW: u64 = 604_800; + + /// Register both contracts at a fixed base timestamp with all auths + /// mocked. Returns `(env, escrow_id, dispute_id)`. + pub fn setup_env() -> (Env, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = BASE_TIME); + let escrow_id = env.register(CampaignEscrowContract, ()); + let dispute_id = env.register(DisputeResolutionContract, ()); + (env, escrow_id, dispute_id) + } + + pub struct Fixture<'a> { + pub escrow: CampaignEscrowContractClient<'a>, + pub disputes: DisputeResolutionContractClient<'a>, + pub business: Address, + pub creator: Address, + pub campaign_id: u64, + } + + impl Fixture<'_> { + /// Take another creator through apply → approve → submit proof on the + /// same campaign, so tests can assert on a second disputable payout. + pub fn add_creator(&self, env: &Env) -> Address { + let creator = Address::generate(env); + self.escrow.apply_to_campaign( + &creator, + &self.campaign_id, + &String::from_str(env, "pitch"), + ); + self.escrow + .approve_creator(&self.business, &self.campaign_id, &creator, &PAYOUT); + self.escrow + .submit_proof(&creator, &self.campaign_id, &String::from_str(env, "proof")); + creator + } + } + + /// Initialize both contracts pointing at each other, fund a campaign, and + /// take one creator as far as a submitted (not yet approved) proof — the + /// state a dispute is actually raised from. + pub fn bootstrap<'a>(env: &'a Env, escrow_id: &Address, dispute_id: &Address) -> Fixture<'a> { + let escrow = CampaignEscrowContractClient::new(env, escrow_id); + let disputes = DisputeResolutionContractClient::new(env, dispute_id); + let admin = Address::generate(env); + escrow.initialize(&admin, dispute_id, &50); + disputes.initialize(&admin, escrow_id); + + let business = Address::generate(env); + let token = env.register_stellar_asset_contract_v2(Address::generate(env)); + StellarAssetClient::new(env, &token.address()).mint(&business, &BUSINESS_FUNDS); + + let now = env.ledger().timestamp(); + let campaign_id = escrow.create_campaign( + &business, + &PayoutAsset { + token: token.address(), + symbol: String::from_str(env, "USDC"), + }, + &10_000_000, + &5, + &(now + 86_400), + &(now + COMPLETION_WINDOW), + &String::from_str(env, "ipfs://brief"), + ); + escrow.fund_campaign(&business, &campaign_id); + + let fixture = Fixture { + escrow, + disputes, + business, + creator: Address::generate(env), + campaign_id, + }; + let creator = fixture.add_creator(env); + Fixture { creator, ..fixture } + } +} + fn setup(env: &Env) -> (DisputeResolutionContractClient<'_>, Address, Address) { let contract_id = env.register(DisputeResolutionContract, ()); let client = DisputeResolutionContractClient::new(env, &contract_id); @@ -79,20 +173,222 @@ fn get_dispute_not_found_before_creation() { assert_eq!(result, Err(Ok(Error::DisputeNotFound))); } -#[test] -#[should_panic(expected = "not yet implemented")] -fn raise_dispute_is_not_yet_implemented() { - let env = Env::default(); - env.mock_all_auths(); - let (client, admin, escrow_contract) = setup(&env); - client.initialize(&admin, &escrow_contract); +mod test_raise_dispute { + use super::test_helpers::*; + use crate::Error; + use ads_bazaar_shared::{DisputeOutcome, DisputeStatus}; + use soroban_sdk::testutils::{Address as _, Ledger as _}; + use soroban_sdk::{Address, String}; + + #[test] + fn creator_raises_dispute_and_stored_fields_match() { + let (env, escrow_id, dispute_id) = setup_env(); + let f = bootstrap(&env, &escrow_id, &dispute_id); + let reason = String::from_str(&env, "ipfs://evidence"); + + let id = f + .disputes + .raise_dispute(&f.creator, &f.campaign_id, &f.creator, &reason); + assert_eq!(id, 0); + + let dispute = f.disputes.get_dispute(&id); + assert_eq!(dispute.campaign_id, f.campaign_id); + assert_eq!(dispute.creator, f.creator); + assert_eq!(dispute.raised_by, f.creator); + assert_eq!(dispute.reason_uri, reason); + assert_eq!(dispute.arbiter, None); + assert_eq!(dispute.status, DisputeStatus::Raised); + assert_eq!(dispute.outcome, DisputeOutcome::Pending); + assert_eq!(dispute.raised_at, BASE_TIME); + assert_eq!(dispute.resolved_at, None); + } + + #[test] + fn dispute_ids_increment_per_payout() { + let (env, escrow_id, dispute_id) = setup_env(); + let f = bootstrap(&env, &escrow_id, &dispute_id); + let reason = String::from_str(&env, "ipfs://evidence"); + let second = f.add_creator(&env); + + let first_id = f + .disputes + .raise_dispute(&f.creator, &f.campaign_id, &f.creator, &reason); + let second_id = f + .disputes + .raise_dispute(&second, &f.campaign_id, &second, &reason); + + assert_eq!(first_id, 0); + assert_eq!(second_id, 1); + assert_eq!(f.disputes.get_dispute(&second_id).creator, second); + } + + #[test] + fn business_may_raise_dispute_against_creator() { + let (env, escrow_id, dispute_id) = setup_env(); + let f = bootstrap(&env, &escrow_id, &dispute_id); + + let id = f.disputes.raise_dispute( + &f.business, + &f.campaign_id, + &f.creator, + &String::from_str(&env, "ipfs://not-as-briefed"), + ); + + let dispute = f.disputes.get_dispute(&id); + assert_eq!(dispute.raised_by, f.business); + assert_eq!(dispute.creator, f.creator); + } + + #[test] + fn stranger_cannot_raise_dispute() { + let (env, escrow_id, dispute_id) = setup_env(); + let f = bootstrap(&env, &escrow_id, &dispute_id); + + let stranger = Address::generate(&env); + let result = f.disputes.try_raise_dispute( + &stranger, + &f.campaign_id, + &f.creator, + &String::from_str(&env, "ipfs://evidence"), + ); + + assert_eq!(result, Err(Ok(Error::Unauthorized))); + // A rejected raise must not have frozen the payout on its way out. + assert!(!f.escrow.get_application(&f.campaign_id, &f.creator).frozen); + } + + #[test] + fn raise_dispute_freezes_payout_in_escrow() { + let (env, escrow_id, dispute_id) = setup_env(); + let f = bootstrap(&env, &escrow_id, &dispute_id); + f.escrow + .approve_submission(&f.business, &f.campaign_id, &f.creator); + + f.disputes.raise_dispute( + &f.creator, + &f.campaign_id, + &f.creator, + &String::from_str(&env, "ipfs://evidence"), + ); + + assert!(f.escrow.get_application(&f.campaign_id, &f.creator).frozen); + assert_eq!( + f.escrow.try_claim_payment(&f.creator, &f.campaign_id), + Err(Ok(ads_bazaar_campaign_escrow::Error::PayoutFrozen)) + ); + } + + #[test] + fn second_dispute_over_same_payout_rejected() { + let (env, escrow_id, dispute_id) = setup_env(); + let f = bootstrap(&env, &escrow_id, &dispute_id); + let reason = String::from_str(&env, "ipfs://evidence"); + + f.disputes + .raise_dispute(&f.creator, &f.campaign_id, &f.creator, &reason); + let result = f + .disputes + .try_raise_dispute(&f.business, &f.campaign_id, &f.creator, &reason); + + assert_eq!(result, Err(Ok(Error::DisputeAlreadyRaised))); + } + + #[test] + fn empty_reason_rejected() { + let (env, escrow_id, dispute_id) = setup_env(); + let f = bootstrap(&env, &escrow_id, &dispute_id); + + let result = f.disputes.try_raise_dispute( + &f.creator, + &f.campaign_id, + &f.creator, + &String::from_str(&env, ""), + ); + + assert_eq!(result, Err(Ok(Error::InvalidReason))); + } + + #[test] + fn dispute_may_be_raised_after_content_deadline() { + let (env, escrow_id, dispute_id) = setup_env(); + let f = bootstrap(&env, &escrow_id, &dispute_id); + + // Past the deadline the creator is auto-approved and could claim at + // any moment — exactly when a business needs to be able to dispute. + env.ledger() + .with_mut(|l| l.timestamp = BASE_TIME + COMPLETION_WINDOW + 1); + f.disputes.raise_dispute( + &f.business, + &f.campaign_id, + &f.creator, + &String::from_str(&env, "ipfs://evidence"), + ); + + assert_eq!( + f.escrow.try_claim_payment(&f.creator, &f.campaign_id), + Err(Ok(ads_bazaar_campaign_escrow::Error::PayoutFrozen)) + ); + } + + #[test] + fn dispute_over_already_paid_payout_is_rejected_by_escrow() { + let (env, escrow_id, dispute_id) = setup_env(); + let f = bootstrap(&env, &escrow_id, &dispute_id); + f.escrow + .approve_submission(&f.business, &f.campaign_id, &f.creator); + f.escrow.claim_payment(&f.creator, &f.campaign_id); + + let result = f.disputes.try_raise_dispute( + &f.creator, + &f.campaign_id, + &f.creator, + &String::from_str(&env, "ipfs://too-late"), + ); + + assert!(result.is_err()); + assert_eq!( + f.disputes.try_get_dispute(&0), + Err(Ok(Error::DisputeNotFound)) + ); + } + + #[test] + fn creator_with_no_application_cannot_raise_dispute() { + let (env, escrow_id, dispute_id) = setup_env(); + let f = bootstrap(&env, &escrow_id, &dispute_id); + + let stranger = Address::generate(&env); + let result = f.disputes.try_raise_dispute( + &stranger, + &f.campaign_id, + &stranger, + &String::from_str(&env, "ipfs://evidence"), + ); + + assert!(result.is_err()); + } + + #[test] + fn other_creators_stay_claimable_while_one_is_disputed() { + let (env, escrow_id, dispute_id) = setup_env(); + let f = bootstrap(&env, &escrow_id, &dispute_id); + let uncontested = f.add_creator(&env); + f.escrow + .approve_submission(&f.business, &f.campaign_id, &uncontested); + + f.disputes.raise_dispute( + &f.creator, + &f.campaign_id, + &f.creator, + &String::from_str(&env, "ipfs://evidence"), + ); - let raised_by = Address::generate(&env); - let creator = Address::generate(&env); - client.raise_dispute( - &raised_by, - &0, - &creator, - &String::from_str(&env, "ipfs://evidence"), - ); + f.escrow.claim_payment(&uncontested, &f.campaign_id); + assert_eq!( + f.escrow + .get_application(&f.campaign_id, &uncontested) + .status, + ads_bazaar_shared::ApplicationStatus::Paid + ); + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 399b6e8..1f41ab9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -29,7 +29,9 @@ types instead of drifting apart. interface: `campaign-escrow::freeze_for_dispute` / `resolve_dispute_payout`, callable only by the configured `dispute_contract` address, and `dispute-resolution` calling back into - escrow once a dispute resolves. Both hooks are currently `todo!()`. + escrow once a dispute resolves. `freeze_for_dispute` is implemented (a + per-application freeze wired up by `raise_dispute`); `resolve_dispute_payout` + is still `todo!()`. ## Multi-currency design @@ -58,8 +60,10 @@ logic for the core flows is left as `todo!()`: | `create_campaign`, `fund_campaign` | `todo!()` | | `apply_to_campaign`, `approve_creator`, `submit_proof` | `todo!()` | | `release_payment`, `cancel_campaign` | `todo!()` | -| `freeze_for_dispute`, `resolve_dispute_payout` | `todo!()` | -| `raise_dispute`, `assign_arbiter`, `resolve_dispute` | `todo!()` | +| `freeze_for_dispute` | Implemented | +| `resolve_dispute_payout` | `todo!()` | +| `raise_dispute` | Implemented | +| `assign_arbiter`, `resolve_dispute` (dispute-resolution) | `todo!()` | Each `todo!()` has a doc comment directly above it describing the intended behavior and the open design questions it depends on — start there.