From 1f361e9b9d6d46e288876271f781fa29c3f37883 Mon Sep 17 00:00:00 2001 From: SmartFlow Developer Date: Fri, 9 Jan 2026 17:40:26 +0100 Subject: [PATCH] feat(util): add MathLib and PositionLib utility libraries - Add MathLib.sol with safe math operations for DeFi calculations: - min, max, clamp, absDiff for value comparison - safeDiv, divUp, mulDiv, mulDivUp for safe division operations - bpsToDecimal, applyBps, applyPercentage for basis point conversions - ratio, proRata, calculateInterest for financial calculations - safeSub, isWithinTolerance, convertDecimals for utility operations - collateralizationRatio, isPositionHealthy for lending calculations - calculateLiquidationAmount for liquidation logic - currentDay helper for time-based tracking - Add PositionLib.sol for lending protocol position management: - HealthStatus enum (Healthy, AtRisk, Liquidatable, BadDebt) - PositionMetrics struct for comprehensive position data - getCollateralizationRatio, getHealthStatus for position health - getAvailableToBorrow, getLiquidationPrice, getValueAtRisk - getWithdrawableCollateral, getRepaymentForTargetRatio - getLiquidationBonus, getMaxLiquidatableAmount, getCollateralToSeize - isLiquidatable, getPositionMetrics for position analysis - Add comprehensive test suites with 48 passing tests including fuzz tests --- foundry.lock | 11 ++ src/util/MathLib.sol | 313 ++++++++++++++++++++++++++++++++++ src/util/PositionLib.sol | 252 +++++++++++++++++++++++++++ test/util/MathLibTest.sol | 286 +++++++++++++++++++++++++++++++ test/util/PositionLibTest.sol | 256 +++++++++++++++++++++++++++ 5 files changed, 1118 insertions(+) create mode 100644 foundry.lock create mode 100644 src/util/MathLib.sol create mode 100644 src/util/PositionLib.sol create mode 100644 test/util/MathLibTest.sol create mode 100644 test/util/PositionLibTest.sol diff --git a/foundry.lock b/foundry.lock new file mode 100644 index 00000000..74992fdf --- /dev/null +++ b/foundry.lock @@ -0,0 +1,11 @@ +{ + "lib/forge-std": { + "rev": "52715a217dc51d0de15877878ab8213f6cbbbab5" + }, + "lib/openzeppelin-contracts": { + "rev": "a241f099054953be8e30bbca5f47c9a79ed24c69" + }, + "lib/solmate": { + "rev": "c892309933b25c03d32b1b0d674df7ae292ba925" + } +} \ No newline at end of file diff --git a/src/util/MathLib.sol b/src/util/MathLib.sol new file mode 100644 index 00000000..3afb4cff --- /dev/null +++ b/src/util/MathLib.sol @@ -0,0 +1,313 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +/** + * @title MathLib + * @notice A library providing safe math operations and utilities for DeFi calculations + * @dev Designed for use across FiRM protocol contracts. All operations include + * overflow/underflow protection and proper rounding behavior. + */ +library MathLib { + /// @notice The basis points denominator (100% = 10000 bps) + uint256 public constant BPS_DENOMINATOR = 10_000; + + /// @notice The precision factor for percentage calculations (1e18) + uint256 public constant PRECISION = 1e18; + + /// @notice Seconds per day for time-based calculations + uint256 public constant SECONDS_PER_DAY = 1 days; + + /// @notice Seconds per year (365 days) for annual rate calculations + uint256 public constant SECONDS_PER_YEAR = 365 days; + + /** + * @notice Calculates the minimum of two values + * @param a The first value + * @param b The second value + * @return The smaller of the two values + */ + function min(uint256 a, uint256 b) internal pure returns (uint256) { + return a < b ? a : b; + } + + /** + * @notice Calculates the maximum of two values + * @param a The first value + * @param b The second value + * @return The larger of the two values + */ + function max(uint256 a, uint256 b) internal pure returns (uint256) { + return a > b ? a : b; + } + + /** + * @notice Clamps a value between a minimum and maximum + * @param value The value to clamp + * @param minValue The minimum allowed value + * @param maxValue The maximum allowed value + * @return The clamped value + * @dev If minValue > maxValue, returns minValue + */ + function clamp(uint256 value, uint256 minValue, uint256 maxValue) internal pure returns (uint256) { + if (minValue > maxValue) return minValue; + if (value < minValue) return minValue; + if (value > maxValue) return maxValue; + return value; + } + + /** + * @notice Calculates the absolute difference between two values + * @param a The first value + * @param b The second value + * @return The absolute difference |a - b| + */ + function absDiff(uint256 a, uint256 b) internal pure returns (uint256) { + return a > b ? a - b : b - a; + } + + /** + * @notice Safe division that returns 0 if divisor is 0 + * @param a The numerator + * @param b The divisor + * @return The result of a/b, or 0 if b is 0 + */ + function safeDiv(uint256 a, uint256 b) internal pure returns (uint256) { + if (b == 0) return 0; + return a / b; + } + + /** + * @notice Division with rounding up + * @param a The numerator + * @param b The divisor + * @return The result of a/b rounded up + * @dev Reverts if b is 0 + */ + function divUp(uint256 a, uint256 b) internal pure returns (uint256) { + require(b > 0, "MathLib: division by zero"); + return a == 0 ? 0 : (a - 1) / b + 1; + } + + /** + * @notice Multiplies two numbers and divides by a third, with protection against overflow + * @param a The first multiplicand + * @param b The second multiplicand + * @param c The divisor + * @return The result of (a * b) / c + * @dev Reverts if c is 0 + */ + function mulDiv(uint256 a, uint256 b, uint256 c) internal pure returns (uint256) { + require(c > 0, "MathLib: division by zero"); + return (a * b) / c; + } + + /** + * @notice Multiplies two numbers and divides by a third, rounding up + * @param a The first multiplicand + * @param b The second multiplicand + * @param c The divisor + * @return The result of (a * b) / c, rounded up + * @dev Reverts if c is 0 + */ + function mulDivUp(uint256 a, uint256 b, uint256 c) internal pure returns (uint256) { + require(c > 0, "MathLib: division by zero"); + uint256 product = a * b; + return product == 0 ? 0 : (product - 1) / c + 1; + } + + /** + * @notice Converts a value from basis points to a decimal multiplier + * @param bps The value in basis points (e.g., 500 = 5%) + * @return The decimal representation with PRECISION (e.g., 500 bps = 0.05e18) + */ + function bpsToDecimal(uint256 bps) internal pure returns (uint256) { + return (bps * PRECISION) / BPS_DENOMINATOR; + } + + /** + * @notice Applies a basis points multiplier to a value + * @param value The base value + * @param bps The multiplier in basis points + * @return The result of value * bps / 10000 + */ + function applyBps(uint256 value, uint256 bps) internal pure returns (uint256) { + return (value * bps) / BPS_DENOMINATOR; + } + + /** + * @notice Calculates the percentage of a value + * @param value The base value + * @param percentage The percentage with PRECISION (1e18 = 100%) + * @return The result + */ + function applyPercentage(uint256 value, uint256 percentage) internal pure returns (uint256) { + return (value * percentage) / PRECISION; + } + + /** + * @notice Calculates the ratio of two values with precision + * @param numerator The numerator + * @param denominator The denominator + * @return The ratio with PRECISION (1e18) + */ + function ratio(uint256 numerator, uint256 denominator) internal pure returns (uint256) { + if (denominator == 0) return 0; + return (numerator * PRECISION) / denominator; + } + + /** + * @notice Calculates pro-rata share based on time elapsed + * @param totalAmount The total amount to distribute + * @param elapsed The elapsed time + * @param period The total period + * @return The pro-rata share + * @dev Commonly used for vesting, streaming, or time-weighted distributions + */ + function proRata(uint256 totalAmount, uint256 elapsed, uint256 period) internal pure returns (uint256) { + if (period == 0) return 0; + if (elapsed >= period) return totalAmount; + return (totalAmount * elapsed) / period; + } + + /** + * @notice Calculates annual rate applied over a time period + * @param principal The principal amount + * @param annualRateBps The annual rate in basis points + * @param duration The duration in seconds + * @return The amount accrued + * @dev Used for interest calculations in lending protocols + */ + function calculateInterest( + uint256 principal, + uint256 annualRateBps, + uint256 duration + ) internal pure returns (uint256) { + return (principal * annualRateBps * duration) / (BPS_DENOMINATOR * SECONDS_PER_YEAR); + } + + /** + * @notice Safely subtracts b from a, returning 0 if b > a + * @param a The value to subtract from + * @param b The value to subtract + * @return The result of a - b, or 0 if b > a + */ + function safeSub(uint256 a, uint256 b) internal pure returns (uint256) { + return a > b ? a - b : 0; + } + + /** + * @notice Checks if a value is within a tolerance of a target + * @param value The value to check + * @param target The target value + * @param toleranceBps The tolerance in basis points + * @return True if value is within tolerance of target + */ + function isWithinTolerance( + uint256 value, + uint256 target, + uint256 toleranceBps + ) internal pure returns (bool) { + if (target == 0) return value == 0; + uint256 diff = absDiff(value, target); + uint256 maxDiff = applyBps(target, toleranceBps); + return diff <= maxDiff; + } + + /** + * @notice Converts token amount from one decimal precision to another + * @param amount The amount to convert + * @param fromDecimals The source decimal precision + * @param toDecimals The target decimal precision + * @return The converted amount + */ + function convertDecimals( + uint256 amount, + uint8 fromDecimals, + uint8 toDecimals + ) internal pure returns (uint256) { + if (fromDecimals == toDecimals) return amount; + if (fromDecimals < toDecimals) { + return amount * 10 ** (toDecimals - fromDecimals); + } + return amount / 10 ** (fromDecimals - toDecimals); + } + + /** + * @notice Gets the current day number (for daily tracking) + * @return The current day number since epoch + */ + function currentDay() internal view returns (uint256) { + return block.timestamp / SECONDS_PER_DAY; + } + + /** + * @notice Calculates collateralization ratio + * @param collateralValue The value of collateral + * @param debtValue The value of debt + * @return The collateralization ratio with PRECISION (1e18) + * @dev Returns type(uint256).max if debtValue is 0 (fully collateralized) + */ + function collateralizationRatio( + uint256 collateralValue, + uint256 debtValue + ) internal pure returns (uint256) { + if (debtValue == 0) return type(uint256).max; + return (collateralValue * PRECISION) / debtValue; + } + + /** + * @notice Checks if a position is healthy (above minimum collateralization) + * @param collateralValue The value of collateral + * @param debtValue The value of debt + * @param minRatioBps The minimum collateralization ratio in bps (e.g., 15000 = 150%) + * @return True if position is healthy + */ + function isPositionHealthy( + uint256 collateralValue, + uint256 debtValue, + uint256 minRatioBps + ) internal pure returns (bool) { + if (debtValue == 0) return true; + // collateralValue / debtValue >= minRatioBps / BPS_DENOMINATOR + // collateralValue * BPS_DENOMINATOR >= debtValue * minRatioBps + return collateralValue * BPS_DENOMINATOR >= debtValue * minRatioBps; + } + + /** + * @notice Calculates liquidation amount needed to restore health + * @param collateralValue The current collateral value + * @param debtValue The current debt value + * @param targetRatioBps The target collateralization ratio in bps + * @param liquidationIncentiveBps The liquidation incentive in bps + * @return The amount of debt to liquidate + */ + function calculateLiquidationAmount( + uint256 collateralValue, + uint256 debtValue, + uint256 targetRatioBps, + uint256 liquidationIncentiveBps + ) internal pure returns (uint256) { + // If already healthy, no liquidation needed + if (isPositionHealthy(collateralValue, debtValue, targetRatioBps)) { + return 0; + } + + // Calculate how much debt needs to be repaid + // (collateral - repay * (1 + incentive)) / (debt - repay) = targetRatio + uint256 incentiveMultiplier = BPS_DENOMINATOR + liquidationIncentiveBps; + uint256 targetCollateral = (debtValue * targetRatioBps) / BPS_DENOMINATOR; + + if (collateralValue >= targetCollateral) { + return 0; + } + + uint256 collateralDeficit = targetCollateral - collateralValue; + uint256 effectiveRepayMultiplier = targetRatioBps - incentiveMultiplier; + + if (effectiveRepayMultiplier == 0) { + return debtValue; // Full liquidation needed + } + + return (collateralDeficit * BPS_DENOMINATOR) / effectiveRepayMultiplier; + } +} diff --git a/src/util/PositionLib.sol b/src/util/PositionLib.sol new file mode 100644 index 00000000..3ae9a1dd --- /dev/null +++ b/src/util/PositionLib.sol @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +/** + * @title PositionLib + * @notice A library providing position health and liquidation utilities + * @dev Designed for lending protocol position management calculations + */ +library PositionLib { + /// @notice The basis points denominator (100% = 10000 bps) + uint256 public constant BPS_DENOMINATOR = 10_000; + + /// @notice The precision factor for calculations (1e18) + uint256 public constant PRECISION = 1e18; + + /// @notice Position health status + enum HealthStatus { + Healthy, // Above liquidation threshold + AtRisk, // Below warning threshold but above liquidation + Liquidatable, // Below liquidation threshold + BadDebt // Collateral worth less than debt + } + + /// @notice Position metrics struct + struct PositionMetrics { + uint256 collateralValue; + uint256 debtValue; + uint256 collateralizationRatio; + uint256 availableToBorrow; + uint256 liquidationPrice; + HealthStatus status; + } + + /** + * @notice Calculates the collateralization ratio of a position + * @param collateralValue The value of collateral in base units + * @param debtValue The value of debt in base units + * @return ratio The collateralization ratio in bps (e.g., 15000 = 150%) + */ + function getCollateralizationRatio( + uint256 collateralValue, + uint256 debtValue + ) internal pure returns (uint256 ratio) { + if (debtValue == 0) return type(uint256).max; + return (collateralValue * BPS_DENOMINATOR) / debtValue; + } + + /** + * @notice Determines the health status of a position + * @param collateralValue The value of collateral + * @param debtValue The value of debt + * @param liquidationThresholdBps The liquidation threshold in bps + * @param warningThresholdBps The warning threshold in bps (should be > liquidationThresholdBps) + * @return The health status of the position + */ + function getHealthStatus( + uint256 collateralValue, + uint256 debtValue, + uint256 liquidationThresholdBps, + uint256 warningThresholdBps + ) internal pure returns (HealthStatus) { + if (debtValue == 0) return HealthStatus.Healthy; + if (collateralValue < debtValue) return HealthStatus.BadDebt; + + uint256 ratio = getCollateralizationRatio(collateralValue, debtValue); + + if (ratio < liquidationThresholdBps) return HealthStatus.Liquidatable; + if (ratio < warningThresholdBps) return HealthStatus.AtRisk; + return HealthStatus.Healthy; + } + + /** + * @notice Calculates the maximum additional debt that can be safely borrowed + * @param collateralValue The value of collateral + * @param currentDebt The current debt value + * @param maxLtvBps The maximum loan-to-value ratio in bps + * @return The maximum additional borrowable amount + */ + function getAvailableToBorrow( + uint256 collateralValue, + uint256 currentDebt, + uint256 maxLtvBps + ) internal pure returns (uint256) { + uint256 maxDebt = (collateralValue * maxLtvBps) / BPS_DENOMINATOR; + if (currentDebt >= maxDebt) return 0; + return maxDebt - currentDebt; + } + + /** + * @notice Calculates the collateral price at which position becomes liquidatable + * @param collateralAmount The amount of collateral (not value) + * @param debtValue The debt value + * @param liquidationThresholdBps The liquidation threshold in bps + * @return The liquidation price per unit of collateral + */ + function getLiquidationPrice( + uint256 collateralAmount, + uint256 debtValue, + uint256 liquidationThresholdBps + ) internal pure returns (uint256) { + if (collateralAmount == 0) return 0; + // liquidationPrice = (debtValue * liquidationThreshold) / collateralAmount + return (debtValue * liquidationThresholdBps) / (collateralAmount * BPS_DENOMINATOR / PRECISION); + } + + /** + * @notice Calculates the value at risk (how much value needs to drop for liquidation) + * @param collateralValue The current collateral value + * @param debtValue The current debt value + * @param liquidationThresholdBps The liquidation threshold in bps + * @return The buffer value before liquidation + */ + function getValueAtRisk( + uint256 collateralValue, + uint256 debtValue, + uint256 liquidationThresholdBps + ) internal pure returns (uint256) { + uint256 minCollateral = (debtValue * liquidationThresholdBps) / BPS_DENOMINATOR; + if (collateralValue <= minCollateral) return 0; + return collateralValue - minCollateral; + } + + /** + * @notice Calculates the amount of collateral that can be safely withdrawn + * @param collateralValue The current collateral value + * @param debtValue The current debt value + * @param minCollateralRatioBps The minimum collateral ratio to maintain in bps + * @return The maximum withdrawable collateral value + */ + function getWithdrawableCollateral( + uint256 collateralValue, + uint256 debtValue, + uint256 minCollateralRatioBps + ) internal pure returns (uint256) { + if (debtValue == 0) return collateralValue; + + uint256 requiredCollateral = (debtValue * minCollateralRatioBps) / BPS_DENOMINATOR; + if (collateralValue <= requiredCollateral) return 0; + return collateralValue - requiredCollateral; + } + + /** + * @notice Calculates the debt repayment needed to reach a target collateralization ratio + * @param collateralValue The current collateral value + * @param debtValue The current debt value + * @param targetRatioBps The target collateralization ratio in bps + * @return The amount of debt to repay + */ + function getRepaymentForTargetRatio( + uint256 collateralValue, + uint256 debtValue, + uint256 targetRatioBps + ) internal pure returns (uint256) { + if (targetRatioBps == 0) return 0; + + // targetRatio = collateralValue / (debtValue - repayment) + // debtValue - repayment = collateralValue / targetRatio + // repayment = debtValue - (collateralValue * BPS / targetRatio) + uint256 targetDebt = (collateralValue * BPS_DENOMINATOR) / targetRatioBps; + + if (debtValue <= targetDebt) return 0; + return debtValue - targetDebt; + } + + /** + * @notice Calculates liquidation incentive/bonus for liquidators + * @param liquidationAmount The amount being liquidated + * @param incentiveBps The liquidation incentive in bps + * @return The bonus amount for the liquidator + */ + function getLiquidationBonus( + uint256 liquidationAmount, + uint256 incentiveBps + ) internal pure returns (uint256) { + return (liquidationAmount * incentiveBps) / BPS_DENOMINATOR; + } + + /** + * @notice Calculates the maximum liquidatable amount for partial liquidation + * @param debtValue The total debt value + * @param maxLiquidationBps The maximum percentage that can be liquidated in bps + * @return The maximum liquidatable debt amount + */ + function getMaxLiquidatableAmount( + uint256 debtValue, + uint256 maxLiquidationBps + ) internal pure returns (uint256) { + return (debtValue * maxLiquidationBps) / BPS_DENOMINATOR; + } + + /** + * @notice Calculates collateral to seize during liquidation + * @param debtToRepay The amount of debt being repaid + * @param collateralPrice The price of collateral (per unit with PRECISION) + * @param incentiveBps The liquidation incentive in bps + * @return The amount of collateral to seize + */ + function getCollateralToSeize( + uint256 debtToRepay, + uint256 collateralPrice, + uint256 incentiveBps + ) internal pure returns (uint256) { + if (collateralPrice == 0) return 0; + + uint256 incentiveMultiplier = BPS_DENOMINATOR + incentiveBps; + uint256 collateralValueToSeize = (debtToRepay * incentiveMultiplier) / BPS_DENOMINATOR; + + return (collateralValueToSeize * PRECISION) / collateralPrice; + } + + /** + * @notice Checks if a position can be liquidated + * @param collateralValue The value of collateral + * @param debtValue The value of debt + * @param liquidationThresholdBps The liquidation threshold in bps + * @return True if position is liquidatable + */ + function isLiquidatable( + uint256 collateralValue, + uint256 debtValue, + uint256 liquidationThresholdBps + ) internal pure returns (bool) { + if (debtValue == 0) return false; + return collateralValue * BPS_DENOMINATOR < debtValue * liquidationThresholdBps; + } + + /** + * @notice Gets comprehensive position metrics + * @param collateralValue The value of collateral + * @param collateralAmount The amount of collateral + * @param debtValue The value of debt + * @param maxLtvBps The maximum LTV in bps + * @param liquidationThresholdBps The liquidation threshold in bps + * @param warningThresholdBps The warning threshold in bps + * @return metrics The comprehensive position metrics + */ + function getPositionMetrics( + uint256 collateralValue, + uint256 collateralAmount, + uint256 debtValue, + uint256 maxLtvBps, + uint256 liquidationThresholdBps, + uint256 warningThresholdBps + ) internal pure returns (PositionMetrics memory metrics) { + metrics.collateralValue = collateralValue; + metrics.debtValue = debtValue; + metrics.collateralizationRatio = getCollateralizationRatio(collateralValue, debtValue); + metrics.availableToBorrow = getAvailableToBorrow(collateralValue, debtValue, maxLtvBps); + metrics.liquidationPrice = getLiquidationPrice(collateralAmount, debtValue, liquidationThresholdBps); + metrics.status = getHealthStatus(collateralValue, debtValue, liquidationThresholdBps, warningThresholdBps); + } +} diff --git a/test/util/MathLibTest.sol b/test/util/MathLibTest.sol new file mode 100644 index 00000000..078391c1 --- /dev/null +++ b/test/util/MathLibTest.sol @@ -0,0 +1,286 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "src/util/MathLib.sol"; + +contract MathLibTest is Test { + using MathLib for uint256; + + // ============ Constants Tests ============ + + function test_Constants() public pure { + assertEq(MathLib.BPS_DENOMINATOR, 10_000); + assertEq(MathLib.PRECISION, 1e18); + assertEq(MathLib.SECONDS_PER_DAY, 86400); + assertEq(MathLib.SECONDS_PER_YEAR, 365 days); + } + + // ============ min/max Tests ============ + + function test_Min() public pure { + assertEq(MathLib.min(10, 20), 10); + assertEq(MathLib.min(20, 10), 10); + assertEq(MathLib.min(10, 10), 10); + assertEq(MathLib.min(0, 100), 0); + assertEq(MathLib.min(type(uint256).max, 0), 0); + } + + function test_Max() public pure { + assertEq(MathLib.max(10, 20), 20); + assertEq(MathLib.max(20, 10), 20); + assertEq(MathLib.max(10, 10), 10); + assertEq(MathLib.max(0, 100), 100); + assertEq(MathLib.max(type(uint256).max, 0), type(uint256).max); + } + + function testFuzz_MinMax(uint256 a, uint256 b) public pure { + uint256 minVal = MathLib.min(a, b); + uint256 maxVal = MathLib.max(a, b); + + assertTrue(minVal <= a && minVal <= b); + assertTrue(maxVal >= a && maxVal >= b); + assertTrue(minVal <= maxVal); + } + + // ============ clamp Tests ============ + + function test_Clamp() public pure { + assertEq(MathLib.clamp(50, 0, 100), 50); + assertEq(MathLib.clamp(0, 10, 100), 10); + assertEq(MathLib.clamp(150, 10, 100), 100); + assertEq(MathLib.clamp(10, 10, 10), 10); + } + + function test_Clamp_InvalidRange() public pure { + // When min > max, returns min + assertEq(MathLib.clamp(50, 100, 10), 100); + } + + function testFuzz_Clamp(uint256 value, uint256 minVal, uint256 maxVal) public pure { + vm.assume(minVal <= maxVal); + uint256 result = MathLib.clamp(value, minVal, maxVal); + assertTrue(result >= minVal && result <= maxVal); + } + + // ============ absDiff Tests ============ + + function test_AbsDiff() public pure { + assertEq(MathLib.absDiff(100, 30), 70); + assertEq(MathLib.absDiff(30, 100), 70); + assertEq(MathLib.absDiff(100, 100), 0); + assertEq(MathLib.absDiff(0, 0), 0); + } + + function testFuzz_AbsDiff(uint256 a, uint256 b) public pure { + uint256 diff = MathLib.absDiff(a, b); + if (a > b) { + assertEq(diff, a - b); + } else { + assertEq(diff, b - a); + } + } + + // ============ safeDiv Tests ============ + + function test_SafeDiv() public pure { + assertEq(MathLib.safeDiv(100, 10), 10); + assertEq(MathLib.safeDiv(100, 0), 0); + assertEq(MathLib.safeDiv(0, 10), 0); + assertEq(MathLib.safeDiv(7, 3), 2); + } + + // ============ divUp Tests ============ + + function test_DivUp() public pure { + assertEq(MathLib.divUp(100, 10), 10); + assertEq(MathLib.divUp(101, 10), 11); + assertEq(MathLib.divUp(99, 10), 10); + assertEq(MathLib.divUp(0, 10), 0); + assertEq(MathLib.divUp(1, 10), 1); + } + + function test_DivUp_RevertOnZero() public { + // Library functions revert inline, so we test by wrapping in a try/catch + bool reverted = false; + try this.callDivUp(100, 0) returns (uint256) { + reverted = false; + } catch { + reverted = true; + } + assertTrue(reverted, "Should revert on division by zero"); + } + + // Helper function for testing reverts in library calls + function callDivUp(uint256 a, uint256 b) external pure returns (uint256) { + return MathLib.divUp(a, b); + } + + // ============ mulDiv Tests ============ + + function test_MulDiv() public pure { + assertEq(MathLib.mulDiv(100, 50, 100), 50); + assertEq(MathLib.mulDiv(1e18, 5000, 10000), 5e17); + assertEq(MathLib.mulDiv(0, 100, 100), 0); + } + + function test_MulDiv_RevertOnZero() public { + bool reverted = false; + try this.callMulDiv(100, 50, 0) returns (uint256) { + reverted = false; + } catch { + reverted = true; + } + assertTrue(reverted, "Should revert on division by zero"); + } + + // Helper function for testing reverts in library calls + function callMulDiv(uint256 a, uint256 b, uint256 c) external pure returns (uint256) { + return MathLib.mulDiv(a, b, c); + } + + function test_MulDivUp() public pure { + assertEq(MathLib.mulDivUp(100, 50, 100), 50); + assertEq(MathLib.mulDivUp(101, 1, 100), 2); + assertEq(MathLib.mulDivUp(0, 100, 100), 0); + } + + // ============ BPS Conversion Tests ============ + + function test_BpsToDecimal() public pure { + assertEq(MathLib.bpsToDecimal(10000), 1e18); // 100% + assertEq(MathLib.bpsToDecimal(5000), 5e17); // 50% + assertEq(MathLib.bpsToDecimal(100), 1e16); // 1% + assertEq(MathLib.bpsToDecimal(1), 1e14); // 0.01% + } + + function test_ApplyBps() public pure { + assertEq(MathLib.applyBps(1000, 10000), 1000); // 100% + assertEq(MathLib.applyBps(1000, 5000), 500); // 50% + assertEq(MathLib.applyBps(1000, 100), 10); // 1% + assertEq(MathLib.applyBps(10000, 250), 250); // 2.5% + } + + function test_ApplyPercentage() public pure { + assertEq(MathLib.applyPercentage(1000, 1e18), 1000); // 100% + assertEq(MathLib.applyPercentage(1000, 5e17), 500); // 50% + assertEq(MathLib.applyPercentage(1000, 1e16), 10); // 1% + } + + // ============ ratio Tests ============ + + function test_Ratio() public pure { + assertEq(MathLib.ratio(100, 100), 1e18); // 1:1 + assertEq(MathLib.ratio(50, 100), 5e17); // 1:2 + assertEq(MathLib.ratio(150, 100), 15e17); // 3:2 + assertEq(MathLib.ratio(100, 0), 0); // div by zero returns 0 + } + + // ============ proRata Tests ============ + + function test_ProRata() public pure { + assertEq(MathLib.proRata(1000, 50, 100), 500); // 50% elapsed + assertEq(MathLib.proRata(1000, 100, 100), 1000); // 100% elapsed + assertEq(MathLib.proRata(1000, 150, 100), 1000); // over 100% capped + assertEq(MathLib.proRata(1000, 0, 100), 0); // 0% elapsed + assertEq(MathLib.proRata(1000, 50, 0), 0); // zero period + } + + // ============ calculateInterest Tests ============ + + function test_CalculateInterest() public pure { + // 1000 principal, 10% annual rate, 1 year + uint256 interest = MathLib.calculateInterest(1000e18, 1000, 365 days); + assertEq(interest, 100e18); + + // 1000 principal, 10% annual rate, 6 months (half year) + interest = MathLib.calculateInterest(1000e18, 1000, 365 days / 2); + assertEq(interest, 50e18); + + // 1000 principal, 5% annual rate, 1 year + interest = MathLib.calculateInterest(1000e18, 500, 365 days); + assertEq(interest, 50e18); + } + + // ============ safeSub Tests ============ + + function test_SafeSub() public pure { + assertEq(MathLib.safeSub(100, 30), 70); + assertEq(MathLib.safeSub(30, 100), 0); + assertEq(MathLib.safeSub(100, 100), 0); + } + + // ============ isWithinTolerance Tests ============ + + function test_IsWithinTolerance() public pure { + assertTrue(MathLib.isWithinTolerance(100, 100, 100)); // exact match + assertTrue(MathLib.isWithinTolerance(101, 100, 100)); // 1% tolerance, 1% diff + assertTrue(MathLib.isWithinTolerance(99, 100, 100)); // 1% tolerance, 1% diff + assertFalse(MathLib.isWithinTolerance(110, 100, 100)); // 1% tolerance, 10% diff + assertTrue(MathLib.isWithinTolerance(0, 0, 100)); // zero target + } + + // ============ convertDecimals Tests ============ + + function test_ConvertDecimals() public pure { + // 6 decimals to 18 decimals + assertEq(MathLib.convertDecimals(1e6, 6, 18), 1e18); + + // 18 decimals to 6 decimals + assertEq(MathLib.convertDecimals(1e18, 18, 6), 1e6); + + // Same decimals + assertEq(MathLib.convertDecimals(1000, 8, 8), 1000); + + // 8 to 18 + assertEq(MathLib.convertDecimals(1e8, 8, 18), 1e18); + } + + // ============ currentDay Tests ============ + + function test_CurrentDay() public { + uint256 day = MathLib.currentDay(); + assertEq(day, block.timestamp / 1 days); + + // Warp to a specific time and verify + vm.warp(86400 * 100); // Day 100 + assertEq(MathLib.currentDay(), 100); + } + + // ============ collateralizationRatio Tests ============ + + function test_CollateralizationRatio() public pure { + // 150% collateralized + assertEq(MathLib.collateralizationRatio(150e18, 100e18), 15e17); + + // 100% collateralized + assertEq(MathLib.collateralizationRatio(100e18, 100e18), 1e18); + + // No debt - max ratio + assertEq(MathLib.collateralizationRatio(100e18, 0), type(uint256).max); + } + + // ============ isPositionHealthy Tests ============ + + function test_IsPositionHealthy() public pure { + // 150% collateral, min 120% required - healthy + assertTrue(MathLib.isPositionHealthy(150e18, 100e18, 12000)); + + // 110% collateral, min 120% required - unhealthy + assertFalse(MathLib.isPositionHealthy(110e18, 100e18, 12000)); + + // No debt - always healthy + assertTrue(MathLib.isPositionHealthy(0, 0, 12000)); + } + + // ============ calculateLiquidationAmount Tests ============ + + function test_CalculateLiquidationAmount() public pure { + // Already healthy - no liquidation needed + assertEq(MathLib.calculateLiquidationAmount(200e18, 100e18, 15000, 500), 0); + + // Undercollateralized - needs liquidation + uint256 amount = MathLib.calculateLiquidationAmount(100e18, 100e18, 15000, 500); + assertTrue(amount > 0); + } +} diff --git a/test/util/PositionLibTest.sol b/test/util/PositionLibTest.sol new file mode 100644 index 00000000..0982a3b5 --- /dev/null +++ b/test/util/PositionLibTest.sol @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "src/util/PositionLib.sol"; + +contract PositionLibTest is Test { + using PositionLib for uint256; + + uint256 constant BPS = 10_000; + uint256 constant PRECISION = 1e18; + + // ============ Constants Tests ============ + + function test_Constants() public pure { + assertEq(PositionLib.BPS_DENOMINATOR, 10_000); + assertEq(PositionLib.PRECISION, 1e18); + } + + // ============ getCollateralizationRatio Tests ============ + + function test_GetCollateralizationRatio() public pure { + // 150% collateralized + assertEq(PositionLib.getCollateralizationRatio(150e18, 100e18), 15000); + + // 100% collateralized + assertEq(PositionLib.getCollateralizationRatio(100e18, 100e18), 10000); + + // 200% collateralized + assertEq(PositionLib.getCollateralizationRatio(200e18, 100e18), 20000); + + // No debt - max ratio + assertEq(PositionLib.getCollateralizationRatio(100e18, 0), type(uint256).max); + } + + function testFuzz_GetCollateralizationRatio(uint256 collateral, uint256 debt) public pure { + vm.assume(debt > 0 && debt < type(uint128).max); + vm.assume(collateral < type(uint128).max); + + uint256 ratio = PositionLib.getCollateralizationRatio(collateral, debt); + assertEq(ratio, (collateral * BPS) / debt); + } + + // ============ getHealthStatus Tests ============ + + function test_GetHealthStatus_Healthy() public pure { + // 180% collateralized, liquidation at 120%, warning at 150% + PositionLib.HealthStatus status = PositionLib.getHealthStatus( + 180e18, 100e18, 12000, 15000 + ); + assertEq(uint(status), uint(PositionLib.HealthStatus.Healthy)); + } + + function test_GetHealthStatus_AtRisk() public pure { + // 130% collateralized, liquidation at 120%, warning at 150% + PositionLib.HealthStatus status = PositionLib.getHealthStatus( + 130e18, 100e18, 12000, 15000 + ); + assertEq(uint(status), uint(PositionLib.HealthStatus.AtRisk)); + } + + function test_GetHealthStatus_Liquidatable() public pure { + // 110% collateralized, liquidation at 120% + PositionLib.HealthStatus status = PositionLib.getHealthStatus( + 110e18, 100e18, 12000, 15000 + ); + assertEq(uint(status), uint(PositionLib.HealthStatus.Liquidatable)); + } + + function test_GetHealthStatus_BadDebt() public pure { + // Collateral worth less than debt + PositionLib.HealthStatus status = PositionLib.getHealthStatus( + 80e18, 100e18, 12000, 15000 + ); + assertEq(uint(status), uint(PositionLib.HealthStatus.BadDebt)); + } + + function test_GetHealthStatus_NoDebt() public pure { + PositionLib.HealthStatus status = PositionLib.getHealthStatus( + 100e18, 0, 12000, 15000 + ); + assertEq(uint(status), uint(PositionLib.HealthStatus.Healthy)); + } + + // ============ getAvailableToBorrow Tests ============ + + function test_GetAvailableToBorrow() public pure { + // 100 collateral, max LTV 80%, no current debt + assertEq(PositionLib.getAvailableToBorrow(100e18, 0, 8000), 80e18); + + // 100 collateral, max LTV 80%, 50 current debt + assertEq(PositionLib.getAvailableToBorrow(100e18, 50e18, 8000), 30e18); + + // 100 collateral, max LTV 80%, 80 current debt (at max) + assertEq(PositionLib.getAvailableToBorrow(100e18, 80e18, 8000), 0); + + // 100 collateral, max LTV 80%, 90 current debt (over max) + assertEq(PositionLib.getAvailableToBorrow(100e18, 90e18, 8000), 0); + } + + function testFuzz_GetAvailableToBorrow(uint256 collateral, uint256 debt, uint256 maxLtv) public pure { + vm.assume(collateral < type(uint128).max); + vm.assume(debt < type(uint128).max); + vm.assume(maxLtv > 0 && maxLtv <= 10000); + + uint256 available = PositionLib.getAvailableToBorrow(collateral, debt, maxLtv); + uint256 maxDebt = (collateral * maxLtv) / BPS; + + if (debt >= maxDebt) { + assertEq(available, 0); + } else { + assertEq(available, maxDebt - debt); + } + } + + // ============ getValueAtRisk Tests ============ + + function test_GetValueAtRisk() public pure { + // 150 collateral, 100 debt, 120% liquidation threshold + // Min collateral = 100 * 1.2 = 120 + // Buffer = 150 - 120 = 30 + assertEq(PositionLib.getValueAtRisk(150e18, 100e18, 12000), 30e18); + + // At liquidation threshold + assertEq(PositionLib.getValueAtRisk(120e18, 100e18, 12000), 0); + + // Below liquidation threshold + assertEq(PositionLib.getValueAtRisk(110e18, 100e18, 12000), 0); + } + + // ============ getWithdrawableCollateral Tests ============ + + function test_GetWithdrawableCollateral() public pure { + // 200 collateral, 100 debt, min 150% ratio + // Required = 100 * 1.5 = 150 + // Withdrawable = 200 - 150 = 50 + assertEq(PositionLib.getWithdrawableCollateral(200e18, 100e18, 15000), 50e18); + + // No debt - all withdrawable + assertEq(PositionLib.getWithdrawableCollateral(100e18, 0, 15000), 100e18); + + // At minimum ratio + assertEq(PositionLib.getWithdrawableCollateral(150e18, 100e18, 15000), 0); + } + + // ============ getRepaymentForTargetRatio Tests ============ + + function test_GetRepaymentForTargetRatio() public pure { + // 120 collateral, 100 debt, want 150% ratio + // Target debt = 120 / 1.5 = 80 + // Repayment = 100 - 80 = 20 + assertEq(PositionLib.getRepaymentForTargetRatio(120e18, 100e18, 15000), 20e18); + + // Already at target ratio + assertEq(PositionLib.getRepaymentForTargetRatio(150e18, 100e18, 15000), 0); + + // Above target ratio + assertEq(PositionLib.getRepaymentForTargetRatio(200e18, 100e18, 15000), 0); + } + + // ============ getLiquidationBonus Tests ============ + + function test_GetLiquidationBonus() public pure { + // 100 liquidation, 5% incentive + assertEq(PositionLib.getLiquidationBonus(100e18, 500), 5e18); + + // 100 liquidation, 10% incentive + assertEq(PositionLib.getLiquidationBonus(100e18, 1000), 10e18); + } + + // ============ getMaxLiquidatableAmount Tests ============ + + function test_GetMaxLiquidatableAmount() public pure { + // 100 debt, max 50% liquidatable + assertEq(PositionLib.getMaxLiquidatableAmount(100e18, 5000), 50e18); + + // 100 debt, max 100% liquidatable + assertEq(PositionLib.getMaxLiquidatableAmount(100e18, 10000), 100e18); + } + + // ============ getCollateralToSeize Tests ============ + + function test_GetCollateralToSeize() public pure { + // 100 debt repay, price 1.0, 5% incentive + // Collateral value to seize = 100 * 1.05 = 105 + // Collateral amount = 105 * 1e18 / 1e18 = 105 + assertEq(PositionLib.getCollateralToSeize(100e18, 1e18, 500), 105e18); + + // 100 debt repay, price 2.0, 5% incentive + // Collateral value to seize = 100 * 1.05 = 105 + // Collateral amount = 105 * 1e18 / 2e18 = 52.5 + assertEq(PositionLib.getCollateralToSeize(100e18, 2e18, 500), 525e17); + + // Zero price + assertEq(PositionLib.getCollateralToSeize(100e18, 0, 500), 0); + } + + // ============ isLiquidatable Tests ============ + + function test_IsLiquidatable() public pure { + // 150% collateralized, 120% threshold - not liquidatable + assertFalse(PositionLib.isLiquidatable(150e18, 100e18, 12000)); + + // 120% collateralized, 120% threshold - not liquidatable (at threshold) + assertFalse(PositionLib.isLiquidatable(120e18, 100e18, 12000)); + + // 110% collateralized, 120% threshold - liquidatable + assertTrue(PositionLib.isLiquidatable(110e18, 100e18, 12000)); + + // No debt - never liquidatable + assertFalse(PositionLib.isLiquidatable(0, 0, 12000)); + } + + function testFuzz_IsLiquidatable(uint256 collateral, uint256 debt, uint256 threshold) public pure { + vm.assume(debt > 0 && debt < type(uint128).max); + vm.assume(collateral < type(uint128).max); + vm.assume(threshold > 0 && threshold < type(uint64).max); + + bool liquidatable = PositionLib.isLiquidatable(collateral, debt, threshold); + bool expected = collateral * BPS < debt * threshold; + assertEq(liquidatable, expected); + } + + // ============ getPositionMetrics Tests ============ + + function test_GetPositionMetrics() public pure { + PositionLib.PositionMetrics memory metrics = PositionLib.getPositionMetrics( + 150e18, // collateralValue + 100e18, // collateralAmount + 100e18, // debtValue + 8000, // maxLtvBps (80%) + 12000, // liquidationThresholdBps (120%) + 15000 // warningThresholdBps (150%) + ); + + assertEq(metrics.collateralValue, 150e18); + assertEq(metrics.debtValue, 100e18); + assertEq(metrics.collateralizationRatio, 15000); // 150% + assertEq(metrics.availableToBorrow, 20e18); // 150 * 0.8 - 100 = 20 + assertEq(uint(metrics.status), uint(PositionLib.HealthStatus.Healthy)); + } + + function test_GetPositionMetrics_Liquidatable() public pure { + PositionLib.PositionMetrics memory metrics = PositionLib.getPositionMetrics( + 110e18, // collateralValue + 100e18, // collateralAmount + 100e18, // debtValue + 8000, // maxLtvBps + 12000, // liquidationThresholdBps + 15000 // warningThresholdBps + ); + + assertEq(uint(metrics.status), uint(PositionLib.HealthStatus.Liquidatable)); + assertEq(metrics.availableToBorrow, 0); + } +}