diff --git a/Cargo.lock b/Cargo.lock index ad20e8f1..8cc2a9f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -278,6 +278,7 @@ dependencies = [ name = "creator-keys-factory" version = "0.1.0" dependencies = [ + "creator-keys", "soroban-sdk", ] diff --git a/creator-keys/src/events.rs b/creator-keys/src/events.rs index 8e85da00..8b9ce59f 100644 --- a/creator-keys/src/events.rs +++ b/creator-keys/src/events.rs @@ -52,25 +52,33 @@ pub const FEE_COLLECTED_EVENT_NAME: Symbol = symbol_short!("fee_coll"); /// Stable fee collection event payload for downstream indexers. /// /// Event shape: -/// - topics: `(FEE_COLLECTED_EVENT_NAME, treasury)` +/// - topics: `(FEE_COLLECTED_EVENT_NAME, treasury, trade_id)` /// - data: `FeeCollectedEvent` /// /// Emitted on every buy and sell once the protocol trade fee is configured, -/// carrying the deducted amount and the treasury address that received it. +/// carrying the trade identifier, the gross amount, the deducted fee, and the +/// treasury address that received it. #[derive(Clone, Debug, Eq, PartialEq)] #[contracttype] pub struct FeeCollectedEvent { /// Treasury address that received the fee. pub treasury: Address, - /// Fee amount deducted from the trade. + /// Monotonic trade counter for the fee collection event. + pub trade_id: u64, + /// Gross trade amount before the protocol deduction. pub amount: i128, + /// Fee amount deducted from the trade. + pub fee: i128, /// Ledger sequence number at the time of the trade. pub ledger: u32, } -/// Shared fee collected event topics tuple. -pub fn fee_collected_topics(treasury: &Address) -> (Symbol, Address) { - (FEE_COLLECTED_EVENT_NAME, treasury.clone()) +/// Shared fee collected event topics tuple including the trade identifier. +pub fn fee_collected_topics_with_trade_id( + treasury: &Address, + trade_id: u64, +) -> (Symbol, Address, u64) { + (FEE_COLLECTED_EVENT_NAME, treasury.clone(), trade_id) } /// Event name for a sell rejected by the anti-flash-trade lockup window. diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index edbfee66..6a794ed3 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -593,6 +593,7 @@ pub mod constants { pub const RETENTION_POLICY: DataKey = DataKey::RetentionPolicy; pub const GLOBAL_DEADLINE_LEDGER: DataKey = DataKey::GlobalDeadlineLedger; pub const PROTOCOL_FEE_BPS: DataKey = DataKey::ProtocolFeeBps; + pub const NEXT_TRADE_ID: DataKey = DataKey::NextTradeId; pub const LOCKUP_DURATION_SECS: DataKey = DataKey::LockupDurationSecs; pub const FLASH_LOAN_GUARD_LEDGERS: DataKey = DataKey::FlashLoanGuardLedgers; @@ -1655,6 +1656,8 @@ pub enum DataKey { HolderCapBps(Address), /// Protocol fee basis points. ProtocolFeeBps, + /// Monotonic counter for protocol trade fee collection events. + NextTradeId, /// Protocol-wide emergency trading halt flag (#784). When `true`, every /// buy and sell is rejected regardless of per-key pause state. GlobalTradingPaused, @@ -3059,6 +3062,20 @@ fn read_protocol_fee_recipient_balance(env: &Env) -> i128 { .unwrap_or(0) } +fn next_trade_id(env: &Env) -> Result { + let current = env + .storage() + .persistent() + .get(&constants::storage::NEXT_TRADE_ID) + .unwrap_or(0u64); + let next = current.checked_add(1).ok_or(ContractError::Overflow)?; + env.storage() + .persistent() + .set(&constants::storage::NEXT_TRADE_ID, &next); + extend_key_ttl_to_full_window(env, &constants::storage::NEXT_TRADE_ID); + Ok(next) +} + fn credit_protocol_fee_recipient_balance(env: &Env, amount: i128) -> Result<(), ContractError> { if amount <= 0 { return Ok(()); @@ -3145,13 +3162,16 @@ fn collect_protocol_trade_fee( return Ok(amount); } let (_, treasury) = read_trade_fee_config(env).ok_or(ContractError::FeeConfigNotSet)?; + let trade_id = next_trade_id(env)?; credit_treasury_balance(env, trade_fee)?; credit_staking_rewards_pool(env, creator, trade_fee)?; env.events().publish( - events::fee_collected_topics(&treasury), + events::fee_collected_topics_with_trade_id(&treasury, trade_id), events::FeeCollectedEvent { treasury: treasury.clone(), - amount: trade_fee, + trade_id, + amount, + fee: trade_fee, ledger: env.ledger().sequence(), }, ); @@ -8893,11 +8913,14 @@ impl CreatorKeysContract { if let Some((_, treasury)) = read_trade_fee_config(&env) { credit_treasury_balance(&env, trade_fee)?; credit_staking_rewards_pool(&env, &creator, trade_fee)?; + let trade_id = next_trade_id(&env)?; env.events().publish( - events::fee_collected_topics(&treasury), + events::fee_collected_topics_with_trade_id(&treasury, trade_id), events::FeeCollectedEvent { treasury: treasury.clone(), - amount: trade_fee, + trade_id, + amount: price, + fee: trade_fee, ledger: env.ledger().sequence(), }, ); diff --git a/creator-keys/tests/protocol_trade_fee.rs b/creator-keys/tests/protocol_trade_fee.rs index 76b1b078..36ab1bc6 100644 --- a/creator-keys/tests/protocol_trade_fee.rs +++ b/creator-keys/tests/protocol_trade_fee.rs @@ -44,14 +44,14 @@ fn setup(env: &Env) -> Setup<'_> { } } -/// Collects `(treasury, amount)` pairs from fee collection events in the log. -fn collected_fees(env: &Env) -> Vec<(Address, i128)> { +/// Collects fee collection payloads from the log. +fn fee_events(env: &Env) -> Vec { let mut found = Vec::new(env); for (_, topics, data) in env.events().all().iter() { let name: Symbol = topics.get(0).unwrap().into_val(env); if name == FEE_COLLECTED_EVENT_NAME { let payload: events::FeeCollectedEvent = data.into_val(env); - found.push_back((payload.treasury, payload.amount)); + found.push_back(payload); } } found @@ -73,7 +73,7 @@ fn test_buy_routes_one_percent_to_treasury_and_remainder_to_creator() { // Capture events immediately after the trade — view calls below will // flush the Soroban test-env event buffer. - let fees = collected_fees(&env); + let fees = fee_events(&env); assert_eq!( s.client.get_treasury_balance(), @@ -87,7 +87,12 @@ fn test_buy_routes_one_percent_to_treasury_and_remainder_to_creator() { ); assert_eq!(fees.len(), 1, "exactly one fee_collected event per trade"); - assert_eq!(fees.get(0).unwrap(), (s.treasury.clone(), 1)); + let fee = fees.get(0).unwrap(); + assert_eq!(fee.treasury, s.treasury); + assert_eq!(fee.trade_id, 1); + assert_eq!(fee.amount, 100); + assert_eq!(fee.fee, 1); + assert_eq!(fee.ledger, env.ledger().sequence()); } #[test] @@ -122,7 +127,7 @@ fn test_sell_routes_one_percent_to_treasury_and_remainder_to_seller() { }) == Some(true) }) .collect(); - let fees = collected_fees(&env); + let fees = fee_events(&env); assert_eq!( s.client.get_treasury_balance(), @@ -131,6 +136,16 @@ fn test_sell_routes_one_percent_to_treasury_and_remainder_to_seller() { ); assert_eq!(sell_events.len(), 1, "exactly one sell event expected"); + let fee = fees + .iter() + .find(|entry| entry.treasury == s.treasury) + .unwrap(); + assert_eq!(fee.trade_id, 2, "trade ids increment across trades"); + assert_eq!( + fee.amount, 100, + "the event should record the gross trade amount" + ); + assert_eq!(fee.fee, 1, "the event should record the deducted fee"); let (_, _, data) = &sell_events[0]; let payload: events::KeysSoldEvent = data.into_val(&env); // With CREATOR_BPS=10_000 the full net goes to the creator, so the @@ -145,7 +160,8 @@ fn test_sell_routes_one_percent_to_treasury_and_remainder_to_seller() { "a fee_collected event must be emitted on the sell" ); assert!( - fees.iter().any(|fee| fee.0 == s.treasury && fee.1 == 1), + fees.iter() + .any(|fee| fee.treasury == s.treasury && fee.fee == 1), "the sell's fee_collected event must carry the treasury and 1 stroop" ); } @@ -211,7 +227,7 @@ fn test_zero_bps_transfers_full_amount_with_no_treasury_call() { "the creator receives the full amount at 0 bps" ); assert!( - collected_fees(&env).is_empty(), + fee_events(&env).is_empty(), "no fee_collected event may be emitted at 0 bps" ); } @@ -234,5 +250,5 @@ fn test_dormant_when_not_configured() { KEY_PRICE, "without the trade fee the full amount flows to the creator" ); - assert!(collected_fees(&env).is_empty()); + assert!(fee_events(&env).is_empty()); }