Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bug fix: kickWithDeposit likely push LUP below HTP affecting healthy loans #894

Merged
merged 17 commits into from
Jun 18, 2023
Merged
Show file tree
Hide file tree
Changes from 4 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
133 changes: 31 additions & 102 deletions src/libraries/external/KickerActions.sol
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,11 @@ library KickerActions {

/// @dev Struct used for `kickWithDeposit` function local vars.
struct KickWithDepositLocalVars {
uint256 amountToDebitFromDeposit; // [WAD] the amount of quote tokens used to kick and debited from lender deposit
uint256 bucketCollateral; // [WAD] amount of collateral in bucket
uint256 bucketDeposit; // [WAD] amount of quote tokens in bucket
uint256 bucketLP; // [WAD] LP of the bucket
uint256 bucketPrice; // [WAD] bucket price
uint256 bucketScale; // [WAD] bucket scales
uint256 bucketUnscaledDeposit; // [WAD] unscaled amount of quote tokens in bucket
uint256 lenderLP; // [WAD] LP of lender in bucket
uint256 redeemedLP; // [WAD] LP used by kick action
uint256 bucketDeposit; // [WAD] amount of quote tokens in bucket
uint256 bucketPrice; // [WAD] bucket price
uint256 currentLup; // [WAD] current LUP in pool
uint256 entitledAmount; // [WAD] amount that lender is entitled to remove at specified index
uint256 lenderLP; // [WAD] LP of lender in bucket
}

/**************/
Expand Down Expand Up @@ -140,6 +136,7 @@ library KickerActions {
* @dev - decrement `lender.lps` accumulator
* @dev - decrement `bucket.lps` accumulator
* @dev === Reverts on ===
* @dev bucket price bellow current pool `LUP` `PriceBelowLUP()`
* @dev insufficient deposit to kick auction `InsufficientLiquidity()`
* @dev no `LP` redeemed to kick auction `InsufficientLP()`
* @dev === Emit events ===
Expand All @@ -157,35 +154,35 @@ library KickerActions {
) external returns (
KickResult memory kickResult_
) {
Bucket storage bucket = buckets_[index_];
Lender storage lender = bucket.lenders[msg.sender];

KickWithDepositLocalVars memory vars;

if (bucket.bankruptcyTime < lender.depositTime) vars.lenderLP = lender.lps;
vars.bucketPrice = _priceAt(index_);
vars.currentLup = Deposits.getLup(deposits_, poolState_.debt);

// revert if the bucket price is below LUP
if (vars.bucketPrice < vars.currentLup) revert PriceBelowLUP();

Bucket storage bucket = buckets_[index_];
Lender storage lender = bucket.lenders[msg.sender];

vars.bucketLP = bucket.lps;
vars.bucketCollateral = bucket.collateral;
vars.bucketPrice = _priceAt(index_);
vars.bucketUnscaledDeposit = Deposits.unscaledValueAt(deposits_, index_);
vars.bucketScale = Deposits.scale(deposits_, index_);
vars.bucketDeposit = Maths.wmul(vars.bucketUnscaledDeposit, vars.bucketScale);
vars.lenderLP = bucket.bankruptcyTime < lender.depositTime ? lender.lps : 0;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kickWithDeposit could be called in the same block as bucket bankruptcy. It would use lender.lps even though the bucket is bankrupt. Recommend adding a revert like we have in moveQuoteToken ->

// cannot move in the same block when target bucket becomes insolvent
if (vars.toBucketBankruptcyTime == block.timestamp) revert BucketBankruptcyBlock();`

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if bucket bankruptcy happens in same block but after kick with deposit is called then vars.lenderLP will be 0 making entitled amount to be 0 and tx to revert with InsufficientLiquidity

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking before, what if a bucket bankruptcy occurs in the same block but before kickWithDeposit is called.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think block when kick happens is the constraint here but the block / time of last deposit?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was misinterpretting the logic...

if bucket.bankruptcyTime < lender.depositTime ? lender.lps : 0; -> if the bucket was bankrupted in the same block this would be 0 as it should be.

All good : )

vars.bucketDeposit = Deposits.valueAt(deposits_, index_);

// calculate amount to remove based on lender LP in bucket
vars.amountToDebitFromDeposit = Buckets.lpToQuoteTokens(
vars.bucketCollateral,
vars.bucketLP,
// calculate amount lender is entitled in current bucket (based on lender LP in bucket)
vars.entitledAmount = Buckets.lpToQuoteTokens(
bucket.collateral,
bucket.lps,
vars.bucketDeposit,
vars.lenderLP,
vars.bucketPrice,
Math.Rounding.Down
);

// cap the amount to remove at bucket deposit
if (vars.amountToDebitFromDeposit > vars.bucketDeposit) vars.amountToDebitFromDeposit = vars.bucketDeposit;
// cap the amount entitled at bucket deposit
if (vars.entitledAmount > vars.bucketDeposit) vars.entitledAmount = vars.bucketDeposit;

// revert if no amount that can be removed
if (vars.amountToDebitFromDeposit == 0) revert InsufficientLiquidity();
// revert if no entitled amount
if (vars.entitledAmount == 0) revert InsufficientLiquidity();

// kick top borrower
kickResult_ = _kick(
Expand All @@ -195,81 +192,10 @@ library KickerActions {
poolState_,
Loans.getMax(loans_).borrower,
limitIndex_,
vars.amountToDebitFromDeposit
);

// amount to remove from deposit covers entire bond amount
if (vars.amountToDebitFromDeposit > kickResult_.amountToCoverBond) {
// cap amount to remove from deposit at amount to cover bond
vars.amountToDebitFromDeposit = kickResult_.amountToCoverBond;

// recalculate the LUP with the amount to cover bond
kickResult_.lup = Deposits.getLup(deposits_, poolState_.debt + vars.amountToDebitFromDeposit);
// entire bond is covered from deposit, no additional amount to be send by lender
kickResult_.amountToCoverBond = 0;
} else {
// lender should send additional amount to cover bond
kickResult_.amountToCoverBond -= vars.amountToDebitFromDeposit;
}

// revert if the bucket price used to kick and remove is below new LUP
if (vars.bucketPrice < kickResult_.lup) revert PriceBelowLUP();

// remove amount from deposits
if (vars.amountToDebitFromDeposit == vars.bucketDeposit && vars.bucketCollateral == 0) {
// In this case we are redeeming the entire bucket exactly, and need to ensure bucket LP are set to 0
vars.redeemedLP = vars.bucketLP;

Deposits.unscaledRemove(deposits_, index_, vars.bucketUnscaledDeposit);
vars.bucketUnscaledDeposit = 0;

} else {
vars.redeemedLP = Buckets.quoteTokensToLP(
vars.bucketCollateral,
vars.bucketLP,
vars.bucketDeposit,
vars.amountToDebitFromDeposit,
vars.bucketPrice,
Math.Rounding.Up
);

uint256 unscaledAmountToRemove = Maths.floorWdiv(vars.amountToDebitFromDeposit, vars.bucketScale);

// revert if calculated unscaled amount is 0
if (unscaledAmountToRemove == 0) revert InsufficientLiquidity();

Deposits.unscaledRemove(deposits_, index_, unscaledAmountToRemove);
vars.bucketUnscaledDeposit -= unscaledAmountToRemove;
}

vars.redeemedLP = Maths.min(vars.lenderLP, vars.redeemedLP);

// revert if LP redeemed amount to kick auction is 0
if (vars.redeemedLP == 0) revert InsufficientLP();

uint256 bucketRemainingLP = vars.bucketLP - vars.redeemedLP;

if (vars.bucketCollateral == 0 && vars.bucketUnscaledDeposit == 0 && bucketRemainingLP != 0) {
bucket.lps = 0;
bucket.bankruptcyTime = block.timestamp;

emit BucketBankruptcy(
index_,
bucketRemainingLP
);
} else {
// update lender and bucket LP balances
lender.lps -= vars.redeemedLP;
bucket.lps -= vars.redeemedLP;
}

emit RemoveQuoteToken(
msg.sender,
index_,
vars.amountToDebitFromDeposit,
vars.redeemedLP,
kickResult_.lup
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quite happy to eliminate all this logic.

vars.entitledAmount
);
// set LUP to current pool value
kickResult_.lup = vars.currentLup;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically, this won't be correct, as the pool's debt will increase slightly. I believe to be totally correct we should recompute the LUP here including the kick penalty interest.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense, though should be done for regular kick too, will add this with a commit

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

/*************************/
Expand Down Expand Up @@ -381,8 +307,11 @@ library KickerActions {
kickResult_.debtPreAction = borrower.t0Debt;
kickResult_.collateralPreAction = borrower.collateral;
kickResult_.t0KickedDebt = kickResult_.debtPreAction ;

// add amount to remove to pool debt in order to calculate proposed LUP
kickResult_.lup = Deposits.getLup(deposits_, poolState_.debt + additionalDebt_);
// for regular kick this is the currrent LUP in pool
// for provisional kick this just simulates LUP movement and needs to be reset to current LUP in pool
kickResult_.lup = Deposits.getLup(deposits_, poolState_.debt + additionalDebt_);

KickLocalVars memory vars;
vars.borrowerDebt = Maths.wmul(kickResult_.t0KickedDebt, poolState_.inflator);
Expand Down
2 changes: 1 addition & 1 deletion tests/INVARIANTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
## Quote Token
- **QT1**: pool quote token balance (`Quote.balanceOf(pool)`) >= liquidation bonds (`AuctionsState.totalBondEscrowed`) + pool deposit size (`Pool.depositSize()`) + reserve auction unclaimed amount (`reserveAuction.unclaimed`) - pool t0 debt (`PoolBalancesState.t0Debt`) (with a `1e13` margin)
- **QT2**: pool t0 debt (`PoolBalancesState.t0Debt`) = sum of t0 debt across all borrowers (`Borrower.t0Debt`)
- **QT3**: pool quote token balance (`Quote.balanceOf(pool)`) >= claimable liquidation bonds + reserve auction unclaimed amount (`reserveAuction.unclaimed`)
- **QT3**: pool quote token balance (`Quote.balanceOf(pool)`) >= liquidation bonds (`AuctionsState.totalBondEscrowed`) + reserve auction unclaimed amount (`reserveAuction.unclaimed`)

## Auctions
- **A1**: total t0 debt auctioned (`PoolBalancesState.t0DebtInAuction`) = sum of debt across all auctioned borrowers (`Borrower.t0Debt` where borrower's `kickTime != 0`)
Expand Down
25 changes: 8 additions & 17 deletions tests/forge/invariants/base/BasicInvariants.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -178,28 +178,19 @@ abstract contract BasicInvariants is BaseInvariants {
require(poolDebt == totalDebt, "Quote Token Invariant QT2");
}

/// @dev checks pool quote token balance is greater than or equal with unclaimed reserves plus claimable auction bonds
/// @dev checks pool quote token balance is greater than or equal with sum of escrowed bonds and unclaimed reserves
function _invariant_QT3() internal view {
// convert pool quote balance into WAD
uint256 poolBalance = _quote.balanceOf(address(_pool)) * _pool.quoteTokenScale();
(, uint256 unClaimed, , ) = _pool.reservesInfo();

uint256 actorCount = IBaseHandler(_handler).getActorsCount();
uint256 claimableAuctionBonds;
for (uint256 i = 0; i < actorCount; i++) {
address kicker = IBaseHandler(_handler).actors(i);
(uint256 claimable, ) = _pool.kickerInfo(kicker);

claimableAuctionBonds += claimable;
}

console.log("poolBalance -> ", poolBalance);
console.log("claimable reserves -> ", unClaimed);
console.log("claimable bonds -> ", claimableAuctionBonds);
(
uint256 totalBondEscrowed,
uint256 unClaimed,
,
) = _pool.reservesInfo();

require(
poolBalance >= unClaimed + claimableAuctionBonds,
"QT3: claimable escrowed bonds and claimable reserves not guaranteed"
poolBalance >= totalBondEscrowed + unClaimed,
"QT3: escrowed bonds and claimable reserves not guaranteed"
);
}

Expand Down
Loading