Platform: Code4rena
Start Date: 26/01/2023
Pot Size: $60,500 USDC
Total HM: 7
Participants: 31
Period: 6 days
Judge: berndartmueller
Total Solo HM: 3
Id: 207
League: ETH
Rank: 4/31
Findings: 2
Award: $1,960.28
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: RaymondFam
Also found by: 0xhacksmithh, Deivitto, peakbolt, rvierdiiev
533.4249 USDC - $533.42
https://github.com/code-423n4/2023-01-numoen/blob/main/src/periphery/LendgineRouter.sol#L142-L169 https://github.com/code-423n4/2023-01-numoen/blob/main/src/periphery/LendgineRouter.sol#L87-L124 https://github.com/code-423n4/2023-01-numoen/blob/main/src/core/Lendgine.sol#L95-L99
In LendgineRouter.sol, mintCallback() will transfer token1 (collateral) to Lendgine.sol contract upon minting of Power Tokens. If token1 is a fee-on-transfer token, these transfers will incur fees and the recipient (Lendgine.sol) will receive a deducted amount that is less than the collateral amount, causing the mint to always fail.
As Numoen is permissionless, anyone can create a pool with fee-on-transfer ERC20 tokens. Example of these fee-on-transfer tokens are STA and PAXG. And some tokens (e.g. USDT) has fee-on-transfer support disabled currently but may enable it in the future.
Start by calling LengineRouter.mint() to trigger the mintCallback() function.
https://github.com/code-423n4/2023-01-numoen/blob/main/src/periphery/LendgineRouter.sol#L142-L169
mintCallback() will initiate token1 transfers to Lendgine contract (which is the msg.sender) via swap(), SafeTransferLib.safeTransfer() and pay(). Lendgine contract will receive a reduced amount due to fee deduction on transfer.
https://github.com/code-423n4/2023-01-numoen/blob/main/src/periphery/LendgineRouter.sol#L87-L124
As Lendgine contract is expecting the full amount of collateral to be received based on the balance increase, the mint transaction will revert.
uint256 balanceBefore = Balance.balance(token1); IMintCallback(msg.sender).mintCallback(collateral, amount0, amount1, liquidity, data); uint256 balanceAfter = Balance.balance(token1); if (balanceAfter < balanceBefore + collateral) revert InsufficientInputError();
https://github.com/code-423n4/2023-01-numoen/blob/main/src/core/Lendgine.sol#L95-L99
One potential mitigation is to take into account the transfer fee during the transfer request. Another option is to block these customised tokens.
#0 - c4-judge
2023-02-07T18:23:02Z
berndartmueller marked the issue as duplicate of #263
#1 - c4-judge
2023-02-16T09:50:18Z
berndartmueller marked the issue as satisfactory
🌟 Selected for report: peakbolt
Also found by: adeolu, rvierdiiev
1426.8567 USDC - $1,426.86
https://github.com/code-423n4/2023-01-numoen/blob/main/src/periphery/LendgineRouter.sol#L142 https://github.com/code-423n4/2023-01-numoen/blob/main/src/periphery/LendgineRouter.sol#L87-L124 https://github.com/code-423n4/2023-01-numoen/blob/main/src/periphery/LendgineRouter.sol#L119-L123 https://github.com/code-423n4/2023-01-numoen/blob/main/src/periphery/Payment.sol#L44-L46
When the collateral/speculative token (Token1) is WETH, a borrower could mint Power Tokens and deposit the collateral tokens by sending ETH while calling the payable mint() function in LendgineRouter.sol.
The exact collateral amount required to be deposited by the borrower is only calculated during minting (due to external swap), which could be lesser than what the borrower has sent for the mint. This means that there will be excess ETH left in LengineRouter contract and they are not automatically refunded to the borrower.
Anyone that see this opportunity can call refundETH() to retrieve the excess ETH.
The borrower could retrieve the remaining ETH with a separate call to refundETH(). However, as the calls are not atomic, it is possible for a MEV bot to frontrun the borrower and steal the ETH too.
Furthermore, there are no documentation and test cases that advise or handle this issue.
First, call payable mint() in LendgineRouter contract with the required ETH amount for collateral.
function mint(MintParams calldata params) external payable checkDeadline(params.deadline) returns (uint256 shares) {
https://github.com/code-423n4/2023-01-numoen/blob/main/src/periphery/LendgineRouter.sol#L142
LendgineRouter.mintCallback() will be triggered, which will perform the external swap of the borrowed token0 to token1 on uniswap. The collateralSwap value (token1) is only calculated and known after the successful swap. Both swapped token1 and borrowed token1 are then sent to Lendgine contract (msg.sender).
// swap all token0 to token1 uint256 collateralSwap = swap( decoded.swapType, SwapParams({ tokenIn: decoded.token0, tokenOut: decoded.token1, amount: SafeCast.toInt256(amount0), recipient: msg.sender }), decoded.swapExtraData ); // send token1 back SafeTransferLib.safeTransfer(decoded.token1, msg.sender, amount1);
https://github.com/code-423n4/2023-01-numoen/blob/main/src/periphery/LendgineRouter.sol#L87-L124
After that, mintCallback() will continue to calculate the remaining token1 required to be paid by the borrower (collateralIn value).
Depending on the external swap, the collateralSwap (token1) value could be higher than expected, resulting in a lower collateralIn value. A small collateralIn value means that less ETH is required to be paid by the borrower (via the pay function), resulting in excess ETH left in the LengineRouter contract. However, the excess ETH is not automatically refunded by the mint() call.
Note: For WETH, the pay() uses the ETH balance deposited and wrap it before transferring to Lendgine contract.
// pull the rest of tokens from the user uint256 collateralIn = collateralTotal - amount1 - collateralSwap; if (collateralIn > decoded.collateralMax) revert AmountError(); pay(decoded.token1, decoded.payer, msg.sender, collateralIn);
https://github.com/code-423n4/2023-01-numoen/blob/main/src/periphery/LendgineRouter.sol#L119-L123
A MEV bot or anyone that see this opportunity can call refundETH() to retrieve the excess ETH.
function refundETH() external payable { if (address(this).balance > 0) SafeTransferLib.safeTransferETH(msg.sender, address(this).balance); }
https://github.com/code-423n4/2023-01-numoen/blob/main/src/periphery/Payment.sol#L44-L46
Automatically refund any excess ETH to the borrower.
#0 - c4-judge
2023-02-06T17:04:45Z
berndartmueller marked the issue as primary issue
#1 - kyscott18
2023-02-08T19:23:06Z
We expect this issue to be mitigated by a user using the multicall feature of our contract. When expecting to receive eth or not spending the total amount of eth sent, a multicall should be called with the second call calling refundEth() to sweep up the rest of the eth left over in the contract. Because the multicall is atomic, no bot can frontrun the user.
This situation is also present in Uniswap V3 and there has been some debate about it. For me, the general consensus is that it is not an issue as refundEth() and multicall() are expected to be used, and not using this is the fault of the user.
#2 - c4-sponsor
2023-02-08T19:23:07Z
kyscott18 marked the issue as sponsor acknowledged
#3 - berndartmueller
2023-02-14T15:51:18Z
It is the responsibility of the user to use the contracts appropriately (e.g. using multicall(..)
) to make sure leftover funds are sent out. However, due to the lack of documentation to properly educate about the usage of multicall, I consider Medium severity to be appropriate.
#4 - c4-judge
2023-02-14T15:51:56Z
berndartmueller changed the severity to 2 (Med Risk)
#5 - c4-judge
2023-02-14T15:54:59Z
berndartmueller marked the issue as satisfactory
#6 - c4-judge
2023-02-14T15:55:05Z
berndartmueller marked the issue as selected for report