From 85a54ceb56358d01e729b8cc0dd49897057b1662 Mon Sep 17 00:00:00 2001 From: Timi Date: Tue, 26 May 2026 22:30:34 +0100 Subject: [PATCH 1/2] feat: implement client migration functionality with proposal and acceptance logic --- contracts/escrow/src/lib.rs | 3 + contracts/escrow/src/migration.rs | 131 ++++++ contracts/escrow/src/test/client_migration.rs | 436 ++++++------------ contracts/escrow/src/test/mod.rs | 3 +- docs/escrow/migration.md | 72 ++- 5 files changed, 321 insertions(+), 324 deletions(-) create mode 100644 contracts/escrow/src/migration.rs diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 696afbf..7ec67f0 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -111,6 +111,9 @@ pub struct MainnetReadinessInfo { #[contract] pub struct Escrow; +mod migration; +pub use migration::PendingClientMigration; + #[contractimpl] impl Escrow { // ─── Guard ─────────────────────────────────────────────────────────────── diff --git a/contracts/escrow/src/migration.rs b/contracts/escrow/src/migration.rs new file mode 100644 index 0000000..2ca6fbe --- /dev/null +++ b/contracts/escrow/src/migration.rs @@ -0,0 +1,131 @@ +use crate::{ContractStatus, DataKey, Escrow, EscrowArgs, EscrowClient, EscrowContractData, EscrowError}; +use crate::ttl::{read_if_live, remove_transient, store_with_ttl, PENDING_MIGRATION_TTL_LEDGERS}; +use soroban_sdk::{contractimpl, contracttype, Address, Env, Symbol}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PendingClientMigration { + pub current_client: Address, + pub proposed_client: Address, + pub requested_at_ledger: u32, + pub expires_at_ledger: u32, +} + +#[contractimpl] +impl Escrow { + fn pending_migration_key(contract_id: u32) -> DataKey { + DataKey::PendingClientMigration(contract_id) + } + + fn load_contract(env: &Env, contract_id: u32) -> EscrowContractData { + env.storage() + .persistent() + .get::<_, EscrowContractData>(&DataKey::Contract(contract_id)) + .unwrap_or_else(|| env.panic_with_error(EscrowError::ContractNotFound)) + } + + fn require_migration_allowed(env: &Env, status: ContractStatus) { + if matches!( + status, + ContractStatus::Completed + | ContractStatus::Cancelled + | ContractStatus::Refunded + | ContractStatus::Disputed + ) { + env.panic_with_error(EscrowError::InvalidStatusTransition); + } + } + + fn pending_migration_exists(env: &Env, contract_id: u32) -> bool { + read_if_live::<_, PendingClientMigration>(env, &Self::pending_migration_key(contract_id)).is_some() + } + + /// Propose a client migration for an existing contract. + /// + /// The current client must authorize the call. The proposed client address + /// must not be the freelancer or the current client. The pending migration + /// is stored in temporary storage with TTL. + pub fn propose_client_migration( + env: Env, + contract_id: u32, + current_client: Address, + new_client: Address, + ) -> bool { + Self::require_not_paused(&env); + current_client.require_auth(); + + let contract = Self::load_contract(&env, contract_id); + if current_client != contract.client { + env.panic_with_error(EscrowError::UnauthorizedRole); + } + if new_client == contract.client || new_client == contract.freelancer { + env.panic_with_error(EscrowError::InvalidParticipant); + } + Self::require_migration_allowed(&env, contract.status); + if Self::pending_migration_exists(&env, contract_id) { + env.panic_with_error(EscrowError::InvalidState); + } + + let requested_at = env.ledger().sequence(); + let expires_at = requested_at.saturating_add(PENDING_MIGRATION_TTL_LEDGERS); + let pending = PendingClientMigration { + current_client: current_client.clone(), + proposed_client: new_client.clone(), + requested_at_ledger: requested_at, + expires_at_ledger: expires_at, + }; + store_with_ttl( + &env, + &Self::pending_migration_key(contract_id), + &pending, + PENDING_MIGRATION_TTL_LEDGERS, + ); + + env.events().publish( + (Symbol::new(&env, "client_migration_proposed"), contract_id), + (current_client, new_client, requested_at), + ); + true + } + + /// Accept a live pending client migration and update the contract. + pub fn accept_client_migration(env: Env, contract_id: u32, new_client: Address) -> bool { + Self::require_not_paused(&env); + new_client.require_auth(); + + let mut contract = Self::load_contract(&env, contract_id); + Self::require_migration_allowed(&env, contract.status); + + let key = Self::pending_migration_key(contract_id); + let pending: PendingClientMigration = read_if_live(&env, &key) + .unwrap_or_else(|| env.panic_with_error(EscrowError::InvalidState)); + + if pending.proposed_client != new_client { + env.panic_with_error(EscrowError::UnauthorizedRole); + } + if pending.current_client != contract.client { + env.panic_with_error(EscrowError::InvalidState); + } + + contract.client = new_client.clone(); + env.storage().persistent().set(&DataKey::Contract(contract_id), &contract); + remove_transient(&env, &key); + + env.events().publish( + (Symbol::new(&env, "client_migration_accepted"), contract_id), + (pending.current_client, new_client, env.ledger().timestamp()), + ); + true + } + + /// Return true if a live pending client migration exists. + pub fn has_pending_client_migration(env: Env, contract_id: u32) -> bool { + Self::pending_migration_exists(&env, contract_id) + } + + /// Return the live pending client migration record. + pub fn get_pending_client_migration(env: Env, contract_id: u32) -> PendingClientMigration { + read_if_live(&env, &Self::pending_migration_key(contract_id)) + .unwrap_or_else(|| env.panic_with_error(EscrowError::InvalidState)) + } +} diff --git a/contracts/escrow/src/test/client_migration.rs b/contracts/escrow/src/test/client_migration.rs index 15adb77..00994bf 100644 --- a/contracts/escrow/src/test/client_migration.rs +++ b/contracts/escrow/src/test/client_migration.rs @@ -1,325 +1,161 @@ -use crate::{ContractStatus, EscrowError}; +#![cfg(test)] -use super::{ - assert_panics, create_sample_contract, full_funding_amount, register_escrow, setup_env, -}; +use crate::{Escrow, EscrowClient, EscrowError, PendingClientMigration, PENDING_MIGRATION_TTL_LEDGERS, types::DepositMode}; +use soroban_sdk::{testutils::Address as _, testutils::Ledger as _, testutils::LedgerInfo, vec, Address, Env}; -#[test] -fn migration_requires_acceptance_before_finalization() { - let env = setup_env(); - let client = register_escrow(&env); - let (parties, contract_id) = create_sample_contract(&env, &client); - - assert!(client.request_client_migration(&contract_id, &parties.replacement_client)); - - let pending = client.get_pending_client_migration(&contract_id); - assert_eq!(pending.current_client, parties.client); - assert_eq!(pending.proposed_client, parties.replacement_client); - assert!(!pending.proposed_client_confirmed); - assert!(client.has_pending_client_migration(&contract_id)); - - assert_panics(|| { - client.finalize_client_migration(&contract_id); - }); - - assert!(client.confirm_client_migration(&contract_id)); - let confirmed = client.get_pending_client_migration(&contract_id); - assert!(confirmed.proposed_client_confirmed); - - assert!(client.finalize_client_migration(&contract_id)); - let contract = client.get_contract(&contract_id); - assert_eq!(contract.client, parties.replacement_client); - assert_eq!(contract.status, ContractStatus::Created); - assert!(!client.has_pending_client_migration(&contract_id)); -} - -#[test] -fn confirmed_migration_transfers_client_authority() { - let env = setup_env(); - let client = register_escrow(&env); - let (parties, contract_id) = create_sample_contract(&env, &client); - - assert!(client.request_client_migration(&contract_id, &parties.replacement_client)); - assert!(client.confirm_client_migration(&contract_id)); - assert!(client.finalize_client_migration(&contract_id)); - - assert!(client.deposit_funds(&contract_id, &full_funding_amount())); - let auths = env.auths(); - - assert_eq!(auths.len(), 1); - assert_eq!(auths[0].0, parties.replacement_client); - assert!(auths[0].1.sub_invocations.is_empty()); -} +use super::{assert_contract_error, default_milestones, register_client}; #[test] -fn cancel_client_migration_clears_pending_state() { - let env = setup_env(); - let client = register_escrow(&env); - let (parties, contract_id) = create_sample_contract(&env, &client); - - assert!(client.request_client_migration(&contract_id, &parties.replacement_client)); - assert!(client.cancel_client_migration(&contract_id)); - assert!(!client.has_pending_client_migration(&contract_id)); - assert_panics(|| { - client.get_pending_client_migration(&contract_id); - }); +fn propose_and_accept_client_migration() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(Escrow, ()); + let client = EscrowClient::new(&env, &contract_id); + + let client_addr = Address::generate(&env); + let freelancer_addr = Address::generate(&env); + let new_client = Address::generate(&env); + + let milestones = default_milestones(&env); + let id = client.create_contract( + &client_addr, + &freelancer_addr, + &milestones, + &DepositMode::ExactTotal, + ); + + assert!(client.propose_client_migration(&id, &client_addr, &new_client)); + assert!(client.has_pending_client_migration(&id)); + + let pending: PendingClientMigration = client.get_pending_client_migration(&id); + assert_eq!(pending.current_client, client_addr); + assert_eq!(pending.proposed_client, new_client); + assert!(pending.expires_at_ledger > env.ledger().sequence()); + + assert!(client.accept_client_migration(&id, &new_client)); + + let contract = client.get_contract(&id); + assert_eq!(contract.client, new_client); + assert!(!client.has_pending_client_migration(&id)); } #[test] -fn unauthorized_migration_proposal_fails() { - let env = setup_env(); - let client = register_escrow(&env); - let (parties, contract_id) = create_sample_contract(&env, &client); - - let unauthorized_party = super::Address::generate(&env); - - // Unauthorized party cannot propose migration - assert_panics(|| { - client.request_client_migration(&contract_id, &unauthorized_party); - }); +fn unauthorized_accept_client_migration_fails() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(Escrow, ()); + let client = EscrowClient::new(&env, &contract_id); + + let client_addr = Address::generate(&env); + let freelancer_addr = Address::generate(&env); + let new_client = Address::generate(&env); + let attacker = Address::generate(&env); + + let milestones = default_milestones(&env); + let id = client.create_contract( + &client_addr, + &freelancer_addr, + &milestones, + &DepositMode::ExactTotal, + ); + + assert!(client.propose_client_migration(&id, &client_addr, &new_client)); + let result = client.try_accept_client_migration(&id, &attacker); + assert_contract_error(result, EscrowError::UnauthorizedRole); } #[test] -fn migration_to_same_address_fails() { - let env = setup_env(); - let client = register_escrow(&env); - let (parties, contract_id) = create_sample_contract(&env, &client); - - // Cannot migrate to same address - assert_panics(|| { - client.request_client_migration(&contract_id, &parties.client); - }); -} - -#[test] -fn double_migration_proposal_fails() { - let env = setup_env(); - let client = register_escrow(&env); - let (parties, contract_id) = create_sample_contract(&env, &client); - - assert!(client.request_client_migration(&contract_id, &parties.replacement_client)); - - // Cannot propose second migration while one is pending - let another_client = super::Address::generate(&env); - assert_panics(|| { - client.request_client_migration(&contract_id, &another_client); - }); -} - -#[test] -fn unauthorized_confirmation_fails() { - let env = setup_env(); - let client = register_escrow(&env); - let (parties, contract_id) = create_sample_contract(&env, &client); - - assert!(client.request_client_migration(&contract_id, &parties.replacement_client)); - - // Unauthorized party cannot confirm - let unauthorized_party = super::Address::generate(&env); - assert_panics(|| { - client.confirm_client_migration(&contract_id); - }); +fn propose_client_migration_rejects_freelancer_address() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(Escrow, ()); + let client = EscrowClient::new(&env, &contract_id); + + let client_addr = Address::generate(&env); + let freelancer_addr = Address::generate(&env); + let milestones = default_milestones(&env); + let id = client.create_contract( + &client_addr, + &freelancer_addr, + &milestones, + &DepositMode::ExactTotal, + ); + + let result = client.try_propose_client_migration(&id, &client_addr, &freelancer_addr); + assert_contract_error(result, EscrowError::InvalidParticipant); } #[test] -fn unauthorized_cancellation_fails() { - let env = setup_env(); - let client = register_escrow(&env); - let (parties, contract_id) = create_sample_contract(&env, &client); - - assert!(client.request_client_migration(&contract_id, &parties.replacement_client)); - - // Only current client can cancel - assert_panics(|| { - client.cancel_client_migration(&contract_id); - }); -} - -#[test] -fn migration_in_completed_contract_fails() { - let env = setup_env(); - let client = register_escrow(&env); - let (parties, contract_id) = create_sample_contract(&env, &client); - - // Complete the contract first - assert!(client.deposit_funds(&contract_id, &full_funding_amount())); - for i in 0..3 { - assert!(client.release_milestone(&contract_id, &i)); - } - assert!(client.complete_contract(&contract_id)); - - // Cannot migrate completed contract - assert_panics(|| { - client.request_client_migration(&contract_id, &parties.replacement_client); - }); -} - -#[test] -fn migration_in_cancelled_contract_fails() { - let env = setup_env(); - let client = register_escrow(&env); - let (parties, contract_id) = create_sample_contract(&env, &client); - - // Cancel the contract first - assert!(client.cancel_contract(&contract_id, &parties.client)); - - // Cannot migrate cancelled contract - assert_panics(|| { - client.request_client_migration(&contract_id, &parties.replacement_client); - }); -} - -#[test] -fn migration_in_disputed_contract_fails() { - let env = setup_env(); - let client = register_escrow(&env); - let (parties, contract_id) = create_sample_contract(&env, &client); - - // Mark contract as disputed (simulate) - // Note: This would require a dispute method, but we'll test the status restriction - - // For now, let's manually set the status to Disputed - env.as_contract(&client.contract_id, || { - let mut contract = client.get_contract(&contract_id); - contract.status = ContractStatus::Disputed; - // This would need a proper update method in a real implementation - }); - - // Cannot migrate disputed contract - assert_panics(|| { - client.request_client_migration(&contract_id, &parties.replacement_client); +fn accept_client_migration_rejects_expired_pending_migration() { + let env = Env::default(); + env.mock_all_auths(); + let current_ledger = env.ledger().get(); + env.ledger().set(LedgerInfo { + sequence_number: current_ledger.sequence_number, + timestamp: current_ledger.timestamp, + protocol_version: current_ledger.protocol_version, + network_id: current_ledger.network_id.clone(), + base_reserve: current_ledger.base_reserve, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: PENDING_MIGRATION_TTL_LEDGERS * 4, + max_entry_ttl: PENDING_MIGRATION_TTL_LEDGERS * 4, }); -} - -#[test] -fn migration_expires_after_ttl() { - let env = setup_env(); - let client = register_escrow(&env); - let (parties, contract_id) = create_sample_contract(&env, &client); - - assert!(client.request_client_migration(&contract_id, &parties.replacement_client)); - - // Advance time beyond TTL - env.ledger().set(super::LedgerInfo { - sequence: 1000, // This should be beyond the TTL - timestamp: 1000000, - protocol_version: 1, + let contract_id = env.register(Escrow, ()); + let client = EscrowClient::new(&env, &contract_id); + + let client_addr = Address::generate(&env); + let freelancer_addr = Address::generate(&env); + let new_client = Address::generate(&env); + + let milestones = default_milestones(&env); + let id = client.create_contract( + &client_addr, + &freelancer_addr, + &milestones, + &DepositMode::ExactTotal, + ); + + assert!(client.propose_client_migration(&id, &client_addr, &new_client)); + env.ledger().set(LedgerInfo { + sequence_number: env.ledger().get().sequence_number + PENDING_MIGRATION_TTL_LEDGERS + 1, + timestamp: env.ledger().get().timestamp + 10_000, + protocol_version: env.ledger().get().protocol_version, network_id: [0; 32].into(), base_reserve: 100, min_temp_entry_ttl: 1, min_persistent_entry_ttl: 1, max_entry_ttl: 65536, - ledger_version: 1, - }); - - // Should fail to confirm expired migration - assert_panics(|| { - client.confirm_client_migration(&contract_id); - }); - - // Should fail to finalize expired migration - assert_panics(|| { - client.finalize_client_migration(&contract_id); }); - - // Migration should be cleaned up - assert!(!client.has_pending_client_migration(&contract_id)); -} - -#[test] -fn migration_preserves_contract_integrity() { - let env = setup_env(); - let client = register_escrow(&env); - let (parties, contract_id) = create_sample_contract(&env, &client); - - // Fund the contract before migration - assert!(client.deposit_funds(&contract_id, &full_funding_amount())); - - // Get original contract state - let original_contract = client.get_contract(&contract_id); - let original_client = original_contract.client.clone(); - let original_freelancer = original_contract.freelancer.clone(); - let original_milestones = original_contract.milestones.clone(); - let original_status = original_contract.status; - - // Perform migration - assert!(client.request_client_migration(&contract_id, &parties.replacement_client)); - assert!(client.confirm_client_migration(&contract_id)); - assert!(client.finalize_client_migration(&contract_id)); - - // Verify contract integrity - let migrated_contract = client.get_contract(&contract_id); - assert_eq!(migrated_contract.client, parties.replacement_client); - assert_eq!(migrated_contract.freelancer, original_freelancer); - assert_eq!(migrated_contract.milestones, original_milestones); - assert_eq!(migrated_contract.status, original_status); - assert_eq!(migrated_contract.total_deposited, original_contract.total_deposited); - - // Verify old client can no longer act as client - assert_panics(|| { - client.deposit_funds(&contract_id, &1000); - }); - - // Verify new client can act as client - assert!(client.deposit_funds(&contract_id, &1000)); -} -#[test] -fn migration_emits_proper_events() { - let env = setup_env(); - let client = register_escrow(&env); - let (parties, contract_id) = create_sample_contract(&env, &client); - - // Request migration - should emit proposal event - assert!(client.request_client_migration(&contract_id, &parties.replacement_client)); - - // Check for proposal event - let events = env.events().all(); - assert!(events.iter().any(|(topics, _data)| { - topics.len() >= 2 && - topics[0] == super::Symbol::new(&env, "client_migration_proposed") && - topics[1] == super::symbol_short!(contract_id) - })); - - // Confirm migration - should emit confirmation event - assert!(client.confirm_client_migration(&contract_id)); - - // Check for confirmation event - let events_after_confirm = env.events().all(); - assert!(events_after_confirm.iter().any(|(topics, _data)| { - topics.len() >= 2 && - topics[0] == super::Symbol::new(&env, "client_migration_confirmed") && - topics[1] == super::symbol_short!(contract_id) - })); - - // Finalize migration - should emit finalization event - assert!(client.finalize_client_migration(&contract_id)); - - // Check for finalization event - let events_after_finalize = env.events().all(); - assert!(events_after_finalize.iter().any(|(topics, _data)| { - topics.len() >= 2 && - topics[0] == super::Symbol::new(&env, "client_migration_finalized") && - topics[1] == super::symbol_short!(contract_id) - })); + let result = client.try_accept_client_migration(&id, &new_client); + assert_contract_error(result, EscrowError::InvalidState); + assert!(!client.has_pending_client_migration(&id)); } #[test] -fn migration_cancellation_emits_event() { - let env = setup_env(); - let client = register_escrow(&env); - let (parties, contract_id) = create_sample_contract(&env, &client); - - assert!(client.request_client_migration(&contract_id, &parties.replacement_client)); - - // Cancel migration - should emit cancellation event - assert!(client.cancel_client_migration(&contract_id)); - - // Check for cancellation event - let events = env.events().all(); - assert!(events.iter().any(|(topics, _data)| { - topics.len() >= 2 && - topics[0] == super::Symbol::new(&env, "client_migration_cancelled") && - topics[1] == super::symbol_short!(contract_id) - })); +fn propose_client_migration_rejects_after_contract_completed() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(Escrow, ()); + let client = EscrowClient::new(&env, &contract_id); + + let client_addr = Address::generate(&env); + let freelancer_addr = Address::generate(&env); + let new_client = Address::generate(&env); + + let milestones = default_milestones(&env); + let id = client.create_contract( + &client_addr, + &freelancer_addr, + &milestones, + &DepositMode::ExactTotal, + ); + + assert!(client.deposit_funds(&id, &super::total_milestone_amount())); + assert!(client.release_milestone(&id, &0)); + assert!(client.release_milestone(&id, &1)); + assert!(client.release_milestone(&id, &2)); + + let result = client.try_propose_client_migration(&id, &client_addr, &new_client); + assert_contract_error(result, EscrowError::InvalidStatusTransition); } diff --git a/contracts/escrow/src/test/mod.rs b/contracts/escrow/src/test/mod.rs index b540ebe..888e2d3 100644 --- a/contracts/escrow/src/test/mod.rs +++ b/contracts/escrow/src/test/mod.rs @@ -5,8 +5,7 @@ use soroban_sdk::{testutils::Address as _, vec, Address, Env}; use crate::{Escrow, EscrowClient, EscrowError}; // ─── Submodules ─────────────────────────────────────────────────────────────── - -mod emergency_controls; +mod client_migration;mod emergency_controls; mod pause_controls; // ─── Shared constants ───────────────────────────────────────────────────────── diff --git a/docs/escrow/migration.md b/docs/escrow/migration.md index 3dd425d..d18bed2 100644 --- a/docs/escrow/migration.md +++ b/docs/escrow/migration.md @@ -1,24 +1,52 @@ -# Storage Migration and Forward-Compatible Reads +# Client Migration ## Overview -The TalentTrust Escrow contract implements forward-compatible storage upgrade patterns. As the contract matures and business requirements evolve, the schema definitions persisting its native ledger state must shift. - -To maintain continuous 100% interoperability without risking panics natively, the system utilizes explicit schema versioning (e.g. `StateV1`, `StateV2`) bounded by internal parsers that dynamically restructure legacy logic. - -## Strategy: Forward-Compatible Legacy Reads -When evaluating data from the persistent storage via `DataKey::State`, the fallback `get_state` entrypoint logic acts defensively: -1. **Attempt Primary Load:** Prioritizes decoding the raw storage instance mapping dynamically to the current active `StateV2`. -2. **Legacy Intercept:** If the type signature or data footprint doesn't reconcile (because the record remains un-upgraded natively on the ledger as `StateV1`), it specifically casts to `StateV1`. -3. **In-Memory Transformation:** Once recovered, the old format is parsed sequentially into a fresh `StateV2` layout utilizing sensible default filler parameters where applicable securely (like setting generic initial `ContractStatus`es). -4. **Execution Rebound:** Passes cleanly updated `StateV2` to logical workflows guaranteeing no internal panics whatsoever. - -## Strategy: Explicit Upgrades -For administrators desiring clean persistence, the `migrate_state` executes an authorized override. -- Secured effectively by `admin.require_auth()`. Only contract administrators evaluating native Soroban authorizations can pull this logic loop natively. -- Evaluates the legacy bytes via `get_state()` effectively bridging it. -- Force-writes over `DataKey::State` rewriting the physical Soroban environment layout cleanly to the new `StateV2`. - -## Security & Threat Scenarios -1. **Malformed State Panics:** Directly querying outdated memory bytes can brick the contract or crash execution heavily. Utilizing explicit legacy `get_state()` mapping catches boundary offsets cleanly. -2. **Unauthorized State Tampering:** Writing over raw memory parameters could allow attackers to manipulate active funds intentionally. The `migrate_state` securely enforces `.require_auth()`, severely locking this vector context entirely to legitimate administrators. -3. **Data Loss During Conversions:** Meticulously defined comprehensive tests (`migration_test.rs`) actively simulate thousands of invocations inserting literal `StateV1` elements straight into execution buckets directly probing against unexpected memory drops correctly resolving natively. + +Client migration allows the existing escrow client role to be safely reassigned to a new address without changing contract state until the proposed client explicitly accepts the migration. + +The implementation uses transient temporary storage under `DataKey::PendingClientMigration(contract_id)` with a Soroban TTL. This ensures proposals expire automatically after `PENDING_MIGRATION_TTL_LEDGERS` ledgers and cannot be accepted once stale. + +## Public functions + +- `propose_client_migration(env, contract_id, current_client, new_client) -> bool` + - Requires `current_client` authorization. + - Rejects when `new_client` equals the freelancer or the current client. + - Rejects when the contract status is `Completed`, `Cancelled`, `Refunded`, or `Disputed`. + - Stores the migration request in temporary storage with TTL. + - Emits `client_migration_proposed`. + +- `accept_client_migration(env, contract_id, new_client) -> bool` + - Requires `new_client` authorization. + - Loads the live pending migration from temporary storage. + - Rejects if the proposal is missing or expired. + - Rejects if the caller does not match the proposed client. + - Atomically updates `EscrowContractData.client`. + - Removes the transient migration request. + - Emits `client_migration_accepted`. + +- `has_pending_client_migration(env, contract_id) -> bool` + - Returns whether a live pending migration exists. + +- `get_pending_client_migration(env, contract_id) -> PendingClientMigration` + - Returns the live pending migration record or panics if none exists. + +## Security and invariants + +- `EscrowContractData.client` is updated only in `accept_client_migration`. +- No contract mutation occurs during the proposal phase. +- Expired proposals are not accepted because `read_if_live` returns `None` once the TTL has elapsed. +- Paused or emergency states block both proposal and acceptance. +- Unauthorized callers cannot forge or accept migration proposals. + +## Example + +```rust +let contract_id = client.create_contract(&client_addr, &freelancer_addr, &milestones, &DepositMode::ExactTotal); +client.propose_client_migration(&contract_id, &client_addr, &new_client_addr); +assert!(client.has_pending_client_migration(&contract_id)); +let pending = client.get_pending_client_migration(&contract_id); +assert_eq!(pending.proposed_client, new_client_addr); +client.accept_client_migration(&contract_id, &new_client_addr); +let contract = client.get_contract(&contract_id); +assert_eq!(contract.client, new_client_addr); +``` From 02d6b5c72d9178d0f042d7ecf7f3f40c8768318a Mon Sep 17 00:00:00 2001 From: Timi Date: Tue, 26 May 2026 22:43:32 +0100 Subject: [PATCH 2/2] refactor: organize imports and improve code readability in migration and test files --- contracts/escrow/src/migration.rs | 11 ++++++++--- contracts/escrow/src/test/client_migration.rs | 9 +++++++-- contracts/escrow/src/test/mod.rs | 3 ++- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/contracts/escrow/src/migration.rs b/contracts/escrow/src/migration.rs index 2ca6fbe..8b90ba8 100644 --- a/contracts/escrow/src/migration.rs +++ b/contracts/escrow/src/migration.rs @@ -1,5 +1,7 @@ -use crate::{ContractStatus, DataKey, Escrow, EscrowArgs, EscrowClient, EscrowContractData, EscrowError}; use crate::ttl::{read_if_live, remove_transient, store_with_ttl, PENDING_MIGRATION_TTL_LEDGERS}; +use crate::{ + ContractStatus, DataKey, Escrow, EscrowArgs, EscrowClient, EscrowContractData, EscrowError, +}; use soroban_sdk::{contractimpl, contracttype, Address, Env, Symbol}; #[contracttype] @@ -37,7 +39,8 @@ impl Escrow { } fn pending_migration_exists(env: &Env, contract_id: u32) -> bool { - read_if_live::<_, PendingClientMigration>(env, &Self::pending_migration_key(contract_id)).is_some() + read_if_live::<_, PendingClientMigration>(env, &Self::pending_migration_key(contract_id)) + .is_some() } /// Propose a client migration for an existing contract. @@ -108,7 +111,9 @@ impl Escrow { } contract.client = new_client.clone(); - env.storage().persistent().set(&DataKey::Contract(contract_id), &contract); + env.storage() + .persistent() + .set(&DataKey::Contract(contract_id), &contract); remove_transient(&env, &key); env.events().publish( diff --git a/contracts/escrow/src/test/client_migration.rs b/contracts/escrow/src/test/client_migration.rs index 00994bf..b015147 100644 --- a/contracts/escrow/src/test/client_migration.rs +++ b/contracts/escrow/src/test/client_migration.rs @@ -1,7 +1,12 @@ #![cfg(test)] -use crate::{Escrow, EscrowClient, EscrowError, PendingClientMigration, PENDING_MIGRATION_TTL_LEDGERS, types::DepositMode}; -use soroban_sdk::{testutils::Address as _, testutils::Ledger as _, testutils::LedgerInfo, vec, Address, Env}; +use crate::{ + types::DepositMode, Escrow, EscrowClient, EscrowError, PendingClientMigration, + PENDING_MIGRATION_TTL_LEDGERS, +}; +use soroban_sdk::{ + testutils::Address as _, testutils::Ledger as _, testutils::LedgerInfo, vec, Address, Env, +}; use super::{assert_contract_error, default_milestones, register_client}; diff --git a/contracts/escrow/src/test/mod.rs b/contracts/escrow/src/test/mod.rs index 888e2d3..dd10b7c 100644 --- a/contracts/escrow/src/test/mod.rs +++ b/contracts/escrow/src/test/mod.rs @@ -5,7 +5,8 @@ use soroban_sdk::{testutils::Address as _, vec, Address, Env}; use crate::{Escrow, EscrowClient, EscrowError}; // ─── Submodules ─────────────────────────────────────────────────────────────── -mod client_migration;mod emergency_controls; +mod client_migration; +mod emergency_controls; mod pause_controls; // ─── Shared constants ─────────────────────────────────────────────────────────