diff --git a/contracts/v2/TermMaxOrderV2.sol b/contracts/v2/TermMaxOrderV2.sol index d60c087..479bb64 100644 --- a/contracts/v2/TermMaxOrderV2.sol +++ b/contracts/v2/TermMaxOrderV2.sol @@ -207,7 +207,7 @@ contract TermMaxOrderV2 is */ function apr() external view virtual override returns (uint256 lendApr_, uint256 borrowApr_) { uint256 daysToMaturity = _daysToMaturity(); - uint256 oriXtReserve = xt.balanceOf(address(this)); + uint256 oriXtReserve = virtualXtReserve; CurveCuts memory curveCuts = _orderConfig.curveCuts; if (curveCuts.lendCurveCuts.length == 0) { diff --git a/contracts/v2/tokens/AbstractGearingTokenV2.sol b/contracts/v2/tokens/AbstractGearingTokenV2.sol index f3dc96f..c9b027a 100644 --- a/contracts/v2/tokens/AbstractGearingTokenV2.sol +++ b/contracts/v2/tokens/AbstractGearingTokenV2.sol @@ -514,10 +514,10 @@ abstract contract AbstractGearingTokenV2 is if (ltv >= Constants.DECIMAL_BASE) { maxRepayAmt = loan.debtAmt; } else { - // collateralValue(price decimals) and HALF_LIQUIDATION_THRESHOLD(base decimals 1e8) - maxRepayAmt = valueAndPrice.collateralValue.mulDiv( - Constants.DECIMAL_BASE, valueAndPrice.priceDenominator - ) < GearingTokenConstants.HALF_LIQUIDATION_THRESHOLD ? loan.debtAmt : loan.debtAmt / 2; + // collateralValue(in base decimals) and HALF_LIQUIDATION_THRESHOLD(in base decimals) + maxRepayAmt = valueAndPrice.collateralValue < GearingTokenConstants.HALF_LIQUIDATION_THRESHOLD + ? loan.debtAmt + : loan.debtAmt / 2; } } } diff --git a/test/v2/GtV2.t.sol b/test/v2/GtV2.t.sol index e704e36..8103abc 100644 --- a/test/v2/GtV2.t.sol +++ b/test/v2/GtV2.t.sol @@ -18,6 +18,7 @@ import { import {MockERC20, ERC20} from "contracts/v1/test/MockERC20.sol"; import {MockPriceFeed} from "contracts/v1/test/MockPriceFeed.sol"; +import {MockPriceFeedV2} from "contracts/v2/test/MockPriceFeedV2.sol"; import {IMintableERC20} from "contracts/v1/tokens/MintableERC20.sol"; import {IGearingToken} from "contracts/v1/tokens/IGearingToken.sol"; import { @@ -1184,6 +1185,90 @@ contract GtTestV2 is Test { assert(maxRepayAmt == 0); } + /// @dev Swap the debt oracle to one reporting 18 decimals (mirrors USPC / DUSD adapters). + /// Returns the new oracle so callers can update its price further if needed. + function _swapDebtOracleTo18Decimals(int256 priceIn18Decimals) internal returns (MockPriceFeedV2 oracle18) { + vm.startPrank(deployer); + oracle18 = new MockPriceFeedV2(deployer); + oracle18.setDecimals(18); + oracle18.updateRoundData( + MockPriceFeedV2.RoundData({ + roundId: 1, + answer: priceIn18Decimals, + startedAt: block.timestamp, + updatedAt: block.timestamp, + answeredInRound: 0 + }) + ); + + res.oracle.submitPendingOracle( + address(res.debt), + IOracleV2.Oracle( + AggregatorV3Interface(address(oracle18)), + AggregatorV3Interface(address(oracle18)), + 0, + 0, + 365 days, + 0 + ) + ); + res.oracle.acceptPendingOracle(address(res.debt)); + vm.stopPrank(); + } + + /// @notice Regression test: half-liquidation must engage when the debt oracle reports 18 decimals. + /// @dev Mirrors `testHalfLiquidate` but swaps the debt oracle to 18 decimals. Before the fix, + /// `collateralValue * 1e8 / 1e18` collapsed to 0 and full liquidation was returned for + /// any realistic position size. After the fix, the comparison is against the base-decimal + /// threshold directly and half-liquidation engages as designed. + function testHalfLiquidateWith18DecimalDebtOracle() public { + // $1 reported as 1e18 — matches the shape of TermMaxUSPCPriceFeedAdapter / DUSD adapter. + _swapDebtOracleTo18Decimals(int256(1e18)); + + uint128 debtAmt = 9000e8; + uint256 collateralAmt = 10e18; + + vm.startPrank(sender); + (uint256 gtId,) = LoanUtils.fastMintGt(res, sender, debtAmt, collateralAmt); + vm.stopPrank(); + + // Drop ETH so the position becomes liquidatable. Collateral value + // = 10 ETH * $1000 = $10K -> equals HALF_LIQUIDATION_THRESHOLD. + vm.startPrank(deployer); + res.collateralOracle.updateRoundData(JSONLoader.getRoundDataFromJson(testdata, ".priceData.ETH_1000_DAI_1.eth")); + vm.stopPrank(); + + (bool isLiquidable,, uint128 maxRepayAmt) = res.gt.getLiquidationInfo(gtId); + assertTrue(isLiquidable, "position should be liquidatable"); + // Before the fix this would be `debtAmt` (full liquidation). After the fix it must + // be `debtAmt / 2` (half liquidation), matching the 8-decimal-oracle behaviour. + assertEq(maxRepayAmt, debtAmt / 2, "half-liquidation must engage with 18-decimal debt oracle"); + } + + /// @notice Companion to `testHalfLiquidateWith18DecimalDebtOracle`: a small position whose + /// collateral value sits below `HALF_LIQUIDATION_THRESHOLD` must still allow full + /// liquidation, even with an 18-decimal debt oracle. This guards against an + /// over-correction that disables the threshold entirely. + function testFullLiquidateBelowThresholdWith18DecimalDebtOracle() public { + _swapDebtOracleTo18Decimals(int256(1e18)); + + // Position worth ~$1K — well below the $10K half-liquidation threshold. + uint128 debtAmt = 900e8; + uint256 collateralAmt = 1e18; + + vm.startPrank(sender); + (uint256 gtId,) = LoanUtils.fastMintGt(res, sender, debtAmt, collateralAmt); + vm.stopPrank(); + + vm.startPrank(deployer); + res.collateralOracle.updateRoundData(JSONLoader.getRoundDataFromJson(testdata, ".priceData.ETH_1000_DAI_1.eth")); + vm.stopPrank(); + + (bool isLiquidable,, uint128 maxRepayAmt) = res.gt.getLiquidationInfo(gtId); + assertTrue(isLiquidable, "small position should be liquidatable"); + assertEq(maxRepayAmt, debtAmt, "position below threshold must allow full liquidation"); + } + function testLiquidateInWindowTime(uint16 exceedTime) public { vm.assume(exceedTime < Constants.LIQUIDATION_WINDOW); uint256 liquidateTime = marketConfig.maturity + exceedTime; diff --git a/test/v2/OrderV2.t.sol b/test/v2/OrderV2.t.sol index 0528075..4f4878f 100644 --- a/test/v2/OrderV2.t.sol +++ b/test/v2/OrderV2.t.sol @@ -825,6 +825,51 @@ contract OrderTestV2 is Test { vm.stopPrank(); } + /// @notice Regression test: `apr()` must be driven by `virtualXtReserve`, not the order's + /// raw XT balance. The two diverge whenever XT is moved into the order outside of a + /// swap (e.g. a direct transfer/donation, or `mint` callbacks that pre-fund the order). + /// Before the fix, `apr()` used `xt.balanceOf(address(this))`, which silently + /// drifted from the curve's current price point and returned a misleading APR. + function testAprUsesVirtualXtReserveNotBalance() public { + // Baseline apr() — driven by the configured virtualXtReserve. + uint256 virtualBefore = res.order.virtualXtReserve(); + (uint256 lendAprBefore, uint256 borrowAprBefore) = res.order.apr(); + + // Sanity: apr() should be non-trivial so that a balance-based formula would + // produce a different result. (The default curve is two-sided with finite APR.) + assertGt(lendAprBefore, 0, "lend APR must be > 0 to make this test meaningful"); + assertLt(borrowAprBefore, type(uint256).max, "borrow APR must be < max to make this test meaningful"); + + // Donate XT to the order without going through a swap. virtualXtReserve must + // not move (it is only updated on swaps); xt.balanceOf will. + uint256 donateAmount = 50e8; + address donor = vm.randomAddress(); + vm.startPrank(deployer); + res.debt.mint(deployer, donateAmount); + res.debt.approve(address(res.market), donateAmount); + res.market.mint(donor, donateAmount); + vm.stopPrank(); + + uint256 xtBalBefore = res.xt.balanceOf(address(res.order)); + + vm.prank(donor); + res.xt.transfer(address(res.order), donateAmount); + + // virtualXtReserve unchanged (only changes on swaps). + assertEq(res.order.virtualXtReserve(), virtualBefore, "virtualXtReserve must not change on direct XT transfer"); + // ...but the raw XT balance has grown. + assertEq( + res.xt.balanceOf(address(res.order)), + xtBalBefore + donateAmount, + "XT balance should reflect the donation" + ); + + // apr() must remain unchanged because it now reads virtualXtReserve, not balanceOf. + (uint256 lendAprAfter, uint256 borrowAprAfter) = res.order.apr(); + assertEq(lendAprAfter, lendAprBefore, "lend APR must depend on virtualXtReserve, not raw balance"); + assertEq(borrowAprAfter, borrowAprBefore, "borrow APR must depend on virtualXtReserve, not raw balance"); + } + function testSetGeneralConfig(uint256 newGtId, ISwapCallback newTrigger) public { vm.startPrank(maker);