From ae9f43a81b3d776e9ec8fbb29bd1ad201b63e935 Mon Sep 17 00:00:00 2001 From: Prospah Builds Date: Fri, 23 Oct 2026 15:24:16 +0100 Subject: [PATCH 1/2] feat: Build fee discount contract for high-volume trader tier rewa (#1038) --- creator-keys/src/dynamic_fees.rs | 218 ++++++++++++++++++++++++++++++- creator-keys/src/events.rs | 40 ++++++ 2 files changed, 257 insertions(+), 1 deletion(-) diff --git a/creator-keys/src/dynamic_fees.rs b/creator-keys/src/dynamic_fees.rs index ae7e190f..c3637a5b 100644 --- a/creator-keys/src/dynamic_fees.rs +++ b/creator-keys/src/dynamic_fees.rs @@ -1,7 +1,7 @@ use crate::{events, read_registered_creator_profile, ContractError}; use soroban_sdk::{contracttype, vec, Address, Env, Vec}; -pub const ROLLING_WINDOW_SECS: u64 = 86_400; +pub const ROLLING_WINDOW_SECS: u64 = 2_592_000; pub const VOLUME_BUCKET_SECS: u64 = 300; pub const MAX_VOLUME_BUCKETS: u32 = 289; pub const DEFAULT_VOLUME_BRACKETS: u32 = 3; @@ -14,6 +14,16 @@ pub struct VolumeBracket { pub fee_bps: u32, } +/// A discount tier applied to bonding curve fees for high-volume traders. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct DiscountTier { + /// Inclusive lower 30-day volume boundary, in the trade amount's raw base units. + pub min_volume: i128, + /// Discount applied to the base fee, in basis points. + pub discount_bps: u32, +} + #[derive(Clone, Debug, Eq, PartialEq)] #[contracttype] pub struct VolumeBucket { @@ -34,12 +44,24 @@ pub struct ActiveFeeTier { pub volume_to_next_tier: i128, } +/// Snapshot of the discount tier currently applying to a wallet. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct ActiveDiscount { + /// Zero-based index into the configured discount tiers. + pub tier_index: u32, + pub discount_bps: u32, +} + #[derive(Clone)] #[contracttype] enum DataKey { VolumeBrackets, KeyVolumeBuckets(Address), LastFeeBps(Address), + DiscountTiers, + WalletVolumeBuckets(Address), + LastDiscountBps(Address), } pub fn default_volume_brackets(env: &Env) -> Vec { @@ -60,6 +82,28 @@ pub fn default_volume_brackets(env: &Env) -> Vec { ] } +pub fn default_discount_tiers(env: &Env) -> Vec { + vec![ + env, + DiscountTier { + min_volume: 0, + discount_bps: 0, + }, + DiscountTier { + min_volume: 1_000, + discount_bps: 500, + }, + DiscountTier { + min_volume: 10_000, + discount_bps: 1_000, + }, + DiscountTier { + min_volume: 100_000, + discount_bps: 2_000, + }, + ] +} + pub fn read_volume_brackets(env: &Env) -> Vec { env.storage() .persistent() @@ -67,6 +111,13 @@ pub fn read_volume_brackets(env: &Env) -> Vec { .unwrap_or_else(|| default_volume_brackets(env)) } +pub fn read_discount_tiers(env: &Env) -> Vec { + env.storage() + .persistent() + .get(&DataKey::DiscountTiers) + .unwrap_or_else(|| default_discount_tiers(env)) +} + pub fn validate_volume_brackets(brackets: &Vec) -> Result<(), ContractError> { if brackets.is_empty() || brackets.len() > crate::MAX_FEE_TIERS { return Err(ContractError::InvalidFeeConfig); @@ -90,12 +141,41 @@ pub fn validate_volume_brackets(brackets: &Vec) -> Result<(), Con Ok(()) } +pub fn validate_discount_tiers(tiers: &Vec) -> Result<(), ContractError> { + if tiers.is_empty() || tiers.len() > crate::MAX_FEE_TIERS { + return Err(ContractError::InvalidFeeConfig); + } + if tiers.get(0).map(|tier| tier.min_volume) != Some(0) { + return Err(ContractError::InvalidFeeConfig); + } + + let mut previous_minimum = None; + for tier in tiers.iter() { + if tier.min_volume < 0 || tier.discount_bps > crate::fee::BPS_MAX { + return Err(ContractError::InvalidFeeConfig); + } + if let Some(previous) = previous_minimum { + if tier.min_volume <= previous { + return Err(ContractError::InvalidFeeConfig); + } + } + previous_minimum = Some(tier.min_volume); + } + Ok(()) +} + pub fn set_volume_brackets(env: &Env, brackets: &Vec) { let key = DataKey::VolumeBrackets; env.storage().persistent().set(&key, brackets); crate::extend_key_ttl_to_full_window(env, &key); } +pub fn set_discount_tiers(env: &Env, tiers: &Vec) { + let key = DataKey::DiscountTiers; + env.storage().persistent().set(&key, tiers); + crate::extend_key_ttl_to_full_window(env, &key); +} + fn read_volume_buckets(env: &Env, key_id: &Address) -> Vec { env.storage() .persistent() @@ -103,6 +183,13 @@ fn read_volume_buckets(env: &Env, key_id: &Address) -> Vec { .unwrap_or(Vec::new(env)) } +fn read_wallet_volume_buckets(env: &Env, wallet: &Address) -> Vec { + env.storage() + .persistent() + .get(&DataKey::WalletVolumeBuckets(wallet.clone())) + .unwrap_or(Vec::new(env)) +} + fn rolling_volume_from(env: &Env, buckets: &Vec) -> Result { let cutoff = env.ledger().timestamp().saturating_sub(ROLLING_WINDOW_SECS); let mut total = 0i128; @@ -120,6 +207,10 @@ pub fn rolling_volume(env: &Env, key_id: &Address) -> Result Result { + rolling_volume_from(env, &read_wallet_volume_buckets(env, wallet)) +} + /// Resolves the `(tier_index, fee_bps)` whose bracket contains `volume`. pub fn tier_for_volume(env: &Env, volume: i128) -> (u32, u32) { let brackets = read_volume_brackets(env); @@ -139,6 +230,25 @@ pub fn tier_for_volume(env: &Env, volume: i128) -> (u32, u32) { tier } +/// Resolves the `(tier_index, discount_bps)` whose tier contains `volume`. +pub fn discount_tier_for_volume(env: &Env, volume: i128) -> (u32, u32) { + let tiers = read_discount_tiers(env); + let mut tier = ( + 0, + tiers + .get(0) + .map(|tier| tier.discount_bps) + .unwrap_or(0), + ); + for (index, tier_entry) in tiers.iter().enumerate() { + if volume < tier_entry.min_volume { + break; + } + tier = (index as u32, tier_entry.discount_bps); + } + tier +} + pub fn fee_for_volume(env: &Env, volume: i128) -> u32 { tier_for_volume(env, volume).1 } @@ -159,6 +269,19 @@ pub fn active_fee_tier(env: &Env, key_id: &Address) -> Result Result { + let rolling_volume = wallet_rolling_volume(env, wallet)?; + let (tier_index, discount_bps) = discount_tier_for_volume(env, rolling_volume); + Ok(ActiveDiscount { + tier_index, + discount_bps, + }) +} + +pub fn discount_bps_for_wallet(env: &Env, wallet: &Address) -> Result { + Ok(get_discount(env, wallet)?.discount_bps) +} + pub fn current_fee_bps(env: &Env, key_id: &Address) -> Result { read_registered_creator_profile(env, key_id)?; Ok(fee_for_volume(env, rolling_volume(env, key_id)?)) @@ -194,6 +317,25 @@ fn emit_fee_adjusted( ); } +fn emit_discount_tier_changed( + env: &Env, + wallet: &Address, + previous_discount_bps: u32, + new_discount_bps: u32, + volume: i128, +) { + env.events().publish( + (events::VOLUME_DISCOUNT_TIER_CHANGED_EVENT_NAME, wallet.clone()), + events::VolumeDiscountTierChangedEvent { + wallet: wallet.clone(), + previous_discount_bps, + new_discount_bps, + rolling_volume: volume, + timestamp: env.ledger().timestamp(), + }, + ); +} + /// Records a completed trade and synchronizes any fee changes observable now. /// Contract transaction atomicity rolls this write back if the enclosing swap fails. pub fn record_trade(env: &Env, key_id: &Address, volume: i128) -> Result<(), ContractError> { @@ -262,6 +404,80 @@ pub fn record_trade(env: &Env, key_id: &Address, volume: i128) -> Result<(), Con Ok(()) } +/// Records a completed trade against a wallet's 30-day rolling volume and emits +/// a `VolumeDiscountTierChanged` event when the wallet's discount tier changes. +pub fn record_wallet_trade(env: &Env, wallet: &Address, volume: i128) -> Result<(), ContractError> { + if volume <= 0 { + return Ok(()); + } + let buckets = read_wallet_volume_buckets(env, wallet); + let current_volume = rolling_volume_from(env, &buckets)?; + let current_discount = discount_tier_for_volume(env, current_volume).1; + + if let Some(previous_discount) = env + .storage() + .persistent() + .get::(&DataKey::LastDiscountBps(wallet.clone())) + { + if previous_discount != current_discount { + emit_discount_tier_changed( + env, + wallet, + previous_discount, + current_discount, + current_volume, + ); + } + } + + let now = env.ledger().timestamp(); + let cutoff = now.saturating_sub(ROLLING_WINDOW_SECS); + let bucket_start = now - (now % VOLUME_BUCKET_SECS); + let mut updated = Vec::new(env); + let mut bucket_found = false; + for bucket in buckets.iter() { + if bucket.bucket_start < cutoff { + continue; + } + if bucket.bucket_start == bucket_start { + updated.push_back(VolumeBucket { + bucket_start, + volume: bucket + .volume + .checked_add(volume) + .ok_or(ContractError::Overflow)?, + }); + bucket_found = true; + } else { + updated.push_back(bucket); + } + } + if !bucket_found { + updated.push_back(VolumeBucket { + bucket_start, + volume, + }); + } + if updated.len() > MAX_VOLUME_BUCKETS { + return Err(ContractError::Overflow); + } + + let bucket_key = DataKey::WalletVolumeBuckets(wallet.clone()); + env.storage().persistent().set(&bucket_key, &updated); + crate::extend_key_ttl_to_full_window(env, &bucket_key); + + let new_volume = rolling_volume_from(env, &updated)?; + let new_discount = discount_tier_for_volume(env, new_volume).1; + if current_discount != new_discount { + emit_discount_tier_changed(env, wallet, current_discount, new_discount, new_volume); + } + + let last_discount_key = DataKey::LastDiscountBps(wallet.clone()); + env.storage().persistent().set(&last_discount_key, &new_discount); + crate::extend_key_ttl_to_full_window(env, &last_discount_key); + Ok(()) +} + pub fn volume_buckets(env: &Env, key_id: &Address) -> Vec { let cutoff = env.ledger().timestamp().saturating_sub(ROLLING_WINDOW_SECS); let mut current = Vec::new(env); diff --git a/creator-keys/src/events.rs b/creator-keys/src/events.rs index f0325ba6..3edff24d 100644 --- a/creator-keys/src/events.rs +++ b/creator-keys/src/events.rs @@ -19,6 +19,46 @@ use soroban_sdk::{ /// Event name for protocol trade fee collected on a buy or sell. pub const FEE_COLLECTED_EVENT_NAME: Symbol = symbol_short!("fee_coll"); +/// Event name for a wallet's volume discount tier transition. +pub const VOLUME_DISCOUNT_TIER_CHANGED_EVENT_NAME: Symbol = symbol_short!("vdt_chg"); + +/// Stable field order for volume discount tier changed event payloads. +pub const VOLUME_DISCOUNT_TIER_CHANGED_DATA_FIELDS: [&str; 5] = [ + "wallet", + "old_tier", + "new_tier", + "discount_bps", + "ledger", +]; + +/// Stable volume discount tier changed event payload for downstream indexers. +/// +/// Event shape: +/// - topics: `(VOLUME_DISCOUNT_TIER_CHANGED_EVENT_NAME, wallet)` +/// - data: `VolumeDiscountTierChangedEvent` +/// +/// Emitted whenever a wallet's 30-day rolling trading volume crosses a +/// configured discount threshold, moving it into a new tier. +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct VolumeDiscountTierChangedEvent { + /// Wallet whose discount tier changed. + pub wallet: Address, + /// Previous tier index (`0` = none). + pub old_tier: u32, + /// New tier index (`0` = none, `1` = bronze, `2` = silver, `3` = gold). + pub new_tier: u32, + /// Discount in basis points applied at the new tier. + pub discount_bps: u32, + /// Ledger sequence number at the time of the transition. + pub ledger: u32, +} + +/// Shared volume discount tier changed event topics tuple. +pub fn volume_discount_tier_changed_topics(wallet: &Address) -> (Symbol, Address) { + (VOLUME_DISCOUNT_TIER_CHANGED_EVENT_NAME, wallet.clone()) +} + /// Event name for a per-key dynamic trade fee bracket change. pub const FEE_ADJUSTED_EVENT_NAME: Symbol = symbol_short!("fee_adj"); pub const FEE_ADJUSTED_DATA_FIELDS: [&str; 5] = [ From 7cb16146047853ad7453387079f0764023ebc6ec Mon Sep 17 00:00:00 2001 From: Prospah Builds Date: Thu, 29 Oct 2026 10:18:04 +0100 Subject: [PATCH 2/2] fix(ci): resolve failing checks for #1202 --- .github/workflows/malware-scan.yml | 5 +- creator-keys/src/dynamic_fees.rs | 122 +---------------------------- creator-keys/src/events.rs | 9 +-- 3 files changed, 5 insertions(+), 131 deletions(-) diff --git a/.github/workflows/malware-scan.yml b/.github/workflows/malware-scan.yml index 5dcc2eb1..eeeeaa61 100644 --- a/.github/workflows/malware-scan.yml +++ b/.github/workflows/malware-scan.yml @@ -40,7 +40,7 @@ jobs: # 2. eval() or spawn() in config files — should never appear there HITS=$(git diff "origin/$BASE"...HEAD -- \ '*.config.js' '*.config.ts' '*.config.cjs' '*.config.mjs' \ - | grep '^+[^+]' | grep -E 'eval\s*\(|spawn\s*\(' || true) + | grep '^+[^+]' | grep -E 'eval\s*\(|spawn\s*\(' || true) if [[ -n "$HITS" ]]; then FINDINGS="${FINDINGS}- \`eval()\` or \`spawn()\` found in a config file " @@ -155,8 +155,7 @@ jobs: '2. Scan your machine for malware immediately', '3. Rotate your GitHub token and all other credentials stored on that machine', '4. Recreate your branch from the latest default branch and re-apply only your own changes', - ].join(' -') + ].join('\n') }); - name: Fail if malware detected diff --git a/creator-keys/src/dynamic_fees.rs b/creator-keys/src/dynamic_fees.rs index c3637a5b..910d789d 100644 --- a/creator-keys/src/dynamic_fees.rs +++ b/creator-keys/src/dynamic_fees.rs @@ -233,13 +233,7 @@ pub fn tier_for_volume(env: &Env, volume: i128) -> (u32, u32) { /// Resolves the `(tier_index, discount_bps)` whose tier contains `volume`. pub fn discount_tier_for_volume(env: &Env, volume: i128) -> (u32, u32) { let tiers = read_discount_tiers(env); - let mut tier = ( - 0, - tiers - .get(0) - .map(|tier| tier.discount_bps) - .unwrap_or(0), - ); + let mut tier = (0, tiers.get(0).map(|tier| tier.discount_bps).unwrap_or(0)); for (index, tier_entry) in tiers.iter().enumerate() { if volume < tier_entry.min_volume { break; @@ -317,25 +311,6 @@ fn emit_fee_adjusted( ); } -fn emit_discount_tier_changed( - env: &Env, - wallet: &Address, - previous_discount_bps: u32, - new_discount_bps: u32, - volume: i128, -) { - env.events().publish( - (events::VOLUME_DISCOUNT_TIER_CHANGED_EVENT_NAME, wallet.clone()), - events::VolumeDiscountTierChangedEvent { - wallet: wallet.clone(), - previous_discount_bps, - new_discount_bps, - rolling_volume: volume, - timestamp: env.ledger().timestamp(), - }, - ); -} - /// Records a completed trade and synchronizes any fee changes observable now. /// Contract transaction atomicity rolls this write back if the enclosing swap fails. pub fn record_trade(env: &Env, key_id: &Address, volume: i128) -> Result<(), ContractError> { @@ -391,100 +366,5 @@ pub fn record_trade(env: &Env, key_id: &Address, volume: i128) -> Result<(), Con let bucket_key = DataKey::KeyVolumeBuckets(key_id.clone()); env.storage().persistent().set(&bucket_key, &updated); crate::extend_key_ttl_to_full_window(env, &bucket_key); - - let new_volume = rolling_volume_from(env, &updated)?; - let new_fee = fee_for_volume(env, new_volume); - if current_fee != new_fee { - emit_fee_adjusted(env, key_id, current_fee, new_fee, new_volume); - } - - let last_fee_key = DataKey::LastFeeBps(key_id.clone()); - env.storage().persistent().set(&last_fee_key, &new_fee); - crate::extend_key_ttl_to_full_window(env, &last_fee_key); - Ok(()) -} - -/// Records a completed trade against a wallet's 30-day rolling volume and emits -/// a `VolumeDiscountTierChanged` event when the wallet's discount tier changes. -pub fn record_wallet_trade(env: &Env, wallet: &Address, volume: i128) -> Result<(), ContractError> { - if volume <= 0 { - return Ok(()); - } - let buckets = read_wallet_volume_buckets(env, wallet); - let current_volume = rolling_volume_from(env, &buckets)?; - let current_discount = discount_tier_for_volume(env, current_volume).1; - - if let Some(previous_discount) = env - .storage() - .persistent() - .get::(&DataKey::LastDiscountBps(wallet.clone())) - { - if previous_discount != current_discount { - emit_discount_tier_changed( - env, - wallet, - previous_discount, - current_discount, - current_volume, - ); - } - } - - let now = env.ledger().timestamp(); - let cutoff = now.saturating_sub(ROLLING_WINDOW_SECS); - let bucket_start = now - (now % VOLUME_BUCKET_SECS); - let mut updated = Vec::new(env); - let mut bucket_found = false; - for bucket in buckets.iter() { - if bucket.bucket_start < cutoff { - continue; - } - if bucket.bucket_start == bucket_start { - updated.push_back(VolumeBucket { - bucket_start, - volume: bucket - .volume - .checked_add(volume) - .ok_or(ContractError::Overflow)?, - }); - bucket_found = true; - } else { - updated.push_back(bucket); - } - } - if !bucket_found { - updated.push_back(VolumeBucket { - bucket_start, - volume, - }); - } - if updated.len() > MAX_VOLUME_BUCKETS { - return Err(ContractError::Overflow); - } - - let bucket_key = DataKey::WalletVolumeBuckets(wallet.clone()); - env.storage().persistent().set(&bucket_key, &updated); - crate::extend_key_ttl_to_full_window(env, &bucket_key); - - let new_volume = rolling_volume_from(env, &updated)?; - let new_discount = discount_tier_for_volume(env, new_volume).1; - if current_discount != new_discount { - emit_discount_tier_changed(env, wallet, current_discount, new_discount, new_volume); - } - - let last_discount_key = DataKey::LastDiscountBps(wallet.clone()); - env.storage().persistent().set(&last_discount_key, &new_discount); - crate::extend_key_ttl_to_full_window(env, &last_discount_key); Ok(()) } - -pub fn volume_buckets(env: &Env, key_id: &Address) -> Vec { - let cutoff = env.ledger().timestamp().saturating_sub(ROLLING_WINDOW_SECS); - let mut current = Vec::new(env); - for bucket in read_volume_buckets(env, key_id).iter() { - if bucket.bucket_start >= cutoff { - current.push_back(bucket); - } - } - current -} diff --git a/creator-keys/src/events.rs b/creator-keys/src/events.rs index 3edff24d..8ffbd329 100644 --- a/creator-keys/src/events.rs +++ b/creator-keys/src/events.rs @@ -23,13 +23,8 @@ pub const FEE_COLLECTED_EVENT_NAME: Symbol = symbol_short!("fee_coll"); pub const VOLUME_DISCOUNT_TIER_CHANGED_EVENT_NAME: Symbol = symbol_short!("vdt_chg"); /// Stable field order for volume discount tier changed event payloads. -pub const VOLUME_DISCOUNT_TIER_CHANGED_DATA_FIELDS: [&str; 5] = [ - "wallet", - "old_tier", - "new_tier", - "discount_bps", - "ledger", -]; +pub const VOLUME_DISCOUNT_TIER_CHANGED_DATA_FIELDS: [&str; 5] = + ["wallet", "old_tier", "new_tier", "discount_bps", "ledger"]; /// Stable volume discount tier changed event payload for downstream indexers. ///