diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 2237bdfd..ec287205 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -99,7 +99,8 @@ pub enum DataKey { Config, // Replaces separate Admin + AgentJudge entries JobRegistry, Locked, - MultisigConfig(u64), + MultisigConfig(u64), // Per-job multisig configuration + UpgradeAdmin, } #[contracttype] @@ -118,6 +119,14 @@ pub struct AgentJudgeUpdatedEvent { pub updated_at: u64, } +#[contracttype] +#[derive(Clone)] +pub struct UpgradeAdminSetEvent { + pub old_admin: Option
, + pub new_admin: Address, + pub updated_at: u64, +} + #[contracterror] #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum EscrowError { @@ -136,8 +145,11 @@ pub enum EscrowError { MultisigRequired = 13, InsufficientSignatures = 14, AlreadySigned = 15, - ArithmeticOverflow = 16, - DisputeResolutionExpired = 17, + ArithmeticError = 16, + UpgradeAdminAlreadySet = 17, + UpgradeAdminNotSet = 18, + ArithmeticOverflow = 19, + DisputeResolutionExpired = 20, } #[contracttype] @@ -242,9 +254,6 @@ pub struct DisputeExpiredEvent { pub expired_at: u64, } -// Requirement [SC-SEC-063]: Smart Contract Gas Audit and Storage Compaction. -// Active reentrancy guard lock prevents simulated re-entrant attacks. -// Leverages custom compact error code reverts (Error #12) to abort execution. fn enter_reentrancy_guard(env: &Env) { if env.storage().instance().has(&DataKey::Locked) { panic_with_error!(env, EscrowError::ReentrancyDetected); @@ -252,7 +261,6 @@ fn enter_reentrancy_guard(env: &Env) { env.storage().instance().set(&DataKey::Locked, &()); } -// Releases the reentrancy lock after transfer has finished safely. fn exit_reentrancy_guard(env: &Env) { env.storage().instance().remove(&DataKey::Locked); } @@ -326,12 +334,18 @@ impl EscrowContract { Ok(()) } + pub fn version(_env: Env) -> u32 { + 1 + } + pub fn initialize(env: Env, admin: Address, agent_judge: Address) -> Result<(), EscrowError> { // Prevent double initialization if env.storage().instance().has(&DataKey::Config) { return Err(EscrowError::AlreadyInitialized); } + admin.require_auth(); + // Basic validation: admin and agent_judge must be distinct if admin == agent_judge { return Err(EscrowError::InvalidInput); @@ -424,7 +438,72 @@ impl EscrowContract { Ok(()) } - /// Upgrades the current contract WASM. Only callable by admin. + + + pub fn get_job_registry(env: Env) -> Option { + env.storage().instance().get(&DataKey::JobRegistry) + } + + /// One-time initialization of the upgrade admin. + pub fn init_upgrade_admin(env: Env, admin: Address) -> Result<(), EscrowError> { + if env.storage().instance().has(&DataKey::UpgradeAdmin) { + return Err(EscrowError::UpgradeAdminAlreadySet); + } + admin.require_auth(); + env.storage().instance().set(&DataKey::UpgradeAdmin, &admin); + + env.events().publish( + ("escrow", "UpgradeAdminSet"), + UpgradeAdminSetEvent { + old_admin: None, + new_admin: admin, + updated_at: env.ledger().timestamp(), + }, + ); + Ok(()) + } + + /// Rotate the upgrade admin. + pub fn set_upgrade_admin( + env: Env, + caller: Address, + new_admin: Address, + ) -> Result<(), EscrowError> { + caller.require_auth(); + let current_admin: Address = env + .storage() + .instance() + .get(&DataKey::UpgradeAdmin) + .ok_or(EscrowError::UpgradeAdminNotSet)?; + + if caller != current_admin { + return Err(EscrowError::Unauthorized); + } + + env.storage() + .instance() + .set(&DataKey::UpgradeAdmin, &new_admin); + + env.events().publish( + ("escrow", "UpgradeAdminSet"), + UpgradeAdminSetEvent { + old_admin: Some(current_admin), + new_admin, + updated_at: env.ledger().timestamp(), + }, + ); + Ok(()) + } + + /// Returns the current upgrade admin address. + pub fn get_upgrade_admin(env: Env) -> Result { + env.storage() + .instance() + .get(&DataKey::UpgradeAdmin) + .ok_or(EscrowError::UpgradeAdminNotSet) + } + + /// Upgrades the current contract WASM. Only callable by upgrade admin. pub fn upgrade( env: Env, caller: Address, @@ -433,13 +512,13 @@ impl EscrowContract { Self::bump_instance_ttl(&env); caller.require_auth(); - let config: ContractConfig = env + let upgrade_admin: Address = env .storage() .instance() - .get(&DataKey::Config) - .ok_or(EscrowError::NotInitialized)?; + .get(&DataKey::UpgradeAdmin) + .ok_or(EscrowError::UpgradeAdminNotSet)?; - if caller != config.admin { + if caller != upgrade_admin { return Err(EscrowError::UpgradeUnauthorized); } @@ -465,14 +544,21 @@ impl EscrowContract { client: Address, freelancer: Address, token_addr: Address, - ) { + ) -> Result<(), EscrowError> { client.require_auth(); let key = DataKey::Job(job_id); if env.storage().persistent().has(&key) { - panic!("job already exists"); + return Err(EscrowError::InvalidInput); } let now: u64 = env.ledger().timestamp(); - let expires_at = now + 30 * 24 * 60 * 60; + let expires_duration = 30u64 + .checked_mul(24) + .and_then(|h| h.checked_mul(60)) + .and_then(|m| m.checked_mul(60)) + .ok_or(EscrowError::ArithmeticError)?; + let expires_at = now + .checked_add(expires_duration) + .ok_or(EscrowError::ArithmeticError)?; let job = EscrowJob { client: client.clone(), @@ -497,16 +583,25 @@ impl EscrowContract { ); env.storage().persistent().set(&key, &job); Self::bump_job_ttl(&env, &key); + Ok(()) } /// Add a milestone to the job (setup phase only). - pub fn add_milestone(env: Env, job_id: u64, amount: i128) { + pub fn add_milestone(env: Env, job_id: u64, amount: i128) -> Result<(), EscrowError> { let key = DataKey::Job(job_id); - let mut job: EscrowJob = env.storage().persistent().get(&key).expect("job not found"); + let mut job: EscrowJob = env + .storage() + .persistent() + .get(&key) + .ok_or(EscrowError::JobNotFound)?; Self::bump_job_ttl(&env, &key); job.client.require_auth(); - assert!(job.status == EscrowStatus::Setup, "not in setup phase"); - assert!(amount > 0, "amount must be > 0"); + if job.status != EscrowStatus::Setup { + return Err(EscrowError::InvalidState); + } + if amount <= 0 { + return Err(EscrowError::InvalidInput); + } job.milestones.push_back(Milestone { amount, @@ -515,6 +610,7 @@ impl EscrowContract { log!(&env, "add_milestone: job {} amount {}", job_id, amount); env.storage().persistent().set(&key, &job); Self::bump_job_ttl(&env, &key); + Ok(()) } /// Client deposits total amount and transitions job to Funded. @@ -667,31 +763,36 @@ impl EscrowContract { /// Happy-path release for an explicit milestone index (0-based). /// Only the client may call this to release the funds for a specific milestone. - pub fn release_funds(env: Env, job_id: u64, caller: Address, milestone_index: u32) { + pub fn release_funds( + env: Env, + job_id: u64, + caller: Address, + milestone_index: u32, + ) -> Result<(), EscrowError> { caller.require_auth(); let key = DataKey::Job(job_id); - let mut job: EscrowJob = env.storage().persistent().get(&key).expect("job not found"); + let mut job: EscrowJob = env + .storage() + .persistent() + .get(&key) + .ok_or(EscrowError::JobNotFound)?; Self::bump_job_ttl(&env, &key); - assert!( - job.status == EscrowStatus::Funded || job.status == EscrowStatus::WorkInProgress, - "job not in releaseable state" - ); - assert!(caller == job.client, "only client can release"); - assert!( - milestone_index < job.milestones.len(), - "invalid milestone index" - ); + if !(job.status == EscrowStatus::Funded || job.status == EscrowStatus::WorkInProgress) { + return Err(EscrowError::InvalidState); + } + if caller != job.client { + return Err(EscrowError::Unauthorized); + } + if milestone_index >= job.milestones.len() { + return Err(EscrowError::InvalidInput); + } - let mut milestone = job - .milestones - .get(milestone_index) - .expect("invalid milestone"); - assert!( - milestone.status == MilestoneStatus::Pending, - "milestone already released" - ); + let mut milestone = job.milestones.get(milestone_index).unwrap(); + if milestone.status != MilestoneStatus::Pending { + return Err(EscrowError::InvalidState); + } milestone.status = MilestoneStatus::Released; job.milestones.set(milestone_index, milestone.clone()); @@ -709,9 +810,7 @@ impl EscrowContract { } else { EscrowStatus::WorkInProgress }; - job.status - .validate_transition(&next_status) - .expect("invalid state transition"); + job.status.validate_transition(&next_status)?; job.status = next_status; enter_reentrancy_guard(&env); @@ -733,6 +832,7 @@ impl EscrowContract { Self::bump_job_ttl(&env, &key); exit_reentrancy_guard(&env); + Ok(()) } /// Either party opens a dispute, locking remaining funds. @@ -780,34 +880,42 @@ impl EscrowContract { caller.require_auth(); let key = DataKey::Job(job_id); - let mut job: EscrowJob = env.storage().persistent().get(&key).expect("job not found"); + let mut job: EscrowJob = env + .storage() + .persistent() + .get(&key) + .ok_or(EscrowError::JobNotFound)?; Self::bump_job_ttl(&env, &key); // 2. Only client or freelancer may raise a dispute - assert!( - caller == job.client || caller == job.freelancer, - "unauthorized: only client or freelancer can raise a dispute" - ); + if !(caller == job.client || caller == job.freelancer) { + return Err(EscrowError::Unauthorized); + } // 3. Job must still be active - assert!( - job.status == EscrowStatus::Funded || job.status == EscrowStatus::WorkInProgress, - "dispute cannot be raised: job is not in active state" - ); + if !(job.status == EscrowStatus::Funded || job.status == EscrowStatus::WorkInProgress) { + return Err(EscrowError::InvalidState); + } // 4. Prevent dispute if all funds are already released - assert!( - job.released_amount < job.total_amount, - "dispute cannot be raised: all funds already released" - ); + if job.released_amount >= job.total_amount { + return Err(EscrowError::InvalidState); + } // 5. Prevent dispute if deadline has drastically expired (7-day grace period) let now: u64 = env.ledger().timestamp(); - let grace_period: u64 = 7 * 24 * 60 * 60; - assert!( - now <= job.expires_at + grace_period, - "dispute cannot be raised: deadline has drastically expired" - ); + let grace_period: u64 = 7u64 + .checked_mul(24) + .and_then(|h| h.checked_mul(60)) + .and_then(|m| m.checked_mul(60)) + .ok_or(EscrowError::ArithmeticError)?; + let expiration_threshold = job + .expires_at + .checked_add(grace_period) + .ok_or(EscrowError::ArithmeticError)?; + if now > expiration_threshold { + return Err(EscrowError::InvalidState); + } // 6. Lock funds by transitioning to Disputed — blocks release_funds & release_milestone let next_status = EscrowStatus::Disputed; @@ -845,7 +953,12 @@ impl EscrowContract { /// Agent Judge resolves dispute -- splits funds by explicit amounts. /// `payee_amount`: Amount to pay to the freelancer (payee). /// `payer_amount`: Amount to return to the client (payer). - pub fn resolve_dispute(env: Env, job_id: u64, payee_amount: i128, payer_amount: i128) { + pub fn resolve_dispute( + env: Env, + job_id: u64, + payee_amount: i128, + payer_amount: i128, + ) -> Result<(), EscrowError> { Self::bump_instance_ttl(&env); let config: ContractConfig = env .storage() @@ -854,13 +967,20 @@ impl EscrowContract { .expect("not initialized"); config.agent_judge.require_auth(); - assert!(payee_amount >= 0, "payee_amount must be >= 0"); - assert!(payer_amount >= 0, "payer_amount must be >= 0"); + if payee_amount < 0 || payer_amount < 0 { + return Err(EscrowError::InvalidInput); + } let key = DataKey::Job(job_id); - let mut job: EscrowJob = env.storage().persistent().get(&key).expect("job not found"); + let mut job: EscrowJob = env + .storage() + .persistent() + .get(&key) + .ok_or(EscrowError::JobNotFound)?; Self::bump_job_ttl(&env, &key); - assert!(job.status == EscrowStatus::Disputed, "job not disputed"); + if job.status != EscrowStatus::Disputed { + return Err(EscrowError::InvalidState); + } if job.dispute_deadline > 0 && env.ledger().timestamp() > job.dispute_deadline { panic_with_error!(&env, EscrowError::DisputeResolutionExpired); @@ -905,6 +1025,7 @@ impl EscrowContract { Self::bump_job_ttl(&env, &key); exit_reentrancy_guard(&env); + Ok(()) } /// Client recoups funds if freelancer never responded or deadline has passed. @@ -1013,11 +1134,23 @@ impl EscrowContract { Ok(()) } - pub fn get_job(env: Env, job_id: u64) -> EscrowJob { + pub fn get_job(env: Env, job_id: u64) -> Result