diff --git a/Cargo.lock b/Cargo.lock index 45e11e40..ec7ad9ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -527,6 +527,9 @@ dependencies = [ ] [[package]] +name = "cross-chain-verifier" +version = "0.1.0" +dependencies = [ name = "cross-chain-payload" version = "0.1.0" dependencies = [ diff --git a/contracts/staking_rewards/README.md b/contracts/staking_rewards/README.md index db80481b..47c2e202 100644 --- a/contracts/staking_rewards/README.md +++ b/contracts/staking_rewards/README.md @@ -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 @@ -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` diff --git a/contracts/staking_rewards/src/lib.rs b/contracts/staking_rewards/src/lib.rs index f6a85e99..124a46f8 100644 --- a/contracts/staking_rewards/src/lib.rs +++ b/contracts/staking_rewards/src/lib.rs @@ -16,6 +16,7 @@ pub enum DataKey { Config, UserState(Address), TotalStaked, + EpochSnapshot(u32), } // ── Configuration Struct ────────────────────────────────────── @@ -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)] @@ -148,9 +159,76 @@ fn multiply_amount(amount: i128, multiplier: Fixed) -> Result 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 { + 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 { + 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 { +fn calculate_multiplier( + e: &Env, + config: &StakingConfig, + t1: u32, + t2: u32, +) -> Result { if t2 <= t1 { return Ok(Fixed::ONE); } @@ -163,48 +241,30 @@ fn calculate_multiplier(config: &StakingConfig, t1: u32, t2: u32) -> Result 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 ─────────────────────────────────── @@ -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); } @@ -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); @@ -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() @@ -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() @@ -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() @@ -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 { @@ -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 diff --git a/contracts/staking_rewards/src/test.rs b/contracts/staking_rewards/src/test.rs index 4d182734..214488ef 100644 --- a/contracts/staking_rewards/src/test.rs +++ b/contracts/staking_rewards/src/test.rs @@ -7,7 +7,8 @@ use soroban_sdk::{ }; const INITIAL_RATE: i128 = 100_000_000_000_000; // 0.0001 in Fixed (18 decimals) -const DECAY_RATE: i128 = 10_000_000_000_000_000; // 0.01 in Fixed (18 decimals) +const EPOCH_DECAY_PERCENT: i128 = 10_000_000_000_000_000; // 0.01 in Fixed (18 decimals) = 1% per epoch +const EPOCH_LENGTH: u32 = 10; const STAKE_AMOUNT: i128 = 10_000; fn advance_ledger(e: &Env, by: u32) { @@ -46,11 +47,11 @@ fn setup() -> ( &staking_token, &reward_token, &INITIAL_RATE, - &DECAY_RATE, + &EPOCH_DECAY_PERCENT, + &EPOCH_LENGTH, &0u32, // start block ); - // Mint staking tokens to users later, and mint reward tokens to the contract let reward_client = token::StellarAssetClient::new(&e, &reward_token); reward_client.mint(&contract_id, &1_000_000_000); @@ -66,14 +67,14 @@ fn test_initialization() { assert_eq!(config.staking_token, staking_token); assert_eq!(config.reward_token, reward_token); assert_eq!(config.initial_rate.0, INITIAL_RATE); - assert_eq!(config.decay_rate.0, DECAY_RATE); + assert_eq!(config.epoch_decay_percent.0, EPOCH_DECAY_PERCENT); + assert_eq!(config.epoch_length, EPOCH_LENGTH); assert_eq!(config.start_block, 0); assert!(!config.is_paused); } #[test] fn test_stake_and_yield_accumulation_no_decay() { - // Re-initialize with 0 decay rate (alpha = 1) let e = Env::default(); e.mock_all_auths(); e.cost_estimate().budget().reset_unlimited(); @@ -94,15 +95,15 @@ fn test_stake_and_yield_accumulation_no_decay() { &staking_token, &reward_token, &INITIAL_RATE, - &0i128, // decay_rate = 0 - &10u32, // start_block = 10 + &0i128, // epoch_decay_percent = 0 (no decay) + &EPOCH_LENGTH, + &10u32, // start_block = 10 ); let user = Address::generate(&e); let staking_client = token::StellarAssetClient::new(&e, &staking_token); staking_client.mint(&user, &STAKE_AMOUNT); - // Fast forward ledger to block 10 advance_ledger(&e, 10); // Stake at block 10 @@ -112,14 +113,11 @@ fn test_stake_and_yield_accumulation_no_decay() { assert_eq!(client.get_accrued_rewards(&user), 0); assert_eq!(client.get_pending_rewards(&user), 0); - // Advance 5 blocks (from 10 to 15) + // Advance 5 blocks (from 10 to 15) — still in epoch 0 advance_ledger(&e, 5); - // Expected multiplier: exp(r0 * 5) - // r0 = 0.0001, so r0 * 5 = 0.0005 - // exp(0.0005) = 1.00050012502 - // Expected reward = 10,000 * (1.00050012502 - 1) = 5.0012502 - // Truncated to integer: 5 + // Expected: multiplier = exp(r0 * 5) = exp(0.0001 * 5) = exp(0.0005) ≈ 1.00050012502 + // reward = 10,000 * (1.00050012502 - 1) = 5.0012502 → truncated to 5 let pending = client.get_pending_rewards(&user); assert_eq!(pending, 5); } @@ -132,21 +130,16 @@ fn test_stake_and_yield_accumulation_with_decay() { let staking_client = token::StellarAssetClient::new(&e, &staking_token); staking_client.mint(&user, &STAKE_AMOUNT); - // Stake at block 0 + // Stake at block 0 — epoch 0 client.stake(&user, &STAKE_AMOUNT); - // Advance 5 blocks (from 0 to 5) + // Advance 5 blocks (from 0 to 5) — still epoch 0, rate = r0 = 0.0001 advance_ledger(&e, 5); - // Expected exponent: (r0 / d) * (1 - alpha^5) - // r0 = 0.0001, d = 0.01, alpha = 0.99 - // alpha^5 = 0.99^5 = 0.9509900499 - // 1 - alpha^5 = 0.0490099501 - // exponent = (0.0001 / 0.01) * 0.0490099501 = 0.000490099501 - // exp(0.000490099501) = 1.0004902196 - // Expected reward = 10,000 * 0.0004902196 = 4.902196 => truncated to 4 + // Expected: multiplier = exp(r0 * 5) = exp(0.0005) ≈ 1.00050012502 + // reward = 10,000 * 0.00050012502 = 5.0012502 → truncated to 5 let pending = client.get_pending_rewards(&user); - assert_eq!(pending, 4); + assert_eq!(pending, 5); // Claim rewards client.claim(&user); @@ -154,24 +147,102 @@ fn test_stake_and_yield_accumulation_with_decay() { assert_eq!(client.get_pending_rewards(&user), 0); } +#[test] +fn test_epoch_boundary_decay() { + let (e, client, _, staking_token, _) = setup(); + let user = Address::generate(&e); + + let staking_client = token::StellarAssetClient::new(&e, &staking_token); + staking_client.mint(&user, &STAKE_AMOUNT); + + // Stake at block 0 — epoch 0 + client.stake(&user, &STAKE_AMOUNT); + + // Advance to block 10 — epoch 1 begins + // Blocks 0-9: epoch 0, rate = r0 = 0.0001 + // Block 10: epoch 1, rate = r0 * (1 - 0.01)^1 = 0.0001 * 0.99 = 0.000099 + // We advance to block 12: 10 blocks epoch 0 + 2 blocks epoch 1 + // exponent = 10 * 0.0001 + 2 * 0.000099 = 0.001 + 0.000198 = 0.001198 + // multiplier = exp(0.001198) ≈ 1.001198718 + // reward = 10,000 * 0.001198718 = 11.98718 → truncated to 11 + advance_ledger(&e, 12); + + let pending = client.get_pending_rewards(&user); + assert_eq!(pending, 11); + + // Claim to reset + client.claim(&user); + + // Advance to block 25: remaining 8 blocks epoch 1 + 5 blocks epoch 2 + // epoch 2 rate = r0 * 0.99^2 = 0.0001 * 0.9801 = 0.00009801 + // exponent = 8 * 0.000099 + 5 * 0.00009801 = 0.000792 + 0.00049005 = 0.00128205 + // multiplier = exp(0.00128205) ≈ 1.001282873 + // reward = 10,000 * 0.001282873 = 12.82873 → truncated to 12 + advance_ledger(&e, 13); + + let pending2 = client.get_pending_rewards(&user); + assert_eq!(pending2, 12); +} + +#[test] +fn test_epoch_snapshot_storage() { + let (e, client, _, staking_token, _) = setup(); + let contract_id = client.address.clone(); + let user = Address::generate(&e); + + let staking_client = token::StellarAssetClient::new(&e, &staking_token); + staking_client.mint(&user, &STAKE_AMOUNT); + + client.stake(&user, &STAKE_AMOUNT); + + // Advance to block 25 — this should create epoch 0, 1, 2 snapshots + advance_ledger(&e, 25); + + // Trigger snapshot creation via pending_rewards + let _pending = client.get_pending_rewards(&user); + + e.as_contract(&contract_id, || { + let snapshot0: EpochSnapshot = e + .storage() + .instance() + .get(&DataKey::EpochSnapshot(0)) + .unwrap(); + assert_eq!(snapshot0.rate.0, INITIAL_RATE); + + let snapshot1: EpochSnapshot = e + .storage() + .instance() + .get(&DataKey::EpochSnapshot(1)) + .unwrap(); + let expected_rate1 = INITIAL_RATE * 99 / 100; + assert_eq!(snapshot1.rate.0, expected_rate1); + + let snapshot2: EpochSnapshot = e + .storage() + .instance() + .get(&DataKey::EpochSnapshot(2)) + .unwrap(); + let expected_rate2 = INITIAL_RATE * 9801 / 10000; + assert_eq!(snapshot2.rate.0, expected_rate2); + }); +} + #[test] fn test_compounding_interest() { let (e, client, _, staking_token, _) = setup(); let user = Address::generate(&e); let staking_client = token::StellarAssetClient::new(&e, &staking_token); - staking_client.mint(&user, &100_000); // larger stake to see compounding clearly + staking_client.mint(&user, &100_000); client.stake(&user, &100_000); - // Advance 10 blocks (from 0 to 10) + // Advance 10 blocks (from 0 to 10) — one full epoch advance_ledger(&e, 10); - // Check rewards without claiming let pending_1 = client.get_pending_rewards(&user); - // Let's do another action to write back accrued rewards to storage (e.g. withdraw 0, or we can just let it update) - // Staking 1 more token triggers a reward update and stores the accrued reward + // Stake 1 more to trigger write-back staking_client.mint(&user, &1); client.stake(&user, &1); @@ -179,10 +250,9 @@ fn test_compounding_interest() { assert!(accrued > 0); assert_eq!(accrued, pending_1); - // Now advance another 10 blocks (from 10 to 20) + // Advance another 10 blocks advance_ledger(&e, 10); - // The new pending rewards should compound on (staked_amount + accrued_rewards) let pending_2 = client.get_pending_rewards(&user); assert!(pending_2 > accrued); } @@ -198,26 +268,50 @@ fn test_zero_stake_security() { client.stake(&user, &STAKE_AMOUNT); advance_ledger(&e, 10); - // User has accrued rewards let pending = client.get_pending_rewards(&user); assert!(pending > 0); - // Withdraw entire principal client.withdraw(&user, &STAKE_AMOUNT); assert_eq!(client.get_staked_balance(&user), 0); - // Accrued rewards are saved let accrued = client.get_accrued_rewards(&user); assert_eq!(accrued, pending); - // Advance another 10 blocks + // Advance another 10 blocks — no compounding since stake = 0 advance_ledger(&e, 10); - // Since stake is 0, compounding is inactive. Accrued rewards must NOT increase! let pending_after = client.get_pending_rewards(&user); assert_eq!(pending_after, accrued); } +#[test] +fn test_multi_epoch_accumulation() { + let (e, client, _, staking_token, _) = setup(); + let user = Address::generate(&e); + + let staking_client = token::StellarAssetClient::new(&e, &staking_token); + staking_client.mint(&user, &STAKE_AMOUNT); + + client.stake(&user, &STAKE_AMOUNT); + + // Advance 100 blocks = 10 full epochs + advance_ledger(&e, 100); + + // Rate per epoch: + // epoch 0: r0 = 0.0001 + // epoch 1: r0 * 0.99 + // epoch 2: r0 * 0.99^2 + // etc. + // Total exponent = 10 * r0 * sum_{k=0}^{9} 0.99^k + // sum = (1 - 0.99^10) / (1 - 0.99) = (1 - 0.904382) / 0.01 = 9.5618 + // exponent = 10 * 0.0001 * 9.5618 = 0.0095618 + // multiplier = exp(0.0095618) ≈ 1.009607 + // reward = 10,000 * (1.009607 - 1) = 96.07 → truncated to 96 + let pending = client.get_pending_rewards(&user); + assert!(pending > 90); + assert!(pending < 110); +} + #[test] fn test_emergency_withdraw() { let (e, client, _, staking_token, _) = setup(); @@ -229,22 +323,19 @@ fn test_emergency_withdraw() { client.stake(&user, &STAKE_AMOUNT); advance_ledger(&e, 10); - // Verify rewards accrued assert!(client.get_pending_rewards(&user) > 0); + client.set_paused(&true); // Pause staking to simulate extreme conditions client.pause_staking(); - // Emergency withdraw should succeed even when paused let withdrawn = client.emergency_withdraw(&user); assert_eq!(withdrawn, STAKE_AMOUNT); - // Verify stake balance is zero and user state is cleared (rewards forfeited) assert_eq!(client.get_staked_balance(&user), 0); assert_eq!(client.get_accrued_rewards(&user), 0); assert_eq!(client.get_pending_rewards(&user), 0); - // Verify principal token is fully returned to the user let token_balance = token::Client::new(&e, &staking_token).balance(&user); assert_eq!(token_balance, STAKE_AMOUNT); }