Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .github/workflows/malware-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
"
Expand Down Expand Up @@ -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
Expand Down
140 changes: 118 additions & 22 deletions creator-keys/src/dynamic_fees.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {
Expand All @@ -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<VolumeBracket> {
Expand All @@ -60,13 +82,42 @@ pub fn default_volume_brackets(env: &Env) -> Vec<VolumeBracket> {
]
}

pub fn default_discount_tiers(env: &Env) -> Vec<DiscountTier> {
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<VolumeBracket> {
env.storage()
.persistent()
.get(&DataKey::VolumeBrackets)
.unwrap_or_else(|| default_volume_brackets(env))
}

pub fn read_discount_tiers(env: &Env) -> Vec<DiscountTier> {
env.storage()
.persistent()
.get(&DataKey::DiscountTiers)
.unwrap_or_else(|| default_discount_tiers(env))
}

pub fn validate_volume_brackets(brackets: &Vec<VolumeBracket>) -> Result<(), ContractError> {
if brackets.is_empty() || brackets.len() > crate::MAX_FEE_TIERS {
return Err(ContractError::InvalidFeeConfig);
Expand All @@ -90,19 +141,55 @@ pub fn validate_volume_brackets(brackets: &Vec<VolumeBracket>) -> Result<(), Con
Ok(())
}

pub fn validate_discount_tiers(tiers: &Vec<DiscountTier>) -> 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<VolumeBracket>) {
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<DiscountTier>) {
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<VolumeBucket> {
env.storage()
.persistent()
.get(&DataKey::KeyVolumeBuckets(key_id.clone()))
.unwrap_or(Vec::new(env))
}

fn read_wallet_volume_buckets(env: &Env, wallet: &Address) -> Vec<VolumeBucket> {
env.storage()
.persistent()
.get(&DataKey::WalletVolumeBuckets(wallet.clone()))
.unwrap_or(Vec::new(env))
}

fn rolling_volume_from(env: &Env, buckets: &Vec<VolumeBucket>) -> Result<i128, ContractError> {
let cutoff = env.ledger().timestamp().saturating_sub(ROLLING_WINDOW_SECS);
let mut total = 0i128;
Expand All @@ -120,6 +207,10 @@ pub fn rolling_volume(env: &Env, key_id: &Address) -> Result<i128, ContractError
rolling_volume_from(env, &read_volume_buckets(env, key_id))
}

pub fn wallet_rolling_volume(env: &Env, wallet: &Address) -> Result<i128, ContractError> {
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);
Expand All @@ -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
}
Expand All @@ -159,6 +263,19 @@ pub fn active_fee_tier(env: &Env, key_id: &Address) -> Result<ActiveFeeTier, Con
})
}

pub fn get_discount(env: &Env, wallet: &Address) -> Result<ActiveDiscount, ContractError> {
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<u32, ContractError> {
Ok(get_discount(env, wallet)?.discount_bps)
}

pub fn current_fee_bps(env: &Env, key_id: &Address) -> Result<u32, ContractError> {
read_registered_creator_profile(env, key_id)?;
Ok(fee_for_volume(env, rolling_volume(env, key_id)?))
Expand Down Expand Up @@ -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<VolumeBucket> {
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
}
35 changes: 35 additions & 0 deletions creator-keys/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [
Expand Down
Loading