Skip to content
Merged
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
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 15 additions & 4 deletions contracts/staking_rewards/README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
# Staking Rewards Contract

The staking rewards contract lets users stake a principal token, accrue reward-token payouts using a fixed-point compounding schedule, and recover principal through normal or emergency withdrawal paths.
The staking rewards contract lets users stake a principal token, accrue reward-token payouts using an epoch-based compounding schedule with decay, and recover principal through normal or emergency withdrawal paths.

## Epoch Snapshot System

Rewards accrue at a rate that is constant within each epoch and decays at epoch boundaries.

- **epoch_length**: blocks per epoch (configurable at initialization)
- **epoch_decay_percent**: percentage reduction in emission rate at each epoch boundary
- Within an epoch: `rate = initial_rate * (1 - epoch_decay_percent)^epoch`
- Snapshots are created lazily on first access and stored on-chain for transparency

This replaces the previous continuous per-block decay model, providing predictable per-epoch rates and reduced computational overhead.

## Initialization

`initialize(owner, staking_token, reward_token, initial_rate, decay_rate, start_block) -> Result<(), ContractError>`
`initialize(owner, staking_token, reward_token, initial_rate, epoch_decay_percent, epoch_length, start_block) -> Result<(), ContractError>`

Creates the contract configuration once. `initial_rate` and `decay_rate` use the contract's 18-decimal fixed-point scale. `decay_rate` must be between `0` and `SCALE`, and `initial_rate` must be non-negative.
Creates the contract configuration once. `initial_rate` and `epoch_decay_percent` use the contract's 18-decimal fixed-point scale. `epoch_decay_percent` must be between `0` and `SCALE`, `epoch_length` must be non-zero, and `initial_rate` must be non-negative. The epoch 0 snapshot is created during initialization.

## Mutating API

Expand Down Expand Up @@ -42,7 +53,7 @@ Returns rewards persisted during the user's last state update, or `0` when no st

`get_pending_rewards(user) -> i128`

Returns accrued rewards plus rewards accumulated since the last update, using the current ledger sequence.
Returns accrued rewards plus rewards accumulated since the last update, using the current ledger sequence and epoch-based decay.

`get_config() -> Result<StakingConfig, ContractError>`

Expand Down
177 changes: 131 additions & 46 deletions contracts/staking_rewards/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub enum DataKey {
Config,
UserState(Address),
TotalStaked,
EpochSnapshot(u32),
}

// ── Configuration Struct ──────────────────────────────────────
Expand All @@ -26,12 +27,22 @@ pub struct StakingConfig {
pub owner: Address,
pub staking_token: Address,
pub reward_token: Address,
pub initial_rate: Fixed, // r0
pub decay_rate: Fixed, // d (where alpha = 1 - d)
pub initial_rate: Fixed, // r0 — emission rate at epoch 0
pub epoch_decay_percent: Fixed, // percentage reduction per epoch (e.g. 0.1 = 10%)
pub epoch_length: u32, // blocks per epoch
pub start_block: u32,
pub is_paused: bool,
}

// ── Epoch Snapshot ────────────────────────────────────────────

#[derive(Clone, Debug, Eq, PartialEq)]
#[contracttype]
pub struct EpochSnapshot {
pub rate: Fixed,
pub start_block: u32,
}

// ── User Staking State ────────────────────────────────────────

#[derive(Clone, Debug, Eq, PartialEq)]
Expand Down Expand Up @@ -148,9 +159,76 @@ fn multiply_amount(amount: i128, multiplier: Fixed) -> Result<i128, ContractErro
mul_div(amount, multiplier.0, SCALE).ok_or(ContractError::Overflow)
}

// ── Epoch Helpers ─────────────────────────────────────────────

fn epoch_for_block(start_block: u32, epoch_length: u32, block: u32) -> u32 {
if block <= start_block {
return 0;
}
(block - start_block) / epoch_length
}

fn epoch_start_block(start_block: u32, epoch_length: u32, epoch: u32) -> u32 {
start_block + epoch * epoch_length
}

fn compute_epoch_rate(
initial_rate: &Fixed,
epoch_decay_percent: &Fixed,
epoch: u32,
) -> Result<Fixed, ContractError> {
if epoch == 0 {
return Ok(*initial_rate);
}
// rate = initial_rate * (1 - epoch_decay_percent)^epoch
let base = Fixed::ONE
.sub(*epoch_decay_percent)
.map_err(|_| ContractError::Overflow)?;
if base.0 < 0 || base.0 > SCALE {
return Err(ContractError::InvalidInput);
}
let decay_factor = fixed_pow_int(base, epoch)?;
initial_rate.mul(decay_factor).map_err(|_| ContractError::Overflow)
}

fn ensure_epoch_snapshots(
e: &Env,
config: &StakingConfig,
up_to_block: u32,
) -> Result<(), ContractError> {
let up_to_epoch = epoch_for_block(config.start_block, config.epoch_length, up_to_block);
// Find the highest epoch already snapshotted
let mut epoch = 0u32;
while epoch <= up_to_epoch {
let key = DataKey::EpochSnapshot(epoch);
if !e.storage().instance().has(&key) {
let rate = compute_epoch_rate(&config.initial_rate, &config.epoch_decay_percent, epoch)?;
let start = epoch_start_block(config.start_block, config.epoch_length, epoch);
e.storage().instance().set(&key, &EpochSnapshot { rate, start_block: start });
}
epoch += 1;
}
Ok(())
}

fn get_epoch_rate(e: &Env, epoch: u32) -> Result<Fixed, ContractError> {
let key = DataKey::EpochSnapshot(epoch);
let snapshot: EpochSnapshot = e
.storage()
.instance()
.get(&key)
.ok_or(ContractError::NotInitialized)?;
Ok(snapshot.rate)
}

// ── Compounding Multiplier Calculation ────────────────────────

fn calculate_multiplier(config: &StakingConfig, t1: u32, t2: u32) -> Result<Fixed, ContractError> {
fn calculate_multiplier(
e: &Env,
config: &StakingConfig,
t1: u32,
t2: u32,
) -> Result<Fixed, ContractError> {
if t2 <= t1 {
return Ok(Fixed::ONE);
}
Expand All @@ -163,48 +241,30 @@ fn calculate_multiplier(config: &StakingConfig, t1: u32, t2: u32) -> Result<Fixe
return Ok(Fixed::ONE);
}

let k1 = t1_eff - t_start;
let k2 = t2_eff - t_start;
ensure_epoch_snapshots(e, config, t2_eff)?;

if config.decay_rate.0 == 0 {
// No decay case: alpha = 1
let elapsed = (k2 - k1) as i128;
let elapsed_fixed = Fixed::from_int(elapsed).map_err(|_| ContractError::Overflow)?;
let exponent = config
.initial_rate
.mul(elapsed_fixed)
.map_err(|_| ContractError::Overflow)?;
let multiplier = exponent.exp().map_err(|_| ContractError::Overflow)?;
Ok(multiplier)
} else {
// Decay case: alpha = 1 - d
let alpha = Fixed::ONE
.sub(config.decay_rate)
.map_err(|_| ContractError::Overflow)?;
if alpha.0 < 0 || alpha.0 > SCALE {
return Err(ContractError::InvalidInput);
}
let e1 = epoch_for_block(t_start, config.epoch_length, t1_eff);
let e2 = epoch_for_block(t_start, config.epoch_length, t2_eff);

let a1 = fixed_pow_int(alpha, k1)?;
let a2 = fixed_pow_int(alpha, k2)?;
let diff = a1.sub(a2).map_err(|_| ContractError::Overflow)?;

// exponent = r0 * diff / decay_rate
let term = config
.initial_rate
.mul(diff)
.map_err(|_| ContractError::Overflow)?;
let exponent = term.div(config.decay_rate).map_err(|_| {
if config.decay_rate.0 == 0 {
ContractError::DivisionByZero
} else {
ContractError::Overflow
}
})?;
let mut mult = Fixed::ONE;

for epoch in e1..=e2 {
let rate = get_epoch_rate(e, epoch)?;
let epoch_start = epoch_start_block(t_start, config.epoch_length, epoch);
let epoch_end = epoch_start + config.epoch_length;
let overlap_start = t1_eff.max(epoch_start);
let overlap_end = t2_eff.min(epoch_end);

let multiplier = exponent.exp().map_err(|_| ContractError::Overflow)?;
Ok(multiplier)
if overlap_end > overlap_start {
let blocks = (overlap_end - overlap_start) as i128;
let blocks_fixed = Fixed::from_int(blocks).map_err(|_| ContractError::Overflow)?;
let exponent = rate.mul(blocks_fixed).map_err(|_| ContractError::Overflow)?;
let factor = exponent.exp().map_err(|_| ContractError::Overflow)?;
mult = mult.mul(factor).map_err(|_| ContractError::Overflow)?;
}
}

Ok(mult)
}

// ── Contract Implementation ───────────────────────────────────
Expand All @@ -220,14 +280,20 @@ impl StakingRewards {
owner: Address,
staking_token: Address,
reward_token: Address,
initial_rate: i128, // initial rate (Fixed point representation)
decay_rate: i128, // decay rate (Fixed point representation, d = 1 - alpha)
initial_rate: i128,
epoch_decay_percent: i128,
epoch_length: u32,
start_block: u32,
) -> Result<(), ContractError> {
if e.storage().instance().has(&DataKey::Config) {
return Err(ContractError::AlreadyInitialized);
}

if epoch_decay_percent < 0 || epoch_decay_percent > SCALE {
return Err(ContractError::InvalidInput);
}

if epoch_length == 0 {
if !(0..=SCALE).contains(&decay_rate) {
return Err(ContractError::InvalidInput);
}
Expand All @@ -241,11 +307,19 @@ impl StakingRewards {
staking_token,
reward_token,
initial_rate: Fixed(initial_rate),
decay_rate: Fixed(decay_rate),
epoch_decay_percent: Fixed(epoch_decay_percent),
epoch_length,
start_block,
is_paused: false,
};

// Create epoch 0 snapshot
let rate0 = compute_epoch_rate(&config.initial_rate, &config.epoch_decay_percent, 0)?;
e.storage().instance().set(
&DataKey::EpochSnapshot(0),
&EpochSnapshot { rate: rate0, start_block },
);

e.storage().instance().set(&DataKey::Config, &config);
e.storage().instance().set(&DataKey::TotalStaked, &0i128);
e.storage().instance().extend_ttl(10000, 10000);
Expand Down Expand Up @@ -291,6 +365,11 @@ impl StakingRewards {
.ok_or(ContractError::Overflow)?;

// Update total staked
let mut total_staked: i128 = e.storage().instance().get(&DataKey::TotalStaked).unwrap_or(0);
total_staked = total_staked
.checked_add(amount)
.ok_or(ContractError::Overflow)?;
e.storage().instance().set(&DataKey::TotalStaked, &total_staked);
let mut total_staked: i128 = e
.storage()
.instance()
Expand Down Expand Up @@ -344,6 +423,11 @@ impl StakingRewards {
.ok_or(ContractError::Overflow)?;

// Update total staked
let mut total_staked: i128 = e.storage().instance().get(&DataKey::TotalStaked).unwrap_or(0);
total_staked = total_staked
.checked_sub(amount)
.ok_or(ContractError::Overflow)?;
e.storage().instance().set(&DataKey::TotalStaked, &total_staked);
let mut total_staked: i128 = e
.storage()
.instance()
Expand Down Expand Up @@ -455,6 +539,7 @@ impl StakingRewards {
let staked_amount = state.staked_amount;

// Update total staked
let mut total_staked: i128 = e.storage().instance().get(&DataKey::TotalStaked).unwrap_or(0);
let mut total_staked: i128 = e
.storage()
.instance()
Expand Down Expand Up @@ -647,7 +732,7 @@ impl StakingRewards {
// Time-based reward calculation: V_new = V_old * multiplier, where
// multiplier = exp(integral of reward rate over time). Rewards are
// computed as R_new = V_new - staked_amount to avoid rounding errors.
let multiplier_res = calculate_multiplier(&config, state.last_update_block, t_curr);
let multiplier_res = calculate_multiplier(&e, &config, state.last_update_block, t_curr);
if let Ok(multiplier) = multiplier_res {
let v_old_res = state.staked_amount.checked_add(state.accrued_rewards);
if let Some(v_old) = v_old_res {
Expand Down Expand Up @@ -697,7 +782,7 @@ impl StakingRewards {
// Time-based reward calculation: V_new = V_old * multiplier, where
// multiplier = exp(integral of reward rate over time). Rewards are
// computed as R_new = V_new - staked_amount to avoid rounding errors.
let multiplier = calculate_multiplier(config, state.last_update_block, t_curr)?;
let multiplier = calculate_multiplier(e, config, state.last_update_block, t_curr)?;

// Virtual Balance V = S + R
let v_old = state
Expand Down
Loading