Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions programs/kamino-mocks/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ impl MinimalReserve {

/// Convert collateral tokens to equivalent liquidity tokens
/// * Returns liquidity tokens (uses `mint_decimals`)
/// * Uses floor rounding to match Kamino's actual CPI behavior
/// * Note: Less precise than Kamino's U256 math, so allow ±2 tolerance when validating
pub fn collateral_to_liquidity(&self, collateral: u64) -> Result<u64> {
let (total_liq, total_col) = self.scaled_supplies()?;

Expand All @@ -204,13 +206,16 @@ impl MinimalReserve {
.checked_div(total_col)
.ok_or_else(math_error!())?;

// Floor rounding (truncation) matches Kamino's mul-div pattern
liquidity
.checked_to_num::<u64>()
.ok_or(KaminoMocksError::MathError.into())
}

/// Convert liquidity tokens to equivalent value in collateral token.
/// * Returns collateral equivalent (in `mint_decimals`)
/// * Uses floor rounding to match Kamino's actual CPI behavior
/// * Note: Less precise than Kamino's U256 math, so allow ±2 tolerance when validating
pub fn liquidity_to_collateral(&self, liquidity: u64) -> Result<u64> {
let (total_liq, total_col) = self.scaled_supplies()?;

Expand All @@ -220,6 +225,7 @@ impl MinimalReserve {
.checked_div(total_liq)
.ok_or_else(math_error!())?;

// Floor rounding (truncation) matches Kamino's mul-div pattern
collateral
.checked_to_num::<u64>()
.ok_or(KaminoMocksError::MathError.into())
Expand Down
24 changes: 21 additions & 3 deletions programs/marginfi/src/instructions/kamino/deposit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::{
marginfi_group::MarginfiGroupImpl,
},
utils::is_kamino_asset_tag,
utils::{assert_within_one_token, validate_asset_tags, validate_bank_state, InstructionKind},
utils::{validate_asset_tags, validate_bank_state, InstructionKind},
MarginfiError, MarginfiResult,
};
use anchor_lang::prelude::*;
Expand Down Expand Up @@ -76,11 +76,29 @@ pub fn kamino_deposit(ctx: Context<KaminoDeposit>, amount: u64) -> MarginfiResul
// Verifying the deposit was successful by checking obligation balance increased by the correct amount
let obligation_collateral_change =
final_obligation_deposited_amount - initial_obligation_deposited_amount;
assert_within_one_token(

// VALIDATION: Allow ±2 token tolerance to account for precision differences
// between our I80F48 mock calculations and Kamino's U256-precise actual math.
// This is NOT a security issue - Kamino's CPI enforces proper rounding internally.
const PRECISION_TOLERANCE: u64 = 2;

let diff = obligation_collateral_change.abs_diff(expected_collateral_amount);
check!(
diff <= PRECISION_TOLERANCE,
MarginfiError::KaminoDepositFailed,
"Collateral change {} differs from expected {} by {} (max {})",
obligation_collateral_change,
expected_collateral_amount,
diff,
PRECISION_TOLERANCE
);

// Sanity check: ensure non-zero deposit succeeded
check!(
obligation_collateral_change > 0,
MarginfiError::KaminoDepositFailed,
)?;
"Deposit resulted in zero collateral shares"
);

{
let mut bank = ctx.accounts.bank.load_mut()?;
Expand Down
19 changes: 15 additions & 4 deletions programs/marginfi/src/instructions/kamino/withdraw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use crate::{
marginfi_group::MarginfiGroupImpl,
},
utils::{
assert_within_one_token, fetch_asset_price_for_bank, is_kamino_asset_tag,
fetch_asset_price_for_bank, is_kamino_asset_tag,
validate_bank_state, InstructionKind,
},
MarginfiError, MarginfiResult,
Expand Down Expand Up @@ -140,11 +140,22 @@ pub fn kamino_withdraw<'info>(
let post_transfer_vault_balance =
accessor::amount(&ctx.accounts.liquidity_vault.to_account_info())?;
let received = post_transfer_vault_balance - pre_transfer_vault_balance;
assert_within_one_token(

// VALIDATION: Allow ±2 token tolerance to account for precision differences
// between our I80F48 mock calculations and Kamino's U256-precise actual math.
// This is NOT a security issue - Kamino's CPI enforces proper rounding internally.
const PRECISION_TOLERANCE: u64 = 2;

let diff = received.abs_diff(expected_liquidity_amount);
check!(
diff <= PRECISION_TOLERANCE,
MarginfiError::KaminoWithdrawFailed,
"Received {} differs from expected {} by {} (max {})",
received,
expected_liquidity_amount,
MarginfiError::KaminoWithdrawFailed,
)?;
diff,
PRECISION_TOLERANCE
);

ctx.accounts
.cpi_transfer_obligation_owner_to_destination(received)?;
Expand Down
59 changes: 58 additions & 1 deletion programs/marginfi/tests/admin_actions/setup_bank.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use marginfi_type_crate::{
},
};
use pretty_assertions::assert_eq;
use solana_program_test::*;
use solana_program_test::{tokio, BanksClientError};
use solana_sdk::{clock::Clock, pubkey::Pubkey};
use test_case::test_case;

Expand Down Expand Up @@ -937,3 +937,60 @@ async fn configure_bank_limits_only_not_admin() -> anyhow::Result<()> {

Ok(())
}

#[tokio::test]
async fn configure_bank_oracle_min_age_validation() -> anyhow::Result<()> {
let test_f = TestFixture::new(Some(TestSettings::all_banks_payer_not_admin())).await;
let bank = test_f.get_bank(&BankMint::Usdc);

// Try to set oracle_max_age below ORACLE_MIN_AGE (10 seconds) - should fail
let res = test_f
.marginfi_group
.try_lending_pool_configure_bank(
&bank,
BankConfigOpt {
oracle_max_age: Some(9),
..Default::default()
},
)
.await;

assert!(res.is_err());
assert_custom_error!(res.unwrap_err(), MarginfiError::InvalidOracleSetup);

// Try to set oracle_max_age exactly at ORACLE_MIN_AGE (10 seconds) - should succeed
let res = test_f
.marginfi_group
.try_lending_pool_configure_bank(
&bank,
BankConfigOpt {
oracle_max_age: Some(10),
..Default::default()
},
)
.await;

assert!(res.is_ok());

let bank_after: Bank = test_f.load_and_deserialize(&bank.key).await;
assert_eq!(bank_after.config.oracle_max_age, 10);

// Try to set oracle_max_age above ORACLE_MIN_AGE - should succeed
let res = test_f
.marginfi_group
.try_lending_pool_configure_bank(
&bank,
BankConfigOpt {
oracle_max_age: Some(30),
..Default::default()
},
)
.await;

assert!(res.is_ok());

let bank_after: Bank = test_f.load_and_deserialize(&bank.key).await;
assert_eq!(bank_after.config.oracle_max_age, 30);

Ok(())
}
8 changes: 6 additions & 2 deletions tests/k16_kaminoConversionValidation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,9 @@ describe("k16: Kamino Conversion Validation", () => {
wrappedI80F48toBigNumber(balance0.assetShares).toString()
);

assertBNApproximately(user0InitialCTokens, expectedUsdcCTokens, new BN(1));
// Allow ±2 tolerance for precision differences between I80F48 mock math and Kamino's U256 math
// This accounts for legitimate precision differences, not security issues
assertBNApproximately(user0InitialCTokens, expectedUsdcCTokens, new BN(2));

user1DepositAmount = new BN(10 * 10 ** ecosystem.tokenADecimals);

Expand Down Expand Up @@ -307,10 +309,12 @@ describe("k16: Kamino Conversion Validation", () => {
user1InitialCTokens = new BN(
wrappedI80F48toBigNumber(balance1.assetShares).toString()
);
// Allow ±2 tolerance for precision differences between I80F48 mock math and Kamino's U256 math
// This accounts for legitimate precision differences, not security issues
assertBNApproximately(
user1InitialCTokens,
expectedTokenACTokens,
new BN(1)
new BN(2)
);
});

Expand Down
3 changes: 2 additions & 1 deletion type-crate/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ pub const SECONDS_PER_YEAR: I80F48 = I80F48!(31_536_000);

/// Due to real-world constraints, oracles using an age less than this value are typically too
/// unreliable, and we want to restrict pools from picking an oracle that is effectively unusable
pub const ORACLE_MIN_AGE: u16 = 30;
/// Switchboard oracles are cranked on demand, so we can use a lower value (10 seconds)
pub const ORACLE_MIN_AGE: u16 = 10;
pub const MAX_PYTH_ORACLE_AGE: u64 = 60;
pub const MAX_SWB_ORACLE_AGE: u64 = 3 * 60;

Expand Down