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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion contracts/v2/TermMaxOrderV2.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 4 additions & 4 deletions contracts/v2/tokens/AbstractGearingTokenV2.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
Expand Down
85 changes: 85 additions & 0 deletions test/v2/GtV2.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
45 changes: 45 additions & 0 deletions test/v2/OrderV2.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading