Skip to content

Commit

Permalink
Merge pull request #296 from alpaca-finance/fix/repurchsae-entire-debt
Browse files Browse the repository at this point in the history
[main][fix] prevent debt left over when repurchase entire debt
  • Loading branch information
jr-alpaca authored Mar 21, 2023
2 parents fb0192f + 56ecf03 commit 6a7f741
Show file tree
Hide file tree
Showing 2 changed files with 136 additions and 37 deletions.
45 changes: 45 additions & 0 deletions solidity/contracts/money-market/facets/LiquidationFacet.sol
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ contract LiquidationFacet is ILiquidationFacet {
uint256 repurchaseRewardBps;
uint256 repayAmountWithoutFee;
uint256 repayTokenPrice;
bool isPurchaseAll;
}

struct LiquidationLocalVars {
Expand Down Expand Up @@ -190,6 +191,8 @@ contract LiquidationFacet is ILiquidationFacet {
if (_desiredRepayAmount > _maxAmountRepurchaseable) {
_vars.repayAmountWithFee = _maxAmountRepurchaseable;
_vars.repayAmountWithoutFee = _currentDebtAmount;
// set flag `isRepurchaseAll` = true for further use in `_actualRepayAmountWithoutFee` calculation
_vars.isPurchaseAll = true;
} else {
_vars.repayAmountWithFee = _desiredRepayAmount;
_vars.repayAmountWithoutFee =
Expand Down Expand Up @@ -247,6 +250,9 @@ contract LiquidationFacet is ILiquidationFacet {
// In case of token with fee on transfer, debt would be repaid by amount after transfer fee
// which won't be able to repurchase entire position
// repaidAmount = amountReceived - repurchaseFee
//
_vars.repayAmountWithFee = _calculateRepayAmountWithFeeRoundUp(_repayToken, _vars, moneyMarketDs);

uint256 _actualRepayAmountWithoutFee = LibMoneyMarket01.unsafePullTokens(
_repayToken,
msg.sender,
Expand Down Expand Up @@ -515,4 +521,43 @@ contract LiquidationFacet is ILiquidationFacet {
revert LiquidationFacet_RepayAmountExceedThreshold();
}
}

function _calculateRepayAmountWithFeeRoundUp(
address _repayToken,
RepurchaseLocalVars memory _vars,
LibMoneyMarket01.MoneyMarketDiamondStorage storage moneyMarketDs
) internal view returns (uint256 _repayAmountWithFee) {
// To prevent precision loss and leaving 1 wei debt in subAccount when user repurchase the entire debt
// assume totalDebtWithFee = 10 wei
// _repayAmountWithoutFee = 10 wei, overCollatDebtShares = 10 wei, overCollatDebtValues = 15 wei

// fully repurchase without adding extra wei
// _repayShare = 10 * 10 / 15 = 6.66666666667 => round down to 6 shares
// _repayShareToValue = 6 * 15 / 10 = 9 wei => result in 1 wei debt leftover

// fully repurchase with adding extra wei
// _repayShare = 10 * 10 / 15 = 6.66666666667
// _repayAmountWithoutFee = 11 wei
// _newRepayShare = 11 * 10 / 15 = 7.33333333333 => round down to 7 shares
// _newRepayShareToValue = 7 * 15 / 10 = 10.5 => round down to 10 wei and repay all debt
_repayAmountWithFee = _vars.repayAmountWithFee;
if (_vars.isPurchaseAll) {
uint256 _repayAmountWithoutFee = _vars.repayAmountWithFee - _vars.repurchaseFeeToProtocol;
uint256 _actualRepayShare = LibShareUtil.valueToShare(
_repayAmountWithoutFee,
moneyMarketDs.overCollatDebtShares[_repayToken],
moneyMarketDs.overCollatDebtValues[_repayToken]
);
uint256 _expectRepayShare = LibShareUtil.valueToShareRoundingUp(
_repayAmountWithoutFee,
moneyMarketDs.overCollatDebtShares[_repayToken],
moneyMarketDs.overCollatDebtValues[_repayToken]
);

// Adding repayAmountWithFee by 1 wei when there is a precision loss in _actualRepayShare
if (_actualRepayShare + 1 == _expectRepayShare) {
_repayAmountWithFee += 1;
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,18 @@ contract MoneyMarket_Liquidation_RepurchaseTest is MoneyMarket_BaseTest {
uint256 debtTokenGlobalDebtValue;
}

struct TestRepurchaseLocalVarCalculation {
uint256 repayAmountWithFee;
uint256 repayAmountWithoutFee;
uint256 repayTokenPriceWithPremium;
uint256 collatSold;
uint256 repurchaseFee;
uint256 actualRepurchaserRepayAmount;
uint256 expectRepayShare;
uint256 actualDebtRepay;
bool isRepurchaseAll;
}

/// @dev this function will test
/// - repay amount and fee calculation
/// - collateral amount sold calculation
Expand Down Expand Up @@ -174,24 +186,52 @@ contract MoneyMarket_Liquidation_RepurchaseTest is MoneyMarket_BaseTest {
///////////////////////////////////
// ghost variables calculation
///////////////////////////////////
uint256 _repayAmountWithFee;
uint256 _repayAmountWithoutFee;
TestRepurchaseLocalVarCalculation memory _vars;
{
uint256 _maxAmountRepurchasable = (ctx.stateBefore.borrowerDebtAmount * (10000 + ctx.repurchaseFeeBps)) / 10000;
// case 1
if (ctx.desiredRepayAmount > _maxAmountRepurchasable) {
_repayAmountWithFee = _maxAmountRepurchasable;
_repayAmountWithoutFee = ctx.stateBefore.borrowerDebtAmount;
_vars.repayAmountWithFee = _maxAmountRepurchasable;
_vars.repayAmountWithoutFee = ctx.stateBefore.borrowerDebtAmount;
_vars.isRepurchaseAll = true;
} else {
// case 2
_repayAmountWithFee = ctx.desiredRepayAmount;
_repayAmountWithoutFee = (ctx.desiredRepayAmount * 10000) / (10000 + (ctx.repurchaseFeeBps));
_vars.repayAmountWithFee = ctx.desiredRepayAmount;
_vars.repayAmountWithoutFee = (ctx.desiredRepayAmount * 10000) / (10000 + (ctx.repurchaseFeeBps));
}
}
_vars.repurchaseFee = _vars.repayAmountWithFee - _vars.repayAmountWithoutFee;
_vars.actualRepurchaserRepayAmount = _vars.repayAmountWithFee;
_vars.actualDebtRepay = _vars.repayAmountWithoutFee;
_vars.expectRepayShare = LibShareUtil.valueToShare(
_vars.repayAmountWithoutFee,
ctx.stateBefore.debtTokenOverCollatDebtShares,
ctx.stateBefore.debtTokenOverCollatDebtAmount
);

if (_vars.isRepurchaseAll) {
{
uint256 _actualRepayShare = LibShareUtil.valueToShare(
_vars.repayAmountWithoutFee,
ctx.stateBefore.debtTokenOverCollatDebtShares,
ctx.stateBefore.debtTokenOverCollatDebtAmount
);

_vars.expectRepayShare = LibShareUtil.valueToShareRoundingUp(
_vars.repayAmountWithoutFee,
ctx.stateBefore.debtTokenOverCollatDebtShares,
ctx.stateBefore.debtTokenOverCollatDebtAmount
);

if (_actualRepayShare + 1 == _vars.expectRepayShare) {
_vars.actualRepurchaserRepayAmount += 1;
_vars.actualDebtRepay += 1;
}
}
}
uint256 _repurchaseFee = _repayAmountWithFee - _repayAmountWithoutFee;
// constant 1% premium
uint256 _repayTokenPriceWithPremium = (ctx.debtTokenPriceDuringRepurchase * (10000 + 100)) / 10000;
uint256 _collatSold = (_repayAmountWithFee * _repayTokenPriceWithPremium) / ctx.collatTokenPrice;
_vars.repayTokenPriceWithPremium = (ctx.debtTokenPriceDuringRepurchase * (10000 + 100)) / 10000;
_vars.collatSold = (_vars.repayAmountWithFee * _vars.repayTokenPriceWithPremium) / ctx.collatTokenPrice;

///////////////////////////////////
// repurchase
Expand All @@ -210,7 +250,7 @@ contract MoneyMarket_Liquidation_RepurchaseTest is MoneyMarket_BaseTest {
{
assertEq(
viewFacet.getCollatAmountOf(ctx.borrower, subAccount0, ctx.collatToken),
ctx.stateBefore.borrowerCollateralAmount - normalizeEther(_collatSold, IERC20(ctx.collatToken).decimals()),
ctx.stateBefore.borrowerCollateralAmount - normalizeEther(_vars.collatSold, IERC20(ctx.collatToken).decimals()),
"borrower remaining collat"
);
(uint256 _debtSharesAfter, uint256 _debtAmountAfter) = viewFacet.getOverCollatDebtShareAndAmountOf(
Expand All @@ -220,20 +260,13 @@ contract MoneyMarket_Liquidation_RepurchaseTest is MoneyMarket_BaseTest {
);
assertEq(
_debtAmountAfter,
ctx.stateBefore.borrowerDebtAmount - normalizeEther(_repayAmountWithoutFee, IERC20(ctx.debtToken).decimals()),
ctx.stateBefore.borrowerDebtAmount -
normalizeEther(_vars.repayAmountWithoutFee, IERC20(ctx.debtToken).decimals()),
"borrower remaining debt amount"
);
assertEq(
_debtSharesAfter,
ctx.stateBefore.borrowerDebtShares -
normalizeEther(
LibShareUtil.valueToShare(
_repayAmountWithoutFee,
ctx.stateBefore.debtTokenOverCollatDebtShares,
ctx.stateBefore.debtTokenOverCollatDebtAmount
),
IERC20(ctx.debtToken).decimals()
),
ctx.stateBefore.borrowerDebtShares - normalizeEther(_vars.expectRepayShare, IERC20(ctx.debtToken).decimals()),
"borrower remaining debt shares"
);
}
Expand All @@ -247,20 +280,21 @@ contract MoneyMarket_Liquidation_RepurchaseTest is MoneyMarket_BaseTest {
assertEq(
IERC20(ctx.collatToken).balanceOf(ctx.repurchaser),
ctx.stateBefore.repurchaserCollatTokenBalance +
normalizeEther(_collatSold, IERC20(ctx.collatToken).decimals()) -
normalizeEther(_repayAmountWithFee, IERC20(ctx.debtToken).decimals()),
normalizeEther(_vars.collatSold, IERC20(ctx.collatToken).decimals()) -
normalizeEther(_vars.repayAmountWithFee, IERC20(ctx.debtToken).decimals()),
"collatToken == debtToken: repurchaser collatToken received"
);
} else {
assertEq(
IERC20(ctx.collatToken).balanceOf(ctx.repurchaser),
ctx.stateBefore.repurchaserCollatTokenBalance + normalizeEther(_collatSold, IERC20(ctx.collatToken).decimals()),
ctx.stateBefore.repurchaserCollatTokenBalance +
normalizeEther(_vars.collatSold, IERC20(ctx.collatToken).decimals()),
"repurchaser collatToken received"
);
assertEq(
IERC20(ctx.debtToken).balanceOf(ctx.repurchaser),
ctx.stateBefore.repurchaserDebtTokenBalance -
normalizeEther(_repayAmountWithFee, IERC20(ctx.debtToken).decimals()),
normalizeEther(_vars.actualRepurchaserRepayAmount, IERC20(ctx.debtToken).decimals()),
"repurchaser debtToken paid"
);
}
Expand All @@ -274,46 +308,39 @@ contract MoneyMarket_Liquidation_RepurchaseTest is MoneyMarket_BaseTest {
assertEq(
viewFacet.getOverCollatTokenDebtValue(ctx.debtToken),
ctx.stateBefore.debtTokenOverCollatDebtAmount -
normalizeEther(_repayAmountWithoutFee, IERC20(ctx.debtToken).decimals()),
normalizeEther(_vars.actualDebtRepay, IERC20(ctx.debtToken).decimals()),
"money market debtToken overCollatDebtValue"
);
assertEq(
viewFacet.getOverCollatTokenDebtShares(ctx.debtToken),
ctx.stateBefore.debtTokenOverCollatDebtShares -
normalizeEther(
LibShareUtil.valueToShare(
_repayAmountWithoutFee,
ctx.stateBefore.debtTokenOverCollatDebtShares,
ctx.stateBefore.debtTokenOverCollatDebtAmount
),
IERC20(ctx.debtToken).decimals()
),
normalizeEther(_vars.expectRepayShare, IERC20(ctx.debtToken).decimals()),
"money market debtToken overCollatDebtShares"
);
assertEq(
viewFacet.getGlobalDebtValue(ctx.debtToken),
ctx.stateBefore.debtTokenGlobalDebtValue -
normalizeEther(_repayAmountWithoutFee, IERC20(ctx.debtToken).decimals()),
normalizeEther(_vars.actualDebtRepay, IERC20(ctx.debtToken).decimals()),
"money market debtToken globalDebtValue"
);
assertEq(
viewFacet.getFloatingBalance(ctx.debtToken),
ctx.stateBefore.debtTokenReserve + normalizeEther(_repayAmountWithoutFee, IERC20(ctx.debtToken).decimals()),
ctx.stateBefore.debtTokenReserve + normalizeEther(_vars.actualDebtRepay, IERC20(ctx.debtToken).decimals()),
"money market debtToken reserve"
);

// check fee in debtToken to liquidationTreasury
assertEq(
IERC20(ctx.debtToken).balanceOf(liquidationTreasury),
ctx.stateBefore.treasuryDebtTokenBalance + _repurchaseFee,
ctx.stateBefore.treasuryDebtTokenBalance + _vars.repurchaseFee,
"repurchase fee"
);

// debt token in MiniFL should be equal to debtShare after repurchased (withdrawn & burned)
// since debt token is minted only one time, so the totalSupply should be equal to _stateAfter.debtShare after burned
address _miniFLDebtToken = viewFacet.getDebtTokenFromToken(ctx.debtToken);
uint256 _poolId = viewFacet.getMiniFLPoolIdOfToken(_miniFLDebtToken);
assertEq(_miniFL.getUserTotalAmountOf(_poolId, ALICE), viewFacet.getOverCollatTokenDebtShares(ctx.debtToken));
assertEq(_miniFL.getStakingReserves(_poolId), viewFacet.getOverCollatTokenDebtShares(ctx.debtToken));
assertEq(DebtToken(_miniFLDebtToken).totalSupply(), viewFacet.getOverCollatTokenDebtShares(ctx.debtToken));
}

Expand Down Expand Up @@ -604,4 +631,31 @@ contract MoneyMarket_Liquidation_RepurchaseTest is MoneyMarket_BaseTest {
vm.expectRevert(abi.encodeWithSelector(ILiquidationFacet.LiquidationFacet_Unauthorized.selector));
liquidationFacet.repurchase(ALICE, 0, address(usdc), address(usdc), 1);
}

function testCorrectness_WhenRepurchaseEntireDebtWithPrecisionLoss_NoDebtRemainInSubAccount() public {
_makeAliceUnderwater();

// 0.01% interest
skip(1);

// make BOB borrow another 0.1 weth to cause precision loss when alice'debt got repurchase
adminFacet.setMinDebtSize(0);
vm.startPrank(BOB);
accountManager.depositAndAddCollateral(subAccount0, address(weth), 1 ether);
accountManager.borrow(subAccount0, address(weth), 0.1 ether);
vm.stopPrank();

TestRepurchaseContext memory ctx;
ctx.borrower = ALICE;
ctx.repurchaser = BOB;
ctx.collatToken = address(usdc);
ctx.debtToken = address(weth);
// more than 1.1 ether (+interest) max repurchasable set by `makeAliceUnderwater`
ctx.desiredRepayAmount = 2 ether;
ctx.repurchaseFeeBps = REPURCHASE_FEE;
ctx.collatTokenPrice = 1 ether;
ctx.debtTokenPriceDuringRepurchase = 2 ether;
ctx.borrowerPendingInterest = viewFacet.getGlobalPendingInterest(address(weth));
_testRepurchase(ctx);
}
}

0 comments on commit 6a7f741

Please sign in to comment.