diff --git a/artifacts/stablecoin-idl.json b/artifacts/stablecoin-idl.json index 5c601e8..b6d257a 100644 --- a/artifacts/stablecoin-idl.json +++ b/artifacts/stablecoin-idl.json @@ -2,6 +2,33 @@ "version": "0.1.0", "name": "stablecoin", "instructions": [ + { + "name": "initialize_stability_fee_state", + "accounts": [ + { + "name": "authority", + "writable": false, + "signer": false, + "init": false + }, + { + "name": "stability_fee_state", + "writable": false, + "signer": false, + "init": false + } + ], + "args": [ + { + "name": "stability_fee_rate", + "type": "u128" + }, + { + "name": "current_timestamp", + "type": "u64" + } + ] + }, { "name": "open_position", "accounts": [ @@ -34,6 +61,12 @@ "writable": false, "signer": false, "init": false + }, + { + "name": "stability_fee_state", + "writable": false, + "signer": false, + "init": false } ], "args": [ @@ -115,6 +148,26 @@ } ], "accounts": [ + { + "name": "StabilityFeeState", + "type": { + "kind": "struct", + "fields": [ + { + "name": "stability_fee_accumulator", + "type": "u128" + }, + { + "name": "stability_fee_rate", + "type": "u128" + }, + { + "name": "last_fee_update_timestamp", + "type": "u64" + } + ] + } + }, { "name": "Position", "type": { @@ -135,6 +188,10 @@ { "name": "debt_amount", "type": "u128" + }, + { + "name": "fee_accumulator", + "type": "u128" } ] } diff --git a/programs/integration_tests/tests/stablecoin.rs b/programs/integration_tests/tests/stablecoin.rs index 4044d84..8bd7baa 100644 --- a/programs/integration_tests/tests/stablecoin.rs +++ b/programs/integration_tests/tests/stablecoin.rs @@ -3,7 +3,10 @@ use nssa::{ public_transaction, PrivateKey, PublicKey, PublicTransaction, V03State, }; use nssa_core::account::{Account, AccountId, Data, Nonce}; -use stablecoin_core::{compute_position_pda, compute_position_vault_pda, Position}; +use stablecoin_core::{ + compute_position_pda, compute_position_vault_pda, compute_stability_fee_state_pda, Position, + StabilityFeeState, FEE_ACCUMULATOR_SCALE, +}; use token_core::{TokenDefinition, TokenHolding}; struct Keys; @@ -67,6 +70,10 @@ impl Ids { fn vault() -> AccountId { compute_position_vault_pda(Self::stablecoin_program(), Self::position()) } + + fn stability_fee_state() -> AccountId { + compute_stability_fee_state_pda(Self::stablecoin_program()) + } } impl Balances { @@ -138,6 +145,15 @@ impl Accounts { } } + fn stability_fee_state_init() -> Account { + Account { + program_owner: stablecoin_methods::STABLECOIN_ID, + balance: 0_u128, + data: Data::from(&StabilityFeeState::new(FEE_ACCUMULATOR_SCALE / 100, 0)), + nonce: Nonce(0), + } + } + fn user_stablecoin_holding_init() -> Account { Account { program_owner: Ids::token_program(), @@ -159,6 +175,7 @@ impl Accounts { collateral_definition_id: Ids::collateral_definition(), collateral_amount: Balances::collateral_deposit(), debt_amount: Balances::initial_debt(), + fee_accumulator: FEE_ACCUMULATOR_SCALE, }), nonce: Nonce(0), } @@ -191,6 +208,10 @@ fn state_for_stablecoin_tests() -> V03State { Accounts::collateral_definition_init(), ); state.force_insert_account(Ids::user_holding(), Accounts::user_holding_init()); + state.force_insert_account( + Ids::stability_fee_state(), + Accounts::stability_fee_state_init(), + ); state } @@ -214,6 +235,10 @@ fn state_for_stablecoin_repay_tests() -> V03State { Ids::user_stablecoin_holding(), Accounts::user_stablecoin_holding_init(), ); + state.force_insert_account( + Ids::stability_fee_state(), + Accounts::stability_fee_state_init(), + ); state } @@ -262,6 +287,7 @@ fn stablecoin_open_position_then_withdraw_collateral() { Ids::vault(), Ids::user_holding(), Ids::collateral_definition(), + Ids::stability_fee_state(), ], vec![ current_nonce(&state, Ids::owner()), diff --git a/programs/stablecoin/core/src/lib.rs b/programs/stablecoin/core/src/lib.rs index b5f51ac..f322526 100644 --- a/programs/stablecoin/core/src/lib.rs +++ b/programs/stablecoin/core/src/lib.rs @@ -2,21 +2,48 @@ use borsh::{BorshDeserialize, BorshSerialize}; use nssa_core::{ - account::{AccountId, AccountWithMetadata, Data}, + account::{Account, AccountId, AccountWithMetadata, Data}, program::{PdaSeed, ProgramId}, }; use serde::{Deserialize, Serialize}; use spel_framework_macros::account_type; +mod stability_fee; + +#[cfg(test)] +mod tests; + +pub use stability_fee::{ + accrue_position_stability_fee, accrue_stability_fees, accrued_debt_amount, + apply_debt_change_after_fee_accrual, collateralization_ratio_bps, + ensure_minimum_collateralization, stability_fee_growth_factor, DebtChange, StabilityFeeError, + StabilityFeeState, COLLATERALIZATION_RATIO_BPS_DENOMINATOR, FEE_ACCUMULATOR_SCALE, +}; + const POSITION_PDA_DOMAIN: [u8; 32] = [0; 32]; const POSITION_VAULT_PDA_DOMAIN: [u8; 32] = [1; 32]; +const STABILITY_FEE_STATE_PDA_DOMAIN: [u8; 32] = [2; 32]; /// Stablecoin Program Instruction. #[derive(Debug, Serialize, Deserialize)] pub enum Instruction { + /// Initialize the program-global [`StabilityFeeState`] account. + /// + /// Required accounts (2): + /// - Stability-fee authority account (authorized; not yet bound to a designated governance + /// identity — see `initialize_stability_fee_state`, treat as a trusted bootstrap step) + /// - Program-global [`StabilityFeeState`] account (uninitialized, address must match + /// `compute_stability_fee_state_pda(self_program_id)`) + InitializeStabilityFeeState { + /// Fixed-point stability fee rate per timestamp unit. + stability_fee_rate: u128, + /// Initial accumulator timestamp. + current_timestamp: u64, + }, + /// Open a new collateral-only [`Position`] for the calling owner. /// - /// Required accounts (5): + /// Required accounts (6): /// - Owner account (authorized) /// - Position account (uninitialized, address must match /// `compute_position_pda(self_program_id, owner, token_definition)`) @@ -26,6 +53,8 @@ pub enum Instruction { /// - Token definition account for the collateral (matches the user holding's `definition_id`; /// its `program_owner` determines the Token Program used by the chained `InitializeAccount` /// / `Transfer` calls) + /// - Program-global [`StabilityFeeState`] account (initialized, address must match + /// `compute_stability_fee_state_pda(self_program_id)`) OpenPosition { /// Amount of collateral tokens to deposit into the position vault. collateral_amount: u128, @@ -80,8 +109,10 @@ pub enum Instruction { /// Persistent state held by a Stablecoin [`Position`] account. /// -/// `debt_amount` is included for forward compatibility with `generate_debt`; until that -/// instruction lands `open_position` always initializes it to `0`. +/// `debt_amount` is nominal debt, and `fee_accumulator` is the global stability-fee accumulator +/// snapshot used when this position was last settled. `open_position` initializes `debt_amount` +/// to `0` and snapshots the initialized global accumulator; drawing debt is deferred to a future +/// `generate_debt` instruction. #[account_type] #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] pub struct Position { @@ -91,8 +122,10 @@ pub struct Position { pub collateral_definition_id: AccountId, /// Amount of collateral tokens deposited. pub collateral_amount: u128, - /// Outstanding stablecoin debt against this position. + /// Outstanding nominal stablecoin debt against this position. pub debt_amount: u128, + /// Global stability-fee accumulator value at the last position fee settlement. + pub fee_accumulator: u128, } impl TryFrom<&Data> for Position { @@ -212,3 +245,69 @@ pub fn verify_position_vault_and_get_seed( ); seed } + +/// PDA seed for the program-global [`StabilityFeeState`] singleton account. +/// +/// The stablecoin program holds exactly one stability-fee state, so the seed is derived +/// solely from a domain-separation tag with no per-caller input. +pub fn compute_stability_fee_state_pda_seed() -> PdaSeed { + use risc0_zkvm::sha::{Impl, Sha256 as _}; + + let mut out = [0u8; 32]; + out.copy_from_slice(Impl::hash_bytes(&STABILITY_FEE_STATE_PDA_DOMAIN).as_bytes()); + PdaSeed::new(out) +} + +/// Account id of the program-global [`StabilityFeeState`] PDA under `stablecoin_program_id`. +pub fn compute_stability_fee_state_pda(stablecoin_program_id: ProgramId) -> AccountId { + AccountId::for_public_pda( + &stablecoin_program_id, + &compute_stability_fee_state_pda_seed(), + ) +} + +/// Verify the stability-fee state account's address matches the program-global PDA and +/// return the [`PdaSeed`] for use in post-state claims. +/// +/// # Panics +/// If `stability_fee_state.account_id` does not match the address derived from +/// `stablecoin_program_id`. +pub fn verify_stability_fee_state_and_get_seed( + stability_fee_state: &AccountWithMetadata, + stablecoin_program_id: ProgramId, +) -> PdaSeed { + let seed = compute_stability_fee_state_pda_seed(); + let expected_id = AccountId::for_public_pda(&stablecoin_program_id, &seed); + assert_eq!( + stability_fee_state.account_id, expected_id, + "Stability fee state account ID does not match expected derivation" + ); + seed +} + +/// Verify the stability-fee state account is the initialized, program-owned global PDA, and +/// return its [`PdaSeed`] (for post-state claims) together with the decoded [`StabilityFeeState`]. +/// +/// # Panics +/// - `stability_fee_state.account_id` does not match the program-global PDA. +/// - the account is uninitialized. +/// - the account is not owned by this Stablecoin Program. +/// - the account data cannot be decoded as a [`StabilityFeeState`]. +pub fn verify_initialized_stability_fee_state( + stability_fee_state: &AccountWithMetadata, + stablecoin_program_id: ProgramId, +) -> (PdaSeed, StabilityFeeState) { + let seed = verify_stability_fee_state_and_get_seed(stability_fee_state, stablecoin_program_id); + assert_ne!( + stability_fee_state.account, + Account::default(), + "Stability fee state account must be initialized" + ); + assert_eq!( + stability_fee_state.account.program_owner, stablecoin_program_id, + "Stability fee state account is not owned by this Stablecoin Program" + ); + let fee_state = StabilityFeeState::try_from(&stability_fee_state.account.data) + .expect("Stability fee state account must hold valid StabilityFeeState data"); + (seed, fee_state) +} diff --git a/programs/stablecoin/core/src/stability_fee.rs b/programs/stablecoin/core/src/stability_fee.rs new file mode 100644 index 0000000..d8cb884 --- /dev/null +++ b/programs/stablecoin/core/src/stability_fee.rs @@ -0,0 +1,458 @@ +use borsh::{BorshDeserialize, BorshSerialize}; +use nssa_core::account::Data; +use serde::{Deserialize, Serialize}; +use spel_framework_macros::account_type; + +use crate::Position; + +/// Fixed-point scale for stability-fee accumulator indexes and rates. +pub const FEE_ACCUMULATOR_SCALE: u128 = 1_000_000_000_000_000_000; + +/// Basis-point denominator used by collateralization ratio checks. +pub const COLLATERALIZATION_RATIO_BPS_DENOMINATOR: u128 = 10_000; + +const EXP_TAYLOR_TERMS: u32 = 32; +const EXP_TAYLOR_MAX_INPUT: u128 = FEE_ACCUMULATOR_SCALE; + +/// Program-global stability-fee accumulator state. +/// +/// `stability_fee_accumulator` starts at [`FEE_ACCUMULATOR_SCALE`] and grows over time according +/// to `stability_fee_rate`. Every position stores the accumulator value it was last settled +/// against. Accruing a position scales its nominal `debt_amount` by +/// `global_accumulator / position.fee_accumulator`, then snapshots the global accumulator into the +/// position. +/// +/// `stability_fee_rate` is a non-negative fixed-point rate per timestamp unit using +/// [`FEE_ACCUMULATOR_SCALE`]. For example, `FEE_ACCUMULATOR_SCALE / 100` means 1% per timestamp +/// unit, compounded continuously. +#[account_type] +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +pub struct StabilityFeeState { + /// Global debt growth index. + pub stability_fee_accumulator: u128, + /// Fixed-point stability fee rate per timestamp unit. + pub stability_fee_rate: u128, + /// Timestamp of the last global accumulator update. + pub last_fee_update_timestamp: u64, +} + +impl StabilityFeeState { + /// Create a new global stability-fee state at `last_fee_update_timestamp`. + pub fn new(stability_fee_rate: u128, last_fee_update_timestamp: u64) -> Self { + Self { + stability_fee_accumulator: FEE_ACCUMULATOR_SCALE, + stability_fee_rate, + last_fee_update_timestamp, + } + } + + /// Accrue global stability fees through `current_timestamp`. + /// + /// This updates `stability_fee_accumulator *= e^(rate * delta_time)` using fixed-point + /// arithmetic and a range-reduced Taylor expansion. Fixed-point division rounds up, so + /// accrual is protocol-favorable. + /// + /// When continuous compounding produces a factor too large to represent, the accumulator + /// saturates at [`u128::MAX`] instead of failing; `current_timestamp` is still recorded, so the + /// global state is not permanently bricked by a long accrual gap. + /// + /// # Errors + /// Returns [`StabilityFeeError::TimestampMovedBackward`] if `current_timestamp` predates the + /// last recorded update. + pub fn accrue_global(&mut self, current_timestamp: u64) -> Result<(), StabilityFeeError> { + let elapsed = current_timestamp + .checked_sub(self.last_fee_update_timestamp) + .ok_or(StabilityFeeError::TimestampMovedBackward { + current_timestamp, + last_fee_update_timestamp: self.last_fee_update_timestamp, + })?; + let growth_factor = stability_fee_growth_factor(self.stability_fee_rate, elapsed); + self.stability_fee_accumulator = mul_div_ceil( + self.stability_fee_accumulator, + growth_factor, + FEE_ACCUMULATOR_SCALE, + ) + .unwrap_or(u128::MAX); + self.last_fee_update_timestamp = current_timestamp; + Ok(()) + } +} + +impl Default for StabilityFeeState { + fn default() -> Self { + Self::new(0, 0) + } +} + +impl TryFrom<&Data> for StabilityFeeState { + type Error = std::io::Error; + + fn try_from(data: &Data) -> Result { + Self::try_from_slice(data.as_ref()) + } +} + +impl From<&StabilityFeeState> for Data { + fn from(state: &StabilityFeeState) -> Self { + let mut data = Vec::with_capacity(std::mem::size_of_val(state)); + BorshSerialize::serialize(state, &mut data).expect("Serialization to Vec should not fail"); + Self::try_from(data).expect("Stability fee state encoded data should fit into Data") + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum StabilityFeeError { + TimestampMovedBackward { + current_timestamp: u64, + last_fee_update_timestamp: u64, + }, + AccumulatorIsZero, + AccumulatorMovedBackward { + current_accumulator: u128, + position_fee_accumulator: u128, + }, + InvalidCollateralPrice, + InvalidRedemptionPrice, + DebtRepaymentExceedsDebt { + debt_amount: u128, + repayment_amount: u128, + }, + ArithmeticOverflow, + CollateralizationRatioTooLow { + collateral_value: u128, + debt_value: u128, + minimum_collateralization_ratio_bps: u128, + }, +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum DebtChange { + Increase(u128), + Decrease(u128), +} + +/// Return `e^(rate_per_time_unit * elapsed)` as a fixed-point accumulator factor. +/// +/// The result saturates at [`u128::MAX`] when continuous compounding produces a factor too large +/// to represent. +pub fn stability_fee_growth_factor(rate_per_time_unit: u128, elapsed: u64) -> u128 { + if rate_per_time_unit == 0 || elapsed == 0 { + return FEE_ACCUMULATOR_SCALE; + } + + let Some(x) = rate_per_time_unit.checked_mul(u128::from(elapsed)) else { + return u128::MAX; + }; + let chunks = div_ceil(x, EXP_TAYLOR_MAX_INPUT); + let reduced_x = div_ceil(x, chunks); + + fixed_point_pow_ceil(exp_taylor(reduced_x), chunks) +} + +/// Derive the debt owed by a position at `current_stability_fee_accumulator`. +/// +/// # Errors +/// - [`StabilityFeeError::AccumulatorIsZero`] for a zero global or position accumulator. +/// - [`StabilityFeeError::AccumulatorMovedBackward`] if the current accumulator is below the +/// position snapshot. +pub fn accrued_debt_amount( + position: &Position, + current_stability_fee_accumulator: u128, +) -> Result { + validate_accumulators(current_stability_fee_accumulator, position.fee_accumulator)?; + if position.debt_amount == 0 { + return Ok(0); + } + Ok(mul_div_ceil( + position.debt_amount, + current_stability_fee_accumulator, + position.fee_accumulator, + ) + .unwrap_or(u128::MAX)) +} + +/// Accrue one position against the current global accumulator and snapshot the index. +/// +/// # Errors +/// - [`StabilityFeeError::AccumulatorIsZero`] for a zero global or position accumulator. +/// - [`StabilityFeeError::AccumulatorMovedBackward`] if the current accumulator is below the +/// position snapshot. +pub fn accrue_position_stability_fee( + position: &mut Position, + current_stability_fee_accumulator: u128, +) -> Result<(), StabilityFeeError> { + position.debt_amount = accrued_debt_amount(position, current_stability_fee_accumulator)?; + position.fee_accumulator = current_stability_fee_accumulator; + Ok(()) +} + +/// Accrue global fees through `current_timestamp`, then accrue the position to that accumulator. +/// +/// # Errors +/// Returns global or position accrual errors. +pub fn accrue_stability_fees( + position: &mut Position, + fee_state: &mut StabilityFeeState, + current_timestamp: u64, +) -> Result<(), StabilityFeeError> { + fee_state.accrue_global(current_timestamp)?; + accrue_position_stability_fee(position, fee_state.stability_fee_accumulator) +} + +/// Accrue fees first, then apply a nominal debt increase or decrease. +/// +/// Debt-changing instructions should use this helper so they cannot mutate debt against a stale +/// fee index. +/// +/// # Errors +/// - global or position accrual errors, +/// - [`StabilityFeeError::ArithmeticOverflow`] if an increase overflows `u128`, +/// - [`StabilityFeeError::DebtRepaymentExceedsDebt`] if a decrease exceeds accrued debt. +pub fn apply_debt_change_after_fee_accrual( + position: &mut Position, + fee_state: &mut StabilityFeeState, + current_timestamp: u64, + debt_change: DebtChange, +) -> Result<(), StabilityFeeError> { + accrue_stability_fees(position, fee_state, current_timestamp)?; + match debt_change { + DebtChange::Increase(amount) => { + position.debt_amount = position + .debt_amount + .checked_add(amount) + .ok_or(StabilityFeeError::ArithmeticOverflow)?; + } + DebtChange::Decrease(amount) => { + if amount > position.debt_amount { + return Err(StabilityFeeError::DebtRepaymentExceedsDebt { + debt_amount: position.debt_amount, + repayment_amount: amount, + }); + } + position.debt_amount -= amount; + } + } + Ok(()) +} + +/// Compute the current collateralization ratio in basis points after accrued fee growth. +/// +/// Returns `None` for debt-free positions. +/// +/// # Errors +/// - [`StabilityFeeError::InvalidCollateralPrice`] / [`StabilityFeeError::InvalidRedemptionPrice`] +/// for a zero price. +/// - accumulator validation errors. +/// - [`StabilityFeeError::ArithmeticOverflow`] if a value product overflows `u128`. +pub fn collateralization_ratio_bps( + position: &Position, + current_stability_fee_accumulator: u128, + collateral_price: u128, + redemption_price: u128, +) -> Result, StabilityFeeError> { + validate_prices(collateral_price, redemption_price)?; + let debt_amount = accrued_debt_amount(position, current_stability_fee_accumulator)?; + if debt_amount == 0 { + return Ok(None); + } + + let collateral_value = position + .collateral_amount + .checked_mul(collateral_price) + .ok_or(StabilityFeeError::ArithmeticOverflow)?; + let debt_value = debt_amount + .checked_mul(redemption_price) + .ok_or(StabilityFeeError::ArithmeticOverflow)?; + let scaled_collateral_value = collateral_value + .checked_mul(COLLATERALIZATION_RATIO_BPS_DENOMINATOR) + .ok_or(StabilityFeeError::ArithmeticOverflow)?; + + Ok(Some(scaled_collateral_value / debt_value)) +} + +/// Require a position to meet `minimum_collateralization_ratio_bps` after fee growth. +/// +/// # Errors +/// - [`StabilityFeeError::InvalidCollateralPrice`] / [`StabilityFeeError::InvalidRedemptionPrice`] +/// for a zero price. +/// - accumulator validation errors. +/// - [`StabilityFeeError::ArithmeticOverflow`] if a value product overflows `u128`. +/// - [`StabilityFeeError::CollateralizationRatioTooLow`] if the position is undercollateralized. +pub fn ensure_minimum_collateralization( + position: &Position, + current_stability_fee_accumulator: u128, + collateral_price: u128, + redemption_price: u128, + minimum_collateralization_ratio_bps: u128, +) -> Result<(), StabilityFeeError> { + validate_prices(collateral_price, redemption_price)?; + let debt_amount = accrued_debt_amount(position, current_stability_fee_accumulator)?; + if debt_amount == 0 { + return Ok(()); + } + + let collateral_value = position + .collateral_amount + .checked_mul(collateral_price) + .ok_or(StabilityFeeError::ArithmeticOverflow)?; + let debt_value = debt_amount + .checked_mul(redemption_price) + .ok_or(StabilityFeeError::ArithmeticOverflow)?; + let scaled_collateral_value = collateral_value + .checked_mul(COLLATERALIZATION_RATIO_BPS_DENOMINATOR) + .ok_or(StabilityFeeError::ArithmeticOverflow)?; + let minimum_debt_value = debt_value + .checked_mul(minimum_collateralization_ratio_bps) + .ok_or(StabilityFeeError::ArithmeticOverflow)?; + + if scaled_collateral_value < minimum_debt_value { + return Err(StabilityFeeError::CollateralizationRatioTooLow { + collateral_value, + debt_value, + minimum_collateralization_ratio_bps, + }); + } + + Ok(()) +} + +fn validate_accumulators( + current_accumulator: u128, + position_fee_accumulator: u128, +) -> Result<(), StabilityFeeError> { + if current_accumulator == 0 || position_fee_accumulator == 0 { + return Err(StabilityFeeError::AccumulatorIsZero); + } + if current_accumulator < position_fee_accumulator { + return Err(StabilityFeeError::AccumulatorMovedBackward { + current_accumulator, + position_fee_accumulator, + }); + } + Ok(()) +} + +fn validate_prices( + collateral_price: u128, + redemption_price: u128, +) -> Result<(), StabilityFeeError> { + if collateral_price == 0 { + return Err(StabilityFeeError::InvalidCollateralPrice); + } + if redemption_price == 0 { + return Err(StabilityFeeError::InvalidRedemptionPrice); + } + Ok(()) +} + +fn exp_taylor(x: u128) -> u128 { + let mut sum = FEE_ACCUMULATOR_SCALE; + let mut term = FEE_ACCUMULATOR_SCALE; + + for divisor in 1..=EXP_TAYLOR_TERMS { + let denominator = FEE_ACCUMULATOR_SCALE * u128::from(divisor); + let Some(next_term) = mul_div_ceil(term, x, denominator) else { + return u128::MAX; + }; + term = next_term; + if term == 0 { + break; + } + let Some(next_sum) = sum.checked_add(term) else { + return u128::MAX; + }; + sum = next_sum; + } + + sum +} + +fn fixed_point_pow_ceil(mut base: u128, mut exponent: u128) -> u128 { + let mut result = FEE_ACCUMULATOR_SCALE; + + while exponent > 0 { + if exponent & 1 == 1 { + let Some(next_result) = mul_div_ceil(result, base, FEE_ACCUMULATOR_SCALE) else { + return u128::MAX; + }; + result = next_result; + } + exponent >>= 1; + if exponent > 0 { + let Some(next_base) = mul_div_ceil(base, base, FEE_ACCUMULATOR_SCALE) else { + return u128::MAX; + }; + base = next_base; + } + } + + result +} + +fn div_ceil(numerator: u128, denominator: u128) -> u128 { + let quotient = numerator / denominator; + quotient.saturating_add(u128::from(!numerator.is_multiple_of(denominator))) +} + +/// Computes the exact quotient and remainder-flag of `(lhs * rhs) / denominator` using a full +/// 256-bit intermediate product so the multiplication never overflows prematurely. +fn mul_div(lhs: u128, rhs: u128, denominator: u128) -> Option<(u128, bool)> { + if denominator == 0 { + return None; + } + + let (product_high, product_low) = widening_mul(lhs, rhs); + if product_high == 0 { + return Some((product_low / denominator, product_low % denominator != 0)); + } + + let mut quotient: u128 = 0; + let mut remainder: u128 = 0; + for bit in (0..256u32).rev() { + let next_bit = if bit >= 128 { + (product_high >> (bit - 128)) & 1 + } else { + (product_low >> bit) & 1 + }; + let carry = remainder >> 127; + remainder = (remainder << 1) | next_bit; + if carry == 1 || remainder >= denominator { + remainder = remainder.wrapping_sub(denominator); + if bit >= 128 { + return None; + } + quotient |= 1u128 << bit; + } + } + + Some((quotient, remainder != 0)) +} + +fn mul_div_ceil(lhs: u128, rhs: u128, denominator: u128) -> Option { + let (quotient, has_remainder) = mul_div(lhs, rhs, denominator)?; + quotient.checked_add(u128::from(has_remainder)) +} + +fn widening_mul(lhs: u128, rhs: u128) -> (u128, u128) { + const LOW_64: u128 = u64::MAX as u128; + let (lhs_low, lhs_high) = (lhs & LOW_64, lhs >> 64); + let (rhs_low, rhs_high) = (rhs & LOW_64, rhs >> 64); + + let low_low = lhs_low * rhs_low; + let low_high = lhs_low * rhs_high; + let high_low = lhs_high * rhs_low; + let high_high = lhs_high * rhs_high; + + let mut low = low_low; + let mut high = high_high; + + let (sum, carried) = low.overflowing_add(low_high << 64); + low = sum; + high += (low_high >> 64) + u128::from(carried); + + let (sum, carried) = low.overflowing_add(high_low << 64); + low = sum; + high += (high_low >> 64) + u128::from(carried); + + (high, low) +} diff --git a/programs/stablecoin/core/src/tests.rs b/programs/stablecoin/core/src/tests.rs new file mode 100644 index 0000000..492b463 --- /dev/null +++ b/programs/stablecoin/core/src/tests.rs @@ -0,0 +1,317 @@ +use nssa_core::account::{AccountId, Data}; + +use crate::{ + accrue_position_stability_fee, accrue_stability_fees, accrued_debt_amount, + apply_debt_change_after_fee_accrual, collateralization_ratio_bps, + ensure_minimum_collateralization, stability_fee_growth_factor, DebtChange, Position, + StabilityFeeError, StabilityFeeState, FEE_ACCUMULATOR_SCALE, +}; + +fn position(collateral_amount: u128, debt_amount: u128, fee_accumulator: u128) -> Position { + Position { + collateral_vault_id: AccountId::new([0x11; 32]), + collateral_definition_id: AccountId::new([0x22; 32]), + collateral_amount, + debt_amount, + fee_accumulator, + } +} + +#[test] +fn stability_fee_growth_factor_approximates_continuous_compounding() { + let one_percent_per_tick = FEE_ACCUMULATOR_SCALE / 100; + let factor = stability_fee_growth_factor(one_percent_per_tick, 10); + + assert!(factor > 1_105_170_918_000_000_000); + assert!(factor < 1_105_170_918_100_000_000); +} + +#[test] +fn stability_fee_growth_factor_uses_range_reduction_for_large_finite_exponent() { + let one_percent_per_tick = FEE_ACCUMULATOR_SCALE / 100; + let factor = stability_fee_growth_factor(one_percent_per_tick, 4_000); + + assert!(factor > 235_385_266_000_000_000_000_000_000_000_000_000); + assert!(factor < 235_385_267_500_000_000_000_000_000_000_000_000); +} + +#[test] +fn stability_fee_growth_factor_saturates_on_extreme_input() { + assert_eq!(stability_fee_growth_factor(u128::MAX, u64::MAX), u128::MAX); +} + +#[test] +fn accrue_global_saturates_and_never_bricks() { + let mut state = StabilityFeeState::new(u128::MAX, 0); + + state.accrue_global(1_000).unwrap(); + assert_eq!(state.stability_fee_accumulator, u128::MAX); + assert_eq!(state.last_fee_update_timestamp, 1_000); + + state.accrue_global(2_000).unwrap(); + assert_eq!(state.stability_fee_accumulator, u128::MAX); + assert_eq!(state.last_fee_update_timestamp, 2_000); +} + +#[test] +fn stability_fee_state_accrues_global_accumulator() { + let mut state = StabilityFeeState::new(FEE_ACCUMULATOR_SCALE / 100, 10); + + state.accrue_global(20).unwrap(); + + assert!(state.stability_fee_accumulator > FEE_ACCUMULATOR_SCALE); + assert_eq!(state.last_fee_update_timestamp, 20); +} + +#[test] +fn stability_fee_state_rejects_timestamp_regression() { + let mut state = StabilityFeeState::new(FEE_ACCUMULATOR_SCALE / 100, 20); + + let err = state.accrue_global(19).unwrap_err(); + + assert_eq!( + err, + StabilityFeeError::TimestampMovedBackward { + current_timestamp: 19, + last_fee_update_timestamp: 20, + } + ); +} + +#[test] +fn accrued_debt_amount_scales_nominal_debt_from_position_accumulator() { + let position = position(1_000, 100, FEE_ACCUMULATOR_SCALE); + + let debt = accrued_debt_amount(&position, FEE_ACCUMULATOR_SCALE * 2).unwrap(); + + assert_eq!(debt, 200); +} + +#[test] +fn accrued_debt_amount_ceils_fractional_growth() { + let position = position(1_000, 1, FEE_ACCUMULATOR_SCALE); + + let debt = accrued_debt_amount(&position, FEE_ACCUMULATOR_SCALE + 1).unwrap(); + + assert_eq!(debt, 2); +} + +#[test] +fn accrued_debt_amount_rejects_zero_position_accumulator() { + let position = position(1_000, 100, 0); + + let err = accrued_debt_amount(&position, FEE_ACCUMULATOR_SCALE).unwrap_err(); + + assert_eq!(err, StabilityFeeError::AccumulatorIsZero); +} + +#[test] +fn accrued_debt_amount_rejects_zero_global_accumulator() { + let position = position(1_000, 100, FEE_ACCUMULATOR_SCALE); + + let err = accrued_debt_amount(&position, 0).unwrap_err(); + + assert_eq!(err, StabilityFeeError::AccumulatorIsZero); +} + +#[test] +fn accrued_debt_amount_rejects_backward_accumulator() { + let position = position(1_000, 100, FEE_ACCUMULATOR_SCALE * 2); + + let err = accrued_debt_amount(&position, FEE_ACCUMULATOR_SCALE).unwrap_err(); + + assert_eq!( + err, + StabilityFeeError::AccumulatorMovedBackward { + current_accumulator: FEE_ACCUMULATOR_SCALE, + position_fee_accumulator: FEE_ACCUMULATOR_SCALE * 2, + } + ); +} + +#[test] +fn accrue_position_stability_fee_updates_debt_and_snapshots_accumulator() { + let mut position = position(1_000, 100, FEE_ACCUMULATOR_SCALE); + + accrue_position_stability_fee(&mut position, FEE_ACCUMULATOR_SCALE * 2).unwrap(); + + assert_eq!(position.debt_amount, 200); + assert_eq!(position.fee_accumulator, FEE_ACCUMULATOR_SCALE * 2); +} + +#[test] +fn accrue_stability_fees_accrues_global_then_position() { + let mut position = position(1_000, 100, FEE_ACCUMULATOR_SCALE); + let mut state = StabilityFeeState::new(FEE_ACCUMULATOR_SCALE / 10, 0); + + accrue_stability_fees(&mut position, &mut state, 1).unwrap(); + + assert!(state.stability_fee_accumulator > FEE_ACCUMULATOR_SCALE); + assert_eq!(position.fee_accumulator, state.stability_fee_accumulator); + assert!(position.debt_amount > 100); +} + +#[test] +fn debt_change_accrues_existing_debt_before_increase() { + let mut position = position(1_000, 100, FEE_ACCUMULATOR_SCALE); + let mut state = StabilityFeeState::new(FEE_ACCUMULATOR_SCALE / 10, 0); + + apply_debt_change_after_fee_accrual(&mut position, &mut state, 1, DebtChange::Increase(50)) + .unwrap(); + + assert_eq!(position.fee_accumulator, state.stability_fee_accumulator); + assert_eq!(position.debt_amount, 161); +} + +#[test] +fn debt_change_rejects_repayment_above_accrued_debt() { + let mut position = position(1_000, 100, FEE_ACCUMULATOR_SCALE); + let mut state = StabilityFeeState::new(0, 0); + + let err = apply_debt_change_after_fee_accrual( + &mut position, + &mut state, + 0, + DebtChange::Decrease(101), + ) + .unwrap_err(); + + assert_eq!( + err, + StabilityFeeError::DebtRepaymentExceedsDebt { + debt_amount: 100, + repayment_amount: 101, + } + ); +} + +#[test] +fn debt_change_rejects_increase_overflow() { + let mut position = position(1_000, u128::MAX, FEE_ACCUMULATOR_SCALE); + let mut state = StabilityFeeState::new(0, 0); + + let err = + apply_debt_change_after_fee_accrual(&mut position, &mut state, 0, DebtChange::Increase(1)) + .unwrap_err(); + + assert_eq!(err, StabilityFeeError::ArithmeticOverflow); +} + +#[test] +fn partial_repayment_reduces_accrued_nominal_debt() { + let mut state = StabilityFeeState { + stability_fee_accumulator: 3 * FEE_ACCUMULATOR_SCALE / 2, + stability_fee_rate: 0, + last_fee_update_timestamp: 5, + }; + let mut position = position(1_000, 100, FEE_ACCUMULATOR_SCALE); + + apply_debt_change_after_fee_accrual(&mut position, &mut state, 5, DebtChange::Decrease(50)) + .unwrap(); + + assert_eq!(position.debt_amount, 100); + assert_eq!(position.fee_accumulator, 3 * FEE_ACCUMULATOR_SCALE / 2); +} + +#[test] +fn exact_full_repayment_zeroes_debt() { + let mut state = StabilityFeeState { + stability_fee_accumulator: 3 * FEE_ACCUMULATOR_SCALE / 2, + stability_fee_rate: 0, + last_fee_update_timestamp: 5, + }; + let mut position = position(1_000, 100, FEE_ACCUMULATOR_SCALE); + + apply_debt_change_after_fee_accrual(&mut position, &mut state, 5, DebtChange::Decrease(150)) + .unwrap(); + + assert_eq!(position.debt_amount, 0); + assert_eq!(position.fee_accumulator, 3 * FEE_ACCUMULATOR_SCALE / 2); +} + +#[test] +fn collateralization_ratio_uses_accrued_debt_and_redemption_price() { + let position = position(300, 100, FEE_ACCUMULATOR_SCALE); + let current_accumulator = FEE_ACCUMULATOR_SCALE * 2; + + let ratio = collateralization_ratio_bps(&position, current_accumulator, 4, 2).unwrap(); + + assert_eq!(ratio, Some(30_000)); +} + +#[test] +fn minimum_collateralization_check_fails_after_fee_growth() { + let position = position(150, 100, FEE_ACCUMULATOR_SCALE); + let current_accumulator = FEE_ACCUMULATOR_SCALE * 2; + + let err = + ensure_minimum_collateralization(&position, current_accumulator, 1, 1, 10_000).unwrap_err(); + + assert_eq!( + err, + StabilityFeeError::CollateralizationRatioTooLow { + collateral_value: 150, + debt_value: 200, + minimum_collateralization_ratio_bps: 10_000, + } + ); +} + +#[test] +fn minimum_collateralization_check_allows_debt_free_position() { + let position = position(0, 0, FEE_ACCUMULATOR_SCALE); + + ensure_minimum_collateralization(&position, FEE_ACCUMULATOR_SCALE * 10, 1, 1, u128::MAX) + .unwrap(); +} + +#[test] +fn collateralization_ratio_rejects_zero_collateral_price() { + let position = position(150, 100, FEE_ACCUMULATOR_SCALE); + + let err = collateralization_ratio_bps(&position, FEE_ACCUMULATOR_SCALE, 0, 1).unwrap_err(); + + assert_eq!(err, StabilityFeeError::InvalidCollateralPrice); +} + +#[test] +fn collateralization_ratio_rejects_zero_redemption_price() { + let position = position(150, 100, FEE_ACCUMULATOR_SCALE); + + let err = collateralization_ratio_bps(&position, FEE_ACCUMULATOR_SCALE, 1, 0).unwrap_err(); + + assert_eq!(err, StabilityFeeError::InvalidRedemptionPrice); +} + +#[test] +fn collateralization_ratio_rejects_collateral_value_overflow() { + let position = position(u128::MAX, 100, FEE_ACCUMULATOR_SCALE); + + let err = collateralization_ratio_bps(&position, FEE_ACCUMULATOR_SCALE, 2, 1).unwrap_err(); + + assert_eq!(err, StabilityFeeError::ArithmeticOverflow); +} + +#[test] +fn ensure_minimum_collateralization_rejects_collateral_value_overflow() { + let position = position(u128::MAX, 100, FEE_ACCUMULATOR_SCALE); + + let err = ensure_minimum_collateralization(&position, FEE_ACCUMULATOR_SCALE, 2, 1, 10_000) + .unwrap_err(); + + assert_eq!(err, StabilityFeeError::ArithmeticOverflow); +} + +#[test] +fn stability_fee_state_round_trips_through_data() { + let state = StabilityFeeState { + stability_fee_accumulator: FEE_ACCUMULATOR_SCALE + 123, + stability_fee_rate: FEE_ACCUMULATOR_SCALE / 100, + last_fee_update_timestamp: 42, + }; + + let data = Data::from(&state); + let decoded = StabilityFeeState::try_from(&data).unwrap(); + + assert_eq!(decoded, state); +} diff --git a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs index f677ca3..614be3c 100644 --- a/programs/stablecoin/methods/guest/src/bin/stablecoin.rs +++ b/programs/stablecoin/methods/guest/src/bin/stablecoin.rs @@ -12,12 +12,36 @@ mod stablecoin { #[allow(unused_imports)] use super::*; + /// Initialize the program-global stability-fee accumulator state. + /// + /// # Errors + /// Returns the host program's panic-converted error if any precondition fails. + #[instruction] + pub fn initialize_stability_fee_state( + ctx: ProgramContext, + authority: AccountWithMetadata, + stability_fee_state: AccountWithMetadata, + stability_fee_rate: u128, + current_timestamp: u64, + ) -> SpelResult { + let post_states = + stablecoin_program::initialize_stability_fee_state::initialize_stability_fee_state( + authority, + stability_fee_state, + ctx.self_program_id, + stability_fee_rate, + current_timestamp, + ); + Ok(spel_framework::SpelOutput::execute(post_states, vec![])) + } + /// Open a new collateral-only position for the calling owner. /// /// # Errors /// Returns the host program's panic-converted error if any precondition fails (see /// [`stablecoin_program::open_position::open_position`] for the full list). #[instruction] + #[allow(clippy::too_many_arguments)] pub fn open_position( ctx: ProgramContext, owner: AccountWithMetadata, @@ -25,14 +49,18 @@ mod stablecoin { vault: AccountWithMetadata, user_holding: AccountWithMetadata, token_definition: AccountWithMetadata, + stability_fee_state: AccountWithMetadata, collateral_amount: u128, ) -> SpelResult { let (post_states, chained_calls) = stablecoin_program::open_position::open_position( - owner, - position, - vault, - user_holding, - token_definition, + stablecoin_program::open_position::OpenPositionAccounts { + owner, + position, + vault, + user_holding, + token_definition, + stability_fee_state, + }, ctx.self_program_id, collateral_amount, ); diff --git a/programs/stablecoin/src/initialize_stability_fee_state.rs b/programs/stablecoin/src/initialize_stability_fee_state.rs new file mode 100644 index 0000000..7970876 --- /dev/null +++ b/programs/stablecoin/src/initialize_stability_fee_state.rs @@ -0,0 +1,58 @@ +use nssa_core::{ + account::{Account, AccountWithMetadata, Data}, + program::{AccountPostState, Claim, ProgramId}, +}; +use stablecoin_core::{verify_stability_fee_state_and_get_seed, StabilityFeeState}; + +/// Initialize the program-global stability-fee accumulator state. +/// +/// This is intentionally separate from `open_position` so position creation cannot silently +/// materialize a zero-rate global fee state. +/// +/// # Authority is not yet governance-bound +/// `authority` is only checked for the runtime `is_authorized` flag — it is **not** pinned to a +/// designated governance identity. Because [`StabilityFeeState`] is a write-once singleton, the +/// first caller permanently fixes `stability_fee_rate`. Binding `authority` to a governance +/// account is deferred to a dedicated governance follow-up; until then this instruction must only +/// be exercised as a trusted deployment/bootstrap step. +/// +/// `current_timestamp` is caller-supplied here rather than oracle-sourced (unlike the clock +/// required by [`StabilityFeeState::accrue_global`]). It should be set to the live oracle time at +/// bootstrap: a far-future value stalls the first accrual until the oracle clock passes it, and a +/// far-past value applies a large retroactive delta on the first accrual. +/// +/// # Panics +/// - `authority` is not authorized. +/// - `stability_fee_state` is already initialized. +/// - `stability_fee_state.account_id` does not match the program-global PDA. +pub fn initialize_stability_fee_state( + authority: AccountWithMetadata, + stability_fee_state: AccountWithMetadata, + stablecoin_program_id: ProgramId, + stability_fee_rate: u128, + current_timestamp: u64, +) -> Vec { + // TODO(governance): pin `authority` to a designated governance account ID instead of + // accepting any authorized signer. Tracked as a governance follow-up. + assert!( + authority.is_authorized, + "Stability fee authority authorization is missing" + ); + assert_eq!( + stability_fee_state.account, + Account::default(), + "Stability fee state account must be uninitialized" + ); + + let seed = verify_stability_fee_state_and_get_seed(&stability_fee_state, stablecoin_program_id); + let fee_state = StabilityFeeState::new(stability_fee_rate, current_timestamp); + + let mut state_post = stability_fee_state.account; + state_post.program_owner = stablecoin_program_id; + state_post.data = Data::from(&fee_state); + + vec![ + AccountPostState::new(authority.account), + AccountPostState::new_claimed(state_post, Claim::Pda(seed)), + ] +} diff --git a/programs/stablecoin/src/lib.rs b/programs/stablecoin/src/lib.rs index 690024e..dbae788 100644 --- a/programs/stablecoin/src/lib.rs +++ b/programs/stablecoin/src/lib.rs @@ -2,6 +2,9 @@ pub use stablecoin_core as core; +/// Initialize the program-global stability-fee accumulator state. +pub mod initialize_stability_fee_state; + /// Open a new collateral-only position for a calling owner. pub mod open_position; diff --git a/programs/stablecoin/src/open_position.rs b/programs/stablecoin/src/open_position.rs index 8c33ae8..c892cb0 100644 --- a/programs/stablecoin/src/open_position.rs +++ b/programs/stablecoin/src/open_position.rs @@ -2,9 +2,22 @@ use nssa_core::{ account::{Account, AccountWithMetadata, Data}, program::{AccountPostState, ChainedCall, Claim, ProgramId}, }; -use stablecoin_core::{verify_position_and_get_seed, verify_position_vault_and_get_seed, Position}; +use stablecoin_core::{ + verify_initialized_stability_fee_state, verify_position_and_get_seed, + verify_position_vault_and_get_seed, Position, +}; use token_core::TokenHolding; +/// Accounts consumed by [`open_position`]. +pub struct OpenPositionAccounts { + pub owner: AccountWithMetadata, + pub position: AccountWithMetadata, + pub vault: AccountWithMetadata, + pub user_holding: AccountWithMetadata, + pub token_definition: AccountWithMetadata, + pub stability_fee_state: AccountWithMetadata, +} + /// Open a new collateral-only position for `owner`. /// /// This claims the [`Position`] PDA, issues two chained token-program calls under the @@ -13,25 +26,32 @@ use token_core::TokenHolding; /// 2. `Transfer` moves `collateral_amount` collateral tokens from the user's holding into the /// freshly initialized vault. /// -/// `debt_amount` is deferred to a future `generate_debt` instruction and is intentionally -/// not parameterized here. +/// The new position is debt-free and snapshots the initialized global +/// [`stablecoin_core::StabilityFeeState`] accumulator. Drawing debt is deferred to a future +/// `generate_debt` instruction. /// /// # Panics /// - `owner` or `user_holding` is not authorized. /// - `position` or `vault` is already initialized. /// - `position.account_id` / `vault.account_id` do not match their PDA derivations. +/// - `stability_fee_state` is not the initialized, program-owned global PDA. /// - `user_holding` cannot be decoded as a [`TokenHolding`]. /// - `user_holding`'s definition does not match `token_definition`. /// - `token_definition.program_owner` does not match `user_holding.program_owner`. pub fn open_position( - owner: AccountWithMetadata, - position: AccountWithMetadata, - vault: AccountWithMetadata, - user_holding: AccountWithMetadata, - token_definition: AccountWithMetadata, + accounts: OpenPositionAccounts, stablecoin_program_id: ProgramId, collateral_amount: u128, ) -> (Vec, Vec) { + let OpenPositionAccounts { + owner, + position, + vault, + user_holding, + token_definition, + stability_fee_state, + } = accounts; + assert!(owner.is_authorized, "Owner authorization is missing"); assert!( user_holding.is_authorized, @@ -69,6 +89,8 @@ pub fn open_position( ); let vault_seed = verify_position_vault_and_get_seed(&vault, position.account_id, stablecoin_program_id); + let (_, fee_state) = + verify_initialized_stability_fee_state(&stability_fee_state, stablecoin_program_id); let mut position_post = position.account; position_post.data = Data::from(&Position { @@ -76,6 +98,7 @@ pub fn open_position( collateral_definition_id: token_definition.account_id, collateral_amount, debt_amount: 0, + fee_accumulator: fee_state.stability_fee_accumulator, }); let post_states = vec![ @@ -84,6 +107,7 @@ pub fn open_position( AccountPostState::new(vault.account.clone()), AccountPostState::new(user_holding.account.clone()), AccountPostState::new(token_definition.account.clone()), + AccountPostState::new(stability_fee_state.account), ]; // Chained Token::InitializeAccount owns the vault as a Token holding. The Stablecoin diff --git a/programs/stablecoin/src/repay_debt.rs b/programs/stablecoin/src/repay_debt.rs index dfe72bd..f94c635 100644 --- a/programs/stablecoin/src/repay_debt.rs +++ b/programs/stablecoin/src/repay_debt.rs @@ -102,6 +102,7 @@ pub fn repay_debt( collateral_definition_id: position_data.collateral_definition_id, collateral_amount: position_data.collateral_amount, debt_amount: new_debt, + fee_accumulator: position_data.fee_accumulator, }; let mut position_post = position.account.clone(); position_post.data = Data::from(&updated_position); diff --git a/programs/stablecoin/src/tests.rs b/programs/stablecoin/src/tests.rs index 41e0154..0f4102a 100644 --- a/programs/stablecoin/src/tests.rs +++ b/programs/stablecoin/src/tests.rs @@ -11,7 +11,8 @@ use nssa_core::{ }; use stablecoin_core::{ compute_position_pda, compute_position_pda_seed, compute_position_vault_pda, - compute_position_vault_pda_seed, Position, + compute_position_vault_pda_seed, compute_stability_fee_state_pda, + compute_stability_fee_state_pda_seed, Position, StabilityFeeState, FEE_ACCUMULATOR_SCALE, }; use token_core::{TokenDefinition, TokenHolding}; @@ -70,6 +71,14 @@ fn owner_account() -> AccountWithMetadata { } } +fn stability_fee_authority_account() -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + is_authorized: true, + account_id: AccountId::new([0x40u8; 32]), + } +} + fn collateral_definition_account() -> AccountWithMetadata { AccountWithMetadata { account: Account { @@ -123,6 +132,7 @@ fn init_position_account(collateral_amount: u128, debt_amount: u128) -> AccountW collateral_definition_id: collateral_definition_id(), collateral_amount, debt_amount, + fee_accumulator: FEE_ACCUMULATOR_SCALE, }), nonce: Nonce(0), }, @@ -174,20 +184,134 @@ fn user_stablecoin_holding_account(balance: u128) -> AccountWithMetadata { account } +fn stability_fee_state_id() -> AccountId { + compute_stability_fee_state_pda(STABLECOIN_PROGRAM_ID) +} + +fn uninit_stability_fee_state_account() -> AccountWithMetadata { + AccountWithMetadata { + account: Account::default(), + is_authorized: false, + account_id: stability_fee_state_id(), + } +} + +fn initialized_stability_fee_state_account(state: &StabilityFeeState) -> AccountWithMetadata { + AccountWithMetadata { + account: Account { + program_owner: STABLECOIN_PROGRAM_ID, + balance: 0, + data: Data::from(state), + nonce: Nonce(0), + }, + is_authorized: false, + account_id: stability_fee_state_id(), + } +} + +fn default_stability_fee_state_account() -> AccountWithMetadata { + initialized_stability_fee_state_account(&StabilityFeeState::default()) +} + +fn open_position_accounts( + owner: AccountWithMetadata, + position: AccountWithMetadata, + vault: AccountWithMetadata, + user_holding: AccountWithMetadata, + token_definition: AccountWithMetadata, + stability_fee_state: AccountWithMetadata, +) -> crate::open_position::OpenPositionAccounts { + crate::open_position::OpenPositionAccounts { + owner, + position, + vault, + user_holding, + token_definition, + stability_fee_state, + } +} + +// --- initialize_stability_fee_state ------------------------------------------------------ + +#[test] +fn initialize_stability_fee_state_claims_pda_and_sets_rate() { + let stability_fee_rate = FEE_ACCUMULATOR_SCALE / 100; + let current_timestamp = 42; + + let post_states = crate::initialize_stability_fee_state::initialize_stability_fee_state( + stability_fee_authority_account(), + uninit_stability_fee_state_account(), + STABLECOIN_PROGRAM_ID, + stability_fee_rate, + current_timestamp, + ); + + assert_eq!(post_states.len(), 2); + assert_eq!(post_states[0].required_claim(), None); + assert_eq!( + post_states[1].required_claim(), + Some(Claim::Pda(compute_stability_fee_state_pda_seed())) + ); + assert_eq!( + post_states[1].account().program_owner, + STABLECOIN_PROGRAM_ID + ); + assert_eq!( + StabilityFeeState::try_from(&post_states[1].account().data).expect("valid fee state"), + StabilityFeeState::new(stability_fee_rate, current_timestamp) + ); +} + +#[test] +#[should_panic(expected = "Stability fee authority authorization is missing")] +fn initialize_stability_fee_state_requires_authority() { + let mut authority = stability_fee_authority_account(); + authority.is_authorized = false; + + crate::initialize_stability_fee_state::initialize_stability_fee_state( + authority, + uninit_stability_fee_state_account(), + STABLECOIN_PROGRAM_ID, + FEE_ACCUMULATOR_SCALE / 100, + 42, + ); +} + +#[test] +#[should_panic(expected = "Stability fee state account must be uninitialized")] +fn initialize_stability_fee_state_rejects_initialized_state() { + crate::initialize_stability_fee_state::initialize_stability_fee_state( + stability_fee_authority_account(), + initialized_stability_fee_state_account(&StabilityFeeState::default()), + STABLECOIN_PROGRAM_ID, + FEE_ACCUMULATOR_SCALE / 100, + 42, + ); +} + +// --- open_position ----------------------------------------------------------------------- + #[test] fn open_position_claims_pda_and_emits_chained_calls() { let collateral_amount: u128 = 500; let (post_states, chained_calls) = crate::open_position::open_position( - owner_account(), - uninit_position_account(), - uninit_vault_account(), - user_holding_account(1_000), - collateral_definition_account(), + open_position_accounts( + owner_account(), + uninit_position_account(), + uninit_vault_account(), + user_holding_account(1_000), + collateral_definition_account(), + initialized_stability_fee_state_account(&StabilityFeeState { + stability_fee_accumulator: FEE_ACCUMULATOR_SCALE + 123, + stability_fee_rate: FEE_ACCUMULATOR_SCALE / 100, + last_fee_update_timestamp: 42, + }), + ), STABLECOIN_PROGRAM_ID, collateral_amount, ); - assert_eq!(post_states.len(), 5); + assert_eq!(post_states.len(), 6); // Position is PDA-claimed and carries the encoded Position state. let position_post = &post_states[1]; @@ -206,10 +330,20 @@ fn open_position_claims_pda_and_emits_chained_calls() { collateral_definition_id: collateral_definition_id(), collateral_amount, debt_amount: 0, + fee_accumulator: FEE_ACCUMULATOR_SCALE + 123, } ); // The runtime sets the program_owner on the claimed account after validating Claim::Pda. assert_eq!(position_post.account().program_owner, ProgramId::default()); + assert_eq!( + post_states[5].account(), + &initialized_stability_fee_state_account(&StabilityFeeState { + stability_fee_accumulator: FEE_ACCUMULATOR_SCALE + 123, + stability_fee_rate: FEE_ACCUMULATOR_SCALE / 100, + last_fee_update_timestamp: 42, + }) + .account + ); assert_eq!(chained_calls.len(), 2); @@ -246,6 +380,23 @@ fn open_position_claims_pda_and_emits_chained_calls() { assert_eq!(chained_calls[1], expected_transfer); } +#[test] +#[should_panic(expected = "Stability fee state account must be initialized")] +fn open_position_rejects_uninitialized_stability_fee_state() { + crate::open_position::open_position( + open_position_accounts( + owner_account(), + uninit_position_account(), + uninit_vault_account(), + user_holding_account(1_000), + collateral_definition_account(), + uninit_stability_fee_state_account(), + ), + STABLECOIN_PROGRAM_ID, + 500, + ); +} + #[test] #[should_panic(expected = "Owner authorization is missing")] fn open_position_requires_owner_authorization() { @@ -253,11 +404,14 @@ fn open_position_requires_owner_authorization() { owner.is_authorized = false; crate::open_position::open_position( - owner, - uninit_position_account(), - uninit_vault_account(), - user_holding_account(1_000), - collateral_definition_account(), + open_position_accounts( + owner, + uninit_position_account(), + uninit_vault_account(), + user_holding_account(1_000), + collateral_definition_account(), + default_stability_fee_state_account(), + ), STABLECOIN_PROGRAM_ID, 500, ); @@ -270,11 +424,14 @@ fn open_position_requires_user_holding_authorization() { holding.is_authorized = false; crate::open_position::open_position( - owner_account(), - uninit_position_account(), - uninit_vault_account(), - holding, - collateral_definition_account(), + open_position_accounts( + owner_account(), + uninit_position_account(), + uninit_vault_account(), + holding, + collateral_definition_account(), + default_stability_fee_state_account(), + ), STABLECOIN_PROGRAM_ID, 500, ); @@ -292,6 +449,7 @@ fn open_position_rejects_initialized_position() { collateral_definition_id: collateral_definition_id(), collateral_amount: 1, debt_amount: 0, + fee_accumulator: FEE_ACCUMULATOR_SCALE, }), nonce: Nonce(0), }, @@ -300,11 +458,14 @@ fn open_position_rejects_initialized_position() { }; crate::open_position::open_position( - owner_account(), - position, - uninit_vault_account(), - user_holding_account(1_000), - collateral_definition_account(), + open_position_accounts( + owner_account(), + position, + uninit_vault_account(), + user_holding_account(1_000), + collateral_definition_account(), + default_stability_fee_state_account(), + ), STABLECOIN_PROGRAM_ID, 500, ); @@ -328,11 +489,14 @@ fn open_position_rejects_initialized_vault() { }; crate::open_position::open_position( - owner_account(), - uninit_position_account(), - vault, - user_holding_account(1_000), - collateral_definition_account(), + open_position_accounts( + owner_account(), + uninit_position_account(), + vault, + user_holding_account(1_000), + collateral_definition_account(), + default_stability_fee_state_account(), + ), STABLECOIN_PROGRAM_ID, 500, ); @@ -348,11 +512,14 @@ fn open_position_rejects_wrong_position_address() { }; crate::open_position::open_position( - owner_account(), - bad_position, - uninit_vault_account(), - user_holding_account(1_000), - collateral_definition_account(), + open_position_accounts( + owner_account(), + bad_position, + uninit_vault_account(), + user_holding_account(1_000), + collateral_definition_account(), + default_stability_fee_state_account(), + ), STABLECOIN_PROGRAM_ID, 500, ); @@ -368,11 +535,14 @@ fn open_position_rejects_wrong_vault_address() { }; crate::open_position::open_position( - owner_account(), - uninit_position_account(), - bad_vault, - user_holding_account(1_000), - collateral_definition_account(), + open_position_accounts( + owner_account(), + uninit_position_account(), + bad_vault, + user_holding_account(1_000), + collateral_definition_account(), + default_stability_fee_state_account(), + ), STABLECOIN_PROGRAM_ID, 500, ); @@ -397,11 +567,14 @@ fn open_position_rejects_mismatched_token_definition() { }; crate::open_position::open_position( - owner_account(), - uninit_position_account(), - uninit_vault_account(), - user_holding_account(1_000), - other_definition, + open_position_accounts( + owner_account(), + uninit_position_account(), + uninit_vault_account(), + user_holding_account(1_000), + other_definition, + default_stability_fee_state_account(), + ), STABLECOIN_PROGRAM_ID, 500, ); @@ -416,16 +589,21 @@ fn open_position_rejects_definition_with_wrong_token_program() { definition.account.program_owner = [9u32; 8]; crate::open_position::open_position( - owner_account(), - uninit_position_account(), - uninit_vault_account(), - user_holding_account(1_000), - definition, + open_position_accounts( + owner_account(), + uninit_position_account(), + uninit_vault_account(), + user_holding_account(1_000), + definition, + default_stability_fee_state_account(), + ), STABLECOIN_PROGRAM_ID, 500, ); } +// --- PDA derivation ---------------------------------------------------------------------- + #[test] fn position_pda_is_deterministic_and_owner_and_collateral_specific() { let id_a = compute_position_pda( @@ -495,6 +673,7 @@ fn withdraw_collateral_updates_position_and_emits_transfer() { collateral_definition_id: collateral_definition_id(), collateral_amount: initial_collateral - amount, debt_amount: 0, + fee_accumulator: FEE_ACCUMULATOR_SCALE, } ); assert_eq!(position_post.account().program_owner, STABLECOIN_PROGRAM_ID); @@ -763,6 +942,7 @@ fn repay_debt_decreases_debt_and_emits_burn() { collateral_definition_id: collateral_definition_id(), collateral_amount: initial_collateral, debt_amount: initial_debt - amount, + fee_accumulator: FEE_ACCUMULATOR_SCALE, } ); assert_eq!(position_post.account().program_owner, STABLECOIN_PROGRAM_ID); diff --git a/programs/stablecoin/src/withdraw_collateral.rs b/programs/stablecoin/src/withdraw_collateral.rs index 259c663..62f9c5c 100644 --- a/programs/stablecoin/src/withdraw_collateral.rs +++ b/programs/stablecoin/src/withdraw_collateral.rs @@ -103,6 +103,7 @@ pub fn withdraw_collateral( collateral_definition_id: position_data.collateral_definition_id, collateral_amount: new_collateral, debt_amount: position_data.debt_amount, + fee_accumulator: position_data.fee_accumulator, }; let mut position_post = position.account.clone(); position_post.data = Data::from(&updated_position);