diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 696afbf..481d0b6 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -503,9 +503,28 @@ impl Escrow { true } - /// Release a milestone to the freelancer. Blocked when paused. - pub fn release_milestone(env: Env, contract_id: u32, milestone_index: u32) -> bool { + /// Release a funded milestone payment to the freelancer. + /// + /// # Parameters + /// - `contract_id`: The ID of the escrow contract. + /// - `caller`: The address authorizing the release. Must be the recorded client. + /// - `milestone_index`: Zero-based index of the milestone to release. + /// + /// # Errors / Panics + /// - `ContractPaused` — contract is paused or in emergency. + /// - `ContractNotFound` — no contract exists for `contract_id`. + /// - `UnauthorizedRole` — `caller` is not the recorded client. + /// - `InvalidMilestone` — `milestone_index` is out of range. + /// - `AlreadyReleased` — milestone was already released. + /// - `InsufficientFunds` — available balance is less than the milestone amount. + pub fn release_milestone( + env: Env, + contract_id: u32, + caller: Address, + milestone_index: u32, + ) -> bool { Self::require_not_paused(&env); + caller.require_auth(); let key = DataKey::Contract(contract_id); let mut contract = env @@ -514,6 +533,10 @@ impl Escrow { .get::<_, EscrowContractData>(&key) .unwrap_or_else(|| env.panic_with_error(EscrowError::ContractNotFound)); + if caller != contract.client { + env.panic_with_error(EscrowError::UnauthorizedRole); + } + if milestone_index >= contract.milestones.len() { env.panic_with_error(EscrowError::InvalidMilestone); } @@ -698,6 +721,60 @@ impl Escrow { // ─── Read-only queries (not blocked by pause) ───────────────────────────── + /// Returns a versioned, denormalized snapshot of the escrow contract for + /// off-chain indexers. Intentionally unauthenticated and never blocked by + /// pause or emergency guards so that data availability is always maintained. + /// + /// Panics with [`EscrowError::ContractNotFound`] if `contract_id` does not exist. + pub fn get_contract_summary(env: Env, contract_id: u32) -> ContractSummary { + let contract = env + .storage() + .persistent() + .get::<_, EscrowContractData>(&DataKey::Contract(contract_id)) + .unwrap_or_else(|| env.panic_with_error(EscrowError::ContractNotFound)); + + let mut total_amount: i128 = 0; + let mut released_milestone_count: u32 = 0; + let mut milestones = Vec::new(&env); + + for i in 0..contract.milestones.len() { + let amount = contract.milestones.get(i).unwrap(); + total_amount += amount; + let released = env + .storage() + .persistent() + .get::<_, bool>(&DataKey::MilestoneReleased(contract_id, i)) + .unwrap_or(false); + if released { + released_milestone_count += 1; + } + milestones.push_back(MilestoneSummary { + index: i, + amount, + released, + refunded: false, + }); + } + + let refundable_balance = + contract.total_deposited - contract.released_amount - contract.refunded_amount; + + ContractSummary { + schema_version: CONTRACT_SUMMARY_SCHEMA_VERSION, + client: contract.client, + freelancer: contract.freelancer, + arbiter: contract.arbiter, + status: contract.status, + reputation_issued: contract.reputation_issued, + total_amount, + funded_amount: contract.total_deposited, + released_amount: contract.released_amount, + refundable_balance, + released_milestone_count, + milestones, + } + } + pub fn get_contract(env: Env, contract_id: u32) -> EscrowContractData { env.storage() .persistent() diff --git a/contracts/escrow/src/test/emergency_controls.rs b/contracts/escrow/src/test/emergency_controls.rs index e12e381..519562d 100644 --- a/contracts/escrow/src/test/emergency_controls.rs +++ b/contracts/escrow/src/test/emergency_controls.rs @@ -27,8 +27,8 @@ fn setup_funded_contract(env: &Env, client: &EscrowClient) -> (Address, Address, fn setup_completed_contract(env: &Env, client: &EscrowClient) -> (Address, Address, u32) { let (client_addr, freelancer_addr, id) = setup_funded_contract(env, client); - client.release_milestone(&id, &0); - client.release_milestone(&id, &1); + client.release_milestone(&id, &client_addr, &0); + client.release_milestone(&id, &client_addr, &1); (client_addr, freelancer_addr, id) } @@ -106,11 +106,11 @@ fn emergency_blocks_deposit_funds() { fn emergency_blocks_release_milestone() { let (env, contract_id, _admin) = setup_initialized(); let client = EscrowClient::new(&env, &contract_id); - let (_, _, id) = setup_funded_contract(&env, &client); + let (client_addr, _, id) = setup_funded_contract(&env, &client); client.activate_emergency_pause(); super::assert_contract_error( - client.try_release_milestone(&id, &0), + client.try_release_milestone(&id, &client_addr, &0), EscrowError::ContractPaused, ); } diff --git a/contracts/escrow/src/test/lifecycle.rs b/contracts/escrow/src/test/lifecycle.rs index 16ddbd9..53b4582 100644 --- a/contracts/escrow/src/test/lifecycle.rs +++ b/contracts/escrow/src/test/lifecycle.rs @@ -22,9 +22,9 @@ fn successful_contract_lifecycle() { ); // Release milestones - assert!(client.release_milestone(&contract_id, &0)); - assert!(client.release_milestone(&contract_id, &1)); - assert!(client.release_milestone(&contract_id, &2)); + assert!(client.release_milestone(&contract_id, &client_addr, &0)); + assert!(client.release_milestone(&contract_id, &client_addr, &1)); + assert!(client.release_milestone(&contract_id, &client_addr, &2)); let finalized = client.get_contract(&contract_id); assert_eq!(finalized.status, ContractStatus::Completed); diff --git a/contracts/escrow/src/test/mod.rs b/contracts/escrow/src/test/mod.rs index b540ebe..d2a60e8 100644 --- a/contracts/escrow/src/test/mod.rs +++ b/contracts/escrow/src/test/mod.rs @@ -8,6 +8,7 @@ use crate::{Escrow, EscrowClient, EscrowError}; mod emergency_controls; mod pause_controls; +mod release_authorization; // ─── Shared constants ───────────────────────────────────────────────────────── @@ -56,9 +57,9 @@ pub fn create_contract(env: &Env, client: &EscrowClient) -> (Address, Address, u pub fn complete_contract(env: &Env, client: &EscrowClient) -> (Address, Address, u32) { let (client_addr, freelancer_addr, id) = create_contract(env, client); assert!(client.deposit_funds(&id, &total_milestone_amount())); - assert!(client.release_milestone(&id, &0)); - assert!(client.release_milestone(&id, &1)); - assert!(client.release_milestone(&id, &2)); + assert!(client.release_milestone(&id, &client_addr, &0)); + assert!(client.release_milestone(&id, &client_addr, &1)); + assert!(client.release_milestone(&id, &client_addr, &2)); (client_addr, freelancer_addr, id) } diff --git a/contracts/escrow/src/test/pause_controls.rs b/contracts/escrow/src/test/pause_controls.rs index 295a950..e6179af 100644 --- a/contracts/escrow/src/test/pause_controls.rs +++ b/contracts/escrow/src/test/pause_controls.rs @@ -29,8 +29,8 @@ fn setup_funded_contract(env: &Env, client: &EscrowClient) -> (Address, Address, /// Create a completed contract ready for reputation issuance. fn setup_completed_contract(env: &Env, client: &EscrowClient) -> (Address, Address, u32) { let (client_addr, freelancer_addr, id) = setup_funded_contract(env, client); - client.release_milestone(&id, &0); - client.release_milestone(&id, &1); + client.release_milestone(&id, &client_addr, &0); + client.release_milestone(&id, &client_addr, &1); (client_addr, freelancer_addr, id) } @@ -111,11 +111,11 @@ fn pause_blocks_deposit_funds() { fn pause_blocks_release_milestone() { let (env, contract_id, _admin) = setup_initialized(); let client = EscrowClient::new(&env, &contract_id); - let (_, _, id) = setup_funded_contract(&env, &client); + let (client_addr, _, id) = setup_funded_contract(&env, &client); client.pause(); super::assert_contract_error( - client.try_release_milestone(&id, &0), + client.try_release_milestone(&id, &client_addr, &0), EscrowError::ContractPaused, ); } diff --git a/contracts/escrow/src/test/persistence.rs b/contracts/escrow/src/test/persistence.rs index be90154..bc0b19d 100644 --- a/contracts/escrow/src/test/persistence.rs +++ b/contracts/escrow/src/test/persistence.rs @@ -23,7 +23,7 @@ fn contract_state_round_trips_across_lifecycle_mutations() { &contract_id, &(total_milestone_amount() - 10_000_000_000_i128), )); - assert!(client.release_milestone(&contract_id, &0)); + assert!(client.release_milestone(&contract_id, &client_addr, &0)); let after_release = client.get_contract(&contract_id); assert_eq!(after_release.released_amount, super::MILESTONE_ONE); diff --git a/contracts/escrow/src/test/release_authorization.rs b/contracts/escrow/src/test/release_authorization.rs index fe5bd3a..ca4ceed 100644 --- a/contracts/escrow/src/test/release_authorization.rs +++ b/contracts/escrow/src/test/release_authorization.rs @@ -1,301 +1,109 @@ -//! # Release Authorization Modes Test Suite +//! Tests for `release_milestone` caller authorization. //! -//! Tests for multi-party milestone release authorization: -//! - Client-only approval mode -//! - Client and freelancer dual approval mode -//! - Arbiter-only approval mode -//! - Approval event emission -//! - Duplicate approval prevention -//! - Unauthorized approval rejection +//! Covers: +//! - Legitimate client can release a funded milestone. +//! - Arbitrary attacker address is rejected with `UnauthorizedRole`. +//! - Double-releasing the same milestone is rejected with `AlreadyReleased`. +//! - Freelancer (non-client) is rejected with `UnauthorizedRole`. #![cfg(test)] -use soroban_sdk::{testutils::Address as _, testutils::Events as _, vec, Address, Env}; +use soroban_sdk::{testutils::Address as _, vec, Address, Env}; -use crate::{ - ContractStatus, Escrow, EscrowClient, EscrowError, ReleaseAuthorizationMode, -}; +use crate::types::DepositMode; +use crate::{Escrow, EscrowClient, EscrowError}; -// --------------------------------------------------------------------------- -// Test helpers -// --------------------------------------------------------------------------- +use super::assert_contract_error; -/// Register the contract and return a client. -fn register_client(env: &Env) -> EscrowClient { +/// Register the escrow contract and return a client. +fn register(env: &Env) -> EscrowClient<'_> { let id = env.register(Escrow, ()); EscrowClient::new(env, &id) } -/// Create a contract with specified authorization mode. -fn create_contract_with_mode( - env: &Env, - client: &EscrowClient, - client_addr: &Address, - freelancer_addr: &Address, - arbiter_addr: &Option
, - mode: &ReleaseAuthorizationMode, -) -> u32 { - let milestones = vec![env, 100_i128, 200_i128, 300_i128]; - client.create_contract( - client_addr, - freelancer_addr, - arbiter_addr, +/// Create a fully-funded 2-milestone contract (500 + 300 = 800 total). +/// Returns `(client_addr, freelancer_addr, contract_id)`. +fn funded_contract(env: &Env, client: &EscrowClient<'_>) -> (Address, Address, u32) { + let client_addr = Address::generate(env); + let freelancer_addr = Address::generate(env); + let milestones = vec![env, 500_i128, 300_i128]; + let id = client.create_contract( + &client_addr, + &freelancer_addr, &milestones, - mode, - &None, - &None, - ) -} - -/// Fund a contract with the full milestone amount (600 total). -fn fund_contract(_env: &Env, client: &EscrowClient, contract_id: &u32) { - client.deposit_funds(contract_id, &600_i128); + &DepositMode::ExactTotal, + ); + client.deposit_funds(&id, &800_i128); + (client_addr, freelancer_addr, id) } // --------------------------------------------------------------------------- -// Client-only authorization tests +// Happy path: legitimate client releases a milestone // --------------------------------------------------------------------------- #[test] -fn client_only_mode_allows_direct_release() { +fn client_can_release_funded_milestone() { let env = Env::default(); env.mock_all_auths(); + let client = register(&env); + let (client_addr, _freelancer_addr, id) = funded_contract(&env, &client); - let client = register_client(&env); - let client_addr = Address::generate(&env); - let freelancer_addr = Address::generate(&env); - - let contract_id = create_contract_with_mode( - &env, - &client, - &client_addr, - &freelancer_addr, - &None, - &ReleaseAuthorizationMode::ClientOnly, - ); - - fund_contract(&env, &client, &contract_id); - - // Client can release directly without approval - assert!(client.release_milestone(&contract_id, &0, &client_addr)); -} - -#[test] -fn client_only_mode_rejects_approval_calls() { - let env = Env::default(); - env.mock_all_auths(); - - let client = register_client(&env); - let client_addr = Address::generate(&env); - let freelancer_addr = Address::generate(&env); - - let contract_id = create_contract_with_mode( - &env, - &client, - &client_addr, - &freelancer_addr, - &None, - &ReleaseAuthorizationMode::ClientOnly, - ); - - fund_contract(&env, &client, &contract_id); + assert!(client.release_milestone(&id, &client_addr, &0)); - // Approval calls should be rejected for client-only mode - let result = client.try_approve_milestone_release(&contract_id, &0, &client_addr); - assert!(result.is_err()); + let contract = client.get_contract(&id); + assert_eq!(contract.released_amount, 500_i128); } // --------------------------------------------------------------------------- -// Dual approval tests +// Attacker is rejected with UnauthorizedRole // --------------------------------------------------------------------------- #[test] -fn dual_approval_mode_requires_both_parties() { +fn attacker_cannot_release_milestone() { let env = Env::default(); env.mock_all_auths(); + let client = register(&env); + let (_client_addr, _freelancer_addr, id) = funded_contract(&env, &client); - let client = register_client(&env); - let client_addr = Address::generate(&env); - let freelancer_addr = Address::generate(&env); - - let contract_id = create_contract_with_mode( - &env, - &client, - &client_addr, - &freelancer_addr, - &None, - &ReleaseAuthorizationMode::ClientAndFreelancer, - ); - - fund_contract(&env, &client, &contract_id); - - // Client approves - assert!(client.approve_milestone_release(&contract_id, &0, &client_addr)); - - // Release should fail without freelancer approval - let result = client.try_release_milestone(&contract_id, &0, &client_addr); - assert!(result.is_err()); - - // Freelancer approves - assert!(client.approve_milestone_release(&contract_id, &0, &freelancer_addr)); - - // Now release should succeed - assert!(client.release_milestone(&contract_id, &0, &client_addr)); -} - -#[test] -fn dual_approval_mode_prevents_duplicate_approvals() { - let env = Env::default(); - env.mock_all_auths(); - - let client = register_client(&env); - let client_addr = Address::generate(&env); - let freelancer_addr = Address::generate(&env); - - let contract_id = create_contract_with_mode( - &env, - &client, - &client_addr, - &freelancer_addr, - &None, - &ReleaseAuthorizationMode::ClientAndFreelancer, - ); - - fund_contract(&env, &client, &contract_id); - - // Client approves - assert!(client.approve_milestone_release(&contract_id, &0, &client_addr)); - - // Duplicate client approval should fail - let result = client.try_approve_milestone_release(&contract_id, &0, &client_addr); - assert!(result.is_err()); + let attacker = Address::generate(&env); + let result = client.try_release_milestone(&id, &attacker, &0); + assert_contract_error(result, EscrowError::UnauthorizedRole); } // --------------------------------------------------------------------------- -// Arbiter-only tests +// Double-release is rejected with AlreadyReleased; no duplicate transfer // --------------------------------------------------------------------------- #[test] -fn arbiter_only_mode_requires_arbiter_approval() { +fn double_release_is_rejected_and_amount_not_duplicated() { let env = Env::default(); env.mock_all_auths(); + let client = register(&env); + let (client_addr, _freelancer_addr, id) = funded_contract(&env, &client); - let client = register_client(&env); - let client_addr = Address::generate(&env); - let freelancer_addr = Address::generate(&env); - let arbiter_addr = Address::generate(&env); - - let contract_id = create_contract_with_mode( - &env, - &client, - &client_addr, - &freelancer_addr, - &Some(arbiter_addr.clone()), - &ReleaseAuthorizationMode::ArbiterOnly, - ); - - fund_contract(&env, &client, &contract_id); - - // Arbiter approves - assert!(client.approve_milestone_release(&contract_id, &0, &arbiter_addr)); - - // Release should succeed - assert!(client.release_milestone(&contract_id, &0, &arbiter_addr)); -} - -#[test] -fn arbiter_only_mode_rejects_without_arbiter() { - let env = Env::default(); - env.mock_all_auths(); - - let client = register_client(&env); - let client_addr = Address::generate(&env); - let freelancer_addr = Address::generate(&env); - - let contract_id = create_contract_with_mode( - &env, - &client, - &client_addr, - &freelancer_addr, - &None, // No arbiter - &ReleaseAuthorizationMode::ArbiterOnly, - ); + // First release succeeds. + assert!(client.release_milestone(&id, &client_addr, &0)); - fund_contract(&env, &client, &contract_id); + // Second release on the same milestone must fail with AlreadyReleased. + let result = client.try_release_milestone(&id, &client_addr, &0); + assert_contract_error(result, EscrowError::AlreadyReleased); - // Approval should fail without arbiter - let result = client.try_approve_milestone_release(&contract_id, &0, &client_addr); - assert!(result.is_err()); + // released_amount must not be doubled. + let contract = client.get_contract(&id); + assert_eq!(contract.released_amount, 500_i128); } // --------------------------------------------------------------------------- -// Event emission tests +// Freelancer (non-client) is also rejected // --------------------------------------------------------------------------- #[test] -fn approval_emits_events() { +fn freelancer_cannot_release_milestone() { let env = Env::default(); env.mock_all_auths(); + let client = register(&env); + let (_client_addr, freelancer_addr, id) = funded_contract(&env, &client); - let client = register_client(&env); - let client_addr = Address::generate(&env); - let freelancer_addr = Address::generate(&env); - - let contract_id = create_contract_with_mode( - &env, - &client, - &client_addr, - &freelancer_addr, - &None, - &ReleaseAuthorizationMode::ClientAndFreelancer, - ); - - fund_contract(&env, &client, &contract_id); - - // Client approves - client.approve_milestone_release(&contract_id, &0, &client_addr); - - // Check approval event was emitted - let events = env.events().all(); - assert!(events.len() > 0); - - // Find the approval event - let approval_event = events.iter().find(|event| { - event.0 == soroban_sdk::symbol_short!("milestone_approved") - }); - assert!(approval_event.is_some()); + let result = client.try_release_milestone(&id, &freelancer_addr, &0); + assert_contract_error(result, EscrowError::UnauthorizedRole); } - -#[test] -fn release_emits_events() { - let env = Env::default(); - env.mock_all_auths(); - - let client = register_client(&env); - let client_addr = Address::generate(&env); - let freelancer_addr = Address::generate(&env); - - let contract_id = create_contract_with_mode( - &env, - &client, - &client_addr, - &freelancer_addr, - &None, - &ReleaseAuthorizationMode::ClientOnly, - ); - - fund_contract(&env, &client, &contract_id); - - // Release milestone - client.release_milestone(&contract_id, &0, &client_addr); - - // Check release event was emitted - let events = env.events().all(); - assert!(events.len() > 0); - - // Find the release event - let release_event = events.iter().find(|event| { - event.0 == soroban_sdk::symbol_short!("milestone_released") - }); - assert!(release_event.is_some()); -} \ No newline at end of file diff --git a/contracts/escrow/src/test/security.rs b/contracts/escrow/src/test/security.rs index 1d7be9b..af9ad24 100644 --- a/contracts/escrow/src/test/security.rs +++ b/contracts/escrow/src/test/security.rs @@ -79,9 +79,9 @@ fn release_rejects_when_contract_not_funded() { let env = Env::default(); env.mock_all_auths(); let client = register_client(&env); - let (_client_addr, _freelancer_addr, contract_id) = create_contract(&env, &client); + let (client_addr, _freelancer_addr, contract_id) = create_contract(&env, &client); - let result = client.try_release_milestone(&contract_id, &0); + let result = client.try_release_milestone(&contract_id, &client_addr, &0); super::assert_contract_error(result, EscrowError::InsufficientFunds); } @@ -90,10 +90,10 @@ fn release_rejects_invalid_milestone_id() { let env = Env::default(); env.mock_all_auths(); let client = register_client(&env); - let (_client_addr, _freelancer_addr, contract_id) = create_contract(&env, &client); + let (client_addr, _freelancer_addr, contract_id) = create_contract(&env, &client); assert!(client.deposit_funds(&contract_id, &super::total_milestone_amount())); - let result = client.try_release_milestone(&contract_id, &99); + let result = client.try_release_milestone(&contract_id, &client_addr, &99); super::assert_contract_error(result, EscrowError::InvalidMilestone); } @@ -102,12 +102,12 @@ fn release_rejects_double_release() { let env = Env::default(); env.mock_all_auths(); let client = register_client(&env); - let (_client_addr, _freelancer_addr, contract_id) = create_contract(&env, &client); + let (client_addr, _freelancer_addr, contract_id) = create_contract(&env, &client); assert!(client.deposit_funds(&contract_id, &super::total_milestone_amount())); - assert!(client.release_milestone(&contract_id, &0)); + assert!(client.release_milestone(&contract_id, &client_addr, &0)); - let result = client.try_release_milestone(&contract_id, &0); + let result = client.try_release_milestone(&contract_id, &client_addr, &0); super::assert_contract_error(result, EscrowError::AlreadyReleased); } diff --git a/docs/escrow/access-control.md b/docs/escrow/access-control.md index 1a13888..dce919e 100644 --- a/docs/escrow/access-control.md +++ b/docs/escrow/access-control.md @@ -44,10 +44,16 @@ Ensure only valid contract actors (client, freelancer, arbiter) can invoke state ### `release_milestone` -- Requires caller auth. -- Caller role validated against `release_auth` mode. -- Requires sufficient approvals for configured mode. -- Rejects invalid or already released milestones. +- Accepts an explicit `caller: Address` parameter. +- Calls `caller.require_auth()` immediately — cryptographic proof of authorization is mandatory before any state is read or mutated. +- Asserts `caller == contract.client`; any other address panics with `EscrowError::UnauthorizedRole` (fail-closed). +- Rejects invalid or already released milestones (`InvalidMilestone`, `AlreadyReleased`). +- Rejects releases when available balance is insufficient (`InsufficientFunds`). +- The `DataKey::MilestoneReleased` guard prevents double-release and duplicate token transfers. + +#### Fail-closed design + +The authorization check is placed before any storage reads of sensitive state. If `require_auth` fails the transaction is aborted by the Soroban host before the contract body executes, ensuring no partial state mutation is possible. The explicit `caller != contract.client` check provides a second, contract-level role boundary independent of the host auth mechanism. ### `issue_reputation`