Skip to content
Open
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
39 changes: 38 additions & 1 deletion escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,8 @@ pub enum EscrowError {
MaturityUpdateNotOpen = 79,
/// [`LiquifactEscrow::propose_admin`] nominated the current admin address.
NewAdminSameAsCurrent = 80,
/// [`LiquifactEscrow::propose_admin`] repeated the already-pending admin address.
PendingAdminUnchanged = 177,
/// [`LiquifactEscrow::update_maturity`] set maturity to the same value as current.
MaturityUnchanged = 81,
/// [`LiquifactEscrow::accept_admin`] called after the proposal expiry recorded at
Expand Down Expand Up @@ -964,6 +966,21 @@ pub struct AdminProposedEvent {
pub pending_admin: Address,
}

/// Emitted by [`LiquifactEscrow::propose_admin`] when a different pending admin proposal is
/// replaced before it is accepted or cancelled.
///
/// Indexers can distinguish a true supersede from a first-time proposal without inferring it from
/// storage diffs.
#[contractevent]
pub struct AdminProposalSuperseded {
#[topic]
pub name: Symbol,
#[topic]
pub invoice_id: Symbol,
pub previous_pending: Address,
pub new_pending: Address,
}

/// Emitted by [`LiquifactEscrow::cancel_pending_admin`] when a pending admin proposal is cancelled.
///
/// Indexers and operators can monitor this event to track when nominations are retracted.
Expand Down Expand Up @@ -4349,6 +4366,8 @@ impl LiquifactEscrow {
/// Propose a new admin (`PendingAdmin`) — step 1 of a two-step handover.
///
/// Requires current admin authorization. The destination must differ from the current admin.
/// If a pending proposal already exists, re-proposing the same address is rejected while
/// replacing it with a different address emits [`AdminProposalSuperseded`].
///
/// Persists [`DataKey::PendingAdmin`] as the proposed successor address and
/// [`DataKey::PendingAdminExpiry`] as `ledger.timestamp() + window`, where `window`
Expand All @@ -4361,7 +4380,8 @@ impl LiquifactEscrow {
///
/// # Errors
/// Emits typed [`EscrowError`] codes when the escrow is uninitialized, the caller is not the
/// current admin, or `new_admin` is the current admin ([`EscrowError::NewAdminSameAsCurrent`]).
/// current admin, `new_admin` is the current admin ([`EscrowError::NewAdminSameAsCurrent`]),
/// or `new_admin` is already pending ([`EscrowError::PendingAdminUnchanged`]).
///
/// # Events
/// Emits [`AdminProposedEvent`] (topic: `adm_prop`) containing the `invoice_id`, the `current_admin`,
Expand All @@ -4379,6 +4399,23 @@ impl LiquifactEscrow {
EscrowError::NewAdminSameAsCurrent,
);

let previous_pending: Option<Address> =
env.storage().instance().get(&DataKey::PendingAdmin);
if let Some(pending) = previous_pending {
ensure(
&env,
pending != new_admin,
EscrowError::PendingAdminUnchanged,
);
AdminProposalSuperseded {
name: symbol_short!("adm_sup"),
invoice_id: escrow.invoice_id.clone(),
previous_pending: pending,
new_pending: new_admin.clone(),
}
.publish(&env);
}

let window = validity_window_secs.unwrap_or(DEFAULT_ADMIN_PROPOSAL_VALIDITY_SECS);
let expiry = env.ledger().timestamp().saturating_add(window);

Expand Down
59 changes: 57 additions & 2 deletions escrow/src/tests/admin.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use super::*;
use crate::{
AdminAcceptedEvent, AdminProposalCancelled, AdminProposedEvent, EscrowCloseSnapshot,
FundingTargetUpdated, MaturityMaxHorizonRaised, RegistryRefRebound,
AdminAcceptedEvent, AdminProposalCancelled, AdminProposalSuperseded, AdminProposedEvent,
EscrowCloseSnapshot, FundingTargetUpdated, MaturityMaxHorizonRaised, RegistryRefRebound,
DEFAULT_MATURITY_MAX_HORIZON_SECS,
};

Expand Down Expand Up @@ -409,6 +409,61 @@ fn test_propose_admin_overwrites_prior_pending() {
assert_eq!(updated.admin, second);
}

#[test]
fn test_propose_admin_rejects_unchanged_pending_admin() {
let env = Env::default();
let (client, admin, sme) = setup(&env);
let pending = Address::generate(&env);
default_init(&client, &env, &admin, &sme);

client.propose_admin(&pending, &None);

assert_contract_error(
client.try_propose_admin(&pending, &None),
EscrowError::PendingAdminUnchanged,
);
}

#[test]
fn test_propose_admin_supersede_emits_distinct_event() {
use soroban_sdk::testutils::Events as _;

let env = Env::default();
let (client, admin, sme) = setup(&env);
let contract_id = client.address.clone();
let first = Address::generate(&env);
let second = Address::generate(&env);
default_init(&client, &env, &admin, &sme);

client.propose_admin(&first, &None);
client.propose_admin(&second, &None);

let invoice_id = client.get_escrow().invoice_id;
let events = env.events().all();
let event_list = events.events();
let superseded = AdminProposalSuperseded {
name: symbol_short!("adm_sup"),
invoice_id: invoice_id.clone(),
previous_pending: first,
new_pending: second.clone(),
};
let proposed = AdminProposedEvent {
name: symbol_short!("adm_prop"),
invoice_id,
current_admin: admin,
pending_admin: second,
};

assert_eq!(
event_list.get(event_list.len() - 2).unwrap().clone(),
superseded.to_xdr(&env, &contract_id)
);
assert_eq!(
event_list.last().unwrap().clone(),
proposed.to_xdr(&env, &contract_id)
);
}

#[test]
fn test_propose_admin_emits_event() {
use soroban_sdk::testutils::Events as _;
Expand Down
4 changes: 3 additions & 1 deletion escrow/src/tests/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ fn escrow_error_discriminants_match_canonical_table() {
(EscrowError::NewCapBelowCurrentFunderCount, 78),
(EscrowError::MaturityUpdateNotOpen, 79),
(EscrowError::NewAdminSameAsCurrent, 80),
(EscrowError::PendingAdminUnchanged, 177),
(EscrowError::FundingBatchEmpty, 82),
(EscrowError::FundingBatchTooLarge, 83),
(EscrowError::MigrationVersionMismatch, 90),
Expand Down Expand Up @@ -229,7 +230,7 @@ fn escrow_error_discriminants_match_canonical_table() {
(EscrowError::NewFloorNotLower, 174),
(EscrowError::NewFloorNotPositive, 175),
];
assert_eq!(TABLE.len(), 88);
assert_eq!(TABLE.len(), 89);
for (variant, code) in TABLE {
assert_eq!(*variant as u32, *code, "discriminant drift for code {code}");
}
Expand Down Expand Up @@ -5361,6 +5362,7 @@ fn test_event_topic0_follows_snake_case_convention() {
let expected_topics: Vec<(&str, &str)> = vec![
("AdminAcceptedEvent", "admin_accepted_event"),
("AdminProposalCancelled", "admin_proposal_cancelled"),
("AdminProposalSuperseded", "admin_proposal_superseded"),
("AdminProposedEvent", "admin_proposed_event"),
("AdminTransferredEvent", "admin_transferred_event"),
("AllowlistEnabledChanged", "allowlist_enabled_changed"),
Expand Down