diff --git a/.gitignore b/.gitignore index cd3e44e8..e651b7de 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,10 @@ # documentation site _site/ + +# fuzz artifacts +**/artifacts +**/corpus +**/fuzz*.log +**/coverage .aider* diff --git a/Cargo.lock b/Cargo.lock index e9cb6aea..7087bb0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2251,6 +2251,16 @@ version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[package]] +name = "libfuzzer-sys" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libm" version = "0.2.15" @@ -4711,6 +4721,18 @@ dependencies = [ "thiserror 2.0.11", ] +[[package]] +name = "templar-fuzz" +version = "1.2.0" +dependencies = [ + "arbitrary", + "hex-literal", + "libfuzzer-sys", + "near-sdk", + "primitive-types 0.10.1", + "templar-common", +] + [[package]] name = "templar-liquidator" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index ef0b5164..0436269a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,13 @@ [workspace] resolver = "2" members = [ - "common", - "contract/*", - "mock/*", - "service/*", - "test-utils", - "universal-account", + "common", + "contract/*", + "fuzz", + "mock/*", + "service/*", + "test-utils", + "universal-account", ] [workspace.package] @@ -17,6 +18,7 @@ version = "1.2.0" [workspace.dependencies] anyhow = "1.0.95" +arbitrary = { version = "1", features = ["derive"] } async-trait = "0.1.88" base64 = "0.22.1" borsh = { version = "1.5", features = ["unstable__schema"] } @@ -26,6 +28,7 @@ getrandom = { version = "0.2", features = ["custom"] } hex = { version = "0.4.3", features = ["serde"] } hex-literal = "0.4" itertools = "0.14.0" +libfuzzer-sys = "0.4" near-account-id = "1.1.4" near-chain-configs = "0.31.1" near-contract-standards = "5.17.2" diff --git a/common/Cargo.toml b/common/Cargo.toml index 8c1bc16c..de85353f 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -15,7 +15,7 @@ primitive-types.workspace = true schemars.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -near-primitives.workspace = true +near-primitives = { workspace = true, optional = true } [dev-dependencies] rstest.workspace = true @@ -24,3 +24,8 @@ rand = "0.8" [lints] workspace = true + +[features] +default = [] +rpc = ["dep:near-primitives"] +non-contract-usage = ["near-sdk/non-contract-usage"] diff --git a/common/src/asset.rs b/common/src/asset.rs index 0511f552..1e003e31 100644 --- a/common/src/asset.rs +++ b/common/src/asset.rs @@ -4,7 +4,7 @@ use std::{ }; use near_contract_standards::fungible_token::core::ext_ft_core; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(not(target_arch = "wasm32"), feature = "rpc"))] use near_primitives::action::FunctionCallAction; use near_sdk::{ env, @@ -96,6 +96,7 @@ impl FungibleAsset { } } + #[cfg(all(not(target_arch = "wasm32"), feature = "rpc"))] pub fn transfer_call_method_name(&self) -> &str { match self.kind { FungibleAssetKind::Nep141(_) => "ft_transfer_call", @@ -135,7 +136,7 @@ impl FungibleAsset { } /// Creates a simple `ft_transfer` action (no callback). - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(not(target_arch = "wasm32"), feature = "rpc"))] pub fn transfer_action( &self, receiver_id: &AccountId, @@ -170,7 +171,7 @@ impl FungibleAsset { } } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(not(target_arch = "wasm32"), feature = "rpc"))] pub fn transfer_call_action( &self, receiver_id: &AccountId, @@ -209,7 +210,7 @@ impl FungibleAsset { } } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(not(target_arch = "wasm32"), feature = "rpc"))] pub fn balance_of_action(&self, account_id: &AccountId) -> FunctionCallAction { let (method_name, args) = match self.kind { FungibleAssetKind::Nep141(_) => ( diff --git a/common/src/borrow.rs b/common/src/borrow.rs index 852f3f26..48b2ca2b 100644 --- a/common/src/borrow.rs +++ b/common/src/borrow.rs @@ -60,15 +60,15 @@ pub enum LiquidationReason { pub struct BorrowPosition { pub started_at_block_timestamp_ms: Option, pub collateral_asset_deposit: CollateralAssetAmount, - borrow_asset_principal: BorrowAssetAmount, + pub borrow_asset_principal: BorrowAssetAmount, #[serde(alias = "borrow_asset_fees")] pub interest: Accumulator, #[serde(default)] pub fees: BorrowAssetAmount, #[serde(default)] - borrow_asset_in_flight: BorrowAssetAmount, + pub borrow_asset_in_flight: BorrowAssetAmount, #[serde(default)] - collateral_asset_in_flight: CollateralAssetAmount, + pub collateral_asset_in_flight: CollateralAssetAmount, #[serde(default)] pub liquidation_lock: CollateralAssetAmount, } diff --git a/common/src/market/mod.rs b/common/src/market/mod.rs index a536990e..22f73649 100644 --- a/common/src/market/mod.rs +++ b/common/src/market/mod.rs @@ -8,7 +8,7 @@ use crate::{ number::Decimal, }; mod configuration; -pub use configuration::{MarketConfiguration, APY_LIMIT}; +pub use configuration::{MarketConfiguration, ValidAmountRange, APY_LIMIT}; mod external; pub use external::*; mod r#impl; diff --git a/common/src/oracle/price_transformer.rs b/common/src/oracle/price_transformer.rs index a7737571..eae6c32e 100644 --- a/common/src/oracle/price_transformer.rs +++ b/common/src/oracle/price_transformer.rs @@ -40,7 +40,7 @@ pub struct Call { } impl Call { - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(not(target_arch = "wasm32"), feature = "rpc"))] #[allow(clippy::unwrap_used)] pub fn new( account_id: &near_sdk::AccountIdRef, @@ -56,7 +56,7 @@ impl Call { } } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(not(target_arch = "wasm32"), feature = "rpc"))] pub fn new_simple(account_id: &near_sdk::AccountIdRef, method_name: impl Into) -> Self { Self::new( account_id, @@ -75,7 +75,7 @@ impl Call { ) } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(not(target_arch = "wasm32"), feature = "rpc"))] pub fn rpc_call(&self) -> near_primitives::views::QueryRequest { near_primitives::views::QueryRequest::CallFunction { account_id: self.account_id.clone(), diff --git a/contract/lst-oracle/src/lib.rs b/contract/lst-oracle/src/lib.rs index 2c9fcf81..fcf06304 100644 --- a/contract/lst-oracle/src/lib.rs +++ b/contract/lst-oracle/src/lib.rs @@ -73,7 +73,7 @@ impl Contract { .insert(&price_identifier, &entry) .is_some() { - env::panic_str("Price identifier collision"); + templar_common::panic_with_message("Price identifier collision"); } } @@ -147,10 +147,11 @@ impl Contract { ) -> OracleResponse { fn callback_result(index: u64) -> T { match env::promise_result(index) { - PromiseResult::Successful(vec) => { - serde_json::from_slice(&vec).unwrap_or_else(|e| env::panic_str(&e.to_string())) + PromiseResult::Successful(vec) => serde_json::from_slice(&vec) + .unwrap_or_else(|e| templar_common::panic_with_message(&e.to_string())), + PromiseResult::Failed => { + templar_common::panic_with_message(&format!("Promise index {index} failed")) } - PromiseResult::Failed => env::panic_str(&format!("Promise index {index} failed")), } } @@ -163,12 +164,12 @@ impl Contract { result.insert(price_id, price.clone()); } else { let Some(entry) = self.transformers.get(&price_id) else { - env::panic_str(&format!( + templar_common::panic_with_message(&format!( "No transformer associated with price ID: {price_id}", )); }; let Some(price) = oracle_result.get(&entry.price_id) else { - env::panic_str(&format!( + templar_common::panic_with_message(&format!( "Mapped price ID is not in oracle result: {price_id}", )); }; diff --git a/contract/market/src/impl_helper.rs b/contract/market/src/impl_helper.rs index d26e415d..d999a038 100644 --- a/contract/market/src/impl_helper.rs +++ b/contract/market/src/impl_helper.rs @@ -20,7 +20,7 @@ impl Contract { self.configuration .price_oracle_configuration .create_price_pair(&oracle_response) - .unwrap_or_else(|e| env::panic_str(&e.to_string())) + .unwrap_or_else(|e| templar_common::panic_with_message(&e.to_string())) } pub fn execute_supply(&mut self, account_id: AccountId, amount: BorrowAssetAmount) { @@ -64,7 +64,7 @@ impl Contract { let snapshot = self.snapshot(); let mut borrow_position = self.get_or_create_borrow_position_guard(snapshot, account_id); if !borrow_position.inner().liquidation_lock.is_zero() { - env::panic_str("Cannot add collateral while liquidation locked"); + templar_common::panic_with_message("Cannot add collateral while liquidation locked"); } let proof = borrow_position.accumulate_interest(); borrow_position.record_collateral_asset_deposit(proof, amount); @@ -98,7 +98,7 @@ impl Contract { // Returns the amount that should be returned to the borrower. borrow_position .record_repay(proof, amount) - .unwrap_or_else(|e| env::panic_str(&e.to_string())) + .unwrap_or_else(|e| templar_common::panic_with_message(&e.to_string())) } } @@ -122,7 +122,9 @@ impl Contract { let Some(mut borrow_position) = self.borrow_position_guard(snapshot, account_id.clone()) else { - env::panic_str("No borrower record. Please deposit collateral first."); + templar_common::panic_with_message( + "No borrower record. Please deposit collateral first.", + ); }; let interest = borrow_position.accumulate_interest(); @@ -135,7 +137,7 @@ impl Contract { &price_pair, env::block_timestamp_ms(), ) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); + .unwrap_or_else(|e| templar_common::panic_with_message(&e.to_string())); drop(borrow_position); @@ -155,7 +157,9 @@ impl Contract { pub fn borrow_02_finalize(&mut self, account_id: AccountId, initial_borrow: InitialBorrow) { let snapshot = self.snapshot(); let Some(mut borrow_position) = self.borrow_position_guard(snapshot, account_id) else { - env::panic_str("Invariant violation: borrow position does not exist after transfer."); + templar_common::panic_with_message( + "Invariant violation: borrow position does not exist after transfer.", + ); }; let proof = borrow_position.accumulate_interest(); @@ -251,13 +255,15 @@ impl Contract { .configuration .price_oracle_configuration .create_price_pair(&oracle_response) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); + .unwrap_or_else(|e| templar_common::panic_with_message(&e.to_string())); let result = { let snapshot = self.snapshot(); let mut borrow_position = self .borrow_position_guard(snapshot, msg.account_id.clone()) - .unwrap_or_else(|| env::panic_str("Borrow position does not exist")); + .unwrap_or_else(|| { + templar_common::panic_with_message("Borrow position does not exist") + }); let proof = borrow_position.accumulate_interest(); @@ -269,7 +275,7 @@ impl Contract { &price_pair, env::block_timestamp_ms(), ) - .unwrap_or_else(|e| env::panic_str(&e.to_string())) + .unwrap_or_else(|e| templar_common::panic_with_message(&e.to_string())) }; self.configuration @@ -316,7 +322,9 @@ impl Contract { let mut borrow_position = self .borrow_position_guard(snapshot, account_id) .unwrap_or_else(|| { - env::panic_str("Invariant violation: Liquidation of nonexistent position.") + templar_common::panic_with_message( + "Invariant violation: Liquidation of nonexistent position.", + ) }); let proof = borrow_position.accumulate_interest(); @@ -341,7 +349,9 @@ impl Contract { let snapshot = self.snapshot(); let Some(mut borrow_position) = self.borrow_position_guard(snapshot, account_id.clone()) else { - env::panic_str("No borrower record. Please deposit collateral first."); + templar_common::panic_with_message( + "No borrower record. Please deposit collateral first.", + ); }; let proof = borrow_position.accumulate_interest(); @@ -378,7 +388,7 @@ impl Contract { let snapshot = self.snapshot(); let Some(mut position) = self.borrow_position_guard(snapshot, account_id.clone()) else { - env::panic_str( + templar_common::panic_with_message( "Invariant violation: Borrow position must exist after collateral withdrawal.", ); }; @@ -405,7 +415,9 @@ impl Contract { ) { if matches!(env::promise_result(0), PromiseResult::Failed) { let mut yield_record = self.static_yield.get(&account_id).unwrap_or_else(|| { - env::panic_str("Invariant violation: static yield entry must exist during callback") + templar_common::panic_with_message( + "Invariant violation: static yield entry must exist during callback", + ) }); yield_record.add_once(amount); self.static_yield.insert(&account_id, &yield_record); diff --git a/contract/market/src/impl_market_external.rs b/contract/market/src/impl_market_external.rs index 518bb960..f46f2d0f 100644 --- a/contract/market/src/impl_market_external.rs +++ b/contract/market/src/impl_market_external.rs @@ -83,7 +83,7 @@ impl MarketExternalInterface for Contract { .configuration .price_oracle_configuration .create_price_pair(&oracle_response) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); + .unwrap_or_else(|e| templar_common::panic_with_message(&e.to_string())); Some(borrow_position.status(&price_pair, env::block_timestamp_ms())) } @@ -111,7 +111,9 @@ impl MarketExternalInterface for Contract { let snapshot = self.snapshot(); let Some(mut borrow_position) = self.borrow_position_guard(snapshot, account_id.clone()) else { - env::panic_str("No borrower record. Please deposit collateral first."); + templar_common::panic_with_message( + "No borrower record. Please deposit collateral first.", + ); }; if borrow_position @@ -170,7 +172,7 @@ impl MarketExternalInterface for Contract { .supply_position_ref(predecessor.clone()) .filter(|supply_position| !supply_position.total_deposit().is_zero()) else { - env::panic_str("Supply position does not exist"); + templar_common::panic_with_message("Supply position does not exist"); }; // We do check here, as well as during the execution. @@ -351,13 +353,15 @@ impl MarketExternalInterface for Contract { &account_id.unwrap_or_else(env::predecessor_account_id), snapshot_limit.unwrap_or(u32::MAX), ) - .unwrap_or_else(|_| env::panic_str("This account does not earn static yield")); + .unwrap_or_else(|_| { + templar_common::panic_with_message("This account does not earn static yield") + }); } fn withdraw_static_yield(&mut self, amount: Option) -> Promise { let predecessor = env::predecessor_account_id(); let Some(mut yield_record) = self.static_yield.get(&predecessor) else { - env::panic_str("Yield record does not exist"); + templar_common::panic_with_message("Yield record does not exist"); }; let amount = amount.unwrap_or_else(|| yield_record.get_total()); diff --git a/contract/market/src/impl_token_receiver.rs b/contract/market/src/impl_token_receiver.rs index e86349c3..caa2f2a6 100644 --- a/contract/market/src/impl_token_receiver.rs +++ b/contract/market/src/impl_token_receiver.rs @@ -21,13 +21,13 @@ impl FungibleTokenReceiver for Contract { const RETURN_STYLE: ReturnStyle = ReturnStyle::Nep141FtTransferCall; let msg = near_sdk::serde_json::from_str::(&msg) - .unwrap_or_else(|_| env::panic_str("Invalid deposit msg")); + .unwrap_or_else(|_| templar_common::panic_with_message("Invalid deposit msg")); let asset_id = env::predecessor_account_id(); let use_borrow_asset = || { if !self.configuration.borrow_asset.is_nep141(&asset_id) { - env::panic_str("Unsupported borrow asset"); + templar_common::panic_with_message("Unsupported borrow asset"); } BorrowAssetAmount::new(amount.0) @@ -35,7 +35,7 @@ impl FungibleTokenReceiver for Contract { let use_collateral_asset = || { if !self.configuration.collateral_asset.is_nep141(&asset_id) { - env::panic_str("Unsupported collateral asset"); + templar_common::panic_with_message("Unsupported collateral asset"); } CollateralAssetAmount::new(amount.0) @@ -122,7 +122,7 @@ impl Nep245Receiver for Contract { let _ = sender_id; let msg = near_sdk::serde_json::from_str::(&msg) - .unwrap_or_else(|_| env::panic_str("Invalid deposit msg")); + .unwrap_or_else(|_| templar_common::panic_with_message("Invalid deposit msg")); let contract_id = env::predecessor_account_id(); @@ -145,7 +145,7 @@ impl Nep245Receiver for Contract { .borrow_asset .is_nep245(&contract_id, token_id) { - env::panic_str("Unsupported borrow asset"); + templar_common::panic_with_message("Unsupported borrow asset"); } BorrowAssetAmount::new(amount.0) @@ -157,7 +157,7 @@ impl Nep245Receiver for Contract { .collateral_asset .is_nep245(&contract_id, token_id) { - env::panic_str("Unsupported collateral asset"); + templar_common::panic_with_message("Unsupported collateral asset"); } CollateralAssetAmount::new(amount.0) diff --git a/contract/market/src/lib.rs b/contract/market/src/lib.rs index 1474fdc0..9828e0ce 100644 --- a/contract/market/src/lib.rs +++ b/contract/market/src/lib.rs @@ -78,7 +78,7 @@ impl Contract { account_id, env::storage_byte_cost().saturating_mul(u128::from(storage_consumption)), ) - .unwrap_or_else(|e| env::panic_str(&format!("Storage error: {e}"))); + .unwrap_or_else(|e| templar_common::panic_with_message(&format!("Storage error: {e}"))); } fn refund_for_storage(&mut self, account_id: &AccountId, storage_consumption: u64) { @@ -86,13 +86,13 @@ impl Contract { account_id, env::storage_byte_cost().saturating_mul(u128::from(storage_consumption)), ) - .unwrap_or_else(|e| env::panic_str(&format!("Storage error: {e}"))); + .unwrap_or_else(|e| templar_common::panic_with_message(&format!("Storage error: {e}"))); } } impl near_sdk_contract_tools::hook::Hook> for Contract { fn hook(_: &mut Self, _: &Nep145ForceUnregister, _: impl FnOnce(&mut Self) -> R) -> R { - env::panic_str("force unregistration is not supported") + templar_common::panic_with_message("force unregistration is not supported") } } diff --git a/contract/registry/src/lib.rs b/contract/registry/src/lib.rs index 256b0333..e7057ce9 100644 --- a/contract/registry/src/lib.rs +++ b/contract/registry/src/lib.rs @@ -127,7 +127,9 @@ impl Contract { let dummy_id: AccountId = format!("deploy.{}", env::current_account_id()) .parse() .unwrap_or_else(|_| { - env::panic_str("Failed to construct deployment account ID.") + templar_common::panic_with_message( + "Failed to construct deployment account ID.", + ) }); PromiseOrValue::Promise( Promise::new(dummy_id) @@ -163,7 +165,9 @@ impl Contract { VersionEntry::Code { code, .. } => { *code = None; } - VersionEntry::GlobalHash(_) => env::panic_str("Global contract cannot be removed"), + VersionEntry::GlobalHash(_) => { + templar_common::panic_with_message("Global contract cannot be removed") + } }); } @@ -179,7 +183,7 @@ impl Contract { self.assert_owner(); let Some(version) = self.versions.get(&version_key) else { - env::panic_str("Version key does not exist"); + templar_common::panic_with_message("Version key does not exist"); }; let attached_deposit = env::attached_deposit(); @@ -187,9 +191,9 @@ impl Contract { let current_account_id = env::current_account_id(); let market_id = format!("{name}.{current_account_id}"); - let market_id: AccountId = market_id - .parse() - .unwrap_or_else(|_| env::panic_str("New market ID is not a valid account ID")); + let market_id: AccountId = market_id.parse().unwrap_or_else(|_| { + templar_common::panic_with_message("New market ID is not a valid account ID") + }); require!( market_id.is_sub_account_of(¤t_account_id), @@ -212,9 +216,9 @@ impl Contract { match version { VersionEntry::Code { code, .. } => { - let code = code - .as_ref() - .unwrap_or_else(|| env::panic_str("Version code has been deleted")); + let code = code.as_ref().unwrap_or_else(|| { + templar_common::panic_with_message("Version code has been deleted") + }); let minimum_deposit = env::storage_byte_cost().saturating_mul(code.len() as u128); @@ -283,7 +287,7 @@ impl Contract { #[private] pub fn fail(&self, message: String) { - env::panic_str(&message); + templar_common::panic_with_message(&message); } } diff --git a/contract/universal-account/src/lib.rs b/contract/universal-account/src/lib.rs index be448757..37ffd0e9 100644 --- a/contract/universal-account/src/lib.rs +++ b/contract/universal-account/src/lib.rs @@ -72,7 +72,7 @@ impl Contract { pub fn execute(&mut self, args: ExecuteArgs) -> Promise { let ExecuteArgs::Passkey { key, message } = args; let Some(key_entry) = self.keys.get_mut(&KeyId::Passkey(key.clone())) else { - env::panic_str("Key does not exist") + templar_common::panic_with_message("Key does not exist") }; *key_entry = key_entry.next(); @@ -80,10 +80,10 @@ impl Contract { let message = key .verify(message) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); + .unwrap_or_else(|e| templar_common::panic_with_message(&e.to_string())); let transactions = message .verify(¤t_account_id, key_entry, |_| true) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); + .unwrap_or_else(|e| templar_common::panic_with_message(&e.to_string())); require!(!transactions.is_empty(), "Transaction list is empty"); diff --git a/contract/vault/src/governance.rs b/contract/vault/src/governance.rs index 1ab90184..6dfe71c6 100644 --- a/contract/vault/src/governance.rs +++ b/contract/vault/src/governance.rs @@ -196,7 +196,7 @@ impl Contract { .emit(); self.pending_timelock = None; } else { - env::panic_str("No pending timelock change"); + templar_common::panic_with_message("No pending timelock change"); } } @@ -226,7 +226,7 @@ impl Contract { .emit(); self.markets .get_mut(&market) - .unwrap_or_else(|| env::panic_str("Config not found")) + .unwrap_or_else(|| templar_common::panic_with_message("Config not found")) } Some(m) => m, }; @@ -274,12 +274,12 @@ impl Contract { let m = self .markets .get_mut(&market) - .unwrap_or_else(|| env::panic_str("Config not found")); + .unwrap_or_else(|| templar_common::panic_with_message("Config not found")); let was_enabled = m.cfg.enabled; let pending_value = m.pending_cap.as_ref().map_or_else( - || env::panic_str("No pending cap change for this market"), + || templar_common::panic_with_message("No pending cap change for this market"), |pending_cap| { pending_cap.verify(); pending_cap.value @@ -309,7 +309,7 @@ impl Contract { self.markets .get_mut(&market) - .unwrap_or_else(|| env::panic_str("Config not found")) + .unwrap_or_else(|| templar_common::panic_with_message("Config not found")) .pending_cap = None; } @@ -335,10 +335,9 @@ impl Contract { /// Requires cap == 0 and no pending cap changes; starts a timelock. pub fn submit_market_removal(&mut self, market: AccountId) { Self::assert_curator_or_owner(); - let rec = self - .markets - .get_mut(&market) - .unwrap_or_else(|| env::panic_str(&format!("Unknown market: {market}"))); + let rec = self.markets.get_mut(&market).unwrap_or_else(|| { + templar_common::panic_with_message(&format!("Unknown market: {market}")) + }); require!( rec.cfg.removable_at == 0, "Removal already pending for this market" @@ -381,7 +380,7 @@ impl Contract { let mut seen = HashSet::new(); for m in &markets { if !seen.insert(m.clone()) { - env::panic_str(&format!("Duplicate market {m}")); + templar_common::panic_with_message(&format!("Duplicate market {m}")); } } // Validate all markets are authorized (cap > 0) before charging storage diff --git a/contract/vault/src/impl_callbacks.rs b/contract/vault/src/impl_callbacks.rs index 03f4be85..4de8bdff 100644 --- a/contract/vault/src/impl_callbacks.rs +++ b/contract/vault/src/impl_callbacks.rs @@ -405,7 +405,9 @@ impl Contract { &ctx.owner, )) .unwrap_or_else(|e| { - env::panic_str(&format!("Failed to refund escrowed shares {e}")) + templar_common::panic_with_message(&format!( + "Failed to refund escrowed shares {e}" + )) }); self_.pending_market_exec.clear(); self_.remove_inflight_and_advance_head(); diff --git a/contract/vault/src/impl_token_receiver.rs b/contract/vault/src/impl_token_receiver.rs index 7ab74a49..3e466a0a 100644 --- a/contract/vault/src/impl_token_receiver.rs +++ b/contract/vault/src/impl_token_receiver.rs @@ -11,7 +11,8 @@ use near_sdk_contract_tools::mt::*; // Parses JSON-encoded DepositMsg or panics with a consistent message. fn parse_deposit_msg(msg: &str) -> DepositMsg { - near_sdk::serde_json::from_str(msg).unwrap_or_else(|_| env::panic_str("Invalid deposit msg")) + near_sdk::serde_json::from_str(msg) + .unwrap_or_else(|_| templar_common::panic_with_message("Invalid deposit msg")) } // Validates NEP-245 transfer inputs and returns (depositor, token_id, amount). @@ -111,7 +112,7 @@ impl Contract { require!(deposit > 0, "Deposit amount must be greater than zero"); if matches!(self.op_state, OpState::Payout(_)) { - env::panic_str("Cannot deposit during payout"); + templar_common::panic_with_message("Cannot deposit during payout"); } self.internal_accrue_fee(); @@ -122,7 +123,7 @@ impl Contract { let shares = self.preview_deposit(U128(accept)).0; self.mint(&Nep141Mint::new(shares, &sender_id)) - .unwrap_or_else(|_| env::panic_str("Failed to mint shares")); + .unwrap_or_else(|_| templar_common::panic_with_message("Failed to mint shares")); Event::MintedShares { amount: shares.into(), diff --git a/contract/vault/src/lib.rs b/contract/vault/src/lib.rs index 22285ce5..2d564a45 100644 --- a/contract/vault/src/lib.rs +++ b/contract/vault/src/lib.rs @@ -273,7 +273,7 @@ impl Contract { &sender, env::current_account_id(), )) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); + .unwrap_or_else(|e| templar_common::panic_with_message(&e.to_string())); self.internal_accrue_fee(); @@ -295,14 +295,13 @@ impl Contract { Self::assert_allocator(); if self.current_withdraw_inflight.is_some() { - env::panic_str("A pending withdrawal is already in-flight"); + templar_common::panic_with_message("A pending withdrawal is already in-flight"); } if let Some(id) = self.peek_next_pending_withdrawal_id() { - let pending = self - .pending_withdrawals - .get(&id) - .unwrap_or_else(|| env::panic_str("pending vanished unexpectedly")); + let pending = self.pending_withdrawals.get(&id).unwrap_or_else(|| { + templar_common::panic_with_message("pending vanished unexpectedly") + }); let owner = pending.owner.clone(); let receiver = pending.receiver.clone(); @@ -343,7 +342,7 @@ impl Contract { }; let Some(market_index) = self.pending_market_exec.first().copied() else { - env::panic_str("No pending market withdrawal request to execute"); + templar_common::panic_with_message("No pending market withdrawal request to execute"); }; if let Err(e) = self.resolve_withdraw_market(market_index) { @@ -435,10 +434,10 @@ impl Contract { let sum_weights: u128 = weights.values().sum(); if sum_weights == 0 { - env::panic_str("Sum of weights is zero"); + templar_common::panic_with_message("Sum of weights is zero"); } if total == 0 { - env::panic_str("No funds to allocate"); + templar_common::panic_with_message("No funds to allocate"); } let op_id = self.next_op_id; @@ -501,9 +500,9 @@ impl Contract { pub fn get_configuration(&self) -> VaultConfiguration { let meta = self.get_metadata(); VaultConfiguration { - owner: self - .own_get_owner() - .unwrap_or_else(|| env::panic_str("Owner not set in get_configuration")), + owner: self.own_get_owner().unwrap_or_else(|| { + templar_common::panic_with_message("Owner not set in get_configuration") + }), curator: Self::with_members_of(&Role::Curator, |members| { require!( members.len() == 1, @@ -807,7 +806,7 @@ impl Contract { fn ensure_idle(&self) { // Invariant: Only one op in flight; ensure_idle() guards all mutating ops. if !matches!(self.op_state, OpState::Idle) { - env::panic_str(&format!( + templar_common::panic_with_message(&format!( "Invariant: Only one op in flight; current op_state = {:?}", self.op_state )); @@ -860,7 +859,7 @@ impl Contract { Some( #[allow(clippy::expect_used, reason = "Infallible")] serde_json::to_string(&templar_common::market::DepositMsg::Supply) - .unwrap_or_else(|e| env::panic_str(&e.to_string())) + .unwrap_or_else(|e| templar_common::panic_with_message(&e.to_string())) .as_str(), ), ) @@ -1188,7 +1187,7 @@ impl Contract { impl near_sdk_contract_tools::hook::Hook> for Contract { fn hook(_: &mut Self, _: &Nep145ForceUnregister, _: impl FnOnce(&mut Self) -> R) -> R { // Invariant: Force unregister must fail to preserve FT ledger integrity. - env::panic_str("force unregistration is not supported") + templar_common::panic_with_message("force unregistration is not supported") } } diff --git a/contract/vault/src/tests.rs b/contract/vault/src/tests.rs index 11b5234b..a0a65d66 100644 --- a/contract/vault/src/tests.rs +++ b/contract/vault/src/tests.rs @@ -47,7 +47,7 @@ fn c_owner_env(vault_id_fixture: AccountId) -> Contract { let c = new_test_contract(&vault_id_fixture); let owner = c .own_get_owner() - .unwrap_or_else(|| env::panic_str("Owner not set")); + .unwrap_or_else(|| templar_common::panic_with_message("Owner not set")); setup_env(&vault_id_fixture, &owner, vec![]); c } @@ -90,7 +90,7 @@ fn c_max(vault_id: AccountId) -> Contract { &vault_id, vec![PromiseResult::Successful( near_sdk::serde_json::to_vec(&U128(u128::MAX)) - .unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())), + .unwrap_or_else(|e| templar_common::panic_with_message(&e.to_string())), )], ); new_test_contract(&vault_id) @@ -136,7 +136,7 @@ fn fee_accrues_only_on_growth_unit(c_vault_env: Contract) { // Seed total supply so fees can mint let user = accounts(1); c.deposit_unchecked(&user, 1_000) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); + .unwrap_or_else(|e| templar_common::panic_with_message(&e.to_string())); c.idle_balance = 1_000; // Set fee to 10% @@ -173,7 +173,7 @@ fn payout_success_burns_only_proportional_escrow_and_refunds_remainder(c_vault_e // Seed escrow into vault account (shares held by vault) c.deposit_unchecked(&near_sdk::env::current_account_id(), 100) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); + .unwrap_or_else(|e| templar_common::panic_with_message(&e.to_string())); // Seed idle to cover payout c.idle_balance = 1_000; @@ -301,7 +301,7 @@ fn queue_allocation_ignores_stale_plan() { setup_env( &vault_id, &c.own_get_owner() - .unwrap_or_else(|| env::panic_str("Owner not set")), + .unwrap_or_else(|| templar_common::panic_with_message("Owner not set")), vec![], ); @@ -612,7 +612,7 @@ fn set_fee_recipient_accrues_before_switch() { // Seed supply so fee shares can mint c.deposit_unchecked(&accounts(1), 1_000) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); + .unwrap_or_else(|e| templar_common::panic_with_message(&e.to_string())); // Simulate profit: last=1000, current=1500 c.idle_balance = 1_500; c.last_total_assets = 1_000; @@ -663,7 +663,7 @@ fn set_fee_recipient_accrues_before_switch_variant() { // Seed supply so fee shares can mint c.deposit_unchecked(&accounts(2), 2_000) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); + .unwrap_or_else(|e| templar_common::panic_with_message(&e.to_string())); // Simulate profit: last=2000, current=2400 c.idle_balance = 2_400; c.last_total_assets = 2_000; @@ -711,12 +711,12 @@ fn set_performance_fee_accrues_with_old_rate_then_updates() { let mut c = new_test_contract(&vault_id); let owner = c .own_get_owner() - .unwrap_or_else(|| env::panic_str("Owner not set")); + .unwrap_or_else(|| templar_common::panic_with_message("Owner not set")); setup_env(&vault_id, &owner, vec![]); // Seed supply so fee shares can mint c.deposit_unchecked(&accounts(1), 1_000) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); + .unwrap_or_else(|e| templar_common::panic_with_message(&e.to_string())); // Simulate profit: last=1000, current=1500 c.idle_balance = 1_500; c.last_total_assets = 1_000; @@ -764,12 +764,12 @@ fn set_performance_fee_accrues_with_old_rate_then_updates_variant() { let mut c = new_test_contract(&vault_id); let owner = c .own_get_owner() - .unwrap_or_else(|| env::panic_str("Owner not set")); + .unwrap_or_else(|| templar_common::panic_with_message("Owner not set")); setup_env(&vault_id, &owner, vec![]); // Seed supply so fee shares can mint c.deposit_unchecked(&accounts(2), 2_000) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); + .unwrap_or_else(|e| templar_common::panic_with_message(&e.to_string())); // Simulate profit: last=2000, current=2400 c.idle_balance = 2_400; c.last_total_assets = 2_000; @@ -819,7 +819,7 @@ fn internal_accrue_fee_mints_zero_on_loss_and_updates_last() { // Seed supply so total_supply > 0 c.deposit_unchecked(&accounts(1), 1_000) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); + .unwrap_or_else(|e| templar_common::panic_with_message(&e.to_string())); // Loss scenario: last=1000, current=800 c.idle_balance = 800; c.last_total_assets = 1_000; @@ -1479,7 +1479,7 @@ fn governance_set_fee_recipient_no_fee_does_not_accrue() { // Seed supply and simulate profit, but fee = 0 c.deposit_unchecked(&owner, 1_000) - .unwrap_or_else(|e| env::panic_str(&e.to_string())); + .unwrap_or_else(|e| templar_common::panic_with_message(&e.to_string())); c.idle_balance = 1_500; c.last_total_assets = 1_000; c.performance_fee = Wad::zero(); @@ -1564,7 +1564,7 @@ fn after_supply_1_check_allocating_not_allocating_index() { &vault_id, vec![PromiseResult::Successful( near_sdk::serde_json::to_vec(&U128(u128::MAX)) - .unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())), + .unwrap_or_else(|e| templar_common::panic_with_message(&e.to_string())), )], ); @@ -1599,7 +1599,7 @@ fn after_supply_1_check_allocating() { &vault_id, vec![PromiseResult::Successful( near_sdk::serde_json::to_vec(&U128(u128::MAX)) - .unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())), + .unwrap_or_else(|e| templar_common::panic_with_message(&e.to_string())), )], ); @@ -1918,7 +1918,7 @@ fn refund_path_consistency() { // Seed escrowed shares into the vault's own account let owner = accounts(1); c.deposit_unchecked(&near_sdk::env::current_account_id(), 10) - .unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())); + .unwrap_or_else(|e| templar_common::panic_with_message(&e.to_string())); // Withdrawing state with remaining=0 and collected=0 forces refund path let op_id = 77; @@ -2339,7 +2339,7 @@ fn stop_and_exit_payout_refunds_and_idle(mut c: Contract, owner: AccountId, rece // Seed escrowed shares into the vault's own account c.deposit_unchecked(&near_sdk::env::current_account_id(), escrow) - .unwrap_or_else(|e| near_sdk::env::panic_str(&e.to_string())); + .unwrap_or_else(|e| templar_common::panic_with_message(&e.to_string())); // Enter Payout with non-zero escrow c.op_state = OpState::Payout(PayoutState { diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 00000000..3f62b58c --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,106 @@ +[package] +name = "templar-fuzz" +license.workspace = true +repository.workspace = true +edition.workspace = true +version.workspace = true + +[dependencies] +arbitrary.workspace = true +hex-literal.workspace = true +libfuzzer-sys.workspace = true +near-sdk = { workspace = true, features = ["non-contract-usage"] } +primitive-types.workspace = true +templar-common = { workspace = true, features = ["non-contract-usage"] } + +[lints] +workspace = true + +[package.metadata] +cargo-fuzz = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[[bin]] +name = "fuzz_borrow_invariants" +path = "fuzz_targets/fuzz_borrow_invariants.rs" +test = false +bench = false + +[[bin]] +name = "fuzz_borrow" +path = "fuzz_targets/fuzz_borrow.rs" +test = false +bench = false + +[[bin]] +name = "fuzz_decimal_arithmetic" +path = "fuzz_targets/fuzz_decimal_arithmetic.rs" +test = false +bench = false + +[[bin]] +name = "fuzz_decimal_parsing" +path = "fuzz_targets/fuzz_decimal_parsing.rs" +test = false +bench = false + +[[bin]] +name = "fuzz_decimals" +path = "fuzz_targets/fuzz_decimals.rs" +test = false +bench = false + +[[bin]] +name = "fuzz_interest_math" +path = "fuzz_targets/fuzz_interest_math.rs" +test = false +bench = false + +[[bin]] +name = "fuzz_liquidations" +path = "fuzz_targets/fuzz_liquidations.rs" +test = false +bench = false + +[[bin]] +name = "fuzz_liquidator_logic" +path = "fuzz_targets/fuzz_liquidator_logic.rs" +test = false +bench = false + +[[bin]] +name = "fuzz_liquidator_transactions" +path = "fuzz_targets/fuzz_liquidator_transactions.rs" +test = false +bench = false + +[[bin]] +name = "fuzz_market_creation" +path = "fuzz_targets/fuzz_market_creation.rs" +test = false +bench = false + +# [[bin]] +# name = "fuzz_market_state" +# path = "fuzz_targets/fuzz_market_state.rs" +# test = false + +[[bin]] +name = "fuzz_price" +path = "fuzz_targets/fuzz_price.rs" +test = false +bench = false + +[[bin]] +name = "fuzz_price_calculations" +path = "fuzz_targets/fuzz_price_calculations.rs" +test = false +bench = false + +[[bin]] +name = "fuzz_supply" +path = "fuzz_targets/fuzz_supply.rs" +test = false +bench = false diff --git a/fuzz/fuzz_targets/borrow/borrow_all_amounts_set.rs b/fuzz/fuzz_targets/borrow/borrow_all_amounts_set.rs new file mode 100644 index 00000000..e6a9a3b5 --- /dev/null +++ b/fuzz/fuzz_targets/borrow/borrow_all_amounts_set.rs @@ -0,0 +1,36 @@ +#![no_main] +#![cfg(not(target_arch = "wasm32"))] + +use libfuzzer_sys::fuzz_target; +use templar_common::{ + asset::{BorrowAssetAmount, CollateralAssetAmount}, + borrow::BorrowPosition, +}; + +// Tests position with all amounts set +fuzz_target!(|data: (u32, u128, u128, u128, u128)| { + let (snapshot_index, collateral_amount, principal_amount, in_flight_amount, lock_divisor) = + data; + + let mut position = BorrowPosition::new(snapshot_index); + position.collateral_asset_deposit = CollateralAssetAmount::new(collateral_amount); + position.borrow_asset_principal = BorrowAssetAmount::new(principal_amount); + position.borrow_asset_in_flight = BorrowAssetAmount::new(in_flight_amount); + + let lock_amount = if lock_divisor > 0 { + collateral_amount / lock_divisor.max(10) + } else { + 0 + }; + position.liquidation_lock = CollateralAssetAmount::new(lock_amount); + + // All getters should work + let liability = position.get_total_borrow_asset_liability(); + let collateral = position.get_total_collateral_amount(); + let principal = position.get_borrow_asset_principal(); + + // Test core invariants with all amounts set + assert!(u128::from(liability) >= u128::from(principal)); + assert!(u128::from(collateral) >= collateral_amount); + assert_eq!(principal, position.borrow_asset_principal); +}); diff --git a/fuzz/fuzz_targets/borrow/borrow_collateral.rs b/fuzz/fuzz_targets/borrow/borrow_collateral.rs new file mode 100644 index 00000000..6bb13869 --- /dev/null +++ b/fuzz/fuzz_targets/borrow/borrow_collateral.rs @@ -0,0 +1,31 @@ +#![no_main] +#![cfg(not(target_arch = "wasm32"))] + +use libfuzzer_sys::fuzz_target; +use templar_common::{ + asset::{BorrowAssetAmount, CollateralAssetAmount}, + borrow::BorrowPosition, +}; + +// Tests only collateral deposit field operations +fuzz_target!(|data: (u32, u128)| { + let (snapshot_index, collateral_amount) = data; + + let mut position = BorrowPosition::new(snapshot_index); + position.collateral_asset_deposit = CollateralAssetAmount::new(collateral_amount); + + let total = position.get_total_collateral_amount(); + assert_eq!(total, position.collateral_asset_deposit); + + // Test existence with collateral + if collateral_amount > 0 { + assert!(position.exists()); + } else { + assert!(!position.exists()); + } + + // Verify other amounts remain zero + assert_eq!(position.get_borrow_asset_principal(), BorrowAssetAmount::zero()); + assert_eq!(position.get_total_borrow_asset_liability(), BorrowAssetAmount::zero()); + assert_eq!(position.liquidation_lock, CollateralAssetAmount::zero()); +}); diff --git a/fuzz/fuzz_targets/borrow/borrow_existence.rs b/fuzz/fuzz_targets/borrow/borrow_existence.rs new file mode 100644 index 00000000..4fb3fa84 --- /dev/null +++ b/fuzz/fuzz_targets/borrow/borrow_existence.rs @@ -0,0 +1,34 @@ +#![no_main] +#![cfg(not(target_arch = "wasm32"))] + +use libfuzzer_sys::fuzz_target; +use templar_common::{ + asset::{BorrowAssetAmount, CollateralAssetAmount}, + borrow::BorrowPosition, +}; + +// Tests position existence logic with any combination of amounts +fuzz_target!(|data: (u32, u128, u128, u128, u128)| { + let (snapshot_index, collateral_amount, principal_amount, in_flight_amount, lock_amount) = data; + + let mut position = BorrowPosition::new(snapshot_index); + position.collateral_asset_deposit = CollateralAssetAmount::new(collateral_amount); + position.borrow_asset_principal = BorrowAssetAmount::new(principal_amount); + position.borrow_asset_in_flight = BorrowAssetAmount::new(in_flight_amount); + position.liquidation_lock = CollateralAssetAmount::new(lock_amount); + + let exists = position.exists(); + let has_any_amount = + collateral_amount > 0 || principal_amount > 0 || in_flight_amount > 0 || lock_amount > 0; + + // Core existence logic + assert_eq!(exists, has_any_amount); + + // Test state transitions - clear all amounts + position.collateral_asset_deposit = CollateralAssetAmount::zero(); + position.borrow_asset_principal = BorrowAssetAmount::zero(); + position.borrow_asset_in_flight = BorrowAssetAmount::zero(); + position.liquidation_lock = CollateralAssetAmount::zero(); + + assert!(!position.exists()); +}); diff --git a/fuzz/fuzz_targets/borrow/borrow_inflight_amounts.rs b/fuzz/fuzz_targets/borrow/borrow_inflight_amounts.rs new file mode 100644 index 00000000..cda53175 --- /dev/null +++ b/fuzz/fuzz_targets/borrow/borrow_inflight_amounts.rs @@ -0,0 +1,30 @@ +#![no_main] +#![cfg(not(target_arch = "wasm32"))] + +use libfuzzer_sys::fuzz_target; +use templar_common::{ + asset::BorrowAssetAmount, + borrow::BorrowPosition, +}; + +// Tests only in_flight amount operations +fuzz_target!(|data: (u32, u128)| { + let (snapshot_index, in_flight_amount) = data; + + let mut position = BorrowPosition::new(snapshot_index); + position.borrow_asset_in_flight = BorrowAssetAmount::new(in_flight_amount); + + let liability = position.get_total_borrow_asset_liability(); + + // Test existence with in_flight only + if in_flight_amount > 0 { + assert!(position.exists()); + assert!(!liability.is_zero()); + } else { + assert!(!position.exists()); + assert_eq!(liability, BorrowAssetAmount::zero()); + } + + // Principal should remain zero + assert_eq!(position.get_borrow_asset_principal(), BorrowAssetAmount::zero()); +}); diff --git a/fuzz/fuzz_targets/borrow/borrow_liability.rs b/fuzz/fuzz_targets/borrow/borrow_liability.rs new file mode 100644 index 00000000..ec566cbb --- /dev/null +++ b/fuzz/fuzz_targets/borrow/borrow_liability.rs @@ -0,0 +1,34 @@ +#![no_main] +#![cfg(not(target_arch = "wasm32"))] + +use libfuzzer_sys::fuzz_target; +use templar_common::{ + asset::{BorrowAssetAmount, CollateralAssetAmount}, + borrow::BorrowPosition, +}; + +// Tests borrow liability calculation (principal + in_flight) +fuzz_target!(|data: (u32, u128, u128)| { + let (snapshot_index, principal_amount, in_flight_amount) = data; + + let mut position = BorrowPosition::new(snapshot_index); + position.borrow_asset_principal = BorrowAssetAmount::new(principal_amount); + position.borrow_asset_in_flight = BorrowAssetAmount::new(in_flight_amount); + + let liability = position.get_total_borrow_asset_liability(); + let principal = position.get_borrow_asset_principal(); + + // Test core invariant: liability >= principal + assert!(u128::from(liability) >= u128::from(principal)); + + // Test existence with borrow amounts + if principal_amount > 0 || in_flight_amount > 0 { + assert!(position.exists()); + } else { + assert!(!position.exists()); + } + + // Verify collateral remains zero + assert_eq!(position.get_total_collateral_amount(), CollateralAssetAmount::zero()); +}); + diff --git a/fuzz/fuzz_targets/borrow/borrow_liquidation_lock.rs b/fuzz/fuzz_targets/borrow/borrow_liquidation_lock.rs new file mode 100644 index 00000000..1e662fb0 --- /dev/null +++ b/fuzz/fuzz_targets/borrow/borrow_liquidation_lock.rs @@ -0,0 +1,29 @@ +#![no_main] +#![cfg(not(target_arch = "wasm32"))] + +use libfuzzer_sys::fuzz_target; +use templar_common::{ + asset::CollateralAssetAmount, + borrow::BorrowPosition, +}; + +// Tests liquidation_lock field operations +fuzz_target!(|data: (u32, u128, u128)| { + let (snapshot_index, collateral_amount, lock_amount) = data; + + let mut position = BorrowPosition::new(snapshot_index); + position.collateral_asset_deposit = CollateralAssetAmount::new(collateral_amount); + position.liquidation_lock = CollateralAssetAmount::new(lock_amount); + + let total = position.get_total_collateral_amount(); + let expected_total = collateral_amount.saturating_add(lock_amount); + + assert_eq!(u128::from(total), expected_total); + + // Test existence with liquidation lock + if collateral_amount > 0 || lock_amount > 0 { + assert!(position.exists()); + } else { + assert!(!position.exists()); + } +}); diff --git a/fuzz/fuzz_targets/borrow/borrow_max_snapshot.rs b/fuzz/fuzz_targets/borrow/borrow_max_snapshot.rs new file mode 100644 index 00000000..f931a3d5 --- /dev/null +++ b/fuzz/fuzz_targets/borrow/borrow_max_snapshot.rs @@ -0,0 +1,27 @@ +#![no_main] +#![cfg(not(target_arch = "wasm32"))] + +use libfuzzer_sys::fuzz_target; +use templar_common::{ + asset::{BorrowAssetAmount, CollateralAssetAmount}, + borrow::BorrowPosition, +}; + +// Tests BorrowPosition with maximum snapshot index +fuzz_target!(|data: (u128, u128, u128)| { + let (collateral_amount, principal_amount, in_flight_amount) = data; + + let mut position = BorrowPosition::new(u32::MAX); + position.collateral_asset_deposit = CollateralAssetAmount::new(collateral_amount); + position.borrow_asset_principal = BorrowAssetAmount::new(principal_amount); + position.borrow_asset_in_flight = BorrowAssetAmount::new(in_flight_amount); + + // All operations should work normally with max snapshot + let liability = position.get_total_borrow_asset_liability(); + let collateral = position.get_total_collateral_amount(); + let principal = position.get_borrow_asset_principal(); + + // Same invariants should hold + assert!(u128::from(liability) >= u128::from(principal)); + assert_eq!(u128::from(collateral), collateral_amount); +}); diff --git a/fuzz/fuzz_targets/borrow/borrow_overflow.rs b/fuzz/fuzz_targets/borrow/borrow_overflow.rs new file mode 100644 index 00000000..b4330d90 --- /dev/null +++ b/fuzz/fuzz_targets/borrow/borrow_overflow.rs @@ -0,0 +1,35 @@ +#![no_main] +#![cfg(not(target_arch = "wasm32"))] + +use libfuzzer_sys::fuzz_target; +use templar_common::{asset::BorrowAssetAmount, borrow::BorrowPosition}; + +// Tests overflow handling with very large amounts +fuzz_target!(|data: (u32, u128, u128)| { + let (snapshot_index, principal_divisor, inflight_divisor) = data; + + let principal_div = match principal_divisor % 2 { + 0 => 1, + _ => principal_divisor, + }; + + let inflight_div = match inflight_divisor % 2 { + 0 => 1, + _ => inflight_divisor, + }; + + let borrow_asset_principal = u128::MAX / principal_div; + let borrow_asset_inflight = u128::MAX / inflight_div; + + let mut position = BorrowPosition::new(snapshot_index); + + position.borrow_asset_principal = BorrowAssetAmount::new(borrow_asset_principal); + position.borrow_asset_in_flight = BorrowAssetAmount::new(borrow_asset_inflight); + + // Should not panic - test saturating arithmetic + let liability = position.get_total_borrow_asset_liability(); + let position_principal = position.get_borrow_asset_principal(); + + // Invariant should still hold even with large values + assert!(u128::from(liability) >= u128::from(position_principal)); +}); diff --git a/fuzz/fuzz_targets/borrow/borrow_status_invariants.rs b/fuzz/fuzz_targets/borrow/borrow_status_invariants.rs new file mode 100644 index 00000000..cbc7310f --- /dev/null +++ b/fuzz/fuzz_targets/borrow/borrow_status_invariants.rs @@ -0,0 +1,54 @@ +#![no_main] +#![cfg(not(target_arch = "wasm32"))] + +use libfuzzer_sys::fuzz_target; +use templar_common::{ + asset::{BorrowAssetAmount, CollateralAssetAmount}, + borrow::{BorrowPosition, BorrowStatus, LiquidationReason}, +}; + +// Tests BorrowStatus enum and timestamp operations +fuzz_target!(|data: (u32, u64, u8)| { + let (snapshot_index, timestamp_ms, status_selector) = data; + + let mut position = BorrowPosition::new(snapshot_index); + + // Test timestamp operations + if timestamp_ms > 0 { + position.started_at_block_timestamp_ms = Some(near_sdk::json_types::U64(timestamp_ms)); + } + + // Test BorrowStatus enum variants + let status = match status_selector % 4 { + 0 => BorrowStatus::Healthy, + 1 => BorrowStatus::MaintenanceRequired, + 2 => BorrowStatus::Liquidation(LiquidationReason::Undercollateralization), + _ => BorrowStatus::Liquidation(LiquidationReason::Expiration), + }; + + // Test enum operations + let cloned_status = status; + assert_eq!(status, cloned_status); + + // Test enum comparisons + let healthy = BorrowStatus::Healthy; + let maintenance = BorrowStatus::MaintenanceRequired; + assert!(healthy < maintenance); + + // Test liquidation reasons + let under_reason = LiquidationReason::Undercollateralization; + let exp_reason = LiquidationReason::Expiration; + assert_ne!(under_reason, exp_reason); + + // Position should remain empty throughout enum testing + assert!(!position.exists()); + assert_eq!( + position.get_borrow_asset_principal(), + BorrowAssetAmount::zero() + ); + assert_eq!( + position.get_total_collateral_amount(), + CollateralAssetAmount::zero() + ); +}); + diff --git a/fuzz/fuzz_targets/borrow/borrow_zero_amounts.rs b/fuzz/fuzz_targets/borrow/borrow_zero_amounts.rs new file mode 100644 index 00000000..04bb18e0 --- /dev/null +++ b/fuzz/fuzz_targets/borrow/borrow_zero_amounts.rs @@ -0,0 +1,31 @@ +#![no_main] +#![cfg(not(target_arch = "wasm32"))] + +use libfuzzer_sys::fuzz_target; +use templar_common::{ + asset::{BorrowAssetAmount, CollateralAssetAmount}, + borrow::BorrowPosition, +}; + +// Tests BorrowPosition with all zero amounts +fuzz_target!(|snapshot_index: u32| { + let position = BorrowPosition::new(snapshot_index); + + // All amounts should be zero + assert_eq!(position.get_borrow_asset_principal(), BorrowAssetAmount::zero()); + assert_eq!(position.get_total_borrow_asset_liability(), BorrowAssetAmount::zero()); + assert_eq!(position.get_total_collateral_amount(), CollateralAssetAmount::zero()); + + // Position should not exist + assert!(!position.exists()); + + // Test that setting zero amounts explicitly doesn't change behavior + let mut mut_position = BorrowPosition::new(snapshot_index); + mut_position.collateral_asset_deposit = CollateralAssetAmount::zero(); + mut_position.borrow_asset_principal = BorrowAssetAmount::zero(); + mut_position.borrow_asset_in_flight = BorrowAssetAmount::zero(); + mut_position.liquidation_lock = CollateralAssetAmount::zero(); + + assert!(!mut_position.exists()); + assert_eq!(mut_position, position); +}); diff --git a/fuzz/fuzz_targets/decimal_arithmetic/basic_arithmetic.rs b/fuzz/fuzz_targets/decimal_arithmetic/basic_arithmetic.rs new file mode 100644 index 00000000..28634d7f --- /dev/null +++ b/fuzz/fuzz_targets/decimal_arithmetic/basic_arithmetic.rs @@ -0,0 +1,51 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use templar_common::number::Decimal; + +fuzz_target!(|data: (u128, u128, u128)| { + let (a, b, c) = data; + + let dec_a = Decimal::from(a); + let dec_b = Decimal::from(b); + let dec_c = Decimal::from(c); + + // Addition operations + let _ = dec_a + dec_b; + let _ = dec_a + dec_b + dec_c; + let mut mut_a = dec_a; + mut_a += dec_b; + + // Subtraction (only if a >= b to avoid underflow) + if a >= b { + let _ = dec_a - dec_b; + let mut mut_sub = dec_a; + mut_sub -= dec_b; + } + + // Multiplication operations + let _ = dec_a * dec_b; + let _ = dec_a * dec_b * dec_c; + let mut mut_mul = dec_a; + mut_mul *= dec_b; + + // Division (avoid division by zero) + if b > 0 { + let _ = dec_a / dec_b; + let mut mut_div = dec_a; + mut_div /= dec_b; + } + + // Mixed operations with integers + let _ = dec_a * 2u32; + let _ = 3u64 * dec_b; + let _ = dec_a / 10u128; + if b > 0 { + let _ = 100u128 / dec_b; + } + + // Chained operations + if b > 0 && c > 0 { + let _ = (dec_a + dec_b) * dec_c / Decimal::TWO; + } +}); \ No newline at end of file diff --git a/fuzz/fuzz_targets/decimal_arithmetic/comparisons.rs b/fuzz/fuzz_targets/decimal_arithmetic/comparisons.rs new file mode 100644 index 00000000..a96ba9c0 --- /dev/null +++ b/fuzz/fuzz_targets/decimal_arithmetic/comparisons.rs @@ -0,0 +1,23 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use templar_common::number::Decimal; + +fuzz_target!(|data: (u128, u128)| { + let (a, b) = data; + + let dec_a = Decimal::from(a); + let dec_b = Decimal::from(b); + + // All comparison operations + let _ = dec_a == dec_b; + let _ = dec_a < dec_b; + let _ = dec_a > dec_b; + let _ = dec_a <= dec_b; + let _ = dec_a >= dec_b; + let _ = dec_a.near_equal(dec_b); + + // Test near_equal with close values + let close_a = dec_a + Decimal::from(1u32); + let _ = dec_a.near_equal(close_a); +}); \ No newline at end of file diff --git a/fuzz/fuzz_targets/decimal_arithmetic/constants.rs b/fuzz/fuzz_targets/decimal_arithmetic/constants.rs new file mode 100644 index 00000000..45b2efab --- /dev/null +++ b/fuzz/fuzz_targets/decimal_arithmetic/constants.rs @@ -0,0 +1,33 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use templar_common::number::Decimal; + +fuzz_target!(|data: u128| { + let a = data; + + let dec_a = Decimal::from(a); + + // Operations with constants + let _ = dec_a + Decimal::ZERO; + let _ = dec_a + Decimal::ONE; + let _ = dec_a + Decimal::TWO; + let _ = dec_a * Decimal::ZERO; + let _ = dec_a * Decimal::ONE; + let _ = dec_a * Decimal::TWO; + + // Mathematical constants operations + let _ = dec_a + Decimal::E; + let _ = dec_a + Decimal::LN2; + let _ = dec_a * Decimal::E; + let _ = dec_a * Decimal::LN2; + + // Extreme values operations + let _ = dec_a + Decimal::MAX; + let _ = dec_a + Decimal::MIN; + + // Test constant properties + let _ = Decimal::ZERO.is_zero(); + let _ = Decimal::ONE > Decimal::ZERO; + let _ = Decimal::TWO > Decimal::ONE; +}); \ No newline at end of file diff --git a/fuzz/fuzz_targets/decimal_arithmetic/conversions.rs b/fuzz/fuzz_targets/decimal_arithmetic/conversions.rs new file mode 100644 index 00000000..99126381 --- /dev/null +++ b/fuzz/fuzz_targets/decimal_arithmetic/conversions.rs @@ -0,0 +1,18 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use templar_common::number::Decimal; + +fuzz_target!(|data: u128| { + let dec_a = Decimal::from(data); + + // Type conversions + let _ = dec_a.to_u128_floor(); + let _ = dec_a.to_u128_ceil(); + let _ = dec_a.to_f64_lossy(); + + // Test ceiling and floor relationship + if let (Some(floor), Some(ceil)) = (dec_a.to_u128_floor(), dec_a.to_u128_ceil()) { + let _ = ceil >= floor; + } +}); \ No newline at end of file diff --git a/fuzz/fuzz_targets/decimal_arithmetic/malformed/alphabetic_chars.rs b/fuzz/fuzz_targets/decimal_arithmetic/malformed/alphabetic_chars.rs new file mode 100644 index 00000000..01c64bfd --- /dev/null +++ b/fuzz/fuzz_targets/decimal_arithmetic/malformed/alphabetic_chars.rs @@ -0,0 +1,25 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use std::str::FromStr; +use templar_common::number::Decimal; + +fuzz_target!(|data: &[u8]| { + // Test alphabetic characters don't panic + if !data.is_empty() { + let fuzz_byte = data[0]; + let alpha_char = (b'a' + (fuzz_byte % 26)) as char; + + let alphabetic_patterns = [ + format!("{fuzz_byte}{alpha_char}"), + format!("{alpha_char}{fuzz_byte}c"), + format!("ab{fuzz_byte}"), + alpha_char.to_string(), + ]; + + for malformed in alphabetic_patterns { + let _ = Decimal::from_str(&malformed); + } + } +}); + diff --git a/fuzz/fuzz_targets/decimal_arithmetic/malformed/invalid_separators.rs b/fuzz/fuzz_targets/decimal_arithmetic/malformed/invalid_separators.rs new file mode 100644 index 00000000..5e510aa6 --- /dev/null +++ b/fuzz/fuzz_targets/decimal_arithmetic/malformed/invalid_separators.rs @@ -0,0 +1,24 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use std::str::FromStr; +use templar_common::number::Decimal; + +fuzz_target!(|data: &[u8]| { + // Test invalid decimal separators don't panic + if !data.is_empty() { + let fuzz_byte = data[0]; + + let invalid_separator_patterns = [ + format!(".{fuzz_byte}"), + format!("{fuzz_byte}.."), + format!("..{fuzz_byte}"), + ".".to_string(), + ]; + + for malformed in invalid_separator_patterns { + let _ = Decimal::from_str(&malformed); + } + } +}); + diff --git a/fuzz/fuzz_targets/decimal_arithmetic/malformed/mixed_invalid.rs b/fuzz/fuzz_targets/decimal_arithmetic/malformed/mixed_invalid.rs new file mode 100644 index 00000000..de069cd1 --- /dev/null +++ b/fuzz/fuzz_targets/decimal_arithmetic/malformed/mixed_invalid.rs @@ -0,0 +1,24 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use std::str::FromStr; +use templar_common::number::Decimal; + +fuzz_target!(|data: &[u8]| { + // Test mixed invalid patterns don't panic + if !data.is_empty() { + let fuzz_byte = data[0]; + + let mixed_invalid_patterns = [ + format!("{fuzz_byte}#{fuzz_byte}"), + format!("@{}.{}", fuzz_byte % 10, fuzz_byte % 10), + format!("{fuzz_byte}%"), + format!("${fuzz_byte}"), + ]; + + for malformed in mixed_invalid_patterns { + let _ = Decimal::from_str(&malformed); + } + } +}); + diff --git a/fuzz/fuzz_targets/decimal_arithmetic/malformed/multiple_decimals.rs b/fuzz/fuzz_targets/decimal_arithmetic/malformed/multiple_decimals.rs new file mode 100644 index 00000000..eb9fc427 --- /dev/null +++ b/fuzz/fuzz_targets/decimal_arithmetic/malformed/multiple_decimals.rs @@ -0,0 +1,23 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use std::str::FromStr; +use templar_common::number::Decimal; + +fuzz_target!(|data: &[u8]| { + // Test multiple decimal points don't panic + if !data.is_empty() { + let fuzz_byte = data[0]; + + let multiple_decimal_patterns = [ + format!("{}.{}.{}", fuzz_byte % 10, fuzz_byte % 100, fuzz_byte % 200), + format!("1.2.{fuzz_byte}"), + format!("{fuzz_byte}.0.0"), + ]; + + for malformed in multiple_decimal_patterns { + let _ = Decimal::from_str(&malformed); + } + } +}); + diff --git a/fuzz/fuzz_targets/decimal_arithmetic/malformed/scientific_notation.rs b/fuzz/fuzz_targets/decimal_arithmetic/malformed/scientific_notation.rs new file mode 100644 index 00000000..f8b95bf6 --- /dev/null +++ b/fuzz/fuzz_targets/decimal_arithmetic/malformed/scientific_notation.rs @@ -0,0 +1,24 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use std::str::FromStr; +use templar_common::number::Decimal; + +fuzz_target!(|data: &[u8]| { + // Test scientific notation (not supported) doesn't panic + if !data.is_empty() { + let fuzz_byte = data[0]; + + let scientific_notation_patterns = [ + format!("{}e{}", fuzz_byte % 10, fuzz_byte % 10), + format!("{}E{}", fuzz_byte, fuzz_byte % 100), + format!("1e{fuzz_byte}"), + format!("{}e+{}", fuzz_byte % 10, fuzz_byte % 10), + ]; + + for malformed in scientific_notation_patterns { + let _ = Decimal::from_str(&malformed); + } + } +}); + diff --git a/fuzz/fuzz_targets/decimal_arithmetic/malformed/sign_errors.rs b/fuzz/fuzz_targets/decimal_arithmetic/malformed/sign_errors.rs new file mode 100644 index 00000000..3081353a --- /dev/null +++ b/fuzz/fuzz_targets/decimal_arithmetic/malformed/sign_errors.rs @@ -0,0 +1,24 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use std::str::FromStr; +use templar_common::number::Decimal; + +fuzz_target!(|data: &[u8]| { + // Test sign errors (negative numbers) don't panic + if !data.is_empty() { + let fuzz_byte = data[0]; + + let sign_error_patterns = [ + format!("-{fuzz_byte}"), + format!("-{}.{}", fuzz_byte % 10, fuzz_byte % 100), + format!("+-{fuzz_byte}"), + "-".to_string(), + ]; + + for malformed in sign_error_patterns { + let _ = Decimal::from_str(&malformed); + } + } +}); + diff --git a/fuzz/fuzz_targets/decimal_arithmetic/malformed/special_values.rs b/fuzz/fuzz_targets/decimal_arithmetic/malformed/special_values.rs new file mode 100644 index 00000000..88baa10c --- /dev/null +++ b/fuzz/fuzz_targets/decimal_arithmetic/malformed/special_values.rs @@ -0,0 +1,23 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use std::str::FromStr; +use templar_common::number::Decimal; + +fuzz_target!(|data: &[u8]| { + // Test special values don't panic + if !data.is_empty() { + let fuzz_byte = data[0]; + let special_idx = fuzz_byte % 4; + let specials = ["NaN", "Infinity", "inf", "null"]; + + let special_value_patterns = [ + specials[special_idx as usize].to_string(), + format!("{}{}", specials[special_idx as usize], fuzz_byte), + ]; + + for malformed in special_value_patterns { + let _ = Decimal::from_str(&malformed); + } + } +}); \ No newline at end of file diff --git a/fuzz/fuzz_targets/decimal_arithmetic/malformed/whitespace.rs b/fuzz/fuzz_targets/decimal_arithmetic/malformed/whitespace.rs new file mode 100644 index 00000000..2b714d25 --- /dev/null +++ b/fuzz/fuzz_targets/decimal_arithmetic/malformed/whitespace.rs @@ -0,0 +1,24 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use std::str::FromStr; +use templar_common::number::Decimal; + +fuzz_target!(|data: &[u8]| { + // Test empty and whitespace strings don't panic + if !data.is_empty() { + let fuzz_byte = data[0]; + + let whitespace_patterns = [ + String::new(), + " ".to_string(), + format!(" {fuzz_byte} "), + format!("\t{fuzz_byte}"), + ]; + + for malformed in whitespace_patterns { + let _ = Decimal::from_str(&malformed); + } + } +}); + diff --git a/fuzz/fuzz_targets/decimal_arithmetic/parsing_conversions.rs b/fuzz/fuzz_targets/decimal_arithmetic/parsing_conversions.rs new file mode 100644 index 00000000..1b83df5d --- /dev/null +++ b/fuzz/fuzz_targets/decimal_arithmetic/parsing_conversions.rs @@ -0,0 +1,17 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use std::str::FromStr; +use templar_common::number::Decimal; + +fuzz_target!(|data: &[u8]| { + // Try to parse as UTF-8 string + if let Ok(s) = std::str::from_utf8(data) { + if let Ok(decimal) = Decimal::from_str(s) { + // Test conversions + let _ = decimal.to_u128_floor(); + let _ = decimal.to_u128_ceil(); + let _ = decimal.to_f64_lossy(); + } + } +}); \ No newline at end of file diff --git a/fuzz/fuzz_targets/decimal_arithmetic/parsing_edge_cases.rs b/fuzz/fuzz_targets/decimal_arithmetic/parsing_edge_cases.rs new file mode 100644 index 00000000..ef5a04bc --- /dev/null +++ b/fuzz/fuzz_targets/decimal_arithmetic/parsing_edge_cases.rs @@ -0,0 +1,54 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use std::str::FromStr; +use templar_common::number::Decimal; + +fuzz_target!(|data: &[u8]| { + // Generate edge case strings using fuzz data + if data.len() >= 6 { + // Generate edge cases from fuzz input bytes + let edge_byte1 = data[5]; + let edge_byte2 = if data.len() > 6 { data[6] } else { 0 }; + + // Create various edge case patterns from fuzz data + let generated_edge_cases = [ + // Simple digit patterns + format!("{}", edge_byte1 % 10), + format!("{}.{}", edge_byte1 % 10, edge_byte2 % 10), + // Zero variations + format!( + "0.{:08}", + u64::from(edge_byte1) * 256 + u64::from(edge_byte2) + ), + format!("{edge_byte1}.0"), + // Large number variations + format!("{edge_byte1}{edge_byte2}{edge_byte1}"), + format!( + "{}.{}{}", + u64::from(edge_byte1) * u64::from(edge_byte2), + edge_byte1, + edge_byte2 + ), + // Precision edge cases + format!( + "0.{:038}", + u128::from(edge_byte1) * 256 + u128::from(edge_byte2) + ), // Max precision + format!( + "{}", + u128::from(edge_byte1) * u128::from(edge_byte2) * 1_000_000 + ), + ]; + + // Test each generated edge case + for edge_case in generated_edge_cases { + if let Ok(decimal) = Decimal::from_str(&edge_case) { + let precision = (data[0] % 39) as usize; // Use first byte for precision + let stringified = decimal.to_fixed(precision); + let _ = Decimal::from_str(&stringified); + } + } + } +}); + diff --git a/fuzz/fuzz_targets/decimal_arithmetic/parsing_large_numbers.rs b/fuzz/fuzz_targets/decimal_arithmetic/parsing_large_numbers.rs new file mode 100644 index 00000000..16f29b3a --- /dev/null +++ b/fuzz/fuzz_targets/decimal_arithmetic/parsing_large_numbers.rs @@ -0,0 +1,28 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use std::str::FromStr; +use templar_common::number::Decimal; + +fuzz_target!(|data: &[u8]| { + // Generate additional numeric edge cases from fuzz data + if data.len() >= 8 { + let large_whole = u128::from_le_bytes([ + data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7], 0, 0, 0, 0, + 0, 0, 0, 0, + ]); + + let fuzz_edge_cases = [ + format!("{large_whole}"), + format!("{large_whole}.0"), + format!("0.{large_whole}"), + ]; + + for case in fuzz_edge_cases { + if let Ok(decimal) = Decimal::from_str(&case) { + let precision = (data[7] % 39) as usize; + let _ = decimal.to_fixed(precision); + } + } + } +}); \ No newline at end of file diff --git a/fuzz/fuzz_targets/decimal_arithmetic/parsing_patterns.rs b/fuzz/fuzz_targets/decimal_arithmetic/parsing_patterns.rs new file mode 100644 index 00000000..a17034cd --- /dev/null +++ b/fuzz/fuzz_targets/decimal_arithmetic/parsing_patterns.rs @@ -0,0 +1,37 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use std::str::FromStr; +use templar_common::number::Decimal; + +fuzz_target!(|data: &[u8]| { + // Test with constructed string patterns using fuzzed inputs + if data.len() >= 4 { + // Generate whole part from first two bytes + let whole_part = u128::from(data[0]) * 256 + u128::from(data[1]); + + // Generate fractional digits from remaining bytes + let frac_digits = data[2] % 10; + let extra_frac = if data.len() > 3 { data[3] % 100 } else { 0 }; + + // Create various decimal string patterns + let test_patterns = [ + format!("{whole_part}"), + format!("{whole_part}.{frac_digits}"), + format!("{whole_part}.{frac_digits:02}{extra_frac:02}"), + format!("0.{frac_digits}"), + format!("0.{frac_digits:09}"), // Leading zeros + ]; + + for pattern in test_patterns { + if let Ok(decimal) = Decimal::from_str(&pattern) { + let precision = if data.len() > 4 { + (data[4] % 39) as usize + } else { + 10 + }; + let _ = decimal.to_fixed(precision); + } + } + } +}); \ No newline at end of file diff --git a/fuzz/fuzz_targets/decimal_arithmetic/parsing_precision.rs b/fuzz/fuzz_targets/decimal_arithmetic/parsing_precision.rs new file mode 100644 index 00000000..a4e3f2f8 --- /dev/null +++ b/fuzz/fuzz_targets/decimal_arithmetic/parsing_precision.rs @@ -0,0 +1,28 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use std::str::FromStr; +use templar_common::number::Decimal; + +fuzz_target!(|data: &[u8]| { + // Try to parse as UTF-8 string + if let Ok(s) = std::str::from_utf8(data) { + if let Ok(decimal) = Decimal::from_str(s) { + // Test various precision levels using fuzzed values + if !data.is_empty() { + let fuzzed_precision = (data[0] % 39) as usize; // 0-38 range + let fixed = decimal.to_fixed(fuzzed_precision); + let _ = Decimal::from_str(&fixed); + } + + // Test additional precision levels if we have more data + if data.len() >= 3 { + data.iter().take(3).for_each(|&byte| { + let precision = (byte % 39) as usize; // 0-38 range + let fixed = decimal.to_fixed(precision); + let _ = Decimal::from_str(&fixed); + }); + } + } + } +}); \ No newline at end of file diff --git a/fuzz/fuzz_targets/decimal_arithmetic/parsing_roundtrip.rs b/fuzz/fuzz_targets/decimal_arithmetic/parsing_roundtrip.rs new file mode 100644 index 00000000..5ad626de --- /dev/null +++ b/fuzz/fuzz_targets/decimal_arithmetic/parsing_roundtrip.rs @@ -0,0 +1,23 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use std::str::FromStr; +use templar_common::number::Decimal; + +fuzz_target!(|data: &[u8]| { + // Try to parse as UTF-8 string + if let Ok(s) = std::str::from_utf8(data) { + if let Ok(decimal) = Decimal::from_str(s) { + // Test round-trip parsing with maximum precision + let to_string = decimal.to_fixed(38); + + // Parse again and check near equality + if let Ok(reparsed) = Decimal::from_str(&to_string) { + assert!( + decimal.near_equal(reparsed), + "Round-trip failed: original={decimal:?}, string={to_string}, reparsed={reparsed:?}", + ); + } + } + } +}); \ No newline at end of file diff --git a/fuzz/fuzz_targets/decimal_arithmetic/parsing_utf8.rs b/fuzz/fuzz_targets/decimal_arithmetic/parsing_utf8.rs new file mode 100644 index 00000000..516e836d --- /dev/null +++ b/fuzz/fuzz_targets/decimal_arithmetic/parsing_utf8.rs @@ -0,0 +1,24 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use std::str::FromStr; +use templar_common::number::Decimal; + +fuzz_target!(|data: &[u8]| { + // Try to parse as UTF-8 string and test basic parsing + if let Ok(s) = std::str::from_utf8(data) { + if let Ok(decimal) = Decimal::from_str(s) { + // Test that is_zero is consistent + if decimal.is_zero() { + assert_eq!(decimal, Decimal::ZERO); + } + + // Test basic operations on parsed value + let _ = decimal + Decimal::ONE; + let _ = decimal * Decimal::TWO; + if !decimal.is_zero() { + let _ = Decimal::ONE / decimal; + } + } + } +}); \ No newline at end of file diff --git a/fuzz/fuzz_targets/decimal_arithmetic/powers.rs b/fuzz/fuzz_targets/decimal_arithmetic/powers.rs new file mode 100644 index 00000000..d9471e79 --- /dev/null +++ b/fuzz/fuzz_targets/decimal_arithmetic/powers.rs @@ -0,0 +1,32 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use templar_common::number::Decimal; + +fuzz_target!(|data: (u128, u32)| { + let (a, pow_exp) = data; + + let dec_a = Decimal::from(a); + + // Power operations with safe exponents + #[allow(clippy::cast_possible_wrap, reason = "Fuzzing context")] + let small_pow = (pow_exp % 20) as i32; + let _ = dec_a.pow(small_pow); + let _ = dec_a.pow(-small_pow); + let _ = dec_a.pow(0); + let _ = dec_a.pow(1); + + // pow2_int with valid range + let pow2_exp = pow_exp % 384; + let _ = Decimal::pow2_int(pow2_exp); + + // pow2 (only for values <= 1) + if dec_a <= Decimal::ONE { + let _ = dec_a.pow2(); + } + + // Test specific constant powers + let _ = Decimal::TWO.pow(10); + let _ = Decimal::ONE_HALF.pow(5); +}); + diff --git a/fuzz/fuzz_targets/decimal_arithmetic/scaling.rs b/fuzz/fuzz_targets/decimal_arithmetic/scaling.rs new file mode 100644 index 00000000..42bda3f6 --- /dev/null +++ b/fuzz/fuzz_targets/decimal_arithmetic/scaling.rs @@ -0,0 +1,20 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use templar_common::number::Decimal; + +fuzz_target!(|data: (u128, i32)| { + let (a, exp1) = data; + + let dec_a = Decimal::from(a); + + // mul_pow10 with safe exponents + let safe_exp1 = exp1.clamp(-38, 115); + let _ = dec_a.mul_pow10(safe_exp1); + let _ = dec_a.mul_pow10(-safe_exp1); + + // Identity and basic scaling tests + let _ = Decimal::ONE.mul_pow10(0); + let _ = dec_a.mul_pow10(1); + let _ = dec_a.mul_pow10(-1); +}); \ No newline at end of file diff --git a/fuzz/fuzz_targets/decimal_arithmetic/strings.rs b/fuzz/fuzz_targets/decimal_arithmetic/strings.rs new file mode 100644 index 00000000..a4fb4e82 --- /dev/null +++ b/fuzz/fuzz_targets/decimal_arithmetic/strings.rs @@ -0,0 +1,20 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use std::str::FromStr; +use templar_common::number::Decimal; + +fuzz_target!(|data: u128| { + let dec_a = Decimal::from(data); + + // String formatting with different precisions + let _ = dec_a.to_fixed(38); + let _ = dec_a.to_fixed(10); + let _ = dec_a.to_fixed(0); + + // Round-trip string conversion + let str_repr = dec_a.to_fixed(20); + if let Ok(parsed) = Decimal::from_str(&str_repr) { + let _ = dec_a.near_equal(parsed); + } +}); \ No newline at end of file diff --git a/fuzz/fuzz_targets/decimal_arithmetic/utilities.rs b/fuzz/fuzz_targets/decimal_arithmetic/utilities.rs new file mode 100644 index 00000000..08806e0d --- /dev/null +++ b/fuzz/fuzz_targets/decimal_arithmetic/utilities.rs @@ -0,0 +1,18 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use templar_common::number::Decimal; + +fuzz_target!(|data: (u128, u128)| { + let (a, b) = data; + + let dec_a = Decimal::from(a); + let dec_b = Decimal::from(b); + + // Utility functions + let _ = dec_a.abs_diff(dec_b); + let _ = dec_b.abs_diff(dec_a); + let _ = dec_a.is_zero(); + let _ = Decimal::ZERO.is_zero(); + let _ = dec_a.fractional_part_as_u128_dividend(); +}); \ No newline at end of file diff --git a/fuzz/fuzz_targets/fuzz_accumulator.rs b/fuzz/fuzz_targets/fuzz_accumulator.rs new file mode 100644 index 00000000..1e4ab3b2 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_accumulator.rs @@ -0,0 +1,29 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use templar_common::{ + accumulator::Accumulator, + asset::{BorrowAsset, FungibleAssetAmount}, +}; + +fuzz_target!(|data: (u32, u128)| { + let (acc, amount) = data; + let amount_fungible = FungibleAssetAmount::new(amount); + let mut accumulator = Accumulator::::new(acc); + // Initial state assertions + assert_eq!(accumulator.get_next_snapshot_index(), acc); + assert_eq!(accumulator.get_total(), 0.into()); + + // Add once + let _ = accumulator.add_once(amount_fungible); + assert!(accumulator.get_total() >= 0.into()); + + // Remove + let _ = accumulator.remove(amount_fungible); + assert!(accumulator.get_total() <= amount_fungible); + + // Add again + let _ = accumulator.add_once(amount_fungible); + + () = accumulator.clear(acc); +}); diff --git a/fuzz/fuzz_targets/fuzz_borrow.rs b/fuzz/fuzz_targets/fuzz_borrow.rs new file mode 100644 index 00000000..6454ef55 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_borrow.rs @@ -0,0 +1,158 @@ +#![no_main] +#![cfg(not(target_arch = "wasm32"))] + +use libfuzzer_sys::fuzz_target; +use templar_common::{ + asset::{BorrowAssetAmount, CollateralAssetAmount}, + borrow::{BorrowPosition, BorrowStatus, LiquidationReason}, +}; + +fuzz_target!(|data: (u32, u128, u128, u128, u128, u64, u8)| { + let ( + snapshot_index, + collateral_amount, + principal_amount, + _fees_amount, + in_flight_amount, + timestamp_ms, + op_selector, + ) = data; + + // Create a new borrow position + let mut position = BorrowPosition::new(snapshot_index); + + // Test basic getters on empty position + let _ = position.get_borrow_asset_principal(); + let _ = position.get_total_borrow_asset_liability(); + let _ = position.get_total_collateral_amount(); + let _ = !position.exists(); + let _ = position.exists(); + + // Fuzz setting various amounts + position.collateral_asset_deposit = CollateralAssetAmount::new(collateral_amount); + position.borrow_asset_principal = BorrowAssetAmount::new(principal_amount); + position.borrow_asset_in_flight = BorrowAssetAmount::new(in_flight_amount); + position.liquidation_lock = CollateralAssetAmount::new(0); // Start with no lock + + // Test getters with populated values + let liability = position.get_total_borrow_asset_liability(); + let collateral = position.get_total_collateral_amount(); + assert_eq!(collateral, position.collateral_asset_deposit); + let principal = position.get_borrow_asset_principal(); + assert_eq!(principal, position.borrow_asset_principal); + + // Test exists and can_be_removed logic + let exists = position.exists(); + let can_remove = !position.exists(); + + // Invariants + if exists { + // If position exists, can_be_removed should consider the amounts + if position.collateral_asset_deposit.is_zero() + && liability.is_zero() + && position.borrow_asset_in_flight.is_zero() + && position.liquidation_lock.is_zero() + { + assert!( + can_remove, + "Position should be removable when all amounts are zero" + ); + } + } + + // Test timestamp handling + if timestamp_ms > 0 { + position.started_at_block_timestamp_ms = Some(near_sdk::json_types::U64(timestamp_ms)); + } + + // Test different operations based on selector + match op_selector % 8 { + 0 => { + // Test with zero amounts + let zero_pos = BorrowPosition::new(0); + assert!(!zero_pos.exists()); + assert!(zero_pos.exists()); + assert_eq!( + zero_pos.get_borrow_asset_principal(), + BorrowAssetAmount::zero() + ); + } + 1 => { + // Test with max snapshot index + let max_pos = BorrowPosition::new(u32::MAX); + let _ = max_pos.get_total_borrow_asset_liability(); + } + 2 => { + // Test collateral operations + let mut pos = BorrowPosition::new(snapshot_index); + pos.collateral_asset_deposit = CollateralAssetAmount::new(collateral_amount); + let total = pos.get_total_collateral_amount(); + assert_eq!(total, pos.collateral_asset_deposit); + } + 3 => { + // Test liquidation lock + let mut pos = BorrowPosition::new(snapshot_index); + pos.collateral_asset_deposit = CollateralAssetAmount::new(collateral_amount); + pos.liquidation_lock = CollateralAssetAmount::new(collateral_amount / 2); + let total = pos.get_total_collateral_amount(); + // Total should be sum of deposit and lock + let _ = total; + } + 4 => { + // Test in-flight amounts + let mut pos = BorrowPosition::new(snapshot_index); + pos.borrow_asset_principal = BorrowAssetAmount::new(principal_amount); + pos.borrow_asset_in_flight = BorrowAssetAmount::new(in_flight_amount); + let liability = pos.get_total_borrow_asset_liability(); + // Liability should include principal and in_flight + let _ = liability; + } + 5 => { + // Test fees accumulation + let mut pos = BorrowPosition::new(snapshot_index); + pos.borrow_asset_principal = BorrowAssetAmount::new(principal_amount); + // Fees are part of liability + let liability = pos.get_total_borrow_asset_liability(); + let _ = liability; + } + 6 => { + // Test position with all amounts set + let mut pos = BorrowPosition::new(snapshot_index); + pos.collateral_asset_deposit = CollateralAssetAmount::new(collateral_amount); + pos.borrow_asset_principal = BorrowAssetAmount::new(principal_amount); + pos.borrow_asset_in_flight = BorrowAssetAmount::new(in_flight_amount); + pos.liquidation_lock = CollateralAssetAmount::new(collateral_amount / 10); + + let _ = pos.get_total_borrow_asset_liability(); + let _ = pos.get_total_collateral_amount(); + let _ = pos.exists(); + let _ = !pos.exists(); + } + _ => { + // Test edge cases with overflow scenarios + let mut pos = BorrowPosition::new(snapshot_index); + + // Try to set amounts that might overflow when combined + pos.borrow_asset_principal = BorrowAssetAmount::new(u128::MAX / 3); + pos.borrow_asset_in_flight = BorrowAssetAmount::new(u128::MAX / 3); + + // This might overflow - fuzzer should catch it + let _ = pos.get_total_borrow_asset_liability(); + } + } + + // Test clone and equality + let cloned = position.clone(); + assert_eq!(position, cloned); + + // Test BorrowStatus enum + let status_healthy = BorrowStatus::Healthy; + let status_maintenance = BorrowStatus::MaintenanceRequired; + let status_liquidation = BorrowStatus::Liquidation(LiquidationReason::Undercollateralization); + let status_liquidation_exp = BorrowStatus::Liquidation(LiquidationReason::Expiration); + + // Test comparisons + let _ = status_healthy == status_maintenance; + let _ = status_liquidation == status_liquidation_exp; + let _ = status_healthy < status_maintenance; +}); diff --git a/fuzz/fuzz_targets/fuzz_borrow_invariants.rs b/fuzz/fuzz_targets/fuzz_borrow_invariants.rs new file mode 100644 index 00000000..d26c4dfa --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_borrow_invariants.rs @@ -0,0 +1,150 @@ +#![no_main] +#![cfg(not(target_arch = "wasm32"))] + +use libfuzzer_sys::fuzz_target; +use templar_common::{ + asset::{BorrowAssetAmount, CollateralAssetAmount}, + borrow::BorrowPosition, +}; + +fuzz_target!(|data: (u32, u128, u128, u128, u128, u128, bool, bool)| { + let ( + snapshot_index, + collateral_1, + collateral_2, + borrow_1, + _borrow_2, + in_flight, + has_timestamp, + has_lock, + ) = data; + + // Test collateral calculations + let mut position = BorrowPosition::new(snapshot_index); + position.collateral_asset_deposit = CollateralAssetAmount::new(collateral_1); + + if has_lock { + position.liquidation_lock = CollateralAssetAmount::new(collateral_2); + } + + let total_collateral = position.get_total_collateral_amount(); + + // Invariant: total collateral >= deposit + assert!( + total_collateral >= position.collateral_asset_deposit, + "Total collateral should be >= deposit" + ); + + // Test borrow liability calculations + position.borrow_asset_principal = BorrowAssetAmount::new(borrow_1); + position.borrow_asset_in_flight = BorrowAssetAmount::new(in_flight); + + let total_liability = position.get_total_borrow_asset_liability(); + + // Invariant: total liability >= principal + assert!( + total_liability >= position.borrow_asset_principal, + "Total liability should be >= principal" + ); + + // Invariant: total liability >= in_flight + assert!( + total_liability >= position.borrow_asset_in_flight, + "Total liability should be >= in_flight" + ); + + // Test exists logic + if !position.collateral_asset_deposit.is_zero() || !total_liability.is_zero() { + assert!( + position.exists(), + "Position should exist with non-zero amounts" + ); + } + + // Test can_be_removed logic + if position.collateral_asset_deposit.is_zero() + && total_liability.is_zero() + && position.borrow_asset_in_flight.is_zero() + && position.liquidation_lock.is_zero() + { + assert!( + !position.exists(), + "Position should be removable when all zero" + ); + } else { + assert!( + position.exists(), + "Position should not be removable with non-zero amounts" + ); + } + + // Test timestamp handling + if has_timestamp { + position.started_at_block_timestamp_ms = Some(near_sdk::json_types::U64(1_000_000)); + assert!(position.started_at_block_timestamp_ms.is_some()); + } + + // Test multiple operations in sequence + let mut seq_position = BorrowPosition::new(snapshot_index); + + // Step 1: Add collateral + seq_position.collateral_asset_deposit = CollateralAssetAmount::new(collateral_1); + let step1_collateral = seq_position.get_total_collateral_amount(); + assert_eq!(step1_collateral, collateral_1.into()); + + // Step 2: Add borrow + seq_position.borrow_asset_principal = BorrowAssetAmount::new(borrow_1); + let step2_liability = seq_position.get_total_borrow_asset_liability(); + assert!(step2_liability >= borrow_1.into()); + + // Step 3: Add in_flight + seq_position.borrow_asset_in_flight = BorrowAssetAmount::new(in_flight); + let step3_liability = seq_position.get_total_borrow_asset_liability(); + assert!(step3_liability >= step2_liability); + + // Step 4: Add liquidation lock + if collateral_2 <= collateral_1 { + seq_position.liquidation_lock = CollateralAssetAmount::new(collateral_2); + let step4_collateral = seq_position.get_total_collateral_amount(); + assert!(step4_collateral >= step1_collateral); + } + + // Test edge cases + + // Edge case 1: Maximum values that don't overflow + let mut max_pos = BorrowPosition::new(snapshot_index); + max_pos.collateral_asset_deposit = CollateralAssetAmount::new(u128::MAX / 2); + max_pos.liquidation_lock = CollateralAssetAmount::new(u128::MAX / 2); + let _ = max_pos.get_total_collateral_amount(); // Should not panic + + // Edge case 2: Zero position + let zero_pos = BorrowPosition::new(0); + assert_eq!(zero_pos.get_total_collateral_amount(), 0.into()); + assert_eq!(zero_pos.get_total_borrow_asset_liability(), 0.into()); + assert!(!zero_pos.exists()); + assert!(zero_pos.exists()); + + // Edge case 3: Only fees, no principal + let mut fee_pos = BorrowPosition::new(snapshot_index); + fee_pos.borrow_asset_principal = BorrowAssetAmount::zero(); + let fee_liability = fee_pos.get_total_borrow_asset_liability(); + // Liability should still be calculable even with zero principal + let _ = fee_liability; + + // Edge case 4: Only in_flight, no principal + let mut flight_pos = BorrowPosition::new(snapshot_index); + flight_pos.borrow_asset_in_flight = BorrowAssetAmount::new(borrow_1); + let flight_liability = flight_pos.get_total_borrow_asset_liability(); + assert!(flight_liability >= borrow_1.into()); + + // Test equality and cloning + let original = position.clone(); + assert_eq!(position, original); + + // Modify and test inequality + let mut modified = position.clone(); + modified.collateral_asset_deposit = CollateralAssetAmount::new(collateral_2); + if collateral_1 != collateral_2 { + assert_ne!(position, modified); + } +}); diff --git a/fuzz/fuzz_targets/fuzz_collateral_combined.rs b/fuzz/fuzz_targets/fuzz_collateral_combined.rs new file mode 100644 index 00000000..27bf4db1 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_collateral_combined.rs @@ -0,0 +1,92 @@ +#![no_main] +#![cfg(not(target_arch = "wasm32"))] + +use libfuzzer_sys::fuzz_target; +use templar_common::{ + asset::{BorrowAssetAmount, CollateralAssetAmount}, + borrow::BorrowPosition, +}; + +fuzz_target!(|data: (u32, u128, u128)| { + let (snapshot_index, collateral_amount, lock_amount) = data; + + let mut position = BorrowPosition::new(snapshot_index); + + // Verify initial state + assert_eq!( + position.get_total_collateral_amount(), + CollateralAssetAmount::zero() + ); + + // Set both collateral deposit and liquidation lock + position.collateral_asset_deposit = CollateralAssetAmount::new(collateral_amount); + position.liquidation_lock = CollateralAssetAmount::new(lock_amount); + + // Test that total collateral is the sum of both + let total = position.get_total_collateral_amount(); + let expected_u128 = collateral_amount.saturating_add(lock_amount); + + assert_eq!(u128::from(total), expected_u128); + + // Test individual components are preserved + assert_eq!( + u128::from(position.collateral_asset_deposit), + collateral_amount + ); + assert_eq!(u128::from(position.liquidation_lock), lock_amount); + + // Test existence logic with both amounts + if collateral_amount > 0 || lock_amount > 0 { + assert!( + position.exists(), + "Position should exist with any non-zero collateral or lock" + ); + } else { + assert!( + !position.exists(), + "Position should not exist when both amounts are zero" + ); + } + + // Test that borrow amounts remain unaffected + assert_eq!( + position.get_borrow_asset_principal(), + BorrowAssetAmount::zero() + ); + assert_eq!( + position.get_total_borrow_asset_liability(), + BorrowAssetAmount::zero() + ); + + // Test math properties: total >= individual components + assert!(u128::from(total) >= collateral_amount); + assert!(u128::from(total) >= lock_amount); + + // Test updating one component at a time + let new_collateral = collateral_amount.saturating_add(1000); + position.collateral_asset_deposit = CollateralAssetAmount::new(new_collateral); + + let updated_total = position.get_total_collateral_amount(); + let expected_updated = new_collateral.saturating_add(lock_amount); + assert_eq!(u128::from(updated_total), expected_updated); + + // Test updating the other component + let new_lock = lock_amount.saturating_add(2000); + position.liquidation_lock = CollateralAssetAmount::new(new_lock); + + let final_total = position.get_total_collateral_amount(); + let expected_final = new_collateral.saturating_add(new_lock); + assert_eq!(u128::from(final_total), expected_final); + + // Test clearing both components + position.collateral_asset_deposit = CollateralAssetAmount::zero(); + position.liquidation_lock = CollateralAssetAmount::zero(); + + let cleared_total = position.get_total_collateral_amount(); + assert_eq!(cleared_total, CollateralAssetAmount::zero()); + assert!( + !position.exists(), + "Position should not exist after clearing both amounts" + ); +}); + diff --git a/fuzz/fuzz_targets/fuzz_collateral_deposit.rs b/fuzz/fuzz_targets/fuzz_collateral_deposit.rs new file mode 100644 index 00000000..bf0c84f5 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_collateral_deposit.rs @@ -0,0 +1,75 @@ +#![no_main] +#![cfg(not(target_arch = "wasm32"))] + +use libfuzzer_sys::fuzz_target; +use templar_common::{ + asset::{BorrowAssetAmount, CollateralAssetAmount}, + borrow::BorrowPosition, +}; + +fuzz_target!(|data: (u32, u128)| { + let (snapshot_index, collateral_amount) = data; + + let mut position = BorrowPosition::new(snapshot_index); + + // Verify initial state - zero collateral + assert_eq!( + position.get_total_collateral_amount(), + CollateralAssetAmount::zero() + ); + assert_eq!( + position.collateral_asset_deposit, + CollateralAssetAmount::zero() + ); + + // Set collateral deposit + position.collateral_asset_deposit = CollateralAssetAmount::new(collateral_amount); + + // Test that total collateral equals deposit (no liquidation lock) + let total = position.get_total_collateral_amount(); + assert_eq!(total, position.collateral_asset_deposit); + assert_eq!(u128::from(total), collateral_amount); + + // Test existence logic + if collateral_amount > 0 { + assert!( + position.exists(), + "Position should exist with non-zero collateral" + ); + } else { + assert!( + !position.exists(), + "Position should not exist with zero collateral" + ); + } + + // Verify liquidation lock remains zero + assert_eq!(position.liquidation_lock, CollateralAssetAmount::zero()); + + // Verify borrow amounts remain zero (collateral operations don't affect them) + assert_eq!( + position.get_borrow_asset_principal(), + BorrowAssetAmount::zero() + ); + assert_eq!( + position.get_total_borrow_asset_liability(), + BorrowAssetAmount::zero() + ); + assert_eq!(position.borrow_asset_principal, BorrowAssetAmount::zero()); + assert_eq!(position.borrow_asset_in_flight, BorrowAssetAmount::zero()); + + // Test that we can update the deposit + let new_amount = collateral_amount.saturating_add(1000); + position.collateral_asset_deposit = CollateralAssetAmount::new(new_amount); + + let updated_total = position.get_total_collateral_amount(); + assert_eq!(u128::from(updated_total), new_amount); + + if new_amount > 0 { + assert!( + position.exists(), + "Position should exist after deposit update" + ); + } +}); + diff --git a/fuzz/fuzz_targets/fuzz_collateral_ops.rs b/fuzz/fuzz_targets/fuzz_collateral_ops.rs new file mode 100644 index 00000000..27923d27 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_collateral_ops.rs @@ -0,0 +1,41 @@ +#![no_main] +#![cfg(not(target_arch = "wasm32"))] + +use libfuzzer_sys::fuzz_target; +use templar_common::{ + asset::{BorrowAssetAmount, CollateralAssetAmount}, + borrow::BorrowPosition, +}; + +fuzz_target!(|data: (u32, u128, u128, u128, u128,)| { + let (snapshot_index, collateral_amount, principal_amount, _fees_amount, in_flight_amount) = + data; + + // Create a new borrow position + let mut position = BorrowPosition::new(snapshot_index); + + // Test basic getters on empty position + let _ = position.get_borrow_asset_principal(); + let _ = position.get_total_borrow_asset_liability(); + let _ = position.get_total_collateral_amount(); + let _ = !position.exists(); + let _ = position.exists(); + + // Fuzz setting various amounts + position.collateral_asset_deposit = CollateralAssetAmount::new(collateral_amount); + position.borrow_asset_principal = BorrowAssetAmount::new(principal_amount); + position.borrow_asset_in_flight = BorrowAssetAmount::new(in_flight_amount); + position.liquidation_lock = CollateralAssetAmount::new(0); // Start with no lock + + // Test getters with populated values + let collateral = position.get_total_collateral_amount(); + assert_eq!(collateral, position.collateral_asset_deposit); + let principal = position.get_borrow_asset_principal(); + assert_eq!(principal, position.borrow_asset_principal); + + // Test collateral operations + let mut pos = BorrowPosition::new(snapshot_index); + pos.collateral_asset_deposit = CollateralAssetAmount::new(collateral_amount); + let total = pos.get_total_collateral_amount(); + assert_eq!(total, pos.collateral_asset_deposit); +}); diff --git a/fuzz/fuzz_targets/fuzz_collateral_overflow.rs b/fuzz/fuzz_targets/fuzz_collateral_overflow.rs new file mode 100644 index 00000000..4dd26c5c --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_collateral_overflow.rs @@ -0,0 +1,84 @@ +#![no_main] +#![cfg(not(target_arch = "wasm32"))] + +use libfuzzer_sys::fuzz_target; +use templar_common::{ + asset::{BorrowAssetAmount, CollateralAssetAmount}, + borrow::BorrowPosition, +}; + +fuzz_target!(|data: (u32, bool)| { + let (snapshot_index, use_exact_max) = data; + + let mut position = BorrowPosition::new(snapshot_index); + + // Test with large values that could cause overflow + let large_value = if use_exact_max { + u128::MAX / 2 // Exactly half of max + } else { + u128::MAX / 3 // One third of max + }; + + // Set both collateral deposit and liquidation lock to large values + position.collateral_asset_deposit = CollateralAssetAmount::new(large_value); + position.liquidation_lock = CollateralAssetAmount::new(large_value); + + // This should not panic due to saturating arithmetic + let total = position.get_total_collateral_amount(); + + // Total should be at least as large as each component + assert!(u128::from(total) >= large_value); + + // Position should exist with these large values + assert!( + position.exists(), + "Position should exist with large collateral values" + ); + + // Test individual components are preserved + assert_eq!(u128::from(position.collateral_asset_deposit), large_value); + assert_eq!(u128::from(position.liquidation_lock), large_value); + + // Test that the total is the saturated sum + let expected_total = large_value.saturating_add(large_value); + assert_eq!(u128::from(total), expected_total); + + // Test edge case: setting one component to u128::MAX + position.collateral_asset_deposit = CollateralAssetAmount::new(u128::MAX); + position.liquidation_lock = CollateralAssetAmount::new(1); + + let max_total = position.get_total_collateral_amount(); + // Should saturate at u128::MAX, not overflow + assert_eq!(u128::from(max_total), u128::MAX); + assert!(position.exists()); + + // Test other extreme: both at u128::MAX + position.liquidation_lock = CollateralAssetAmount::new(u128::MAX); + + let double_max_total = position.get_total_collateral_amount(); + // Should still saturate at u128::MAX + assert_eq!(u128::from(double_max_total), u128::MAX); + assert!(position.exists()); + + // Verify borrow amounts remain zero even with extreme collateral values + assert_eq!( + position.get_borrow_asset_principal(), + BorrowAssetAmount::zero() + ); + assert_eq!( + position.get_total_borrow_asset_liability(), + BorrowAssetAmount::zero() + ); + + // Test that we can still clear the amounts after overflow scenarios + position.collateral_asset_deposit = CollateralAssetAmount::zero(); + position.liquidation_lock = CollateralAssetAmount::zero(); + + let cleared_total = position.get_total_collateral_amount(); + assert_eq!(cleared_total, CollateralAssetAmount::zero()); + assert!( + !position.exists(), + "Position should not exist after clearing extreme amounts" + ); +}); + diff --git a/fuzz/fuzz_targets/fuzz_decimal_arithmetic.rs b/fuzz/fuzz_targets/fuzz_decimal_arithmetic.rs new file mode 100644 index 00000000..1c701997 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_decimal_arithmetic.rs @@ -0,0 +1,162 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use std::str::FromStr; +use templar_common::number::Decimal; + +fuzz_target!(|data: (u128, u128, u128, i32, i32, u32, u8)| { + let (a, b, c, exp1, _exp2, pow_exp, op_selector) = data; + + // Create decimals from various u128 values + let dec_a = Decimal::from(a); + let dec_b = Decimal::from(b); + let dec_c = Decimal::from(c); + + // Fuzz basic arithmetic operations + // Addition + let _ = dec_a + dec_b; + let _ = dec_a + dec_b + dec_c; + let mut mut_a = dec_a; + mut_a += dec_b; + + // Subtraction (only if a >= b to avoid underflow in unsigned context) + if a >= b { + let _ = dec_a - dec_b; + let mut mut_sub = dec_a; + mut_sub -= dec_b; + } + + // Multiplication + let _ = dec_a * dec_b; + let _ = dec_a * dec_b * dec_c; + let mut mut_mul = dec_a; + mut_mul *= dec_b; + + // Division (avoid division by zero) + if b > 0 { + let _ = dec_a / dec_b; + let mut mut_div = dec_a; + mut_div /= dec_b; + } + + // Mixed operations with integers + let _ = dec_a * 2u32; + let _ = 3u64 * dec_b; + let _ = dec_a / 10u128; + if b > 0 { + let _ = 100u128 / dec_b; + } + + // Fuzz power operations + #[allow(clippy::cast_possible_wrap, reason = "Fuzzing context")] + let small_pow = (pow_exp % 20) as i32; // Keep small to avoid overflows + let _ = dec_a.pow(small_pow); + let _ = dec_a.pow(-small_pow); + let _ = dec_a.pow(0); + let _ = dec_a.pow(1); + + // Fuzz pow2_int + let pow2_exp = pow_exp % 384; // Keep within valid range + let _ = Decimal::pow2_int(pow2_exp); + + // Fuzz pow2 + if dec_a <= Decimal::ONE { + let _ = dec_a.pow2(); + } + + // Fuzz mul_pow10 + let safe_exp1 = exp1.clamp(-38, 115); // Within valid range + let _ = dec_a.mul_pow10(safe_exp1); + let _ = dec_b.mul_pow10(-safe_exp1); + + // Fuzz comparison operations + let _ = dec_a == dec_b; + let _ = dec_a < dec_b; + let _ = dec_a > dec_b; + let _ = dec_a <= dec_b; + let _ = dec_a >= dec_b; + let _ = dec_a.near_equal(dec_b); + + // Fuzz conversions + let _ = dec_a.to_u128_floor(); + let _ = dec_a.to_u128_ceil(); + let _ = dec_a.to_f64_lossy(); + + // Fuzz string operations + let _ = dec_a.to_fixed(38); + let _ = dec_a.to_fixed(10); + let _ = dec_a.to_fixed(0); + + // Fuzz abs_diff + let _ = dec_a.abs_diff(dec_b); + let _ = dec_b.abs_diff(dec_a); + + // Fuzz is_zero + let _ = dec_a.is_zero(); + let _ = Decimal::ZERO.is_zero(); + + // Fuzz fractional part + let _ = dec_a.fractional_part_as_u128_dividend(); + + // Test edge cases based on operation selector + match op_selector % 10 { + 0 => { + // Test with constants + let _ = dec_a + Decimal::ONE; + let _ = dec_a * Decimal::ZERO; + let _ = dec_a + Decimal::TWO; + } + 1 => { + // Test with ONE_HALF + let _ = dec_a * Decimal::ONE_HALF; + if b > 0 { + let _ = Decimal::ONE_HALF / dec_b; + } + } + 2 => { + // Test with MAX and MIN + let _ = Decimal::MAX.to_u128_floor(); + let _ = Decimal::MIN.is_zero(); + } + 3 => { + // Test pow with specific values + let _ = Decimal::TWO.pow(10); + let _ = Decimal::ONE_HALF.pow(5); + } + 4 => { + // Test mul_pow10 edge cases + let _ = Decimal::ONE.mul_pow10(0); + let _ = dec_a.mul_pow10(1); + let _ = dec_a.mul_pow10(-1); + } + 5 => { + // Test with E and LN2 constants + let _ = Decimal::E + dec_a; + let _ = Decimal::LN2 * dec_b; + } + 6 => { + // Chained operations + if b > 0 && c > 0 { + let _ = (dec_a + dec_b) * dec_c / Decimal::TWO; + } + } + 7 => { + // Test near_equal with close values + let close_a = dec_a + Decimal::from(1u32); + let _ = dec_a.near_equal(close_a); + } + 8 => { + // Test ceiling and floor differences + if let (Some(floor), Some(ceil)) = (dec_a.to_u128_floor(), dec_a.to_u128_ceil()) { + let _ = ceil >= floor; + } + } + _ => { + // Test string round-trip + let str_repr = dec_a.to_fixed(20); + if let Ok(parsed) = Decimal::from_str(&str_repr) { + let _ = dec_a.near_equal(parsed); + } + } + } +}); diff --git a/fuzz/fuzz_targets/fuzz_decimal_parsing.rs b/fuzz/fuzz_targets/fuzz_decimal_parsing.rs new file mode 100644 index 00000000..80d1edb0 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_decimal_parsing.rs @@ -0,0 +1,86 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use std::str::FromStr; +use templar_common::number::Decimal; + +fuzz_target!(|data: &[u8]| { + // Try to parse as UTF-8 string + if let Ok(s) = std::str::from_utf8(data) { + // Attempt to parse as Decimal + if let Ok(decimal) = Decimal::from_str(s) { + // If parsing succeeds, test round-trip + let to_string = decimal.to_fixed(38); + + // Parse again and check near equality + if let Ok(reparsed) = Decimal::from_str(&to_string) { + assert!( + decimal.near_equal(reparsed), + "Round-trip failed: original={decimal:?}, string={to_string}, reparsed={reparsed:?}", + ); + } + + // Test various precision levels + for precision in [0, 1, 5, 10, 20, 38] { + let fixed = decimal.to_fixed(precision); + let _ = Decimal::from_str(&fixed); + } + + // Test conversions + let _ = decimal.to_u128_floor(); + let _ = decimal.to_u128_ceil(); + let _ = decimal.to_f64_lossy(); + + // Test that is_zero is consistent + if decimal.is_zero() { + assert_eq!(decimal, Decimal::ZERO); + } + + // Test basic operations on parsed value + let _ = decimal + Decimal::ONE; + let _ = decimal * Decimal::TWO; + if !decimal.is_zero() { + let _ = Decimal::ONE / decimal; + } + } + } + + // Test with constructed string patterns + if data.len() >= 2 { + let whole_part = u128::from(data[0]); + let frac_digit = data[1] % 10; + + // Create a decimal string manually + let test_str = format!("{whole_part}.{frac_digit}"); + if let Ok(decimal) = Decimal::from_str(&test_str) { + let _ = decimal.to_fixed(10); + } + } + + // Test edge case strings + let edge_cases = [ + "0", + "1", + "0.0", + "1.0", + "0.1", + "0.00000001", + "999999999999999", + ]; + + for case in edge_cases { + if let Ok(decimal) = Decimal::from_str(case) { + let stringified = decimal.to_fixed(38); + let _ = Decimal::from_str(&stringified); + } + } + + // Test malformed strings don't panic + let malformed = [ + ".", ".0", "0.", "abc", "1.2.3", "-1", "1e10", "NaN", "Infinity", "", + ]; + + for mal in malformed { + let _ = Decimal::from_str(mal); + } +}); diff --git a/fuzz/fuzz_targets/fuzz_decimals.rs b/fuzz/fuzz_targets/fuzz_decimals.rs new file mode 100644 index 00000000..dd9916e2 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_decimals.rs @@ -0,0 +1,35 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use templar_common::interest_rate_strategy::UsageCurve; +use templar_common::interest_rate_strategy::{Exponential2, Piecewise}; +use templar_common::number::Decimal; + +// Helper to convert u64 to a Decimal in [0, 1] +fn to_decimal01(x: u64) -> Decimal { + Decimal::from(x) / Decimal::from(u64::MAX) +} + +fuzz_target!(|data: (u64, u64, u64, u64, u64, u64, u64)| { + // Fuzz Piecewise + let base = to_decimal01(data.0); + let optimal = to_decimal01(data.1); // must be <= 1 + let rate_1 = to_decimal01(data.2); + let rate_2 = to_decimal01(data.3); + let usage = to_decimal01(data.4); + + if let Some(piecewise) = Piecewise::new(base, optimal, rate_1, rate_2) { + // Should not panic for usage <= 1 + let _ = piecewise.at(usage.min(Decimal::ONE)); + } + + // Fuzz Exponential2 + let base = to_decimal01(data.0); + let top = to_decimal01(data.5).max(base); // top >= base + let eccentricity = to_decimal01(data.6) * Decimal::from(24u32); // [0,24] + let usage = to_decimal01(data.4); + + if let Some(exp2) = Exponential2::new(base, top, eccentricity) { + let _ = exp2.at(usage.min(Decimal::ONE)); + } +}); diff --git a/fuzz/fuzz_targets/fuzz_interest_math.rs b/fuzz/fuzz_targets/fuzz_interest_math.rs new file mode 100644 index 00000000..cb4460d0 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_interest_math.rs @@ -0,0 +1,189 @@ +// Fuzzes interest rate calculations and compound interest to find overflow bugs + +#![no_main] + +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; + +#[derive(Arbitrary, Debug)] +struct InterestScenario { + principal: u64, + interest_rate_bps: u16, // Basis points (10000 = 100%) + time_periods: u16, // Number of compounding periods + utilization_rate: u8, // 0-100% +} + +fuzz_target!(|scenario: InterestScenario| { + // Validate inputs are reasonable + if scenario.interest_rate_bps > 50000 { + // Max 500% APR + return; + } + if scenario.utilization_rate > 100 { + return; + } + if scenario.time_periods > 365 * 10 { + // Max 10 years + return; + } + + let principal = u128::from(scenario.principal); + let rate_bps = u128::from(scenario.interest_rate_bps); + let periods = u128::from(scenario.time_periods); + + // Test 1: Simple interest calculation + // Interest = Principal * Rate * Time + if let Some(simple_interest) = principal + .checked_mul(rate_bps) + .and_then(|x| x.checked_mul(periods)) + .and_then(|x| x.checked_div(10000)) + { + // Invariant: Simple interest should never exceed principal * rate * periods + assert!( + simple_interest <= principal.saturating_mul(rate_bps).saturating_mul(periods) / 10000, + "Simple interest overflow" + ); + } + + // Test 2: Compound interest calculation + // A = P(1 + r)^n + // For small rates, use binomial approximation to avoid overflow + // if rate_bps <= 1000 && periods <= 365 { + // let rate_per_period = rate_bps as f64 / 10000.0; + // let compound_multiplier = (1.0 + rate_per_period).powi(periods as i32); + // + // if compound_multiplier.is_finite() && compound_multiplier > 0.0 { + // let result = (principal as f64 * compound_multiplier) as u128; + // + // // Invariant: Result should always be >= principal + // assert!( + // result >= principal, + // "Compound interest resulted in less than principal" + // ); + // + // // Invariant: Result shouldn't be absurdly large + // assert!( + // result <= principal.saturating_mul(100), + // "Compound interest grew unreasonably" + // ); + // } + // } + + // Test 3: Utilization rate calculations + // Utilization = TotalBorrowed / TotalSupplied + let utilization = u128::from(scenario.utilization_rate); + + if utilization > 0 && utilization <= 100 { + // Calculate borrow rate based on utilization + // Common model: BorrowRate = BaseRate + UtilRate * Slope + let base_rate = 200u128; // 2% base + let slope = 1000u128; // 10% slope + + if let Some(borrow_rate) = + base_rate.checked_add(utilization.checked_mul(slope).unwrap_or(0) / 100) + { + // Invariant: Borrow rate should be >= base rate + assert!(borrow_rate >= base_rate, "Borrow rate below base"); + + // Invariant: Borrow rate should increase with utilization + // (This is implicit in the formula but good to verify) + + // Calculate supply rate + // SupplyRate = BorrowRate * Utilization * (1 - ReserveFactor) + let reserve_factor = 1000u128; // 10% + if let Some(supply_rate) = borrow_rate + .checked_mul(utilization) + .and_then(|x| x.checked_mul(10000 - reserve_factor)) + .and_then(|x| x.checked_div(1_000_000)) + { + // Invariant: Supply rate must be < borrow rate + assert!( + supply_rate <= borrow_rate, + "Supply rate exceeds borrow rate: {supply_rate} > {borrow_rate}", + ); + + // Invariant: At 100% utilization, supply rate should approach borrow rate + if utilization == 100 { + let expected_max = borrow_rate * (10000 - reserve_factor) / 10000; + assert!( + supply_rate <= expected_max, + "Supply rate too high at full utilization" + ); + } + } + } + } + + // Test 4: Interest accrual over time + // Simulate multiple periods + let mut balance = principal; + for _ in 0..periods.min(100) { + // Limit iterations + if let Some(interest) = balance + .checked_mul(rate_bps) + .and_then(|x| x.checked_div(10000)) + { + if let Some(new_balance) = balance.checked_add(interest) { + // Invariant: Balance should always increase (unless rate is 0) + if rate_bps > 0 { + assert!( + new_balance >= balance, + "Balance didn't increase with interest" + ); + if interest > 0 { + assert!( + new_balance > balance, + "Balance didn't strictly increase when interest > 0" + ); + } + } + balance = new_balance; + } else { + // Overflow is acceptable, just stop iterating + break; + } + } else { + break; + } + } + + // Test 5: Exchange rate calculations + // ExchangeRate = (TotalCash + TotalBorrows - TotalReserves) / TotalSupply + let total_cash = principal; + let total_borrows = principal.saturating_mul(utilization) / 100; + let total_reserves = total_borrows / 10; // 10% reserve + let total_supply = principal; + + if total_supply > 0 { + let numerator = total_cash + .saturating_add(total_borrows) + .saturating_sub(total_reserves); + + let exchange_rate = numerator / total_supply; + + // Invariant: Exchange rate should be close to 1:1 initially + // and should only increase over time (never decrease) + assert!(exchange_rate > 0, "Exchange rate is zero or negative"); + + // Invariant: Exchange rate shouldn't deviate wildly + assert!( + exchange_rate <= 10, + "Exchange rate is unreasonably high: {exchange_rate}", + ); + } +}); + +// ============================================================================ +// CRITICAL BUGS TO LOOK FOR: +// ============================================================================ +// 1. Integer overflow when calculating compound interest +// 2. Underflow when principal < interest payment +// 3. Division by zero in utilization rate calculations +// 4. Rounding errors that favor borrowers/lenders +// 5. Interest rate manipulation through edge cases +// 6. Exchange rate manipulation +// 7. Reserve factor calculations that drain protocol +// 8. Time-weighted average calculations +// +// Run with: +// cargo +nightly fuzz run fuzz_interest_math -- -max_total_time=600 diff --git a/fuzz/fuzz_targets/fuzz_liquidations.rs b/fuzz/fuzz_targets/fuzz_liquidations.rs new file mode 100644 index 00000000..5320ceec --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_liquidations.rs @@ -0,0 +1,201 @@ +// Fuzzes liquidation logic to ensure it's profitable and fair + +#![no_main] + +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; + +#[derive(Arbitrary, Debug)] +struct LiquidationScenario { + // Borrower's position + collateral_amount: u64, + borrowed_amount: u64, + + // Price oracle data + collateral_price: u32, // Price in USD (scaled by 1e6) + borrow_price: u32, + + // Liquidation attempt + liquidation_amount: u64, + + // Protocol parameters + collateral_ratio: u16, + liquidation_threshold: u16, + liquidation_bonus: u16, +} + +fuzz_target!(|scenario: LiquidationScenario| { + // Validate inputs + if scenario.collateral_price == 0 || scenario.borrow_price == 0 { + return; + } + if scenario.collateral_ratio < 10000 { + // Must be > 100% + return; + } + if scenario.liquidation_threshold >= scenario.collateral_ratio { + return; + } + if scenario.liquidation_bonus < 10000 || scenario.liquidation_bonus > 12000 { + return; + } + let u64_max = u128::from(u64::MAX); + + // Scale amounts to avoid overflow + let collateral = u128::from(scenario.collateral_amount).min(u64_max / 1_000_000); + let borrowed = u128::from(scenario.borrowed_amount).min(u64_max / 1_000_000); + let liquidate_amount = u128::from(scenario.liquidation_amount).min(borrowed); + + let collateral_price = u128::from(scenario.collateral_price); + let borrow_price = u128::from(scenario.borrow_price); + + // Calculate position values + let collateral_value = collateral.saturating_mul(collateral_price); + let borrowed_value = borrowed.saturating_mul(borrow_price); + + if borrowed_value == 0 { + return; + } + + // Calculate health factor + // health_factor = (collateral_value * liquidation_threshold) / (borrowed_value * 10000) + let health_numerator = + collateral_value.saturating_mul(u128::from(scenario.liquidation_threshold)); + let health_denominator = borrowed_value.saturating_mul(10000); + + if health_denominator == 0 { + return; + } + + let health_factor = health_numerator / health_denominator; + + // Test 2: Calculate maximum liquidatable amount + // Typically limited to 50% of debt or full debt if near insolvency + let max_liquidate = if health_factor < 5000 { + borrowed // Can liquidate full position if very underwater + } else { + borrowed / 2 // Max 50% otherwise + }; + + let actual_liquidate = liquidate_amount.min(max_liquidate); + + // Test 3: Calculate collateral to seize + // seized_collateral = (liquidate_amount * borrow_price * liquidation_bonus) / collateral_price + let seized_value = actual_liquidate + .saturating_mul(borrow_price) + .saturating_mul(u128::from(scenario.liquidation_bonus)) + / 10000; + + let seized_collateral = if collateral_price > 0 { + seized_value / collateral_price + } else { + return; + }; + + // Invariant 1: Seized collateral shouldn't exceed available collateral + assert!( + seized_collateral <= collateral, + "Liquidation tried to seize more collateral than available: {seized_collateral} > {collateral}", + ); + + // Invariant 2: Liquidator profit is bounded by liquidation bonus + let liquidator_profit_value = + seized_value.saturating_sub(actual_liquidate.saturating_mul(borrow_price)); + let max_profit = actual_liquidate + .saturating_mul(borrow_price) + .saturating_mul(u128::from(scenario.liquidation_bonus.saturating_sub(10000))) + / 10000; + + assert!( + liquidator_profit_value <= max_profit, + "Liquidator profit exceeds bonus: {liquidator_profit_value} > {max_profit}", + ); + + // Invariant 3: Borrowed amount decreases by liquidation amount + let new_borrowed = borrowed.saturating_sub(actual_liquidate); + assert!( + new_borrowed < borrowed || actual_liquidate == 0, + "Borrowed amount didn't decrease" + ); + + // Invariant 4: After liquidation, remaining position should be healthier + let remaining_collateral = collateral.saturating_sub(seized_collateral); + let remaining_borrowed_value = new_borrowed.saturating_mul(borrow_price); + + if remaining_borrowed_value > 0 && remaining_collateral > 0 { + let remaining_collateral_value = remaining_collateral.saturating_mul(collateral_price); + let new_health_numerator = + remaining_collateral_value.saturating_mul(u128::from(scenario.liquidation_threshold)); + let new_health_factor = + new_health_numerator / remaining_borrowed_value.saturating_mul(10000); + + // New health should be >= old health (position improved) + // Allow small margin for rounding + assert!( + new_health_factor >= health_factor.saturating_sub(100), + "Liquidation made position worse: old_health={health_factor} new_health={new_health_factor}", + ); + } + + // Invariant 5: Protocol shouldn't lose money + // Total value of (borrowed repaid + remaining collateral) >= original collateral value + let repaid_value = actual_liquidate.saturating_mul(borrow_price); + let remaining_value = remaining_collateral.saturating_mul(collateral_price); + let total_recovered = repaid_value.saturating_add(remaining_value); + + // This might not hold for severely underwater positions, but check it's reasonable + if health_factor > 7000 { + // If not too underwater + assert!( + total_recovered <= collateral_value.saturating_mul(12000) / 10000, + "Protocol recovered too much value somehow" + ); + } + + // Test 4: Partial liquidations + if actual_liquidate < borrowed { + // After partial liquidation, some debt should remain + assert!(new_borrowed > 0, "Partial liquidation cleared all debt"); + + // And some collateral should remain + assert!( + remaining_collateral > 0, + "Partial liquidation took all collateral" + ); + } + + // Test 5: Multiple liquidations + // Simulate multiple small liquidations vs one large one + let num_liquidations = 3u128; + let small_liquidation = actual_liquidate / num_liquidations; + + if small_liquidation > 0 { + let mut running_collateral = collateral; + let mut running_debt = borrowed; + + for _ in 0..num_liquidations { + if running_debt == 0 { + break; + } + + let small_seized_value = small_liquidation + .saturating_mul(borrow_price) + .saturating_mul(u128::from(scenario.liquidation_bonus)) + / 10000; + let small_seized = small_seized_value / collateral_price; + + running_collateral = running_collateral.saturating_sub(small_seized); + running_debt = running_debt.saturating_sub(small_liquidation); + } + + // Multiple small liquidations shouldn't be significantly worse than one large one + // (Within rounding error) + let diff = remaining_collateral.abs_diff(running_collateral); + + // Allow 1% difference for rounding + assert!( + diff <= collateral / 100, + "Multiple liquidations deviate too much from single liquidation" + ); + } +}); diff --git a/fuzz/fuzz_targets/fuzz_liquidator_logic.rs b/fuzz/fuzz_targets/fuzz_liquidator_logic.rs new file mode 100644 index 00000000..ac182065 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_liquidator_logic.rs @@ -0,0 +1,139 @@ +#![no_main] +#![cfg(not(target_arch = "wasm32"))] + +use libfuzzer_sys::fuzz_target; +use near_sdk::json_types::U128; + +fuzz_target!(|data: (u128, u128, f64, bool)| { + let (swap_amount_raw, liquidation_amount_raw, exchange_rate, should_swap) = data; + + let swap_amount = U128(swap_amount_raw); + let liquidation_amount = U128(liquidation_amount_raw); + + // Fuzz the decision logic for liquidation profitability + // This simulates the should_liquidate logic + + // Test 1: Basic amount comparisons + let _ = swap_amount.0 > 0; + let _ = liquidation_amount.0 > 0; + let _ = swap_amount == liquidation_amount; + let _ = swap_amount.0 < liquidation_amount.0; + + // Test 2: Exchange rate calculations (mock swap quote logic) + if exchange_rate > 0.0 && exchange_rate.is_finite() { + #[allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss + )] + let input_amount = (liquidation_amount.0 as f64 / exchange_rate) as u128; + + // Verify calculations don't overflow + let _ = U128(input_amount); + + // Test profitability calculation + if swap_amount.0 > 0 && input_amount > 0 { + let profit = if liquidation_amount.0 > swap_amount.0 { + liquidation_amount.0.saturating_sub(swap_amount.0) + } else { + 0 + }; + + // Minimum profit threshold (e.g., 1%) + let min_profit = swap_amount.0 / 100; + let is_profitable = profit >= min_profit; + + let _ = is_profitable; + } + } + + // Test 3: Balance checks + let available_balance = U128(swap_amount_raw.saturating_mul(2)); + let has_sufficient_balance = available_balance.0 >= swap_amount.0; + let _ = has_sufficient_balance; + + // Test 4: Swap amount calculation with different asset balances + let asset_balance = U128(swap_amount_raw / 2); + let needs_swap = if asset_balance.0 >= liquidation_amount.0 { + U128(0) + } else { + U128(liquidation_amount.0.saturating_sub(asset_balance.0)) + }; + + assert!( + needs_swap.0 <= liquidation_amount.0, + "Swap need should not exceed liquidation amount" + ); + + // Test 5: Multiple swap scenarios + if should_swap { + // Test scenario where we need to swap + let swap_needed = liquidation_amount.0.saturating_sub(asset_balance.0); + let _ = U128(swap_needed); + } + + // Test 6: Gas cost estimation (mock) + let gas_cost = 1000u128; // Mock gas cost + let total_cost = swap_amount.0.saturating_add(gas_cost); + let net_profit = liquidation_amount.0.saturating_sub(total_cost); + + let is_worth_liquidating = liquidation_amount.0 > total_cost; + let _ = is_worth_liquidating; + let _ = net_profit; + + // Test 7: Edge cases + + // Zero amounts + let zero_swap = U128(0); + let zero_liq = U128(0); + assert_eq!(zero_swap.0, 0); + assert_eq!(zero_liq.0, 0); + + // Maximum amounts + let max_swap = U128(u128::MAX); + let max_liq = U128(u128::MAX); + let _ = max_swap.0.saturating_add(1); + let _ = max_liq.0.saturating_sub(1); + + // Test 8: Ratio calculations + if liquidation_amount.0 > 0 { + // Calculate swap-to-liquidation ratio + #[allow(clippy::cast_precision_loss)] + let ratio = swap_amount.0 as f64 / liquidation_amount.0 as f64; + + if ratio.is_finite() { + // Healthy liquidation should have ratio < 1.0 (profit) + let is_healthy = ratio < 1.0; + let _ = is_healthy; + } + } + + // Test 9: Partial liquidation calculation + let max_liquidatable = U128(liquidation_amount_raw); + let requested_liquidation = U128(swap_amount_raw); + let actual_liquidation = if requested_liquidation.0 > max_liquidatable.0 { + max_liquidatable + } else { + requested_liquidation + }; + + assert!( + actual_liquidation.0 <= max_liquidatable.0, + "Actual liquidation should not exceed maximum" + ); + + // Test 10: Slippage calculation + let slippage_bps = 50u128; // 0.5% slippage + let slippage_amount = swap_amount.0.saturating_mul(slippage_bps) / 10000; + let swap_with_slippage = swap_amount.0.saturating_add(slippage_amount); + + assert!( + swap_with_slippage >= swap_amount.0, + "Swap with slippage should be >= original amount" + ); + + // Test 11: Minimum liquidation thresholds + let min_liquidation_threshold = U128(1000); // Minimum viable liquidation + let meets_threshold = liquidation_amount.0 >= min_liquidation_threshold.0; + let _ = meets_threshold; +}); diff --git a/fuzz/fuzz_targets/fuzz_liquidator_transactions.rs b/fuzz/fuzz_targets/fuzz_liquidator_transactions.rs new file mode 100644 index 00000000..c68795b3 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_liquidator_transactions.rs @@ -0,0 +1,175 @@ +#![no_main] +#![cfg(not(target_arch = "wasm32"))] + +use libfuzzer_sys::fuzz_target; +use near_sdk::{json_types::U128, AccountId}; +use std::str::FromStr; + +fuzz_target!(|data: (u128, u64, bool, u8, &[u8])| { + let (liquidation_amount_raw, nonce, is_nep141, account_suffix, account_name_bytes) = data; + + let liquidation_amount = U128(liquidation_amount_raw); + + // Test 1: Account ID creation from various inputs + let account_name = String::from_utf8_lossy(account_name_bytes); + let sanitized = account_name + .chars() + .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == '.') + .take(64) + .collect::(); + + if !sanitized.is_empty() { + let account_str = format!("{sanitized}.testnet"); + let _ = AccountId::from_str(&account_str); + } + + // Test 2: Generate valid account IDs for testing + let valid_accounts = [ + "borrower.testnet", + "liquidator.testnet", + "market.testnet", + "usdc.testnet", + ]; + + for account_str in valid_accounts { + #[allow(clippy::unwrap_used, reason = "Fuzzing valid inputs")] + let account_id = AccountId::from_str(account_str).unwrap(); + assert!(!account_id.as_str().is_empty()); + } + + // Test 3: Liquidation message creation + #[allow(clippy::unwrap_used, reason = "Fuzzing valid inputs")] + let borrower_account = AccountId::from_str("borrower.testnet").unwrap(); + + // Simulate DepositMsg::Liquidate creation + let liquidate_msg = + format!(r#"{{"Liquidate":{{"account_id":"{borrower_account}","amount":null}}}}"#,); + + // Verify the message is valid JSON-like + assert!(liquidate_msg.contains("Liquidate")); + assert!(liquidate_msg.contains(&borrower_account.to_string())); + + // Test with explicit amount + let liquidate_msg_with_amount = format!( + r#"{{"Liquidate":{{"account_id":"{}","amount":{}}}}}"#, + borrower_account, liquidation_amount.0 + ); + + assert!(liquidate_msg_with_amount.contains(&liquidation_amount.0.to_string())); + + // Test 4: Asset specification parsing + if is_nep141 { + // NEP-141 format: "nep141:contract.near" + let asset_spec = "nep141:usdc.testnet"; + assert!(asset_spec.starts_with("nep141:")); + + let parts: Vec<&str> = asset_spec.split(':').collect(); + assert_eq!(parts.len(), 2); + assert_eq!(parts[0], "nep141"); + + // Verify contract ID is valid + let _ = AccountId::from_str(parts[1]); + } else { + // NEP-245 format: "nep245:contract.near:token_id" + let asset_spec = "nep245:multitoken.testnet:eth"; + assert!(asset_spec.starts_with("nep245:")); + + let parts: Vec<&str> = asset_spec.split(':').collect(); + assert_eq!(parts.len(), 3); + assert_eq!(parts[0], "nep245"); + + // Verify contract ID is valid + let _ = AccountId::from_str(parts[1]); + assert!(!parts[2].is_empty()); // Token ID should not be empty + } + + // Test 5: Nonce handling + let nonce_incremented = nonce.saturating_add(1); + assert!(nonce_incremented >= nonce); + + // Test with max nonce + let max_nonce = u64::MAX; + let _ = max_nonce.saturating_add(1); // Should not overflow + + // Test 6: Liquidation amount boundaries + if liquidation_amount.0 == 0 { + // Edge case: zero liquidation + assert_eq!(liquidation_amount.0, 0); + } else { + // Normal case: positive liquidation + assert!(liquidation_amount.0 > 0); + } + + // Test 7: Balance calculations for transfer_call + let one_yocto = 1u128; // Attached deposit for transfer_call + let total_needed = liquidation_amount.0.saturating_add(one_yocto); + + assert!(total_needed >= liquidation_amount.0); + + // Test 8: Method name generation + let transfer_call_method = if is_nep141 { + "ft_transfer_call" + } else { + "mt_transfer_call" + }; + + assert!(!transfer_call_method.is_empty()); + assert!(transfer_call_method.ends_with("_call")); + + // Test 9: Multiple liquidation amounts + let amounts = [ + U128(0), + U128(1), + U128(1000), + U128(1_000_000), + U128(u128::MAX / 2), + U128(u128::MAX), + ]; + + for amount in amounts { + let msg = format!( + r#"{{"Liquidate":{{"account_id":"test.near","amount":{}}}}}"#, + amount.0 + ); + assert!(msg.contains("Liquidate")); + } + + // Test 10: Account suffix handling + let suffix_num = u32::from(account_suffix); + let account_with_suffix = format!("liquidator_{suffix_num}.testnet"); + + if let Ok(account_id) = AccountId::from_str(&account_with_suffix) { + assert!(account_id.as_str().contains("liquidator")); + } + + // Test 11: Edge case - empty liquidation + let empty_msg = r#"{"Liquidate":{"account_id":"test.near","amount":null}}"#; + assert!(empty_msg.contains("null")); + + // Test 12: Gas and timeout calculations + let timeout_seconds = 60u64; + let timeout_nanos = timeout_seconds.saturating_mul(1_000_000_000); + assert!(timeout_nanos >= timeout_seconds); + + // Test 13: Transaction action building + let receiver_id = "usdc.testnet"; + let method_name = "ft_transfer_call"; + let args = liquidate_msg.as_bytes(); + + assert!(!receiver_id.is_empty()); + assert!(!method_name.is_empty()); + assert!(!args.is_empty()); + + // Test 14: Block hash handling (mock) + let mock_block_hash = [0u8; 32]; + assert_eq!(mock_block_hash.len(), 32); + + // Test 15: Signer account validation + let signer_accounts = ["liquidator.testnet", "bot1.near", "system.testnet"]; + + for signer in signer_accounts { + if let Ok(account) = AccountId::from_str(signer) { + assert!(!account.as_str().is_empty()); + } + } +}); diff --git a/fuzz/fuzz_targets/fuzz_market_creation.rs b/fuzz/fuzz_targets/fuzz_market_creation.rs new file mode 100644 index 00000000..f8f667f1 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_market_creation.rs @@ -0,0 +1,210 @@ +#![no_main] +#![cfg(not(target_arch = "wasm32"))] + +use std::str::FromStr; + +use libfuzzer_sys::fuzz_target; +use near_sdk::AccountId; +use templar_common::{ + asset::FungibleAsset, + fee::{Fee, TimeBasedFee}, + interest_rate_strategy::InterestRateStrategy, + market::{MarketConfiguration, PriceOracleConfiguration, ValidAmountRange, YieldWeights}, + number::Decimal, + oracle::pyth::PriceIdentifier, + time_chunk::TimeChunkConfiguration, +}; + +pub const DEFAULT_COLLATERAL_PRICE_ID: PriceIdentifier = PriceIdentifier(hex_literal::hex!( + "cccccccc232290221461220bd4e2acd1dcdfbc89c84092c93c18bdc7756c1588" +)); +pub const DEFAULT_BORROW_PRICE_ID: PriceIdentifier = PriceIdentifier(hex_literal::hex!( + "bbbbbbbbf4f61076456d1a73b14c7edc1cf5cef4f4d6193a33424288f11bd0f4" +)); + +fn create_account_id(seed: u8) -> AccountId { + let name = format!("account{seed}.testnet"); + #[allow(clippy::unwrap_used, reason = "Fuzzing with valid inputs")] + AccountId::from_str(&name).unwrap() +} + +#[allow(clippy::too_many_arguments)] +fn try_create_market_config( + mcr_maintenance_num: u128, + mcr_liquidation_num: u128, + usage_ratio_num: u128, + liquidation_spread_num: u128, + borrow_min: u128, + borrow_max: Option, + supply_min: u128, + supply_max: Option, + withdrawal_min: u128, + withdrawal_max: Option, + same_asset: bool, +) -> Option { + // Create decimals (divide by 1000 to get values in [0, ~340_282]) + let mcr_maintenance = Decimal::from(mcr_maintenance_num); + let mcr_liquidation = Decimal::from(mcr_liquidation_num); + let usage_ratio = Decimal::from(usage_ratio_num % 1001); // [0, 1] + let liquidation_spread = Decimal::from(liquidation_spread_num % 1000); // [0, 0.999] + + let borrow_asset = FungibleAsset::nep141(create_account_id(1)); + let collateral_asset = if same_asset { + borrow_asset.clone().coerce() + } else { + FungibleAsset::nep141(create_account_id(2)) + }; + + // Try to create ranges + let borrow_range = ValidAmountRange::try_from((borrow_min, borrow_max)).ok()?; + let supply_range = ValidAmountRange::try_from((supply_min, supply_max)).ok()?; + let supply_withdrawal_range = + ValidAmountRange::try_from((withdrawal_min, withdrawal_max)).ok()?; + + let config = MarketConfiguration { + time_chunk_configuration: TimeChunkConfiguration::new(86_400_000), // 1 day + borrow_asset, + collateral_asset, + price_oracle_configuration: PriceOracleConfiguration { + account_id: create_account_id(3), + borrow_asset_price_id: DEFAULT_BORROW_PRICE_ID, + borrow_asset_decimals: 24, + collateral_asset_price_id: DEFAULT_COLLATERAL_PRICE_ID, + collateral_asset_decimals: 24, + price_maximum_age_s: 60, + }, + borrow_mcr_maintenance: mcr_maintenance, + borrow_mcr_liquidation: mcr_liquidation, + borrow_asset_maximum_usage_ratio: usage_ratio, + borrow_origination_fee: Fee::zero(), + #[allow(clippy::unwrap_used, reason = "Fuzzing with valid inputs")] + borrow_interest_rate_strategy: InterestRateStrategy::linear( + Decimal::from(5u128), // 5% base + Decimal::from(50u128), // 50% max + ) + .unwrap(), + borrow_maximum_duration_ms: None, + borrow_range, + supply_range, + supply_withdrawal_range, + supply_withdrawal_fee: TimeBasedFee::zero(), + yield_weights: YieldWeights::new_with_supply_weight(1), + protocol_account_id: create_account_id(4), + liquidation_maximum_spread: liquidation_spread, + }; + + Some(config) +} + +fuzz_target!(|data: ( + u128, + u128, + u128, + u128, + u128, + u128, + u128, + u128, + u128, + u128, + bool +)| { + let ( + mcr_maintenance_num, + mcr_liquidation_num, + usage_ratio_num, + liquidation_spread_num, + borrow_min, + borrow_max_raw, + supply_min, + supply_max_raw, + withdrawal_min, + withdrawal_max_raw, + same_asset, + ) = data; + + // Convert to options (0 means None) + let borrow_max = (borrow_max_raw != 0).then_some(borrow_max_raw); + let supply_max = (supply_max_raw != 0).then_some(supply_max_raw); + let withdrawal_max = (withdrawal_max_raw != 0).then_some(withdrawal_max_raw); + + let Some(config) = try_create_market_config( + mcr_maintenance_num, + mcr_liquidation_num, + usage_ratio_num, + liquidation_spread_num, + borrow_min, + borrow_max, + supply_min, + supply_max, + withdrawal_min, + withdrawal_max, + same_asset, + ) else { + return; // Invalid ranges, skip + }; + + // Test validation + let validation_result = config.validate(); + + // Check invariants + if same_asset { + // Same asset should always fail validation + assert!( + validation_result.is_err(), + "Same borrow/collateral asset should be invalid" + ); + } + + if config.borrow_mcr_maintenance <= Decimal::ONE { + assert!( + validation_result.is_err(), + "MCR maintenance <= 1 should be invalid" + ); + } + + if config.borrow_mcr_liquidation <= Decimal::ONE { + assert!( + validation_result.is_err(), + "MCR liquidation <= 1 should be invalid" + ); + } + + if config.borrow_mcr_maintenance < config.borrow_mcr_liquidation { + assert!( + validation_result.is_err(), + "MCR maintenance < liquidation should be invalid" + ); + } + + if config.borrow_asset_maximum_usage_ratio.is_zero() + || config.borrow_asset_maximum_usage_ratio > Decimal::ONE + { + assert!( + validation_result.is_err(), + "Usage ratio out of (0, 1] should be invalid" + ); + } + + if config.liquidation_maximum_spread >= Decimal::ONE { + assert!( + validation_result.is_err(), + "Liquidation spread >= 1 should be invalid" + ); + } + + // If all checks pass, validation should succeed + if !same_asset + && config.borrow_mcr_maintenance > Decimal::ONE + && config.borrow_mcr_liquidation > Decimal::ONE + && config.borrow_mcr_maintenance >= config.borrow_mcr_liquidation + && !config.borrow_asset_maximum_usage_ratio.is_zero() + && config.borrow_asset_maximum_usage_ratio <= Decimal::ONE + && config.liquidation_maximum_spread < Decimal::ONE + { + assert!( + validation_result.is_ok(), + "Valid config should pass validation: {validation_result:?}", + ); + } +}); diff --git a/fuzz/fuzz_targets/fuzz_price.rs b/fuzz/fuzz_targets/fuzz_price.rs new file mode 100644 index 00000000..bf0730e8 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_price.rs @@ -0,0 +1,81 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use near_sdk::json_types::{I64, U64}; +use templar_common::asset::{BorrowAssetAmount, CollateralAssetAmount}; +use templar_common::oracle::pyth; +use templar_common::price::{Appraise, Convert, PricePair}; + +fuzz_target!(|data: (i64, u64, i64, u64, i32, i32, u128, u128)| { + let ( + collateral_price_raw, + collateral_conf, + borrow_price_raw, + borrow_conf, + collateral_decimals, + borrow_decimals, + collateral_amount, + borrow_amount, + ) = data; + + // Create Pyth price structs + let collateral_pyth_price = pyth::Price { + price: I64(collateral_price_raw), + conf: U64(collateral_conf), + expo: -8, // typical exponent + publish_time: 0, + }; + + let borrow_pyth_price = pyth::Price { + price: I64(borrow_price_raw), + conf: U64(borrow_conf), + expo: -8, + publish_time: 0, + }; + + // Fuzz PricePair creation + if let Ok(price_pair) = PricePair::new( + &collateral_pyth_price, + collateral_decimals, + &borrow_pyth_price, + borrow_decimals, + ) { + // Fuzz valuation for collateral + let collateral_amt = CollateralAssetAmount::new(collateral_amount); + let _ = price_pair.valuation(collateral_amt); + + // Fuzz valuation for borrow + let borrow_amt = BorrowAssetAmount::new(borrow_amount); + let _ = price_pair.valuation(borrow_amt); + + // Fuzz conversions + let _ = price_pair.convert(collateral_amt); + let _ = price_pair.convert(borrow_amt); + + // Fuzz Valuation::ratio + let val1 = price_pair.valuation(collateral_amt); + let val2 = price_pair.valuation(borrow_amt); + let _ = val1.ratio(val2); + + // Test edge cases + let zero_collateral = CollateralAssetAmount::zero(); + let zero_borrow = BorrowAssetAmount::zero(); + let _ = price_pair.valuation(zero_collateral); + let _ = price_pair.valuation(zero_borrow); + } + + // Fuzz with different exponents + let varying_expo_price = pyth::Price { + price: I64(collateral_price_raw), + conf: U64(collateral_conf), + expo: collateral_decimals.wrapping_sub(borrow_decimals), + publish_time: 0, + }; + + let _ = PricePair::new( + &varying_expo_price, + collateral_decimals, + &borrow_pyth_price, + borrow_decimals, + ); +}); diff --git a/fuzz/fuzz_targets/fuzz_price_calculations.rs b/fuzz/fuzz_targets/fuzz_price_calculations.rs new file mode 100644 index 00000000..ac259e74 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_price_calculations.rs @@ -0,0 +1,303 @@ +// ============================================================================ +// FILE: fuzz/fuzz_targets/fuzz_price_calculations.rs +// ============================================================================ +// Fuzzes price oracle calculations and conversions between asset pairs + +#![no_main] + +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; + +#[derive(Arbitrary, Debug)] +struct PriceScenario { + // Asset prices (scaled by 1e8 for precision) + collateral_price: u32, + borrow_price: u32, + + // Amounts to convert + collateral_amount: u64, + borrow_amount: u64, + + // Oracle parameters + price_age: u32, // Seconds since last update + max_price_age: u32, // Maximum acceptable age + price_deviation: u16, // Basis points of acceptable deviation + + // Previous prices for comparison + prev_collateral_price: u32, + // prev_borrow_price: u32, +} + +fuzz_target!(|scenario: PriceScenario| { + // Validate inputs + if scenario.collateral_price == 0 || scenario.borrow_price == 0 { + return; // Invalid prices + } + if scenario.max_price_age == 0 { + return; + } + + let collateral_price = u128::from(scenario.collateral_price); + let borrow_price = u128::from(scenario.borrow_price); + let collateral_amount = u128::from(scenario.collateral_amount); + let borrow_amount = u128::from(scenario.borrow_amount); + + // Test 1: Price staleness check + let is_stale = scenario.price_age > scenario.max_price_age; + + // Invariant: Stale prices should be rejected + if is_stale { + // In actual code: assert!(get_price().is_err()) + return; + } + + // Test 2: Convert collateral amount to borrow amount + // borrow_equivalent = (collateral_amount * collateral_price) / borrow_price + if let Some(collateral_value) = collateral_amount.checked_mul(collateral_price) { + if let Some(borrow_equivalent) = collateral_value.checked_div(borrow_price) { + // Invariant: Result should be proportional to input + // If collateral_price > borrow_price, should get more borrow tokens + if collateral_price > borrow_price { + assert!( + borrow_equivalent >= collateral_amount, + "Price conversion error: higher priced asset should convert to more" + ); + } + + // Invariant: Converting back should give approximately original amount + if let Some(back_value) = borrow_equivalent.checked_mul(borrow_price) { + if let Some(back_to_collateral) = back_value.checked_div(collateral_price) { + // Allow 1 unit difference for rounding + let diff = back_to_collateral.abs_diff(collateral_amount); + + assert!( + diff <= 1, + "Round-trip conversion lost too much: original={collateral_amount}, back={back_to_collateral}", + ); + } + } + } + } + + // Test 3: Calculate position value in USD + let collateral_value_usd = collateral_amount.saturating_mul(collateral_price); + let borrow_value_usd = borrow_amount.saturating_mul(borrow_price); + + // Invariant: Values should never overflow to wrap around + assert!( + collateral_value_usd >= collateral_amount || collateral_amount == 0, + "Collateral value calculation overflowed" + ); + assert!( + borrow_value_usd >= borrow_amount || borrow_amount == 0, + "Borrow value calculation overflowed" + ); + + // Test 4: Price deviation checks (circuit breaker) + if scenario.prev_collateral_price > 0 { + let prev_price = u128::from(scenario.prev_collateral_price); + let current_price = collateral_price; + + // Calculate percentage change + let (change_numerator, change_denominator) = if current_price > prev_price { + (current_price - prev_price, prev_price) + } else { + (prev_price - current_price, prev_price) + }; + + if change_denominator > 0 { + let change_bps = (change_numerator * 10000) / change_denominator; + + // Invariant: Reject prices that deviate too much + if change_bps > u128::from(scenario.price_deviation) { + // In actual code: should reject this price update + // assert!(update_price(current_price).is_err()) + return; + } + + // Invariant: Change should never exceed 100% in one update + assert!( + change_bps <= 10000, + "Price changed by more than 100% in one update: {change_bps}bps", + ); + } + } + + // Test 5: Collateral ratio calculations with prices + // collateral_ratio = (collateral_value * 10000) / borrow_value + if borrow_value_usd > 0 { + if let Some(ratio_numerator) = collateral_value_usd.checked_mul(10000) { + let collateral_ratio = ratio_numerator / borrow_value_usd; + + // Invariant: Ratio calculation should be consistent + // If we have 2x collateral value, ratio should be 20000 (200%) + if collateral_value_usd >= borrow_value_usd * 2 { + assert!( + collateral_ratio >= 20000, + "Collateral ratio calculation is wrong: ratio={collateral_ratio}" + ); + } + + // Invariant: If collateral value < borrow value, ratio < 100% + if collateral_value_usd < borrow_value_usd { + assert!( + collateral_ratio < 10000, + "Undercollateralized but ratio shows healthy: {collateral_ratio}" + ); + } + } + } + + // Test 6: Liquidation calculations with price changes + // Simulate price crash of collateral + let crash_scenarios = [ + (80, "20% drop"), // 80% of original price + (50, "50% drop"), // 50% of original price + (20, "80% drop"), // 20% of original price + ]; + + for (crash_percent, _description) in crash_scenarios { + #[allow(clippy::unwrap_used, reason = "Fuzzing with valid inputs")] + let crashed_price = (collateral_price * u128::try_from(crash_percent).unwrap()) / 100; + if crashed_price == 0 { + continue; + } + + let crashed_value = collateral_amount.saturating_mul(crashed_price); + + // Check if position becomes liquidatable + let collateral_ratio_threshold = 13000u128; // 130% + let required_collateral = + borrow_value_usd.saturating_mul(collateral_ratio_threshold) / 10000; + + if crashed_value < required_collateral && borrow_value_usd > 0 { + // Position is now liquidatable + + // Calculate liquidation health factor + let health = (crashed_value * 10000) / required_collateral; + + // Invariant: Health factor should be < 100% for liquidatable position + assert!( + health < 10000, + "Position should be liquidatable but health={health}/10000", + ); + } + } + + // Test 7: Price precision and rounding + // Test that we don't lose precision in conversions + let small_amounts = [1u128, 10, 100, 1000]; + + for small in small_amounts { + if let Some(value) = small.checked_mul(collateral_price) { + if let Some(converted) = value.checked_div(borrow_price) { + if converted > 0 { + // Converting back should not be zero + let back = converted.saturating_mul(borrow_price) / collateral_price; + + // Should not lose everything to rounding + assert!( + back > 0 || small == 0, + "Small amount lost to rounding: {small} -> {converted} -> {back}", + ); + } + } + } + } + + // Test 8: TWAP (Time-Weighted Average Price) calculations + // Simulate multiple price points + let price_points = [ + collateral_price, + collateral_price * 95 / 100, // -5% + collateral_price * 105 / 100, // +5% + collateral_price * 98 / 100, // -2% + ]; + + let weights = [1u128, 2, 3, 1]; // Different time weights + let total_weight: u128 = weights.iter().sum(); + + if total_weight > 0 { + let mut weighted_sum: u128 = 0; + for (price, weight) in price_points.iter().zip(weights.iter()) { + weighted_sum = weighted_sum.saturating_add(price.saturating_mul(*weight)); + } + + let twap = weighted_sum / total_weight; + + // Invariant: TWAP should be within range of price points + #[allow(clippy::unwrap_used, reason = "Fuzzing with valid inputs")] + let min_price = *price_points.iter().min().unwrap(); + #[allow(clippy::unwrap_used, reason = "Fuzzing with valid inputs")] + let max_price = *price_points.iter().max().unwrap(); + + assert!( + twap >= min_price && twap <= max_price, + "TWAP outside price range: {twap} not in [{min_price}, {max_price}]", + ); + + // Invariant: TWAP should be close to simple average for equal weights + let simple_avg = price_points.iter().sum::() / price_points.len() as u128; + let diff_pct = if twap > simple_avg { + ((twap - simple_avg) * 100) / simple_avg + } else { + ((simple_avg - twap) * 100) / simple_avg + }; + + // For equal weights, difference should be minimal + if weights.iter().all(|&w| w == weights[0]) { + assert!( + diff_pct == 0, + "Equal weights should give same result as simple average" + ); + } + } + + // Test 9: Exchange rate calculations + // For cToken-like logic: exchangeRate = (totalCash + totalBorrows - reserves) / totalSupply + let total_cash = collateral_amount; + let total_borrows = borrow_amount; + let reserves = total_borrows / 10; // 10% reserve factor + let total_supply = collateral_amount; + + if total_supply > 0 { + let numerator = total_cash + .saturating_add(total_borrows) + .saturating_sub(reserves); + let exchange_rate = numerator / total_supply; + + // Invariant: Exchange rate should be reasonable (not zero, not absurdly high) + assert!(exchange_rate > 0, "Exchange rate is zero"); + assert!( + exchange_rate <= total_supply * 10, + "Exchange rate absurdly high: {exchange_rate} for supply {total_supply}", + ); + } + + // Test 10: Multi-hop price conversions (A -> B -> C -> A) + // Ensure round-trip conversions preserve value + if let Some(step1) = collateral_amount + .checked_mul(collateral_price) + .and_then(|v| v.checked_div(borrow_price)) + { + if let Some(step2) = step1 + .checked_mul(borrow_price) + .and_then(|v| v.checked_div(collateral_price)) + { + // Should get back approximately the same amount + let loss = collateral_amount.abs_diff(step2); + + let loss_pct = if collateral_amount > 0 { + (loss * 100) / collateral_amount + } else { + 0 + }; + + // Should lose less than 1% to rounding + assert!( + loss_pct <= 1, + "Multi-hop conversion lost {loss_pct}% of value", + ); + } + } +}); diff --git a/fuzz/fuzz_targets/fuzz_supply.rs b/fuzz/fuzz_targets/fuzz_supply.rs new file mode 100644 index 00000000..e4cda589 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_supply.rs @@ -0,0 +1,93 @@ +#![no_main] +#[cfg(not(target_arch = "wasm32"))] +use libfuzzer_sys::fuzz_target; +use templar_common::{ + accumulator::Accumulator, + asset::BorrowAssetAmount, + incoming_deposit::IncomingDeposit, + supply::{Deposit, SupplyPosition}, +}; + +fuzz_target!(|data: (u32, u128, u128, u128, u32, u32, u128)| { + let ( + snapshot_index, + active_amount, + incoming_amount_1, + incoming_amount_2, + activate_at_1, + activate_at_2, + yield_amount, + ) = data; + + // Fuzz SupplyPosition creation and basic operations + let mut position = SupplyPosition::new(snapshot_index); + + // Test exists() on new position + let _ = position.exists(); + let _ = position.can_be_removed(); + // Fuzz deposit structure + let mut deposit = Deposit { + active: BorrowAssetAmount::new(active_amount), + incoming: vec![], + outgoing: BorrowAssetAmount::zero(), + }; + + // Add incoming deposits + if incoming_amount_1 > 0 && activate_at_1 > snapshot_index { + deposit.incoming.push(IncomingDeposit { + activate_at_snapshot_index: activate_at_1, + amount: BorrowAssetAmount::new(incoming_amount_1), + }); + } + + if incoming_amount_2 > 0 && activate_at_2 > snapshot_index && activate_at_2 != activate_at_1 { + deposit.incoming.push(IncomingDeposit { + activate_at_snapshot_index: activate_at_2, + amount: BorrowAssetAmount::new(incoming_amount_2), + }); + } + + // Test total calculation (should not panic on overflow) + let _ = deposit.total(); + + // Update position with deposit + + // Fuzz yield accumulator + let yield_acc = Accumulator::new(snapshot_index); + if yield_amount > 0 { + // Try to add yield + let _ = yield_acc.get_total(); + } + position.borrow_asset_yield = yield_acc; + + // Test total_incoming + let _ = position.total_incoming(); + + // Test exists and can_be_removed after modifications + let _ = position.exists(); + let _ = position.can_be_removed(); + + // Test get methods + let _ = position.get_deposit(); + let _ = position.get_started_at_block_timestamp_ms(); + + // Fuzz edge cases + let zero_position = SupplyPosition::new(0); + let _ = zero_position.exists(); + let _ = zero_position.can_be_removed(); + + let max_position = SupplyPosition::new(u32::MAX); + let _ = max_position.exists(); + + // Test deposit with max values + let max_deposit = Deposit { + active: BorrowAssetAmount::new(u128::MAX / 3), + incoming: vec![IncomingDeposit { + activate_at_snapshot_index: u32::MAX - 1, + amount: BorrowAssetAmount::new(u128::MAX / 3), + }], + outgoing: BorrowAssetAmount::new(u128::MAX / 3), + }; + // This might overflow, which is expected behavior + let _ = max_deposit.total(); +}); diff --git a/fuzz/fuzz_targets/snapshot/fuzz_snapshot_creation.rs b/fuzz/fuzz_targets/snapshot/fuzz_snapshot_creation.rs new file mode 100644 index 00000000..7069e92b --- /dev/null +++ b/fuzz/fuzz_targets/snapshot/fuzz_snapshot_creation.rs @@ -0,0 +1,53 @@ +#![no_main] + +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; +use near_sdk::json_types::U64; +use templar_common::{ + asset::{BorrowAssetAmount, CollateralAssetAmount}, + number::Decimal, + snapshot::Snapshot, + time_chunk::TimeChunk, +}; + +#[derive(Arbitrary, Debug)] +struct SnapshotCreationScenario { + time_chunk: u64, + end_timestamp_ms: u64, + borrow_asset_deposited_active: u128, + borrow_asset_borrowed: u128, + collateral_asset_deposited: u128, + yield_distribution: u128, +} + +fuzz_target!(|scenario: SnapshotCreationScenario| { + // Create time chunk + let time_chunk = TimeChunk(U64(scenario.time_chunk)); + + // Build snapshot manually to test all fields + let snapshot = Snapshot { + time_chunk, + end_timestamp_ms: U64(scenario.end_timestamp_ms), + borrow_asset_deposited_active: BorrowAssetAmount::from( + scenario.borrow_asset_deposited_active, + ), + borrow_asset_borrowed: BorrowAssetAmount::from(scenario.borrow_asset_borrowed), + collateral_asset_deposited: CollateralAssetAmount::from( + scenario.collateral_asset_deposited, + ), + yield_distribution: BorrowAssetAmount::from(scenario.yield_distribution), + interest_rate: Decimal::ZERO, // Use safe default + }; + + // Invariant: Time chunk should be preserved + assert_eq!( + snapshot.time_chunk, time_chunk, + "Time chunk should match input" + ); + + // Invariant: Timestamp should be preserved + assert_eq!( + snapshot.end_timestamp_ms.0, scenario.end_timestamp_ms, + "Timestamp should match input" + ); +}); diff --git a/fuzz/run_fuzzing.sh b/fuzz/run_fuzzing.sh new file mode 100755 index 00000000..ec5854be --- /dev/null +++ b/fuzz/run_fuzzing.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +# Run each fuzz target for 2 minutes +for t in $(cargo +nightly fuzz list); do + echo "=== Running $t ===" + cargo +nightly fuzz run "$t" -- -max_total_time=120 +done diff --git a/fuzz/src/lib.rs b/fuzz/src/lib.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/fuzz/src/lib.rs @@ -0,0 +1 @@ + diff --git a/script/fuzz.sh b/script/fuzz.sh new file mode 100755 index 00000000..e046ef36 --- /dev/null +++ b/script/fuzz.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -ex + +TARGETS=( + fuzz_borrow + fuzz_borrow_invariants + fuzz_decimal_arithmetic + fuzz_decimal_parsing + fuzz_decimals + fuzz_interest_math + fuzz_liquidations + fuzz_liquidator_logic + fuzz_liquidator_transactions + fuzz_market_creation + fuzz_price + fuzz_price_calculations + fuzz_supply +) + +for TARGET in "${TARGETS[@]}"; do + echo "Running fuzz target: $TARGET" + cargo +nightly fuzz run "$TARGET" +done diff --git a/service/liquidator/Cargo.toml b/service/liquidator/Cargo.toml index c4379f75..a03586de 100644 --- a/service/liquidator/Cargo.toml +++ b/service/liquidator/Cargo.toml @@ -26,7 +26,7 @@ near-primitives = { workspace = true } near-sdk = { workspace = true, features = ["non-contract-usage"] } reqwest = { version = "0.11", features = ["json"] } serde = { version = "1.0", features = ["derive"] } -templar-common = { workspace = true } +templar-common = { workspace = true, features = ["rpc"] } thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } diff --git a/service/relayer/Cargo.toml b/service/relayer/Cargo.toml index 6112cadd..bd4b4f31 100644 --- a/service/relayer/Cargo.toml +++ b/service/relayer/Cargo.toml @@ -13,7 +13,10 @@ near-crypto.workspace = true near-jsonrpc-client.workspace = true near-jsonrpc-primitives.workspace = true near-primitives.workspace = true -near-sdk = { workspace = true, features = ["non-contract-usage", "unit-testing"] } +near-sdk = { workspace = true, features = [ + "non-contract-usage", + "unit-testing", +] } near-sdk-contract-tools.workspace = true sha2 = "0.10.9" sqlx = { version = "0.8", features = [ @@ -26,7 +29,7 @@ sqlx = { version = "0.8", features = [ "rust_decimal", "uuid", ] } -templar-common.workspace = true +templar-common = { workspace = true, features = ["rpc"] } templar-universal-account.workspace = true thiserror.workspace = true tokio.workspace = true