diff --git a/contracts/ephemeral_account/src/events.rs b/contracts/ephemeral_account/src/events.rs index 15e6f7a..552e5b7 100644 --- a/contracts/ephemeral_account/src/events.rs +++ b/contracts/ephemeral_account/src/events.rs @@ -37,12 +37,14 @@ pub struct AccountExpired { pub reserve_amount: i128, } -/// Reserve reclaimed event - emitted when base reserve is reclaimed #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct ReserveReclaimed { pub destination: Address, pub amount: i128, + pub sweep_id: u64, + pub fully_reclaimed: bool, + pub remaining_reserve: i128, } pub fn emit_account_created(env: &Env, creator: Address, expiry_ledger: u32) { @@ -85,10 +87,20 @@ pub fn emit_account_expired( env.events().publish((symbol_short!("expired"),), event); } -pub fn emit_reserve_reclaimed(env: &Env, destination: Address, amount: i128) { +pub fn emit_reserve_reclaimed( + env: &Env, + destination: Address, + amount: i128, + sweep_id: u64, + fully_reclaimed: bool, + remaining_reserve: i128, +) { let event = ReserveReclaimed { destination, amount, + sweep_id, + fully_reclaimed, + remaining_reserve, }; env.events().publish((symbol_short!("reserve"),), event); } diff --git a/contracts/ephemeral_account/src/lib.rs b/contracts/ephemeral_account/src/lib.rs index 8ea2869..ad227e8 100644 --- a/contracts/ephemeral_account/src/lib.rs +++ b/contracts/ephemeral_account/src/lib.rs @@ -16,6 +16,8 @@ pub use events::{ }; pub use storage::DataKey; +const BASE_RESERVE_STROOPS: i128 = 1_000_000_000; + #[contract] pub struct EphemeralAccountContract; @@ -56,6 +58,7 @@ impl EphemeralAccountContract { storage::set_expiry_ledger(&env, expiry_ledger); storage::set_recovery_address(&env, &recovery_address); storage::set_status(&env, AccountStatus::Active); + storage::init_reserve_tracking(&env, BASE_RESERVE_STROOPS); // Emit event events::emit_account_created(&env, creator, expiry_ledger); @@ -167,17 +170,16 @@ impl EphemeralAccountContract { storage::set_status(&env, AccountStatus::Swept); storage::set_swept_to(&env, &destination); - // Note: Actual token transfers happen in the SDK via Stellar SDK - // This contract enforces the business logic and authorization - // The SDK will call this function, get approval, then execute all transfers atomically - // All transfers must succeed or the entire operation fails - - // Base reserve amount (1 XLM in stroops) - let reserve_amount = 1_000_000_000i128; + // Note: Actual token transfers happen in the SDK via Stellar SDK. + // This contract enforces authorization/state transitions and reserve lifecycle. + let sweep_id = env.ledger().sequence() as u64; + storage::set_last_sweep_id(&env, sweep_id); - // Emit events for sweep execution and reserve reclamation + // Emit sweep event once transfer authorization/state update succeeds. events::emit_sweep_executed_multi(&env, destination.clone(), &payments_vec); - events::emit_reserve_reclaimed(&env, destination, reserve_amount); + + // Reclaim base reserve only after successful sweep state transition. + Self::reclaim_reserve_to(&env, &destination, sweep_id)?; Ok(()) } @@ -235,23 +237,92 @@ impl EphemeralAccountContract { // Get total amount from all payments if any payments were received let total_amount = if storage::has_payment_received(&env) { let payments = storage::get_all_payments(&env); - payments - .iter() - .fold(0, |sum, (_, payment)| sum + payment.amount) + let mut total = 0i128; + for (_, payment) in payments.iter() { + total = total + .checked_add(payment.amount) + .ok_or(Error::InvalidAmount)?; + } + total } else { 0 }; - // Base reserve amount (1 XLM in stroops) - let reserve_amount = 1_000_000_000i128; + let sweep_id = env.ledger().sequence() as u64; + storage::set_last_sweep_id(&env, sweep_id); + + // Reclaim reserve to recovery destination. + let reclaimed_reserve = Self::reclaim_reserve_to(&env, &recovery_address, sweep_id)?; - // Emit events for account expiration and reserve reclamation - events::emit_account_expired(&env, recovery_address.clone(), total_amount, reserve_amount); - events::emit_reserve_reclaimed(&env, recovery_address, reserve_amount); + // Emit expiration event with reserve amount reclaimed in this call. + events::emit_account_expired(&env, recovery_address, total_amount, reclaimed_reserve); Ok(()) } + /// Reclaim remaining base reserve for a previously swept/expired account. + /// This is safe to call repeatedly: once fully reclaimed, subsequent calls transfer 0. + pub fn reclaim_reserve(env: Env) -> Result { + if !storage::is_initialized(&env) { + return Err(Error::NotInitialized); + } + + let status = storage::get_status(&env); + if status != AccountStatus::Swept && status != AccountStatus::Expired { + return Err(Error::InvalidStatus); + } + + let destination = storage::get_swept_to(&env).ok_or(Error::InvalidStatus)?; + let sweep_id = storage::get_last_sweep_id(&env); + + Self::reclaim_reserve_to(&env, &destination, sweep_id) + } + + /// Remaining reserve amount (stroops) still eligible for reclaim. + pub fn get_reserve_remaining(env: Env) -> i128 { + if !storage::is_initialized(&env) { + return 0; + } + + storage::get_base_reserve_remaining(&env) + } + + /// Tracked reserve currently available for transfer (stroops). + pub fn get_reserve_available(env: Env) -> i128 { + if !storage::is_initialized(&env) { + return 0; + } + + storage::get_available_reserve(&env) + } + + /// Whether reserve has been fully reclaimed. + pub fn is_reserve_reclaimed(env: Env) -> bool { + if !storage::is_initialized(&env) { + return false; + } + + storage::is_reserve_reclaimed(&env) + } + + /// Last reserve reclaim event payload emitted by this contract. + pub fn get_last_reserve_event(env: Env) -> Option { + if !storage::is_initialized(&env) { + return None; + } + + storage::get_last_reserve_event(&env) + } + + /// Number of reserve reclaim events emitted by this contract. + pub fn get_reserve_reclaim_event_count(env: Env) -> u32 { + if !storage::is_initialized(&env) { + return 0; + } + + storage::get_reserve_event_count(&env) + } + /// Get account information pub fn get_info(env: Env) -> Result { if !storage::is_initialized(&env) { @@ -291,4 +362,72 @@ impl EphemeralAccountContract { // Future: Verify signature against authorized signer Ok(()) } + + fn reclaim_reserve_to(env: &Env, destination: &Address, sweep_id: u64) -> Result { + let reserve_remaining = storage::get_base_reserve_remaining(env); + let reserve_available = storage::get_available_reserve(env); + + if reserve_remaining < 0 || reserve_available < 0 { + return Err(Error::InvalidAmount); + } + + if reserve_remaining == 0 { + storage::set_reserve_reclaimed(env, true); + let event = ReserveReclaimed { + destination: destination.clone(), + amount: 0, + sweep_id, + fully_reclaimed: true, + remaining_reserve: 0, + }; + Self::emit_and_store_reserve_event(env, event)?; + return Ok(0); + } + + let reclaim_amount = if reserve_available < reserve_remaining { + reserve_available + } else { + reserve_remaining + }; + + let new_available = reserve_available + .checked_sub(reclaim_amount) + .ok_or(Error::InvalidAmount)?; + let new_remaining = reserve_remaining + .checked_sub(reclaim_amount) + .ok_or(Error::InvalidAmount)?; + + storage::set_available_reserve(env, new_available); + storage::set_base_reserve_remaining(env, new_remaining); + storage::set_reserve_reclaimed(env, new_remaining == 0); + + let event = ReserveReclaimed { + destination: destination.clone(), + amount: reclaim_amount, + sweep_id, + fully_reclaimed: new_remaining == 0, + remaining_reserve: new_remaining, + }; + Self::emit_and_store_reserve_event(env, event)?; + + Ok(reclaim_amount) + } + + fn emit_and_store_reserve_event(env: &Env, event: ReserveReclaimed) -> Result<(), Error> { + events::emit_reserve_reclaimed( + env, + event.destination.clone(), + event.amount, + event.sweep_id, + event.fully_reclaimed, + event.remaining_reserve, + ); + + let event_count = storage::get_reserve_event_count(env); + let next_count = event_count.checked_add(1).ok_or(Error::InvalidAmount)?; + storage::set_last_reserve_event(env, &event); + storage::set_reserve_event_count(env, next_count); + + Ok(()) + } } diff --git a/contracts/ephemeral_account/src/storage.rs b/contracts/ephemeral_account/src/storage.rs index 30a3921..618b6bb 100644 --- a/contracts/ephemeral_account/src/storage.rs +++ b/contracts/ephemeral_account/src/storage.rs @@ -1,3 +1,4 @@ +use crate::events::ReserveReclaimed; use bridgelet_shared::{AccountStatus, Payment}; use soroban_sdk::{contracttype, Address, Env, Map}; @@ -10,6 +11,12 @@ pub enum DataKey { Payments, Status, SweptTo, + BaseReserveRemaining, + AvailableReserve, + ReserveReclaimed, + LastSweepId, + ReserveEventCount, + LastReserveEvent, } // Initialization @@ -113,3 +120,87 @@ pub fn set_swept_to(env: &Env, address: &Address) { pub fn get_swept_to(env: &Env) -> Option
{ env.storage().instance().get(&DataKey::SweptTo) } + +// Reserve lifecycle +pub fn init_reserve_tracking(env: &Env, base_reserve: i128) { + set_base_reserve_remaining(env, base_reserve); + set_available_reserve(env, base_reserve); + set_reserve_reclaimed(env, base_reserve == 0); + set_last_sweep_id(env, 0); + set_reserve_event_count(env, 0); +} + +pub fn set_base_reserve_remaining(env: &Env, amount: i128) { + env.storage() + .instance() + .set(&DataKey::BaseReserveRemaining, &amount); +} + +pub fn get_base_reserve_remaining(env: &Env) -> i128 { + env.storage() + .instance() + .get(&DataKey::BaseReserveRemaining) + .unwrap_or(0) +} + +pub fn set_available_reserve(env: &Env, amount: i128) { + env.storage() + .instance() + .set(&DataKey::AvailableReserve, &amount); +} + +pub fn get_available_reserve(env: &Env) -> i128 { + env.storage() + .instance() + .get(&DataKey::AvailableReserve) + .unwrap_or(0) +} + +pub fn set_reserve_reclaimed(env: &Env, reclaimed: bool) { + env.storage() + .instance() + .set(&DataKey::ReserveReclaimed, &reclaimed); +} + +pub fn is_reserve_reclaimed(env: &Env) -> bool { + env.storage() + .instance() + .get(&DataKey::ReserveReclaimed) + .unwrap_or(false) +} + +pub fn set_last_sweep_id(env: &Env, sweep_id: u64) { + env.storage() + .instance() + .set(&DataKey::LastSweepId, &sweep_id); +} + +pub fn get_last_sweep_id(env: &Env) -> u64 { + env.storage() + .instance() + .get(&DataKey::LastSweepId) + .unwrap_or(0) +} + +pub fn set_reserve_event_count(env: &Env, count: u32) { + env.storage() + .instance() + .set(&DataKey::ReserveEventCount, &count); +} + +pub fn get_reserve_event_count(env: &Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::ReserveEventCount) + .unwrap_or(0) +} + +pub fn set_last_reserve_event(env: &Env, event: &ReserveReclaimed) { + env.storage() + .instance() + .set(&DataKey::LastReserveEvent, event); +} + +pub fn get_last_reserve_event(env: &Env) -> Option { + env.storage().instance().get(&DataKey::LastReserveEvent) +} diff --git a/contracts/ephemeral_account/src/test.rs b/contracts/ephemeral_account/src/test.rs index fe08eac..87077f7 100644 --- a/contracts/ephemeral_account/src/test.rs +++ b/contracts/ephemeral_account/src/test.rs @@ -1,19 +1,25 @@ #[cfg(test)] mod test { + extern crate std; + use crate::{ - AccountStatus, EphemeralAccountContract, EphemeralAccountContractClient, ReserveReclaimed, - }; - use soroban_sdk::{ - symbol_short, - testutils::{Address as _, Events}, - Address, BytesN, Env, TryFromVal, Val, + storage, AccountStatus, EphemeralAccountContract, EphemeralAccountContractClient, + ReserveReclaimed, }; + use soroban_sdk::{testutils::Address as _, Address, BytesN, Env}; + + const BASE_RESERVE_STROOPS: i128 = 1_000_000_000; + + fn latest_reserve_event(client: &EphemeralAccountContractClient) -> ReserveReclaimed { + client + .get_last_reserve_event() + .expect("reserve event was not emitted") + } #[test] fn test_initialize() { let env = Env::default(); env.mock_all_auths(); - env.budget().reset_unlimited(); let contract_id = env.register(EphemeralAccountContract, ()); let client = EphemeralAccountContractClient::new(&env, &contract_id); @@ -23,9 +29,12 @@ mod test { let expiry_ledger = env.ledger().sequence() + 1000; client.initialize(&creator, &expiry_ledger, &recovery); - let status = client.get_status(); - assert_eq!(status, AccountStatus::Active); - assert_eq!(client.is_expired(), false); + + assert_eq!(client.get_status(), AccountStatus::Active); + assert!(!client.is_expired()); + assert_eq!(client.get_reserve_remaining(), BASE_RESERVE_STROOPS); + assert_eq!(client.get_reserve_available(), BASE_RESERVE_STROOPS); + assert!(!client.is_reserve_reclaimed()); } #[test] @@ -44,8 +53,7 @@ mod test { client.initialize(&creator, &expiry_ledger, &recovery); client.record_payment(&100, &asset); - let status = client.get_status(); - assert_eq!(status, AccountStatus::PaymentReceived); + assert_eq!(client.get_status(), AccountStatus::PaymentReceived); } #[test] @@ -72,8 +80,7 @@ mod test { let info = client.get_info(); assert_eq!(info.payment_count, 2); - let status = client.get_status(); - assert_eq!(status, AccountStatus::PaymentReceived); + assert_eq!(client.get_status(), AccountStatus::PaymentReceived); } #[test] @@ -96,8 +103,17 @@ mod test { let auth_sig = BytesN::from_array(&env, &[0u8; 64]); client.sweep(&destination, &auth_sig); - let status = client.get_status(); - assert_eq!(status, AccountStatus::Swept); + assert_eq!(client.get_status(), AccountStatus::Swept); + assert_eq!(client.get_reserve_remaining(), 0); + assert!(client.is_reserve_reclaimed()); + + let reserve_event = latest_reserve_event(&client); + assert_eq!(reserve_event.destination, destination); + assert_eq!(reserve_event.amount, BASE_RESERVE_STROOPS); + assert_eq!(reserve_event.remaining_reserve, 0); + assert!(reserve_event.fully_reclaimed); + assert_eq!(reserve_event.sweep_id, env.ledger().sequence() as u64); + assert_eq!(client.get_reserve_reclaim_event_count(), 1); } #[test] @@ -142,9 +158,10 @@ mod test { } #[test] - fn test_sweep_multiple_assets() { + fn test_sweep_reclaims_base_reserve_success_lifecycle() { let env = Env::default(); env.mock_all_auths(); + let contract_id = env.register(EphemeralAccountContract, ()); let client = EphemeralAccountContractClient::new(&env, &contract_id); @@ -157,121 +174,147 @@ mod test { let asset1 = Address::generate(&env); let asset2 = Address::generate(&env); - let asset3 = Address::generate(&env); - client.record_payment(&100, &asset1); client.record_payment(&200, &asset2); - client.record_payment(&300, &asset3); - - let info = client.get_info(); - assert_eq!(info.payment_count, 3); - assert_eq!(info.payments.len(), 3); let auth_sig = BytesN::from_array(&env, &[0u8; 64]); client.sweep(&destination, &auth_sig); assert_eq!(client.get_status(), AccountStatus::Swept); + assert_eq!(client.get_reserve_remaining(), 0); + assert!(client.is_reserve_reclaimed()); + + let reserve_event = latest_reserve_event(&client); + assert_eq!(reserve_event.destination, destination); + assert_eq!(reserve_event.amount, BASE_RESERVE_STROOPS); + assert_eq!(reserve_event.remaining_reserve, 0); + assert!(reserve_event.fully_reclaimed); + assert_eq!(client.get_reserve_reclaim_event_count(), 1); } #[test] - fn test_multi_payment_events() { + fn test_reserve_double_claim_prevention() { let env = Env::default(); env.mock_all_auths(); + let contract_id = env.register(EphemeralAccountContract, ()); let client = EphemeralAccountContractClient::new(&env, &contract_id); let creator = Address::generate(&env); let recovery = Address::generate(&env); + let destination = Address::generate(&env); + let asset = Address::generate(&env); let expiry_ledger = env.ledger().sequence() + 1000; client.initialize(&creator, &expiry_ledger, &recovery); + client.record_payment(&100, &asset); - let asset1 = Address::generate(&env); - let asset2 = Address::generate(&env); + let auth_sig = BytesN::from_array(&env, &[0u8; 64]); + client.sweep(&destination, &auth_sig); - client.record_payment(&100, &asset1); - client.record_payment(&200, &asset2); + assert_eq!(client.get_reserve_remaining(), 0); + assert!(client.is_reserve_reclaimed()); + + let reclaimed_again = client.reclaim_reserve(); + assert_eq!(reclaimed_again, 0); + assert_eq!(client.get_reserve_remaining(), 0); + + let reserve_event = latest_reserve_event(&client); + assert_eq!(reserve_event.destination, destination); + assert_eq!(reserve_event.amount, 0); + assert_eq!(reserve_event.remaining_reserve, 0); + assert!(reserve_event.fully_reclaimed); + assert_eq!(client.get_reserve_reclaim_event_count(), 2); } #[test] - #[ignore] - fn test_sweep_emits_reserve_reclaimed_event() { + fn test_reserve_reclaim_insufficient_balance_lifecycle() { let env = Env::default(); env.mock_all_auths(); - env.budget().reset_unlimited(); let contract_id = env.register(EphemeralAccountContract, ()); let client = EphemeralAccountContractClient::new(&env, &contract_id); let creator = Address::generate(&env); let recovery = Address::generate(&env); - let asset = Address::generate(&env); let destination = Address::generate(&env); + let asset = Address::generate(&env); let expiry_ledger = env.ledger().sequence() + 1000; client.initialize(&creator, &expiry_ledger, &recovery); client.record_payment(&100, &asset); + let initial_available = 250_000_000i128; + env.as_contract(&contract_id, || { + storage::set_available_reserve(&env, initial_available); + }); + let auth_sig = BytesN::from_array(&env, &[0u8; 64]); client.sweep(&destination, &auth_sig); + let expected_remaining = BASE_RESERVE_STROOPS - initial_available; assert_eq!(client.get_status(), AccountStatus::Swept); - - let events = env.events().all(); - - let reserve_event = - events - .iter() - .find(|(_, topics, _): &(Address, soroban_sdk::Vec, Val)| { - if let Some(topic) = topics.get(0) { - if let Ok(sym) = soroban_sdk::Symbol::try_from_val(&env, &topic) { - return sym == symbol_short!("reserve"); - } - } - false - }); - - assert!( - reserve_event.is_some(), - "ReserveReclaimed event was not emitted" - ); - - let (_, _, data) = reserve_event.unwrap(); - let reclaimed = ReserveReclaimed::try_from_val(&env, &data) - .expect("Failed to decode ReserveReclaimed event data"); - assert_eq!(reclaimed.destination, destination); - assert_eq!(reclaimed.amount, 1_000_000_000i128); + assert_eq!(client.get_reserve_remaining(), expected_remaining); + assert_eq!(client.get_reserve_available(), 0); + assert!(!client.is_reserve_reclaimed()); + + let partial_event = latest_reserve_event(&client); + assert_eq!(partial_event.destination, destination); + assert_eq!(partial_event.amount, initial_available); + assert_eq!(partial_event.remaining_reserve, expected_remaining); + assert!(!partial_event.fully_reclaimed); + + let no_balance_reclaim = client.reclaim_reserve(); + assert_eq!(no_balance_reclaim, 0); + assert_eq!(client.get_reserve_remaining(), expected_remaining); + assert!(!client.is_reserve_reclaimed()); + + env.as_contract(&contract_id, || { + storage::set_available_reserve(&env, expected_remaining); + }); + let final_reclaim = client.reclaim_reserve(); + assert_eq!(final_reclaim, expected_remaining); + assert_eq!(client.get_reserve_remaining(), 0); + assert!(client.is_reserve_reclaimed()); + + let noop_after_full_reclaim = client.reclaim_reserve(); + assert_eq!(noop_after_full_reclaim, 0); + assert_eq!(client.get_reserve_remaining(), 0); + assert_eq!(client.get_reserve_reclaim_event_count(), 4); } #[test] - fn test_sweep_multiple_assets_with_reserve_event() { + fn test_replay_sweep_call_does_not_reclaim_twice() { let env = Env::default(); env.mock_all_auths(); + let contract_id = env.register(EphemeralAccountContract, ()); let client = EphemeralAccountContractClient::new(&env, &contract_id); let creator = Address::generate(&env); let recovery = Address::generate(&env); let destination = Address::generate(&env); + let asset = Address::generate(&env); let expiry_ledger = env.ledger().sequence() + 1000; client.initialize(&creator, &expiry_ledger, &recovery); - - let asset1 = Address::generate(&env); - let asset2 = Address::generate(&env); - let asset3 = Address::generate(&env); - - client.record_payment(&100, &asset1); - client.record_payment(&200, &asset2); - client.record_payment(&300, &asset3); - - let info = client.get_info(); - assert_eq!(info.payment_count, 3); - assert_eq!(info.payments.len(), 3); + client.record_payment(&100, &asset); let auth_sig = BytesN::from_array(&env, &[0u8; 64]); client.sweep(&destination, &auth_sig); + let reserve_events_before = client.get_reserve_reclaim_event_count(); + let replay_attempt = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.sweep(&destination, &auth_sig); + })); + + assert!(replay_attempt.is_err()); assert_eq!(client.get_status(), AccountStatus::Swept); + assert_eq!(client.get_reserve_remaining(), 0); + assert!(client.is_reserve_reclaimed()); + assert_eq!( + client.get_reserve_reclaim_event_count(), + reserve_events_before + ); } }