Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
29 changes: 29 additions & 0 deletions contracts/campaign-escrow/src/dispute.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//! Client for the narrow slice of `dispute-resolution` that this contract calls.
//!
//! Declared locally with `#[contractclient]` rather than depending on the
//! `ads-bazaar-dispute-resolution` 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 `dispute-resolution/src/lib.rs`. This mirrors the
//! local `escrow` client in `dispute-resolution/src/escrow.rs`.
//!
//! The method is declared infallible even though the dispute-resolution
//! 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: `resolve_dispute` must not settle a payout while leaving
//! dispute-resolution's record behind as permanently open.
#![allow(dead_code)]

use ads_bazaar_shared::{CampaignId, DisputeOutcome};
use soroban_sdk::{contractclient, Address, Env};

#[contractclient(name = "DisputeResolutionClient")]
pub trait DisputeContract {
fn close_dispute(
env: Env,
caller: Address,
campaign_id: CampaignId,
creator: Address,
outcome: DisputeOutcome,
);
}
42 changes: 37 additions & 5 deletions contracts/campaign-escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
//! USDC, etc. without per-asset special-casing.
#![no_std]

mod dispute;
mod error;
mod events;
mod storage;
Expand All @@ -20,7 +21,9 @@ mod types;
pub use error::Error;
pub use types::{Application, Campaign, DisputeResolution, ProtocolConfig};

use ads_bazaar_shared::{ApplicationStatus, CampaignId, CampaignStatus, PayoutAsset};
use ads_bazaar_shared::{
ApplicationStatus, CampaignId, CampaignStatus, DisputeOutcome, PayoutAsset,
};
use soroban_sdk::{contract, contractimpl, token, Address, BytesN, Env, String};

/// Version string stored at `initialize` time. `upgrade` swaps the WASM
Expand Down Expand Up @@ -895,6 +898,15 @@ impl CampaignEscrowContract {
/// - At least `MIN_EVIDENCE_WINDOW` must have elapsed since that freeze
/// (`Error::EvidenceWindowOpen` otherwise), so neither party's payout
/// can be reallocated out from under them without warning.
///
/// Reconciliation with `dispute-resolution`: when the frozen payout was
/// frozen via `raise_dispute` (the normal flow), this contract closes the
/// corresponding open dispute record out by calling
/// `dispute-resolution::close_dispute` with the `DisputeOutcome` that
/// matches `resolution`, so `get_dispute` never reports a permanently
/// open dispute over money that has moved. When the freeze came from the
/// admin's direct `freeze_for_dispute` path there is no record to close,
/// and the call is a no-op.
pub fn resolve_dispute(
env: Env,
admin: Address,
Expand Down Expand Up @@ -929,16 +941,22 @@ impl CampaignEscrowContract {

let payout_amount = application.payout_amount;
let fee_bps = campaign.fee_bps;
let (creator_gross, business_amount) = match resolution {
DisputeResolution::PayCreator => (payout_amount, 0),
DisputeResolution::RefundBusiness => (0, payout_amount),
let (creator_gross, business_amount, dispute_outcome) = match resolution {
DisputeResolution::PayCreator => (payout_amount, 0, DisputeOutcome::CreatorFavored),
DisputeResolution::RefundBusiness => {
(0, payout_amount, DisputeOutcome::BusinessFavored)
}
DisputeResolution::Split(bps) => {
if !(0..=ads_bazaar_shared::BASIS_POINTS_DENOMINATOR).contains(&bps) {
return Err(Error::InvalidAmount);
}
let creator_gross = payout_amount.checked_mul(bps).ok_or(Error::InvalidAmount)?
/ ads_bazaar_shared::BASIS_POINTS_DENOMINATOR;
(creator_gross, payout_amount - creator_gross)
(
creator_gross,
payout_amount - creator_gross,
DisputeOutcome::Split(bps),
)
}
};

Expand Down Expand Up @@ -982,6 +1000,20 @@ impl CampaignEscrowContract {
}
storage::set_campaign(&env, &campaign);

// Keep `dispute-resolution`'s record in step: if this payout has an
// open dispute raised through `raise_dispute`, close it out so its
// `get_dispute` read never reports a permanently-open dispute over
// funds that have already moved. No-op when the freeze came from the
// admin's direct path and no record exists to close.
let dispute_contract =
dispute::DisputeResolutionClient::new(&env, &storage::get_dispute_contract(&env)?);
dispute_contract.close_dispute(
&env.current_contract_address(),
&campaign_id,
&creator,
&dispute_outcome,
);

events::DisputeResolved {
campaign_id,
creator,
Expand Down
11 changes: 10 additions & 1 deletion contracts/campaign-escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ mod test_helpers {
/// Initialize the contract (admin + dispute contract + fee_bps) and mint
/// `BUSINESS_FUNDS` to a freshly generated business address. Returns the
/// client plus the generated identities.
///
/// The dispute slot is a real, initialized `dispute-resolution` contract
/// rather than a bare generated address: `resolve_dispute` closes out an
/// open dispute there with a cross-contract call, so a placeholder
/// address would trap. No dispute-resolution record is created unless a
/// test explicitly goes through `raise_dispute`, so the close-out call is
/// a no-op for most tests here.
pub fn bootstrap<'a>(
env: &'a Env,
contract_id: &Address,
Expand All @@ -68,9 +75,11 @@ mod test_helpers {
) {
let client = CampaignEscrowContractClient::new(env, contract_id);
let admin = Address::generate(env);
let dispute = Address::generate(env);
let dispute = env.register(ads_bazaar_dispute_resolution::DisputeResolutionContract, ());
let business = Address::generate(env);
client.initialize(&admin, &dispute, &fee_bps);
ads_bazaar_dispute_resolution::DisputeResolutionContractClient::new(env, &dispute)
.initialize(&admin, contract_id);
let token = setup_token(env, &business, BUSINESS_FUNDS);
(client, admin, dispute, business, token)
}
Expand Down
73 changes: 73 additions & 0 deletions contracts/campaign-escrow/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
//! | 14 | Raise in auto-approval window still blocks claim | PayoutFrozen |
//! | 15 | Two creators on same campaign can have independent disputes | separate dispute ids |
//! | 16 | raise → immediate admin resolve rejected | EvidenceWindowOpen; no funds moved |
//! | 17 | raise → admin resolve closes dispute-resolution record | record `Resolved`, matching outcome |
//!
//! Note that tests 6–8 advance the ledger past `MIN_EVIDENCE_WINDOW` before
//! resolving. That is the real flow, not test scaffolding: a dispute raised
Expand Down Expand Up @@ -612,3 +613,75 @@ fn admin_cannot_resolve_dispute_immediately_after_cross_contract_raise() {
ApplicationStatus::Paid
);
}

// ── 17. Admin resolve after raise closes the dispute-resolution record ────────

/// The data-integrity fix: `resolve_dispute` settles the payout *and* closes
/// the corresponding dispute-resolution record, so `get_dispute` never
/// reports a permanently-open dispute over money that has already moved.
#[test]
fn admin_resolve_after_cross_contract_raise_closes_dispute_record() {
let f = Fixture::setup();
let campaign_id = f.create_funded_campaign();
let creator = f.add_creator_with_proof(campaign_id);

let dispute_id = f.disputes().raise_dispute(
&creator,
&campaign_id,
&creator,
&String::from_str(&f.env, "ipfs://evidence"),
);
assert_eq!(
f.disputes().get_dispute(&dispute_id).status,
DisputeStatus::Raised
);

f.wait_out_evidence_window();
f.escrow().resolve_dispute(
&f.admin,
&campaign_id,
&creator,
&ads_bazaar_campaign_escrow::DisputeResolution::PayCreator,
);

let d = f.disputes().get_dispute(&dispute_id);
assert_eq!(d.status, DisputeStatus::Resolved);
// The admin's PayCreator maps to CreatorFavored in the dispute record.
assert_eq!(d.outcome, DisputeOutcome::CreatorFavored);
assert_eq!(d.resolved_at, Some(BASE_TIME + MIN_EVIDENCE_WINDOW));

// The application agrees with the record: paid and no longer frozen.
let app = f.escrow().get_application(&campaign_id, &creator);
assert_eq!(app.status, ApplicationStatus::Paid);
assert!(!app.frozen);
}

/// Each admin resolution maps to the matching `DisputeOutcome` on the
/// record: RefundBusiness → BusinessFavored, Split → Split.
#[test]
fn admin_resolve_outcome_mapping_reaches_dispute_record() {
let f = Fixture::setup();
let campaign_id = f.create_funded_campaign();

// Business raises against the creator, admin settles in the business's
// favor; assert the record reflects that rather than the default pending.
let creator = f.add_creator_with_proof(campaign_id);
let dispute_id = f.disputes().raise_dispute(
&f.business,
&campaign_id,
&creator,
&String::from_str(&f.env, "ipfs://no-work"),
);
f.wait_out_evidence_window();

f.escrow().resolve_dispute(
&f.admin,
&campaign_id,
&creator,
&ads_bazaar_campaign_escrow::DisputeResolution::RefundBusiness,
);

let d = f.disputes().get_dispute(&dispute_id);
assert_eq!(d.status, DisputeStatus::Resolved);
assert_eq!(d.outcome, DisputeOutcome::BusinessFavored);
}
6 changes: 4 additions & 2 deletions contracts/dispute-resolution/src/events.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
//! Event definitions for the dispute-resolution contract. See the
//! campaign-escrow crate's `events.rs` for more detail on the
//! `#[contractevent]` pattern used here. None of these are published yet —
//! wire up `.publish(&env)` calls as each `todo!()` handler is implemented.
//! `#[contractevent]` pattern used here. `DisputeResolved` is published by
//! `close_dispute` (the escrow-side admin bypass closing out a raised
//! dispute); the arbiter-resolved `resolve_dispute` will publish it too once
//! that `todo!()` is implemented.
#![allow(dead_code)]

use ads_bazaar_shared::DisputeId;
Expand Down
54 changes: 54 additions & 0 deletions contracts/dispute-resolution/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,61 @@ impl DisputeResolutionContract {
todo!("design + implement dispute resolution — see doc comment above")
}

/// Close out the open dispute over a `(campaign_id, creator)` payout that
/// `campaign-escrow::resolve_dispute` (the admin bypass) just settled.
/// Only the `campaign-escrow` contract set at `initialize` may call this —
/// it is the party that moved the funds, and the only one that knows the
/// admin's chosen resolution, which it records here as the matching
/// `DisputeOutcome` (see `escrow.rs`).
///
/// Idempotent: if there is no open dispute record for that payout — the
/// freeze came from the admin's direct `freeze_for_dispute` path rather
/// than `raise_dispute`, or the dispute was already closed — this is a
/// no-op rather than an error. Without that, `resolve_dispute` settling
/// an admin-frozen (never-`raise_dispute`d) payout would trap on a
/// missing record.
pub fn close_dispute(
env: Env,
caller: Address,
campaign_id: CampaignId,
creator: Address,
outcome: DisputeOutcome,
) -> Result<(), Error> {
caller.require_auth();
if caller != storage::get_escrow_contract(&env)? {
return Err(Error::Unauthorized);
}
if outcome == DisputeOutcome::Pending {
return Err(Error::InvalidStatus);
}

let Some(dispute_id) = storage::get_open_dispute(&env, campaign_id, &creator) else {
return Ok(());
};

let mut dispute = storage::get_dispute(&env, dispute_id)?;
dispute.status = DisputeStatus::Resolved;
dispute.outcome = outcome;
dispute.resolved_at = Some(env.ledger().timestamp());
storage::set_dispute(&env, dispute_id, &dispute);
storage::clear_open_dispute(&env, campaign_id, &creator);

events::DisputeResolved { dispute_id }.publish(&env);
Ok(())
}

/// Read-only lookup of a dispute's current state.
///
/// Note: a dispute raised via `raise_dispute` may also be settled by the
/// admin directly through `campaign-escrow::resolve_dispute`, which
/// closes the record out via `close_dispute`. A `Raised`/`Pending` status
/// here therefore only means "still open in *this* contract" — it is not
/// authoritative over whether the escrowed payout is still held. To tell
/// the two apart, cross-reference
/// `campaign-escrow::get_application(campaign_id, creator)`: a dispute
/// still reporting `Raised` while the application is `frozen == false`
/// (or `status == Paid`) means the admin bypass settled it without a
/// dispute-resolution record to close.
pub fn get_dispute(env: Env, dispute_id: DisputeId) -> Result<Dispute, Error> {
storage::get_dispute(&env, dispute_id)
}
Expand Down
3 changes: 2 additions & 1 deletion contracts/dispute-resolution/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ pub fn set_open_dispute(env: &Env, campaign_id: CampaignId, creator: &Address, i
}

/// Clear the open-dispute marker for a payout so a fresh dispute can be
/// raised over it later. Called once `resolve_dispute` is implemented.
/// raised over it later. Called by `close_dispute`, which the
/// `campaign-escrow` admin bypass invokes after settling a payout.
pub fn clear_open_dispute(env: &Env, campaign_id: CampaignId, creator: &Address) {
env.storage()
.persistent()
Expand Down
Loading
Loading