From e11efe6a0c094c0974bae1f8125030a1b16801b4 Mon Sep 17 00:00:00 2001 From: Dopezapha Date: Sat, 30 May 2026 00:05:59 +0100 Subject: [PATCH] fix #392 event hub emit gas and #395 reentry tests --- src/event_hub/README.md | 4 +- src/event_hub/lib.rs | 165 ++++++++++++++++------------------------ src/flash_loan/lib.rs | 19 +++++ src/flash_loan/tests.rs | 101 +++++++++++++++++++++++- src/utils/events.rs | 5 +- 5 files changed, 187 insertions(+), 107 deletions(-) diff --git a/src/event_hub/README.md b/src/event_hub/README.md index 5c92ebd..58fdc30 100644 --- a/src/event_hub/README.md +++ b/src/event_hub/README.md @@ -117,7 +117,7 @@ let event = client.get_event(&1u64); The Event Hub emits `CrossContractEvent` as part of the standardized `AnchorEvent` enum, which allows off-chain indexers to: -1. **Listen for Events**: Monitor all events published with `symbol_short!("anchor")` and `symbol_short!("x_contract")` topics +1. **Listen for Events**: Monitor all events published with `symbol_short!("anchor")` and `symbol_short!("xcontract")` topics 2. **Store Metadata**: Access source contract, timestamp, and event type for filtering 3. **Process Events**: Decode the `event_data` based on `event_type` for database storage 4. **Query History**: Use pagination APIs to build complete event histories @@ -127,7 +127,7 @@ The Event Hub emits `CrossContractEvent` as part of the standardized `AnchorEven ```javascript // Listen for cross-contract events provider.on('contract:event', async (event) => { - if (event.topic[0] === 'anchor' && event.topic[1] === 'x_contract') { + if (event.topic[0] === 'anchor' && event.topic[1] === 'xcontract') { const payload = event.data; // Store in database diff --git a/src/event_hub/lib.rs b/src/event_hub/lib.rs index 6d928d6..23b4451 100644 --- a/src/event_hub/lib.rs +++ b/src/event_hub/lib.rs @@ -11,10 +11,11 @@ //! - Maintain event log with timestamps and metadata //! - Query event history for indexers +use anchorpointutils::events::{emit_event, AnchorEvent, CrossContractEvent}; use soroban_sdk::{ - bytes, contract, contractimpl, contracttype, symbol_short, vec, Address, Bytes, Env, String as SorobanString, Map, Vec, + contract, contractimpl, contracttype, symbol_short, Address, Bytes, Env, Map, + String as SorobanString, Vec, }; -use utils::events::{emit_event, AnchorEvent, CrossContractEvent}; const MAX_REGISTERED_CONTRACTS: usize = 100; @@ -28,8 +29,8 @@ pub enum DataKey { RegisteredContracts, /// Event counter for generating unique event IDs EventCounter, - /// Event archive: log of all captured cross-contract events - EventLog, + /// Event archive entry keyed by event id. + EventLogEntry(u64), } // ── Contract Types ────────────────────────────────────────────────────────── @@ -67,24 +68,15 @@ impl EventHub { } admin.require_auth(); env.storage().instance().set(&DataKey::Admin, &admin); - env.storage() - .instance() - .set(&DataKey::EventCounter, &0u64); + env.storage().instance().set(&DataKey::EventCounter, &0u64); let contracts: Map = Map::new(&env); env.storage() .instance() .set(&DataKey::RegisteredContracts, &contracts); - let event_log: Vec = Vec::new(&env); - env.storage() - .persistent() - .set(&DataKey::EventLog, &event_log); - - env.events().publish( - (symbol_short!("hub"), symbol_short!("init")), - admin, - ); + env.events() + .publish((symbol_short!("hub"), symbol_short!("init")), admin); } /// Register a new source contract with the Event Hub @@ -117,10 +109,8 @@ impl EventHub { .instance() .set(&DataKey::RegisteredContracts, &contracts); - env.events().publish( - (symbol_short!("hub"), symbol_short!("reg")), - contract, - ); + env.events() + .publish((symbol_short!("hub"), symbol_short!("reg")), contract); } /// Unregister a source contract @@ -149,10 +139,8 @@ impl EventHub { .instance() .set(&DataKey::RegisteredContracts, &contracts); - env.events().publish( - (symbol_short!("hub"), symbol_short!("unreg")), - contract, - ); + env.events() + .publish((symbol_short!("hub"), symbol_short!("unreg")), contract); } /// Check if a contract is registered @@ -164,16 +152,7 @@ impl EventHub { /// # Returns /// `true` if the contract is registered, `false` otherwise pub fn is_registered(env: Env, contract: Address) -> bool { - let contracts: Map = env - .storage() - .instance() - .get(&DataKey::RegisteredContracts) - .expect("hub not initialized"); - - contracts - .get(contract) - .map(|v| v) - .unwrap_or(false) + Self::is_registered_source(&env, &contract) } /// Capture and re-emit an event from a source contract @@ -193,7 +172,7 @@ impl EventHub { event_data: Bytes, ) { // Verify source contract is registered - let is_registered = Self::is_registered(env.clone(), source_contract.clone()); + let is_registered = Self::is_registered_source(&env, &source_contract); assert!(is_registered, "source contract not registered"); // Get current timestamp @@ -219,17 +198,9 @@ impl EventHub { event_data: event_data.clone(), }; - // Add to event log - let mut event_log: Vec = env - .storage() - .persistent() - .get(&DataKey::EventLog) - .unwrap_or_else(|| Vec::new(&env)); - - event_log.push_back(log_entry); env.storage() .persistent() - .set(&DataKey::EventLog, &event_log); + .set(&DataKey::EventLogEntry(new_counter), &log_entry); // Create and emit cross-contract event let cross_contract_event = CrossContractEvent { @@ -266,25 +237,24 @@ impl EventHub { /// # Returns /// A vector of EventLogEntry items pub fn get_events(env: Env, start_id: u64, limit: u32) -> Vec { - let event_log: Vec = env - .storage() - .persistent() - .get(&DataKey::EventLog) - .unwrap_or_else(|| Vec::new(&env)); - let mut result = Vec::new(&env); - let limit = limit as usize; - let mut count = 0; + let counter = Self::get_event_count(env.clone()); + let mut event_id = start_id; - for i in 0..event_log.len() { - if count >= limit { - break; + while event_id <= counter && result.len() < limit { + if event_id == 0 { + event_id = 1; + continue; } - let entry = event_log.get(i).unwrap(); - if entry.id >= start_id { + + if let Some(entry) = Self::get_event_entry(&env, event_id) { result.push_back(entry); - count += 1; } + + if event_id == u64::MAX { + break; + } + event_id += 1; } result @@ -299,20 +269,7 @@ impl EventHub { /// # Returns /// The EventLogEntry if found, panics otherwise pub fn get_event(env: Env, event_id: u64) -> EventLogEntry { - let event_log: Vec = env - .storage() - .persistent() - .get(&DataKey::EventLog) - .unwrap_or_else(|| Vec::new(&env)); - - for i in 0..event_log.len() { - let entry = event_log.get(i).unwrap(); - if entry.id == event_id { - return entry; - } - } - - panic!("event not found"); + Self::get_event_entry(&env, event_id).expect("event not found") } /// Get events from a specific source contract @@ -329,25 +286,21 @@ impl EventHub { source_contract: Address, limit: u32, ) -> Vec { - let event_log: Vec = env - .storage() - .persistent() - .get(&DataKey::EventLog) - .unwrap_or_else(|| Vec::new(&env)); - let mut result = Vec::new(&env); - let limit = limit as usize; - let mut count = 0; + let counter = Self::get_event_count(env.clone()); + let mut event_id = 1u64; + + while event_id <= counter && result.len() < limit { + if let Some(entry) = Self::get_event_entry(&env, event_id) { + if entry.source_contract == source_contract { + result.push_back(entry); + } + } - for i in 0..event_log.len() { - if count >= limit { + if event_id == u64::MAX { break; } - let entry = event_log.get(i).unwrap(); - if entry.source_contract == source_contract { - result.push_back(entry); - count += 1; - } + event_id += 1; } result @@ -367,14 +320,23 @@ impl EventHub { .get(&DataKey::RegisteredContracts) .expect("hub not initialized"); - let mut result = Vec::new(&env); - for i in 0..contracts.len() { - if let Some(key) = contracts.key_by_index(i) { - result.push_back(key); - } - } + contracts.keys() + } - result + fn is_registered_source(env: &Env, contract: &Address) -> bool { + let contracts: Map = env + .storage() + .instance() + .get(&DataKey::RegisteredContracts) + .expect("hub not initialized"); + + contracts.get(contract.clone()).unwrap_or(false) + } + + fn get_event_entry(env: &Env, event_id: u64) -> Option { + env.storage() + .persistent() + .get(&DataKey::EventLogEntry(event_id)) } } @@ -386,6 +348,7 @@ mod tests { #[test] fn test_initialize() { let env = Env::default(); + env.mock_all_auths(); let contract_id = env.register(EventHub, ()); let client = EventHubClient::new(&env, &contract_id); @@ -398,6 +361,7 @@ mod tests { #[test] fn test_register_contract() { let env = Env::default(); + env.mock_all_auths(); let contract_id = env.register(EventHub, ()); let client = EventHubClient::new(&env, &contract_id); @@ -413,6 +377,7 @@ mod tests { #[test] fn test_capture_event() { let env = Env::default(); + env.mock_all_auths(); env.ledger().set_timestamp(1_000_000u64); let contract_id = env.register(EventHub, ()); @@ -424,14 +389,10 @@ mod tests { let source_contract = Address::generate(&env); client.register_contract(&admin, &source_contract); - let event_type = SorobanString::from_slice(&env, b"transfer"); + let event_type = SorobanString::from_str(&env, "transfer"); let event_data = Bytes::from_slice(&env, b"test_event_data"); - client.capture_event( - &source_contract, - &event_type, - &event_data, - ); + client.capture_event(&source_contract, &event_type, &event_data); assert_eq!(client.get_event_count(), 1); @@ -447,6 +408,7 @@ mod tests { #[test] fn test_unregister_contract() { let env = Env::default(); + env.mock_all_auths(); let contract_id = env.register(EventHub, ()); let client = EventHubClient::new(&env, &contract_id); @@ -464,6 +426,7 @@ mod tests { #[test] fn test_get_events_by_contract() { let env = Env::default(); + env.mock_all_auths(); let contract_id = env.register(EventHub, ()); let client = EventHubClient::new(&env, &contract_id); @@ -476,7 +439,7 @@ mod tests { client.register_contract(&admin, &contract1); client.register_contract(&admin, &contract2); - let event_type = SorobanString::from_slice(&env, b"transfer"); + let event_type = SorobanString::from_str(&env, "transfer"); let event_data = Bytes::from_slice(&env, b"data"); client.capture_event(&contract1, &event_type, &event_data); diff --git a/src/flash_loan/lib.rs b/src/flash_loan/lib.rs index 0143871..19395b5 100644 --- a/src/flash_loan/lib.rs +++ b/src/flash_loan/lib.rs @@ -38,6 +38,8 @@ enum DataKey { FeeTiers, /// Security registry address (optional). SecurityRegistry, + /// Transaction-scoped lock for flash-loan callbacks. + ReentrancyLock, } // ── Public types ────────────────────────────────────────────────────────────── @@ -202,6 +204,7 @@ impl FlashLoanProvider { /// /// The entire transaction reverts if repayment is insufficient. pub fn flash_loan(env: Env, receiver: Address, token: Address, amount: i128) { + Self::acquire_reentrancy_lock(&env); assert!(amount > 0, "amount must be positive"); let fee = Self::_calculate_fee(&env, amount); @@ -228,6 +231,7 @@ impl FlashLoanProvider { env.events() .publish((symbol_short!("flash_ln"),), (receiver, token, amount, fee)); + Self::release_reentrancy_lock(&env); } // ── Batch flash loan ────────────────────────────────────────────────────── @@ -241,6 +245,7 @@ impl FlashLoanProvider { /// * `receiver` – contract implementing `FlashLoanBatchReceiver`. /// * `loans` – vec of `(token_address, amount)` pairs. pub fn flash_loan_batch(env: Env, receiver: Address, loans: Vec<(Address, i128)>) { + Self::acquire_reentrancy_lock(&env); if loans.is_empty() { panic!("cannot flash loan zero assets"); } @@ -293,6 +298,20 @@ impl FlashLoanProvider { env.events() .publish((symbol_short!("fl_batch"), receiver), loan_details); + Self::release_reentrancy_lock(&env); + } + + fn acquire_reentrancy_lock(env: &Env) { + if env.storage().temporary().has(&DataKey::ReentrancyLock) { + panic!("reentrant flash loan"); + } + env.storage() + .temporary() + .set(&DataKey::ReentrancyLock, &true); + } + + fn release_reentrancy_lock(env: &Env) { + env.storage().temporary().remove(&DataKey::ReentrancyLock); } } diff --git a/src/flash_loan/tests.rs b/src/flash_loan/tests.rs index 31c6701..f58859a 100644 --- a/src/flash_loan/tests.rs +++ b/src/flash_loan/tests.rs @@ -1,8 +1,7 @@ #[cfg(test)] mod tests { - use crate::{FlashLoanProvider, FlashLoanProviderClient, LoanDetail}; + use crate::{FlashLoanProvider, FlashLoanProviderClient}; use soroban_sdk::{ - symbol_short, testutils::Address as _, token::{Client as TokenClient, StellarAssetClient}, Address, Env, Vec, @@ -57,6 +56,42 @@ mod tests { } pub use mock_receiver_failure::MockReceiverFailure; + mod mock_receiver_reentrant { + use crate::FlashLoanProviderClient; + use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env}; + + #[contract] + pub struct MockReceiverReentrant; + + #[contractimpl] + impl MockReceiverReentrant { + pub fn execute_loan(env: Env, _token: Address, _amount: i128, _fee: i128) { + let provider = env + .storage() + .instance() + .get::<_, Address>(&symbol_short!("provider")) + .unwrap(); + let token = env + .storage() + .instance() + .get::<_, Address>(&symbol_short!("token")) + .unwrap(); + let provider_client = FlashLoanProviderClient::new(&env, &provider); + provider_client.flash_loan(&env.current_contract_address(), &token, &1); + } + + pub fn set_reentry_target(env: Env, provider: Address, token: Address) { + env.storage() + .instance() + .set(&symbol_short!("provider"), &provider); + env.storage() + .instance() + .set(&symbol_short!("token"), &token); + } + } + } + pub use mock_receiver_reentrant::{MockReceiverReentrant, MockReceiverReentrantClient}; + mod mock_batch_receiver_success { use crate::LoanDetail; use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, Env, Vec}; @@ -107,6 +142,38 @@ mod tests { } pub use mock_batch_receiver_failure::MockBatchReceiverFailure; + mod mock_batch_receiver_reentrant { + use crate::{FlashLoanProviderClient, LoanDetail}; + use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, Vec}; + + #[contract] + pub struct MockBatchReceiverReentrant; + + #[contractimpl] + impl MockBatchReceiverReentrant { + pub fn execute_batch_loan(env: Env, loans: Vec) { + let provider = env + .storage() + .instance() + .get::<_, Address>(&symbol_short!("provider")) + .unwrap(); + let token = loans.get(0).unwrap().token; + let provider_client = FlashLoanProviderClient::new(&env, &provider); + let reentrant_loans = soroban_sdk::vec![&env, (token, 1_i128)]; + provider_client.flash_loan_batch(&env.current_contract_address(), &reentrant_loans); + } + + pub fn set_provider(env: Env, provider: Address) { + env.storage() + .instance() + .set(&symbol_short!("provider"), &provider); + } + } + } + pub use mock_batch_receiver_reentrant::{ + MockBatchReceiverReentrant, MockBatchReceiverReentrantClient, + }; + mod mock_batch_receiver_partial { use crate::LoanDetail; use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, Env, Vec}; @@ -241,6 +308,20 @@ mod tests { provider_client.flash_loan(&receiver_id, &token_id, &100_000); } + #[test] + #[should_panic(expected = "Contract re-entry is not allowed")] + fn test_flash_loan_reentrancy_guard_blocks_nested_loan() { + let env = Env::default(); + let receiver_id = env.register(MockReceiverReentrant, ()); + let (provider_id, token_id, _admin) = setup_with_receiver(&env, &receiver_id); + + let receiver_client = MockReceiverReentrantClient::new(&env, &receiver_id); + receiver_client.set_reentry_target(&provider_id, &token_id); + + let provider_client = FlashLoanProviderClient::new(&env, &provider_id); + provider_client.flash_loan(&receiver_id, &token_id, &100_000); + } + #[test] fn test_set_fee_bps() { let env = Env::default(); @@ -433,6 +514,22 @@ mod tests { provider_client.flash_loan_batch(&receiver_id, &loans); } + #[test] + #[should_panic(expected = "Contract re-entry is not allowed")] + fn test_flash_loan_batch_reentrancy_guard_blocks_nested_batch() { + let env = Env::default(); + let receiver_id = env.register(MockBatchReceiverReentrant, ()); + let (provider_id, token_ids, _admin) = + setup_multiple_tokens_with_receiver(&env, 1, &receiver_id); + + let receiver_client = MockBatchReceiverReentrantClient::new(&env, &receiver_id); + receiver_client.set_provider(&provider_id); + + let provider_client = FlashLoanProviderClient::new(&env, &provider_id); + let loans = soroban_sdk::vec![&env, (token_ids.get(0).unwrap(), 100_000_i128)]; + provider_client.flash_loan_batch(&receiver_id, &loans); + } + #[test] #[should_panic(expected = "Flash loan not repaid")] fn test_flash_loan_batch_partial_repayment() { diff --git a/src/utils/events.rs b/src/utils/events.rs index bab48af..6163c3e 100644 --- a/src/utils/events.rs +++ b/src/utils/events.rs @@ -3,7 +3,7 @@ //! Provides a unified shape for all events emitted by AnchorPoint contracts, //! making it easier for off-chain indexers to process Soroban data. -use soroban_sdk::{contracttype, symbol_short, Address, Env, Bytes}; +use soroban_sdk::{contracttype, symbol_short, Address, Bytes, Env}; #[contracttype] #[derive(Clone, Debug, PartialEq)] @@ -111,7 +111,7 @@ pub fn emit_event(env: &Env, event: AnchorEvent) { AnchorEvent::Voted(_) => symbol_short!("voted"), AnchorEvent::ProposalExecuted(_) => symbol_short!("prop_exe"), AnchorEvent::FundsReleased(_) => symbol_short!("release"), - AnchorEvent::CrossContractEvent(_) => symbol_short!("x_contract"), + AnchorEvent::CrossContractEvent(_) => symbol_short!("xcontract"), }; env.events() @@ -123,6 +123,7 @@ mod tests { use super::*; use soroban_sdk::{ contract, contractimpl, testutils::Address as _, testutils::Events, vec, FromVal, IntoVal, + Val, }; #[contract]