pyra-margin 0.4.2

Margin weight, balance, and price calculations for Drift spot positions
Documentation
use std::cmp;

use crate::error::{MathError, MathResult};
use crate::spend_limits::get_remaining_timeframe_limit;

/// USDC has 6 decimals: 1 USDC = 1_000_000 base units = 100 cents.
/// So 1 cent = 10_000 base units.
pub const USDC_BASE_UNITS_PER_CENT: u64 = 10_000;

/// Convert USDC base units to cents.
///
/// 1 USDC = 1_000_000 base units = 100 cents, so divide by 10_000.
pub fn usdc_base_units_to_cents(base_units: u64) -> MathResult<u64> {
    base_units
        .checked_div(USDC_BASE_UNITS_PER_CENT)
        .ok_or(MathError::Overflow)
}

/// Calculate spend limit in cents from vault state.
///
/// Returns `min(transaction_limit, timeframe_remaining, max_cap)` in cents.
///
/// `now_unix` is the current Unix timestamp in seconds (e.g. `Utc::now().timestamp() as u64`).
pub fn calculate_spend_limit_cents(
    vault: &pyra_types::Vault,
    max_transaction_limit_cents: u64,
    now_unix: u64,
) -> MathResult<u64> {
    let timeframe_base_units = get_remaining_timeframe_limit(vault, now_unix);
    let transaction_limit_cents = usdc_base_units_to_cents(vault.spend_limit_per_transaction)?;
    let timeframe_remaining_cents = usdc_base_units_to_cents(timeframe_base_units)?;

    let spend_limit_no_cap = cmp::min(transaction_limit_cents, timeframe_remaining_cents);
    Ok(cmp::min(spend_limit_no_cap, max_transaction_limit_cents))
}

#[cfg(test)]
#[allow(
    clippy::allow_attributes,
    clippy::allow_attributes_without_reason,
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::panic,
    clippy::arithmetic_side_effects,
    reason = "test code"
)]
mod tests {
    use super::*;
    use pyra_types::Vault;

    fn make_vault(
        spend_limit_per_transaction: u64,
        spend_limit_per_timeframe: u64,
        remaining_spend_limit_per_timeframe: u64,
        next_timeframe_reset_timestamp: u64,
        timeframe_in_seconds: u64,
    ) -> Vault {
        Vault {
            owner: vec![0; 32],
            bump: 0,
            spend_limit_per_transaction,
            spend_limit_per_timeframe,
            remaining_spend_limit_per_timeframe,
            next_timeframe_reset_timestamp,
            timeframe_in_seconds,
        }
    }

    // --- usdc_base_units_to_cents ---

    #[test]
    fn cents_basic() {
        assert_eq!(usdc_base_units_to_cents(1_000_000).unwrap(), 100); // 1 USDC = 100 cents
    }

    #[test]
    fn cents_zero() {
        assert_eq!(usdc_base_units_to_cents(0).unwrap(), 0);
    }

    #[test]
    fn cents_sub_cent_truncates() {
        assert_eq!(usdc_base_units_to_cents(9_999).unwrap(), 0); // < 1 cent
        assert_eq!(usdc_base_units_to_cents(10_000).unwrap(), 1); // exactly 1 cent
    }

    // --- calculate_spend_limit_cents ---

    const NOW: u64 = 1_700_000_000;

    #[test]
    fn spend_limit_basic() {
        let vault = make_vault(
            10_000_000, // 10 USDC per tx
            50_000_000, // 50 USDC per timeframe
            30_000_000, // 30 USDC remaining
            NOW + 3600, // active timeframe
            86_400,
        );
        let limit = calculate_spend_limit_cents(&vault, 10_000, NOW).unwrap();
        assert_eq!(limit, 1000);
    }

    #[test]
    fn spend_limit_expired_timeframe_uses_full() {
        let vault = make_vault(
            100_000_000, // 100 USDC per tx
            50_000_000,  // 50 USDC per timeframe
            10_000_000,  // 10 USDC remaining (ignored because expired)
            NOW - 100,   // expired
            86_400,
        );
        let limit = calculate_spend_limit_cents(&vault, 100_000, NOW).unwrap();
        assert_eq!(limit, 5000);
    }

    #[test]
    fn spend_limit_capped_by_max() {
        let vault = make_vault(
            1_000_000_000, // 1000 USDC per tx
            1_000_000_000, // 1000 USDC per timeframe
            1_000_000_000, // 1000 USDC remaining
            NOW + 3600,
            86_400,
        );
        let limit = calculate_spend_limit_cents(&vault, 500, NOW).unwrap();
        assert_eq!(limit, 500);
    }

    #[test]
    fn spend_limit_zero_timeframe() {
        let vault = make_vault(10_000_000, 50_000_000, 30_000_000, NOW + 3600, 0);
        let limit = calculate_spend_limit_cents(&vault, 10_000, NOW).unwrap();
        assert_eq!(limit, 0);
    }
}

#[cfg(test)]
#[allow(
    clippy::allow_attributes,
    clippy::allow_attributes_without_reason,
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::panic,
    clippy::arithmetic_side_effects,
    reason = "test code"
)]
mod proptests {
    use super::*;
    use proptest::prelude::*;

    proptest! {
        #[test]
        fn usdc_cents_never_exceeds_base_units(base_units in 0u64..=u64::MAX) {
            let cents = usdc_base_units_to_cents(base_units).unwrap();
            prop_assert!(cents <= base_units, "cents {} > base_units {}", cents, base_units);
        }
    }
}