Skip to content

Commit fe2ad0a

Browse files
authored
Merge pull request #1491 from abimbolaalabi/fix/1326-volume-based-dynamic-fee-tiers
[Contract] — Volume-Based Dynamic Fee Tiers #1326
2 parents bcd07cb + 0879992 commit fe2ad0a

6 files changed

Lines changed: 283 additions & 56 deletions

File tree

contracts/open-market/src/config.rs

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ pub enum ReputationDecayMode {
88
}
99

1010
use crate::errors::InsightArenaError;
11-
use crate::storage_types::DataKey;
11+
use crate::storage_types::{DataKey, VolumeFeeConfig};
1212

1313
// ── TTL constants ─────────────────────────────────────────────────────────────
1414
// Assuming ~5 s per ledger:
@@ -244,6 +244,11 @@ pub struct Config {
244244
/// via `ProposalType::UpdateQuorum` (timelocked governance path). Defaults
245245
/// to `1000` (10%) at initialization.
246246
pub governance_quorum_bps: u32,
247+
/// Volume-based fee tier schedule. Governs the swap fee charged by every
248+
/// market's AMM pool based on its cumulative trading volume.
249+
/// Governance-configurable via `set_volume_fee_config` (admin, immediate).
250+
/// Defaults to [`VolumeFeeConfig::default_config`] at initialization.
251+
pub volume_fee_config: VolumeFeeConfig,
247252
/// Stake (stroops) an oracle must lock via
248253
/// `dispute::submit_resolution_with_stake` when submitting a market
249254
/// resolution. Held through the market's dispute window; slashed if a
@@ -399,6 +404,7 @@ pub fn initialize(
399404
arbiter_slash_bps: 1000, // 10% of stake slashed for a missed vote
400405
arbiter_voting_period_seconds: 172_800, // ~2 days
401406
governance_quorum_bps: 1000, // 10% of registered users must participate
407+
volume_fee_config: VolumeFeeConfig::default_config(env),
402408
oracle_stake_amount: 100_000_000, // 10 XLM expressed in stroops
403409
oracle_reward_bps: 500, // 5% of stake paid as a reward when resolution stands
404410
vesting_tranche_count: 4,
@@ -1104,6 +1110,83 @@ fn emit_governance_quorum_updated(env: &Env, old_quorum_bps: u32, new_quorum_bps
11041110
);
11051111
}
11061112

1113+
// ── Volume Fee Config ──────────────────────────────────────────────────────────
1114+
1115+
fn validate_volume_fee_config(config: &VolumeFeeConfig) -> Result<(), InsightArenaError> {
1116+
if config.tiers.is_empty() {
1117+
return Err(InsightArenaError::InvalidInput);
1118+
}
1119+
1120+
// Tier 0 must have threshold 0.
1121+
let first = config.tiers.get(0).unwrap();
1122+
if first.volume_threshold != 0 {
1123+
return Err(InsightArenaError::InvalidInput);
1124+
}
1125+
1126+
// Thresholds must be monotonically increasing.
1127+
let mut prev_threshold = first.volume_threshold;
1128+
for i in 1..config.tiers.len() {
1129+
let entry = config.tiers.get(i).unwrap();
1130+
if entry.volume_threshold <= prev_threshold {
1131+
return Err(InsightArenaError::InvalidInput);
1132+
}
1133+
prev_threshold = entry.volume_threshold;
1134+
}
1135+
1136+
Ok(())
1137+
}
1138+
1139+
/// Update the volume-based fee tier schedule. Caller must be the stored admin.
1140+
///
1141+
/// The new schedule must have at least one tier, with tier 0's threshold at `0`,
1142+
/// and monotonically increasing thresholds thereafter. All fee rates must be
1143+
/// ≤ 10_000 bps.
1144+
pub fn set_volume_fee_config(
1145+
env: &Env,
1146+
admin: Address,
1147+
new_config: VolumeFeeConfig,
1148+
) -> Result<(), InsightArenaError> {
1149+
let mut config = load_config(env)?;
1150+
1151+
admin.require_auth();
1152+
if admin != config.admin {
1153+
return Err(InsightArenaError::Unauthorized);
1154+
}
1155+
1156+
validate_volume_fee_config(&new_config)?;
1157+
1158+
let old_config = config.volume_fee_config.clone();
1159+
config.volume_fee_config = new_config;
1160+
env.storage().persistent().set(&DataKey::Config, &config);
1161+
bump_config(env);
1162+
1163+
emit_volume_fee_config_updated(env, &old_config, &config.volume_fee_config);
1164+
1165+
Ok(())
1166+
}
1167+
1168+
fn emit_volume_fee_config_updated(
1169+
env: &Env,
1170+
old_config: &VolumeFeeConfig,
1171+
new_config: &VolumeFeeConfig,
1172+
) {
1173+
env.events().publish(
1174+
(symbol_short!("cfg"), symbol_short!("vfc_upd")),
1175+
(old_config.clone(), new_config.clone()),
1176+
);
1177+
}
1178+
1179+
/// Return the current volume-based fee tier schedule. Extends the Config TTL.
1180+
pub fn get_volume_fee_config(env: &Env) -> VolumeFeeConfig {
1181+
match load_config(env) {
1182+
Ok(config) => {
1183+
bump_config(env);
1184+
config.volume_fee_config
1185+
}
1186+
Err(_) => VolumeFeeConfig::default_config(env),
1187+
}
1188+
}
1189+
11071190
fn validate_oracle_stake_config(stake_amount: i128, reward_bps: u32) -> Result<(), InsightArenaError> {
11081191
if stake_amount <= 0 {
11091192
return Err(InsightArenaError::InvalidInput);

contracts/open-market/src/lib.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1194,4 +1194,20 @@ impl InsightArenaContract {
11941194
) -> Result<(), InsightArenaError> {
11951195
liquidity::set_fee_tier_config(&env, admin, new_config)
11961196
}
1197+
1198+
// ── Volume-Based Fee Tiers (#1326) ──────────────────────────────────────────
1199+
1200+
/// Return the current volume-based fee tier schedule.
1201+
pub fn get_volume_fee_config(env: Env) -> crate::storage_types::VolumeFeeConfig {
1202+
config::get_volume_fee_config(&env)
1203+
}
1204+
1205+
/// Update the volume-based fee tier schedule. Caller must be the platform admin.
1206+
pub fn update_volume_fee_config(
1207+
env: Env,
1208+
admin: Address,
1209+
new_config: crate::storage_types::VolumeFeeConfig,
1210+
) -> Result<(), InsightArenaError> {
1211+
config::set_volume_fee_config(&env, admin, new_config)
1212+
}
11971213
}

contracts/open-market/src/liquidity.rs

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use crate::escrow;
66
use crate::market;
77
use crate::storage_types::{
88
DataKey, FeeTier, FeeTierConfig, LPPosition, LiquidityPool, Market, MarketFeeInfo,
9-
PriceAccumulator, PriceObservation, SwapRecord, VolatilityState,
9+
PriceAccumulator, PriceObservation, SwapRecord, VolatilityState, VolumeFeeConfig,
1010
};
1111

1212
// ── Constants ─────────────────────────────────────────────────────────────────
@@ -142,6 +142,29 @@ pub fn fee_bps_for_tier(tier: &FeeTier, cfg: &FeeTierConfig) -> u32 {
142142
}
143143
}
144144

145+
/// Select the volume-based fee tier for a market given its cumulative volume.
146+
/// Returns the index into `VolumeFeeConfig::tiers` and the corresponding fee bps.
147+
/// The last tier whose threshold is ≤ `cumulative_volume` is chosen.
148+
pub fn select_volume_fee_tier(
149+
cumulative_volume: i128,
150+
config: &VolumeFeeConfig,
151+
) -> (u32, u32) {
152+
let mut active_idx: u32 = 0;
153+
let mut active_fee_bps = config.tiers.get(0).map(|t| t.fee_bps).unwrap_or(30);
154+
155+
for i in 1..config.tiers.len() {
156+
let entry = config.tiers.get(i).unwrap();
157+
if cumulative_volume >= entry.volume_threshold {
158+
active_idx = i;
159+
active_fee_bps = entry.fee_bps;
160+
} else {
161+
break;
162+
}
163+
}
164+
165+
(active_idx, active_fee_bps)
166+
}
167+
145168
fn validate_fee_tier_config(cfg: &FeeTierConfig) -> Result<(), InsightArenaError> {
146169
if cfg.calm_threshold_bps >= cfg.volatile_threshold_bps {
147170
return Err(InsightArenaError::InvalidInput);
@@ -265,20 +288,28 @@ fn update_volatility_state(
265288
Ok(state)
266289
}
267290

268-
/// Return the current dynamic fee tier and effective swap fee for a market.
291+
/// Return the current dynamic fee state for a market.
292+
/// `effective_fee_bps` reflects the volume-based fee tier active for this
293+
/// market's cumulative volume. Volatility-tier info (`tier`, `volatility_ema_bps`)
294+
/// is provided for informational / off-chain analysis.
269295
pub fn get_market_fee_info(env: &Env, market_id: u64) -> Result<MarketFeeInfo, InsightArenaError> {
270-
market::get_market(env, market_id)?;
296+
let mkt = market::get_market(env, market_id)?;
271297

272298
let tier_config = get_fee_tier_config(env);
273299
let volatility = get_volatility_state(env, market_id);
274300
let tier = determine_fee_tier(volatility.ema_bps, &tier_config);
275-
let effective_fee_bps = fee_bps_for_tier(&tier, &tier_config);
301+
302+
let cfg = config::get_config(env)?;
303+
let (volume_tier_index, effective_fee_bps) =
304+
select_volume_fee_tier(mkt.cumulative_volume, &cfg.volume_fee_config);
276305

277306
Ok(MarketFeeInfo {
278307
market_id,
279308
tier,
280309
effective_fee_bps,
281310
volatility_ema_bps: volatility.ema_bps,
311+
volume_tier_index,
312+
volume_tier_fee_bps: effective_fee_bps,
282313
})
283314
}
284315

@@ -962,12 +993,17 @@ pub fn swap_outcome(
962993
.get(to_outcome.clone())
963994
.ok_or(InsightArenaError::InvalidOutcome)?;
964995

965-
// Fee tier is derived from volatility observed *before* this swap, so a
966-
// trade cannot influence the fee rate it itself pays.
996+
// ── Volume-based fee tier selection ────────────────────────────────────
997+
// The fee is derived from the market's cumulative volume *before* this
998+
// swap, so a trade cannot influence the fee rate it itself pays.
999+
let cfg = config::get_config(env)?;
1000+
let volume_before = mkt.cumulative_volume;
1001+
let (volume_tier_before, effective_fee_bps) =
1002+
select_volume_fee_tier(volume_before, &cfg.volume_fee_config);
1003+
1004+
// Volatility state is still tracked (for informational purposes / TWAP).
9671005
let tier_config = get_fee_tier_config(env);
9681006
let volatility_before = get_volatility_state(env, market_id);
969-
let tier = determine_fee_tier(volatility_before.ema_bps, &tier_config);
970-
let effective_fee_bps = fee_bps_for_tier(&tier, &tier_config);
9711007

9721008
let amount_out = calculate_swap_output(amount_in, from_reserve, to_reserve, effective_fee_bps)?;
9731009

@@ -984,6 +1020,7 @@ pub fn swap_outcome(
9841020
// Split the fee between the protocol treasury and liquidity providers.
9851021
// `lp_fee_share` is derived by subtraction so the two shares always sum
9861022
// to `fee_amount` exactly, with no stroop lost or double-counted.
1023+
// Protocol share bps is read from the volatility-based FeeTierConfig.
9871024
let protocol_fee_share = fee_amount
9881025
.checked_mul(tier_config.protocol_share_bps as i128)
9891026
.ok_or(InsightArenaError::Overflow)?
@@ -1027,7 +1064,6 @@ pub fn swap_outcome(
10271064
// default `treasury_split_bps == 10_000`, so the entire protocol fee
10281065
// share keeps flowing to the treasury exactly as it did before this
10291066
// split was introduced.
1030-
let cfg = config::get_config(env)?;
10311067
let treasury_amount = protocol_fee_share
10321068
.checked_mul(cfg.treasury_split_bps as i128)
10331069
.ok_or(InsightArenaError::Overflow)?
@@ -1054,6 +1090,26 @@ pub fn swap_outcome(
10541090
total_lp_share,
10551091
);
10561092

1093+
// ── Update cumulative market volume and detect tier crossing ─────────────
1094+
let new_volume = volume_before
1095+
.checked_add(amount_in)
1096+
.ok_or(InsightArenaError::Overflow)?;
1097+
1098+
let (volume_tier_after, _) =
1099+
select_volume_fee_tier(new_volume, &cfg.volume_fee_config);
1100+
1101+
if volume_tier_after > volume_tier_before {
1102+
emit_volume_tier_crossed(env, market_id, volume_tier_before, volume_tier_after, new_volume);
1103+
}
1104+
1105+
let mut mkt = mkt;
1106+
mkt.cumulative_volume = new_volume;
1107+
env.storage()
1108+
.persistent()
1109+
.set(&DataKey::Market(market_id), &mkt);
1110+
market::bump_market(env, market_id);
1111+
1112+
// ── Record swap with volume tier snapshot ────────────────────────────────
10571113
let record = SwapRecord::new(
10581114
trader,
10591115
market_id,
@@ -1063,6 +1119,7 @@ pub fn swap_outcome(
10631119
amount_out,
10641120
fee_amount,
10651121
env.ledger().timestamp(),
1122+
volume_tier_before,
10661123
);
10671124

10681125
let mut history: Vec<SwapRecord> = env
@@ -1080,6 +1137,19 @@ pub fn swap_outcome(
10801137
Ok(amount_out)
10811138
}
10821139

1140+
fn emit_volume_tier_crossed(
1141+
env: &Env,
1142+
market_id: u64,
1143+
from_tier: u32,
1144+
to_tier: u32,
1145+
cumulative_volume: i128,
1146+
) {
1147+
env.events().publish(
1148+
(symbol_short!("vol"), symbol_short!("tier_x")),
1149+
(market_id, from_tier, to_tier, cumulative_volume),
1150+
);
1151+
}
1152+
10831153
/// Emit an event recording exactly how a swap's collected fee was split
10841154
/// between the protocol treasury and liquidity providers. Published on every
10851155
/// swap that reaches the fee-collection step, including zero-fee swaps (in

contracts/open-market/src/market.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ pub struct CreateMarketParams {
3333

3434
// ── TTL helpers ───────────────────────────────────────────────────────────────
3535

36-
fn bump_market(env: &Env, market_id: u64) {
36+
pub(crate) fn bump_market(env: &Env, market_id: u64) {
3737
config::extend_market_ttl(env, market_id);
3838
}
3939

0 commit comments

Comments
 (0)