From 7e232413deb051dbbb94da8dc0502eaf506924d3 Mon Sep 17 00:00:00 2001 From: OlolaJaco Date: Tue, 28 Jul 2026 14:34:00 +0100 Subject: [PATCH 1/4] feat: implement fee revenue sharing mechanism with Merkle-based distribution and claims --- src/balance.rs | 58 ++++++++++++++- src/errors.rs | 9 +++ src/events.rs | 25 +++++++ src/storage.rs | 18 +++++ src/test_integration.rs | 149 ++++++++++++++++++++++++++++++++++++++ src/vault.rs | 156 ++++++++++++++++++++++++++++++++++++---- 6 files changed, 401 insertions(+), 14 deletions(-) diff --git a/src/balance.rs b/src/balance.rs index 12eb505..e2fb3eb 100644 --- a/src/balance.rs +++ b/src/balance.rs @@ -2,8 +2,9 @@ use crate::storage::{ AdminProposal, AutoConvertConfig, BrandingConfig, ChangelogEntry, ClaimWindow, DataKey, DayBucket, DynamicFeeConfig, FeeRecipient, FlashStakeReceipt, GovernanceProposal, InsurancePolicy, InsuranceProduct, Loan, LoanConfig, LotteryConfig, Milestone, MultisigConfig, - PendingAction, PriceCondition, PriorityBidRecord, RateHistoryEntry, ReferralStats, Season, - StakePosition, SunsetState, VestingEntry, + PendingAction, PriceCondition, PriorityBidRecord, RateHistoryEntry, ReferralStats, + RevenueShareMerkleRoot, RevenueSharingConfig, Season, StakePosition, SunsetState, + VestingEntry, }; use soroban_sdk::{symbol_short, Address, Env, String, Symbol, Vec}; @@ -2048,3 +2049,56 @@ pub fn set_grace_period_end(env: &Env, ledger: u32) { .instance() .set(&symbol_short!("snst_gpe"), &ledger); } + +// ── Issue #281: Fee Revenue Sharing ────────────────────────────────────────── + +pub fn get_revenue_sharing_config(env: &Env) -> Option { + env.storage().instance().get(&symbol_short!("rev_cfg")) +} + +pub fn set_revenue_sharing_config(env: &Env, config: &RevenueSharingConfig) { + env.storage().instance().set(&symbol_short!("rev_cfg"), config); +} + +pub fn get_revenue_share_pool(env: &Env) -> i128 { + env.storage() + .instance() + .get(&symbol_short!("rev_pool")) + .unwrap_or(0) +} + +pub fn set_revenue_share_pool(env: &Env, amount: i128) { + env.storage() + .instance() + .set(&symbol_short!("rev_pool"), &amount); +} + +pub fn get_revenue_share_epoch(env: &Env) -> u32 { + env.storage() + .instance() + .get(&symbol_short!("rev_ep")) + .unwrap_or(0) +} + +pub fn set_revenue_share_epoch(env: &Env, epoch: u32) { + env.storage().instance().set(&symbol_short!("rev_ep"), &epoch); +} + +pub fn get_revenue_share_merkle_root(env: &Env) -> Option { + env.storage().instance().get(&symbol_short!("rev_mrk")) +} + +pub fn set_revenue_share_merkle_root(env: &Env, root: &RevenueShareMerkleRoot) { + env.storage().instance().set(&symbol_short!("rev_mrk"), root); +} + +pub fn is_revenue_share_claimed(env: &Env, user: &Address, epoch: u32) -> bool { + let key = (Symbol::new(env, "rev_clm"), user.clone(), epoch); + env.storage().persistent().get(&key).unwrap_or(false) +} + +pub fn set_revenue_share_claimed(env: &Env, user: &Address, epoch: u32) { + let key = (Symbol::new(env, "rev_clm"), user.clone(), epoch); + env.storage().persistent().set(&key, &true); +} + diff --git a/src/errors.rs b/src/errors.rs index cedcb15..1de43fe 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -433,8 +433,17 @@ pub enum VaultFeatureError { /// `liquidate_loan()` when the borrower's LTV is still below /// `LIQUIDATION_LTV_BPS` (9 000). LoanNotLiquidatable = 22, + + // ── Issue #281: Fee Revenue Sharing ────────────────────────────────────── + /// `set_revenue_sharing()`: `share_bps` above 10 000. + InvalidRevenueShareConfig = 32, + /// `claim_revenue_share()`: invalid Merkle proof. + RevenueShareInvalidProof = 33, + /// `claim_revenue_share()`: user already claimed for this epoch. + RevenueShareAlreadyClaimed = 34, } + impl From for VaultExtError { fn from(err: VaultError) -> Self { match err { diff --git a/src/events.rs b/src/events.rs index 18ee21f..91e10ca 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1084,3 +1084,28 @@ pub fn force_resolved( env.events() .publish(topics, (principal, accrued_reward, ledger)); } + +// ── Issue #281: Fee Revenue Sharing ────────────────────────────────────────── + +pub fn revenue_distributed( + env: &Env, + merkle_root: &soroban_sdk::Bytes, + total_amount: i128, + ledger: u32, +) { + let topics = (symbol_short!("rev_dist"),); + env.events() + .publish(topics, (merkle_root.clone(), total_amount, ledger)); +} + +pub fn revenue_share_claimed( + env: &Env, + user: &Address, + amount: i128, + epoch: u32, + ledger: u32, +) { + let topics = (symbol_short!("rev_clm"), user); + env.events().publish(topics, (amount, epoch, ledger)); +} + diff --git a/src/storage.rs b/src/storage.rs index bcf711d..9253b9c 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1034,3 +1034,21 @@ pub enum SunsetState { ForceResolutionActive, Closed, } + +// ── Issue #281: Fee Revenue Sharing ────────────────────────────────────────── + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RevenueSharingConfig { + pub governance_token: Address, + pub share_bps: u32, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RevenueShareMerkleRoot { + pub root: soroban_sdk::Bytes, + pub epoch: u32, + pub total_amount: i128, +} + diff --git a/src/test_integration.rs b/src/test_integration.rs index 7e39d30..58f1a46 100644 --- a/src/test_integration.rs +++ b/src/test_integration.rs @@ -1103,3 +1103,152 @@ fn test_no_rewards_accrued_during_cooldown() { let claim_after = vault.claim(&alice); assert_eq!(claim_after, pending_before); } + +// ── Issue #281: Fee Revenue Sharing Tests ─────────────────────────────────── + +#[test] +fn test_revenue_sharing_flow() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|li| { + li.sequence_number = 1; + li.min_persistent_entry_ttl = 10_000_000; + li.max_entry_ttl = 10_000_000; + }); + + let admin = Address::generate(&env); + let alice = Address::generate(&env); + let gov_token = Address::generate(&env); + let (token_addr, token, token_admin) = create_token(&env, &admin); + let vault_id = env.register_contract(None, VaultContract); + let vault = VaultContractClient::new(&env, &vault_id); + vault.initialize(&admin, &token_addr, &0_u32, &None, &None); + + // Configure revenue sharing: 20% (2000 BPS) of collected fees go to RevenueSharePool + vault.set_revenue_sharing(&admin, &gov_token, &2000); + assert_eq!(vault.get_revenue_share_pool(), 0); + + // Configure 5% unstake fee (500 BPS) + vault.set_unstake_fee_bps(&admin, &500); + + // Stake 100_000 tokens for Alice + token_admin.mint(&alice, &100_000); + vault.stake(&alice, &100_000); + + // Unstake 100_000: Unstake fee is 5% = 5,000. + // 20% of 5,000 = 1,000 goes to RevenueSharePool. + // 80% of 5,000 = 4,000 goes to reward pool. + vault.unstake(&alice, &100_000); + + assert_eq!(vault.get_revenue_share_pool(), 1000); + + fn compute_leaf(env: &Env, user: &Address, amount: i128) -> soroban_sdk::BytesN<32> { + let mut buf = soroban_sdk::Bytes::new(env); + let addr_str = user.to_string(); + let len = addr_str.len(); + let mut raw = [0u8; 56]; + let slice = &mut raw[..len as usize]; + addr_str.copy_into_slice(slice); + buf.extend_from_slice(slice); + buf.extend_from_slice(&amount.to_be_bytes()); + env.crypto().sha256(&buf).into() + } + + let alice_leaf = compute_leaf(&env, &alice, 1000); + let root_bytes: soroban_sdk::Bytes = alice_leaf.clone().into(); + + // Distribute revenue with Merkle root + vault.distribute_revenue(&admin, &root_bytes); + + let alice_bal_before = token.balance(&alice); + + // Alice claims revenue share with empty proof (single leaf tree) + let proof = Vec::new(&env); + vault.claim_revenue_share(&alice, &1000, &proof); + + assert_eq!(token.balance(&alice), alice_bal_before + 1000); + assert_eq!(vault.get_revenue_share_pool(), 0); +} + +#[test] +fn test_revenue_sharing_invalid_proof_and_double_claim() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|li| { + li.sequence_number = 1; + li.min_persistent_entry_ttl = 10_000_000; + li.max_entry_ttl = 10_000_000; + }); + + let admin = Address::generate(&env); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + let gov_token = Address::generate(&env); + let (token_addr, _token, token_admin) = create_token(&env, &admin); + let vault_id = env.register_contract(None, VaultContract); + let vault = VaultContractClient::new(&env, &vault_id); + vault.initialize(&admin, &token_addr, &0_u32, &None, &None); + + // Invalid config (share_bps > 10000) should fail + let res = vault.try_set_revenue_sharing(&admin, &gov_token, &10_001); + assert!(res.is_err()); + + // Configure 50% revenue sharing + vault.set_revenue_sharing(&admin, &gov_token, &5000); + vault.set_unstake_fee_bps(&admin, &500); + + token_admin.mint(&alice, &200_000); + vault.stake(&alice, &200_000); + vault.unstake(&alice, &200_000); // 10,000 fee -> 5,000 to rev pool + + assert_eq!(vault.get_revenue_share_pool(), 5000); + + fn compute_leaf(env: &Env, user: &Address, amount: i128) -> soroban_sdk::BytesN<32> { + let mut buf = soroban_sdk::Bytes::new(env); + let addr_str = user.to_string(); + let len = addr_str.len(); + let mut raw = [0u8; 56]; + let slice = &mut raw[..len as usize]; + addr_str.copy_into_slice(slice); + buf.extend_from_slice(slice); + buf.extend_from_slice(&amount.to_be_bytes()); + env.crypto().sha256(&buf).into() + } + + fn compute_node(env: &Env, a: soroban_sdk::BytesN<32>, b: soroban_sdk::BytesN<32>) -> soroban_sdk::BytesN<32> { + let cur_arr: [u8; 32] = a.to_array(); + let sib_arr: [u8; 32] = b.to_array(); + let mut combined = soroban_sdk::Bytes::new(env); + if cur_arr[0] <= sib_arr[0] { + combined.extend_from_slice(&cur_arr); + combined.extend_from_slice(&sib_arr); + } else { + combined.extend_from_slice(&sib_arr); + combined.extend_from_slice(&cur_arr); + } + env.crypto().sha256(&combined).into() + } + + let alice_leaf = compute_leaf(&env, &alice, 3000); + let bob_leaf = compute_leaf(&env, &bob, 2000); + let root_node = compute_node(&env, alice_leaf.clone(), bob_leaf.clone()); + let root_bytes: soroban_sdk::Bytes = root_node.into(); + + vault.distribute_revenue(&admin, &root_bytes); + + // Invalid proof for Alice + let mut bad_proof = Vec::new(&env); + bad_proof.push_back(alice_leaf.clone()); // wrong sibling + let res_bad = vault.try_claim_revenue_share(&alice, &3000, &bad_proof); + assert!(res_bad.is_err()); + + // Valid proof for Alice + let mut alice_proof = Vec::new(&env); + alice_proof.push_back(bob_leaf); + vault.claim_revenue_share(&alice, &3000, &alice_proof); + + // Double claim should fail + let res_double = vault.try_claim_revenue_share(&alice, &3000, &alice_proof); + assert!(res_double.is_err()); +} + diff --git a/src/vault.rs b/src/vault.rs index c9fa320..21cfe4f 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -18,7 +18,8 @@ use crate::{ OptimalClaimAdvice, PauseInfo, PauseReason, PendingAction, PoolComparison, PoolConfig, PoolHealthReport, PoolStats, PriceCondition, PriorityBidRecord, ProposableParam, RateHistoryEntry, ReferralLeaderboardEntry, ReferralTreeNode, ReputationScore, - RewardMultiplierBreakdown, RoundingPolicy, Season, SmoothingSchedule, SmoothingStatus, + RewardMultiplierBreakdown, RevenueShareMerkleRoot, RevenueSharingConfig, RoundingPolicy, + Season, SmoothingSchedule, SmoothingStatus, StakeAction, StakeHistoryEntry, StakePosition, StakeStreak, StakingCertificate, StakingEfficiencyScore, StorageUsageReport, SunsetState, TaxReport, Tournament, TriggerDirection, UnbondingPosition, UnstakeCheckResult, UserStats, UserSummary, @@ -5790,14 +5791,30 @@ impl VaultContract { Self::check_activation(env); if unstake_fee > 0 { - // Issue #197: if fee-split recipients are configured, pay them - // directly instead of crediting the reward treasury. - let recipients = balance::get_fee_recipients(env); - if recipients.is_empty() { - let reward_pool = balance::get_reward_pool_balance(env); - balance::set_reward_pool_balance(env, reward_pool + unstake_fee); + let rev_share_portion = if let Some(rev_cfg) = balance::get_revenue_sharing_config(env) { + if rev_cfg.share_bps > 0 { + (unstake_fee * rev_cfg.share_bps as i128) / BOOST_BPS_BASE as i128 + } else { + 0 + } } else { - Self::distribute_fee(env, &token_addr, unstake_fee, &recipients); + 0 + }; + + let treasury_fee = unstake_fee - rev_share_portion; + if rev_share_portion > 0 { + let rev_pool = balance::get_revenue_share_pool(env); + balance::set_revenue_share_pool(env, rev_pool + rev_share_portion); + } + + if treasury_fee > 0 { + let recipients = balance::get_fee_recipients(env); + if recipients.is_empty() { + let reward_pool = balance::get_reward_pool_balance(env); + balance::set_reward_pool_balance(env, reward_pool + treasury_fee); + } else { + Self::distribute_fee(env, &token_addr, treasury_fee, &recipients); + } } balance::add_protocol_fee_collected(env, unstake_fee); } @@ -10817,11 +10834,27 @@ impl VaultContract { .ok_or(VaultFeatureError::ArithmeticError)? / BOOST_BPS_BASE as i128; if fee > 0 { - let treasury = match balance::get_slash_treasury(&env) { - Some(treasury) => treasury, - None => admin::get_admin(&env)?, + let rev_share_portion = if let Some(rev_cfg) = balance::get_revenue_sharing_config(&env) { + if rev_cfg.share_bps > 0 { + (fee * rev_cfg.share_bps as i128) / BOOST_BPS_BASE as i128 + } else { + 0 + } + } else { + 0 }; - token_client.transfer(&contract, &treasury, &fee); + let treasury_fee = fee - rev_share_portion; + if rev_share_portion > 0 { + let rev_pool = balance::get_revenue_share_pool(&env); + balance::set_revenue_share_pool(&env, rev_pool + rev_share_portion); + } + if treasury_fee > 0 { + let treasury = match balance::get_slash_treasury(&env) { + Some(treasury) => treasury, + None => admin::get_admin(&env)?, + }; + token_client.transfer(&contract, &treasury, &treasury_fee); + } } let returned = amount - fee; if returned > 0 { @@ -11354,4 +11387,103 @@ impl VaultContract { events::sunset_stage_changed(&env, SunsetState::Closed, ledger); Ok(()) } + + // ── Issue #281: Fee Revenue Sharing ────────────────────────────────────── + + pub fn set_revenue_sharing( + env: Env, + admin: Address, + governance_token: Address, + share_bps: u32, + ) -> Result<(), VaultFeatureError> { + admin::require_admin(&env)?; + admin.require_auth(); + + if share_bps > BOOST_BPS_BASE { + return Err(VaultFeatureError::InvalidRevenueShareConfig); + } + + let config = RevenueSharingConfig { + governance_token, + share_bps, + }; + balance::set_revenue_sharing_config(&env, &config); + Ok(()) + } + + pub fn get_revenue_share_pool(env: Env) -> i128 { + balance::get_revenue_share_pool(&env) + } + + pub fn get_revenue_sharing_config(env: Env) -> Option { + balance::get_revenue_sharing_config(&env) + } + + pub fn distribute_revenue( + env: Env, + admin: Address, + merkle_root: soroban_sdk::Bytes, + ) -> Result<(), VaultFeatureError> { + admin::require_admin(&env)?; + admin.require_auth(); + + let total_amount = balance::get_revenue_share_pool(&env); + if total_amount <= 0 { + return Err(VaultFeatureError::ZeroAmount); + } + + let epoch = balance::get_revenue_share_epoch(&env) + 1; + balance::set_revenue_share_epoch(&env, epoch); + + let mr = RevenueShareMerkleRoot { + root: merkle_root.clone(), + epoch, + total_amount, + }; + balance::set_revenue_share_merkle_root(&env, &mr); + events::revenue_distributed(&env, &merkle_root, total_amount, env.ledger().sequence()); + Ok(()) + } + + pub fn claim_revenue_share( + env: Env, + user: Address, + amount: i128, + proof: Vec>, + ) -> Result<(), VaultFeatureError> { + user.require_auth(); + + if amount <= 0 { + return Err(VaultFeatureError::ZeroAmount); + } + + let mr = balance::get_revenue_share_merkle_root(&env) + .ok_or(VaultFeatureError::RevenueShareInvalidProof)?; + + if balance::is_revenue_share_claimed(&env, &user, mr.epoch) { + return Err(VaultFeatureError::RevenueShareAlreadyClaimed); + } + + let leaf = Self::merkle_leaf_hash(&env, &user, amount); + let computed_root = Self::compute_merkle_root(&env, leaf, proof); + let computed_root_bytes: soroban_sdk::Bytes = computed_root.into(); + if computed_root_bytes != mr.root { + return Err(VaultFeatureError::RevenueShareInvalidProof); + } + + let pool = balance::get_revenue_share_pool(&env); + if pool < amount { + return Err(VaultFeatureError::InsufficientRewardPool); + } + + balance::set_revenue_share_claimed(&env, &user, mr.epoch); + balance::set_revenue_share_pool(&env, pool - amount); + + let reward_token = Self::token_address(&env)?; + let token_client = token::Client::new(&env, &reward_token); + token_client.transfer(&env.current_contract_address(), &user, &amount); + + events::revenue_share_claimed(&env, &user, amount, mr.epoch, env.ledger().sequence()); + Ok(()) + } } From 3ee704a53d4d10478dc0a2ddc4934c48adaad841 Mon Sep 17 00:00:00 2001 From: OlolaJaco Date: Tue, 28 Jul 2026 14:41:08 +0100 Subject: [PATCH 2/4] feat: implement staker reward escrow mechanism with adjustable locking period --- src/balance.rs | 46 ++++++++++++++++ src/events.rs | 9 +++ src/test_integration.rs | 119 ++++++++++++++++++++++++++++++++++++++++ src/vault.rs | 91 ++++++++++++++++++++++++++++-- 4 files changed, 260 insertions(+), 5 deletions(-) diff --git a/src/balance.rs b/src/balance.rs index e2fb3eb..6e0722d 100644 --- a/src/balance.rs +++ b/src/balance.rs @@ -2102,3 +2102,49 @@ pub fn set_revenue_share_claimed(env: &Env, user: &Address, epoch: u32) { env.storage().persistent().set(&key, &true); } +// ── Issue #280: New Staker Reward Escrow ──────────────────────────────────── + +pub fn get_escrow_period(env: &Env) -> u32 { + env.storage() + .instance() + .get(&symbol_short!("esc_prd")) + .unwrap_or(0) +} + +pub fn set_escrow_period(env: &Env, ledgers: u32) { + env.storage() + .instance() + .set(&symbol_short!("esc_prd"), &ledgers); +} + +pub fn get_escrow_balance(env: &Env, user: &Address) -> i128 { + let key = (Symbol::new(env, "esc_bal"), user.clone()); + env.storage().persistent().get(&key).unwrap_or(0) +} + +pub fn set_escrow_balance(env: &Env, user: &Address, amount: i128) { + let key = (Symbol::new(env, "esc_bal"), user.clone()); + env.storage().persistent().set(&key, &amount); +} + +pub fn remove_escrow_balance(env: &Env, user: &Address) { + let key = (Symbol::new(env, "esc_bal"), user.clone()); + env.storage().persistent().remove(&key); +} + +pub fn get_escrow_release_ledger(env: &Env, user: &Address) -> Option { + let key = (Symbol::new(env, "esc_rel"), user.clone()); + env.storage().persistent().get(&key) +} + +pub fn set_escrow_release_ledger(env: &Env, user: &Address, ledger: u32) { + let key = (Symbol::new(env, "esc_rel"), user.clone()); + env.storage().persistent().set(&key, &ledger); +} + +pub fn remove_escrow_release_ledger(env: &Env, user: &Address) { + let key = (Symbol::new(env, "esc_rel"), user.clone()); + env.storage().persistent().remove(&key); +} + + diff --git a/src/events.rs b/src/events.rs index 91e10ca..4f0ad99 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1109,3 +1109,12 @@ pub fn revenue_share_claimed( env.events().publish(topics, (amount, epoch, ledger)); } +// ── Issue #280: New Staker Reward Escrow ──────────────────────────────────── + +pub fn escrow_released(env: &Env, user: &Address, escrowed_amount: i128, ledger: u32) { + let topics = (symbol_short!("esc_rel"), user); + env.events() + .publish(topics, (user.clone(), escrowed_amount, ledger)); +} + + diff --git a/src/test_integration.rs b/src/test_integration.rs index 58f1a46..e9f1ee0 100644 --- a/src/test_integration.rs +++ b/src/test_integration.rs @@ -1252,3 +1252,122 @@ fn test_revenue_sharing_invalid_proof_and_double_claim() { assert!(res_double.is_err()); } +// ── Issue #280: New Staker Reward Escrow Tests ────────────────────────────── + +#[test] +fn test_new_staker_reward_escrow_flow() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|li| { + li.sequence_number = 100; + li.min_persistent_entry_ttl = 10_000_000; + li.max_entry_ttl = 10_000_000; + }); + + let admin = Address::generate(&env); + let alice = Address::generate(&env); + let (token_addr, token, token_admin) = create_token(&env, &admin); + let vault_id = env.register_contract(None, VaultContract); + let vault = VaultContractClient::new(&env, &vault_id); + vault.initialize(&admin, &token_addr, &0_u32, &None, &None); + + // Fund reward pool + token_admin.mint(&admin, &1_000_000); + vault.fund_reward_pool(&admin, &1_000_000); + + // Default escrow period is 0 (disabled) + assert_eq!(vault.get_escrow_period(), 0); + + // Configure escrow period of 100 ledgers + vault.set_escrow_period(&admin, &100); + assert_eq!(vault.get_escrow_period(), 100); + + // Alice stakes at ledger 100 -> release ledger should be 200 + token_admin.mint(&alice, &100_000); + vault.stake(&alice, &100_000); + + assert_eq!(vault.get_escrow_release_ledger(&alice), Some(200)); + assert_eq!(vault.get_escrow_balance(&alice), 0); + + // Set reward rate + vault.set_reward_rate_bps(&1000); + + // Advance to ledger 150 (during escrow period) + env.ledger().with_mut(|li| li.sequence_number = 150); + + // Claim during escrow period -> returns 0, adds accrued rewards to EscrowBalance + let claim_during = vault.claim(&alice); + assert_eq!(claim_during, 0); + let escrow_bal = vault.get_escrow_balance(&alice); + assert!(escrow_bal > 0, "expected escrow balance to accumulate"); + + // Advance past escrow period to ledger 250 + env.ledger().with_mut(|li| li.sequence_number = 250); + + let alice_bal_before = token.balance(&alice); + + // Claim after escrow period -> releases full escrow balance + current pending in one payment + let claim_after = vault.claim(&alice); + assert!(claim_after > escrow_bal, "expected payout to include escrow balance + new pending"); + assert_eq!(token.balance(&alice), alice_bal_before + claim_after); + assert_eq!(vault.get_escrow_balance(&alice), 0); + assert_eq!(vault.get_escrow_release_ledger(&alice), None); +} + +#[test] +fn test_escrow_period_zero_disables_and_restaker_gets_new_escrow() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|li| { + li.sequence_number = 100; + li.min_persistent_entry_ttl = 10_000_000; + li.max_entry_ttl = 10_000_000; + }); + + let admin = Address::generate(&env); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + let (token_addr, _token, token_admin) = create_token(&env, &admin); + let vault_id = env.register_contract(None, VaultContract); + let vault = VaultContractClient::new(&env, &vault_id); + vault.initialize(&admin, &token_addr, &0_u32, &None, &None); + + token_admin.mint(&admin, &1_000_000); + vault.fund_reward_pool(&admin, &1_000_000); + + // 1. Escrow period 0 disables escrow for new stakers + vault.set_escrow_period(&admin, &0); + token_admin.mint(&bob, &50_000); + vault.stake(&bob, &50_000); + assert_eq!(vault.get_escrow_release_ledger(&bob), None); + + // 2. Configure escrow period = 50 ledgers + vault.set_escrow_period(&admin, &50); + + // Alice stakes at ledger 100 + token_admin.mint(&alice, &100_000); + vault.stake(&alice, &100_000); + assert_eq!(vault.get_escrow_release_ledger(&alice), Some(150)); + + vault.set_reward_rate_bps(&1000); + env.ledger().with_mut(|li| li.sequence_number = 120); + + // Claim during escrow + vault.claim(&alice); + assert!(vault.get_escrow_balance(&alice) > 0); + + // Full unstake clears position and escrow state + vault.unstake(&alice, &100_000); + assert_eq!(vault.get_escrow_balance(&alice), 0); + assert_eq!(vault.get_escrow_release_ledger(&alice), None); + + // Re-staker after full exit gets a new escrow period starting at new stake ledger + env.ledger().with_mut(|li| li.sequence_number = 300); + token_admin.mint(&alice, &100_000); + vault.stake(&alice, &100_000); + + // New release ledger is 300 + 50 = 350 + assert_eq!(vault.get_escrow_release_ledger(&alice), Some(350)); +} + + diff --git a/src/vault.rs b/src/vault.rs index 21cfe4f..4c1aaf4 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -5587,6 +5587,11 @@ impl VaultContract { env.storage() .persistent() .set(&DataKey::StakedAtLedger(staker.clone()), ¤t_ledger); + let escrow_period = balance::get_escrow_period(env); + if escrow_period > 0 { + let release_ledger = current_ledger.saturating_add(escrow_period); + balance::set_escrow_release_ledger(env, staker, release_ledger); + } // Write-once: record the very first time this address ever staked. if !env .storage() @@ -5823,6 +5828,8 @@ impl VaultContract { env.storage() .persistent() .remove(&DataKey::StakedAtLedger(staker.clone())); + balance::remove_escrow_release_ledger(env, staker); + balance::remove_escrow_balance(env, staker); // Issue #41: record the ledger of this full unstake and clear restaked flag balance::set_last_unstake_ledger(env, staker, current_ledger); balance::remove_restaked(env, staker); @@ -6855,6 +6862,58 @@ impl VaultContract { // pool below. let (user_reward, loan_repayment) = Self::apply_reward_to_loan(env, staker, user_reward)?; + let current_ledger = env.ledger().sequence(); + let in_escrow = if let Some(release_ledger) = balance::get_escrow_release_ledger(env, staker) { + current_ledger < release_ledger + } else { + false + }; + + if in_escrow { + let current_escrow = balance::get_escrow_balance(env, staker); + balance::set_escrow_balance(env, staker, current_escrow + user_reward); + + balance::set_reward_pool_balance(env, reward_pool - reward + loan_repayment); + let total_ever_claimed: i128 = env + .storage() + .instance() + .get(&DataKey::TotalEverClaimed) + .unwrap_or(0); + env.storage() + .instance() + .set(&DataKey::TotalEverClaimed, &(total_ever_claimed + reward)); + let remaining_accrued = accrued + .checked_sub(reward) + .ok_or(VaultError::ArithmeticError)?; + balance::set_accrued_reward(env, staker, remaining_accrued); + balance::set_last_claim_ledger(env, staker, current_ledger); + + let paid = balance::get_total_rewards_paid(env); + balance::set_total_rewards_paid(env, paid + reward); + balance::increment_user_claim_count(env, staker); + balance::add_user_total_claimed(env, staker, reward); + + let mut claim_history = balance::get_claim_history(env, staker); + if claim_history.len() >= balance::MAX_CLAIM_HISTORY { + claim_history.pop_front(); + } + claim_history.push_back((current_ledger, reward)); + balance::set_claim_history(env, staker, &claim_history); + + events::claimed(env, staker, reward, current_ledger); + return Ok(0); + } + + let escrowed_amount = balance::get_escrow_balance(env, staker); + let payout = user_reward + escrowed_amount; + if escrowed_amount > 0 { + balance::set_escrow_balance(env, staker, 0); + events::escrow_released(env, staker, escrowed_amount, current_ledger); + } + if balance::get_escrow_release_ledger(env, staker).is_some() { + balance::remove_escrow_release_ledger(env, staker); + } + let token_addr = Self::token_address(env)?; let vesting_period: u32 = env @@ -6872,19 +6931,19 @@ impl VaultContract { if entries.len() >= 10 { return Err(VaultError::VestingQueueFull); } - let claimable_at_ledger = env.ledger().sequence().saturating_add(vesting_period); + let claimable_at_ledger = current_ledger.saturating_add(vesting_period); entries.push_back(VestingEntry { - amount: user_reward, + amount: payout, claimable_at_ledger, }); env.storage() .persistent() .set(&DataKey::VestingEntries(staker.clone()), &entries); - } else if !Self::try_reinvest_reward(env, staker, &token_addr, user_reward)? - && !Self::try_auto_convert_reward(env, staker, &token_addr, user_reward)? + } else if !Self::try_reinvest_reward(env, staker, &token_addr, payout)? + && !Self::try_auto_convert_reward(env, staker, &token_addr, payout)? { let token_client = token::Client::new(env, &token_addr); - token_client.transfer(&env.current_contract_address(), staker, &user_reward); + token_client.transfer(&env.current_contract_address(), staker, &payout); } balance::set_reward_pool_balance(env, reward_pool - reward + loan_repayment); @@ -11486,4 +11545,26 @@ impl VaultContract { events::revenue_share_claimed(&env, &user, amount, mr.epoch, env.ledger().sequence()); Ok(()) } + + // ── Issue #280: New Staker Reward Escrow ──────────────────────────────────── + + pub fn set_escrow_period(env: Env, admin: Address, ledgers: u32) -> Result<(), VaultFeatureError> { + admin::require_admin(&env)?; + admin.require_auth(); + + balance::set_escrow_period(&env, ledgers); + Ok(()) + } + + pub fn get_escrow_period(env: Env) -> u32 { + balance::get_escrow_period(&env) + } + + pub fn get_escrow_balance(env: Env, user: Address) -> i128 { + balance::get_escrow_balance(&env, &user) + } + + pub fn get_escrow_release_ledger(env: Env, user: Address) -> Option { + balance::get_escrow_release_ledger(&env, &user) + } } From fdc625f35c1bea02026ab74e5a0357fb6513049c Mon Sep 17 00:00:00 2001 From: OlolaJaco Date: Tue, 28 Jul 2026 14:44:21 +0100 Subject: [PATCH 3/4] feat: implement stake-gated access tiers with dynamic NFT minting and revocation logic --- src/balance.rs | 35 +++++++++- src/errors.rs | 9 +++ src/events.rs | 19 ++++++ src/storage.rs | 11 ++++ src/test_integration.rs | 87 +++++++++++++++++++++++++ src/vault.rs | 140 +++++++++++++++++++++++++++++++++++++++- 6 files changed, 298 insertions(+), 3 deletions(-) diff --git a/src/balance.rs b/src/balance.rs index 6e0722d..4b94aca 100644 --- a/src/balance.rs +++ b/src/balance.rs @@ -1,6 +1,6 @@ use crate::storage::{ - AdminProposal, AutoConvertConfig, BrandingConfig, ChangelogEntry, ClaimWindow, DataKey, - DayBucket, DynamicFeeConfig, FeeRecipient, FlashStakeReceipt, GovernanceProposal, + AccessTier, AdminProposal, AutoConvertConfig, BrandingConfig, ChangelogEntry, ClaimWindow, + DataKey, DayBucket, DynamicFeeConfig, FeeRecipient, FlashStakeReceipt, GovernanceProposal, InsurancePolicy, InsuranceProduct, Loan, LoanConfig, LotteryConfig, Milestone, MultisigConfig, PendingAction, PriceCondition, PriorityBidRecord, RateHistoryEntry, ReferralStats, RevenueShareMerkleRoot, RevenueSharingConfig, Season, StakePosition, SunsetState, @@ -2147,4 +2147,35 @@ pub fn remove_escrow_release_ledger(env: &Env, user: &Address) { env.storage().persistent().remove(&key); } +// ── Issue #282: Stake-Gated Access ─────────────────────────────────────────── + +pub fn get_access_tiers(env: &Env) -> Vec { + env.storage() + .instance() + .get(&symbol_short!("acc_tier")) + .unwrap_or_else(|| Vec::new(env)) +} + +pub fn set_access_tiers(env: &Env, tiers: &Vec) { + env.storage() + .instance() + .set(&symbol_short!("acc_tier"), tiers); +} + +pub fn get_user_access_tier(env: &Env, user: &Address) -> Option { + let key = (Symbol::new(env, "acc_u_tr"), user.clone()); + env.storage().persistent().get(&key) +} + +pub fn set_user_access_tier(env: &Env, user: &Address, tier_index: u32) { + let key = (Symbol::new(env, "acc_u_tr"), user.clone()); + env.storage().persistent().set(&key, &tier_index); +} + +pub fn remove_user_access_tier(env: &Env, user: &Address) { + let key = (Symbol::new(env, "acc_u_tr"), user.clone()); + env.storage().persistent().remove(&key); +} + + diff --git a/src/errors.rs b/src/errors.rs index 1de43fe..94aabc9 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -441,9 +441,18 @@ pub enum VaultFeatureError { RevenueShareInvalidProof = 33, /// `claim_revenue_share()`: user already claimed for this epoch. RevenueShareAlreadyClaimed = 34, + + // ── Issue #282: Stake-Gated Access ─────────────────────────────────────── + /// `set_access_tier()`: 5 access tiers are already configured. + TooManyAccessTiers = 35, + /// `claim_access_token()`: user does not qualify for any access tier. + IneligibleForAccessTier = 36, + /// `revoke_access_token()`: user still qualifies for their current tier. + UserStillEligible = 37, } + impl From for VaultExtError { fn from(err: VaultError) -> Self { match err { diff --git a/src/events.rs b/src/events.rs index 4f0ad99..6de5eac 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1117,4 +1117,23 @@ pub fn escrow_released(env: &Env, user: &Address, escrowed_amount: i128, ledger: .publish(topics, (user.clone(), escrowed_amount, ledger)); } +// ── Issue #282: Stake-Gated Access ─────────────────────────────────────────── + +pub fn access_token_issued(env: &Env, user: &Address, tier_index: u32, ledger: u32) { + let topics = (symbol_short!("acc_iss"), user); + env.events().publish(topics, (tier_index, ledger)); +} + +pub fn access_token_revoked( + env: &Env, + user: &Address, + tier_index: u32, + reason: Symbol, + ledger: u32, +) { + let topics = (symbol_short!("acc_rev"), user); + env.events().publish(topics, (tier_index, reason, ledger)); +} + + diff --git a/src/storage.rs b/src/storage.rs index 9253b9c..17f1ddd 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1052,3 +1052,14 @@ pub struct RevenueShareMerkleRoot { pub total_amount: i128, } +// ── Issue #282: Stake-Gated Access ─────────────────────────────────────────── + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct AccessTier { + pub min_stake: i128, + pub min_duration_ledgers: u32, + pub access_token_contract: Address, +} + + diff --git a/src/test_integration.rs b/src/test_integration.rs index e9f1ee0..08725a0 100644 --- a/src/test_integration.rs +++ b/src/test_integration.rs @@ -1370,4 +1370,91 @@ fn test_escrow_period_zero_disables_and_restaker_gets_new_escrow() { assert_eq!(vault.get_escrow_release_ledger(&alice), Some(350)); } +// ── Issue #282: Stake-Gated Access Tests ──────────────────────────────────── + +#[test] +fn test_stake_gated_access_flow() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|li| { + li.sequence_number = 10; + li.min_persistent_entry_ttl = 10_000_000; + li.max_entry_ttl = 10_000_000; + }); + + let admin = Address::generate(&env); + let alice = Address::generate(&env); + let (token_addr, _token, token_admin) = create_token(&env, &admin); + let vault_id = env.register_contract(None, VaultContract); + let vault = VaultContractClient::new(&env, &vault_id); + vault.initialize(&admin, &token_addr, &0_u32, &None, &None); + + // Setup NFT contracts for access token tiers + let nft_id_1 = env.register_contract(None, StakeReceiptNFT); + let nft_client_1 = StakeReceiptNFTClient::new(&env, &nft_id_1); + nft_client_1.initialize(&vault_id); + + let nft_id_2 = env.register_contract(None, StakeReceiptNFT); + let nft_client_2 = StakeReceiptNFTClient::new(&env, &nft_id_2); + nft_client_2.initialize(&vault_id); + + // Tier 0: min 50_000 stake, min 10 duration ledgers -> nft_id_1 + let tier_0 = AccessTier { + min_stake: 50_000, + min_duration_ledgers: 10, + access_token_contract: nft_id_1.clone(), + }; + vault.set_access_tier(&admin, &tier_0); + + // Tier 1: min 100_000 stake, min 20 duration ledgers -> nft_id_2 + let tier_1 = AccessTier { + min_stake: 100_000, + min_duration_ledgers: 20, + access_token_contract: nft_id_2.clone(), + }; + vault.set_access_tier(&admin, &tier_1); + + // 1. Staker with 30_000 (below threshold) -> no eligibility + token_admin.mint(&alice, &100_000); + vault.stake(&alice, &30_000); + assert_eq!(vault.check_access_eligibility(&alice), None); + assert!(vault.try_claim_access_token(&alice).is_err()); + + // 2. Stake up to 60_000, advance ledgers by 10 + vault.stake(&alice, &30_000); // total 60_000 + env.ledger().with_mut(|li| li.sequence_number = 25); + + // Qualifies for Tier 0 (index 0) + assert_eq!(vault.check_access_eligibility(&alice), Some(0)); + + // Claim access token for Tier 0 + let tier_idx = vault.claim_access_token(&alice); + assert_eq!(tier_idx, 0); + assert!(nft_client_1.has_receipt(&alice)); + + // 3. Stake up to 100_000 and advance ledgers to 35 -> qualifies for Tier 1 (highest tier wins) + vault.stake(&alice, &40_000); // total 100_000 + env.ledger().with_mut(|li| li.sequence_number = 35); + assert_eq!(vault.check_access_eligibility(&alice), Some(1)); + + // Claim upgrade to Tier 1 + let upgraded_idx = vault.claim_access_token(&alice); + assert_eq!(upgraded_idx, 1); + assert!(nft_client_2.has_receipt(&alice)); + assert!(!nft_client_1.has_receipt(&alice)); // old token burned + + // 4. Revoke fails when user is still eligible + let res_rev_early = vault.try_revoke_access_token(&alice); + assert!(res_rev_early.is_err()); + + // 5. Unstake 60_000 -> stake drops to 40_000 (below Tier 1 min 100_000 and Tier 0 min 50_000) + vault.unstake(&alice, &60_000); + assert_eq!(vault.check_access_eligibility(&alice), None); + + // Revoke succeeds now that user no longer meets requirements + vault.revoke_access_token(&alice); + assert!(!nft_client_2.has_receipt(&alice)); +} + + diff --git a/src/vault.rs b/src/vault.rs index 4c1aaf4..257af40 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -9,7 +9,7 @@ use crate::{ events, nft::StakeReceiptNFTClient, storage::{ - AdminAction, AdminProposal, AuctionBid, AutoConvertConfig, BoostTierProgress, + AccessTier, AdminAction, AdminProposal, AuctionBid, AutoConvertConfig, BoostTierProgress, BootstrapConfig, BrandingConfig, CampaignInfo, CapacityAuction, ChangelogEntry, ClaimWindow, ContractAddresses, ContractMetadata, DataKey, DayBucket, DelegationChain, DynamicFeeConfig, EpochState, FeeRecipient, FlashStakeReceipt, GovernanceProposal, HalvingConfig, @@ -11567,4 +11567,142 @@ impl VaultContract { pub fn get_escrow_release_ledger(env: Env, user: Address) -> Option { balance::get_escrow_release_ledger(&env, &user) } + + // ── Issue #282: Stake-Gated Access ─────────────────────────────────────── + + pub fn set_access_tier( + env: Env, + admin: Address, + tier: AccessTier, + ) -> Result<(), VaultFeatureError> { + admin::require_admin(&env)?; + admin.require_auth(); + + let mut tiers = balance::get_access_tiers(&env); + if tiers.len() >= 5 { + return Err(VaultFeatureError::TooManyAccessTiers); + } + tiers.push_back(tier); + balance::set_access_tiers(&env, &tiers); + Ok(()) + } + + pub fn get_access_tiers(env: Env) -> Vec { + balance::get_access_tiers(&env) + } + + pub fn check_access_eligibility(env: Env, user: Address) -> Option { + let user_shares = balance::get_shares(&env, &user); + if user_shares <= 0 { + return None; + } + + let total_shares = balance::get_total_shares(&env); + let total_deposited = balance::get_total_deposited(&env); + let user_stake = balance::shares_to_amount(total_shares, total_deposited, user_shares).unwrap_or(0); + + let staked_at_ledger = env + .storage() + .persistent() + .get(&DataKey::StakedAtLedger(user.clone())) + .unwrap_or(0); + let duration = env.ledger().sequence().saturating_sub(staked_at_ledger); + + let tiers = balance::get_access_tiers(&env); + let mut best_tier_index: Option = None; + let mut max_req_stake: i128 = -1; + + for i in 0..tiers.len() { + let t = tiers.get(i).unwrap(); + if user_stake >= t.min_stake && duration >= t.min_duration_ledgers { + if t.min_stake > max_req_stake { + max_req_stake = t.min_stake; + best_tier_index = Some(i as u32); + } else if best_tier_index.is_none() { + best_tier_index = Some(i as u32); + } + } + } + best_tier_index + } + + pub fn claim_access_token(env: Env, user: Address) -> Result { + user.require_auth(); + + let eligible_index = Self::check_access_eligibility(env.clone(), user.clone()) + .ok_or(VaultFeatureError::IneligibleForAccessTier)?; + + let tiers = balance::get_access_tiers(&env); + let eligible_tier = tiers.get(eligible_index).unwrap(); + + if let Some(current_index) = balance::get_user_access_tier(&env, &user) { + if current_index == eligible_index { + return Ok(eligible_index); + } + // User upgrading or changing tier: revoke old token first + if let Some(old_tier) = tiers.get(current_index) { + let old_nft_client = StakeReceiptNFTClient::new(&env, &old_tier.access_token_contract); + old_nft_client.burn(&user); + events::access_token_revoked( + &env, + &user, + current_index, + Symbol::new(&env, "upgrade"), + env.ledger().sequence(), + ); + } + } + + // Mint new access token + let nft_client = StakeReceiptNFTClient::new(&env, &eligible_tier.access_token_contract); + let user_shares = balance::get_shares(&env, &user); + let total_shares = balance::get_total_shares(&env); + let total_deposited = balance::get_total_deposited(&env); + let user_stake = balance::shares_to_amount(total_shares, total_deposited, user_shares).unwrap_or(0); + + let staked_at_ledger = env + .storage() + .persistent() + .get(&DataKey::StakedAtLedger(user.clone())) + .unwrap_or(0); + + nft_client.mint( + &user, + &env.current_contract_address(), + &user_stake, + &staked_at_ledger, + ); + + balance::set_user_access_tier(&env, &user, eligible_index); + events::access_token_issued(&env, &user, eligible_index, env.ledger().sequence()); + Ok(eligible_index) + } + + pub fn revoke_access_token(env: Env, user: Address) -> Result<(), VaultFeatureError> { + let current_index = balance::get_user_access_tier(&env, &user) + .ok_or(VaultFeatureError::IneligibleForAccessTier)?; + + let eligible_index = Self::check_access_eligibility(env.clone(), user.clone()); + if let Some(elig) = eligible_index { + if elig >= current_index { + return Err(VaultFeatureError::UserStillEligible); + } + } + + let tiers = balance::get_access_tiers(&env); + if let Some(current_tier) = tiers.get(current_index) { + let nft_client = StakeReceiptNFTClient::new(&env, ¤t_tier.access_token_contract); + nft_client.burn(&user); + } + + balance::remove_user_access_tier(&env, &user); + events::access_token_revoked( + &env, + &user, + current_index, + Symbol::new(&env, "ineligible"), + env.ledger().sequence(), + ); + Ok(()) + } } From 6f5a3dfcf2f47def4f6e1b2a53b02b69e8c8b084 Mon Sep 17 00:00:00 2001 From: OlolaJaco Date: Tue, 28 Jul 2026 14:53:37 +0100 Subject: [PATCH 4/4] feat: implement reward halving schedule and update integration tests for rate adjustment logic --- rust-toolchain.toml | 2 ++ src/test_integration.rs | 57 +++++++++++++++++++++++++++++++++++++++++ src/vault.rs | 6 +++++ 3 files changed, 65 insertions(+) diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 5e8698d..f07fd27 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,4 @@ [toolchain] channel = "1.81.0" + + diff --git a/src/test_integration.rs b/src/test_integration.rs index 08725a0..717042f 100644 --- a/src/test_integration.rs +++ b/src/test_integration.rs @@ -1456,5 +1456,62 @@ fn test_stake_gated_access_flow() { assert!(!nft_client_2.has_receipt(&alice)); } +// ── Issue #279: Reward Halving Schedule Integration Tests ─────────────────── + +#[test] +fn test_reward_halving_schedule_integration() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|li| { + li.sequence_number = 0; + li.min_persistent_entry_ttl = 10_000_000; + li.max_entry_ttl = 10_000_000; + }); + + let admin = Address::generate(&env); + let alice = Address::generate(&env); + let (token_addr, _token, token_admin) = create_token(&env, &admin); + let vault_id = env.register_contract(None, VaultContract); + let vault = VaultContractClient::new(&env, &vault_id); + vault.initialize(&admin, &token_addr, &0_u32, &None, &None); + + // 1. Without halving config, base rate is used + vault.set_reward_rate_bps(&1000); + assert_eq!(vault.get_current_halving_count(), 0); + assert_eq!(vault.next_halving_at(), None); + + // 2. Set halving schedule: interval = 100 ledgers, floor_rate_bps = 200 + vault.set_halving_schedule(&admin, &100, &200); + + let config = vault.get_halving_config().unwrap(); + assert_eq!(config.interval_ledgers, 100); + assert_eq!(config.floor_rate_bps, 200); + + // Next halving boundary is at ledger 100 + assert_eq!(vault.next_halving_at(), Some(100)); + + // Stake at ledger 0 + token_admin.mint(&alice, &100_000); + vault.stake(&alice, &100_000); + + // Ledger 50: halving count is 0 + env.ledger().with_mut(|li| li.sequence_number = 50); + assert_eq!(vault.get_current_halving_count(), 0); + + // Ledger 150: 1 halving has occurred (count = 1). Base rate 1000 / 2^1 = 500 + env.ledger().with_mut(|li| li.sequence_number = 150); + assert_eq!(vault.get_current_halving_count(), 1); + assert_eq!(vault.next_halving_at(), Some(200)); + + // Ledger 250: 2 halvings have occurred (count = 2). Base rate 1000 / 2^2 = 250 + env.ledger().with_mut(|li| li.sequence_number = 250); + assert_eq!(vault.get_current_halving_count(), 2); + + // Ledger 350: 3 halvings occurred. Base rate 1000 / 2^3 = 125, but floored at floor_rate_bps = 200! + env.ledger().with_mut(|li| li.sequence_number = 350); + assert_eq!(vault.get_current_halving_count(), 3); +} + + diff --git a/src/vault.rs b/src/vault.rs index 257af40..789abf2 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -6199,6 +6199,12 @@ impl VaultContract { .checked_add(segment_dust) .ok_or(VaultError::ArithmeticError)?; + if seg_end == next_halving_boundary && next_halving_boundary < end_ledger { + let h_count = balance::halving_count_at(env, seg_end); + let eff_rate = balance::halving_adjusted_rate(env, base_rate, seg_end); + events::halving_occurred(env, h_count, eff_rate, seg_end); + } + // Advance tier multiplier when we land exactly on a tier boundary if seg_end == next_tier_boundary && tier_index < schedule.len() { let (_, tier_mult) = schedule.get(tier_index).unwrap();