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
9 changes: 9 additions & 0 deletions contracts/escrow_contract/src/event_names.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,12 @@ pub const MILESTONE_CREATED: Symbol = symbol_short!("mile_crt");
// ── Escrow creation timing record ──────────────────────────────────────────────

pub const ESCROW_CREATION_TIME: Symbol = symbol_short!("esc_ctim");

// ── Issue 2: Admin transfer timelock events ───────────────────────────────────

/// Emitted by `propose_admin` with the timelock expiry ledger sequence.
pub const ADMIN_TRANSFER_PROPOSED: Symbol = symbol_short!("adm_prp2");
/// Emitted by `accept_admin` after the timelock has elapsed.
pub const ADMIN_TRANSFER_ACCEPTED: Symbol = symbol_short!("adm_acc2");
/// Emitted by `cancel_admin_proposal` when the current admin cancels the pending transfer.
pub const ADMIN_PROPOSAL_CANCELLED: Symbol = symbol_short!("adm_canc");
41 changes: 41 additions & 0 deletions contracts/escrow_contract/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -642,3 +642,44 @@ pub fn emit_cooldown_elapsed(env: &Env, escrow_id: u64, cooldown_ended_at: u64)
env.events()
.publish((ev::COOLDOWN_ELAPSED, escrow_id), cooldown_ended_at);
}

// ── Issue 2: Admin transfer timelock events ───────────────────────────────────

/// Emitted by `propose_admin` (Issue 2 version with timelock expiry ledger).
///
/// Schema: topic=(ADMIN_TRANSFER_PROPOSED,), data=(current_admin, pending_admin, valid_after_ledger)
pub fn emit_admin_transfer_proposed(
env: &Env,
current_admin: &Address,
pending_admin: &Address,
valid_after_ledger: u32,
) {
env.events().publish(
(ev::ADMIN_TRANSFER_PROPOSED,),
(current_admin.clone(), pending_admin.clone(), valid_after_ledger),
);
}

/// Emitted by `accept_admin` once the timelock has elapsed and the transfer is complete.
///
/// Schema: topic=(ADMIN_TRANSFER_ACCEPTED,), data=(old_admin, new_admin)
pub fn emit_admin_transfer_accepted(env: &Env, old_admin: &Address, new_admin: &Address) {
env.events().publish(
(ev::ADMIN_TRANSFER_ACCEPTED,),
(old_admin.clone(), new_admin.clone()),
);
}

/// Emitted by `cancel_admin_proposal` when the current admin cancels the pending transfer.
///
/// Schema: topic=(ADMIN_PROPOSAL_CANCELLED,), data=(admin, cancelled_pending_admin)
pub fn emit_admin_proposal_cancelled(
env: &Env,
admin: &Address,
cancelled_pending_admin: &Address,
) {
env.events().publish(
(ev::ADMIN_PROPOSAL_CANCELLED,),
(admin.clone(), cancelled_pending_admin.clone()),
);
}
69 changes: 64 additions & 5 deletions contracts/escrow_contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,12 @@ pub const DEFAULT_DISPUTE_COOLDOWN_SECS: u64 = 86_400;
/// Threshold for high-value escrows that can be escalated to governance (1000 XLM in stroops).
pub const HIGH_VALUE_THRESHOLD: i128 = 10_000_000_000i128;

/// Timelock delay for admin transfer: approximately 48 hours at ~5 s/ledger.
///
/// 48 h × 3600 s/h ÷ 5 s/ledger = 34 560 ledgers.
/// This gives the current admin time to detect and cancel an unauthorised transfer.
pub const ADMIN_TRANSFER_TIMELOCK_LEDGERS: u32 = 34_560;

// ── Granular storage keys ─────────────────────────────────────────────────────
// Separate keys for meta vs each milestone avoids deserialising the full
// milestone list on every escrow-level operation.
Expand Down Expand Up @@ -4966,30 +4972,44 @@ impl EscrowContract {
Ok(admin)
}

/// Step 1 of two-step admin transfer: propose a new admin.
/// Step 1 of two-step admin transfer: propose a new admin with a timelock.
///
/// Only the current admin may call this. Stores `new_admin` under
/// `DataKey::PendingAdmin`. The transfer is not complete until the
/// proposed admin calls `accept_admin`.
/// `DataKey::PendingAdmin` and records `current_ledger + ADMIN_TRANSFER_TIMELOCK_LEDGERS`
/// as the earliest ledger at which `accept_admin` can succeed.
///
/// The transfer is not complete until the proposed admin calls `accept_admin`
/// **after** the timelock has elapsed. The current admin may call
/// `cancel_admin_proposal` at any time to abort the transfer.
pub fn propose_admin(env: Env, caller: Address, new_admin: Address) -> Result<(), EscrowError> {
caller.require_auth();
ContractStorage::require_admin(&env, &caller)?;

let valid_after_ledger = env
.ledger()
.sequence()
.saturating_add(ADMIN_TRANSFER_TIMELOCK_LEDGERS);

env.storage()
.instance()
.set(&DataKey::PendingAdmin, &new_admin);
env.storage()
.instance()
.set(&FeatDataKey::AdminTransferValidAfterLedger, &valid_after_ledger);
ContractStorage::bump_instance_ttl(&env);

events::emit_admin_proposed(&env, &caller, &new_admin);
events::emit_admin_transferred(&env, &caller, &new_admin);
events::emit_admin_transfer_proposed(&env, &caller, &new_admin, valid_after_ledger);
Ok(())
}

/// Step 2 of two-step admin transfer: accept the pending admin role.
///
/// Only the address stored as `DataKey::PendingAdmin` may call this.
/// Only the address stored as `DataKey::PendingAdmin` may call this,
/// **and** only after the ledger sequence recorded at proposal time has passed.
/// On success, `DataKey::Admin` is updated to the caller and
/// `DataKey::PendingAdmin` is cleared.
/// `DataKey::PendingAdmin` / `FeatDataKey::AdminTransferValidAfterLedger` are cleared.
pub fn accept_admin(env: Env, caller: Address) -> Result<(), EscrowError> {
caller.require_auth();
ContractStorage::require_initialized(&env)?;
Expand All @@ -5004,6 +5024,16 @@ impl EscrowContract {
return Err(EscrowError::E3);
}

// Enforce timelock: accept_admin is only callable after valid_after_ledger.
let valid_after_ledger: u32 = env
.storage()
.instance()
.get(&FeatDataKey::AdminTransferValidAfterLedger)
.unwrap_or(0_u32);
if env.ledger().sequence() <= valid_after_ledger {
return Err(EscrowError::E46);
}

let old_admin: Address = env
.storage()
.instance()
Expand All @@ -5012,10 +5042,39 @@ impl EscrowContract {

env.storage().instance().set(&DataKey::Admin, &caller);
env.storage().instance().remove(&DataKey::PendingAdmin);
env.storage()
.instance()
.remove(&FeatDataKey::AdminTransferValidAfterLedger);
ContractStorage::bump_instance_ttl(&env);

events::emit_admin_changed(&env, &old_admin, &caller);
events::emit_admin_accepted(&env, &caller);
events::emit_admin_transfer_accepted(&env, &old_admin, &caller);
Ok(())
}

/// Cancel a pending admin transfer proposal.
///
/// Only the **current** admin may call this. Clears `DataKey::PendingAdmin`
/// and `FeatDataKey::AdminTransferValidAfterLedger` so no transfer can proceed.
/// Returns `EscrowError::E3` if there is no pending proposal to cancel.
pub fn cancel_admin_proposal(env: Env, caller: Address) -> Result<(), EscrowError> {
caller.require_auth();
ContractStorage::require_admin(&env, &caller)?;

let pending: Address = env
.storage()
.instance()
.get(&DataKey::PendingAdmin)
.ok_or(EscrowError::E3)?;

env.storage().instance().remove(&DataKey::PendingAdmin);
env.storage()
.instance()
.remove(&FeatDataKey::AdminTransferValidAfterLedger);
ContractStorage::bump_instance_ttl(&env);

events::emit_admin_proposal_cancelled(&env, &caller, &pending);
Ok(())
}

Expand Down
23 changes: 23 additions & 0 deletions contracts/escrow_contract/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -883,4 +883,27 @@ pub enum FeatDataKey {
/// can calculate inactivity without replaying the full event log.
/// key: u64 (escrow_id), value: u64 (Unix timestamp from env.ledger().timestamp())
LastActivityTimestamp(u64),

// ── Issue 1: Pending milestone counts ────────────────────────────────────

/// Number of milestones in Pending state per escrow — key: u64, value: u32
///
/// Incremented by `add_milestone` / `create_milestone` (new milestones start Pending).
/// Decremented by `approve_milestone` and `reject_milestone` (both transition out of Pending).
PendingMilestoneCount(u64),

// ── Issue 2: Admin transfer timelock ─────────────────────────────────────

/// The ledger sequence number after which `accept_admin` becomes callable.
/// Set by `propose_admin`; cleared on `accept_admin` or `cancel_admin_proposal`.
/// value: u32
AdminTransferValidAfterLedger,

// ── Issue 3: Dispute counters ─────────────────────────────────────────────

/// Cumulative count of all `raise_dispute` calls across every escrow — value: u32
TotalDisputeCount,

/// Per-escrow count of `raise_dispute` calls — key: u64 (escrow_id), value: u32
DisputeCountByEscrow(u64),
}
Loading