diff --git a/escrow/src/lib.rs b/escrow/src/lib.rs index 5ca65939..f1817fc4 100644 --- a/escrow/src/lib.rs +++ b/escrow/src/lib.rs @@ -821,6 +821,16 @@ pub struct MaturityUpdatedEvent { pub new_maturity: u64, } +#[contractevent] +pub struct BeneficiaryRotated { + #[topic] + pub name: Symbol, + #[topic] + pub invoice_id: Symbol, + pub old_sme: Address, + pub new_sme: Address, +} + #[contractevent] pub struct AdminTransferredEvent { #[topic] @@ -3333,11 +3343,13 @@ impl LiquifactEscrow { escrow.yield_bps, ); Self::set_persistent_investor_claim_not_before(&env, investor.clone(), 0u64); + tier_lock_secs = 0; } else { // Returning investor: yield was set on first deposit; read it for the event. investor_effective_yield_bps = Self::get_persistent_investor_effective_yield(&env, investor.clone()) .unwrap_or(escrow.yield_bps); + tier_lock_secs = 0; } // If prev > 0, preserve existing effective yield and claim lock } else { @@ -3926,6 +3938,42 @@ impl LiquifactEscrow { } } + /// Update the SME beneficiary address via dual consent (current SME and admin). + /// + /// Allowed only in non-terminal states (0 = open, 1 = funded). + /// Invariant: after rotation, only the new SME may withdraw/settle. + pub fn rotate_beneficiary(env: Env, new_sme: Address) { + let mut escrow = Self::get_escrow(env.clone()); + + ensure( + &env, + escrow.status == 0 || escrow.status == 1, + EscrowError::RotateBeneficiaryNotOpen, + ); + + escrow.sme_address.require_auth(); + escrow.admin.require_auth(); + + ensure( + &env, + escrow.sme_address != new_sme, + EscrowError::NewSmeSameAsCurrent, + ); + + let old_sme = escrow.sme_address.clone(); + escrow.sme_address = new_sme.clone(); + + env.storage().instance().set(&DataKey::Escrow, &escrow); + + BeneficiaryRotated { + name: Symbol::new(&env, "BeneficiaryRotated"), + invoice_id: escrow.invoice_id.clone(), + old_sme, + new_sme, + } + .publish(&env); + } + /// Propose a new admin (`PendingAdmin`) — step 1 of a two-step handover. /// /// Requires current admin authorization. The destination must differ from the current admin. diff --git a/escrow/src/tests/admin.rs b/escrow/src/tests/admin.rs index 762446c3..0ad8966f 100644 --- a/escrow/src/tests/admin.rs +++ b/escrow/src/tests/admin.rs @@ -282,6 +282,84 @@ fn test_transfer_admin_same_address_panics() { client.propose_admin(&admin, &None); } +#[test] +fn test_rotate_beneficiary_success() { + let env = Env::default(); + let (client, admin, sme) = setup(&env); + let new_sme = Address::generate(&env); + let invoice_id = soroban_sdk::String::from_str(&env, "ROT001"); + client.init( + &admin, + &invoice_id, + &sme, + &TARGET, + &800i64, + &1000u64, + &Address::generate(&env), + &None, + &Address::generate(&env), + &None, + &None, + &None, + &None, + &None, + ); + + client.rotate_beneficiary(&new_sme); + let updated = client.get_escrow(); + assert_eq!(updated.sme_address, new_sme); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #81)")] +fn test_rotate_beneficiary_same_address_panics() { + let env = Env::default(); + let (client, admin, sme) = setup(&env); + client.init( + &admin, + &soroban_sdk::String::from_str(&env, "ROT002"), + &sme, + &TARGET, + &800i64, + &1000u64, + &Address::generate(&env), + &None, + &Address::generate(&env), + &None, + &None, + &None, + &None, + &None, + ); + client.rotate_beneficiary(&sme); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #82)")] +fn test_rotate_beneficiary_wrong_state() { + let env = Env::default(); + let (client, admin, sme) = setup(&env); + client.init( + &admin, + &soroban_sdk::String::from_str(&env, "ROT003"), + &sme, + &TARGET, + &800i64, + &1000u64, + &Address::generate(&env), + &None, + &Address::generate(&env), + &None, + &None, + &None, + &None, + &None, + ); + // Cancel the escrow so it's in a terminal state + client.cancel_funding(); + client.rotate_beneficiary(&Address::generate(&env)); +} + #[test] #[should_panic] fn test_transfer_admin_uninitialized_panics() {