Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
78 changes: 78 additions & 0 deletions escrow/src/tests/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading