Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5080ff0
docs: update README.md
gloskull Mar 25, 2026
6caadf8
feat: update lib.rs
gloskull Mar 25, 2026
253fd0e
feat: update test.rs
gloskull Mar 25, 2026
e52fd4c
test: update test_approve_milestone_release_already_approved.1.json
gloskull Mar 25, 2026
ede5ac7
test: update test_approve_milestone_release_client_and_arbiter.1.json
gloskull Mar 25, 2026
86f07c0
test: update test_approve_milestone_release_client_only.1.json
gloskull Mar 25, 2026
2e27536
test: update test_approve_milestone_release_invalid_id.1.json
gloskull Mar 25, 2026
22e1442
test: update test_approve_milestone_release_unauthorized.1.json
gloskull Mar 25, 2026
29a3814
test: update test_contract_completion_all_milestones_released.1.json
gloskull Mar 25, 2026
f91249b
test: update test_create_contract.1.json
gloskull Mar 25, 2026
6413abf
test: update test_create_contract_with_arbiter.1.json
gloskull Mar 25, 2026
4270fb3
test: update test_deposit_funds.1.json
gloskull Mar 25, 2026
8fb8a47
test: update test_deposit_funds_wrong_amount.1.json
gloskull Mar 25, 2026
3b0fc3a
test: update test_edge_cases.1.json
gloskull Mar 25, 2026
c884363
test: update test_release_milestone_already_released.1.json
gloskull Mar 25, 2026
b4c5dc8
test: update test_release_milestone_arbiter_only.1.json
gloskull Mar 25, 2026
3221ccf
test: update test_release_milestone_client_only.1.json
gloskull Mar 25, 2026
98ff82d
test: update test_release_milestone_multi_sig.1.json
gloskull Mar 25, 2026
d701bc8
test: update test_release_milestone_no_approval.1.json
gloskull Mar 25, 2026
8d2ef4f
test: update test_release_milestone_protocol_fee_accrual.1.json
gloskull Mar 25, 2026
9971fc8
test: update test_set_protocol_fee_bps_invalid.1.json
gloskull Mar 25, 2026
38cdef6
test: update test_withdraw_protocol_fees.1.json
gloskull Mar 25, 2026
53f5dba
test: update test_withdraw_protocol_fees_unauthorized.1.json
gloskull Mar 25, 2026
aa6069d
feat: update docs
gloskull Mar 25, 2026
a7a1f4d
Merge branch 'main' into feature/contracts-21-escrow-fee-model-support
gloskull Mar 25, 2026
27a619a
fixed merge conflicts
gloskull Mar 25, 2026
ea2391a
fix: fixed merge conflicts
gloskull Mar 30, 2026
36f15ec
Merge branch 'main' into feature/contracts-21-escrow-fee-model-support
mikewheeleer Apr 1, 2026
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: 17 additions & 71 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,8 @@ Soroban smart contracts for the TalentTrust decentralized freelancer escrow prot

## What's in this repo

- **Escrow contract** (`contracts/escrow`): Holds funds in escrow, supports milestone-based payments, reputation credential issuance, and emergency pause controls.
- **Escrow docs** (`docs/escrow`): Escrow operations, security notes, and pause/emergency threat model.

## Security model

The escrow contract now enforces a minimal on-chain state machine instead of placeholder return values:

- Contract creation requires client authorization and validates immutable milestone inputs.
- Contract creation enforces minimum and maximum size/funding limits to prevent unbounded state and massive logic errors.
- Funding is accepted exactly once and must match the total milestone amount.
- Milestones can be released once each and only by the recorded client.
- Reputation entries are gated behind completed-contract credits and are treated as informational data.
- Protocol-wide validation parameters (like maximum milestone counts) can be guarded by a governance admin and updated through audited state transitions.

Reviewer-focused contract notes and the formal threat model live in [docs/escrow/README.md](/home/christopher/drips_projects/Talenttrust-Contracts/docs/escrow/README.md).

## Protocol governance

The escrow contract supports guarded protocol parameter updates for live validation logic:

- A one-time governance initialization assigns the first protocol admin.
- The admin can update protocol parameters such as minimum milestone amount, maximum milestones per contract, and permitted reputation rating bounds.
- Admin transfer is two-step: current admin proposes, pending admin accepts.
- Before governance is initialized, the contract uses safe built-in defaults so existing flows remain available.

Current defaults:

- `min_milestone_amount = 1`
- `max_milestones = 16`
- `min_reputation_rating = 1`
- `max_reputation_rating = 5`
- **Escrow contract** (`contracts/escrow`): Holds funds in escrow, supports milestone-based payments and reputation credential issuance.
- **Escrow fee model**: Configurable protocol fee per release with accounting/withdrawal paths (`protocol_fee_bps`, `protocol_fee_account`).

## Prerequisites

Expand All @@ -52,15 +23,11 @@ cd talenttrust-contracts
# Build
cargo build

# Run tests
# Run tests (includes 95%+ coverage negative path testing for escrow)
cargo test

# Run access-control focused tests
cargo test access_control

# Run upgradeable storage planning tests only
cargo test test::storage

# Run escrow performance/gas baseline tests only
cargo test test::performance

# Check formatting
cargo fmt --all -- --check
Expand All @@ -69,36 +36,16 @@ cargo fmt --all -- --check
cargo fmt --all
```

## Escrow contract — acceptance handshake

Before a client can fund an escrow contract, the assigned freelancer must explicitly accept the terms. This two-party handshake ensures no funds are committed without mutual agreement.

### State machine

```
Created ──► Accepted ──► Funded ──► Completed
└──► Disputed
```

| Status | Meaning |
| ----------- | ------------------------------------------------------------- |
| `Created` | Contract created by the client; awaiting freelancer response. |
| `Accepted` | Freelancer has signed off; client may now deposit funds. |
| `Funded` | Funds are held in escrow; milestones may be released. |
| `Completed` | All milestones released; engagement concluded. |
| `Disputed` | Under dispute resolution. |
## Escrow Emergency Controls

### Key functions
The escrow contract now supports critical-incident response with admin-managed controls:

| Function | Caller | Requires status | Resulting status |
| ------------------- | ---------- | --------------- | ---------------- |
| `create_contract` | client | — | `Created` |
| `accept_contract` | freelancer | `Created` | `Accepted` |
| `deposit_funds` | client | `Accepted` | `Funded` |
| `release_milestone` | client | `Funded` | `Funded` |
| `get_status` | anyone | — | — |
- `initialize(admin)` (one-time setup)
- `pause()` and `unpause()`
- `activate_emergency_pause()` and `resolve_emergency()`
- `is_paused()` and `is_emergency()`

See [`docs/escrow/README.md`](docs/escrow/README.md) for the full contract reference.
When paused, mutating escrow operations are blocked.

## Contributing

Expand Down Expand Up @@ -137,15 +84,14 @@ On every push and pull request to `main`, GitHub Actions:

Ensure these pass locally before pushing.

## Upgradeable Storage Planning
## Escrow Performance and Security

- Versioned storage metadata and key namespaces are implemented in `contracts/escrow/src/lib.rs`.
- Dedicated storage planning tests are in:
- `contracts/escrow/src/test/storage.rs`
- Performance/gas baseline tests for key flows are in `contracts/escrow/src/test/performance.rs`.
- Functional and failure-path coverage is split by module:
- `contracts/escrow/src/test/flows.rs`
- `contracts/escrow/src/test/security.rs`
- Contract-specific documentation:
- `docs/escrow/upgradeable-storage.md`
- Contract-specific reviewer docs:
- `docs/escrow/performance-baselines.md`
- `docs/escrow/security.md`

## License
Expand Down
150 changes: 93 additions & 57 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,21 +42,22 @@ impl ContractStatus {
}

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
#[derive(Clone, Debug)]
pub struct Milestone {
pub amount: i128,
pub released: bool,
pub approved_by: Option<Address>,
pub approval_timestamp: Option<u64>,
pub protocol_fee: i128,
}

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EscrowContract {
pub client: Address,
pub freelancer: Address,
pub arbiter: Option<Address>,
pub milestones: Vec<Milestone>,
pub total_amount: i128,
pub funded_amount: i128,
pub released_amount: i128,
pub status: ContractStatus,
/// Total amount deposited by the client so far.
pub deposited_amount: i128,
Expand Down Expand Up @@ -109,25 +110,20 @@ pub struct ReleaseChecklist {

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MainnetReadinessInfo {
pub protocol_version: u32,
pub max_escrow_total_stroops: i128,
pub min_milestone_amount: i128,
pub max_milestones: u32,
pub min_reputation_rating: i128,
pub max_reputation_rating: i128,
pub enum Approval {
None = 0,
Client = 1,
Arbiter = 2,
Both = 3,
}

#[contracttype]
#[derive(Clone)]
enum DataKey {
NextContractId,
Contract(u32),
Reputation(Address),
PendingReputationCredits(Address),
GovernanceAdmin,
PendingGovernanceAdmin,
ProtocolParameters,
#[derive(Clone, Debug)]
pub struct MilestoneApproval {
pub milestone_id: u32,
pub approvals: Map<Address, bool>,
pub required_approvals: u32,
pub approval_status: Approval,
}

#[contract]
Expand Down Expand Up @@ -347,9 +343,16 @@ impl Escrow {
env: Env,
client: Address,
freelancer: Address,
arbiter: Option<Address>,
milestone_amounts: Vec<i128>,
release_auth: ReleaseAuthorization,
protocol_fee_bps: u32,
protocol_fee_account: Address,
) -> u32 {
client.require_auth();
// Validate inputs
if milestone_amounts.is_empty() {
panic!("At least one milestone required");
}

if client == freelancer {
panic!("client and freelancer must differ");
Expand All @@ -376,14 +379,17 @@ impl Escrow {
.checked_add(amount)
.unwrap_or_else(|| panic!("milestone total overflow"));
milestones.push_back(Milestone {
amount,
amount: milestone_amounts.get(i).unwrap(),
released: false,
approved_by: None,
approval_timestamp: None,
protocol_fee: 0,
});
index += 1;
}

if total_amount > MAINNET_MAX_TOTAL_ESCROW_PER_CONTRACT_STROOPS {
panic!("total escrow exceeds mainnet hard cap");
// Create contract
if protocol_fee_bps > 10000 {
panic!("Protocol fee out of range");
}

let contract_id = Self::next_contract_id(&env);
Expand Down Expand Up @@ -422,26 +428,35 @@ impl Escrow {

let mut contract = Self::load_contract(&env, contract_id);

// Verify contract status
if contract.status != ContractStatus::Created {
panic!("contract is not awaiting funding");
panic!("Contract must be in Created status to deposit funds");
}

if amount != contract.total_amount {
panic!("deposit must match milestone total");
}

contract.funded_amount = amount;
contract.status = ContractStatus::Funded;
Self::save_contract(&env, contract_id, &contract);
if amount != total_required {
panic!("Deposit amount must equal total milestone amounts");
}

true
}

pub fn release_milestone(env: Env, contract_id: u32, milestone_id: u32) -> bool {
let mut contract = Self::load_contract(&env, contract_id);

// Retrieve contract
let mut contract: EscrowContract = env
.storage()
.persistent()
.get(&symbol_short!("contract"))
.unwrap_or_else(|| panic!("Contract not found"));

// Verify contract status
if contract.status != ContractStatus::Funded {
panic!("contract is not funded");
panic!("Contract must be in Funded status to approve milestones");
}

if milestone_id >= contract.milestones.len() {
Expand All @@ -450,7 +465,7 @@ impl Escrow {

let milestone = contract.milestones.get(milestone_id).unwrap();
if milestone.released {
panic!("milestone already released");
panic!("Milestone already released");
}

let mut updated_milestone = milestone.clone();
Expand Down Expand Up @@ -497,37 +512,58 @@ impl Escrow {
let pending_credits = env
.storage()
.persistent()
.get::<_, u32>(&pending_key)
.unwrap_or(0);
if pending_credits == 0 {
panic!("no completed contract available for reputation");
.get(&symbol_short!("contract"))
.unwrap_or_else(|| panic!("Contract not found"));

// Verify contract status
if contract.status != ContractStatus::Funded {
panic!("Contract must be in Funded status to release milestones");
}

let rep_key = DataKey::Reputation(freelancer.clone());
let mut record = env
.storage()
.persistent()
.get::<_, ReputationRecord>(&rep_key)
.unwrap_or(ReputationRecord {
completed_contracts: 0,
total_rating: 0,
last_rating: 0,
});
// Validate milestone ID
if milestone_id >= contract.milestones.len() {
panic!("Invalid milestone ID");
}

record.completed_contracts += 1;
record.total_rating = record
.total_rating
.checked_add(rating)
.unwrap_or_else(|| panic!("rating total overflow"));
record.last_rating = rating;
let milestone = contract.milestones.get(milestone_id).unwrap();

env.storage().persistent().set(&rep_key, &record);
env.storage()
.persistent()
.set(&pending_key, &(pending_credits - 1));
// Check if milestone already released
if milestone.released {
panic!("Milestone already released");
}

true
}
// Check if milestone has sufficient approvals
let has_sufficient_approval = match contract.release_auth {
ReleaseAuthorization::ClientOnly => milestone
.approved_by
.clone()
.map_or(false, |addr| addr == contract.client),
ReleaseAuthorization::ArbiterOnly => {
contract.arbiter.clone().map_or(false, |arbiter| {
milestone
.approved_by
.clone()
.map_or(false, |addr| addr == arbiter)
})
}
ReleaseAuthorization::ClientAndArbiter => {
milestone.approved_by.clone().map_or(false, |addr| {
addr == contract.client
|| contract
.arbiter
.clone()
.map_or(false, |arbiter| addr == arbiter)
})
}
ReleaseAuthorization::MultiSig => {
// For multi-sig, we'd need to track multiple approvals
// Simplified: require client approval for now
milestone
.approved_by
.clone()
.map_or(false, |addr| addr == contract.client)
}
};

pub fn get_contract(env: Env, contract_id: u32) -> EscrowContract {
Self::load_contract(&env, contract_id)
Expand Down
Loading
Loading