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
28 changes: 0 additions & 28 deletions contracts/escrow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,34 +18,6 @@ Soroban smart contract for a decentralized freelance escrow flow.
- `raise_dispute(job_id)`
- `resolve_dispute(job_id, winner)`

## Events

The contract emits the following symbolic-topic events on key state transitions:

| Event Topic | Trigger | Event Data |
|-------------|---------|------------|
| `init` | `initialize` | `(admin: Address, native_token: Address)` |
| `JobPosted` | `post_job`, `create_job_with_milestones` | `(job_id: u64, client: Address, desc_hash: Bytes, amount: i128)` |
| `JobAccepted` | `accept_job` | `(job_id: u64, client: Address, freelancer: Address, amount: i128)` |
| `WorkSub` | `submit_work` | `(job_id: u64, client: Address, freelancer: Address, amount: i128)` |
| `WorkAppr` | `approve_work` | `(job_id: u64, client: Address, freelancer: Address, amount: i128)` |
| `JobCanc` | `cancel_job`, `freelancer_cancel_job`, `enforce_deadline`, `relay_cancel_job` | `(job_id: u64, client: Address, freelancer: Address, amount: i128)` |
| `Dispute` | `raise_dispute` | `(job_id: u64, client: Address, freelancer: Address, amount: i128)` |
| `DispRes` | `resolve_dispute`, `resolve_dispute_split` | `(job_id: u64, client: Address, freelancer: Address, amount: i128, client_bps: u32, freelancer_bps: u32)` |
| `mstone` | `approve_milestone` | `(job_id: u64, milestone_id: u32, client: Address)` |
| `ttl_ext` | `extend_job_ttl` | `(job_id: u64)` |
| `tok_add` | `add_allowed_token` | `(token: Address)` |
| `tok_rem` | `remove_allowed_token` | `(token: Address)` |
| `fees_wdr` | `withdraw_fees` | `(admin: Address, token: Address, accumulated: i128)` |
| `wl_mode` | `set_whitelist_mode` | `(enabled: bool)` |
| `wl_add` | `add_to_whitelist` | `(address: Address)` |
| `wl_rem` | `remove_from_whitelist` | `(address: Address)` |
| `bl_add` | `add_to_blacklist` | `(address: Address)` |
| `bl_rem` | `remove_from_blacklist` | `(address: Address)` |
| `fwd_set` | `set_trusted_forwarder` | `(forwarder: Address, is_trusted: bool)` |

All events use Soroban's `contract.emit()` with symbolic (short) topics. Off-chain indexers can subscribe to these topic symbols to react to state changes without polling.

## Test

```bash
Expand Down
118 changes: 118 additions & 0 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const PLATFORM_FEE_BPS: u64 = 250;
const MAX_DESC_PAYLOAD: u32 = 8192;
const SLA_PENALTY_DENOMINATOR: u32 = 10_000;
const CANCELLATION_GRACE_PERIOD: u64 = 100;
const INITIAL_JOB_VERSION: u32 = 1;

fn current_ledger(env: &Env) -> u64 {
u64::from(env.ledger().sequence())
Expand Down Expand Up @@ -36,6 +37,7 @@ pub struct Job {
pub submitted_at: u64,
pub title: BytesN<64>,
pub category: Symbol,
pub version: u32,
}

#[derive(Clone, Debug, Eq, PartialEq)]
Expand Down Expand Up @@ -272,6 +274,7 @@ impl EscrowContract {
submitted_at: 0,
title,
category,
version: INITIAL_JOB_VERSION,
};

put_job(&env, job_id, &job);
Expand Down Expand Up @@ -515,6 +518,28 @@ impl EscrowContract {
get_job(&env, job_id)
}

pub fn get_job_count(env: Env) -> u64 {
env.storage().instance().get(&DataKey::JobCount).unwrap_or(0)
}

pub fn get_completed_jobs_count(env: Env) -> u64 {
env.storage().instance().get(&DataKey::CompletedJobsCount).unwrap_or(0)
}

pub fn get_freelancer_jobs(env: Env, freelancer: Address) -> Vec<u64> {
env.storage()
.persistent()
.get(&DataKey::FreelancerJobs(freelancer))
.unwrap_or_else(|| Vec::new(&env))
}

pub fn get_client_jobs(env: Env, client: Address) -> Vec<u64> {
env.storage()
.persistent()
.get(&DataKey::ClientJobs(client))
.unwrap_or_else(|| Vec::new(&env))
}

}

pub fn get_job(env: Env, job_id: u64) -> Job {
Expand Down Expand Up @@ -575,6 +600,60 @@ impl EscrowContract {
}
}

pub fn get_job_version(env: Env, job_id: u64) -> u32 {
let job = get_job(&env, job_id);
job.version
}

pub fn migrate_job_version(env: Env, caller: Address, job_id: u64, target_version: u32) -> u32 {
caller.require_auth();
let mut job = get_job(&env, job_id);
let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
if caller != job.client && caller != admin {
panic!("unauthorized");
}
if target_version < job.version {
panic!("cannot downgrade version");
}
let old_version = job.version;
job.version = target_version;
put_job(&env, job_id, &job);

env.events().publish(
(soroban_sdk::Symbol::new(&env, "job_version_migrated"),),
(job_id, old_version, target_version),
);
target_version
}

pub fn get_sla_status(env: Env, job_id: u64) -> SLAStatus {
let has_config = env.storage().persistent().has(&DataKey::SLAConfig(job_id));
let (response_time_ledgers, delivery_time_ledgers, penalty_bps, auto_escalate) =
if has_config {
let cfg: SLAConfig = env.storage().persistent().get(&DataKey::SLAConfig(job_id)).unwrap();
(cfg.response_time_ledgers, cfg.delivery_time_ledgers, cfg.penalty_bps, cfg.auto_escalate)
} else {
(0u64, 0u64, 0u64, false)
};
let accepted_at: u64 = env.storage().persistent().get(&DataKey::SLAAcceptedAt(job_id)).unwrap_or(0);
let breached = if accepted_at > 0 && delivery_time_ledgers > 0 {
current_ledger(&env) > accepted_at + delivery_time_ledgers
} else {
false
};
let penalty_applied: bool = env.storage().persistent().get(&DataKey::SLAPenaltyApplied(job_id)).unwrap_or(false);
SLAStatus {
has_config,
response_time_ledgers,
delivery_time_ledgers,
penalty_bps,
auto_escalate,
accepted_at,
breached,
penalty_applied,
}
}

pub fn get_sla_status(env: Env, job_id: u64) -> SLAStatus {
let has_config = env.storage().persistent().has(&DataKey::SLAConfig(job_id));
let (response_time_ledgers, delivery_time_ledgers, penalty_bps, auto_escalate) =
Expand Down Expand Up @@ -623,6 +702,31 @@ impl EscrowContract {
.unwrap_or_else(|| Vec::new(&env))
}

pub fn calculate_effective_fee_bps(env: Env, user: Address) -> u32 {
let base_fee: u32 = env
.storage()
.instance()
.get(&DataKey::BaseFeeBps)
.unwrap_or(PLATFORM_FEE_BPS as u32);

let completed_jobs = Self::get_user_completed_jobs(env.clone(), user);
let tiers = Self::get_discount_tiers(env.clone());

let mut discount_bps = 0u32;
for tier in tiers.iter() {
if completed_jobs >= tier.min_completed_jobs {
discount_bps = tier.discount_bps;
}
}

base_fee.saturating_sub(discount_bps)
}

.instance()
.get(&DataKey::DiscountTiers)
.unwrap_or_else(|| Vec::new(&env))
}

pub fn calculate_effective_fee_bps(env: Env, user: Address) -> u32 {
let base_fee: u32 = env
.storage()
Expand Down Expand Up @@ -814,6 +918,7 @@ impl EscrowContract {
submitted_at: 0,
title,
category,
version: INITIAL_JOB_VERSION,
};
put_job(&env, count, &job);

Expand All @@ -830,6 +935,19 @@ impl EscrowContract {
count
}

let mut c_jobs: Vec<u64> = env
.storage()
.persistent()
.get(&DataKey::ClientJobs(client.clone()))
.unwrap_or_else(|| Vec::new(&env));
c_jobs.push_back(count);
env.storage()
.persistent()
.set(&DataKey::ClientJobs(client.clone()), &c_jobs);

count
}

pub fn approve_milestone(env: Env, client: Address, job_id: u64, milestone_id: u32) {
client.require_auth();
let job = get_job(&env, job_id);
Expand Down
79 changes: 79 additions & 0 deletions contracts/escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -635,3 +635,82 @@ fn test_get_job_status_counts() {
assert_eq!(counts.total, 3);
assert_eq!(counts.disputed, 1);
}

#[test]
fn test_job_initial_version_is_one() {
let env = Env::default();
let (_admin, client, _freelancer, token, contract_id) = setup_test(&env);
let escrow = new_escrow(&env, &contract_id);
let desc_hash = BytesN::from_array(&env, &[0u8; 32]);
let deadline: u64 = 1000;

let job_id = escrow.post_job(
&client,
&100_0000000i128,
&desc_hash,
&100u32,
&deadline,
&token,
&dummy_title(&env),
&dummy_category(&env),
);
assert_eq!(escrow.get_job_version(&job_id), 1);
let job = escrow.get_job(&job_id);
assert_eq!(job.version, 1);
}

#[test]
fn test_migrate_job_version_success() {
let env = Env::default();
let (admin, client, _freelancer, token, contract_id) = setup_test(&env);
let escrow = new_escrow(&env, &contract_id);
let desc_hash = BytesN::from_array(&env, &[0u8; 32]);
let deadline: u64 = 1000;

let job_id = escrow.post_job(
&client,
&100_0000000i128,
&desc_hash,
&100u32,
&deadline,
&token,
&dummy_title(&env),
&dummy_category(&env),
);

// Client migrates version to 2
let new_ver = escrow.migrate_job_version(&client, &job_id, &2u32);
assert_eq!(new_ver, 2);
assert_eq!(escrow.get_job_version(&job_id), 2);
let job = escrow.get_job(&job_id);
assert_eq!(job.version, 2);

// Admin migrates version to 3
let admin_ver = escrow.migrate_job_version(&admin, &job_id, &3u32);
assert_eq!(admin_ver, 3);
assert_eq!(escrow.get_job_version(&job_id), 3);
}

#[test]
#[should_panic(expected = "unauthorized")]
fn test_migrate_job_version_rejects_unauthorized() {
let env = Env::default();
let (_admin, client, _freelancer, token, contract_id) = setup_test(&env);
let escrow = new_escrow(&env, &contract_id);
let desc_hash = BytesN::from_array(&env, &[0u8; 32]);
let deadline: u64 = 1000;
let stranger = Address::generate(&env);

let job_id = escrow.post_job(
&client,
&100_0000000i128,
&desc_hash,
&100u32,
&deadline,
&token,
&dummy_title(&env),
&dummy_category(&env),
);

escrow.migrate_job_version(&stranger, &job_id, &2u32);
}
Binary file added contracts/escrow/test.rs
Binary file not shown.
Loading
Loading