From c71bf71d1e8ad336ba20539f710131ef76a9c533 Mon Sep 17 00:00:00 2001 From: kelvin1john0901-blip Date: Wed, 29 Jul 2026 00:43:57 +0000 Subject: [PATCH] feat(contract): pending milestone count, admin timelock transfer, dispute count tracking --- contracts/escrow_contract/src/event_names.rs | 9 +++ contracts/escrow_contract/src/events.rs | 41 ++++++++++++ contracts/escrow_contract/src/lib.rs | 69 ++++++++++++++++++-- contracts/escrow_contract/src/types.rs | 23 +++++++ 4 files changed, 137 insertions(+), 5 deletions(-) diff --git a/contracts/escrow_contract/src/event_names.rs b/contracts/escrow_contract/src/event_names.rs index 06e0f9e5..f06534a5 100644 --- a/contracts/escrow_contract/src/event_names.rs +++ b/contracts/escrow_contract/src/event_names.rs @@ -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"); diff --git a/contracts/escrow_contract/src/events.rs b/contracts/escrow_contract/src/events.rs index c7143367..a28a3007 100644 --- a/contracts/escrow_contract/src/events.rs +++ b/contracts/escrow_contract/src/events.rs @@ -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()), + ); +} diff --git a/contracts/escrow_contract/src/lib.rs b/contracts/escrow_contract/src/lib.rs index 260b29b0..d75cf31b 100644 --- a/contracts/escrow_contract/src/lib.rs +++ b/contracts/escrow_contract/src/lib.rs @@ -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. @@ -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)?; @@ -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() @@ -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(()) } diff --git a/contracts/escrow_contract/src/types.rs b/contracts/escrow_contract/src/types.rs index 2a87acd0..7f5fb263 100644 --- a/contracts/escrow_contract/src/types.rs +++ b/contracts/escrow_contract/src/types.rs @@ -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), }