From 2bc7570911e2c8523f57240418dea852c4fce46c Mon Sep 17 00:00:00 2001 From: Obiajulu-gif Date: Thu, 23 Apr 2026 15:42:57 -0700 Subject: [PATCH 1/4] feat(contracts): add EscrowStatus state transition validation and logging --- contracts/escrow/src/lib.rs | 74 ++++++++++++++++++---- docs/contracts/escrow_state_transitions.md | 36 +++++++++++ 2 files changed, 98 insertions(+), 12 deletions(-) create mode 100644 docs/contracts/escrow_state_transitions.md diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 88425352..1d32e3ee 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -2,7 +2,7 @@ use soroban_sdk::BytesN; use soroban_sdk::{ - contract, contractclient, contracterror, contractimpl, contracttype, token, Address, Env, Vec, + contract, contractclient, contracterror, contractimpl, contracttype, log, token, Address, Env, Vec, }; #[contracterror] @@ -33,6 +33,24 @@ pub enum EscrowStatus { Refunded, } +impl EscrowStatus { + pub fn validate_transition(&self, next: &EscrowStatus) -> Result<(), EscrowError> { + match (self, next) { + (EscrowStatus::Setup, EscrowStatus::Funded) => Ok(()), + (EscrowStatus::Funded, EscrowStatus::WorkInProgress) => Ok(()), + (EscrowStatus::Funded, EscrowStatus::Completed) => Ok(()), + (EscrowStatus::Funded, EscrowStatus::Disputed) => Ok(()), + (EscrowStatus::Funded, EscrowStatus::Refunded) => Ok(()), + (EscrowStatus::WorkInProgress, EscrowStatus::WorkInProgress) => Ok(()), + (EscrowStatus::WorkInProgress, EscrowStatus::Completed) => Ok(()), + (EscrowStatus::WorkInProgress, EscrowStatus::Disputed) => Ok(()), + (EscrowStatus::WorkInProgress, EscrowStatus::Refunded) => Ok(()), + (EscrowStatus::Disputed, EscrowStatus::Resolved) => Ok(()), + _ => Err(EscrowError::InvalidStateTransition), + } + } +} + #[contracttype] #[derive(Clone, Debug, PartialEq)] pub enum MilestoneStatus { @@ -98,6 +116,7 @@ pub enum EscrowError { NoPendingMilestones = 8, JobRegistrySyncFailed = 9, UpgradeUnauthorized = 10, + InvalidStateTransition = 11, } #[contracttype] @@ -229,6 +248,7 @@ impl EscrowContract { .set(&DataKey::AgentJudge, &agent_judge); // Emit an initialization event for off-chain consumers and logging + log!(&env, "Escrow initialized with admin: {} and agent_judge: {}", admin, agent_judge); env.events().publish( ("escrow", "Initialized"), (admin.clone(), agent_judge.clone(), env.ledger().timestamp()), @@ -258,6 +278,7 @@ impl EscrowContract { .set(&DataKey::AgentJudge, &new_agent_judge); // Emit an event for off-chain logging and debugging + log!(&env, "Agent Judge updated to: {}", new_agent_judge); env.events().publish( ("escrow", "AgentJudgeUpdated"), ( @@ -285,6 +306,7 @@ impl EscrowContract { .instance() .set(&DataKey::JobRegistry, &job_registry); + log!(&env, "JobRegistry configured to: {}", job_registry); env.events().publish( ("escrow", "JobRegistryConfigured"), JobRegistryConfiguredEvent { @@ -320,6 +342,7 @@ impl EscrowContract { env.deployer() .update_current_contract_wasm(new_wasm_hash.clone()); + log!(&env, "Contract upgraded by admin"); env.events().publish( ("escrow", "ContractUpgraded"), ContractUpgradedEvent { @@ -359,6 +382,7 @@ impl EscrowContract { expires_at, milestones: Vec::new(&env), }; + log!(&env, "create_job: id {} client {} freelancer {}", job_id, client, freelancer); env.storage().persistent().set(&key, &job); Self::bump_job_ttl(&env, &key); } @@ -376,6 +400,7 @@ impl EscrowContract { amount, status: MilestoneStatus::Pending, }); + log!(&env, "add_milestone: job {} amount {}", job_id, amount); env.storage().persistent().set(&key, &job); Self::bump_job_ttl(&env, &key); } @@ -419,8 +444,11 @@ impl EscrowContract { let token_client = token::Client::new(&env, &job.token); token_client.transfer(&job.client, &env.current_contract_address(), &amount); + let next_status = EscrowStatus::Funded; + job.status.validate_transition(&next_status)?; job.total_amount = amount; - job.status = EscrowStatus::Funded; + job.status = next_status; + log!(&env, "deposit: job {} amount {}", job_id, amount); env.storage().persistent().set(&key, &job); Self::bump_job_ttl(&env, &key); @@ -483,10 +511,15 @@ impl EscrowContract { &milestone.amount, ); - if job.released_amount == job.total_amount { - job.status = EscrowStatus::Completed; - } + let next_status = if job.released_amount == job.total_amount { + EscrowStatus::Completed + } else { + EscrowStatus::WorkInProgress + }; + job.status.validate_transition(&next_status)?; + job.status = next_status; + log!(&env, "release_milestone: job {} amount {}", job_id, milestone.amount); env.storage().persistent().set(&key, &job); Self::bump_job_ttl(&env, &key); @@ -540,10 +573,15 @@ impl EscrowContract { &milestone.amount, ); - if job.released_amount == job.total_amount { - job.status = EscrowStatus::Completed; - } + let next_status = if job.released_amount == job.total_amount { + EscrowStatus::Completed + } else { + EscrowStatus::WorkInProgress + }; + job.status.validate_transition(&next_status).expect("invalid state transition"); + job.status = next_status; + log!(&env, "release_funds: job {} amount {}", job_id, milestone.amount); env.storage().persistent().set(&key, &job); Self::bump_job_ttl(&env, &key); } @@ -568,7 +606,10 @@ impl EscrowContract { return Err(EscrowError::Unauthorized); } - job.status = EscrowStatus::Disputed; + let next_status = EscrowStatus::Disputed; + job.status.validate_transition(&next_status)?; + job.status = next_status; + log!(&env, "open_dispute: job {}", job_id); env.storage().persistent().set(&key, &job); Self::bump_job_ttl(&env, &key); @@ -619,7 +660,10 @@ impl EscrowContract { ); // 6. Lock funds by transitioning to Disputed — blocks release_funds & release_milestone - job.status = EscrowStatus::Disputed; + let next_status = EscrowStatus::Disputed; + job.status.validate_transition(&next_status)?; + job.status = next_status; + log!(&env, "raise_dispute: job {}", job_id); env.storage().persistent().set(&key, &job); Self::bump_job_ttl(&env, &key); @@ -683,8 +727,11 @@ impl EscrowContract { token_client.transfer(&env.current_contract_address(), &job.client, &payer_amount); } + let next_status = EscrowStatus::Resolved; + job.status.validate_transition(&next_status).expect("invalid state transition"); job.released_amount += total_payout; - job.status = EscrowStatus::Resolved; + job.status = next_status; + log!(&env, "resolve_dispute: job {} payee {} payer {}", job_id, payee_amount, payer_amount); env.storage().persistent().set(&key, &job); Self::bump_job_ttl(&env, &key); } @@ -715,8 +762,11 @@ impl EscrowContract { token_client.transfer(&env.current_contract_address(), &job.client, &remaining); } + let next_status = EscrowStatus::Refunded; + job.status.validate_transition(&next_status)?; job.released_amount = job.total_amount; - job.status = EscrowStatus::Refunded; + job.status = next_status; + log!(&env, "refund: job {} amount {}", job_id, remaining); env.storage().persistent().set(&key, &job); Self::bump_job_ttl(&env, &key); diff --git a/docs/contracts/escrow_state_transitions.md b/docs/contracts/escrow_state_transitions.md new file mode 100644 index 00000000..b263be8b --- /dev/null +++ b/docs/contracts/escrow_state_transitions.md @@ -0,0 +1,36 @@ +# Escrow State Transitions + +## Overview + +The `EscrowContract` relies on strict state transition validations to prevent common Web3 attack vectors like reentrancy and unauthorized state changes. The `EscrowStatus` enum defines the current phase of an escrowed job, and the `validate_transition` method rigorously checks all requested transitions. + +## `EscrowStatus` States + +- `Setup`: Initial phase. Client configures job and milestones. +- `Funded`: Client has deposited the total amount matching the milestones. +- `WorkInProgress`: First or subsequent milestones have been released. Job is active. +- `Completed`: All milestones released. +- `Disputed`: A dispute has been raised by either party. Funds are locked. +- `Resolved`: Dispute has been addressed and settled by the AI Judge or Agent. +- `Refunded`: Job was cancelled or deadline expired, and remaining funds were returned to the client. + +## Valid Transitions (`validate_transition`) + +To minimize on-chain footprint and prevent unauthorized overwrites, the protocol asserts the following permitted transitions: + +- `Setup` -> `Funded`: Occurs on `deposit`. +- `Funded` -> `WorkInProgress`: Occurs on partial `release_milestone` or `release_funds`. +- `Funded` -> `Completed`: Occurs if a single milestone is fully released. +- `Funded` -> `Disputed`: Occurs on `open_dispute` or `raise_dispute`. +- `Funded` -> `Refunded`: Occurs on `refund`. +- `WorkInProgress` -> `WorkInProgress`: Permitted for partial milestone releases. +- `WorkInProgress` -> `Completed`: Occurs when the final milestone is released. +- `WorkInProgress` -> `Disputed`: Occurs on `open_dispute` or `raise_dispute`. +- `WorkInProgress` -> `Refunded`: Occurs on `refund`. +- `Disputed` -> `Resolved`: Occurs on `resolve_dispute`. + +Attempting any other transition will result in `EscrowError::InvalidStateTransition` (11). + +## Comprehensive Logging + +All state-changing operations within the `EscrowContract` invoke the `soroban_sdk::log!` macro. These logs emit context details (like `job_id`, `amount`, and target states) to the Soroban runtime, making it highly observable for debugging and backend indexing without bloating persistent storage. From 3e3f60e5fccec4adce719ddc01e3eaed7110e806 Mon Sep 17 00:00:00 2001 From: Obiajulu-gif Date: Thu, 23 Apr 2026 16:12:47 -0700 Subject: [PATCH 2/4] style(contracts): apply cargo fmt fixes to escrow logs --- contracts/escrow/src/lib.rs | 44 +++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 1d32e3ee..6df142ff 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -2,7 +2,8 @@ use soroban_sdk::BytesN; use soroban_sdk::{ - contract, contractclient, contracterror, contractimpl, contracttype, log, token, Address, Env, Vec, + contract, contractclient, contracterror, contractimpl, contracttype, log, token, Address, Env, + Vec, }; #[contracterror] @@ -248,7 +249,12 @@ impl EscrowContract { .set(&DataKey::AgentJudge, &agent_judge); // Emit an initialization event for off-chain consumers and logging - log!(&env, "Escrow initialized with admin: {} and agent_judge: {}", admin, agent_judge); + log!( + &env, + "Escrow initialized with admin: {} and agent_judge: {}", + admin, + agent_judge + ); env.events().publish( ("escrow", "Initialized"), (admin.clone(), agent_judge.clone(), env.ledger().timestamp()), @@ -382,7 +388,13 @@ impl EscrowContract { expires_at, milestones: Vec::new(&env), }; - log!(&env, "create_job: id {} client {} freelancer {}", job_id, client, freelancer); + log!( + &env, + "create_job: id {} client {} freelancer {}", + job_id, + client, + freelancer + ); env.storage().persistent().set(&key, &job); Self::bump_job_ttl(&env, &key); } @@ -519,7 +531,12 @@ impl EscrowContract { job.status.validate_transition(&next_status)?; job.status = next_status; - log!(&env, "release_milestone: job {} amount {}", job_id, milestone.amount); + log!( + &env, + "release_milestone: job {} amount {}", + job_id, + milestone.amount + ); env.storage().persistent().set(&key, &job); Self::bump_job_ttl(&env, &key); @@ -578,10 +595,17 @@ impl EscrowContract { } else { EscrowStatus::WorkInProgress }; - job.status.validate_transition(&next_status).expect("invalid state transition"); + job.status + .validate_transition(&next_status) + .expect("invalid state transition"); job.status = next_status; - log!(&env, "release_funds: job {} amount {}", job_id, milestone.amount); + log!( + &env, + "release_funds: job {} amount {}", + job_id, + milestone.amount + ); env.storage().persistent().set(&key, &job); Self::bump_job_ttl(&env, &key); } @@ -731,7 +755,13 @@ impl EscrowContract { job.status.validate_transition(&next_status).expect("invalid state transition"); job.released_amount += total_payout; job.status = next_status; - log!(&env, "resolve_dispute: job {} payee {} payer {}", job_id, payee_amount, payer_amount); + log!( + &env, + "resolve_dispute: job {} payee {} payer {}", + job_id, + payee_amount, + payer_amount + ); env.storage().persistent().set(&key, &job); Self::bump_job_ttl(&env, &key); } From d62e1b0b3ca4d0be24e33566cc02cbadb53b079b Mon Sep 17 00:00:00 2001 From: Obiajulu-gif Date: Thu, 23 Apr 2026 16:23:40 -0700 Subject: [PATCH 3/4] style(escrow): apply cargo fmt to resolve_dispute --- contracts/escrow/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 6df142ff..72855554 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -752,7 +752,9 @@ impl EscrowContract { } let next_status = EscrowStatus::Resolved; - job.status.validate_transition(&next_status).expect("invalid state transition"); + job.status + .validate_transition(&next_status) + .expect("invalid state transition"); job.released_amount += total_payout; job.status = next_status; log!( From 019b3a91d9909ee232a06b9dfe95c5156b52803d Mon Sep 17 00:00:00 2001 From: Obiajulu-gif Date: Thu, 23 Apr 2026 16:28:10 -0700 Subject: [PATCH 4/4] fix(escrow): fix moved value errors in create_job logs --- contracts/escrow/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 72855554..bc98cd7f 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -378,8 +378,8 @@ impl EscrowContract { let expires_at = now + 30 * 24 * 60 * 60; let job = EscrowJob { - client, - freelancer, + client: client.clone(), + freelancer: freelancer.clone(), token: token_addr, total_amount: 0, released_amount: 0,