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 ae7e190f..910d789d 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,19 @@ 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 +263,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)?)) @@ -249,26 +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(()) } - -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 f0325ba6..8ffbd329 100644 --- a/creator-keys/src/events.rs +++ b/creator-keys/src/events.rs @@ -19,6 +19,41 @@ 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] = [