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
73 changes: 73 additions & 0 deletions contracts/escrow/src/dispute.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
use soroban_sdk::contracttype;

use crate::{safe_add_amounts, ContractStatus, EscrowContractData, EscrowError};

/// Resolution selected by the assigned arbiter for a disputed escrow.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DisputeResolution {
/// Refund all remaining escrowed funds to the client.
FullRefund,
/// Refund 70% of the remaining balance to the client and release 30% to the freelancer.
PartialRefund,
/// Release all remaining escrowed funds to the freelancer.
FullPayout,
/// Apply a custom split of the remaining balance.
Split(i128, i128),
}

impl DisputeResolution {
pub fn code(&self) -> u32 {
match self {
Self::FullRefund => 0,
Self::PartialRefund => 1,
Self::FullPayout => 2,
Self::Split(_, _) => 3,
}
}
}

pub fn resolution_payouts(
contract: &EscrowContractData,
resolution: &DisputeResolution,
) -> Result<(i128, i128), EscrowError> {
let available = contract
.total_deposited
.checked_sub(contract.released_amount)
.and_then(|value| value.checked_sub(contract.refunded_amount))
.ok_or(EscrowError::AccountingInvariantViolated)?;
if available < 0 {
return Err(EscrowError::AccountingInvariantViolated);
}

match resolution {
DisputeResolution::FullRefund => Ok((available, 0)),
DisputeResolution::PartialRefund => {
let freelancer_payout = available
.checked_mul(30)
.and_then(|value| value.checked_div(100))
.ok_or(EscrowError::PotentialOverflow)?;
Ok((available - freelancer_payout, freelancer_payout))
}
DisputeResolution::FullPayout => Ok((0, available)),
DisputeResolution::Split(client_amount, freelancer_amount) => {
if *client_amount < 0 || *freelancer_amount < 0 {
return Err(EscrowError::InvalidDisputeSplit);
}
let total = safe_add_amounts(*client_amount, *freelancer_amount)
.ok_or(EscrowError::PotentialOverflow)?;
if total != available {
return Err(EscrowError::InvalidDisputeSplit);
}
Ok((*client_amount, *freelancer_amount))
}
}
}

pub fn final_status_after_resolution(contract: &EscrowContractData) -> ContractStatus {
if contract.refunded_amount == contract.total_deposited {
ContractStatus::Refunded
} else {
ContractStatus::Completed
}
}
268 changes: 209 additions & 59 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ pub use types::{
MilestoneSummary, ReadinessChecklist, CONTRACT_SUMMARY_SCHEMA_VERSION,
};

mod dispute;
pub use dispute::DisputeResolution;

mod amount_validation;
pub use amount_validation::{safe_add_amounts, safe_subtract_amounts, AmountValidationError};

Expand Down Expand Up @@ -113,6 +116,82 @@ pub struct Escrow;

#[contractimpl]
impl Escrow {
fn create_contract_internal(
env: &Env,
client: Address,
freelancer: Address,
arbiter: Option<Address>,
milestone_amounts: Vec<i128>,
deposit_mode: DepositMode,
) -> u32 {
if client == freelancer {
env.panic_with_error(EscrowError::InvalidParticipant);
}
if let Some(arbiter_addr) = arbiter.clone() {
if arbiter_addr == client || arbiter_addr == freelancer {
env.panic_with_error(EscrowError::InvalidParticipant);
}
}
if milestone_amounts.is_empty() {
env.panic_with_error(EscrowError::EmptyMilestones);
}
if milestone_amounts.len() > MAX_MILESTONES {
env.panic_with_error(EscrowError::TooManyMilestones);
}

let mut total: i128 = 0;
for i in 0..milestone_amounts.len() {
let amt = milestone_amounts.get(i).unwrap();
if amt <= 0 {
env.panic_with_error(EscrowError::InvalidMilestoneAmount);
}
total = safe_add_amounts(total, amt)
.unwrap_or_else(|| env.panic_with_error(EscrowError::PotentialOverflow));
}
if total > MAX_TOTAL_ESCROW_STROOPS {
env.panic_with_error(EscrowError::InvalidMilestoneAmount);
}

let id: u32 = env
.storage()
.persistent()
.get(&DataKey::NextContractId)
.unwrap_or(1);
env.storage()
.persistent()
.set(&DataKey::NextContractId, &(id + 1));

let data = EscrowContractData {
client: client.clone(),
freelancer: freelancer.clone(),
arbiter,
milestones: milestone_amounts,
status: ContractStatus::Created,
total_deposited: 0,
released_amount: 0,
refunded_amount: 0,
reputation_issued: false,
deposit_mode,
};
env.storage()
.persistent()
.set(&DataKey::Contract(id), &data);

Self::emit_audit_event(
env,
id,
ContractStatus::Created,
ContractStatus::Created,
&client,
);

env.events().publish(
(symbol_short!("created"), id),
(client, freelancer, env.ledger().timestamp()),
);
id
}

// ─── Guard ───────────────────────────────────────────────────────────────

/// Panics with `ContractPaused` if the contract is paused or in emergency.
Expand Down Expand Up @@ -375,68 +454,36 @@ impl Escrow {
Self::require_not_paused(&env);
client.require_auth();

if client == freelancer {
env.panic_with_error(EscrowError::InvalidParticipant);
}
if milestone_amounts.is_empty() {
env.panic_with_error(EscrowError::EmptyMilestones);
}
if milestone_amounts.len() > MAX_MILESTONES {
env.panic_with_error(EscrowError::TooManyMilestones);
}

let mut total: i128 = 0;
for i in 0..milestone_amounts.len() {
let amt = milestone_amounts.get(i).unwrap();
if amt <= 0 {
env.panic_with_error(EscrowError::InvalidMilestoneAmount);
}
total = safe_add_amounts(total, amt)
.unwrap_or_else(|| env.panic_with_error(EscrowError::PotentialOverflow));
}
if total > MAX_TOTAL_ESCROW_STROOPS {
env.panic_with_error(EscrowError::InvalidMilestoneAmount);
}

let id: u32 = env
.storage()
.persistent()
.get(&DataKey::NextContractId)
.unwrap_or(1);
env.storage()
.persistent()
.set(&DataKey::NextContractId, &(id + 1));

let data = EscrowContractData {
client: client.clone(),
freelancer: freelancer.clone(),
arbiter: None,
milestones: milestone_amounts,
status: ContractStatus::Created,
total_deposited: 0,
released_amount: 0,
refunded_amount: 0,
reputation_issued: false,
Self::create_contract_internal(
&env,
client,
freelancer,
None,
milestone_amounts,
deposit_mode,
};
env.storage()
.persistent()
.set(&DataKey::Contract(id), &data);
)
}

// Audit: contract created
Self::emit_audit_event(
&env,
id,
ContractStatus::Created,
ContractStatus::Created,
&client,
);
/// Create a new escrow contract with an assigned arbiter for dispute resolution.
pub fn create_contract_with_arbiter(
env: Env,
client: Address,
freelancer: Address,
arbiter: Address,
milestone_amounts: Vec<i128>,
deposit_mode: DepositMode,
) -> u32 {
Self::require_not_paused(&env);
client.require_auth();

env.events().publish(
(symbol_short!("created"), id),
(client, freelancer, env.ledger().timestamp()),
);
id
Self::create_contract_internal(
&env,
client,
freelancer,
Some(arbiter),
milestone_amounts,
deposit_mode,
)
}

/// Deposit funds into an escrow contract. Blocked when paused.
Expand Down Expand Up @@ -514,6 +561,12 @@ impl Escrow {
.get::<_, EscrowContractData>(&key)
.unwrap_or_else(|| env.panic_with_error(EscrowError::ContractNotFound));

if contract.status != ContractStatus::Funded
&& contract.status != ContractStatus::PartiallyFunded
{
env.panic_with_error(EscrowError::InvalidStatusTransition);
}

if milestone_index >= contract.milestones.len() {
env.panic_with_error(EscrowError::InvalidMilestone);
}
Expand Down Expand Up @@ -579,6 +632,103 @@ impl Escrow {
true
}

/// Raise a dispute on a funded escrow. Only the client or freelancer may call this.
pub fn raise_dispute(env: Env, contract_id: u32, caller: Address) -> bool {
Self::require_not_paused(&env);
caller.require_auth();

let key = DataKey::Contract(contract_id);
let mut contract = env
.storage()
.persistent()
.get::<_, EscrowContractData>(&key)
.unwrap_or_else(|| env.panic_with_error(EscrowError::ContractNotFound));

if caller != contract.client && caller != contract.freelancer {
env.panic_with_error(EscrowError::UnauthorizedRole);
}
if contract.arbiter.is_none() {
env.panic_with_error(EscrowError::ArbiterRequired);
}
if contract.status != ContractStatus::Funded
&& contract.status != ContractStatus::PartiallyFunded
{
env.panic_with_error(EscrowError::InvalidStatusTransition);
}

let old_status = contract.status;
contract.status = ContractStatus::Disputed;

Self::check_accounting_invariant(&env, &contract, contract_id);
env.storage().persistent().set(&key, &contract);

Self::emit_audit_event(&env, contract_id, old_status, contract.status, &caller);
env.events().publish(
(symbol_short!("dispute"), contract_id),
(caller, env.ledger().timestamp()),
);
true
}

/// Resolve a disputed escrow and distribute the remaining balance according to the resolution.
pub fn resolve_dispute(
env: Env,
contract_id: u32,
arbiter: Address,
resolution: DisputeResolution,
) -> bool {
Self::require_not_paused(&env);
arbiter.require_auth();

let key = DataKey::Contract(contract_id);
let mut contract = env
.storage()
.persistent()
.get::<_, EscrowContractData>(&key)
.unwrap_or_else(|| env.panic_with_error(EscrowError::ContractNotFound));

if contract.status != ContractStatus::Disputed {
env.panic_with_error(EscrowError::InvalidStatusTransition);
}
if contract.arbiter.clone() != Some(arbiter.clone()) {
env.panic_with_error(EscrowError::UnauthorizedRole);
}

let old_status = contract.status;
let (client_payout, freelancer_payout) =
dispute::resolution_payouts(&contract, &resolution)
.unwrap_or_else(|err| env.panic_with_error(err));

contract.refunded_amount = safe_add_amounts(contract.refunded_amount, client_payout)
.unwrap_or_else(|| env.panic_with_error(EscrowError::PotentialOverflow));
contract.released_amount = safe_add_amounts(contract.released_amount, freelancer_payout)
.unwrap_or_else(|| env.panic_with_error(EscrowError::PotentialOverflow));

if safe_add_amounts(contract.released_amount, contract.refunded_amount)
!= Some(contract.total_deposited)
{
env.panic_with_error(EscrowError::AccountingInvariantViolated);
}

contract.status = dispute::final_status_after_resolution(&contract);

Self::check_accounting_invariant(&env, &contract, contract_id);
env.storage().persistent().set(&key, &contract);

Self::emit_audit_event(&env, contract_id, old_status, contract.status, &arbiter);
env.events().publish(
(symbol_short!("dsp_res"), contract_id),
(
arbiter,
resolution.code(),
client_payout,
freelancer_payout,
env.ledger().timestamp(),
),
);
true
}

/// Issue reputation for a completed contract. Blocked when paused.
pub fn issue_reputation(
env: Env,
Expand Down
Loading
Loading