-
Notifications
You must be signed in to change notification settings - Fork 14
HHK - Wrong accLoanRatePerSeconds
in repay()
can lead to underflow
#119
Comments
I think you are wrong in your conclusions. Try playing with emergency close tests. The calculation must be made with the new borrowing.borrowedAmount, since in the future it will be used in the next calculation. The commission debt should be maintained and increase over time, and in your case, it is decreasing. |
accLoanRatePerSeconds
in repay()
can lead to underflowaccLoanRatePerSeconds
in repay()
can lead to underflow
Escalate While using the new To find the missing collateral balance with a smaller borrowed amount, the only solution is to increase the percentage. But like I said, if you increase the percentage you might end up underflowing as the Here is a simple example:
I did play with the emergency close tests. Here I reused it and added comments: it("emergency repay will be successful for PosManNFT owner if the collateral is depleted", async () => {
let debt: LiquidityBorrowingManager.BorrowingInfoExtStructOutput[] =
await borrowingManager.getBorrowerDebtsInfo(bob.address);
await time.increase(debt[1].estimatedLifeTime.toNumber() + 3600 * 36); //add 36 hours as an example
debt = await borrowingManager.getBorrowerDebtsInfo(bob.address); //update debt variable
let borrowingKey = await borrowingManager.userBorrowingKeys(bob.address, 1);
let swap_params = ethers.utils.defaultAbiCoder.encode(
["address", "address", "uint256", "uint256"],
[constants.AddressZero, constants.AddressZero, 0, 0]
);
swapData = swapIface.encodeFunctionData("swap", [swap_params]);
let swapParams: ApproveSwapAndPay.SwapParamsStruct = {
swapTarget: constants.AddressZero,
swapAmountInDataIndex: 0,
maxGasForCall: 0,
swapData: swapData,
};
let params: LiquidityBorrowingManager.RepayParamsStruct = {
isEmergency: true, //emergency
internalSwapPoolfee: 0,
externalSwap: swapParams,
borrowingKey: borrowingKey,
swapSlippageBP1000: 0,
};
//console.log(debt);
let loans: LiquidityManager.LoanInfoStructOutput[] = await borrowingManager.getLoansInfo(borrowingKey);
expect(loans.length).to.equal(3);
let tokenRate = await borrowingManager.getHoldTokenDailyRateInfo(
debt[1].info.saleToken,
debt[1].info.holdToken
);
// We're going to repay alice loan which is [0] in loans array
const aliceLoanPercentage = loans[0].liquidity
.mul(100)
.div(loans[0].liquidity.add(loans[1].liquidity).add(loans[2].liquidity));
console.log("Alice % of total borrow: " + aliceLoanPercentage); //Alice liquidity is around 26% of the borrowing position
//Here we do like the smart contract does, try to see how much accLoanRatePerSeconds we need to remove to find back our missing collateral
//without removedAmount we use the new borrowed amount (current implementation)
let accToRemoveWithoutRemovedAmount = debt[1].collateralBalance
.mul(-1)
.mul(10_000)
.div(debt[1].info.borrowedAmount.mul(100 - aliceLoanPercentage.toNumber()).div(100));
//with removedAmount (proposed solution), we forfeit alice part of missing collateral since she emergency withdraw
let accToRemoveWithRemovedAmount = debt[1].collateralBalance
.mul(-1)
.mul(10_000)
.div(debt[1].info.borrowedAmount);
//this is the accLoanRatePerSeconds that we're going to substract from
console.log("% to remove: " + accToRemoveWithoutRemovedAmount);
console.log("accLoanRatePerSeconds: " + tokenRate.holdTokenRateInfo.accLoanRatePerSeconds);
//for now even after 36hours we're still less than accLoanRatePerSeconds so no underflow
console.log("% to remove is less than accLoanRatePerSeconds");
expect(tokenRate.holdTokenRateInfo.accLoanRatePerSeconds.gt(accToRemoveWithoutRemovedAmount));
//let's add 12 more hours
await time.increase(3600 * 12); //add 12 hours
tokenRate = await borrowingManager.getHoldTokenDailyRateInfo(debt[1].info.saleToken, debt[1].info.holdToken); //update token rate
debt = await borrowingManager.getBorrowerDebtsInfo(bob.address); //update debt variable
accToRemoveWithoutRemovedAmount = debt[1].collateralBalance
.mul(-1)
.mul(10_000)
.div(debt[1].info.borrowedAmount.mul(100 - aliceLoanPercentage.toNumber()).div(100));
accToRemoveWithRemovedAmount = debt[1].collateralBalance.mul(-1).mul(10_000).div(debt[1].info.borrowedAmount);
console.log("% to remove: " + accToRemoveWithoutRemovedAmount);
console.log("accLoanRatePerSeconds: " + tokenRate.holdTokenRateInfo.accLoanRatePerSeconds);
//the accToRemoveWithoutRemovedAmount is now greater than accLoanRatePerSeconds
console.log("% to remove is greater than accLoanRatePerSeconds thus it will underflow");
expect(tokenRate.holdTokenRateInfo.accLoanRatePerSeconds.lt(accToRemoveWithoutRemovedAmount));
let deadline = (await time.latest()) + 60;
//this is going to revert with underflow
await expect(borrowingManager.connect(alice).repay(params, deadline)).to.be.reverted;
/**
expect(await borrowingManager.getLenderCreditsCount(nftpos[0].tokenId)).to.be.equal(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[1].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[2].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[3].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[4].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[5].tokenId)).to.be.gt(0);
expect(await borrowingManager.getBorrowerDebtsCount(bob.address)).to.be.equal(2);
debt = await borrowingManager.getBorrowerDebtsInfo(bob.address);
//console.log(debt);
loans = await borrowingManager.getLoansInfo(borrowingKey);
expect(loans.length).to.equal(2);
await time.increase(100);
deadline = (await time.latest()) + 60;
await expect(borrowingManager.connect(bob).repay(params, deadline))
.to.emit(borrowingManager, "EmergencyLoanClosure")
.withArgs(bob.address, bob.address, borrowingKey);
expect(await borrowingManager.getLenderCreditsCount(nftpos[0].tokenId)).to.be.equal(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[1].tokenId)).to.be.equal(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[2].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[3].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[4].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[5].tokenId)).to.be.gt(0);
expect(await borrowingManager.getBorrowerDebtsCount(bob.address)).to.be.equal(2);
debt = await borrowingManager.getBorrowerDebtsInfo(bob.address);
//console.log(debt);
loans = await borrowingManager.getLoansInfo(borrowingKey);
expect(loans.length).to.equal(1);
await time.increase(100);
deadline = (await time.latest()) + 60;
await expect(borrowingManager.connect(owner).repay(params, deadline))
.to.emit(borrowingManager, "EmergencyLoanClosure")
.withArgs(bob.address, owner.address, borrowingKey);
expect(await borrowingManager.getLenderCreditsCount(nftpos[0].tokenId)).to.be.equal(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[1].tokenId)).to.be.equal(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[2].tokenId)).to.be.equal(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[3].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[4].tokenId)).to.be.gt(0);
expect(await borrowingManager.getLenderCreditsCount(nftpos[5].tokenId)).to.be.gt(0);
expect(await borrowingManager.getBorrowerDebtsCount(bob.address)).to.be.equal(1);*/
}); In this example Alice represents only 26% of the borrowed amount and the I understand that my solution might not be what you want, but the underflow problem do exist. |
The escalation could not be created because you are not exceeding the escalation threshold. You can view the required number of additional valid issues/judging contest payouts in your Profile page, |
tokenRate also needs to be updated like the debt variable |
Right, my bad sorry wrote the test quickly. Updated my comment with the new token rate, |
@fann95 @cvetanovv sorry to bother but would appreciate if you could read the escalation comment again and run the 2 POCs provided (comment and initial finding) before escalation period ends. The |
issue 195 is also similar. I think both issues should be reconsidered to be valid. This bug is easily replicated using protocol's own default test file WagmiLeverageTests.ts by changing line 1040 to 100_000 seconds (~1 day) Expecting an under-collateralized loan to not be liquidated in ~1 day is not unrealistic, after which the failure of emergency mode represents a significant impact to a critical protocol function. |
@fann95 @cvetanovv could you take a look at this and #195? |
Should be duplicated and rather seem valid to me |
@fann95 could you post your opinion on this issue? It seems to be valid. |
I looked at this problem one more and realized: yes, this is a mistake. When a liquidity provider withdraws its liquidity, it also surrenders its fee debt, so the debt must be reduced in proportion to the reduction in liquidity. |
I came to an interesting conclusion: the borrowingStorage.accLoanRatePerSeconds does not need to be recalculated. It should remain the same as it was, and the borrowed amount should be decreased only. So, accordingly, the fee debt will be reduced during a future recalculation. |
@fann95 may I ask why do you consider this a high severity issue? It seems the watson classified this as medium severity. |
Agree with medium severity. Will be accepting the escalation. |
Fix looks good. |
HHK
medium
Wrong
accLoanRatePerSeconds
inrepay()
can lead to underflowSummary
When a Lender call the
repay()
function of the LiquidityBorrowingManager contract to do an emergency liquidity restoration usingisEmergency = true
, theborrowingStorage.accLoanRatePerSeconds
is updated if the borrowing position hasn't been fully closed.The computation is made in sort that the missing collateral can be computed again later so we don't loose the missing fees in case someone take over or the borrower decide to reimburse the fees.
But this computation is wrong and can lead to underflow.
Vulnerability Detail
Because the
repay()
function resets thedailyRateCollateralBalance
to 0 when the lender call didn't fully close the position. We want to be able to compute the missing collateral again.To do so we substract the percentage of collateral not paid to the
accLoanRatePerSeconds
so on the next call we will be adding extra second of fees that will allow the contract to compute the missing collateral.The problem lies in the fact that we compute a percentage using the borrowed amount left instead of the initial borrow amount causing the percentage to be higher. In practice this do allows the contract to recompute the missing collateral.
But in the case of the missing
collateralBalance
orremovedAmt
being very high (ex: multiple days not paid or the loan removed was most of the position's liquidity) we might end up with a percentage higher than theaccLoanRatePerSeconds
which will cause an underflow.In case of an underflow the call will revert and the lender will not be able to get his tokens back.
Consider this POC that can be copied and pasted in the test files (replace all tests and just keep the setup & NFT creation):
Impact
Medium. Lender might not be able to use
isEmergency
onrepay()
and will have to do a normal liquidation if he want his liquidity back.Code Snippet
https://github.com/sherlock-audit/2023-10-real-wagmi/blob/b33752757fd6a9f404b8577c1eae6c5774b3a0db/wagmi-leverage/contracts/LiquidityBorrowingManager.sol#L532
https://github.com/sherlock-audit/2023-10-real-wagmi/blob/b33752757fd6a9f404b8577c1eae6c5774b3a0db/wagmi-leverage/contracts/LiquidityBorrowingManager.sol#L613
Tool used
Manual Review
Recommendation
Consider that when a lender do an emergency liquidity restoration they give up on their collateral missing and so use the initial amount in the computation instead of borrowed amount left.
The text was updated successfully, but these errors were encountered: