Skip to content

Commit 6f0b564

Browse files
authored
Merge pull request #443 from Baskarayelu/feature/rewards-419-421-model
feat(rewards): accrue protocol fee rewards with view, event, and boundary tests
2 parents 4bd1f13 + bfedbf0 commit 6f0b564

1 file changed

Lines changed: 108 additions & 0 deletions

File tree

src/lib.rs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,12 @@ pub enum DataKey {
165165
/// (singleton, `u64`, persistent). Incremented with `saturating_add`
166166
/// so it is monotonic and never panics. Defaults to `0`.
167167
TotalRoutesAllTime,
168+
/// Protocol-wide lifetime sum of fees collected by `compute_route_fee`
169+
/// (singleton, `i128`, persistent), owed to whoever holds
170+
/// `FeeRecipient` once distributed off-chain. Accumulated with
171+
/// `saturating_add` so it is monotonic and never panics on overflow.
172+
/// Defaults to `0`.
173+
RewardsAccrued,
168174
/// Ledger timestamp of the most recent `compute_route_fee` for a
169175
/// pair (keyed per-pair, `u64`, persistent). Used by the cooldown
170176
/// rate-limit gate. Absent reads as `None` (`Option`); `get_pair_info`
@@ -994,6 +1000,18 @@ impl StableRouteRouter {
9941000
.unwrap_or(0)
9951001
}
9961002

1003+
/// Read-only view of the protocol-wide lifetime sum of fees accrued by
1004+
/// `compute_route_fee`, owed to `FeeRecipient` once distributed.
1005+
///
1006+
/// Does not mutate storage. Returns `0` before any fee-bearing route
1007+
/// has been processed.
1008+
pub fn get_rewards_accrued(env: Env) -> i128 {
1009+
env.storage()
1010+
.persistent()
1011+
.get(&DataKey::RewardsAccrued)
1012+
.unwrap_or(0)
1013+
}
1014+
9971015
/// Returns the number of successful routes executed for the specified pair.
9981016
///
9991017
/// Returns `0` if the pair has never been routed.
@@ -1270,6 +1288,22 @@ impl StableRouteRouter {
12701288
.persistent()
12711289
.set(&DataKey::TotalRoutesAllTime, &total.saturating_add(1));
12721290

1291+
// A zero fee is not a rewards-state change: skip the write and
1292+
// the event entirely rather than recording a no-op accrual.
1293+
if fee > 0 {
1294+
let accrued: i128 = env
1295+
.storage()
1296+
.persistent()
1297+
.get(&DataKey::RewardsAccrued)
1298+
.unwrap_or(0);
1299+
let new_accrued = accrued.saturating_add(fee);
1300+
env.storage()
1301+
.persistent()
1302+
.set(&DataKey::RewardsAccrued, &new_accrued);
1303+
env.events()
1304+
.publish((symbol_short!("rwd_accr"),), (fee, new_accrued));
1305+
}
1306+
12731307
// Construct the route-count key once — shared between read and write.
12741308
let route_count_key = DataKey::PairRouteCount(source.clone(), destination.clone());
12751309
let pair_count: u64 = env
@@ -2338,6 +2372,80 @@ mod test {
23382372
assert_eq!(fee, 5_000);
23392373
}
23402374

2375+
// --- rewards accrual ---
2376+
2377+
#[test]
2378+
fn test_rewards_accrued_defaults_to_zero() {
2379+
let env = Env::default();
2380+
let (client, _admin) = setup_initialized(&env);
2381+
assert_eq!(client.get_rewards_accrued(), 0);
2382+
}
2383+
2384+
#[test]
2385+
fn test_rewards_accrued_accumulates_across_routes() {
2386+
let env = Env::default();
2387+
let (client, _admin) = setup_initialized(&env);
2388+
let (s, d) = (symbol_short!("USDC"), symbol_short!("EURC"));
2389+
client.register_pair(&s, &d);
2390+
client.set_pair_fee_bps(&s, &d, &50u32);
2391+
let fee1 = client.compute_route_fee(&s, &d, &1_000_000_i128);
2392+
assert_eq!(client.get_rewards_accrued(), fee1);
2393+
let fee2 = client.compute_route_fee(&s, &d, &2_000_000_i128);
2394+
assert_eq!(client.get_rewards_accrued(), fee1 + fee2);
2395+
}
2396+
2397+
#[test]
2398+
fn test_rewards_accrued_unaffected_by_zero_fee_route() {
2399+
let env = Env::default();
2400+
let (client, _admin) = setup_initialized(&env);
2401+
let (s, d) = (symbol_short!("USDC"), symbol_short!("EURC"));
2402+
client.register_pair(&s, &d);
2403+
// No fee_bps set — every route is free.
2404+
let fee = client.compute_route_fee(&s, &d, &1_000_000_i128);
2405+
assert_eq!(fee, 0);
2406+
assert_eq!(client.get_rewards_accrued(), 0);
2407+
assert_eq!(
2408+
event_payloads(&env, symbol_short!("rwd_accr")).len(),
2409+
0,
2410+
"a zero-fee route must not emit rwd_accr"
2411+
);
2412+
}
2413+
2414+
#[test]
2415+
fn test_rewards_accrued_event_carries_fee_and_running_total() {
2416+
let env = Env::default();
2417+
let (client, _admin) = setup_initialized(&env);
2418+
let (s, d) = (symbol_short!("USDC"), symbol_short!("EURC"));
2419+
client.register_pair(&s, &d);
2420+
client.set_pair_fee_bps(&s, &d, &50u32);
2421+
let fee = client.compute_route_fee(&s, &d, &1_000_000_i128);
2422+
let payloads = event_payloads(&env, symbol_short!("rwd_accr"));
2423+
assert_eq!(payloads.len(), 1, "one rwd_accr event for one fee-bearing route");
2424+
let (emitted_fee, emitted_total): (i128, i128) =
2425+
soroban_sdk::TryFromVal::try_from_val(&env, &payloads[0])
2426+
.expect("rwd_accr payload decodes to (fee, running_total)");
2427+
assert_eq!(emitted_fee, fee);
2428+
assert_eq!(emitted_total, client.get_rewards_accrued());
2429+
}
2430+
2431+
/// Over-limit boundary: accrual must saturate at `i128::MAX` instead of
2432+
/// panicking on overflow.
2433+
#[test]
2434+
fn test_rewards_accrued_saturates_instead_of_overflowing() {
2435+
let env = Env::default();
2436+
let (client, _admin, contract_id) = setup_initialized_with_id(&env);
2437+
let (s, d) = (symbol_short!("USDC"), symbol_short!("EURC"));
2438+
client.register_pair(&s, &d);
2439+
client.set_pair_fee_bps(&s, &d, &50u32);
2440+
env.as_contract(&contract_id, || {
2441+
env.storage()
2442+
.persistent()
2443+
.set(&DataKey::RewardsAccrued, &(i128::MAX - 10));
2444+
});
2445+
client.compute_route_fee(&s, &d, &1_000_000_i128);
2446+
assert_eq!(client.get_rewards_accrued(), i128::MAX);
2447+
}
2448+
23412449
#[test]
23422450
fn test_compute_route_fee_is_zero_when_fee_unset() {
23432451
let env = Env::default();

0 commit comments

Comments
 (0)