From 575698df18eaf82c766f43296952b5fb7241e31d Mon Sep 17 00:00:00 2001 From: Wraith Protocol Dev Date: Mon, 28 Sep 2026 12:59:02 +0100 Subject: [PATCH 1/4] feat: add protocol fee collection metadata --- creator-keys/src/events.rs | 19 +++++++++++--- creator-keys/src/lib.rs | 33 +++++++++++++++++++++--- creator-keys/tests/protocol_trade_fee.rs | 25 ++++++++++++------ 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/creator-keys/src/events.rs b/creator-keys/src/events.rs index 8e85da00..1ca6e7cd 100644 --- a/creator-keys/src/events.rs +++ b/creator-keys/src/events.rs @@ -52,18 +52,23 @@ 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, } @@ -73,6 +78,14 @@ 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. pub const LOCKUP_BLOCKED_EVENT_NAME: Symbol = symbol_short!("lck_blk"); diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index edbfee66..def5b326 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,22 @@ 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 +3164,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 +8915,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..74d7985b 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,10 @@ 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 @@ -211,7 +220,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 +243,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()); } From 177a71cf553f9284309bec802d56d47ae584bd20 Mon Sep 17 00:00:00 2001 From: Wraith Protocol Dev Date: Wed, 30 Sep 2026 05:32:42 +0100 Subject: [PATCH 2/4] fix: correct struct field access in protocol_trade_fee test (fee.0/fee.1 -> fee.treasury/fee.fee) --- Cargo.lock | 1 + creator-keys/src/lib.rs | 4 +--- creator-keys/tests/protocol_trade_fee.rs | 13 ++++++++++--- 3 files changed, 12 insertions(+), 6 deletions(-) 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/lib.rs b/creator-keys/src/lib.rs index def5b326..67b97911 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -3068,9 +3068,7 @@ fn next_trade_id(env: &Env) -> Result { .persistent() .get(&constants::storage::NEXT_TRADE_ID) .unwrap_or(0u64); - let next = current - .checked_add(1) - .ok_or(ContractError::Overflow)?; + let next = current.checked_add(1).ok_or(ContractError::Overflow)?; env.storage() .persistent() .set(&constants::storage::NEXT_TRADE_ID, &next); diff --git a/creator-keys/tests/protocol_trade_fee.rs b/creator-keys/tests/protocol_trade_fee.rs index 74d7985b..36ab1bc6 100644 --- a/creator-keys/tests/protocol_trade_fee.rs +++ b/creator-keys/tests/protocol_trade_fee.rs @@ -136,9 +136,15 @@ 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(); + 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.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); @@ -154,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" ); } From a4c6052e1a64457c2c6840a1d3f3091c5b087b2e Mon Sep 17 00:00:00 2001 From: Wraith Protocol Dev Date: Wed, 30 Sep 2026 09:16:03 +0100 Subject: [PATCH 3/4] fix: remove dead fee_collected_topics helper superseded by fee_collected_topics_with_trade_id --- creator-keys/src/events.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/creator-keys/src/events.rs b/creator-keys/src/events.rs index 1ca6e7cd..8b9ce59f 100644 --- a/creator-keys/src/events.rs +++ b/creator-keys/src/events.rs @@ -52,7 +52,7 @@ 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[, trade_id])` +/// - topics: `(FEE_COLLECTED_EVENT_NAME, treasury, trade_id)` /// - data: `FeeCollectedEvent` /// /// Emitted on every buy and sell once the protocol trade fee is configured, @@ -73,11 +73,6 @@ pub struct FeeCollectedEvent { 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, From 7e0811511a9e7702db02479ce1c3c9c5398333aa Mon Sep 17 00:00:00 2001 From: Wraith Protocol Dev Date: Thu, 1 Oct 2026 05:16:50 +0100 Subject: [PATCH 4/4] fix: drop needless borrow in next_trade_id (clippy::needless_borrow) next_trade_id takes env: &Env, so passing &env built a &&Env that the compiler immediately dereferenced. Clippy rejected it under -D warnings, failing the Contracts CI / verify clippy step. --- creator-keys/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index 67b97911..6a794ed3 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -3072,7 +3072,7 @@ fn next_trade_id(env: &Env) -> Result { env.storage() .persistent() .set(&constants::storage::NEXT_TRADE_ID, &next); - extend_key_ttl_to_full_window(&env, &constants::storage::NEXT_TRADE_ID); + extend_key_ttl_to_full_window(env, &constants::storage::NEXT_TRADE_ID); Ok(next) }