Skip to content
Open
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
88 changes: 74 additions & 14 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,15 @@ pub struct DisputeRaisedEvent {
}

#[contracttype]
#[derive(Clone)]
#[derive(Clone, Debug, PartialEq)]
pub struct DepositEvent {
pub job_id: u64,
pub amount: i128,
pub deposited_at: u64,
}

#[contracttype]
#[derive(Clone)]
#[derive(Clone, Debug, PartialEq)]
pub struct ReleaseMilestoneEvent {
pub job_id: u64,
pub milestone_index: u32,
Expand All @@ -149,15 +150,27 @@ pub struct ReleaseMilestoneEvent {
}

#[contracttype]
#[derive(Clone)]
#[derive(Clone, Debug, PartialEq)]
pub struct ReleaseEvent {
pub job_id: u64,
pub released_by: Address,
pub released_to: Address,
pub milestone_index: u32,
pub amount: i128,
pub total_released: i128,
pub released_at: u64,
}

#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub struct OpenDisputeEvent {
pub job_id: u64,
pub initiator: Address,
pub opened_at: u64,
}

#[contracttype]
#[derive(Clone)]
#[derive(Clone, Debug, PartialEq)]
pub struct JobRegistryConfiguredEvent {
pub configured_by: Address,
pub registry_contract: Address,
Expand Down Expand Up @@ -601,19 +614,15 @@ impl EscrowContract {
milestone.status = MilestoneStatus::Released;
job.milestones.set(milestone_index, milestone.clone());

job.released_amount += milestone.amount;
job.released_amount = job.released_amount.saturating_add(milestone.amount);

let next_status = if job.released_amount == job.total_amount {
EscrowStatus::Completed
} else {
EscrowStatus::WorkInProgress
};
job.status
.validate_transition(&next_status)
.expect("invalid state transition");
job.status = next_status;

enter_reentrancy_guard(&env);

let token_client = token::Client::new(&env, &job.token);
token_client.transfer(
&env.current_contract_address(),
Expand All @@ -628,9 +637,17 @@ impl EscrowContract {
milestone.amount
);
env.storage().persistent().set(&key, &job);
Self::bump_job_ttl(&env, &key);

exit_reentrancy_guard(&env);
let evt = ReleaseEvent {
job_id,
released_by: caller.clone(),
released_to: job.freelancer.clone(),
milestone_index,
amount: milestone.amount,
total_released: job.released_amount,
released_at: env.ledger().timestamp(),
};
env.events().publish(("escrow", "ReleaseEvent"), evt);
}

/// Either party opens a dispute, locking remaining funds.
Expand Down Expand Up @@ -867,8 +884,8 @@ impl EscrowContract {
#[cfg(test)]
mod test {
use super::*;
use soroban_sdk::testutils::Address as _;
use soroban_sdk::{token, Address, Env};
use soroban_sdk::testutils::{Address as _, Events as _};
use soroban_sdk::{token, Address, Env, IntoVal};

fn setup_token(env: &Env, admin: &Address) -> Address {
let contract = env.register_stellar_asset_contract_v2(admin.clone());
Expand Down Expand Up @@ -1205,6 +1222,49 @@ mod test {
assert_eq!(tc.balance(&contract_id), 0);
}

#[test]
fn test_release_funds_emits_release_event() {
let env = Env::default();
env.mock_all_auths();

let admin = Address::generate(&env);
let agent_judge = Address::generate(&env);
let client = Address::generate(&env);
let freelancer = Address::generate(&env);

let token_addr = setup_token(&env, &admin);
mint(&env, &token_addr, &client);

let contract_id = env.register_contract(None, EscrowContract);
let cc = EscrowContractClient::new(&env, &contract_id);

cc.initialize(&admin, &agent_judge);
cc.create_job(&88u64, &client, &freelancer, &token_addr);
cc.add_milestone(&88u64, &1500i128);
cc.add_milestone(&88u64, &2500i128);
cc.deposit(&88u64, &4000i128);

cc.release_funds(&88u64, &client, &1u32);

let events = env.events().all();
let release_event = events.get(events.len() - 1).unwrap();
assert_eq!(release_event.0, contract_id);
assert_eq!(release_event.1, ("escrow", "ReleaseEvent").into_val(&env));
let actual_event: ReleaseEvent = release_event.2.into_val(&env);
assert_eq!(
actual_event,
ReleaseEvent {
job_id: 88,
released_by: client,
released_to: freelancer,
milestone_index: 1,
amount: 2500,
total_released: 2500,
released_at: env.ledger().timestamp(),
}
);
}

#[test]
fn test_raise_dispute_by_client_locks_funds() {
let env = Env::default();
Expand Down
37 changes: 37 additions & 0 deletions docs/contracts/escrow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Escrow Smart Contract

## Overview

The `Escrow` contract manages funded milestone payments, releases, refunds, and disputes for Lance jobs.

## `ReleaseEvent`

### Purpose

`ReleaseEvent` is emitted by `release_funds` after a client releases an explicit milestone index to the freelancer. It gives backend indexers and clients an auditable signal for state-changing escrow releases.

### Topic

- `("escrow", "ReleaseEvent")`

### Payload

- `job_id`: Unique job identifier.
- `released_by`: Client address that authorized the release.
- `released_to`: Freelancer address that received the payment.
- `milestone_index`: Zero-based milestone index released by the call.
- `amount`: Amount transferred for the released milestone.
- `total_released`: Cumulative amount released for the job after this release.
- `released_at`: Ledger timestamp when the event was emitted.

### Validation

`release_funds` preserves the existing escrow checks before emitting the event:

- Caller authentication via `require_auth()`.
- Job must be funded or already in progress.
- Caller must be the job client.
- Milestone index must exist.
- Milestone must still be pending.

The event is emitted only after the token transfer and persistent job update succeed.
Loading