diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs
index 5e6dd625..d635478c 100644
--- a/contracts/escrow/src/lib.rs
+++ b/contracts/escrow/src/lib.rs
@@ -14,6 +14,58 @@ pub enum EscrowStatus {
Refunded,
}
+impl EscrowStatus {
+ /// Requirement [SC-ESC-013]: Validate state machine transitions across multi-milestone gigs.
+ ///
+ /// Every state-mutating function in `EscrowContract` calls this guard before updating
+ /// persistent storage, ensuring the escrow lifecycle is a strict directed acyclic graph:
+ ///
+ /// ```text
+ /// Setup ──► Funded ──► WorkInProgress ──► Completed
+ /// │ │ │
+ /// │ └──────────────┼──► Disputed ──► Resolved
+ /// │ │ │
+ /// └────────────────────────┴────────────────────┴──► Refunded
+ /// ```
+ ///
+ /// Returning `Err(InvalidStateTransition)` causes the calling function to surface
+ /// error code `#11` to the caller — equivalent to a 422 Unprocessable Entity at
+ /// the protocol layer. This is distinct from `Unauthorized` (`#3`) which covers
+ /// authentication failures.
+ ///
+ /// The `WorkInProgress → WorkInProgress` self-transition is intentional: each
+ /// partial milestone release keeps the job in `WorkInProgress` until all funds
+ /// are released, at which point it transitions to `Completed`.
+ pub fn validate_transition(&self, next: &EscrowStatus) -> Result<(), EscrowError> {
+ match (self, next) {
+ // [SC-ESC-013]: Setup is the initial configuration phase; only Funded (deposit
+ // received) or Refunded (brief cancelled before funding) are valid exits.
+ (EscrowStatus::Setup, EscrowStatus::Funded) => Ok(()),
+ (EscrowStatus::Setup, EscrowStatus::Refunded) => Ok(()),
+ // [SC-ESC-013]: Funded allows work to start, dispute, partial refund, or
+ // immediate completion if a single-milestone job is released all at once.
+ (EscrowStatus::Funded, EscrowStatus::WorkInProgress) => Ok(()),
+ (EscrowStatus::Funded, EscrowStatus::Completed) => Ok(()),
+ (EscrowStatus::Funded, EscrowStatus::Disputed) => Ok(()),
+ (EscrowStatus::Funded, EscrowStatus::Refunded) => Ok(()),
+ // [SC-ESC-013]: WorkInProgress self-loop covers partial milestone releases.
+ // Transitions to Completed (all milestones released), Disputed (conflict raised),
+ // or Refunded (client cancels mid-gig) are valid exits.
+ (EscrowStatus::WorkInProgress, EscrowStatus::WorkInProgress) => Ok(()),
+ (EscrowStatus::WorkInProgress, EscrowStatus::Completed) => Ok(()),
+ (EscrowStatus::WorkInProgress, EscrowStatus::Disputed) => Ok(()),
+ (EscrowStatus::WorkInProgress, EscrowStatus::Refunded) => Ok(()),
+ // [SC-ESC-013]: A Disputed job can only be Resolved (judge adjudicates) or
+ // Refunded (dispute deadline expires via `expire_dispute`). No other exits.
+ (EscrowStatus::Disputed, EscrowStatus::Resolved) => Ok(()),
+ (EscrowStatus::Disputed, EscrowStatus::Refunded) => Ok(()),
+ // [SC-ESC-013]: All other transitions are invalid. Completed, Resolved, and
+ // Refunded are terminal — they have no valid outgoing edges.
+ _ => Err(EscrowError::InvalidStateTransition),
+ }
+ }
+}
+
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub enum MilestoneStatus {
@@ -67,6 +119,63 @@ pub struct ContractConfig {
}
#[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 {
+ AlreadyInitialized = 1,
+ NotInitialized = 2,
+ Unauthorized = 3,
+ InvalidInput = 4,
+ JobNotFound = 5,
+ InvalidState = 6,
+ AmountMismatch = 7,
+ NoPendingMilestones = 8,
+ JobRegistrySyncFailed = 9,
+ UpgradeUnauthorized = 10,
+ InvalidStateTransition = 11,
+ ReentrancyDetected = 12,
+ MultisigRequired = 13,
+ FeeTooHigh = 21,
+ NothingToSweep = 22,
+ InsufficientSignatures = 14,
+ AlreadySigned = 15,
+ ArithmeticError = 16,
+ UpgradeAdminAlreadySet = 17,
+ UpgradeAdminNotSet = 18,
+ ArithmeticOverflow = 19,
+ DisputeResolutionExpired = 20,
+ MaxMilestonesExceeded = 21,
+ FeeTooHigh = 22,
+ NothingToSweep = 23,
+ FeeTooHigh = 21,
+ NothingToSweep = 22,
+ ReentrantCall = 11,
+}
+
+/// Maximum platform fee, in basis points (100% = 10_000 bps).
+pub const MAX_FEE_BPS: u32 = 10_000;
+
+/// Requirement [SC-ESC-016]: Maximum number of milestones allowed per escrow job.
+///
+/// This hard cap serves two purposes:
+/// 1. **Storage efficiency** — each `Milestone` struct is written to Soroban persistent
+/// storage as part of the `EscrowJob` value. Unbounded growth would bloat the
+/// ledger entry beyond the Soroban footprint limits and drive up rent fees.
+/// 2. **WASM execution safety** — iteration over milestones during `deposit`,
+/// `release_milestone`, and `amend_milestones` is O(n); capping at 12 guarantees
+/// these loops stay well inside the Soroban instruction-count block boundary.
+///
+/// Checked arithmetic (`checked_add`, `checked_mul`) is used throughout all
+/// milestone-amount summation paths to prevent silent integer overflow.
+pub const MAX_MILESTONES: u32 = 12;
+
pub enum DataKey {
Job(u64),
Config,
@@ -84,6 +193,156 @@ pub struct DisputeRaisedEvent {
pub raised_at: u64,
}
+#[contracttype]
+#[derive(Clone)]
+pub struct DepositEvent {
+ pub job_id: u64,
+ pub amount: i128,
+ pub deposited_at: u64,
+}
+#[contracttype]
+#[derive(Clone)]
+pub struct ReleaseMilestoneEvent {
+ pub job_id: u64,
+ pub milestone_index: u32,
+ pub amount: i128,
+ pub released_at: u64,
+}
+
+#[contracttype]
+#[derive(Clone)]
+pub struct OpenDisputeEvent {
+ pub job_id: u64,
+ pub initiator: Address,
+ pub opened_at: u64,
+}
+
+#[contracttype]
+#[derive(Clone)]
+pub struct JobRegistryConfiguredEvent {
+ pub configured_by: Address,
+ pub registry_contract: Address,
+ pub configured_at: u64,
+}
+
+#[contracttype]
+#[derive(Clone)]
+pub struct RegistryDisputeSyncedEvent {
+ pub job_id: u64,
+ pub registry_contract: Address,
+ pub synced_at: u64,
+}
+
+#[contracttype]
+#[derive(Clone)]
+pub struct ContractUpgradedEvent {
+ pub by_admin: Address,
+ pub new_wasm_hash: BytesN<32>,
+ pub upgraded_at: u64,
+}
+
+#[contracttype]
+#[derive(Clone)]
+pub struct BriefCanceledEvent {
+ pub job_id: u64,
+ pub refunded_amount: i128,
+ pub canceled_by: Address,
+ pub canceled_at: u64,
+}
+
+#[contracttype]
+#[derive(Clone, Debug, PartialEq)]
+pub struct MultisigConfig {
+ pub signers: Vec,
+ pub required_signatures: u32,
+ pub current_signatures: Vec,
+}
+
+#[contracttype]
+#[derive(Clone)]
+pub struct MultisigConfiguredEvent {
+ pub job_id: u64,
+ pub required_signatures: u32,
+ pub total_signers: u32,
+ pub configured_at: u64,
+}
+
+#[contracttype]
+#[derive(Clone)]
+pub struct MultisigSignedEvent {
+ pub job_id: u64,
+ pub signer: Address,
+ pub signature_count: u32,
+ pub signed_at: u64,
+}
+
+#[contracttype]
+#[derive(Clone)]
+pub struct DisputeExpiredEvent {
+ pub job_id: u64,
+ pub refunded_to: Address,
+ pub amount: i128,
+ pub expired_at: u64,
+}
+
+#[contracttype]
+#[derive(Clone)]
+pub struct FeeConfigUpdatedEvent {
+ pub treasury: Option,
+ pub fee_bps: u32,
+ pub updated_at: u64,
+}
+
+#[contracttype]
+#[derive(Clone)]
+pub struct LockupUpdatedEvent {
+ pub job_id: u64,
+ pub expires_at: u64,
+ pub updated_at: u64,
+}
+
+#[contracttype]
+#[derive(Clone)]
+pub struct EmergencySweepEvent {
+ pub job_id: u64,
+ pub admin: Address,
+ pub rescue_address: Address,
+ pub amount: i128,
+ pub swept_at: u64,
+}
+
+#[contracttype]
+#[derive(Clone)]
+pub struct MilestonesAmendedEvent {
+ pub job_id: u64,
+ pub milestone_count: u32,
+ pub remaining_amount: i128,
+ pub amended_at: u64,
+}
+
+fn enter_reentrancy_guard(env: &Env) {
+struct ReentrancyGuard<'a> {
+ env: &'a Env,
+}
+
+impl Drop for ReentrancyGuard<'_> {
+ fn drop(&mut self) {
+ self.env.storage().instance().remove(&DataKey::Locked);
+ }
+}
+
+fn enter_reentrancy_guard(env: &Env) -> ReentrancyGuard<'_> {
+ if env.storage().instance().has(&DataKey::Locked) {
+ panic_with_error!(env, EscrowError::ReentrancyDetected);
+ }
+ env.storage().instance().set(&DataKey::Locked, &());
+ ReentrancyGuard { env }
+}
+
+fn checked_i128_add(lhs: i128, rhs: i128) -> Result {
+ lhs.checked_add(rhs).ok_or(EscrowError::InvalidInput)
+}
+
#[contract]
pub struct EscrowContract;
@@ -186,6 +445,62 @@ impl EscrowContract {
env.storage().persistent().set(&key, &job);
}
+ /// Requirement [SC-ESC-016]: Add a milestone to the job during the Setup phase.
+ ///
+ /// Milestones partition the total escrow budget into discrete delivery tranches that
+ /// the client unlocks incrementally via `release_milestone` or `release_funds`.
+ ///
+ /// **Authorization**: only the job's client may add milestones (`env.require_auth`).
+ ///
+ /// **State guard**: only valid in `Setup` state. Once `deposit` transitions the job
+ /// to `Funded`, the milestone structure is locked (modifications require `amend_milestones`
+ /// with dual-party authorization).
+ ///
+ /// **Partition count limit** ([SC-ESC-016]): adding a 13th milestone (or beyond) is
+ /// rejected with `MaxMilestonesExceeded` (error code `#21`). This cap prevents
+ /// unbounded persistent-storage growth and keeps WASM instruction counts bounded.
+ ///
+ /// **Checked arithmetic**: all milestone amount accumulation in `deposit` uses
+ /// `checked_add` to guard against `i128` overflow when summing across all partitions.
+ ///
+ /// # Errors
+ /// - `JobNotFound` — no persistent record for `job_id`.
+ /// - `Unauthorized` — caller is not the job client.
+ /// - `InvalidState` — job is not in `Setup` state.
+ /// - `InvalidInput` — `amount` is zero or negative.
+ /// - `MaxMilestonesExceeded` — already at the `MAX_MILESTONES` (12) limit.
+ 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)
+ .ok_or(EscrowError::JobNotFound)?;
+ Self::bump_job_ttl(&env, &key);
+ // [SC-ESC-016]: Only the authenticated client may mutate the milestone set.
+ job.client.require_auth();
+ // [SC-ESC-016]: Milestones are only configurable during Setup; once funded the
+ // partition set is immutable without explicit `amend_milestones` dual-auth.
+ if job.status != EscrowStatus::Setup {
+ return Err(EscrowError::InvalidState);
+ }
+ // [SC-ESC-016]: Reject zero or negative amounts to prevent zero-value milestones
+ // that would misalign the deposit total vs. milestone sum check.
+ if amount <= 0 || amount > Self::MAX_MILESTONE_AMOUNT {
+ return Err(EscrowError::InvalidInput);
+ }
+
+ let next_total = checked_i128_add(job.total_amount, amount)?;
+ if next_total > Self::MAX_JOB_BUDGET {
+ return Err(EscrowError::InvalidInput);
+ }
+
+ // [SC-ESC-016]: Enforce maximum milestone partition count.
+ // This is the primary invariant of this task — cap at MAX_MILESTONES (12) to
+ // bound ledger footprint and keep on-chain iteration within safe gas limits.
+ if job.milestones.len() >= MAX_MILESTONES {
+ return Err(EscrowError::MaxMilestonesExceeded);
+ }
/// Add a milestone to the job (setup phase only).
pub fn add_milestone(env: Env, job_id: u64, amount: i128) {
let key = DataKey::Job(job_id);
@@ -262,6 +577,32 @@ impl EscrowContract {
Self::clear_guard(&env, job_id);
}
+ fn payout_with_fee(env: &Env, _job_id: u64, job: &EscrowJob, amount: i128) {
+ let token_client = token::Client::new(env, &job.token);
+ let fee_bps = Self::get_fee_bps(env.clone());
+ let mut payout_amount = amount;
+
+ if fee_bps > 0 {
+ if let Some(treasury) = Self::get_treasury(env.clone()) {
+ let fee_amount = amount
+ .checked_mul(fee_bps as i128)
+ .unwrap_or(0)
+ .checked_div(MAX_FEE_BPS as i128)
+ .unwrap_or(0);
+
+ payout_amount = amount.checked_sub(fee_amount).unwrap_or(amount);
+
+ if fee_amount > 0 {
+ token_client.transfer(&env.current_contract_address(), &treasury, &fee_amount);
+ }
+ }
+ }
+
+ if payout_amount > 0 {
+ token_client.transfer(&env.current_contract_address(), &job.freelancer, &payout_amount);
+ }
+ }
+
/// Happy-path release for an explicit milestone index (0-based).
pub fn release_funds(env: Env, job_id: u64, caller: Address, milestone_index: u32) {
caller.require_auth();
@@ -292,6 +633,25 @@ impl EscrowContract {
let key = DataKey::Job(job_id);
let mut job: EscrowJob = env.storage().persistent().get(&key).expect("job not found");
+ if !(caller == job.client || caller == job.freelancer) {
+ return Err(EscrowError::Unauthorized);
+ }
+
+ let next_status = EscrowStatus::Disputed;
+ job.status.validate_transition(&next_status)?;
+ job.status = next_status;
+ job.dispute_deadline = env.ledger().timestamp()
+ .checked_add(Self::DISPUTE_RESOLUTION_WINDOW)
+ .ok_or(EscrowError::ArithmeticError)?;
+ log!(&env, "open_dispute: job {}", job_id);
+ env.storage().persistent().set(&key, &job);
+ Self::bump_job_ttl(&env, &key);
+
+ Self::sync_dispute_to_job_registry(&env, job_id)?;
+
+ env.events().publish(
+ ("escrow", "OpenDispute"),
+ (job_id, caller, env.ledger().timestamp()),
assert!(
job.status == EscrowStatus::Funded || job.status == EscrowStatus::WorkInProgress,
"job not in active/funded state"
@@ -346,6 +706,14 @@ impl EscrowContract {
"dispute cannot be raised: deadline has drastically expired"
);
+ // 6. Lock funds by transitioning to Disputed ΓÇö blocks release_funds & release_milestone
+ let next_status = EscrowStatus::Disputed;
+ job.status.validate_transition(&next_status)?;
+ job.status = next_status;
+ job.dispute_deadline = now
+ .checked_add(Self::DISPUTE_RESOLUTION_WINDOW)
+ .ok_or(EscrowError::ArithmeticError)?;
+ log!(&env, "raise_dispute: job {}", job_id);
// 6. Lock funds by transitioning to Disputed — blocks release_funds & release_milestone
job.status = EscrowStatus::Disputed;
env.storage().persistent().set(&key, &job);
@@ -354,6 +722,7 @@ impl EscrowContract {
let mut released_count = 0u32;
for m in job.milestones.iter() {
if m.status == MilestoneStatus::Released {
+ released_count = released_count.checked_add(1).ok_or(EscrowError::ArithmeticError)?;
released_count = released_count.checked_add(1).expect("overflow");
}
}
@@ -470,6 +839,93 @@ impl EscrowContract {
.expect("job not found")
}
+ /// Requirement [SC-ESC-019]: Dynamic Storage Allocation Recouping (State De-allocation).
+ ///
+ /// Soroban persistent storage incurs ongoing rent fees proportional to the byte size
+ /// of each ledger entry. Once an escrow job reaches a terminal state, all token
+ /// transfers are complete and the on-chain `EscrowJob` record serves no further
+ /// functional purpose. This function allows either party to explicitly de-allocate
+ /// the storage entry, recouping the associated ledger rent budget.
+ ///
+ /// **De-allocation scope** ([SC-ESC-019]):
+ /// - Removes the `Job(job_id)` persistent entry (the primary `EscrowJob` struct).
+ /// - If a `MultisigConfig(job_id)` entry was written during setup, it is also removed
+ /// in the same call to prevent orphaned storage keys.
+ ///
+ /// **Safety invariants**:
+ /// - Only the client or freelancer (authenticated via `env.require_auth`) may trigger
+ /// cleanup — preventing griefing by third parties.
+ /// - The job must be in a terminal state: `Completed`, `Refunded`, or `Resolved`.
+ /// Active jobs (`Setup`, `Funded`, `WorkInProgress`, `Disputed`) are rejected with
+ /// `InvalidState` (error code `#6`).
+ /// - A `checked_sub` guard verifies `remaining == 0` before removal. If funds are
+ /// somehow still outstanding (which should be impossible in a terminal state), the
+ /// call is rejected rather than silently discarding live funds.
+ ///
+ /// # Errors
+ /// - `JobNotFound` — no persistent record for `job_id`.
+ /// - `Unauthorized` — caller is neither client nor freelancer.
+ /// - `InvalidState` — job is not in a terminal state, or remaining balance > 0.
+ /// - `ArithmeticError` — underflow when computing `total_amount - released_amount`.
+ pub fn cleanup_job(env: Env, job_id: u64, caller: Address) -> Result<(), EscrowError> {
+ // [SC-ESC-019]: Caller must authenticate before triggering de-allocation.
+ caller.require_auth();
+
+ let key = DataKey::Job(job_id);
+ let job: EscrowJob = env
+ .storage()
+ .persistent()
+ .get(&key)
+ .ok_or(EscrowError::JobNotFound)?;
+
+ // [SC-ESC-019]: Only the escrow parties (client or freelancer) may trigger cleanup.
+ // Third-party callers receive `Unauthorized` (error code #3).
+ if !(caller == job.client || caller == job.freelancer) {
+ return Err(EscrowError::Unauthorized);
+ }
+
+ // [SC-ESC-019]: Terminal state gate — only jobs that have fully concluded
+ // (Completed, Refunded, Resolved) may be de-allocated. Active jobs are blocked
+ // to prevent accidental data loss while funds are still in escrow.
+ if !(job.status == EscrowStatus::Completed
+ || job.status == EscrowStatus::Refunded
+ || job.status == EscrowStatus::Resolved)
+ {
+ return Err(EscrowError::InvalidState);
+ }
+
+ // [SC-ESC-019]: Defensive arithmetic check — `checked_sub` prevents underflow
+ // and guarantees the outstanding balance is truly zero before removal.
+ let remaining = job
+ .total_amount
+ .checked_sub(job.released_amount)
+ .ok_or(EscrowError::ArithmeticError)?;
+ if remaining > 0 {
+ // This branch should be unreachable in a well-formed terminal state, but is
+ // kept as a hard safety guard against any future state-machine bugs.
+ return Err(EscrowError::InvalidState);
+ }
+
+ // [SC-ESC-019]: Primary de-allocation — remove the EscrowJob ledger entry.
+ env.storage().persistent().remove(&key);
+
+ // [SC-ESC-019]: Secondary de-allocation — remove orphaned MultisigConfig if present.
+ // This ensures no dangling storage keys are left after job teardown.
+ let ms_key = DataKey::MultisigConfig(job_id);
+ if env.storage().persistent().has(&ms_key) {
+ env.storage().persistent().remove(&ms_key);
+ }
+
+ env.events().publish(
+ ("escrow", "CleanupJob"),
+ (job_id, caller, env.ledger().timestamp()),
+ );
+
+ Ok(())
+ }
+
+ pub fn get_job(env: Env, job_id: u64) -> Result {
+ let key = DataKey::Job(job_id);
pub fn get_escrow_balance(env: Env, job_id: u64) -> i128 {
let job: EscrowJob = env
.storage()
@@ -544,89 +1000,364 @@ impl EscrowContract {
job.status = EscrowStatus::Completed;
}
- env.storage().persistent().set(&DataKey::Job(job_id), job);
- }
-
- fn payout_with_fee(env: &Env, job: &EscrowJob, amount: i128) {
- let token_client = token::Client::new(env, &job.token);
- let mut freelancer_amount = amount;
-
- if let Some(treasury_config) = env.storage().instance().get::<_, TreasuryConfig>(&DataKey::Treasury) {
- let fee = amount
- .checked_mul(treasury_config.fee_bps as i128)
- .expect("overflow")
- .checked_div(10000)
- .expect("overflow");
+ log!(
+ &env,
+ "expire_dispute: job {} refunded {}",
+ job_id,
+ remaining
+ );
+env.storage().persistent().set(&key, &job);
+Self::bump_job_ttl(&env, &key);
- if fee > 0 {
- freelancer_amount = amount
- .checked_sub(fee)
- .expect("overflow");
+exit_reentrancy_guard(&env);
+ env.events().publish(
+ ("escrow", "DisputeExpired"),
+ DisputeExpiredEvent {
+ job_id,
+ refunded_to: job.client,
+ amount: remaining,
+ expired_at: now,
+ },
+ );
- token_client.transfer(
- &env.current_contract_address(),
- &treasury_config.routing_address,
- &fee,
- );
- }
- }
+ Ok(())
+ }
- if freelancer_amount > 0 {
- token_client.transfer(
- &env.current_contract_address(),
- &job.freelancer,
- &freelancer_amount,
- );
+ /// Retrieve the status of all milestones for a given job.
+ pub fn get_milestone_status(
+ env: Env,
+ job_id: u64,
+ ) -> Result, EscrowError> {
+ let key = DataKey::Job(job_id);
+ let job: EscrowJob = env
+ .storage()
+ .persistent()
+ .get(&key)
+ .ok_or(EscrowError::JobNotFound)?;
+ Self::bump_job_ttl(&env, &key);
+ let mut statuses = Vec::new(&env);
+ for m in job.milestones.iter() {
+ statuses.push_back(m.status);
}
+ Ok(statuses)
}
-}
-#[cfg(test)]
-mod test {
- use super::*;
- use soroban_sdk::testutils::Address as _;
- use soroban_sdk::{token, Address, Env};
+ /// Retrieve the multisig configuration for a given job.
+ pub fn get_multisig_config(env: Env, job_id: u64) -> Result {
+ let config_key = DataKey::MultisigConfig(job_id);
+ let config: MultisigConfig = env
+ .storage()
+ .persistent()
+ .get(&config_key)
+ .ok_or(EscrowError::InvalidInput)?;
+ Self::bump_job_ttl(&env, &config_key);
+ Ok(config)
+ }
- fn setup_token(env: &Env, admin: &Address) -> Address {
- let contract = env.register_stellar_asset_contract_v2(admin.clone());
- contract.address()
+ /// Read-only helper exposing active escrow configuration.
+ pub fn get_escrow_config(env: Env) -> Result<(Address, Address, Option), EscrowError> {
+ let config: ContractConfig = env
+ .storage()
+ .instance()
+ .get(&DataKey::Config)
+ .ok_or(EscrowError::NotInitialized)?;
+ let job_registry: Option = env.storage().instance().get(&DataKey::JobRegistry);
+ Self::bump_instance_ttl(&env);
+ Ok((config.admin, config.agent_judge, job_registry))
}
- fn mint(env: &Env, token_addr: &Address, to: &Address) {
- let admin_client = token::StellarAssetClient::new(env, token_addr);
- admin_client.mint(to, &100_000);
+ /// Read-only helper exposing unreleased escrow balance for a job.
+ pub fn get_remaining_balance(env: Env, job_id: u64) -> Result {
+ let key = DataKey::Job(job_id);
+ let job: EscrowJob = env
+ .storage()
+ .persistent()
+ .get(&key)
+ .ok_or(EscrowError::JobNotFound)?;
+ Self::bump_job_ttl(&env, &key);
+ Self::checked_sub_i128(&env, job.total_amount, job.released_amount)
}
- #[test]
- fn test_happy_path_lifecycle() {
- let env = Env::default();
- env.mock_all_auths();
+ /// Configure multisig for a job. Only callable by client during Setup phase.
+ pub fn configure_multisig(
+ env: Env,
+ job_id: u64,
+ signers: Vec,
+ required_signatures: u32,
+ ) -> Result<(), EscrowError> {
+ let key = DataKey::Job(job_id);
+ let mut job: EscrowJob = env
+ .storage()
+ .persistent()
+ .get(&key)
+ .ok_or(EscrowError::JobNotFound)?;
+ Self::bump_job_ttl(&env, &key);
- let admin = Address::generate(&env);
- let agent_judge = Address::generate(&env);
- let client = Address::generate(&env);
- let freelancer = Address::generate(&env);
+ job.client.require_auth();
- let token_addr = setup_token(&env, &admin);
- mint(&env, &token_addr, &client);
+ if job.status != EscrowStatus::Setup {
+ return Err(EscrowError::InvalidState);
+ }
- let contract_id = env.register_contract(None, EscrowContract);
- let cc = EscrowContractClient::new(&env, &contract_id);
+ if signers.is_empty() || required_signatures == 0 {
+ return Err(EscrowError::InvalidInput);
+ }
- cc.initialize(&admin, &agent_judge);
- cc.create_job(&1u64, &client, &freelancer, &token_addr);
- cc.add_milestone(&1u64, &3000i128);
- cc.add_milestone(&1u64, &3000i128);
- cc.add_milestone(&1u64, &3000i128);
- cc.deposit(&1u64, &9000i128);
+ if required_signatures > signers.len() {
+ return Err(EscrowError::InvalidInput);
+ }
- let tc = token::Client::new(&env, &token_addr);
- assert_eq!(tc.balance(&contract_id), 9000);
+ let config = MultisigConfig {
+ signers: signers.clone(),
+ required_signatures,
+ current_signatures: Vec::new(&env),
+ };
- cc.release_milestone(&1u64, &client);
- assert_eq!(tc.balance(&freelancer), 3000);
+ env.storage()
+ .persistent()
+ .set(&DataKey::MultisigConfig(job_id), &config);
- cc.release_milestone(&1u64, &client);
+ job.requires_multisig = true;
+ env.storage().persistent().set(&key, &job);
+ Self::bump_job_ttl(&env, &key);
+
+ env.events().publish(
+ ("escrow", "MultisigConfigured"),
+ MultisigConfiguredEvent {
+ job_id,
+ required_signatures,
+ total_signers: signers.len(),
+ configured_at: env.ledger().timestamp(),
+ },
+ );
+
+ Ok(())
+ }
+
+ /// Sign a multisig job. Callable by any configured signer.
+ pub fn sign_multisig(env: Env, job_id: u64, signer: Address) -> Result<(), EscrowError> {
+ signer.require_auth();
+
+ let key = DataKey::Job(job_id);
+ let job: EscrowJob = env
+ .storage()
+ .persistent()
+ .get(&key)
+ .ok_or(EscrowError::JobNotFound)?;
+ Self::bump_job_ttl(&env, &key);
+
+ if !job.requires_multisig {
+ return Err(EscrowError::InvalidInput);
+ }
+
+ let config_key = DataKey::MultisigConfig(job_id);
+ let mut config: MultisigConfig = env
+ .storage()
+ .persistent()
+ .get(&config_key)
+ .ok_or(EscrowError::InvalidInput)?;
+
+ // Check if signer is authorized
+ let mut is_signer = false;
+ for s in config.signers.iter() {
+ if s == signer {
+ is_signer = true;
+ break;
+ }
+ }
+ if !is_signer {
+ return Err(EscrowError::Unauthorized);
+ }
+
+ // Check if already signed
+ for s in config.current_signatures.iter() {
+ if s == signer {
+ return Err(EscrowError::AlreadySigned);
+ }
+ }
+
+ config.current_signatures.push_back(signer.clone());
+ env.storage().persistent().set(&config_key, &config);
+ Self::bump_job_ttl(&env, &config_key);
+
+ env.events().publish(
+ ("escrow", "MultisigSigned"),
+ MultisigSignedEvent {
+ job_id,
+ signer,
+ signature_count: config.current_signatures.len(),
+ signed_at: env.ledger().timestamp(),
+ },
+ );
+
+ Ok(())
+ }
+
+ /// Check if a multisig job has enough signatures
+ pub fn check_multisig_ready(env: Env, job_id: u64) -> Result {
+ let key = DataKey::Job(job_id);
+ let job: EscrowJob = env
+ .storage()
+ .persistent()
+ .get(&key)
+ .ok_or(EscrowError::JobNotFound)?;
+
+ if !job.requires_multisig {
+ return Ok(true);
+ }
+
+ let config_key = DataKey::MultisigConfig(job_id);
+ let config: MultisigConfig = env
+ .storage()
+ .persistent()
+ .get(&config_key)
+ .ok_or(EscrowError::InvalidInput)?;
+
+ Ok(config.current_signatures.len() >= config.required_signatures)
+ }
+
+ // ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ
+ // SC-ESC-001: Admin fee splitting
+ // ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ
+
+ /// Admin configures the platform treasury and fee (in basis points).
+ /// Once set, milestone releases route `fee_bps` of each payout to the
+ /// treasury and the remainder to the freelancer.
+ pub fn set_fee_config(
+ env: Env,
+ treasury: Address,
+ fee_bps: u32,
+ ) -> Result<(), EscrowError> {
+ pub fn set_fee_config(env: Env, treasury: Address, fee_bps: u32) -> Result<(), EscrowError> {
+ let config: ContractConfig = env
+ .storage()
+ .instance()
+ .get(&DataKey::Config)
+ .ok_or(EscrowError::NotInitialized)?;
+ let admin = config.admin;
+ admin.require_auth();
+
+ if fee_bps > MAX_FEE_BPS {
+ return Err(EscrowError::FeeTooHigh);
+ }
+
+ env.storage().instance().set(&DataKey::Treasury, &treasury);
+ env.storage().instance().set(&DataKey::FeeBps, &fee_bps);
+ Self::bump_instance_ttl(&env);
+
+ env.events().publish(
+ ("escrow", "FeeConfigUpdated"),
+ FeeConfigUpdatedEvent {
+ treasury,
+ fee_bps,
+ updated_at: env.ledger().timestamp(),
+ },
+ );
+
+ Ok(())
+ }
+
+ /// Returns the active platform fee in basis points (0 when unset).
+ pub fn get_fee_bps(env: Env) -> u32 {
+ env.storage()
+ .instance()
+ .get(&DataKey::FeeBps)
+ .unwrap_or(0)
+ }
+
+ /// Returns the configured treasury address, if any.
+ pub fn get_treasury(env: Env) -> Option {
+ env.storage().instance().get(&DataKey::Treasury)
+ }
+
+ fn fee_bps(env: &Env) -> u32 {
+ env.storage()
+ .instance()
+ .get::<_, u32>(&DataKey::FeeBps)
+ .unwrap_or(0u32)
+ env.storage().persistent().set(&DataKey::Job(job_id), job);
+ }
+
+ fn payout_with_fee(env: &Env, job: &EscrowJob, amount: i128) {
+ let token_client = token::Client::new(env, &job.token);
+ let mut freelancer_amount = amount;
+
+ if let Some(treasury_config) = env.storage().instance().get::<_, TreasuryConfig>(&DataKey::Treasury) {
+ let fee = amount
+ .checked_mul(treasury_config.fee_bps as i128)
+ .expect("overflow")
+ .checked_div(10000)
+ .expect("overflow");
+
+ if fee > 0 {
+ freelancer_amount = amount
+ .checked_sub(fee)
+ .expect("overflow");
+
+ token_client.transfer(
+ &env.current_contract_address(),
+ &treasury_config.routing_address,
+ &fee,
+ );
+ }
+ }
+
+ if freelancer_amount > 0 {
+ token_client.transfer(
+ &env.current_contract_address(),
+ &job.freelancer,
+ &freelancer_amount,
+ );
+ }
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+ use soroban_sdk::testutils::Address as _;
+ use soroban_sdk::{token, Address, Env};
+
+ fn setup_token(env: &Env, admin: &Address) -> Address {
+ let contract = env.register_stellar_asset_contract_v2(admin.clone());
+ contract.address()
+ }
+
+ fn mint(env: &Env, token_addr: &Address, to: &Address) {
+ let admin_client = token::StellarAssetClient::new(env, token_addr);
+ admin_client.mint(to, &100_000);
+ }
+
+ #[test]
+ fn test_happy_path_lifecycle() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &3000i128);
+ cc.add_milestone(&1u64, &3000i128);
+ cc.add_milestone(&1u64, &3000i128);
+ cc.deposit(&1u64, &9000i128);
+
+ let tc = token::Client::new(&env, &token_addr);
+ assert_eq!(tc.balance(&contract_id), 9000);
+
+ cc.release_milestone(&1u64, &client);
+ assert_eq!(tc.balance(&freelancer), 3000);
+
+ cc.release_milestone(&1u64, &client);
assert_eq!(tc.balance(&freelancer), 6000);
cc.release_milestone(&1u64, &client);
@@ -1128,4 +1859,934 @@ mod test {
assert_eq!(tc.balance(&freelancer_one), 4000);
assert_eq!(tc.balance(&freelancer_two), 2500);
}
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // SC-ESC-016: Enforce Limit Restrictions on Maximum Milestone Partition Counts
+ // ─────────────────────────────────────────────────────────────────────────
+
+ #[test]
+ fn test_add_milestones_up_to_limit_succeeds() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+
+ // Adding exactly MAX_MILESTONES (12) should succeed
+ for _ in 0..MAX_MILESTONES {
+ cc.add_milestone(&1u64, &100i128);
+ }
+
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.milestones.len(), MAX_MILESTONES);
+ }
+
+ #[test]
+ #[should_panic(expected = "Error(Contract, #21)")]
+ fn test_add_milestones_limit_exceeded() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+
+ // Fill up to the max
+ for _ in 0..MAX_MILESTONES {
+ cc.add_milestone(&1u64, &100i128);
+ }
+
+ // The 13th milestone must fail with MaxMilestonesExceeded (#21)
+ cc.add_milestone(&1u64, &100i128);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // SC-ESC-013: Verify State Machine Integrity across Multi-Milestone Gigs
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// Full lifecycle: Setup → Funded → WIP → WIP → Completed.
+ /// Validates status, released_amount, remaining balance, and milestone
+ /// statuses at every intermediate step.
+ #[test]
+ fn test_multi_milestone_full_lifecycle_state_integrity() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+ let tc = token::Client::new(&env, &token_addr);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+
+ // ── Setup phase ──
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::Setup);
+ assert_eq!(job.milestones.len(), 0);
+ assert_eq!(job.total_amount, 0);
+ assert_eq!(job.released_amount, 0);
+
+ cc.add_milestone(&1u64, &1000i128);
+ cc.add_milestone(&1u64, &2000i128);
+ cc.add_milestone(&1u64, &3000i128);
+ cc.add_milestone(&1u64, &4000i128);
+
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.milestones.len(), 4);
+ assert_eq!(job.status, EscrowStatus::Setup);
+
+ // ── Deposit → Funded ──
+ cc.deposit(&1u64, &10_000i128);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::Funded);
+ assert_eq!(job.total_amount, 10_000);
+ assert_eq!(job.released_amount, 0);
+ assert_eq!(cc.get_escrow_balance(&1u64), 10_000);
+ assert_eq!(cc.get_remaining_balance(&1u64), 10_000);
+ assert_eq!(tc.balance(&contract_id), 10_000);
+
+ // Verify all milestones are Pending
+ let statuses = cc.get_milestone_status(&1u64);
+ for i in 0..4u32 {
+ assert_eq!(statuses.get(i).unwrap(), MilestoneStatus::Pending);
+ }
+
+ // ── Release milestone 0 → WorkInProgress ──
+ cc.release_milestone(&1u64, &client);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::WorkInProgress);
+ assert_eq!(job.released_amount, 1000);
+ assert_eq!(cc.get_escrow_balance(&1u64), 9000);
+ assert_eq!(cc.get_remaining_balance(&1u64), 9000);
+ assert_eq!(tc.balance(&freelancer), 1000);
+
+ let statuses = cc.get_milestone_status(&1u64);
+ assert_eq!(statuses.get(0).unwrap(), MilestoneStatus::Released);
+ assert_eq!(statuses.get(1).unwrap(), MilestoneStatus::Pending);
+ assert_eq!(statuses.get(2).unwrap(), MilestoneStatus::Pending);
+ assert_eq!(statuses.get(3).unwrap(), MilestoneStatus::Pending);
+
+ // ── Release milestone 1 → still WorkInProgress ──
+ cc.release_milestone(&1u64, &client);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::WorkInProgress);
+ assert_eq!(job.released_amount, 3000);
+ assert_eq!(cc.get_remaining_balance(&1u64), 7000);
+ assert_eq!(tc.balance(&freelancer), 3000);
+
+ // ── Release milestone 2 → still WorkInProgress ──
+ cc.release_milestone(&1u64, &client);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::WorkInProgress);
+ assert_eq!(job.released_amount, 6000);
+ assert_eq!(cc.get_remaining_balance(&1u64), 4000);
+ assert_eq!(tc.balance(&freelancer), 6000);
+
+ // ── Release milestone 3 → Completed ──
+ cc.release_milestone(&1u64, &client);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::Completed);
+ assert_eq!(job.released_amount, 10_000);
+ assert_eq!(cc.get_remaining_balance(&1u64), 0);
+ assert_eq!(tc.balance(&freelancer), 10_000);
+ assert_eq!(tc.balance(&contract_id), 0);
+
+ // All milestones must be Released
+ let statuses = cc.get_milestone_status(&1u64);
+ for i in 0..4u32 {
+ assert_eq!(statuses.get(i).unwrap(), MilestoneStatus::Released);
+ }
+ }
+
+ /// Out-of-order release_funds (explicit index) with state checks at each step.
+ #[test]
+ fn test_out_of_order_release_funds_state_integrity() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+ let tc = token::Client::new(&env, &token_addr);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &1500i128);
+ cc.add_milestone(&1u64, &2500i128);
+ cc.add_milestone(&1u64, &3000i128);
+ cc.add_milestone(&1u64, &3000i128);
+ cc.deposit(&1u64, &10_000i128);
+
+ // Release milestone index 3 first (out of order)
+ cc.release_funds(&1u64, &client, &3u32);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::WorkInProgress);
+ assert_eq!(job.released_amount, 3000);
+ let statuses = cc.get_milestone_status(&1u64);
+ assert_eq!(statuses.get(0).unwrap(), MilestoneStatus::Pending);
+ assert_eq!(statuses.get(3).unwrap(), MilestoneStatus::Released);
+
+ // Release milestone index 1
+ cc.release_funds(&1u64, &client, &1u32);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::WorkInProgress);
+ assert_eq!(job.released_amount, 5500);
+ assert_eq!(tc.balance(&freelancer), 5500);
+
+ // Release milestone index 0
+ cc.release_funds(&1u64, &client, &0u32);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::WorkInProgress);
+ assert_eq!(job.released_amount, 7000);
+
+ // Release milestone index 2 — final → Completed
+ cc.release_funds(&1u64, &client, &2u32);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::Completed);
+ assert_eq!(job.released_amount, 10_000);
+ assert_eq!(tc.balance(&freelancer), 10_000);
+ assert_eq!(tc.balance(&contract_id), 0);
+
+ // All Released
+ let statuses = cc.get_milestone_status(&1u64);
+ for i in 0..4u32 {
+ assert_eq!(statuses.get(i).unwrap(), MilestoneStatus::Released);
+ }
+ }
+
+ /// Dispute raised mid-WIP after partial milestone releases; verifies balance
+ /// accounting is correct through dispute resolution.
+ #[test]
+ fn test_dispute_mid_wip_partial_milestones_balance_integrity() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+ let tc = token::Client::new(&env, &token_addr);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &2000i128);
+ cc.add_milestone(&1u64, &3000i128);
+ cc.add_milestone(&1u64, &5000i128);
+ cc.deposit(&1u64, &10_000i128);
+
+ // Release first two milestones
+ cc.release_milestone(&1u64, &client); // 2000
+ cc.release_milestone(&1u64, &client); // 3000
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::WorkInProgress);
+ assert_eq!(job.released_amount, 5000);
+ assert_eq!(tc.balance(&freelancer), 5000);
+
+ // Raise dispute with 5000 remaining
+ cc.raise_dispute(&1u64, &freelancer);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::Disputed);
+ assert_eq!(cc.get_remaining_balance(&1u64), 5000);
+
+ // Milestone statuses: first two Released, third Pending
+ let statuses = cc.get_milestone_status(&1u64);
+ assert_eq!(statuses.get(0).unwrap(), MilestoneStatus::Released);
+ assert_eq!(statuses.get(1).unwrap(), MilestoneStatus::Released);
+ assert_eq!(statuses.get(2).unwrap(), MilestoneStatus::Pending);
+
+ // Resolve: 60/40 split of remaining 5000
+ cc.resolve_dispute(&1u64, &3000i128, &2000i128);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::Resolved);
+ // Total released: 5000 (milestones) + 5000 (resolution) = 10000
+ assert_eq!(job.released_amount, 10_000);
+ assert_eq!(tc.balance(&freelancer), 8000); // 5000 + 3000
+ assert_eq!(tc.balance(&client), 92_000); // 100000 - 10000 + 2000
+ }
+
+ /// Cancel brief in WorkInProgress state refunds only the unreleased portion.
+ #[test]
+ fn test_cancel_brief_wip_refunds_remaining_only() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+ let tc = token::Client::new(&env, &token_addr);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &2000i128);
+ cc.add_milestone(&1u64, &3000i128);
+ cc.add_milestone(&1u64, &5000i128);
+ cc.deposit(&1u64, &10_000i128);
+
+ // Release first milestone → WIP
+ cc.release_milestone(&1u64, &client);
+ assert_eq!(tc.balance(&freelancer), 2000);
+ assert_eq!(tc.balance(&client), 90_000);
+
+ // Cancel brief — should refund remaining 8000 to client
+ cc.cancel_brief(&1u64, &client);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::Refunded);
+ assert_eq!(job.released_amount, job.total_amount); // fully accounted
+ assert_eq!(tc.balance(&client), 98_000); // 90000 + 8000
+ assert_eq!(tc.balance(&freelancer), 2000); // unchanged
+ assert_eq!(tc.balance(&contract_id), 0); // fully drained
+ }
+
+ /// Amend milestones mid-WIP, then release amended milestones to Completed.
+ /// Validates the state machine remains coherent through amendment.
+ #[test]
+ fn test_amend_milestones_then_complete_state_integrity() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+ let tc = token::Client::new(&env, &token_addr);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &2000i128);
+ cc.add_milestone(&1u64, &3000i128);
+ cc.add_milestone(&1u64, &5000i128);
+ cc.deposit(&1u64, &10_000i128);
+
+ // Release first milestone
+ cc.release_milestone(&1u64, &client);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::WorkInProgress);
+ assert_eq!(job.released_amount, 2000);
+
+ // Amend remaining milestones: 3000+5000=8000 → 4000+4000=8000
+ let new_amounts = soroban_sdk::vec![&env, 4000i128, 4000i128];
+ cc.amend_milestones(&1u64, &new_amounts);
+
+ // Verify structure: 1 Released + 2 new Pending
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.milestones.len(), 3);
+ assert_eq!(job.status, EscrowStatus::WorkInProgress);
+ assert_eq!(job.released_amount, 2000);
+
+ let statuses = cc.get_milestone_status(&1u64);
+ assert_eq!(statuses.get(0).unwrap(), MilestoneStatus::Released);
+ assert_eq!(statuses.get(1).unwrap(), MilestoneStatus::Pending);
+ assert_eq!(statuses.get(2).unwrap(), MilestoneStatus::Pending);
+
+ // Release remaining amended milestones
+ cc.release_milestone(&1u64, &client);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::WorkInProgress);
+ assert_eq!(job.released_amount, 6000);
+ assert_eq!(tc.balance(&freelancer), 6000);
+
+ cc.release_milestone(&1u64, &client);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::Completed);
+ assert_eq!(job.released_amount, 10_000);
+ assert_eq!(tc.balance(&freelancer), 10_000);
+ assert_eq!(tc.balance(&contract_id), 0);
+ }
+
+ /// Getter consistency: get_escrow_balance and get_remaining_balance match
+ /// across every state transition in a multi-milestone lifecycle.
+ #[test]
+ fn test_getter_consistency_across_transitions() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &3000i128);
+ cc.add_milestone(&1u64, &3000i128);
+ cc.add_milestone(&1u64, &4000i128);
+ cc.deposit(&1u64, &10_000i128);
+
+ // Both getters must agree at every step
+ assert_eq!(cc.get_escrow_balance(&1u64), cc.get_remaining_balance(&1u64));
+ assert_eq!(cc.get_escrow_balance(&1u64), 10_000);
+
+ cc.release_milestone(&1u64, &client);
+ assert_eq!(cc.get_escrow_balance(&1u64), cc.get_remaining_balance(&1u64));
+ assert_eq!(cc.get_escrow_balance(&1u64), 7000);
+
+ cc.release_milestone(&1u64, &client);
+ assert_eq!(cc.get_escrow_balance(&1u64), cc.get_remaining_balance(&1u64));
+ assert_eq!(cc.get_escrow_balance(&1u64), 4000);
+
+ cc.release_milestone(&1u64, &client);
+ assert_eq!(cc.get_escrow_balance(&1u64), cc.get_remaining_balance(&1u64));
+ assert_eq!(cc.get_escrow_balance(&1u64), 0);
+
+ // Final state
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::Completed);
+ }
+
+ /// Unauthorized callers are blocked from all state-mutating functions
+ /// on a multi-milestone job.
+ #[test]
+ fn test_unauthorized_state_mutations_blocked_multi_milestone() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+ let rando = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &5000i128);
+ cc.add_milestone(&1u64, &5000i128);
+ cc.deposit(&1u64, &10_000i128);
+
+ // Release first milestone to enter WIP
+ cc.release_milestone(&1u64, &client);
+ assert_eq!(cc.get_job(&1u64).status, EscrowStatus::WorkInProgress);
+
+ // Verify: release_milestone by rando → Unauthorized (#3)
+ let result = cc.try_release_milestone(&1u64, &rando);
+ assert!(result.is_err());
+
+ // Verify: release_funds by freelancer → Unauthorized (#3)
+ let result = cc.try_release_funds(&1u64, &freelancer, &1u32);
+ assert!(result.is_err());
+
+ // Verify: refund by freelancer → Unauthorized (#3)
+ let result = cc.try_refund(&1u64, &freelancer);
+ assert!(result.is_err());
+
+ // Verify: cancel_brief by rando → Unauthorized (#3)
+ let result = cc.try_cancel_brief(&1u64, &rando);
+ assert!(result.is_err());
+
+ // State is still WIP — no mutation occurred
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::WorkInProgress);
+ assert_eq!(job.released_amount, 5000);
+ }
+
+ /// Invalid state transitions are blocked on multi-milestone jobs:
+ /// cannot release after dispute, cannot dispute after completion.
+ #[test]
+ fn test_invalid_transitions_blocked_multi_milestone() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+
+ cc.initialize(&admin, &agent_judge);
+
+ // ── Test 1: Cannot release after dispute ──
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &3000i128);
+ cc.add_milestone(&1u64, &7000i128);
+ cc.deposit(&1u64, &10_000i128);
+
+ cc.release_milestone(&1u64, &client); // WIP
+ cc.raise_dispute(&1u64, &client); // Disputed
+ assert_eq!(cc.get_job(&1u64).status, EscrowStatus::Disputed);
+
+ // release_milestone must fail in Disputed state
+ let result = cc.try_release_milestone(&1u64, &client);
+ assert!(result.is_err());
+
+ // release_funds must fail in Disputed state
+ let result = cc.try_release_funds(&1u64, &client, &1u32);
+ assert!(result.is_err());
+
+ // ── Test 2: Cannot dispute after completion ──
+ cc.create_job(&2u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&2u64, &5000i128);
+ cc.add_milestone(&2u64, &5000i128);
+ cc.deposit(&2u64, &10_000i128);
+
+ cc.release_milestone(&2u64, &client);
+ cc.release_milestone(&2u64, &client);
+ assert_eq!(cc.get_job(&2u64).status, EscrowStatus::Completed);
+
+ // raise_dispute must fail in Completed state
+ let result = cc.try_raise_dispute(&2u64, &client);
+ assert!(result.is_err());
+
+ // open_dispute must fail in Completed state
+ let result = cc.try_open_dispute(&2u64, &client);
+ assert!(result.is_err());
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // SC-ESC-019: Dynamic Storage Allocation Recouping (State De-allocation)
+ // ─────────────────────────────────────────────────────────────────────────
+ #[test]
+ fn test_cleanup_job_completed() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &5000i128);
+ cc.deposit(&1u64, &5000i128);
+ cc.release_milestone(&1u64, &client);
+
+ assert_eq!(cc.get_job(&1u64).status, EscrowStatus::Completed);
+
+ // cleanup_job by client
+ cc.cleanup_job(&1u64, &client);
+
+ // verify it is deleted
+ let result = cc.try_get_job(&1u64);
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn test_cleanup_job_invalid_state() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &5000i128);
+
+ // Setup state
+ assert_eq!(cc.get_job(&1u64).status, EscrowStatus::Setup);
+ let res = cc.try_cleanup_job(&1u64, &client);
+ assert!(res.is_err());
+
+ cc.deposit(&1u64, &5000i128);
+ // Funded state
+ assert_eq!(cc.get_job(&1u64).status, EscrowStatus::Funded);
+ let res = cc.try_cleanup_job(&1u64, &client);
+ assert!(res.is_err());
+ }
+
+ #[test]
+ fn test_cleanup_job_unauthorized() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+ let random = Address::generate(&env);
+
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &5000i128);
+ cc.deposit(&1u64, &5000i128);
+ cc.release_milestone(&1u64, &client);
+
+ // Random tries to cleanup
+ let res = cc.try_cleanup_job(&1u64, &random);
+ assert!(res.is_err());
+ }
+
+ // ──────────────────────────────────────────────────────────────────────────────
+ // SC-ESC-016: Additional milestone partition count edge-case tests
+ // ──────────────────────────────────────────────────────────────────────────────
+
+ /// [SC-ESC-016] Adding a milestone with a zero amount must be rejected with
+ /// `InvalidInput` (error code #4). Zero-value milestones would corrupt the
+ /// deposit-vs-milestone-sum invariant enforced by `deposit`.
+ #[test]
+ #[should_panic(expected = "Error(Contract, #4)")]
+ fn test_add_milestone_zero_amount_panics() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+ let token_addr = setup_token(&env, &admin);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ // Zero amount must be rejected
+ cc.add_milestone(&1u64, &0i128);
+ }
+
+ /// [SC-ESC-016] Adding a milestone after the job is `Funded` must be rejected with
+ /// `InvalidState` (error code #6). Once deposited, the partition set is locked.
+ #[test]
+ #[should_panic(expected = "Error(Contract, #6)")]
+ fn test_add_milestone_after_deposit_panics() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &5000i128);
+ cc.deposit(&1u64, &5000i128);
+
+ // Adding a milestone after deposit must fail with InvalidState
+ cc.add_milestone(&1u64, &1000i128);
+ }
+
+ // ──────────────────────────────────────────────────────────────────────────────
+ // SC-ESC-013: Additional state machine integrity tests
+ // ──────────────────────────────────────────────────────────────────────────────
+
+ /// [SC-ESC-013] `deposit` must reject when no milestones have been added.
+ /// Enforces the invariant: funded amount must equal sum of milestone amounts.
+ #[test]
+ #[should_panic(expected = "Error(Contract, #4)")]
+ fn test_state_machine_deposit_with_no_milestones_panics() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ // No milestones added — deposit must fail with InvalidInput (#4)
+ cc.deposit(&1u64, &5000i128);
+ }
+
+ /// [SC-ESC-013] A Disputed job cannot be resolved twice. The first `resolve_dispute`
+ /// transitions to `Resolved`; a second call must fail with `InvalidState` (#6).
+ #[test]
+ #[should_panic(expected = "Error(Contract, #6)")]
+ fn test_state_machine_resolve_dispute_twice_panics() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &6000i128);
+ cc.deposit(&1u64, &6000i128);
+
+ cc.raise_dispute(&1u64, &client);
+ // First resolution succeeds
+ cc.resolve_dispute(&1u64, &6000i128, &0i128);
+ assert_eq!(cc.get_job(&1u64).status, EscrowStatus::Resolved);
+
+ // Second resolution must fail — Resolved → Resolved is not a valid transition
+ cc.resolve_dispute(&1u64, &0i128, &0i128);
+ }
+
+ /// [SC-ESC-013] Verifies that the job transitions from `Funded` directly to `Completed`
+ /// (skipping `WorkInProgress`) when a single-milestone job is fully released at once.
+ #[test]
+ fn test_state_machine_single_milestone_funded_to_completed() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+ let tc = token::Client::new(&env, &token_addr);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &10_000i128);
+ cc.deposit(&1u64, &10_000i128);
+
+ // Single milestone: Funded → Completed in one release
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, EscrowStatus::Funded);
+
+ cc.release_milestone(&1u64, &client);
+ let job = cc.get_job(&1u64);
+ // Funded → Completed (single-milestone fast path, no WIP intermediate)
+ assert_eq!(job.status, EscrowStatus::Completed);
+ assert_eq!(job.released_amount, 10_000);
+ assert_eq!(tc.balance(&freelancer), 10_000);
+ assert_eq!(tc.balance(&contract_id), 0);
+ }
+
+ // ──────────────────────────────────────────────────────────────────────────────
+ // SC-ESC-019: Additional storage de-allocation tests
+ // ──────────────────────────────────────────────────────────────────────────────
+
+ /// [SC-ESC-019] `cleanup_job` must succeed in the `Refunded` terminal state and
+ /// remove the persistent entry so that `get_job` returns `JobNotFound`.
+ #[test]
+ fn test_cleanup_job_after_refund_succeeds() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &4000i128);
+ cc.deposit(&1u64, &4000i128);
+
+ // Expire lockup, then refund
+ let expiry = cc.get_expiry(&1u64);
+ env.ledger().with_mut(|li| li.timestamp = expiry + 1);
+ cc.refund(&1u64, &client);
+ assert_eq!(cc.get_job(&1u64).status, EscrowStatus::Refunded);
+
+ // Cleanup by the client — must succeed
+ cc.cleanup_job(&1u64, &client);
+
+ // Storage entry must now be absent
+ let result = cc.try_get_job(&1u64);
+ assert!(result.is_err());
+ }
+
+ /// [SC-ESC-019] `cleanup_job` must succeed in the `Resolved` terminal state and
+ /// free both the Job and any associated MultisigConfig ledger entries.
+ #[test]
+ fn test_cleanup_job_after_resolved_succeeds() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+ cc.add_milestone(&1u64, &8000i128);
+ cc.deposit(&1u64, &8000i128);
+
+ cc.raise_dispute(&1u64, &client);
+ // Resolve fully in favour of freelancer
+ cc.resolve_dispute(&1u64, &8000i128, &0i128);
+ assert_eq!(cc.get_job(&1u64).status, EscrowStatus::Resolved);
+
+ // Freelancer cleans up
+ cc.cleanup_job(&1u64, &freelancer);
+
+ // Storage entry must now be absent
+ let result = cc.try_get_job(&1u64);
+ assert!(result.is_err());
+ }
+
+ /// [SC-ESC-019] Attempting to `cleanup_job` on a non-existent job ID must return
+ /// `JobNotFound` (error code #5).
+ #[test]
+ #[should_panic(expected = "Error(Contract, #5)")]
+ fn test_cleanup_job_not_found_panics() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let caller = Address::generate(&env);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+
+ cc.initialize(&admin, &agent_judge);
+ // Job ID 999 was never created
+ cc.cleanup_job(&999u64, &caller);
+ }
+
+ /// [SC-ESC-019] `cleanup_job` also removes the associated `MultisigConfig` persistent
+ /// key when one was configured, leaving no orphaned ledger entries.
+ #[test]
+ fn test_cleanup_job_also_removes_multisig_config() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let agent_judge = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+ let signer1 = Address::generate(&env);
+ let signer2 = Address::generate(&env);
+ let token_addr = setup_token(&env, &admin);
+ mint(&env, &token_addr, &client);
+
+ let contract_id = env.register_contract(None, EscrowContract);
+ let cc = EscrowContractClient::new(&env, &contract_id);
+
+ cc.initialize(&admin, &agent_judge);
+ cc.create_job(&1u64, &client, &freelancer, &token_addr);
+
+ // Configure multisig during Setup
+ let signers = soroban_sdk::vec![&env, signer1.clone(), signer2.clone()];
+ cc.configure_multisig(&1u64, &signers, &2u32);
+
+ cc.add_milestone(&1u64, &5000i128);
+ cc.deposit(&1u64, &5000i128);
+ cc.release_milestone(&1u64, &client);
+ assert_eq!(cc.get_job(&1u64).status, EscrowStatus::Completed);
+
+ // MultisigConfig must be readable before cleanup
+ let config = cc.get_multisig_config(&1u64);
+ assert_eq!(config.required_signatures, 2);
+
+ // Cleanup removes both Job and MultisigConfig
+ cc.cleanup_job(&1u64, &client);
+
+ // Job is gone
+ let job_result = cc.try_get_job(&1u64);
+ assert!(job_result.is_err());
+
+ // MultisigConfig is also gone
+ let ms_result = cc.try_get_multisig_config(&1u64);
+ assert!(ms_result.is_err());
+ }
}
diff --git a/contracts/job_registry/src/lib.rs b/contracts/job_registry/src/lib.rs
index dd4a1042..791b71af 100644
--- a/contracts/job_registry/src/lib.rs
+++ b/contracts/job_registry/src/lib.rs
@@ -1,4 +1,1685 @@
#![no_std]
+
+use soroban_sdk::{
+ contract, contracterror, contractimpl, contracttype, log, panic_with_error, symbol_short,
+ token, Address, Bytes, Env, Vec,
+};
+
+#[allow(dead_code)]
+const MAX_HASH_LEN: u32 = 96;
+
+// Requirement [SC-REG-037]: Contract-wide budget floor and ceiling enforced at input validation.
+// MIN prevents dust spam; MAX caps exposure to a realistic large project value.
+const MIN_BUDGET_STROOPS: i128 = 100_000; // 0.01 XLM
+const MAX_BUDGET_STROOPS: i128 = 100_000_000_000_000; // 10,000,000 XLM
+
+#[contracterror]
+#[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord)]
+#[repr(u32)]
+pub enum JobRegistryError {
+ AlreadyInitialized = 1,
+ NotInitialized = 2,
+ InvalidJobId = 3,
+ InvalidBudget = 4,
+ InvalidHash = 5,
+ JobAlreadyExists = 6,
+ JobNotFound = 7,
+ JobNotOpen = 8,
+ Unauthorized = 9,
+ BidAlreadySubmitted = 10,
+ BidNotFound = 11,
+ InvalidStateTransition = 12,
+ NoDeliverable = 13,
+ Overflow = 14,
+ InvalidExpiration = 15,
+ JobExpired = 16,
+ JobNotExpired = 17,
+ InvalidCollateral = 18,
+}
+
+#[contracttype]
+#[derive(Clone, Debug, PartialEq)]
+pub enum JobStatus {
+ Open,
+ Assigned,
+ InProgress,
+ DeliverableSubmitted,
+ Completed,
+ Disputed,
+ Expired,
+ Defaulted,
+}
+
+#[contracttype]
+#[derive(Clone)]
+pub struct JobRecord {
+ pub client: Address,
+ pub freelancer: Option,
+ pub metadata_hash: Bytes,
+ pub budget_stroops: i128,
+ pub expires_at: u64,
+ pub status: JobStatus,
+ pub bid_deadline: u64,
+ pub collateral_token: Address,
+ pub collateral_amount: i128,
+ pub collateral_locked: bool,
+}
+
+// Requirement [SC-REG-036]: Storage Packing for Bid Struct Instance Allocations.
+// Groups `freelancer` address, `proposal_hash` (IPFS CID), and bid collateral fields
+// into a single packed struct to minimize Soroban ledger footprint and reduce storage charges.
+#[contracttype]
+#[derive(Clone)]
+pub struct BidRecord {
+ pub freelancer: Address,
+ pub proposal_hash: Bytes,
+ pub collateral_stroops: i128,
+}
+
+#[contracttype]
+pub enum DataKey {
+ Admin,
+ NextJobId,
+ Job(u64),
+ BidCount(u64),
+ Bid(u64, u32),
+ BidIndex(u64, Address),
+ Deliverable(u64),
+}
+
+#[contract]
+pub struct JobRegistryContract;
+
+#[contractimpl]
+impl JobRegistryContract {
+ /// One-time storage bootstrap.
+ ///
+ /// Sets contract admin and initializes `next_job_id` to 1.
+ pub fn initialize(env: Env, admin: Address) {
+ if env.storage().instance().has(&DataKey::Admin) {
+ panic_with_error!(&env, JobRegistryError::AlreadyInitialized);
+ }
+
+ admin.require_auth();
+
+ env.storage().instance().set(&DataKey::Admin, &admin);
+ env.storage().instance().set(&DataKey::NextJobId, &1u64);
+
+ log!(&env, "initialized");
+ }
+
+ pub fn is_initialized(env: Env) -> bool {
+ env.storage().instance().has(&DataKey::Admin)
+ }
+
+ pub fn get_admin(env: Env) -> Address {
+ read_admin(&env)
+ }
+
+ pub fn get_next_job_id(env: Env) -> u64 {
+ read_next_job_id(&env)
+ }
+
+ /// Client posts a job with explicit `job_id` and collateral lockup details.
+ pub fn post_job(
+ env: Env,
+ job_id: u64,
+ client: Address,
+ hash: Bytes,
+ budget: i128,
+ expires_at: u64,
+ bid_deadline: u64,
+ collateral_token: Address,
+ collateral_amount: i128,
+ ) {
+ ensure_initialized(&env);
+
+ validate_job_input(
+ &env,
+ job_id,
+ &hash,
+ budget,
+ expires_at,
+ bid_deadline,
+ );
+
+ if collateral_amount < 0 {
+ panic_with_error!(&env, JobRegistryError::InvalidBudget);
+ }
+
+ client.require_auth();
+
+ post_job_with_id(
+ &env,
+ job_id,
+ client.clone(),
+ hash,
+ budget,
+ expires_at,
+ bid_deadline,
+ collateral_token.clone(),
+ collateral_amount,
+ );
+
+ // Lock collateral from client into this contract
+ if collateral_amount > 0 {
+ let token_client = token::Client::new(&env, &collateral_token);
+ token_client.transfer(&client, &env.current_contract_address(), &collateral_amount);
+ }
+
+ let next_job_id = read_next_job_id(&env);
+
+ if job_id >= next_job_id {
+ let updated = job_id
+ .checked_add(1)
+ .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::Overflow));
+
+ env.storage()
+ .instance()
+ .set(&DataKey::NextJobId, &updated);
+ }
+
+ env.events()
+ .publish((symbol_short!("jobpost"), job_id), client);
+ }
+
+ /// Client posts a job using internal registry index allocation and collateral lockup details.
+ pub fn post_job_auto(
+ env: Env,
+ client: Address,
+ hash: Bytes,
+ budget: i128,
+ expires_at: u64,
+ bid_deadline: u64,
+ collateral_token: Address,
+ collateral_amount: i128,
+ ) -> u64 {
+ ensure_initialized(&env);
+
+ let job_id = read_next_job_id(&env);
+
+ validate_job_input(
+ &env,
+ job_id,
+ &hash,
+ budget,
+ expires_at,
+ bid_deadline,
+ );
+
+ if collateral_amount < 0 {
+ panic_with_error!(&env, JobRegistryError::InvalidBudget);
+ }
+
+ client.require_auth();
+
+ post_job_with_id(
+ &env,
+ job_id,
+ client.clone(),
+ hash,
+ budget,
+ expires_at,
+ bid_deadline,
+ collateral_token.clone(),
+ collateral_amount,
+ );
+
+ // Lock collateral from client into this contract
+ if collateral_amount > 0 {
+ let token_client = token::Client::new(&env, &collateral_token);
+ token_client.transfer(&client, &env.current_contract_address(), &collateral_amount);
+ }
+
+ let next = job_id
+ .checked_add(1)
+ .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::Overflow));
+
+ env.storage().instance().set(&DataKey::NextJobId, &next);
+
+ job_id
+ }
+
+ /// Freelancer submits a bid.
+ /// Freelancer submits a bid, with optionally provided freelancer collateral.
+ pub fn submit_bid(
+ env: Env,
+ job_id: u64,
+ freelancer: Address,
+ proposal_hash: Bytes,
+ collateral_stroops: i128,
+ ) {
+ ensure_initialized(&env);
+
+ validate_hash(&env, &proposal_hash);
+ if collateral_stroops < 0 {
+ panic_with_error!(&env, JobRegistryError::InvalidCollateral);
+ }
+ freelancer.require_auth();
+
+ let key = DataKey::Job(job_id);
+
+ let job: JobRecord = env
+ .storage()
+ .persistent()
+ .get(&key)
+ .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound));
+
+ if job.status != JobStatus::Open {
+ panic_with_error!(&env, JobRegistryError::JobNotOpen);
+ }
+
+ if env.ledger().timestamp() > job.bid_deadline {
+ panic_with_error!(&env, JobRegistryError::BidWindowClosed);
+ }
+
+ if env.ledger().timestamp() >= job.expires_at {
+ panic_with_error!(&env, JobRegistryError::JobExpired);
+ }
+
+ if collateral_stroops < 0 {
+ panic_with_error!(&env, JobRegistryError::InvalidBudget);
+ }
+
+ let bids_key = DataKey::Bids(job_id);
+
+ let mut bids: Vec = env
+ .storage()
+ .persistent()
+ .get(&bids_key)
+ .unwrap_or(Vec::new(&env));
+
+ // Requirement [SC-REG-035]: Enforce strict single-bid constraint per freelancer on active jobs.
+ for bid in bids.iter() {
+ if bid.freelancer == freelancer {
+ panic_with_error!(&env, JobRegistryError::BidAlreadySubmitted);
+ }
+ }
+
+ let bid_count = read_bid_count(&env, job_id);
+ let next_count = bid_count
+ .checked_add(1)
+ .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::Overflow));
+ let bid = BidRecord {
+ freelancer: freelancer.clone(),
+ proposal_hash,
+ collateral_stroops,
+ });
+
+ env.storage().persistent().set(&bids_key, &bids);
+
+ log!(
+ &env,
+ "submit_bid: id {} freelancer {} collateral {}",
+ job_id,
+ freelancer,
+ collateral_stroops
+ );
+ env.events()
+ .publish((symbol_short!("bid"), job_id), freelancer);
+ }
+
+ /// Client accepts a bid, locking in the freelancer.
+ pub fn accept_bid(
+ env: Env,
+ job_id: u64,
+ client: Address,
+ freelancer: Address,
+ ) {
+ ensure_initialized(&env);
+
+ client.require_auth();
+
+ let key = DataKey::Job(job_id);
+
+ let mut job: JobRecord = env
+ .storage()
+ .persistent()
+ .get(&key)
+ .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound));
+
+ if job.status != JobStatus::Open {
+ panic_with_error!(&env, JobRegistryError::JobNotOpen);
+ }
+
+ if client != job.client {
+ panic_with_error!(&env, JobRegistryError::Unauthorized);
+ }
+
+ if env.ledger().timestamp() >= job.expires_at {
+ panic_with_error!(&env, JobRegistryError::JobExpired);
+ }
+
+ let bids: Vec = env
+ .storage()
+ .persistent()
+ .get(&DataKey::Bids(job_id))
+ .unwrap_or(Vec::new(&env));
+
+ let mut found = false;
+
+ for bid in bids.iter() {
+ if bid.freelancer == freelancer {
+ found = true;
+ break;
+ }
+ }
+
+ if !found {
+ panic_with_error!(&env, JobRegistryError::BidNotFound);
+ }
+
+ job.freelancer = Some(freelancer.clone());
+ job.status = JobStatus::Assigned;
+
+ env.storage().persistent().set(&key, &job);
+
+ env.events()
+ .publish((symbol_short!("accept"), job_id), freelancer);
+ }
+
+ pub fn refund_bid_collateral(
+ env: Env,
+ job_id: u64,
+ freelancer: Address,
+ ) {
+ ensure_initialized(&env);
+
+ freelancer.require_auth();
+
+ release_collateral(&env, job_id, freelancer, false);
+ }
+
+ pub fn slash_bid_collateral(
+ env: Env,
+ job_id: u64,
+ client: Address,
+ freelancer: Address,
+ ) {
+ ensure_initialized(&env);
+
+ client.require_auth();
+
+ let job: JobRecord = env
+ .storage()
+ .persistent()
+ .get(&DataKey::Job(job_id))
+ .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound));
+
+ if client != job.client {
+ panic_with_error!(&env, JobRegistryError::Unauthorized);
+ }
+
+ release_collateral(&env, job_id, freelancer, true);
+ }
+
+ /// Client completes a job, releasing locked client collateral to the freelancer.
+ pub fn complete_job(env: Env, job_id: u64, client: Address) {
+ ensure_initialized(&env);
+ client.require_auth();
+
+ let key = DataKey::Job(job_id);
+ let mut job: JobRecord = env
+ .storage()
+ .persistent()
+ .get(&key)
+ .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound));
+
+ if client != job.client {
+ panic_with_error!(&env, JobRegistryError::Unauthorized);
+ }
+
+ if job.status != JobStatus::DeliverableSubmitted {
+ panic_with_error!(&env, JobRegistryError::InvalidStateTransition);
+ }
+
+ job.status = JobStatus::Completed;
+
+ if job.collateral_locked && job.collateral_amount > 0 {
+ if let Some(ref freelancer) = job.freelancer {
+ let token_client = token::Client::new(&env, &job.collateral_token);
+ token_client.transfer(
+ &env.current_contract_address(),
+ freelancer,
+ &job.collateral_amount,
+ );
+ job.collateral_locked = false;
+ }
+ }
+
+ env.storage().persistent().set(&key, &job);
+
+ log!(&env, "complete_job: id {}", job_id);
+ env.events().publish((symbol_short!("complete"), job_id), ());
+ }
+
+ /// Client refunds their locked collateral if the job has expired without an accepted bid.
+ pub fn refund_collateral(env: Env, job_id: u64, client: Address) {
+ ensure_initialized(&env);
+ client.require_auth();
+
+ let key = DataKey::Job(job_id);
+ let mut job: JobRecord = env
+ .storage()
+ .persistent()
+ .get(&key)
+ .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound));
+
+ if client != job.client {
+ panic_with_error!(&env, JobRegistryError::Unauthorized);
+ }
+
+ let now = env.ledger().timestamp();
+ if job.status != JobStatus::Open || now <= job.bid_deadline {
+ panic_with_error!(&env, JobRegistryError::InvalidStateTransition);
+ }
+
+ if job.collateral_locked && job.collateral_amount > 0 {
+ let token_client = token::Client::new(&env, &job.collateral_token);
+ token_client.transfer(
+ &env.current_contract_address(),
+ &job.client,
+ &job.collateral_amount,
+ );
+ job.collateral_locked = false;
+ }
+
+ env.storage().persistent().set(&key, &job);
+
+ log!(&env, "refund_collateral: id {}", job_id);
+ env.events().publish((symbol_short!("refund"), job_id), ());
+ }
+
+ /// Client cancels an expired open job, returning client collateral and deleting bids list.
+ pub fn cancel_expired_job(
+ env: Env,
+ job_id: u64,
+ client: Address,
+ ) {
+ ensure_initialized(&env);
+
+ client.require_auth();
+
+ let key = DataKey::Job(job_id);
+
+ let mut job: JobRecord = env
+ .storage()
+ .persistent()
+ .get(&key)
+ .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound));
+
+ if job.status != JobStatus::Open {
+ panic_with_error!(&env, JobRegistryError::InvalidStateTransition);
+ }
+
+ if client != job.client {
+ panic_with_error!(&env, JobRegistryError::Unauthorized);
+ }
+
+ if env.ledger().timestamp() < job.expires_at {
+ panic_with_error!(&env, JobRegistryError::JobNotExpired);
+ }
+
+ job.status = JobStatus::Expired;
+
+ // Refund collateral if locked
+ if job.collateral_locked && job.collateral_amount > 0 {
+ let token_client = token::Client::new(&env, &job.collateral_token);
+ token_client.transfer(
+ &env.current_contract_address(),
+ &job.client,
+ &job.collateral_amount,
+ );
+ job.collateral_locked = false;
+ }
+
+ env.storage().persistent().set(&key, &job);
+ env.storage().persistent().remove(&DataKey::Bids(job_id));
+
+ env.events()
+ .publish((symbol_short!("expired"), job_id), client);
+ }
+
+ pub fn submit_deliverable(
+ env: Env,
+ job_id: u64,
+ freelancer: Address,
+ hash: Bytes,
+ ) {
+ ensure_initialized(&env);
+
+ validate_hash(&env, &hash);
+
+ freelancer.require_auth();
+
+ let key = DataKey::Job(job_id);
+
+ let mut job: JobRecord = env
+ .storage()
+ .persistent()
+ .get(&key)
+ .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound));
+
+ if job.status != JobStatus::Assigned {
+ panic_with_error!(&env, JobRegistryError::InvalidStateTransition);
+ }
+
+ if job.freelancer != Some(freelancer.clone()) {
+ panic_with_error!(&env, JobRegistryError::Unauthorized);
+ }
+
+ job.status = JobStatus::DeliverableSubmitted;
+
+ env.storage().persistent().set(&key, &job);
+
+ env.storage()
+ .persistent()
+ .set(&DataKey::Deliverable(job_id), &hash);
+
+ env.events()
+ .publish((symbol_short!("deliver"), job_id), freelancer);
+ }
+
+ pub fn mark_disputed(env: Env, job_id: u64) {
+ ensure_initialized(&env);
+
+ let admin = read_admin(&env);
+
+ admin.require_auth();
+
+ let key = DataKey::Job(job_id);
+
+ let mut job: JobRecord = env
+ .storage()
+ .persistent()
+ .get(&key)
+ .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound));
+
+ if job.status != JobStatus::Assigned
+ && job.status != JobStatus::DeliverableSubmitted
+ {
+ panic_with_error!(&env, JobRegistryError::InvalidStateTransition);
+ }
+
+ job.status = JobStatus::Disputed;
+
+ env.storage().persistent().set(&key, &job);
+ }
+
+ /// Requirement [SC-REG-025]: Enforce Collateral Slashing Logic during Bid Default Status.
+ ///
+ /// Triggered by the job creator (client) when the assigned freelancer has failed to
+ /// deliver or respond within the job's expiration window. This function:
+ ///
+ /// 1. **Validates authorization** — only the original job creator may call this.
+ /// 2. **Validates state** — the job must be in `Assigned` status (freelancer has been
+ /// selected via `accept_bid` but has not submitted a deliverable).
+ /// 3. **Validates expiration** — the on-chain ledger timestamp must be past `expires_at`,
+ /// confirming the freelancer has definitively defaulted.
+ /// 4. **Looks up the accepted bid** from the persistent `Bids(job_id)` map-like storage
+ /// array to retrieve the collateral amount deposited with the bid.
+ /// 5. **Computes the slashed amount** using safe `checked_mul` / `checked_div` arithmetic
+ /// (100% penalty expressed through 10_000 basis-point representation to preserve
+ /// integer precision without floating-point operations, avoiding overflow panics).
+ /// 6. **Transitions state** cleanly to `Defaulted` — a terminal status that prevents
+ /// any further state mutations on the job.
+ /// 7. **Emits an on-chain event** `("slash", job_id) → (freelancer, slashed_amount)` for
+ /// off-chain consumers (indexers, AI judge, reputation engine).
+ ///
+ /// # Returns
+ /// The slashed collateral amount in stroops (`i128`). The caller is responsible for
+ /// routing this amount through the escrow layer.
+ ///
+ /// # Errors
+ /// - `JobNotFound` — no persistent record exists for `job_id`.
+ /// - `Unauthorized` — caller is not the job creator.
+ /// - `InvalidStateTransition` — job is not in `Assigned` state.
+ /// - `JobNotExpired` — ledger timestamp is still before `expires_at`.
+ /// - `BidNotFound` — no bid record found for the assigned freelancer (should never
+ /// happen in a well-formed state, but guarded for safety).
+ /// - `Overflow` — collateral × penalty_bps exceeds `i128::MAX`.
+ pub fn enforce_default_slashing(env: Env, job_id: u64, client: Address) -> i128 {
+ ensure_initialized(&env);
+ client.require_auth();
+
+ let key = DataKey::Job(job_id);
+ let mut job: JobRecord = env
+ .storage()
+ .persistent()
+ .get(&key)
+ .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound));
+
+ // [SC-REG-025]: Strict ownership validation — only the original job creator (client)
+ // is authorised to trigger the default slashing flow. Third-party callers are
+ // explicitly rejected with `Unauthorized` (error code 9).
+ if client != job.client {
+ panic_with_error!(&env, JobRegistryError::Unauthorized);
+ }
+
+ // [SC-REG-025]: The job must be in `Assigned` state, meaning a freelancer was
+ // selected but has not yet delivered. Any other state (Open, Disputed, Defaulted,
+ // Completed, etc.) is an invalid transition and is rejected.
+ if job.status != JobStatus::Assigned {
+ panic_with_error!(&env, JobRegistryError::InvalidStateTransition);
+ }
+
+ // [SC-REG-025]: Expiration check — the ledger timestamp must exceed `expires_at`
+ // to confirm the freelancer is definitively in default. Calling before expiry is
+ // blocked (error code 17 = JobNotExpired) to prevent premature slashing.
+ let now = env.ledger().timestamp();
+ if now < job.expires_at {
+ panic_with_error!(&env, JobRegistryError::JobNotExpired);
+ }
+
+ // Retrieve the assigned freelancer address stored in the `JobRecord`.
+ // Safety: `job.freelancer` will always be `Some` when status is `Assigned`,
+ // but we guard with `Unauthorized` to satisfy the borrow checker and prevent
+ // undefined behaviour if state is somehow corrupted.
+ let freelancer = job.freelancer.clone().unwrap_or_else(|| {
+ panic_with_error!(&env, JobRegistryError::Unauthorized)
+ });
+
+ // [SC-REG-025]: Retrieve the accepted bid from the Job ID → Bids map-like
+ // persistent storage array to obtain the collateral amount locked at bid time.
+ let bids: Vec = env
+ .storage()
+ .persistent()
+ .get(&DataKey::Bids(job_id))
+ .unwrap_or(Vec::new(&env));
+
+ let mut collateral_stroops: i128 = 0;
+ let mut found = false;
+ for bid in bids.iter() {
+ if bid.freelancer == freelancer {
+ collateral_stroops = bid.collateral_stroops;
+ found = true;
+ break;
+ }
+ }
+
+ if !found {
+ // Defensive guard: this should be unreachable in a healthy state machine,
+ // because `accept_bid` always verifies the bid exists before transitioning.
+ panic_with_error!(&env, JobRegistryError::BidNotFound);
+ }
+
+ // [SC-REG-025]: Safe checked arithmetic for slashing calculation.
+ // We express 100% penalty as `collateral × 10_000 / 10_000` using basis-points
+ // arithmetic to keep future partial-slashing extensions simple while avoiding
+ // floating-point. `checked_mul` / `checked_div` protect against `i128` overflow.
+ let penalty_bps: i128 = 10_000; // 100% = 10_000 bps
+ let slashed_amount = collateral_stroops
+ .checked_mul(penalty_bps)
+ .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::Overflow))
+ .checked_div(10_000)
+ .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::Overflow));
+
+ // [SC-REG-025]: Clean state transition — `Defaulted` is a terminal status.
+ // After this point, no further state mutations (bid acceptance, deliverable
+ // submission, dispute) are permitted on this job.
+ job.status = JobStatus::Defaulted;
+ env.storage().persistent().set(&key, &job);
+
+ log!(
+ &env,
+ "enforce_default_slashing: id {} freelancer {} slashed {}",
+ job_id,
+ freelancer,
+ slashed_amount
+ );
+ // Emit slashing event for off-chain indexers, AI judge, and reputation system.
+ env.events()
+ .publish((symbol_short!("slash"), job_id), (freelancer, slashed_amount));
+
+ slashed_amount
+ }
+
+ pub fn get_job(env: Env, job_id: u64) -> JobRecord {
+ ensure_initialized(&env);
+
+ env.storage()
+ .persistent()
+ .get(&DataKey::Job(job_id))
+ .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound))
+ }
+
+ pub fn get_bids(env: Env, job_id: u64) -> Vec {
+ ensure_initialized(&env);
+
+ env.storage()
+ .persistent()
+ .get(&DataKey::Bids(job_id))
+ .unwrap_or(Vec::new(&env))
+ }
+
+ // Requirement [SC-REG-039]: Gas-efficient paginated getter avoids loading the full bids vector
+ // when only a window of records is needed. Callers supply an offset and a limit; the function
+ // returns at most `limit` entries starting at `offset`, clamping automatically at the end.
+ pub fn get_bids_page(env: Env, job_id: u64, offset: u32, limit: u32) -> Vec {
+ ensure_initialized(&env);
+ let all_bids: Vec = env
+ .storage()
+ .persistent()
+ .get(&DataKey::Bids(job_id))
+ .unwrap_or(Vec::new(&env));
+
+ let total = all_bids.len();
+ let start = offset.min(total);
+ let end = (start.saturating_add(limit)).min(total);
+
+ let mut page = Vec::new(&env);
+ for i in start..end {
+ page.push_back(all_bids.get_unchecked(i));
+ }
+ page
+ }
+
+ // Requirement [SC-REG-039]: Returns only the length of the bids vector without deserialising
+ // each entry, keeping the read cost proportional to one storage key lookup rather than O(n).
+ pub fn get_bids_count(env: Env, job_id: u64) -> u32 {
+ ensure_initialized(&env);
+ env.storage()
+ .persistent()
+ .get::<_, Vec>(&DataKey::Bids(job_id))
+ .map(|bids| bids.len())
+ .unwrap_or(0)
+ }
+
+ pub fn get_deliverable(env: Env, job_id: u64) -> Bytes {
+ ensure_initialized(&env);
+
+ env.storage()
+ .persistent()
+ .get(&DataKey::Deliverable(job_id))
+ .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::NoDeliverable))
+ }
+}
+
+fn ensure_initialized(env: &Env) {
+ if !env.storage().instance().has(&DataKey::Admin) {
+ panic_with_error!(env, JobRegistryError::NotInitialized);
+ }
+}
+
+fn read_admin(env: &Env) -> Address {
+ env.storage()
+ .instance()
+ .get(&DataKey::Admin)
+ .unwrap_or_else(|| panic_with_error!(env, JobRegistryError::NotInitialized))
+}
+
+fn read_next_job_id(env: &Env) -> u64 {
+ env.storage()
+ .instance()
+ .get(&DataKey::NextJobId)
+ .unwrap_or_else(|| panic_with_error!(env, JobRegistryError::NotInitialized))
+}
+
+fn validate_job_input(
+ env: &Env,
+ job_id: u64,
+ hash: &Bytes,
+ budget: i128,
+ expires_at: u64,
+ bid_deadline: u64,
+) {
+ if job_id == 0 {
+ panic_with_error!(env, JobRegistryError::InvalidJobId);
+ }
+ // Requirement [SC-REG-037]: Verify Budget Bounds against Contract Minimum and Maximum limits.
+ // Rejects dust amounts and unrealistically large values to prevent storage abuse.
+ if budget < MIN_BUDGET_STROOPS || budget > MAX_BUDGET_STROOPS {
+ panic_with_error!(env, JobRegistryError::InvalidBudget);
+ }
+
+ if bid_deadline <= env.ledger().timestamp() {
+ panic_with_error!(env, JobRegistryError::BidWindowClosed);
+ }
+
+ if bid_deadline >= expires_at {
+ panic_with_error!(env, JobRegistryError::InvalidExpiration);
+ }
+
+ validate_hash(env, hash);
+ validate_expiration(env, expires_at);
+}
+
+fn validate_expiration(env: &Env, expires_at: u64) {
+ let now = env.ledger().timestamp();
+
+ if expires_at == 0 || expires_at <= now {
+ panic_with_error!(env, JobRegistryError::InvalidExpiration);
+ }
+}
+
+fn validate_hash(env: &Env, hash: &Bytes) {
+ validate_ipfs_cid(env, hash);
+}
+
+fn validate_ipfs_cid(env: &Env, hash: &Bytes) {
+ let len = hash.len();
+ if len == 46 {
+ // Must be CIDv0 (Qm...)
+ let mut buf = [0u8; 46];
+ hash.copy_into_slice(&mut buf);
+ if buf[0] != b'Q' || buf[1] != b'm' {
+ panic_with_error!(env, JobRegistryError::InvalidHash);
+ }
+ for i in 2..46 {
+ if !is_valid_base58_char(buf[i]) {
+ panic_with_error!(env, JobRegistryError::InvalidHash);
+ }
+ }
+ } else if len == 59 {
+ // Must be CIDv1 (bafy...)
+ let mut buf = [0u8; 59];
+ hash.copy_into_slice(&mut buf);
+ if buf[0] != b'b' || buf[1] != b'a' || buf[2] != b'f' || buf[3] != b'y' {
+ panic_with_error!(env, JobRegistryError::InvalidHash);
+ }
+ for i in 4..59 {
+ if !is_valid_base32_char(buf[i]) {
+ panic_with_error!(env, JobRegistryError::InvalidHash);
+ }
+ }
+ } else {
+ panic_with_error!(env, JobRegistryError::InvalidHash);
+ }
+}
+
+fn read_job(env: &Env, job_id: u64) -> JobRecord {
+ env.storage()
+ .persistent()
+ .get(&DataKey::Job(job_id))
+ .unwrap_or_else(|| panic_with_error!(env, JobRegistryError::JobNotFound))
+}
+
+fn read_bid_count(env: &Env, job_id: u64) -> u32 {
+ env.storage()
+ .persistent()
+ .get(&DataKey::BidCount(job_id))
+ .unwrap_or(0u32)
+}
+
+fn read_bid_at(env: &Env, job_id: u64, index: u32) -> BidRecord {
+ env.storage()
+ .persistent()
+ .get(&DataKey::Bid(job_id, index))
+ .unwrap_or_else(|| panic_with_error!(env, JobRegistryError::BidIndexOutOfBounds))
+}
+
+fn post_job_with_id(
+ env: &Env,
+ job_id: u64,
+ client: Address,
+ hash: Bytes,
+ budget: i128,
+ expires_at: u64,
+ bid_deadline: u64,
+ collateral_token: Address,
+ collateral_amount: i128,
+) {
+ let key = DataKey::Job(job_id);
+
+ if env.storage().persistent().has(&key) {
+ panic_with_error!(env, JobRegistryError::JobAlreadyExists);
+ }
+
+ let job = JobRecord {
+ client,
+ freelancer: None,
+ metadata_hash: hash,
+ budget_stroops: budget,
+ expires_at,
+ status: JobStatus::Open,
+ bid_deadline,
+ collateral_token,
+ collateral_amount,
+ collateral_locked: collateral_amount > 0,
+ };
+
+ env.storage().persistent().set(&key, &job);
+
+ let bids: Vec = Vec::new(env);
+
+ env.storage()
+ .persistent()
+ .set(&DataKey::BidCount(job_id), &0u32);
+}
+
+fn release_collateral(env: &Env, job_id: u64, freelancer: Address, _slash: bool) {
+ let _job: JobRecord = env
+ .storage()
+ .persistent()
+ .get(&DataKey::Job(job_id))
+ .unwrap_or_else(|| panic_with_error!(env, JobRegistryError::JobNotFound));
+
+ let bids_key = DataKey::Bids(job_id);
+ let bids: Vec = env
+ .storage()
+ .persistent()
+ .get(&bids_key)
+ .unwrap_or_else(|| panic_with_error!(env, JobRegistryError::CollateralNotFound));
+
+ let mut updated_bids: Vec = Vec::new(env);
+ let mut found = false;
+
+ for bid in bids.iter() {
+ if bid.freelancer == freelancer {
+ found = true;
+ if bid.collateral_released {
+ panic_with_error!(env, JobRegistryError::CollateralAlreadyReleased);
+ }
+ let mut updated = bid.clone();
+ updated.collateral_released = true;
+ updated_bids.push_back(updated);
+ } else {
+ updated_bids.push_back(bid.clone());
+ }
+ }
+
+ if !found {
+ panic_with_error!(env, JobRegistryError::CollateralNotFound);
+ }
+
+ env.storage().persistent().set(&bids_key, &updated_bids);
+}
+
+fn release_collateral(env: &Env, job_id: u64, freelancer: Address, slash: bool) {
+ let bids_key = DataKey::Bids(job_id);
+ let mut bids: Vec = env
+ .storage()
+ .persistent()
+ .get(&bids_key)
+ .unwrap_or_else(|| panic_with_error!(env, JobRegistryError::BidNotFound));
+
+ let mut updated = false;
+ for i in 0..bids.len() {
+ let mut bid = bids.get(i).unwrap();
+ if bid.freelancer == freelancer {
+ if bid.collateral_released {
+ panic_with_error!(
+ env,
+ JobRegistryError::CollateralAlreadyReleased
+ );
+ }
+ bid.collateral_released = true;
+ bids.set(i, bid);
+ updated = true;
+ break;
+ }
+ }
+
+ if !updated {
+ panic_with_error!(env, JobRegistryError::BidNotFound);
+ }
+
+ env.storage().persistent().set(&bids_key, &bids);
+
+ if slash {
+ env.events().publish(
+ (symbol_short!("slash"), job_id),
+ freelancer,
+ );
+ } else {
+ env.events().publish(
+ (symbol_short!("release"), job_id),
+ freelancer,
+ );
+ }
+}
+
+fn release_collateral(env: &Env, job_id: u64, freelancer: Address, slash: bool) {
+ let bids_key = DataKey::Bids(job_id);
+ let mut bids: Vec = env
+ .storage()
+ .persistent()
+ .get(&bids_key)
+ .unwrap_or_else(|| panic_with_error!(env, JobRegistryError::BidNotFound));
+
+ let mut updated = false;
+ for i in 0..bids.len() {
+ let mut bid = bids.get(i).unwrap();
+ if bid.freelancer == freelancer {
+ if bid.collateral_released {
+ panic_with_error!(
+ env,
+ JobRegistryError::CollateralAlreadyReleased
+ );
+ }
+ bid.collateral_released = true;
+ bids.set(i, bid);
+ updated = true;
+ break;
+ }
+ }
+
+ if !updated {
+ panic_with_error!(env, JobRegistryError::BidNotFound);
+ }
+
+ env.storage().persistent().set(&bids_key, &bids);
+
+ if slash {
+ env.events().publish(
+ (symbol_short!("slash"), job_id),
+ freelancer,
+ );
+ } else {
+ env.events().publish(
+ (symbol_short!("release"), job_id),
+ freelancer,
+ );
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+ use soroban_sdk::testutils::{Address as _, Ledger as _};
+ use soroban_sdk::{Address, Bytes, Env};
+
+ fn setup() -> (
+ Env,
+ JobRegistryContractClient<'static>,
+ Address,
+ Address,
+ Address,
+ Address, // Mock Token
+ ) {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let client = Address::generate(&env);
+ let freelancer = Address::generate(&env);
+
+ let contract_id = env.register_contract(None, JobRegistryContract);
+ let cc = JobRegistryContractClient::new(&env, &contract_id);
+
+ (env, cc, admin, client, freelancer)
+ }
+
+ fn future_expires_at(env: &Env) -> u64 {
+ env.ledger().timestamp() + 30 * 24 * 60 * 60
+ }
+
+ fn default_bidding_deadline(env: &Env) -> u64 {
+ env.ledger().timestamp() + 30
+ }
+
+ const DEFAULT_COLLATERAL_STROOPS: i128 = 1_000;
+
+ #[test]
+ fn test_initialize_bootstraps_storage() {
+ let (_env, cc, admin, _, _) = setup();
+
+ cc.initialize(&admin);
+
+ assert!(cc.is_initialized());
+ assert_eq!(cc.get_admin(), admin);
+ assert_eq!(cc.get_next_job_id(), 1u64);
+ }
+
+ #[test]
+ #[should_panic]
+ fn test_double_initialize_panics() {
+ let (_env, cc, admin, _, _, _) = setup();
+
+ let (_env, cc, admin, _, _) = setup();
+
+ cc.initialize(&admin);
+ cc.initialize(&admin);
+ }
+
+ #[test]
+ #[should_panic]
+ fn test_post_job_before_initialize_panics() {
+ let (env, cc, _admin, client, _, token_addr) = setup();
+ let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &2000u64, &token_addr, &1000i128);
+ }
+
+ #[test]
+ fn test_post_job_auto_allocates_sequential_ids() {
+ let (env, cc, admin, client, _, token_addr) = setup();
+ cc.initialize(&admin);
+
+ let hash1 = Bytes::from_slice(&env, b"QmHash1");
+ let hash2 = Bytes::from_slice(&env, b"QmHash2");
+ let expires_at1 = future_expires_at(&env);
+ let expires_at2 = future_expires_at(&env);
+
+ let id1 = cc.post_job_auto(&client, &hash1, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at1);
+ let id2 = cc.post_job_auto(&client, &hash2, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at2);
+
+ assert_eq!(id1, 1u64);
+ assert_eq!(id2, 2u64);
+ assert_eq!(cc.get_next_job_id(), 3u64);
+ }
+
+ #[test]
+ fn test_post_job_with_explicit_id_updates_next_job_id() {
+ let (env, cc, admin, client, _, token_addr) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmHash");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&42u64, &client, &hash, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at);
+
+ assert_eq!(cc.get_next_job_id(), 43u64);
+ }
+
+ #[test]
+ #[should_panic]
+ fn test_invalid_budget_panics() {
+ let (env, cc, admin, client, _, token_addr) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmHash");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &0i128, &default_bidding_deadline(&env), &expires_at);
+ }
+
+ #[test]
+ #[should_panic]
+ fn test_empty_hash_panics() {
+ let (env, cc, admin, client, _, token_addr) = setup();
+ cc.initialize(&admin);
+
+ let empty = Bytes::from_slice(&env, b"");
+ env.ledger().set_timestamp(100);
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &empty, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at);
+ }
+
+ #[test]
+ fn test_full_lifecycle() {
+ let (env, cc, admin, client, freelancer, token_addr) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmSomeIPFSHash");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at);
+
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, JobStatus::Open);
+ assert_eq!(job.freelancer, None);
+
+ let proposal = Bytes::from_slice(&env, b"QmProposalHash");
+ cc.submit_bid(&1u64, &freelancer, &proposal, &1000i128);
+
+ let bids = cc.get_bids(&1u64);
+ assert_eq!(bids.len(), 1);
+ assert_eq!(bids.get(0).unwrap().collateral_stroops, 1000i128);
+
+ cc.accept_bid(&1u64, &client, &freelancer);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, JobStatus::Assigned);
+ assert_eq!(job.freelancer, Some(freelancer.clone()));
+
+ let deliverable = Bytes::from_slice(&env, b"QmDeliverableHash");
+ cc.submit_deliverable(&1u64, &freelancer, &deliverable);
+
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, JobStatus::DeliverableSubmitted);
+
+ let d = cc.get_deliverable(&1u64);
+ assert_eq!(d, deliverable);
+
+ cc.complete_job(&1u64, &client);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, JobStatus::Completed);
+ assert!(!job.collateral_locked);
+ assert_eq!(tc.balance(&freelancer), 1000);
+ }
+
+ #[test]
+ #[should_panic]
+ fn test_duplicate_bid_panics() {
+ let (env, cc, admin, client, freelancer, token_addr) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmHash");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at);
+
+ let proposal = Bytes::from_slice(&env, b"QmProposal");
+ cc.submit_bid(&1u64, &freelancer, &proposal, &500i128);
+ cc.submit_bid(&1u64, &freelancer, &proposal, &500i128);
+ }
+
+ #[test]
+ #[should_panic]
+ fn test_accept_without_matching_bid_panics() {
+ let (env, cc, admin, client, freelancer, token_addr) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmHash");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at);
+
+ cc.accept_bid(&1u64, &client, &freelancer);
+ }
+
+ #[test]
+ fn test_mark_disputed_from_assigned() {
+ let (env, cc, admin, client, freelancer, token_addr) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmHash");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at);
+
+ let proposal = Bytes::from_slice(&env, b"QmProposal");
+ cc.submit_bid(&1u64, &freelancer, &proposal, &0i128);
+ cc.accept_bid(&1u64, &client, &freelancer);
+
+ cc.mark_disputed(&1u64);
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, JobStatus::Disputed);
+ }
+
+ #[test]
+ #[should_panic]
+ fn test_mark_disputed_from_open_panics() {
+ let (env, cc, admin, client, _, token_addr) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f");
+ env.ledger().set_timestamp(100);
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &1000i128);
+
+ cc.mark_disputed(&1u64);
+ }
+
+ #[test]
+ #[should_panic]
+ fn test_get_deliverable_without_submission_panics() {
+ let (env, cc, admin, client, _, token_addr) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmHash");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at);
+
+ env.ledger().set_timestamp(expires_at + 1);
+
+ let proposal = Bytes::from_slice(&env, b"QmProposal");
+ cc.submit_bid(&1u64, &freelancer, &proposal, &100i128);
+ }
+
+ #[test]
+ #[should_panic]
+ fn test_invalid_cidv0_prefix_panics() {
+ let (env, cc, admin, client, _, token_addr) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmHash");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at);
+
+ let hash = Bytes::from_slice(&env, b"bafxbeigdyrzt5sbi7ee3xjc3vyqptsyfuwwspw2gx6pqdfaaaaabbbbbccccc");
+ env.ledger().set_timestamp(100);
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &1000i128);
+ }
+
+ // --- SC-REG-037: Budget Bounds Tests ---
+
+ #[test]
+ fn test_budget_at_minimum_succeeds() {
+ let (env, cc, admin, client, _, token_addr) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmHash");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at);
+
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.budget_stroops, MIN_BUDGET_STROOPS);
+ }
+
+ #[test]
+ fn test_budget_at_maximum_succeeds() {
+ let (env, cc, admin, client, _, token_addr) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &MAX_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at);
+
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.budget_stroops, MAX_BUDGET_STROOPS);
+ }
+
+ #[test]
+ #[should_panic]
+ fn test_budget_below_minimum_panics() {
+ let (env, cc, admin, client, _, token_addr) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmHash");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &(MIN_BUDGET_STROOPS - 1), &default_bidding_deadline(&env), &expires_at);
+ }
+
+ #[test]
+ #[should_panic]
+ fn test_budget_above_maximum_panics() {
+ let (env, cc, admin, client, _) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmHash");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &(MAX_BUDGET_STROOPS + 1), &default_bidding_deadline(&env), &expires_at);
+ }
+
+ #[test]
+ #[should_panic]
+ fn test_zero_budget_still_panics() {
+ let (env, cc, admin, client, _) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmHash");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &0i128, &default_bidding_deadline(&env), &expires_at);
+ }
+
+ let proposal = Bytes::from_slice(&env, b"QmProposal");
+ cc.submit_bid(&1u64, &freelancer, &proposal, &200i128);
+
+ #[test]
+ fn test_get_bids_count_empty_returns_zero() {
+ let (env, cc, admin, client, _, token_addr) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at);
+
+ assert_eq!(cc.get_bids_count(&1u64), 0u32);
+ }
+
+ #[test]
+ fn test_get_bids_count_after_submissions() {
+ let (env, cc, admin, client, _, token_addr) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at);
+
+ for _ in 0..3u32 {
+ let freelancer = Address::generate(&env);
+ let proposal = Bytes::from_slice(&env, b"QmProposal");
+ cc.submit_bid(&1u64, &freelancer, &proposal, &DEFAULT_COLLATERAL_STROOPS);
+ }
+
+ assert_eq!(cc.get_bids_count(&1u64), 3u32);
+ }
+
+ #[test]
+ fn test_get_bids_page_first_window() {
+ let (env, cc, admin, client, _, token_addr) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at);
+
+ for _ in 0..5u32 {
+ let freelancer = Address::generate(&env);
+ let proposal = Bytes::from_slice(&env, b"QmProposal");
+ cc.submit_bid(&1u64, &freelancer, &proposal, &DEFAULT_COLLATERAL_STROOPS);
+ }
+
+ let page = cc.get_bids_page(&1u64, &0u32, &3u32);
+ assert_eq!(page.len(), 3u32);
+ }
+
+ #[test]
+ fn test_get_bids_page_second_window() {
+ let (env, cc, admin, client, _, token_addr) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at);
+
+ for _ in 0..5u32 {
+ let freelancer = Address::generate(&env);
+ let proposal = Bytes::from_slice(&env, b"QmProposal");
+ cc.submit_bid(&1u64, &freelancer, &proposal, &DEFAULT_COLLATERAL_STROOPS);
+ }
+
+ let page = cc.get_bids_page(&1u64, &3u32, &3u32);
+ assert_eq!(page.len(), 2u32);
+ }
+
+ #[test]
+ fn test_get_bids_page_offset_beyond_end_returns_empty() {
+ let (env, cc, admin, client, _, token_addr) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at);
+
+ for _ in 0..3u32 {
+ let freelancer = Address::generate(&env);
+ let proposal = Bytes::from_slice(&env, b"QmProposal");
+ cc.submit_bid(&1u64, &freelancer, &proposal, &DEFAULT_COLLATERAL_STROOPS);
+ }
+
+ let page = cc.get_bids_page(&1u64, &10u32, &5u32);
+ assert_eq!(page.len(), 0u32);
+ }
+
+ #[test]
+ fn test_enforce_default_slashing_success() {
+ let (env, cc, admin, client, freelancer) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmHash");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at);
+
+ let proposal = Bytes::from_slice(&env, b"QmProposal");
+ cc.submit_bid(&1u64, &freelancer, &proposal, &12345i128);
+
+ cc.accept_bid(&1u64, &client, &freelancer);
+
+ let job = cc.get_job(&1u64);
+ assert_eq!(job.status, JobStatus::Assigned);
+
+ // Advance ledger timestamp to default threshold
+ env.ledger().set_timestamp(expires_at + 1);
+
+ // Client triggers default and gets 100% of collateral slashed
+ let slashed = cc.enforce_default_slashing(&1u64, &client);
+ assert_eq!(slashed, 12345i128);
+
+ let updated_job = cc.get_job(&1u64);
+ assert_eq!(updated_job.status, JobStatus::Defaulted);
+ }
+
+ #[test]
+ #[should_panic]
+ fn test_enforce_default_slashing_before_expiration_panics() {
+ let (env, cc, admin, client, freelancer) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmHash");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at);
+
+ let proposal = Bytes::from_slice(&env, b"QmProposal");
+ cc.submit_bid(&1u64, &freelancer, &proposal, &100i128);
+ cc.accept_bid(&1u64, &client, &freelancer);
+
+ // Calling enforce default slashing before job expires must fail
+ cc.enforce_default_slashing(&1u64, &client);
+ }
+
+ #[test]
+ #[should_panic]
+ fn test_enforce_default_slashing_unauthorized_panics() {
+ let (env, cc, admin, client, freelancer) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmHash");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at);
+
+ let proposal = Bytes::from_slice(&env, b"QmProposal");
+ cc.submit_bid(&1u64, &freelancer, &proposal, &200i128);
+ cc.accept_bid(&1u64, &client, &freelancer);
+
+ env.ledger().set_timestamp(expires_at + 1);
+
+ // A third-party address (represented by freelancer here) attempts to default
+ cc.enforce_default_slashing(&1u64, &freelancer);
+ }
+
+ #[test]
+ #[should_panic]
+ fn test_enforce_default_slashing_invalid_state_panics() {
+ let (env, cc, admin, client, freelancer) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmHash");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at);
+
+ let proposal = Bytes::from_slice(&env, b"QmProposal");
+ cc.submit_bid(&1u64, &freelancer, &proposal, &300i128);
+
+ env.ledger().set_timestamp(expires_at + 1);
+
+ // The job status is Open (not Assigned). Enforce default slashing should fail.
+ cc.enforce_default_slashing(&1u64, &client);
+ }
+
+ #[test]
+ #[should_panic]
+ fn test_submit_bid_negative_collateral_panics() {
+ let (env, cc, admin, client, freelancer) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmHash");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at);
+
+ let proposal = Bytes::from_slice(&env, b"QmProposal");
+ // Bid with negative collateral must panic
+ cc.submit_bid(&1u64, &freelancer, &proposal, &-100i128);
+ }
+
+ // ──────────────────────────────────────────────────────────────────────────────
+ // SC-REG-025: Additional collateral slashing edge-case tests
+ // ──────────────────────────────────────────────────────────────────────────────
+
+ /// [SC-REG-025] A freelancer who bid zero collateral results in a slashed amount
+ /// of 0 (not a panic). The job still transitions cleanly to `Defaulted`.
+ #[test]
+ fn test_enforce_default_slashing_zero_collateral_returns_zero() {
+ let (env, cc, admin, client, freelancer) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmIPFSZero");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at);
+
+ // Freelancer bids with zero collateral (allowed)
+ let proposal = Bytes::from_slice(&env, b"QmProposalZero");
+ cc.submit_bid(&1u64, &freelancer, &proposal, &0i128);
+ cc.accept_bid(&1u64, &client, &freelancer);
+
+ // Advance past expiry
+ env.ledger().set_timestamp(expires_at + 1);
+
+ let slashed = cc.enforce_default_slashing(&1u64, &client);
+ // 0 collateral → 0 slash; no overflow, no panic
+ assert_eq!(slashed, 0i128);
+ // State must still be Defaulted
+ assert_eq!(cc.get_job(&1u64).status, JobStatus::Defaulted);
+ }
+
+ /// [SC-REG-025] Verifies the job transitions to the terminal `Defaulted` status
+ /// and cannot be slashed a second time (InvalidStateTransition on retry).
+ #[test]
+ #[should_panic]
+ fn test_enforce_default_slashing_double_slash_panics() {
+ let (env, cc, admin, client, freelancer) = setup();
+ cc.initialize(&admin);
+
+ let hash = Bytes::from_slice(&env, b"QmIPFSDouble");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at);
+
+ let proposal = Bytes::from_slice(&env, b"QmProposalDouble");
+ cc.submit_bid(&1u64, &freelancer, &proposal, &500i128);
+ cc.accept_bid(&1u64, &client, &freelancer);
+
+ env.ledger().set_timestamp(expires_at + 1);
+ cc.enforce_default_slashing(&1u64, &client);
+ // Second call must panic: job is now Defaulted, not Assigned
+ cc.enforce_default_slashing(&1u64, &client);
+ }
+
+ /// [SC-REG-025] When multiple freelancers bid, only the accepted one's collateral
+ /// is used for the slashing calculation. Others are unaffected.
+ #[test]
+ fn test_enforce_default_slashing_multiple_bids_only_accepted_slashed() {
+ let (env, cc, admin, client, freelancer) = setup();
+ cc.initialize(&admin);
+
+ // Generate a second bidder
+ let bidder2 = Address::generate(&env);
+
+ let hash = Bytes::from_slice(&env, b"QmIPFSMulti");
+ let expires_at = future_expires_at(&env);
+ cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at);
+
+ // Two bids with different collateral amounts
+ let p1 = Bytes::from_slice(&env, b"QmProposal1");
+ cc.submit_bid(&1u64, &freelancer, &p1, &1000i128); // accepted freelancer
+ let p2 = Bytes::from_slice(&env, b"QmProposal2");
+ cc.submit_bid(&1u64, &bidder2, &p2, &9999i128); // competing bidder
+
+ // Accept the first freelancer's bid
+ cc.accept_bid(&1u64, &client, &freelancer);
+
+ env.ledger().set_timestamp(expires_at + 1);
+
+ // Slashed amount must equal only the accepted bid's collateral (1000)
+ let slashed = cc.enforce_default_slashing(&1u64, &client);
+ assert_eq!(slashed, 1000i128);
+ assert_eq!(cc.get_job(&1u64).status, JobStatus::Defaulted);
+ }
+}
+
+#388 [SC-REG-034] Job Registry and Proposal Scaling Validation - Step 34
+Repo Avatar
+DXmakers/lance
+Implement Dynamic Service Fee Adjustments for Job Postings
+Category: Smart Contract: Job Registry & Bidding
+Task ID: SC-REG-034
+Description
+This issue is dedicated to the technical design, implementation, and rigorous auditing of 'Implement Dynamic Service Fee Adjustments for Job Postings' inside the Lance marketplace ecosystem, specifically focusing on the Smart Contract: Job Registry & Bidding component. As a Soroban smart contract task, the contributor must design robust instance or persistent storage allocations, ensure safe checked math operations, and write high-coverage unit tests within the Rust cargo test harness. The compiled WASM footprint must fit comfortably within standard block boundaries. Ensure that your implementation strictly adheres to the project's architectural guidelines, features self-documenting code with comprehensive inline annotations, and provides solid verification proofs. Any modifications to state variables must undergo strict validation before commits.
+
+Requirements
+Scaffold and write the contract logic in contracts/job_registry/src/lib.rs for Implement Dynamic Service Fee Adjustments for Job Postings.
+Compress heavy text strings into compact IPFS Content Identifiers (CIDs) before storing on-chain.
+Design clean mappings from Job IDs to dynamic bid structures utilizing map-like storage arrays.
+Implement strict ownership validation so that only the job creator can accept proposals.
+Acceptance Criteria
+Contract successfully compiles and fits within the standard Soroban WASM size limits.
+Registry state transitions cleanly to 'Assigned' once a bid is successfully accepted.
+Out-of-bounds inputs or late bid submissions are gracefully blocked and return specific error codes.
use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, Vec, Bytes};
/* -----------------------------------------------------------------
diff --git a/contracts/reputation/src/lib.rs b/contracts/reputation/src/lib.rs
index b1e6bc9d..39302447 100644
--- a/contracts/reputation/src/lib.rs
+++ b/contracts/reputation/src/lib.rs
@@ -1,4 +1,4 @@
-#![no_std]
+#![no_std]
use soroban_sdk::{
contract, contractimpl, contracttype, Address, Bytes, Env, IntoVal, Symbol, Vec,
@@ -291,12 +291,58 @@ impl ReputationContract {
profile.review_count = profile.review_count.saturating_add(1);
profile.completed_jobs = profile.completed_jobs.saturating_add(1);
+ let is_blacklisted = profile.is_blacklisted;
+ let metrics = Self::role_metrics_mut(&mut profile, &role);
+ let previous_score = metrics.score;
+ metrics.completed_jobs = metrics.completed_jobs.saturating_add(1);
+ Self::apply_manual_delta(metrics, delta, is_blacklisted);
+ let new_score = metrics.score;
+ let total_jobs = metrics.completed_jobs;
+ let badge_level = metrics.badge_level;
+
+ storage::write_profile(&env, &address, &profile);
+ env.events().publish(
+ ("reputation", "ScoreAdjusted"),
+ ScoreAdjustedEvent {
+ address,
+ role,
+ delta: new_score.saturating_sub(previous_score),
+ new_score,
+ total_jobs,
+ badge_level,
+ adjusted_at: env.ledger().timestamp(),
+ },
// Calculate new average rating using fixed-point arithmetic
profile.avg_rating = fixed_point::calculate_avg_rating(
profile.total_review_points,
profile.review_count,
);
+ let mut profile = storage::read_profile_or_default(&env, &address);
+ if profile.is_blacklisted {
+ soroban_sdk::panic_with_error!(&env, ReputationError::Blacklisted);
+ }
+
+ let is_blacklisted = profile.is_blacklisted;
+ let metrics = Self::role_metrics_mut(&mut profile, &role);
+ let previous_score = metrics.score;
+ Self::apply_role_decay(&env, metrics, Self::SLASH_DECAY_BPS, is_blacklisted);
+ let new_score = metrics.score;
+ let total_jobs = metrics.completed_jobs;
+ let badge_level = metrics.badge_level;
+
+ storage::write_profile(&env, &address, &profile);
+ env.events().publish(
+ ("reputation", "ScoreAdjusted"),
+ ScoreAdjustedEvent {
+ address,
+ role,
+ delta: new_score.saturating_sub(previous_score),
+ new_score,
+ total_jobs,
+ badge_level,
+ adjusted_at: env.ledger().timestamp(),
+ },
// Update reputation score based on average rating
// Scale: 1->2000 BPS, 2->4000 BPS, ..., 5->10000 BPS
let rating_bps = (profile.avg_rating * 2) / 1000; // Convert from 1000-5000 scale to 2000-10000 BPS
@@ -426,6 +472,65 @@ impl ReputationContract {
metrics.push_back(rep.reviews as i128);
metrics
}
+
+ pub fn query_reputation(env: Env, address: Address) -> ReputationView {
+ Self::bump_instance_ttl(&env);
+ let profile = storage::read_profile_or_default(&env, &address);
+ let client = Self::score_from_profile(&address, Role::Client, &profile);
+ let freelancer = Self::score_from_profile(&address, Role::Freelancer, &profile);
+ ReputationView {
+ address,
+ client,
+ freelancer,
+ is_blacklisted: profile.is_blacklisted,
+ }
+ }
+
+ pub fn get_badge(env: Env, address: Address, role: Role) -> BadgeLevel {
+ Self::bump_instance_ttl(&env);
+ let profile = storage::read_profile_or_default(&env, &address);
+ let score = Self::role_metrics(&profile, &role).score;
+ BadgeLevel::from_score(score)
+ }
+
+ pub fn set_badge_metadata(
+ env: Env,
+ admin: Address,
+ address: Address,
+ tier: BadgeTier,
+ uri: Bytes,
+ ) {
+ Self::require_admin(&env, &admin);
+ let mut profile = storage::read_profile_or_default(&env, &address);
+
+ let mut updated = false;
+ for i in 0..profile.badge_metadata.len() {
+ if let Some(mut entry) = profile.badge_metadata.get(i) {
+ if entry.tier == tier {
+ entry.uri = uri.clone();
+ profile.badge_metadata.set(i, entry);
+ updated = true;
+ break;
+ }
+ }
+ }
+ if !updated {
+ profile.badge_metadata.push_back(BadgeMetadataEntry { tier, uri });
+ }
+ storage::write_profile(&env, &address, &profile);
+ Self::bump_instance_ttl(&env);
+ }
+
+ pub fn get_badge_metadata(env: Env, address: Address, tier: BadgeTier) -> Option {
+ Self::bump_instance_ttl(&env);
+ let profile = storage::read_profile_or_default(&env, &address);
+ for entry in profile.badge_metadata.iter() {
+ if entry.tier == tier {
+ return Some(entry.uri);
+ }
+ }
+ None
+ }
}
#[cfg(test)]
@@ -649,6 +754,11 @@ mod test {
let client = ReputationContractClient::new(&env, &contract_id);
client.initialize(&admin);
+ let uri = Bytes::from_slice(&env, b"ipfs://QmBronzeBadge");
+ client.set_badge_metadata(&admin, &addr, &BadgeTier::Bronze, &uri);
+
+ let result = client.get_badge_metadata(&addr, &BadgeTier::Bronze);
+ assert_eq!(result, Some(uri));
client.slash(
&address,
&Role::Client,
@@ -673,6 +783,10 @@ mod test {
// Initialize without mocking all auths for this test
env.mock_all_auths();
client.initialize(&admin);
+ let uri_v1 = Bytes::from_slice(&env, b"ipfs://QmSilverV1");
+ let uri_v2 = Bytes::from_slice(&env, b"ipfs://QmSilverV2");
+ client.set_badge_metadata(&admin, &addr, &BadgeTier::Silver, &uri_v1);
+ client.set_badge_metadata(&admin, &addr, &BadgeTier::Silver, &uri_v2);
env.mock_all_auths_allow_last();
// Try to submit rating without proper job context
@@ -688,6 +802,18 @@ mod test {
}
#[test]
+ fn test_multiple_tiers_stored_independently() {
+ let env = Env::default();
+ env.mock_all_auths();
+ let admin = Address::generate(&env);
+ let addr = Address::generate(&env);
+ let cid = env.register_contract(None, ReputationContract);
+ let client = ReputationContractClient::new(&env, &cid);
+ client.initialize(&admin);
+ let bronze_uri = Bytes::from_slice(&env, b"ipfs://Bronze");
+ let gold_uri = Bytes::from_slice(&env, b"ipfs://Gold");
+ client.set_badge_metadata(&admin, &addr, &BadgeTier::Bronze, &bronze_uri);
+ client.set_badge_metadata(&admin, &addr, &BadgeTier::Gold, &gold_uri);
fn test_fixed_point_arithmetic() {
// Test fixed-point arithmetic for safe rating calculations
diff --git a/contracts/reputation/src/profile.rs b/contracts/reputation/src/profile.rs
index 503bf866..5e17f630 100644
--- a/contracts/reputation/src/profile.rs
+++ b/contracts/reputation/src/profile.rs
@@ -106,6 +106,7 @@ pub struct Profile {
}
impl Profile {
+ pub fn new(env: &soroban_sdk::Env, address: Address) -> Self {
pub fn new(env: &Env, address: Address) -> Self {
Self {
address,
diff --git a/main_lib.rs b/main_lib.rs
new file mode 100644
index 00000000..4d7fd7eb
Binary files /dev/null and b/main_lib.rs differ