Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions contracts/campaign-escrow/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
11 changes: 11 additions & 0 deletions contracts/campaign-escrow/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
73 changes: 65 additions & 8 deletions contracts/campaign-escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
}
Expand All @@ -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);
}
Expand Down Expand Up @@ -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<Address, Error> {
Ok(storage::get_campaign(&env, campaign_id)?.business)
}

/// Apply a dispute outcome (from `dispute-resolution`) by releasing or
Expand Down Expand Up @@ -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;
Expand Down
207 changes: 207 additions & 0 deletions contracts/campaign-escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
}
Loading