diff --git a/contracts/escrow/src/approvals.rs b/contracts/escrow/src/approvals.rs index 97482c9..b7f4473 100644 --- a/contracts/escrow/src/approvals.rs +++ b/contracts/escrow/src/approvals.rs @@ -1,21 +1,23 @@ use crate::ttl::{PENDING_APPROVAL_BUMP_THRESHOLD, PENDING_APPROVAL_TTL_LEDGERS}; -use crate::types::{Contract, ContractStatus, DataKey, Error, MilestoneApprovals, Milestone, ReleaseAuthorization}; +use crate::types::{ + Contract, ContractStatus, DataKey, Error, Milestone, MilestoneApprovals, ReleaseAuthorization, +}; use soroban_sdk::{Address, Env, Symbol, Vec}; /// Approves a milestone for release by the caller. -/// +/// /// Records the approval in temporary storage with TTL expiry. /// The approval will automatically expire after PENDING_APPROVAL_TTL_LEDGERS. -/// +/// /// # Arguments /// * `env` - The contract environment /// * `contract_id` - The contract ID /// * `milestone_index` - The index of the milestone to approve /// * `caller` - The address of the caller (must be client, freelancer, or arbiter) -/// +/// /// # Returns /// `true` if approval was recorded successfully -/// +/// /// # Errors /// * `ContractNotFound` - If contract doesn't exist /// * `InvalidState` - If contract is not in Funded state @@ -23,7 +25,7 @@ use soroban_sdk::{Address, Env, Symbol, Vec}; /// * `MilestoneAlreadyReleased` - If milestone was already released /// * `UnauthorizedRole` - If caller is not authorized to approve /// * `AlreadyApproved` - If caller has already approved this milestone -/// +/// /// # Security /// - Caller must be authenticated via require_auth() /// - Only authorized parties (client/freelancer/arbiter) can approve @@ -73,7 +75,7 @@ pub fn approve_milestone( // Determine caller role and check authorization let is_client = caller == &contract.client; let is_freelancer = caller == &contract.freelancer; - let is_arbiter = contract.arbiter.as_ref().map_or(false, |a| caller == a); + let is_arbiter = contract.arbiter.as_ref() == Some(caller); // Verify caller is a valid participant if !is_client && !is_freelancer && !is_arbiter { @@ -106,15 +108,15 @@ pub fn approve_milestone( // Load or create approval record let approval_key = DataKey::MilestoneApprovals(contract_id, milestone_index); - let mut approvals: MilestoneApprovals = env - .storage() - .temporary() - .get(&approval_key) - .unwrap_or(MilestoneApprovals { - client_approved: false, - freelancer_approved: false, - arbiter_approved: false, - }); + let mut approvals: MilestoneApprovals = + env.storage() + .temporary() + .get(&approval_key) + .unwrap_or(MilestoneApprovals { + client_approved: false, + freelancer_approved: false, + arbiter_approved: false, + }); // Check for duplicate approval and update if is_client { @@ -135,32 +137,32 @@ pub fn approve_milestone( } // Store approval with TTL - env.storage() - .temporary() - .set(&approval_key, &approvals); - - env.storage() - .temporary() - .extend_ttl(&approval_key, PENDING_APPROVAL_BUMP_THRESHOLD, PENDING_APPROVAL_TTL_LEDGERS); + env.storage().temporary().set(&approval_key, &approvals); + + env.storage().temporary().extend_ttl( + &approval_key, + PENDING_APPROVAL_BUMP_THRESHOLD, + PENDING_APPROVAL_TTL_LEDGERS, + ); Ok(true) } /// Checks if a milestone has sufficient approvals for release. -/// +/// /// Expired approvals (TTL elapsed) are treated as absent and return None. -/// +/// /// # Arguments /// * `env` - The contract environment /// * `contract` - The contract data /// * `contract_id` - The contract ID /// * `milestone_index` - The milestone index -/// +/// /// # Returns /// * `Ok(true)` - If sufficient approvals exist and are valid /// * `Err(InsufficientApprovals)` - If approvals are missing or insufficient /// * `Err(ApprovalExpired)` - If approvals existed but have expired -/// +/// /// # Security /// - Fail-closed: missing or expired approvals prevent release /// - TTL expiry is enforced by Soroban's temporary storage @@ -171,13 +173,10 @@ pub fn check_approvals( milestone_index: u32, ) -> Result { let approval_key = DataKey::MilestoneApprovals(contract_id, milestone_index); - + // Try to load approvals from temporary storage // If TTL has expired, this will return None - let approvals: Option = env - .storage() - .temporary() - .get(&approval_key); + let approvals: Option = env.storage().temporary().get(&approval_key); // If no approvals exist (or they expired), fail let approvals = approvals.ok_or(Error::InsufficientApprovals)?; @@ -202,9 +201,9 @@ pub fn check_approvals( } /// Clears approval records for a milestone after successful release. -/// +/// /// This prevents approval reuse and cleans up temporary storage. -/// +/// /// # Arguments /// * `env` - The contract environment /// * `contract_id` - The contract ID @@ -217,16 +216,18 @@ pub fn clear_approvals(env: &Env, contract_id: u32, milestone_index: u32) { #[cfg(test)] mod tests { use super::*; - use soroban_sdk::{testutils::Address as _, Env}; - - #[test] - fn test_approve_milestone_client_only() { - let env = Env::default(); - env.mock_all_auths(); - - let client = Address::generate(&env); - let freelancer = Address::generate(&env); - + use crate::Escrow; + use soroban_sdk::{testutils::Address as _, Address, Env}; + + const CONTRACT_ID: u32 = 1; + + fn seed_funded_contract( + env: &Env, + escrow: &Address, + release_authorization: ReleaseAuthorization, + ) -> (Address, Address, Contract) { + let client = Address::generate(env); + let freelancer = Address::generate(env); let contract = Contract { client: client.clone(), freelancer: freelancer.clone(), @@ -235,134 +236,82 @@ mod tests { funded_amount: 1000, released_amount: 0, refunded_amount: 0, - release_authorization: ReleaseAuthorization::ClientOnly, + release_authorization, }; - - let contract_id = 1u32; - env.storage() - .persistent() - .set(&DataKey::Contract(contract_id), &contract); - let milestones = Vec::from_array( - &env, + env, [Milestone { amount: 1000, + funded_amount: 0, released: false, refunded: false, work_evidence: None, + refunded_amount: 0, }], ); - let milestone_key = Symbol::new(&env, "milestones"); - env.storage() - .persistent() - .set(&(DataKey::Contract(contract_id), milestone_key), &milestones); - - // Client approves - let result = approve_milestone(&env, contract_id, 0, &client); - assert!(result.is_ok()); + let milestone_key = Symbol::new(env, "milestones"); + + env.as_contract(escrow, || { + env.storage() + .persistent() + .set(&DataKey::Contract(CONTRACT_ID), &contract); + env.storage().persistent().set( + &(DataKey::Contract(CONTRACT_ID), milestone_key), + &milestones, + ); + }); - // Check approvals - let check = check_approvals(&env, &contract, contract_id, 0); - assert!(check.is_ok()); + (client, freelancer, contract) } #[test] - fn test_approve_milestone_multisig() { + fn test_approve_milestone_client_only() { let env = Env::default(); env.mock_all_auths(); + let escrow = env.register(Escrow, ()); + let (client, _, contract) = + seed_funded_contract(&env, &escrow, ReleaseAuthorization::ClientOnly); - let client = Address::generate(&env); - let freelancer = Address::generate(&env); - - let contract = Contract { - client: client.clone(), - freelancer: freelancer.clone(), - arbiter: None, - status: ContractStatus::Funded, - funded_amount: 1000, - released_amount: 0, - refunded_amount: 0, - release_authorization: ReleaseAuthorization::MultiSig, - }; - - let contract_id = 1u32; - env.storage() - .persistent() - .set(&DataKey::Contract(contract_id), &contract); - - let milestones = Vec::from_array( - &env, - [Milestone { - amount: 1000, - released: false, - refunded: false, - work_evidence: None, - }], - ); - let milestone_key = Symbol::new(&env, "milestones"); - env.storage() - .persistent() - .set(&(DataKey::Contract(contract_id), milestone_key), &milestones); - - // Only client approves - insufficient - let result = approve_milestone(&env, contract_id, 0, &client); - assert!(result.is_ok()); - - let check = check_approvals(&env, &contract, contract_id, 0); - assert_eq!(check, Err(Error::InsufficientApprovals)); - - // Freelancer also approves - now sufficient - let result = approve_milestone(&env, contract_id, 0, &freelancer); - assert!(result.is_ok()); + env.as_contract(&escrow, || { + assert!(approve_milestone(&env, CONTRACT_ID, 0, &client).is_ok()); + assert!(check_approvals(&env, &contract, CONTRACT_ID, 0).is_ok()); + }); + } - let check = check_approvals(&env, &contract, contract_id, 0); - assert!(check.is_ok()); + #[test] + fn test_approve_milestone_multisig() { + let env = Env::default(); + env.mock_all_auths(); + let escrow = env.register(Escrow, ()); + let (client, freelancer, contract) = + seed_funded_contract(&env, &escrow, ReleaseAuthorization::MultiSig); + + env.as_contract(&escrow, || { + assert!(approve_milestone(&env, CONTRACT_ID, 0, &client).is_ok()); + assert_eq!( + check_approvals(&env, &contract, CONTRACT_ID, 0), + Err(Error::InsufficientApprovals) + ); + assert!(approve_milestone(&env, CONTRACT_ID, 0, &freelancer).is_ok()); + assert!(check_approvals(&env, &contract, CONTRACT_ID, 0).is_ok()); + }); } #[test] fn test_duplicate_approval_rejected() { let env = Env::default(); env.mock_all_auths(); + let escrow = env.register(Escrow, ()); + let (client, _, _) = seed_funded_contract(&env, &escrow, ReleaseAuthorization::ClientOnly); - let client = Address::generate(&env); - let freelancer = Address::generate(&env); - - let contract = Contract { - client: client.clone(), - freelancer: freelancer.clone(), - arbiter: None, - status: ContractStatus::Funded, - funded_amount: 1000, - released_amount: 0, - refunded_amount: 0, - release_authorization: ReleaseAuthorization::ClientOnly, - }; - - let contract_id = 1u32; - env.storage() - .persistent() - .set(&DataKey::Contract(contract_id), &contract); - - let milestones = Vec::from_array( - &env, - [Milestone { - amount: 1000, - released: false, - refunded: false, - work_evidence: None, - }], - ); - let milestone_key = Symbol::new(&env, "milestones"); - env.storage() - .persistent() - .set(&(DataKey::Contract(contract_id), milestone_key), &milestones); - - // First approval succeeds - let result = approve_milestone(&env, contract_id, 0, &client); - assert!(result.is_ok()); - - // Second approval fails - let result = approve_milestone(&env, contract_id, 0, &client); - assert_eq!(result, Err(Error::AlreadyApproved)); + env.as_contract(&escrow, || { + assert!(approve_milestone(&env, CONTRACT_ID, 0, &client).is_ok()); + }); + env.as_contract(&escrow, || { + assert_eq!( + approve_milestone(&env, CONTRACT_ID, 0, &client), + Err(Error::AlreadyApproved) + ); + }); } } diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 065f28d..5a9267a 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -23,13 +23,17 @@ #![allow(clippy::single_match)] #![allow(clippy::useless_conversion)] -mod types; -mod ttl; mod approvals; +mod ttl; +mod types; -pub use types::{Contract, ContractStatus, DataKey, Error, Milestone, MilestoneApprovals, ReleaseAuthorization}; +pub use types::{ + Contract, ContractStatus, DataKey, Error, Milestone, MilestoneApprovals, ReleaseAuthorization, +}; -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, Symbol, Vec}; +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, symbol_short, Address, Env, Symbol, Vec, +}; #[contract] pub struct Escrow; @@ -66,7 +70,7 @@ impl Escrow { } /// Creates a new escrow contract with the specified client, freelancer, and milestone amounts. - /// + /// /// # Arguments /// * `env` - The contract environment /// * `client` - The address of the client funding the contract @@ -74,16 +78,18 @@ impl Escrow { /// * `arbiter` - Optional arbiter address for dispute resolution /// * `milestones` - Vector of milestone amounts (in stroops) /// * `release_authorization` - Authorization mode for milestone releases - /// + /// /// # Returns /// The unique contract ID - /// + /// /// # Errors /// * `InvalidParticipants` - If client and freelancer are the same address /// * `EmptyMilestones` - If no milestones are provided /// * `InvalidMilestoneAmount` - If any milestone amount is <= 0 /// * `MissingArbiter` - If arbiter is required but not provided /// * `InvalidArbiter` - If arbiter is same as client or freelancer + /// * `ContractIdOverflow` - If the next id would exceed `u32::MAX` + /// * `ContractIdCollision` - If the allocated id slot is already occupied pub fn create_contract( env: Env, client: Address, @@ -97,63 +103,41 @@ impl Escrow { if client == freelancer { env.panic_with_error(Error::InvalidParticipants); } - + // Validate arbiter requirements match release_authorization { - ReleaseAuthorization::ArbiterOnly | ReleaseAuthorization::ClientAndArbiter => { - if arbiter.is_none() { - env.panic_with_error(Error::MissingArbiter); - } + ReleaseAuthorization::ArbiterOnly | ReleaseAuthorization::ClientAndArbiter + if arbiter.is_none() => + { + env.panic_with_error(Error::MissingArbiter); } _ => {} } - + // Validate arbiter is not client or freelancer if let Some(ref arb) = arbiter { if arb == &client || arb == &freelancer { env.panic_with_error(Error::InvalidArbiter); } } - + if milestones.is_empty() { env.panic_with_error(Error::EmptyMilestones); } for amount in milestones.iter() { if amount <= 0 { - env.panic_with_error(EscrowError::InvalidMilestoneAmount); + env.panic_with_error(Error::InvalidMilestoneAmount); } } - 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::<_, u32>(&DataKey::NextContractId) - .unwrap_or(1); + let id = Self::next_contract_id(&env); // Store contract metadata + let freelancer_addr = freelancer.clone(); let contract = Contract { client: client.clone(), - freelancer, + freelancer: freelancer_addr.clone(), arbiter, status: ContractStatus::Created, funded_amount: 0, @@ -170,9 +154,11 @@ impl Escrow { for amount in milestones.iter() { milestone_vec.push_back(Milestone { amount, + funded_amount: 0, released: false, refunded: false, work_evidence: None, + refunded_amount: 0, }); } let milestone_key = Symbol::new(&env, "milestones"); @@ -180,35 +166,55 @@ impl Escrow { .persistent() .set(&(DataKey::Contract(id), milestone_key), &milestone_vec); - env.storage() - .persistent() - .set(&DataKey::NextContractId, &(id + 1)); - - Self::emit_audit_event( - env, - id, - ContractStatus::Created, - ContractStatus::Created, - &client, - ); + Self::bump_next_contract_id(&env, id); env.events().publish( (symbol_short!("created"), id), - (client, freelancer, env.ledger().timestamp()), + (client, freelancer_addr, env.ledger().timestamp()), ); id } + /// Returns the next contract id after verifying the slot is unused. + fn next_contract_id(env: &Env) -> u32 { + let id: u32 = env + .storage() + .persistent() + .get(&DataKey::NextContractId) + .unwrap_or(1); + + if env + .storage() + .persistent() + .get::<_, Contract>(&DataKey::Contract(id)) + .is_some() + { + env.panic_with_error(Error::ContractIdCollision); + } + + id + } + + /// Advances [`DataKey::NextContractId`] after a contract is persisted. + fn bump_next_contract_id(env: &Env, id: u32) { + let next_id = id + .checked_add(1) + .unwrap_or_else(|| env.panic_with_error(Error::ContractIdOverflow)); + env.storage() + .persistent() + .set(&DataKey::NextContractId, &next_id); + } + /// Deposits funds into the contract. Transitions to Funded status when fully funded. - /// + /// /// # Arguments /// * `env` - The contract environment /// * `contract_id` - The contract ID /// * `amount` - The amount to deposit (in stroops) - /// + /// /// # Returns /// `true` if deposit was successful - /// + /// /// # Errors /// * `AmountMustBePositive` - If amount is <= 0 /// * `ContractNotFound` - If contract doesn't exist @@ -229,9 +235,9 @@ impl Escrow { if caller != contract.client { env.panic_with_error(Error::UnauthorizedRole); } - + caller.require_auth(); - + // Can only deposit in Created state if contract.status != ContractStatus::Created { env.panic_with_error(Error::InvalidState); @@ -262,19 +268,19 @@ impl Escrow { } /// Approves a milestone for release. - /// + /// /// Records the approval in temporary storage with TTL expiry. /// Approvals automatically expire after PENDING_APPROVAL_TTL_LEDGERS. - /// + /// /// # Arguments /// * `env` - The contract environment /// * `contract_id` - The contract ID /// * `caller` - The address of the caller (must be authorized) /// * `milestone_index` - The index of the milestone to approve - /// + /// /// # Returns /// `true` if approval was recorded successfully - /// + /// /// # Errors /// * `ContractNotFound` - If contract doesn't exist /// * `InvalidState` - If contract is not in Funded state @@ -282,7 +288,7 @@ impl Escrow { /// * `MilestoneAlreadyReleased` - If milestone was already released /// * `UnauthorizedRole` - If caller is not authorized to approve /// * `AlreadyApproved` - If caller has already approved this milestone - /// + /// /// # Security /// - Caller must be authenticated /// - Only authorized parties can approve based on ReleaseAuthorization mode @@ -299,18 +305,18 @@ impl Escrow { } /// Releases a specific milestone, transferring funds to the freelancer. - /// + /// /// Requires valid, non-expired approvals based on the contract's ReleaseAuthorization mode. - /// + /// /// # Arguments /// * `env` - The contract environment /// * `contract_id` - The contract ID /// * `caller` - The address of the caller (must be authorized) /// * `milestone_index` - The index of the milestone to release - /// + /// /// # Returns /// `true` if release was successful - /// + /// /// # Errors /// * `ContractNotFound` - If contract doesn't exist /// * `InvalidState` - If contract is not in Funded state @@ -321,7 +327,7 @@ impl Escrow { /// * `InsufficientApprovals` - If required approvals are missing /// * `ApprovalExpired` - If approvals have expired /// * `UnauthorizedRole` - If caller is not authorized to release - /// + /// /// # Security /// - Requires valid approvals that haven't expired /// - Approvals are cleared after successful release @@ -345,7 +351,7 @@ impl Escrow { // Check authorization for release let is_client = caller == contract.client; - let is_arbiter = contract.arbiter.as_ref().map_or(false, |a| &caller == a); + let is_arbiter = contract.arbiter.as_ref() == Some(&caller); match contract.release_authorization { ReleaseAuthorization::ClientOnly => { @@ -404,9 +410,10 @@ impl Escrow { env.panic_with_error(Error::InsufficientFunds); } + let release_amount = milestone.amount; milestone.released = true; milestones.set(milestone_index, milestone); - contract.released_amount += milestone.amount; + contract.released_amount += release_amount; // Clear approvals after successful release approvals::clear_approvals(&env, contract_id, milestone_index); @@ -417,9 +424,10 @@ impl Escrow { contract.status = ContractStatus::Completed; } - env.storage() - .persistent() - .set(&(DataKey::Contract(contract_id), milestone_key), &milestones); + env.storage().persistent().set( + &(DataKey::Contract(contract_id), milestone_key), + &milestones, + ); env.storage() .persistent() .set(&DataKey::Contract(contract_id), &contract); @@ -428,15 +436,15 @@ impl Escrow { } /// Refunds unreleased milestones back to the client. - /// + /// /// # Arguments /// * `env` - The contract environment /// * `contract_id` - The contract ID /// * `milestone_indices` - Vector of milestone indices to refund - /// + /// /// # Returns /// The total amount refunded - /// + /// /// # Errors /// * `ContractNotFound` - If contract doesn't exist /// * `EmptyRefundRequest` - If milestone_indices is empty @@ -528,9 +536,10 @@ impl Escrow { } } - env.storage() - .persistent() - .set(&(DataKey::Contract(contract_id), milestone_key), &milestones); + env.storage().persistent().set( + &(DataKey::Contract(contract_id), milestone_key), + &milestones, + ); env.storage() .persistent() .set(&DataKey::Contract(contract_id), &contract); @@ -539,14 +548,14 @@ impl Escrow { } /// Retrieves contract information. - /// + /// /// # Arguments /// * `env` - The contract environment /// * `contract_id` - The contract ID - /// + /// /// # Returns /// The contract data - /// + /// /// # Errors /// * `ContractNotFound` - If contract doesn't exist pub fn get_contract(env: Env, contract_id: u32) -> Contract { @@ -557,14 +566,14 @@ impl Escrow { } /// Retrieves all milestones for a contract. - /// + /// /// # Arguments /// * `env` - The contract environment /// * `contract_id` - The contract ID - /// + /// /// # Returns /// Vector of milestones - /// + /// /// # Errors /// * `ContractNotFound` - If contract doesn't exist pub fn get_milestones(env: Env, contract_id: u32) -> Vec { @@ -576,14 +585,14 @@ impl Escrow { } /// Calculates the refundable balance (funded but not released or refunded). - /// + /// /// # Arguments /// * `env` - The contract environment /// * `contract_id` - The contract ID - /// + /// /// # Returns /// The refundable balance amount - /// + /// /// # Errors /// * `ContractNotFound` - If contract doesn't exist pub fn get_refundable_balance(env: Env, contract_id: u32) -> i128 { @@ -595,16 +604,16 @@ impl Escrow { contract.funded_amount - contract.released_amount - contract.refunded_amount } - + /// Retrieves approval status for a milestone. - /// + /// /// Returns None if approvals have expired or don't exist. - /// + /// /// # Arguments /// * `env` - The contract environment /// * `contract_id` - The contract ID /// * `milestone_index` - The milestone index - /// + /// /// # Returns /// Optional MilestoneApprovals struct pub fn get_milestone_approvals( @@ -617,10 +626,5 @@ impl Escrow { } } -#[cfg(test)] -mod proptest; -#[cfg(test)] -mod simple_amount_test; - #[cfg(test)] mod test; diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs deleted file mode 100644 index 25e3d4c..0000000 --- a/contracts/escrow/src/test.rs +++ /dev/null @@ -1,167 +0,0 @@ -#![cfg(test)] - -use soroban_sdk::{symbol_short, testutils::Address as _, vec, Address, Env, Vec}; - -use crate::{Contract, ContractStatus, Escrow, EscrowClient, Milestone, ReleaseAuthorization}; - -// Test helper functions -pub fn setup() -> (Env, Address, Address) { - let env = Env::default(); - env.mock_all_auths(); - let client_addr = Address::generate(&env); - let freelancer_addr = Address::generate(&env); - (env, client_addr, freelancer_addr) -} - -pub fn setup_env() -> Env { - let env = Env::default(); - env.mock_all_auths(); - env -} - -pub fn register_escrow(env: &Env) -> EscrowClient { - let contract_id = env.register(Escrow, ()); - EscrowClient::new(env, &contract_id) -} - -pub fn register_client(env: &Env) -> EscrowClient { - let contract_id = env.register(Escrow, ()); - EscrowClient::new(env, &contract_id) -} - -pub fn generated_participants(env: &Env) -> (Address, Address, Address) { - let client_addr = Address::generate(env); - let freelancer_addr = Address::generate(env); - let arbiter_addr = Address::generate(env); - (client_addr, freelancer_addr, arbiter_addr) -} - -pub fn default_milestones(env: &Env) -> Vec { - vec![env, 1000_0000000_i128, 2000_0000000_i128, 3000_0000000_i128] -} - -pub fn total_milestones() -> i128 { - 6000_0000000_i128 -} - -pub fn create_client(env: &Env) -> EscrowClient { - let contract_id = env.register(Escrow, ()); - EscrowClient::new(env, &contract_id) -} - -pub fn create_default_contract( - env: &Env, - client: &EscrowClient, - client_addr: &Address, - freelancer_addr: &Address, -) -> u32 { - let milestones = vec![env, 200_0000000_i128, 400_0000000_i128, 600_0000000_i128]; - client.create_contract( - client_addr, - freelancer_addr, - &None, - &milestones, - &ReleaseAuthorization::ClientOnly, - ) -} - -pub fn assert_contract_state( - contract: Contract, - expected_status: ContractStatus, - expected_funded: i128, - expected_released: i128, - expected_refunded: i128, -) { - assert_eq!(contract.status, expected_status); - assert_eq!(contract.funded_amount, expected_funded); - assert_eq!(contract.released_amount, expected_released); - assert_eq!(contract.refunded_amount, expected_refunded); -} - -pub fn assert_milestone_flags( - milestones: Vec, - index: u32, - expected_released: bool, - expected_refunded: bool, -) { - let milestone = milestones.get(index).unwrap(); - assert_eq!(milestone.released, expected_released); - assert_eq!(milestone.refunded, expected_refunded); -} - -#[test] -fn test_hello() { - let env = Env::default(); - let contract_id = env.register(Escrow, ()); - let client = EscrowClient::new(&env, &contract_id); - - let result = client.hello(&symbol_short!("World")); - assert_eq!(result, symbol_short!("World")); -} - -#[test] -fn test_create_contract() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register(Escrow, ()); - let client = EscrowClient::new(&env, &contract_id); - - let client_addr = Address::generate(&env); - let freelancer_addr = Address::generate(&env); - let milestones = vec![&env, 200_0000000_i128, 400_0000000_i128, 600_0000000_i128]; - - let id = client.create_contract( - &client_addr, - &freelancer_addr, - &None, - &milestones, - &ReleaseAuthorization::ClientOnly, - ); - assert_eq!(id, 1); -} - -#[test] -fn test_deposit_funds() { - let (env, client_addr, freelancer_addr) = setup(); - let client = create_client(&env); - let contract_id = create_default_contract(&env, &client, &client_addr, &freelancer_addr); - - let result = client.deposit_funds(&contract_id, &client_addr, &1_000_0000000); - assert!(result); -} - -#[test] -fn test_release_milestone() { - let (env, client_addr, freelancer_addr) = setup(); - let client = create_client(&env); - let contract_id = create_default_contract(&env, &client, &client_addr, &freelancer_addr); - - assert!(client.deposit_funds(&contract_id, &client_addr, &1_200_0000000_i128)); - assert!(client.approve_milestone_release(&contract_id, &client_addr, &0)); - let result = client.release_milestone(&contract_id, &client_addr, &0); - assert!(result); -} - -// Include test modules -mod refund; -mod release; -mod deposit; -mod create_contract; -mod access_control; -mod approval_expiry; -mod hello; -mod lifecycle; -mod flows; -mod security; -mod storage; -mod persistence; -mod performance; -mod input_sanitization_amounts; -mod input_sanitization_identities; -mod milestone_schedule; -mod governance; -mod emergency_controls; -mod pause_controls; -mod timeout_tests; -mod mainnet_readiness; -mod client_migration; diff --git a/contracts/escrow/src/test/contract_id_allocation.rs b/contracts/escrow/src/test/contract_id_allocation.rs new file mode 100644 index 0000000..3272d99 --- /dev/null +++ b/contracts/escrow/src/test/contract_id_allocation.rs @@ -0,0 +1,87 @@ +//! Overflow-safe `NextContractId` allocation tests. + +use super::{default_milestones, generated_participants, register_client}; +use crate::{DataKey, Error, ReleaseAuthorization}; +use soroban_sdk::{testutils::Address as _, Address, Env}; + +fn assert_error( + result: Result< + Result, + Result, + >, + expected: Error, +) { + match result { + Err(Ok(e)) => { + let expected_err: soroban_sdk::Error = expected.into(); + assert_eq!(e, expected_err); + } + other => panic!("expected {:?}, got {:?}", expected, other), + } +} + +#[test] +fn next_contract_id_overflow_at_u32_max() { + let env = Env::default(); + env.mock_all_auths(); + let escrow = register_client(&env); + let (client_addr, freelancer_addr, _) = generated_participants(&env); + let milestones = default_milestones(&env); + + env.as_contract(&escrow.address, || { + env.storage() + .persistent() + .set(&DataKey::NextContractId, &u32::MAX); + }); + + let result = escrow.try_create_contract( + &client_addr, + &freelancer_addr, + &None, + &milestones, + &ReleaseAuthorization::ClientOnly, + ); + assert_error(result, Error::ContractIdOverflow); + + env.as_contract(&escrow.address, || { + let next: u32 = env + .storage() + .persistent() + .get(&DataKey::NextContractId) + .unwrap(); + assert_eq!(next, u32::MAX); + }); +} + +#[test] +fn next_contract_id_rejects_occupied_slot() { + let env = Env::default(); + env.mock_all_auths(); + let escrow = register_client(&env); + let (client_addr, freelancer_addr, _) = generated_participants(&env); + let milestones = default_milestones(&env); + + let existing_id = escrow.create_contract( + &client_addr, + &freelancer_addr, + &None, + &milestones, + &ReleaseAuthorization::ClientOnly, + ); + + env.as_contract(&escrow.address, || { + env.storage() + .persistent() + .set(&DataKey::NextContractId, &existing_id); + }); + + let intruder = Address::generate(&env); + let result = escrow.try_create_contract( + &intruder, + &freelancer_addr, + &None, + &milestones, + &ReleaseAuthorization::ClientOnly, + ); + assert_error(result, Error::ContractIdCollision); +} diff --git a/contracts/escrow/src/test/mod.rs b/contracts/escrow/src/test/mod.rs index 9f95ca6..26674de 100644 --- a/contracts/escrow/src/test/mod.rs +++ b/contracts/escrow/src/test/mod.rs @@ -1,101 +1,92 @@ #![cfg(test)] -use soroban_sdk::{testutils::Address as _, vec, Address, Env}; +use soroban_sdk::{symbol_short, testutils::Address as _, vec, Address, Env, Vec}; -use crate::{Escrow, EscrowClient, EscrowError}; +use crate::{Escrow, EscrowClient, ReleaseAuthorization}; -// ─── Submodules ─────────────────────────────────────────────────────────────── - -mod admin_auth_helper; -mod dispute; -mod emergency_controls; -mod lifecycle; -mod pause_controls; -mod ttl_tests; - -// ─── Shared constants ───────────────────────────────────────────────────────── - -#[allow(dead_code)] // shared test fixture; not all test modules use every constant -pub const MILESTONE_ONE: i128 = 200_0000000; -#[allow(dead_code)] -pub const MILESTONE_TWO: i128 = 400_0000000; -#[allow(dead_code)] -pub const MILESTONE_THREE: i128 = 600_0000000; +mod contract_id_allocation; // ─── Shared helpers ─────────────────────────────────────────────────────────── -#[allow(dead_code)] // shared test fixture; not all test modules use every helper pub fn register_client(env: &Env) -> EscrowClient<'_> { let id = env.register(Escrow, ()); EscrowClient::new(env, &id) } -#[allow(dead_code)] -pub fn default_milestones(env: &Env) -> soroban_sdk::Vec { - vec![env, MILESTONE_ONE, MILESTONE_TWO, MILESTONE_THREE] +pub fn generated_participants(env: &Env) -> (Address, Address, Address) { + ( + Address::generate(env), + Address::generate(env), + Address::generate(env), + ) } -#[allow(dead_code)] -pub fn total_milestone_amount() -> i128 { - MILESTONE_ONE + MILESTONE_TWO + MILESTONE_THREE +pub fn default_milestones(env: &Env) -> Vec { + vec![env, 1000_0000000_i128, 2000_0000000_i128, 3000_0000000_i128] } -#[allow(dead_code)] -pub fn total_milestones() -> i128 { - total_milestone_amount() +pub fn create_default_contract( + env: &Env, + client: &EscrowClient, + client_addr: &Address, + freelancer_addr: &Address, +) -> u32 { + client.create_contract( + client_addr, + freelancer_addr, + &None, + &default_milestones(env), + &ReleaseAuthorization::ClientOnly, + ) } -#[allow(dead_code)] -pub fn generated_participants(env: &Env) -> (Address, Address) { - (Address::generate(env), Address::generate(env)) +// ─── Smoke tests (current contract API) ─────────────────────────────────────── + +#[test] +fn test_hello() { + let env = Env::default(); + let client = register_client(&env); + let result = client.hello(&symbol_short!("World")); + assert_eq!(result, symbol_short!("World")); } -/// Create a contract and return (client_addr, freelancer_addr, contract_id). -#[allow(dead_code)] -pub fn create_contract(env: &Env, client: &EscrowClient) -> (Address, Address, u32) { - let client_addr = Address::generate(env); - let freelancer_addr = Address::generate(env); - let milestones = default_milestones(env); +#[test] +fn test_create_contract() { + let env = Env::default(); + env.mock_all_auths(); + let client = register_client(&env); + let (client_addr, freelancer_addr, _) = generated_participants(&env); + let id = client.create_contract( &client_addr, &freelancer_addr, - &milestones, - &crate::types::DepositMode::ExactTotal, + &None, + &default_milestones(&env), + &ReleaseAuthorization::ClientOnly, ); - (client_addr, freelancer_addr, id) + assert_eq!(id, 1); } -/// Create and fully complete a contract (all milestones released). -#[allow(dead_code)] -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)); - (client_addr, freelancer_addr, id) +#[test] +fn test_deposit_funds() { + let env = Env::default(); + env.mock_all_auths(); + let client = register_client(&env); + let (client_addr, freelancer_addr, _) = generated_participants(&env); + let contract_id = create_default_contract(&env, &client, &client_addr, &freelancer_addr); + + assert!(client.deposit_funds(&contract_id, &client_addr, &1_000_0000000)); } -/// Assert that a `try_*` call returns the expected contract error. -/// -/// Soroban `try_*` methods return: -/// `Result, Result>` -/// A contract-level `panic_with_error` surfaces as `Err(Ok(soroban_sdk::Error))`. -pub fn assert_contract_error( - result: Result< - Result, - Result, - >, - expected: EscrowError, -) { - match result { - Err(Ok(e)) => { - let expected_err: soroban_sdk::Error = expected.into(); - assert_eq!(e, expected_err, "contract error code mismatch"); - } - _other => panic!( - "expected contract error {:?}, got unexpected result variant", - expected - ), - } +#[test] +fn test_release_milestone() { + let env = Env::default(); + env.mock_all_auths(); + let client = register_client(&env); + let (client_addr, freelancer_addr, _) = generated_participants(&env); + let contract_id = create_default_contract(&env, &client, &client_addr, &freelancer_addr); + + assert!(client.deposit_funds(&contract_id, &client_addr, &6_000_0000000_i128)); + assert!(client.approve_milestone_release(&contract_id, &client_addr, &0)); + assert!(client.release_milestone(&contract_id, &client_addr, &0)); } diff --git a/contracts/escrow/src/test/storage.rs b/contracts/escrow/src/test/storage.rs index 754179c..97a8afa 100644 --- a/contracts/escrow/src/test/storage.rs +++ b/contracts/escrow/src/test/storage.rs @@ -501,3 +501,87 @@ fn deposit_exceeding_total_fails() { EscrowError::ExactDepositRequired, ); } + +// ─── NextContractId overflow / collision ───────────────────────────────────── + +fn assert_error( + result: Result< + Result, + Result, + >, + expected: crate::Error, +) { + match result { + Err(Ok(e)) => { + let expected_err: soroban_sdk::Error = expected.into(); + assert_eq!(e, expected_err); + } + other => panic!("expected {:?}, got {:?}", expected, other), + } +} + +#[test] +fn next_contract_id_overflow_at_u32_max() { + let env = Env::default(); + env.mock_all_auths(); + let escrow = register_client(&env); + let (client_addr, freelancer_addr, _) = generated_participants(&env); + let milestones = default_milestones(&env); + + env.as_contract(&escrow.address, || { + env.storage() + .persistent() + .set(&DataKey::NextContractId, &u32::MAX); + }); + + let result = escrow.try_create_contract( + &client_addr, + &freelancer_addr, + &None, + &milestones, + &crate::ReleaseAuthorization::ClientOnly, + ); + assert_error(result, crate::Error::ContractIdOverflow); + + env.as_contract(&escrow.address, || { + let next: u32 = env + .storage() + .persistent() + .get(&DataKey::NextContractId) + .unwrap(); + assert_eq!(next, u32::MAX); + }); +} + +#[test] +fn next_contract_id_rejects_occupied_slot() { + let env = Env::default(); + env.mock_all_auths(); + let escrow = register_client(&env); + let (client_addr, freelancer_addr, _) = generated_participants(&env); + let milestones = default_milestones(&env); + + let existing_id = escrow.create_contract( + &client_addr, + &freelancer_addr, + &None, + &milestones, + &crate::ReleaseAuthorization::ClientOnly, + ); + + env.as_contract(&escrow.address, || { + env.storage() + .persistent() + .set(&DataKey::NextContractId, &existing_id); + }); + + let intruder = Address::generate(&env); + let result = escrow.try_create_contract( + &intruder, + &freelancer_addr, + &None, + &milestones, + &crate::ReleaseAuthorization::ClientOnly, + ); + assert_error(result, crate::Error::ContractIdCollision); +} diff --git a/contracts/escrow/src/ttl.rs b/contracts/escrow/src/ttl.rs index 3e9707c..bc6ae36 100644 --- a/contracts/escrow/src/ttl.rs +++ b/contracts/escrow/src/ttl.rs @@ -1,8 +1,7 @@ /// TTL (Time To Live) constants for temporary storage -/// +/// /// These constants define the lifetime of approval records in temporary storage. /// Expired approvals are automatically evicted and treated as absent. - /// Number of ledgers an approval remains valid before expiring /// At ~5 seconds per ledger, this is approximately 7 days pub const PENDING_APPROVAL_TTL_LEDGERS: u32 = 120_960; @@ -12,4 +11,5 @@ pub const PENDING_APPROVAL_TTL_LEDGERS: u32 = 120_960; pub const PENDING_APPROVAL_BUMP_THRESHOLD: u32 = 60_480; /// Minimum TTL for approval records (1 day worth of ledgers) +#[allow(dead_code)] pub const MIN_APPROVAL_TTL: u32 = 17_280; diff --git a/contracts/escrow/src/types.rs b/contracts/escrow/src/types.rs index dd6b380..29cdf4b 100644 --- a/contracts/escrow/src/types.rs +++ b/contracts/escrow/src/types.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracterror, contracttype, Address, String}; +use soroban_sdk::{contracterror, contracttype, Address, String, Vec}; #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -39,6 +39,10 @@ pub enum Error { FreelancerMismatch = 21, InvalidRating = 22, ReputationAlreadyIssued = 23, + EmptyMilestones = 24, + ContractIdOverflow = 25, + ContractIdCollision = 26, + InvalidMilestoneAmount = 27, } #[contracttype] @@ -93,6 +97,7 @@ pub struct GovernedParameters { // ─── Indexer summary types ──────────────────────────────────────────────────── +#[allow(dead_code)] pub const CONTRACT_SUMMARY_SCHEMA_VERSION: u32 = 1; #[contracttype] diff --git a/docs/escrow/state-persistence.md b/docs/escrow/state-persistence.md index e515901..e0d727b 100644 --- a/docs/escrow/state-persistence.md +++ b/docs/escrow/state-persistence.md @@ -41,7 +41,9 @@ Protocol fee implementation is tracked in * **Description:** Bookkeeping indices capturing un-issued tokens and completion certificates for network participants. * **Storage Lifespan:** `Persistent`. Preserved explicitly to guarantee deterministic chronological processing when users harvest pending system values. -- Contract ids are monotonically assigned from `NextContractId`. +- Contract ids are monotonically assigned from `NextContractId` (default `1`). + `create_contract` rejects an occupied `Contract(id)` slot (`ContractIdCollision`) + and refuses to advance the counter past `u32::MAX` (`ContractIdOverflow`). - Milestone amounts and participant addresses are immutable after creation. - `total_deposited`, `released_amount`, and `refunded_amount` are checked after balance-changing operations.