Thunder Loan

AI First Flight #7
Beginner FriendlyFoundryDeFiOracle
EXP
View results
Submission Details
Impact: medium
Likelihood: high
Invalid

Dust being collected due to loss of precision in redeem function

Root + Impact

  • Root: There's a loss of precision in the ThunderLoan::redeem function during the calculation of amountUnderlying, as it's not been rounded up.

  • Impact: With this, dust will start getting collected in the protocol itself. The amount might look tiny, but after a while it will get a lot bigger.

Description

  • Normal Behaviour: The protocol is intended to take funds from Liquidity Providers, collect fees from users who process flash loans, and then redeem the funds to liquidity providers along with the interest they accrued, which is coming from those fees, obviously. Overall, no dust or pending amount should be left in the protocol.

  • Issue: Unfortunately, the case discussed above doesn't appear to be completely true. As there's definitely a case of loss of precision in the system when we calculate the amountUnderlying in the redeem function. Due to this, the Liquidity providers will start losing some wei every time. The worst part? This amount will keep getting bigger and bigger over time. And no one's there to collect it anyway.

function redeem(
IERC20 token,
uint256 amountOfAssetToken
)
external
revertIfZero(amountOfAssetToken)
revertIfNotAllowedToken(token)
{
// ...
@> uint256 amountUnderlying = (amountOfAssetToken * exchangeRate) / assetToken.EXCHANGE_RATE_PRECISION();
// ...
}

Risk

  • Likelihood: High

    • Occurs every time a liquidity provider tries to redeem their underlying tokens

  • Impact: Medium

    • Dust is getting collected in the protocol, leading to a good amount of loss collectively.

    • Frequent users will be affected more than others.

Proof of Concept

  1. First, add this particular modifier in test/unit/ThunderLoanTest.t.sol

    modifier flashLoan() {
    uint256 amountToBorrow = AMOUNT * 100;
    uint256 calculatedFee = thunderLoan.getCalculatedFee(tokenA, amountToBorrow);
    vm.startPrank(user);
    tokenA.mint(address(mockFlashLoanReceiver), AMOUNT);
    thunderLoan.flashloan(address(mockFlashLoanReceiver), tokenA, amountToBorrow, "");
    vm.stopPrank();
    _;
    }

  2. Next, add this test

    function test__DustBeingCollectedInProtocolDueToLossPrecisionInRedeem() public setAllowedToken hasDeposits flashLoan {
    // Current exchange rate
    console.log("ExchangeRate after user processed a flash loan: ", thunderLoan.getAssetFromToken(tokenA).getExchangeRate());
    // A new liquidity provider comes into play
    address liquidityProvider2 = address(789);
    // Mints himself some tokens
    vm.prank(liquidityProvider2);
    tokenA.mint(liquidityProvider2, DEPOSIT_AMOUNT);
    uint256 initialBalance = tokenA.balanceOf(liquidityProvider2);
    console.log("Balance of liquidity provider 2 before deposit: ", initialBalance);
    // Deposits
    vm.startPrank(liquidityProvider2);
    tokenA.approve(address(thunderLoan), DEPOSIT_AMOUNT);
    thunderLoan.deposit(tokenA, DEPOSIT_AMOUNT);
    vm.stopPrank();
    console.log("Balance of liquidity provider 2 after deposit: ", tokenA.balanceOf(liquidityProvider2));
    // LP2 will get less asset tokens due to increased exchange rate
    console.log("AssetToken balance of liquidity provider 2 after deposit: ", thunderLoan.getAssetFromToken(tokenA).balanceOf(liquidityProvider2));
    // Redeem
    vm.prank(liquidityProvider2);
    thunderLoan.redeem(tokenA, type(uint256).max);
    console.log("Balance of liquidity provider 2 after redeem: ", tokenA.balanceOf(liquidityProvider2));
    assertNotEq(tokenA.balanceOf(liquidityProvider2), initialBalance);
    }

  3. Run the test using:

    forge test --mt test__DustBeingCollectedInProtocolDueToLossPrecisionInRedeem -vv

  4. Logs:

    Ran 1 test for test/unit/ThunderLoanTest.t.sol:ThunderLoanTest
    [PASS] test__DustBeingCollectedInProtocolDueToLossPrecisionInRedeem() (gas: 1803371)
    Logs:
    ExchangeRate after user processed a flash loan: 1003000000000000000
    Balance of liquidity provider 2 before deposit: 1000000000000000000000
    Balance of liquidity provider 2 after deposit: 0
    AssetToken balance of liquidity provider 2 after deposit: 997008973080757726819
    Balance of liquidity provider 2 after redeem: 999999999999999999999
    Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.33ms (878.26µs CPU time)

Recommended Mitigation

Round up amountUnderlying to 1

function redeem(
IERC20 token,
uint256 amountOfAssetToken
)
external
revertIfZero(amountOfAssetToken)
revertIfNotAllowedToken(token)
{
AssetToken assetToken = s_tokenToAssetToken[token];
uint256 exchangeRate = assetToken.getExchangeRate();
if (amountOfAssetToken == type(uint256).max) {
amountOfAssetToken = assetToken.balanceOf(msg.sender);
}
- uint256 amountUnderlying = (amountOfAssetToken * exchangeRate) / assetToken.EXCHANGE_RATE_PRECISION();
+ uint256 amountUnderlying = ((amountOfAssetToken * exchangeRate) / assetToken.EXCHANGE_RATE_PRECISION()) + 1;
emit Redeemed(msg.sender, token, amountOfAssetToken, amountUnderlying);
assetToken.burn(msg.sender, amountOfAssetToken);
assetToken.transferUnderlyingTo(msg.sender, amountUnderlying);
}
Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 8 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!