Skip to content

Commit 28731e3

Browse files
authored
Merge pull request #825 from opratem/feat/dynamic-fee
Feat/dynamic fee
2 parents 73b199f + 909484e commit 28731e3

2 files changed

Lines changed: 624 additions & 110 deletions

File tree

contracts/payment-stream/src/lib.rs

Lines changed: 168 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ pub enum DataKey {
3232
Dispute(u64),
3333
/// The id of the currently active (unresolved) dispute for a stream, if any.
3434
ActiveDispute(u64),
35+
/// Configured fee-tier list for volume-based protocol fee discounts (instance storage).
36+
FeeTiers,
37+
/// Cumulative escrowed volume for a sender address, used for fee-tier eligibility (persistent storage).
38+
SenderVolume(Address),
3539
}
3640

3741
/// Stream status enum
@@ -103,18 +107,17 @@ pub struct ProtocolMetrics {
103107
pub total_delegations: u64, // Total number of delegations across all streams
104108
}
105109

106-
/// Fee tier threshold and fee rate pair.
110+
/// Fee tier configuration for volume-based protocol fee discounts.
107111
///
108-
/// Represents: "if the donor's cumulative volume is `>= threshold`, apply
109-
/// `fee_rate`". The tier list is sorted by strictly increasing thresholds;
110-
/// the first tier must have a threshold of `0` so every donor is covered, and
111-
/// fee rates must be monotonically non-increasing across tiers.
112+
/// Tiers are stored sorted ascending by `min_volume`. The highest tier
113+
/// whose `min_volume` is <= the sender's cumulative stream volume determines
114+
/// their protocol fee rate at withdrawal time.
112115
#[contracttype]
113116
#[derive(Clone)]
114117
pub struct FeeTier {
115-
/// Minimum cumulative volume to qualify for this tier.
116-
pub threshold: i128,
117-
/// Fee rate in basis points applied for this tier (e.g., 250 = 2.5 %).
118+
/// Minimum cumulative stream volume (token units) required for this tier
119+
pub min_volume: i128,
120+
/// Protocol fee rate in basis points (e.g., 30 = 0.3%, max 500 = 5%)
118121
pub fee_rate: u32,
119122
}
120123

@@ -309,11 +312,16 @@ pub enum Error {
309312
InvalidTierConfiguration = 32,
310313
/// Fee rates across tiers are not monotonically non-increasing
311314
TierFeeNotMonotonic = 33,
315+
/// A tier configuration was invalid (rate exceeds max, bad sort order, etc.)
316+
InvalidTier = 34,
317+
/// More than MAX_TIERS entries were supplied
318+
TooManyTiers = 35,
312319
}
313320

314321
// Constants
315322
const MAX_FEE: u32 = 500; // 5% in basis points
316323
const MAX_STREAMS_PER_BATCH: u32 = 50; // max streams per create_batch_streams call
324+
const MAX_TIERS: u32 = 10; // max configurable fee tiers
317325
const LEDGER_THRESHOLD: u32 = 518400; // ~30 days at 5s/ledger
318326
const LEDGER_BUMP: u32 = 535680; // ~31 days
319327
const DISPUTE_TIMELOCK_DELAY: u64 = 172800; // 48 hours in seconds
@@ -359,12 +367,12 @@ impl PaymentStreamContract {
359367
// Tier 2 (500,000+): 100 bps (1.0 %)
360368
let default_tiers: Vec<FeeTier> = {
361369
let mut v = Vec::new(&env);
362-
v.push_back(FeeTier { threshold: 0, fee_rate: 500 });
363-
v.push_back(FeeTier { threshold: 50_000, fee_rate: 250 });
364-
v.push_back(FeeTier { threshold: 500_000, fee_rate: 100 });
370+
v.push_back(FeeTier { min_volume: 0, fee_rate: 500 });
371+
v.push_back(FeeTier { min_volume: 50_000, fee_rate: 250 });
372+
v.push_back(FeeTier { min_volume: 500_000, fee_rate: 100 });
365373
v
366374
};
367-
env.storage().instance().set(&Symbol::new(&env, "fee_tiers"), &default_tiers);
375+
env.storage().instance().set(&DataKey::FeeTiers, &default_tiers);
368376

369377
// Initialize protocol metrics
370378
let initial_metrics = ProtocolMetrics {
@@ -392,6 +400,63 @@ impl PaymentStreamContract {
392400
}
393401
}
394402

403+
/// Resolve the applicable fee rate for a sender based on their cumulative
404+
/// stream volume and the configured fee tiers.
405+
///
406+
/// Tiers are walked in ascending order of `min_volume`; the last qualifying
407+
/// entry wins. Falls back to the flat `FeeRate` when no tiers are configured
408+
/// or when the sender's volume is below all tier thresholds.
409+
fn get_applicable_fee_rate_internal(env: &Env, sender: &Address) -> u32 {
410+
let general_rate: u32 = env.storage().instance()
411+
.get(&DataKey::FeeRate)
412+
.unwrap_or(0);
413+
414+
let tiers: Vec<FeeTier> = env.storage().instance()
415+
.get(&DataKey::FeeTiers)
416+
.unwrap_or_else(|| Vec::new(env));
417+
418+
if tiers.is_empty() {
419+
return general_rate;
420+
}
421+
422+
let sender_vol_key = DataKey::SenderVolume(sender.clone());
423+
let volume: i128 = env.storage().persistent()
424+
.get(&sender_vol_key)
425+
.unwrap_or(0);
426+
if volume > 0 {
427+
env.storage().persistent().extend_ttl(&sender_vol_key, LEDGER_THRESHOLD, LEDGER_BUMP);
428+
}
429+
430+
// Start with the general rate; upgrade to each qualifying tier
431+
let mut applicable_rate = general_rate;
432+
let len = tiers.len();
433+
for i in 0..len {
434+
let tier = tiers.get(i).unwrap();
435+
if volume >= tier.min_volume {
436+
applicable_rate = tier.fee_rate;
437+
}
438+
}
439+
applicable_rate
440+
}
441+
442+
/// Calculate the protocol fee for a withdrawal amount.
443+
///
444+
/// Uses the sender's cumulative stream volume to select the applicable
445+
/// fee tier, rewarding high-volume ecosystem donors with lower fees.
446+
fn calculate_protocol_fee(env: &Env, amount: i128, sender: &Address) -> i128 {
447+
let fee_rate = Self::get_applicable_fee_rate_internal(env, sender);
448+
449+
if fee_rate == 0 {
450+
return 0;
451+
}
452+
453+
// fee = (amount * fee_rate) / 10000
454+
// Split to avoid i128 overflow while preserving precision
455+
let rate = fee_rate as i128;
456+
let fee = (amount / 10000) * rate + ((amount % 10000) * rate) / 10000;
457+
fee.max(0)
458+
}
459+
395460
/// Activate the global emergency pause switch.
396461
///
397462
/// When active, all calls to `create_stream`, `deposit`, `withdraw`, and
@@ -735,13 +800,17 @@ impl PaymentStreamContract {
735800
env.storage().persistent().extend_ttl(&DataKey::Stream(stream_id), LEDGER_THRESHOLD, LEDGER_BUMP);
736801
env.storage().persistent().extend_ttl(&DataKey::Metrics(stream_id), LEDGER_THRESHOLD, LEDGER_BUMP);
737802

738-
// Update donor cumulative volume for fee tier calculation
739-
let donor_volume_key = (Symbol::new(&env, "donor_volume"), sender.clone());
740-
let current_volume: i128 = env.storage().persistent().get(&donor_volume_key).unwrap_or(0);
741-
let new_volume = current_volume.checked_add(total_amount)
742-
.unwrap_or_else(|| panic_with_error!(&env, Error::ArithmeticOverflow));
743-
env.storage().persistent().set(&donor_volume_key, &new_volume);
744-
env.storage().persistent().extend_ttl(&donor_volume_key, LEDGER_THRESHOLD, LEDGER_BUMP);
803+
// Track sender's cumulative escrowed volume for fee tier eligibility.
804+
// Only funds actually escrowed (initial_amount) are counted here to
805+
// prevent tier-gaming via zero-deposit streams. Additional deposits
806+
// via deposit() also increment this counter.
807+
let sender_vol_key = DataKey::SenderVolume(sender.clone());
808+
let current_vol: i128 = env.storage().persistent()
809+
.get(&sender_vol_key)
810+
.unwrap_or(0);
811+
let new_vol = current_vol.saturating_add(initial_amount);
812+
env.storage().persistent().set(&sender_vol_key, &new_vol);
813+
env.storage().persistent().extend_ttl(&sender_vol_key, LEDGER_THRESHOLD, LEDGER_BUMP);
745814

746815
// Update protocol metrics
747816
let mut protocol_metrics: ProtocolMetrics = env.storage().instance()
@@ -813,6 +882,18 @@ impl PaymentStreamContract {
813882
env.storage().persistent().set(&DataKey::Metrics(stream_id), &metrics);
814883
env.storage().persistent().extend_ttl(&DataKey::Metrics(stream_id), LEDGER_THRESHOLD, LEDGER_BUMP);
815884

885+
// Update sender's cumulative volume for fee tier eligibility.
886+
// deposit() actually moves tokens into escrow, so each deposited
887+
// amount counts toward the sender's volume just as initial_amount
888+
// does at stream creation.
889+
let sender_vol_key = DataKey::SenderVolume(stream.sender.clone());
890+
let current_vol: i128 = env.storage().persistent()
891+
.get(&sender_vol_key)
892+
.unwrap_or(0);
893+
let new_vol = current_vol.saturating_add(amount);
894+
env.storage().persistent().set(&sender_vol_key, &new_vol);
895+
env.storage().persistent().extend_ttl(&sender_vol_key, LEDGER_THRESHOLD, LEDGER_BUMP);
896+
816897
// Emit StreamDeposit event
817898
StreamDepositEvent { stream_id, amount }.publish(&env);
818899
}
@@ -1156,48 +1237,6 @@ impl PaymentStreamContract {
11561237
env.storage().persistent().get(&DataKey::Delegate(stream_id))
11571238
}
11581239

1159-
/// Calculate the protocol fee for a given withdrawal based on the
1160-
/// donor's cumulative volume and the configured fee tiers.
1161-
///
1162-
/// Falls back to the flat `FeeRate` if no tier list has been configured
1163-
/// (should not happen after `initialize`).
1164-
fn calculate_protocol_fee(env: &Env, donor: &Address, amount: i128) -> i128 {
1165-
if amount <= 0 {
1166-
return 0;
1167-
}
1168-
1169-
// Get donor's cumulative volume
1170-
let donor_volume_key = (Symbol::new(env, "donor_volume"), donor.clone());
1171-
let cumulative_volume: i128 = env.storage().persistent().get(&donor_volume_key).unwrap_or(0);
1172-
1173-
// Get fee tiers (fallback to flat fee if tiers not configured)
1174-
let tiers: Vec<FeeTier> = match env.storage().instance().get(&Symbol::new(env, "fee_tiers")) {
1175-
Some(tiers) => tiers,
1176-
None => {
1177-
// Fallback: use general fee rate if tiers not set up
1178-
let fee_rate: u32 = env.storage().instance().get(&DataKey::FeeRate).unwrap_or(0);
1179-
let rate = fee_rate as i128;
1180-
return (amount / 10000) * rate + ((amount % 10000) * rate) / 10000;
1181-
}
1182-
};
1183-
1184-
// Determine applicable tier
1185-
let mut applicable_fee_rate: u32 = 0;
1186-
for tier in tiers.iter() {
1187-
if cumulative_volume >= tier.threshold {
1188-
applicable_fee_rate = tier.fee_rate;
1189-
}
1190-
}
1191-
1192-
// Calculate fee with the determined rate
1193-
if applicable_fee_rate == 0 {
1194-
return 0;
1195-
}
1196-
1197-
let rate = applicable_fee_rate as i128;
1198-
let fee = (amount / 10000) * rate + ((amount % 10000) * rate) / 10000;
1199-
fee.max(0)
1200-
}
12011240

12021241
/// Calculate withdrawable amount for a stream
12031242
pub fn withdrawable_amount(env: Env, stream_id: u64) -> i128 {
@@ -1263,7 +1302,7 @@ impl PaymentStreamContract {
12631302
}
12641303

12651304
// Calculate protocol fee based on donor's cumulative volume
1266-
let fee = Self::calculate_protocol_fee(&env, &stream.sender, amount);
1305+
let fee = Self::calculate_protocol_fee(&env, amount, &stream.sender);
12671306
let net_amount = amount - fee;
12681307

12691308
stream.withdrawn_amount += amount;
@@ -1465,6 +1504,11 @@ impl PaymentStreamContract {
14651504
}
14661505

14671506
/// Set the protocol fee rate
1507+
///
1508+
/// # Errors
1509+
/// * [`Error::FeeTooHigh`] — `new_fee_rate > MAX_FEE`.
1510+
/// * [`Error::InvalidTier`] — `new_fee_rate` is below the first configured
1511+
/// tier's fee_rate (would violate the non-increasing invariant).
14681512
pub fn set_protocol_fee_rate(env: Env, new_fee_rate: u32) {
14691513
let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
14701514
admin.require_auth();
@@ -1473,6 +1517,20 @@ impl PaymentStreamContract {
14731517
panic_with_error!(&env, Error::FeeTooHigh);
14741518
}
14751519

1520+
// Preserve the non-increasing fee-rate invariant: the general rate must
1521+
// be >= every configured tier rate. The first tier has the highest
1522+
// fee_rate among all tiers (since tiers are stored non-increasing), so
1523+
// checking only the first tier is sufficient.
1524+
let tiers: Vec<FeeTier> = env.storage().instance()
1525+
.get(&DataKey::FeeTiers)
1526+
.unwrap_or_else(|| Vec::new(&env));
1527+
if !tiers.is_empty() {
1528+
let first_tier = tiers.get(0).unwrap();
1529+
if new_fee_rate < first_tier.fee_rate {
1530+
panic_with_error!(&env, Error::InvalidTier);
1531+
}
1532+
}
1533+
14761534
env.storage().instance().set(&DataKey::FeeRate, &new_fee_rate);
14771535
env.storage().instance().extend_ttl(LEDGER_THRESHOLD, LEDGER_BUMP);
14781536
}
@@ -1496,17 +1554,18 @@ impl PaymentStreamContract {
14961554
env.storage().instance().get(&DataKey::FeeCollector).unwrap()
14971555
}
14981556

1499-
/// Replace the fee-tier configuration (admin only).
1557+
/// Set fee tiers for volume-based protocol fee discounts (admin only).
15001558
///
1501-
/// `tiers` must be a non-empty list whose first entry has a threshold of
1502-
/// `0`, whose thresholds are strictly increasing, and whose fee rates are
1503-
/// monotonically non-increasing and at most [`MAX_FEE`].
1559+
/// `tiers` must be sorted in **strictly ascending** order by `min_volume`.
1560+
/// Each tier's `fee_rate` must not exceed `MAX_FEE` (500 bps = 5%).
1561+
/// Pass an empty Vec to clear all tiers and revert to the flat `FeeRate`.
15041562
///
15051563
/// # Errors
1506-
/// * [`Error::InvalidTierConfiguration`] — empty list, first threshold not
1507-
/// zero, or thresholds not strictly increasing.
1508-
/// * [`Error::TierFeeNotMonotonic`] — fee rates increase across tiers.
1509-
/// * [`Error::FeeTooHigh`] — a tier fee rate exceeds 500 bps.
1564+
/// * [`Error::Unauthorized`] — caller is not the contract admin.
1565+
/// * [`Error::TooManyTiers`] — `tiers.len() > MAX_TIERS` (10).
1566+
/// * [`Error::InvalidTier`] — a tier's `fee_rate` exceeds `MAX_FEE`, or tiers
1567+
/// are not strictly sorted ascending by `min_volume`, or fee rates are
1568+
/// not monotonically non-increasing.
15101569
pub fn set_fee_tiers(env: Env, tiers: Vec<FeeTier>) {
15111570
let admin: Address = env
15121571
.storage()
@@ -1515,56 +1574,60 @@ impl PaymentStreamContract {
15151574
.unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized));
15161575
admin.require_auth();
15171576

1518-
// Validate that tiers is not empty
1519-
if tiers.is_empty() {
1520-
panic_with_error!(&env, Error::InvalidTierConfiguration);
1521-
}
1522-
1523-
// Validate first tier has threshold 0
1524-
if let Some(first_tier) = tiers.first() {
1525-
if first_tier.threshold != 0 {
1526-
panic_with_error!(&env, Error::InvalidTierConfiguration);
1527-
}
1577+
if tiers.len() > MAX_TIERS {
1578+
panic_with_error!(&env, Error::TooManyTiers);
15281579
}
15291580

1530-
// Validate thresholds are strictly increasing
1531-
let mut prev_threshold: i128 = -1;
1532-
for tier in tiers.iter() {
1533-
if tier.threshold <= prev_threshold {
1534-
panic_with_error!(&env, Error::InvalidTierConfiguration);
1581+
let general_rate: u32 = env.storage().instance()
1582+
.get(&DataKey::FeeRate)
1583+
.unwrap_or(0);
1584+
let mut prev_min_volume: i128 = -1_i128;
1585+
let mut prev_fee_rate: u32 = general_rate;
1586+
let len = tiers.len();
1587+
for i in 0..len {
1588+
let tier = tiers.get(i).unwrap();
1589+
if tier.fee_rate > MAX_FEE {
1590+
panic_with_error!(&env, Error::InvalidTier);
15351591
}
1536-
prev_threshold = tier.threshold;
1537-
}
1538-
1539-
// Validate fee rates are monotonically non-increasing and within MAX_FEE
1540-
let mut prev_fee_rate: u32 = u32::MAX;
1541-
for tier in tiers.iter() {
15421592
if tier.fee_rate > prev_fee_rate {
1543-
panic_with_error!(&env, Error::TierFeeNotMonotonic);
1593+
panic_with_error!(&env, Error::InvalidTier);
15441594
}
1545-
if tier.fee_rate > MAX_FEE {
1546-
panic_with_error!(&env, Error::FeeTooHigh);
1595+
if tier.min_volume <= prev_min_volume {
1596+
panic_with_error!(&env, Error::InvalidTier);
15471597
}
1598+
prev_min_volume = tier.min_volume;
15481599
prev_fee_rate = tier.fee_rate;
15491600
}
15501601

1551-
// Store the new tier configuration
1552-
env.storage().instance().set(&Symbol::new(&env, "fee_tiers"), &tiers);
1602+
env.storage().instance().set(&DataKey::FeeTiers, &tiers);
15531603
env.storage().instance().extend_ttl(LEDGER_THRESHOLD, LEDGER_BUMP);
1604+
1605+
env.events().publish(("FeeTiersUpdated",), ());
15541606
}
15551607

1556-
/// Get the current fee-tier configuration.
1608+
/// Return the currently configured fee tiers.
1609+
/// Returns an empty Vec when no tiers have been configured.
15571610
pub fn get_fee_tiers(env: Env) -> Vec<FeeTier> {
1558-
match env.storage().instance().get(&Symbol::new(&env, "fee_tiers")) {
1559-
Some(tiers) => tiers,
1560-
None => Vec::new(&env),
1561-
}
1611+
env.storage().instance()
1612+
.get(&DataKey::FeeTiers)
1613+
.unwrap_or_else(|| Vec::new(&env))
1614+
}
1615+
1616+
/// Return the cumulative escrowed volume for a sender address.
1617+
///
1618+
/// This is the sum of all tokens the sender has actually transferred into
1619+
/// escrow: the `initial_amount` at stream creation plus any subsequent
1620+
/// `deposit()` calls. It determines their position in the fee tier
1621+
/// hierarchy and cannot be gamed by declaring a large `total_amount`
1622+
/// without depositing funds.
1623+
pub fn get_sender_volume(env: Env, sender: Address) -> i128 {
1624+
let sender_vol_key = DataKey::SenderVolume(sender);
1625+
env.storage().persistent().get(&sender_vol_key).unwrap_or(0)
15621626
}
15631627

1564-
/// Get a donor's cumulative streamed volume, used to select their fee tier.
1565-
pub fn get_donor_cumulative_volume(env: Env, donor: Address) -> i128 {
1566-
let donor_volume_key = (Symbol::new(&env, "donor_volume"), donor);
1567-
env.storage().persistent().get(&donor_volume_key).unwrap_or(0)
1628+
/// Return the applicable fee rate for a sender given their current volume.
1629+
pub fn get_applicable_fee_rate(env: Env, sender: Address) -> u32 {
1630+
Self::get_applicable_fee_rate_internal(&env, &sender)
15681631
}
15691632

15701633
/// Get stream-specific metrics

0 commit comments

Comments
 (0)