diff --git a/contracts/.gitignore b/contracts/.gitignore index 0c2ae5d..7dfae98 100644 --- a/contracts/.gitignore +++ b/contracts/.gitignore @@ -1,5 +1,6 @@ target/ Cargo.lock +test_snapshots/ # Rust build outputs target/ target_local/ diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index a849c1f..224a644 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -13,11 +13,13 @@ soroban-sdk = "21.0.0" [dev-dependencies] soroban-sdk = { version = "21.0.0", features = ["testutils"] } -honggfuzz = "0.5.55" proptest = "1.4.0" quickcheck = "1.0.3" arbitrary = "1.3.2" +[target.'cfg(not(windows))'.dev-dependencies] +honggfuzz = "0.5.55" + [profile.release] opt-level = "z" overflow-checks = true diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 5694eff..d746a63 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -3,7 +3,9 @@ #[cfg(test)] extern crate std; -use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, BytesN, Env, String, Vec}; +use soroban_sdk::{ + contract, contractimpl, contracttype, symbol_short, Address, BytesN, Env, String, Vec, +}; #[contracttype] #[derive(Clone, Debug, PartialEq)] @@ -40,6 +42,9 @@ pub enum DataKey { ProjectCount, Admin, Metadata(String), + ReentrancyLock, + EmergencyPaused, + ReentrancyIncidentCount, } /// Input parameters for batch project creation. @@ -52,23 +57,138 @@ pub struct ProjectInput { pub github_repo: String, } +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct SecurityStatus { + pub paused: bool, + pub locked: bool, + pub reentrancy_incidents: u32, +} + #[contract] pub struct AgenticPayContract; +struct ReentrancyScope<'a> { + env: &'a Env, +} + +impl Drop for ReentrancyScope<'_> { + fn drop(&mut self) { + AgenticPayContract::exit_reentrancy_guard(self.env); + } +} + +impl AgenticPayContract { + fn get_admin(env: &Env) -> Address { + env.storage() + .instance() + .get(&DataKey::Admin) + .expect("Not initialized") + } + + fn require_admin(env: &Env, admin: &Address) { + let stored_admin = Self::get_admin(env); + assert!(*admin == stored_admin, "Only admin"); + } + + fn is_reentrancy_locked(env: &Env) -> bool { + env.storage() + .instance() + .get(&DataKey::ReentrancyLock) + .unwrap_or(false) + } + + fn is_paused(env: &Env) -> bool { + env.storage() + .instance() + .get(&DataKey::EmergencyPaused) + .unwrap_or(false) + } + + fn reentrancy_incidents(env: &Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::ReentrancyIncidentCount) + .unwrap_or(0) + } + + fn record_reentrancy_attempt(env: &Env) { + let incidents = Self::reentrancy_incidents(env).saturating_add(1); + env.storage() + .instance() + .set(&DataKey::ReentrancyIncidentCount, &incidents); + env.storage() + .instance() + .set(&DataKey::EmergencyPaused, &true); + env.events().publish( + (symbol_short!("security"), symbol_short!("reentrant")), + incidents, + ); + } + + fn enter_reentrancy_guard(env: &Env) -> bool { + if Self::is_reentrancy_locked(env) { + Self::record_reentrancy_attempt(env); + return false; + } + + env.storage() + .instance() + .set(&DataKey::ReentrancyLock, &true); + true + } + + fn exit_reentrancy_guard(env: &Env) { + env.storage() + .instance() + .set(&DataKey::ReentrancyLock, &false); + } + + fn with_reentrancy_lock R, G: FnOnce() -> R>( + env: &Env, + on_reentrant: G, + action: F, + ) -> R { + if !Self::enter_reentrancy_guard(env) { + return on_reentrant(); + } + + let _scope = ReentrancyScope { env }; + action() + } + + fn with_reentrancy_guard R, G: FnOnce() -> R>( + env: &Env, + on_reentrant: G, + action: F, + ) -> R { + Self::with_reentrancy_lock(env, on_reentrant, || { + assert!(!Self::is_paused(env), "Emergency circuit breaker active"); + action() + }) + } +} + #[contractimpl] impl AgenticPayContract { /// Initialize the contract with an admin address pub fn initialize(env: Env, admin: Address) { admin.require_auth(); + assert!( + !env.storage().instance().has(&DataKey::Admin), + "Already initialized" + ); env.storage().instance().set(&DataKey::Admin, &admin); env.storage().instance().set(&DataKey::ProjectCount, &0u64); - } - - fn get_admin(env: &Env) -> Address { env.storage() .instance() - .get(&DataKey::Admin) - .expect("Not initialized") + .set(&DataKey::ReentrancyLock, &false); + env.storage() + .instance() + .set(&DataKey::EmergencyPaused, &false); + env.storage() + .instance() + .set(&DataKey::ReentrancyIncidentCount, &0u32); } /// Create a new project with escrow @@ -86,37 +206,47 @@ impl AgenticPayContract { ) -> u64 { client.require_auth(); - let mut count: u64 = env - .storage() - .instance() - .get(&DataKey::ProjectCount) - .unwrap_or(0); - count += 1; - - let project = Project { - id: count, - client: client.clone(), - freelancer: freelancer.clone(), - amount, - deposited: 0, - status: ProjectStatus::Created, - github_repo, - description, - created_at: env.ledger().timestamp(), - deadline, - }; - - env.storage() - .persistent() - .set(&DataKey::Project(count), &project); - env.storage().instance().set(&DataKey::ProjectCount, &count); - - env.events().publish( - (symbol_short!("project"), symbol_short!("created")), - (count, client, freelancer, amount), - ); - - count + Self::with_reentrancy_guard( + &env, + || 0, + || { + assert!(amount > 0, "Amount must be positive"); + let now = env.ledger().timestamp(); + assert!(deadline == 0 || deadline > now, "Deadline must be future"); + + let mut count: u64 = env + .storage() + .instance() + .get(&DataKey::ProjectCount) + .unwrap_or(0); + count += 1; + + let project = Project { + id: count, + client: client.clone(), + freelancer: freelancer.clone(), + amount, + deposited: 0, + status: ProjectStatus::Created, + github_repo, + description, + created_at: now, + deadline, + }; + + env.storage() + .persistent() + .set(&DataKey::Project(count), &project); + env.storage().instance().set(&DataKey::ProjectCount, &count); + + env.events().publish( + (symbol_short!("project"), symbol_short!("created")), + (count, client, freelancer, amount), + ); + + count + }, + ) } /// Create multiple projects in a single call. @@ -138,78 +268,102 @@ impl AgenticPayContract { ) -> Vec { client.require_auth(); - let mut count: u64 = env - .storage() - .instance() - .get(&DataKey::ProjectCount) - .unwrap_or(0); - - let timestamp = env.ledger().timestamp(); - let mut ids = Vec::new(&env); - - for i in 0..projects.len() { - let input = projects.get(i).expect("Invalid project input"); - count += 1; - - let project = Project { - id: count, - client: client.clone(), - freelancer: input.freelancer.clone(), - amount: input.amount, - deposited: 0, - status: ProjectStatus::Created, - github_repo: input.github_repo, - description: input.description, - created_at: timestamp, - deadline: 0, - }; - - env.storage() - .persistent() - .set(&DataKey::Project(count), &project); - - env.events().publish( - (symbol_short!("project"), symbol_short!("created")), - (count, client.clone(), input.freelancer, input.amount), - ); - - ids.push_back(count); - } - - // Single counter update after all projects are created - env.storage().instance().set(&DataKey::ProjectCount, &count); - - ids + Self::with_reentrancy_guard( + &env, + || Vec::new(&env), + || { + let mut count: u64 = env + .storage() + .instance() + .get(&DataKey::ProjectCount) + .unwrap_or(0); + + let timestamp = env.ledger().timestamp(); + let mut ids = Vec::new(&env); + + for i in 0..projects.len() { + let input = projects.get(i).expect("Invalid project input"); + assert!(input.amount > 0, "Amount must be positive"); + count += 1; + + let project = Project { + id: count, + client: client.clone(), + freelancer: input.freelancer.clone(), + amount: input.amount, + deposited: 0, + status: ProjectStatus::Created, + github_repo: input.github_repo, + description: input.description, + created_at: timestamp, + deadline: 0, + }; + + env.storage() + .persistent() + .set(&DataKey::Project(count), &project); + + env.events().publish( + (symbol_short!("project"), symbol_short!("created")), + (count, client.clone(), input.freelancer, input.amount), + ); + + ids.push_back(count); + } + + // Single counter update after all projects are created + env.storage().instance().set(&DataKey::ProjectCount, &count); + + ids + }, + ) } /// Fund a project escrow with XLM pub fn fund_project(env: Env, project_id: u64, client: Address, amount: i128) { client.require_auth(); - let mut project: Project = env - .storage() - .persistent() - .get(&DataKey::Project(project_id)) - .expect("Project not found"); - - assert!(project.client == client, "Only client can fund"); - assert!( - project.status == ProjectStatus::Created, - "Project must be in Created status" - ); - - project.deposited += amount; - if project.deposited >= project.amount { - project.status = ProjectStatus::Funded; - } - - env.storage() - .persistent() - .set(&DataKey::Project(project_id), &project); - - env.events().publish( - (symbol_short!("project"), symbol_short!("funded")), - (project_id, amount), + Self::with_reentrancy_guard( + &env, + || (), + || { + assert!(amount > 0, "Amount must be positive"); + + let mut project: Project = env + .storage() + .persistent() + .get(&DataKey::Project(project_id)) + .expect("Project not found"); + + assert!(project.client == client, "Only client can fund"); + assert!( + project.status == ProjectStatus::Created, + "Project must be in Created status" + ); + + let new_deposit = project + .deposited + .checked_add(amount) + .expect("Deposit overflow"); + assert!( + new_deposit <= project.amount, + "Deposit exceeds project amount" + ); + + project.deposited = new_deposit; + if project.deposited == project.amount { + project.status = ProjectStatus::Funded; + } + + env.storage() + .persistent() + .set(&DataKey::Project(project_id), &project); + + env.events().publish( + (symbol_short!("project"), symbol_short!("funded")), + (project_id, amount), + ); + }, ); } @@ -217,31 +371,38 @@ impl AgenticPayContract { pub fn submit_work(env: Env, project_id: u64, freelancer: Address, github_repo: String) { freelancer.require_auth(); - let mut project: Project = env - .storage() - .persistent() - .get(&DataKey::Project(project_id)) - .expect("Project not found"); - - assert!( - project.freelancer == freelancer, - "Only assigned freelancer can submit" - ); - assert!( - project.status == ProjectStatus::Funded || project.status == ProjectStatus::InProgress, - "Project must be funded or in progress" - ); - - project.github_repo = github_repo.clone(); - project.status = ProjectStatus::WorkSubmitted; - - env.storage() - .persistent() - .set(&DataKey::Project(project_id), &project); - - env.events().publish( - (symbol_short!("project"), symbol_short!("work_sub")), - (project_id, github_repo), + Self::with_reentrancy_guard( + &env, + || (), + || { + let mut project: Project = env + .storage() + .persistent() + .get(&DataKey::Project(project_id)) + .expect("Project not found"); + + assert!( + project.freelancer == freelancer, + "Only assigned freelancer can submit" + ); + assert!( + project.status == ProjectStatus::Funded + || project.status == ProjectStatus::InProgress, + "Project must be funded or in progress" + ); + + project.github_repo = github_repo.clone(); + project.status = ProjectStatus::WorkSubmitted; + + env.storage() + .persistent() + .set(&DataKey::Project(project_id), &project); + + env.events().publish( + (symbol_short!("project"), symbol_short!("work_sub")), + (project_id, github_repo), + ); + }, ); } @@ -249,32 +410,37 @@ impl AgenticPayContract { pub fn approve_work(env: Env, project_id: u64, client: Address) { client.require_auth(); - let mut project: Project = env - .storage() - .persistent() - .get(&DataKey::Project(project_id)) - .expect("Project not found"); - - assert!(project.client == client, "Only client can approve"); - assert!( - project.status == ProjectStatus::WorkSubmitted - || project.status == ProjectStatus::Verified, - "Work must be submitted or verified" - ); - - // TODO: Transfer deposited funds to freelancer via Stellar token transfer - - let amount_released = project.deposited; - project.status = ProjectStatus::Completed; - project.deposited = 0; - - env.storage() - .persistent() - .set(&DataKey::Project(project_id), &project); - - env.events().publish( - (symbol_short!("project"), symbol_short!("payment")), - (project_id, amount_released), + Self::with_reentrancy_guard( + &env, + || (), + || { + let mut project: Project = env + .storage() + .persistent() + .get(&DataKey::Project(project_id)) + .expect("Project not found"); + + assert!(project.client == client, "Only client can approve"); + assert!( + project.status == ProjectStatus::WorkSubmitted + || project.status == ProjectStatus::Verified, + "Work must be submitted or verified" + ); + + // Effects are committed before any future token interaction is added. + let amount_released = project.deposited; + project.status = ProjectStatus::Completed; + project.deposited = 0; + + env.storage() + .persistent() + .set(&DataKey::Project(project_id), &project); + + env.events().publish( + (symbol_short!("project"), symbol_short!("payment")), + (project_id, amount_released), + ); + }, ); } @@ -282,26 +448,37 @@ impl AgenticPayContract { pub fn raise_dispute(env: Env, project_id: u64, caller: Address) { caller.require_auth(); - let mut project: Project = env - .storage() - .persistent() - .get(&DataKey::Project(project_id)) - .expect("Project not found"); - - assert!( - caller == project.client || caller == project.freelancer, - "Only client or freelancer can dispute" - ); - - project.status = ProjectStatus::Disputed; - - env.storage() - .persistent() - .set(&DataKey::Project(project_id), &project); - - env.events().publish( - (symbol_short!("project"), symbol_short!("disputed")), - (project_id, caller), + Self::with_reentrancy_guard( + &env, + || (), + || { + let mut project: Project = env + .storage() + .persistent() + .get(&DataKey::Project(project_id)) + .expect("Project not found"); + + assert!( + caller == project.client || caller == project.freelancer, + "Only client or freelancer can dispute" + ); + assert!( + project.status != ProjectStatus::Completed + && project.status != ProjectStatus::Cancelled, + "Terminal project cannot be disputed" + ); + + project.status = ProjectStatus::Disputed; + + env.storage() + .persistent() + .set(&DataKey::Project(project_id), &project); + + env.events().publish( + (symbol_short!("project"), symbol_short!("disputed")), + (project_id, caller), + ); + }, ); } @@ -309,36 +486,35 @@ impl AgenticPayContract { pub fn resolve_dispute(env: Env, project_id: u64, admin: Address, release_to_freelancer: bool) { admin.require_auth(); - let stored_admin: Address = env - .storage() - .instance() - .get(&DataKey::Admin) - .expect("Not initialized"); - assert!(admin == stored_admin, "Only admin can resolve disputes"); - - let mut project: Project = env - .storage() - .persistent() - .get(&DataKey::Project(project_id)) - .expect("Project not found"); - - assert!( - project.status == ProjectStatus::Disputed, - "Project must be disputed" + Self::with_reentrancy_guard( + &env, + || (), + || { + Self::require_admin(&env, &admin); + + let mut project: Project = env + .storage() + .persistent() + .get(&DataKey::Project(project_id)) + .expect("Project not found"); + + assert!( + project.status == ProjectStatus::Disputed, + "Project must be disputed" + ); + + if release_to_freelancer { + project.status = ProjectStatus::Completed; + } else { + project.status = ProjectStatus::Cancelled; + } + + project.deposited = 0; + env.storage() + .persistent() + .set(&DataKey::Project(project_id), &project); + }, ); - - if release_to_freelancer { - // TODO: Transfer funds to freelancer - project.status = ProjectStatus::Completed; - } else { - // TODO: Refund funds to client - project.status = ProjectStatus::Cancelled; - } - - project.deposited = 0; - env.storage() - .persistent() - .set(&DataKey::Project(project_id), &project); } /// Check if a project's deadline has expired and auto-cancel if so. @@ -351,44 +527,49 @@ impl AgenticPayContract { /// /// Returns `true` if the project was auto-cancelled, `false` otherwise. pub fn check_deadline(env: Env, project_id: u64) -> bool { - let mut project: Project = env - .storage() - .persistent() - .get(&DataKey::Project(project_id)) - .expect("Project not found"); - - // No deadline set or already in a terminal state - if project.deadline == 0 { - return false; - } - if project.status == ProjectStatus::Completed - || project.status == ProjectStatus::Cancelled - || project.status == ProjectStatus::Disputed - { - return false; - } - - let now = env.ledger().timestamp(); - if now < project.deadline { - return false; - } - - // Deadline expired — auto-cancel and refund escrow - // TODO: Transfer deposited funds back to client via Stellar token transfer - let refund_amount = project.deposited; - project.deposited = 0; - project.status = ProjectStatus::Cancelled; - - env.storage() - .persistent() - .set(&DataKey::Project(project_id), &project); - - env.events().publish( - (symbol_short!("project"), symbol_short!("expired")), - (project_id, refund_amount), - ); - - true + Self::with_reentrancy_guard( + &env, + || false, + || { + let mut project: Project = env + .storage() + .persistent() + .get(&DataKey::Project(project_id)) + .expect("Project not found"); + + // No deadline set or already in a terminal state + if project.deadline == 0 { + return false; + } + if project.status == ProjectStatus::Completed + || project.status == ProjectStatus::Cancelled + || project.status == ProjectStatus::Disputed + { + return false; + } + + let now = env.ledger().timestamp(); + if now < project.deadline { + return false; + } + + // Deadline expired - effects are recorded before any future refund call. + let refund_amount = project.deposited; + project.deposited = 0; + project.status = ProjectStatus::Cancelled; + + env.storage() + .persistent() + .set(&DataKey::Project(project_id), &project); + + env.events().publish( + (symbol_short!("project"), symbol_short!("expired")), + (project_id, refund_amount), + ); + + true + }, + ) } /// Get project details @@ -410,16 +591,20 @@ impl AgenticPayContract { /// Store metadata key-value pair (admin only) pub fn set_metadata(env: Env, admin: Address, key: String, value: String) { admin.require_auth(); - let stored_admin = Self::get_admin(&env); - assert!(admin == stored_admin, "Only admin can set metadata"); - env.storage() - .persistent() - .set(&DataKey::Metadata(key.clone()), &value); + Self::with_reentrancy_guard( + &env, + || (), + || { + Self::require_admin(&env, &admin); - env.events().publish( - (symbol_short!("meta"), symbol_short!("set")), - (key, value), + env.storage() + .persistent() + .set(&DataKey::Metadata(key.clone()), &value); + + env.events() + .publish((symbol_short!("meta"), symbol_short!("set")), (key, value)); + }, ); } @@ -431,16 +616,52 @@ impl AgenticPayContract { /// Remove metadata entry (admin only) pub fn remove_metadata(env: Env, admin: Address, key: String) { admin.require_auth(); - let stored_admin = Self::get_admin(&env); - assert!(admin == stored_admin, "Only admin can remove metadata"); - env.storage().persistent().remove(&DataKey::Metadata(key.clone())); + Self::with_reentrancy_guard( + &env, + || (), + || { + Self::require_admin(&env, &admin); - env.events().publish( - (symbol_short!("meta"), symbol_short!("del")), - key, + env.storage() + .persistent() + .remove(&DataKey::Metadata(key.clone())); + + env.events() + .publish((symbol_short!("meta"), symbol_short!("del")), key); + }, ); } + + /// Pause or resume guarded state mutations. Admin-only. + pub fn set_emergency_pause(env: Env, admin: Address, paused: bool) { + admin.require_auth(); + + Self::with_reentrancy_lock( + &env, + || (), + || { + Self::require_admin(&env, &admin); + env.storage() + .instance() + .set(&DataKey::EmergencyPaused, &paused); + env.events().publish( + (symbol_short!("security"), symbol_short!("paused")), + (admin, paused), + ); + }, + ); + } + + /// Return current guard and circuit-breaker state. + pub fn get_security_status(env: Env) -> SecurityStatus { + SecurityStatus { + paused: Self::is_paused(&env), + locked: Self::is_reentrancy_locked(&env), + reentrancy_incidents: Self::reentrancy_incidents(&env), + } + } + /// Upgrade the contract WASM code. Admin-only. /// /// Uses Soroban's built-in upgrade mechanism which replaces the contract @@ -453,14 +674,15 @@ impl AgenticPayContract { pub fn upgrade(env: Env, admin: Address, new_wasm_hash: BytesN<32>) { admin.require_auth(); - let stored_admin: Address = env - .storage() - .instance() - .get(&DataKey::Admin) - .expect("Not initialized"); - assert!(admin == stored_admin, "Only admin can upgrade"); - - env.deployer().update_current_contract_wasm(new_wasm_hash); + Self::with_reentrancy_lock( + &env, + || (), + || { + let stored_admin = Self::get_admin(&env); + assert!(admin == stored_admin, "Only admin can upgrade"); + env.deployer().update_current_contract_wasm(new_wasm_hash); + }, + ); } /// Return the contract version for tracking upgrades. @@ -469,6 +691,9 @@ impl AgenticPayContract { } } +#[cfg(test)] +mod security_properties; + #[cfg(test)] mod test { use super::*; diff --git a/contracts/src/security_properties.rs b/contracts/src/security_properties.rs index 9544173..8b06b49 100644 --- a/contracts/src/security_properties.rs +++ b/contracts/src/security_properties.rs @@ -1,194 +1,145 @@ -// Property-based tests for AgenticPay smart contracts -// Uses proptest for randomized testing with various inputs - -#[cfg(test)] -mod contract_properties { - use proptest::prelude::*; - use soroban_sdk::{testutils::mock_all, Env}; - - prop_compose! { - // Generate valid contract initialization parameters - fn valid_payment_params()( - amount in 0u128..=10_000_000_000_000i128, - seller_fee_bps in 0u16..=10_000u16, - protocol_fee_bps in 0u16..=10_000u16, - ) -> (u128, u16, u16) { - (amount, seller_fee_bps, protocol_fee_bps) - } - } - - prop_compose! { - // Generate valid escrow scenarios - fn valid_escrow_scenario()( - project_id in "0-9a-f{1,32}", - client_address in "[A-Z0-9]{56}", - freelancer_address in "[A-Z0-9]{56}", - amount in 1u128..=1_000_000_000_000u128, - ) -> (String, String, String, u128) { - (project_id, client_address, freelancer_address, amount) - } - } - - // Property: Money conservation - // Total output should never exceed total input - #[test] - fn prop_payments_conserve_balance( - (amount, _, _) in valid_payment_params(), - ) { - let env = Env::default(); - // Test that sum of outputs <= sum of inputs - prop_assert!(true); - } - - // Property: Fee constraints - // Fees should never exceed 100% (10,000 basis points) - #[test] - fn prop_fees_within_bounds( - _, (seller_fee_bps, protocol_fee_bps) in valid_payment_params().prop_map(|(_, b, c)| ((), (b, c))) - ) { - prop_assert!(seller_fee_bps <= 10_000); - prop_assert!(protocol_fee_bps <= 10_000); - prop_assert!(seller_fee_bps.saturating_add(protocol_fee_bps) <= 10_000); - } - - // Property: Idempotency - // Running the same operation twice should produce the same result - #[test] - fn prop_operations_are_idempotent( - scenario in valid_escrow_scenario() - ) { - let (project_id, client, freelancer, amount) = scenario; - // First operation - let result1 = format!("{}-{}-{}-{}", project_id, client, freelancer, amount); - // Second operation (same inputs) - let result2 = format!("{}-{}-{}-{}", project_id, client, freelancer, amount); - - prop_assert_eq!(result1, result2); - } - - // Property: State transitions - // Contract state should only transition to valid states - #[test] - fn prop_valid_state_transitions( - scenario in valid_escrow_scenario() - ) { - let (_, _, _, amount) = scenario; - // Valid states: Created -> Funded -> Released - // Invalid: Direct transition from Created -> Released - prop_assert!(amount > 0); - } +use crate::{AgenticPayContract, AgenticPayContractClient, DataKey, ProjectStatus, SecurityStatus}; +use proptest::prelude::*; +use soroban_sdk::testutils::Address as _; +use soroban_sdk::{Address, Env, String}; + +fn setup_contract(env: &Env) -> (Address, Address, Address, AgenticPayContractClient<'_>) { + env.mock_all_auths(); + let contract_id = env.register_contract(None, AgenticPayContract); + let contract = AgenticPayContractClient::new(env, &contract_id); + let admin = Address::generate(env); + let client = Address::generate(env); + let freelancer = Address::generate(env); + contract.initialize(&admin); + (admin, client, freelancer, contract) +} - // Property: No negative values - // Amounts and fees should always be non-negative - #[test] - fn prop_no_negative_amounts( - (amount, seller_fee, protocol_fee) in valid_payment_params() - ) { - prop_assert!(amount >= 0u128); - prop_assert!(seller_fee >= 0u16); - prop_assert!(protocol_fee >= 0u16); - } +fn create_funded_project( + env: &Env, + contract: &AgenticPayContractClient<'_>, + client: &Address, + freelancer: &Address, + amount: i128, +) -> u64 { + let project_id = contract.create_project( + client, + freelancer, + &amount, + &String::from_str(env, "Security test"), + &String::from_str(env, "https://github.com/security/test"), + &(env.ledger().timestamp() + 1000), + ); + contract.fund_project(&project_id, client, &amount); + project_id +} - // Property: Timestamp ordering - // Timestamps should represent causally correct ordering - #[test] - fn prop_causality_preserved( - (t1, t2, t3) in (0u64..i64::MAX as u64, 0u64..i64::MAX as u64, 0u64..i64::MAX as u64) - .prop_filter("t1 < t2 < t3", |(a, b, c)| a < b && b < c) - ) { - prop_assert!(t1 < t2); - prop_assert!(t2 < t3); - prop_assert!(t1 < t3); - } +#[test] +fn emergency_pause_blocks_guarded_mutations() { + let env = Env::default(); + let (admin, client, freelancer, contract) = setup_contract(&env); + + contract.set_emergency_pause(&admin, &true); + let status: SecurityStatus = contract.get_security_status(); + assert!(status.paused); + assert!(!status.locked); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + contract.create_project( + &client, + &freelancer, + &10, + &String::from_str(&env, "Paused mutation"), + &String::from_str(&env, "https://github.com/security/paused"), + &(env.ledger().timestamp() + 1000), + ); + })); + + assert!(result.is_err()); + assert_eq!(contract.get_project_count(), 0); + let status = contract.get_security_status(); + assert!(status.paused); + assert!(!status.locked); + assert_eq!(status.reentrancy_incidents, 0); +} - // Property: Atomicity - // Concurrent operations should be atomic - #[test] - fn prop_operations_are_atomic( - scenario in valid_escrow_scenario() - ) { - let (project_id, _, _, _) = scenario; - // Each operation either fully succeeds or fully fails - prop_assert!(!project_id.is_empty()); - } +#[test] +fn emergency_pause_can_be_released_by_admin() { + let env = Env::default(); + let (admin, client, freelancer, contract) = setup_contract(&env); + + contract.set_emergency_pause(&admin, &true); + contract.set_emergency_pause(&admin, &false); + + let project_id = contract.create_project( + &client, + &freelancer, + &10, + &String::from_str(&env, "Unpaused mutation"), + &String::from_str(&env, "https://github.com/security/unpaused"), + &(env.ledger().timestamp() + 1000), + ); + + assert_eq!(project_id, 1); + assert!(!contract.get_security_status().paused); } -#[cfg(test)] -mod security_properties { - use proptest::prelude::*; +#[test] +fn reentrancy_lock_blocks_guarded_mutation() { + let env = Env::default(); + let (_admin, client, freelancer, contract) = setup_contract(&env); + let contract_id = contract.address.clone(); + + env.as_contract(&contract_id, || { + env.storage() + .instance() + .set(&DataKey::ReentrancyLock, &true); + }); + + let project_id = contract.create_project( + &client, + &freelancer, + &10, + &String::from_str(&env, "Nested mutation"), + &String::from_str(&env, "https://github.com/security/reentrant"), + &(env.ledger().timestamp() + 1000), + ); + + assert_eq!(project_id, 0); + assert_eq!(contract.get_project_count(), 0); + let status = contract.get_security_status(); + assert!(status.paused); + assert!(status.locked); + assert_eq!(status.reentrancy_incidents, 1); +} - // Property: Access control enforcement - // Unauthorized users should not be able to call protected functions - #[test] - fn prop_access_control_enforced( - caller in "[A-Z0-9]{56}", - owner in "[A-Z0-9]{56}", - ) { - if caller != owner { - // Non-owner should not be able to execute owner functions - prop_assert!(true); - } - } +#[test] +fn successful_mutation_releases_reentrancy_lock() { + let env = Env::default(); + let (_admin, client, freelancer, contract) = setup_contract(&env); - // Property: Reentrancy prevention - // No recursive calls should modify state unsafely - #[test] - fn prop_reentrancy_safe( - call_depth in 0u32..=10u32, - ) { - // Each call should preserve invariants - prop_assert!(call_depth <= 10); - } + let project_id = create_funded_project(&env, &contract, &client, &freelancer, 25); - // Property: Safe arithmetic - // All arithmetic operations should be overflow-safe - #[test] - fn prop_arithmetic_overflow_safe( - a in 0u128..=u128::MAX, - b in 0u128..=u128::MAX, - ) { - if let Some(result) = a.checked_add(b) { - prop_assert!(result >= a && result >= b); - } - } + let status = contract.get_security_status(); + assert!(!status.locked); + assert_eq!(status.reentrancy_incidents, 0); - // Property: Input validation - // Invalid inputs should be rejected or handled safely - #[test] - fn prop_input_validation( - input in ".*", - ) { - // All inputs should be safely handled without panicking - let _ = input.len(); - prop_assert!(true); - } + let project = contract.get_project(&project_id); + assert_eq!(project.status, ProjectStatus::Funded); + assert_eq!(project.deposited, 25); } -// Invariant-based testing for contract state -#[cfg(test)] -mod invariants { - use proptest::prelude::*; - - // Invariant: Total supply conservation - // Sum of all balances should equal total supply +proptest! { #[test] - fn invariant_total_supply_conserved() { - // Initial condition: total_supply == sum of all balances - // This should hold after any operation - prop_assert!(true); - } + fn funded_projects_preserve_escrow_accounting(amount in 1i128..1_000_000i128) { + let env = Env::default(); + let (_admin, client, freelancer, contract) = setup_contract(&env); - // Invariant: Escrow state consistency - // If escrow is funded, it must have a client, freelancer, and amount - #[test] - fn invariant_escrow_consistency() { - prop_assert!(true); - } + let project_id = create_funded_project(&env, &contract, &client, &freelancer, amount); + let project = contract.get_project(&project_id); - // Invariant: Payment ordering - // Older transactions should settle before newer ones (by block number) - #[test] - fn invariant_payment_ordering() { - prop_assert!(true); + prop_assert_eq!(project.amount, amount); + prop_assert_eq!(project.deposited, amount); + prop_assert_eq!(project.status, ProjectStatus::Funded); + prop_assert!(!contract.get_security_status().locked); } }