Status DataClose notification

Rain Disclosed Report

Liquidity entry under-mints shares due to per-leg denominator

Company
Created date
hidden

Target

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

Vulnerability Details

Summary

The enterLiquidity() function systematically under-issues shares when splitting deposits across multiple options. This occurs because shares are calculated per option using denominators of (allFunds + amount_i) rather than the correct simultaneous denominator of (allFunds + totalAmount), creating a deterministic value loss for liquidity providers.

Description

When users call enterLiquidity() to add liquidity proportionally across all options, the function splits the total deposit and calculates shares for each option individually through getReturnedLiquidity(). The issue lies in the share calculation logic within _getReturnedShares(), which computes shares using a per-leg approach rather than a simultaneous pricing model.

For each option i, the current implementation calculates:

  • price_i = (optionFunds_i + amount_i) * PRICE_MAGNIFICATION / (allFunds + amount_i)
  • shares_i = amount_i * PRICE_MAGNIFICATION / price_i

However, since the state updates apply the full deposit simultaneously across all options (preserving price invariance), the correct denominator should be (allFunds + totalAmount) for all legs. This discrepancy causes the per-leg calculations to overestimate post-trade prices and systematically under-mint shares compared to what a simultaneous deposit would yield.

After share minting, the contract updates allFunds and totalFunds using the full split amounts, maintaining constant prices as intended. However, the shares already issued are based on the incorrect per-leg calculations, resulting in fewer shares than the price-invariant model would provide.

// src/RainPool.sol:1252-1272 - getReturnedLiquidity() function splits amounts and calls share calculation per option

    function getReturnedLiquidity(
        uint256 totalAmount
    )
        public
        view
        returns (
            uint256[] memory returnedShares,
            uint256[] memory returnedAmounts
        )
    {
        returnedShares = new uint256[](numberOfOptions + 1);
        returnedAmounts = new uint256[](numberOfOptions + 1);
        uint256 i = 1;
        for (; i <= numberOfOptions; ) {
            returnedAmounts[i] = (totalAmount * totalFunds[i]) / allFunds;
            returnedShares[i] = getReturnedShares(i, returnedAmounts[i]);
            unchecked {
                ++i;
            }
        }
    }
// src/RainPool.sol:1242-1247 - getReturnedShares() function calls the flawed per-leg calculation

    function getReturnedShares(
        uint256 option,
        uint256 amount
    ) public view returns (uint256) {
        return _getReturnedShares(amount, totalFunds[option], allFunds);
    }
    
// src/RainPool.sol:1881-1889 - _getReturnedShares() function uses incorrect denominator (totalAmount + amount) instead of (allFunds + totalDeposit)

    function _getReturnedShares(
        uint256 amount,
        uint256 optionFunds,
        uint256 totalAmount
    ) private view returns (uint256 shares) {
        uint256 price = ((optionFunds + amount) * PRICE_MAGNIFICATION) /
            (totalAmount + amount);
        shares = ((amount * PRICE_MAGNIFICATION) / price);
    }
    
// src/RainPool.sol:631-641 - enterLiquidity() function updates state assuming simultaneous pricing while using per-leg minted shares

        totalLiquidity += totalAmount;
        userLiquidity[msg.sender] += totalAmount;

        uint256 i = 1;
        for (; i <= numberOfOptions; ) {
            allVotes += sharesReceived[i];
            allFunds += amountReceived[i];
            userVotes[i][msg.sender] += sharesReceived[i];
            totalVotes[i] += sharesReceived[i];
            totalFunds[i] += amountReceived[i];
            emit EnterOption(

This vulnerability creates systematic economic loss for every liquidity provider using enterLiquidity(). The shortfall can be substantial - in a balanced two-option pool where the deposit equals existing funds, LPs receive approximately 25% fewer shares than the price-invariant model dictates. This directly reduces their potential payouts from winningPoolShare, effectively transferring value to existing vote holders and undermining the pool's fair economics.

Validation steps

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import "forge-std/Test.sol";

import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";

import { RainFactory } from "../../src/RainFactory.sol";
import { RainDeployer, IRainDeployer } from "../../src/RainDeployer.sol";
import { RainPool } from "../../src/RainPool.sol";

import { ERC20Mock } from "../../src/mocks/ERC20Mock.sol";
import { OracleMock } from "../../src/mocks/OracleMock.sol";
import { ISwapRouter } from "../../src/interfaces/ISwapRouter.sol";
import { IRainToken } from "../../src/interfaces/IRainToken.sol";

// Minimal swap router mock to avoid reverting in _swapAndBurn during closePool
contract MockSwapRouter {
    function exactInput(ISwapRouter.ExactInputParams calldata) external pure returns (uint256 amountOut) {
        return 0;
    }
}

// Minimal Rain token mock that supports balanceOf and burn() to satisfy _swapAndBurn
contract MockRainToken is IRainToken {
    function burn(uint256) external override {}
    function balanceOf(address) external pure returns (uint256) { return 0; }
}

contract LiquidityUnderMintPOC is Test {
    RainFactory internal rainFactory;
    RainDeployer internal rainDeployer;
    RainPool internal pool;

    ERC20Mock internal baseToken;
    OracleMock internal oracleFactory;

    address internal poolOwner;
    address internal user;

    uint256 internal constant BASE_DECIMALS = 6; // ERC20Mock returns 6
    uint256 internal constant ORACLE_FIXED_FEE = 15 * (10 ** BASE_DECIMALS);
    uint256 internal constant INITIAL_LIQ = 1_000_000; // 1.0 in 6 decimals

    function setUp() public {
        // Deploy base token and grant large initial supply to this test contract
        baseToken = new ERC20Mock("Mock USD", "mUSD", 1_000_000_000_000);

        // Simple addresses for resolver AI and rain token; deploy a mock router to satisfy _swapAndBurn
        address resolverAI = address(0xBEEF);
        address rainToken = address(new MockRainToken());
        address swapRouter = address(new MockSwapRouter());

        poolOwner = address(this); // make this test the resolverOwner too
        user = makeAddr("user");

        // Oracle factory mock and factory/deployer
        oracleFactory = new OracleMock(address(baseToken));
        rainFactory = new RainFactory();

        // Deploy upgradeable RainDeployer and initialize
        RainDeployer impl = new RainDeployer();
        bytes memory initData = abi.encodeWithSelector(
            RainDeployer.initialize.selector,
            address(rainFactory),
            address(oracleFactory),
            address(baseToken),
            poolOwner, // platformAddress
            resolverAI,
            rainToken,
            swapRouter,
            BASE_DECIMALS,
            12, // liquidityFee = 1.2%
            25, // platformFee = 2.5%
            ORACLE_FIXED_FEE,
            12, // creatorFee = 1.2%
            1   // resultResolverFee = 0.1%
        );
        ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData);
        rainDeployer = RainDeployer(address(proxy));

        // Create a pool with 2 options, 50/50 initial liquidity, resolver = this contract
        uint256[] memory liqPcts = new uint256[](2);
        liqPcts[0] = 50;
        liqPcts[1] = 50;

        IRainDeployer.Params memory p = IRainDeployer.Params({
            isPublic: false,
            resolverIsAI: false,
            poolOwner: poolOwner,
            startTime: block.timestamp + 10,
            endTime: block.timestamp + 1 hours,
            numberOfOptions: 2,
            oracleEndTime: block.timestamp + 2 hours,
            ipfsUri: "ipfs://poc",
            initialLiquidity: INITIAL_LIQ,
            liquidityPercentages: liqPcts,
            poolResolver: address(this)
        });

        // Fund and approve deployer to pull fixed fee + initial liquidity
        baseToken.approve(address(rainDeployer), type(uint256).max);

        address poolAddr = rainDeployer.createPool(p);
        pool = RainPool(poolAddr);

        // Warp to sale start
        vm.warp(pool.startTime());

        // Fund user and approve pool to spend for liquidity entry
        baseToken.mint(user, 10_000_000_000); // mint 10,000 mUSD to user
        vm.prank(user);
        baseToken.approve(address(pool), type(uint256).max);
    }

    function test_POC_LiquidityUnderMint_ShowsConcreteImpact() public {
        // Preconditions: symmetric pool 50/50 with allFunds = 1_000_000 and each optionFunds = 500_000
        uint256 allFundsBefore = pool.allFunds();
        uint256 f1Before = pool.totalFunds(1);
        uint256 f2Before = pool.totalFunds(2);
        assertEq(allFundsBefore, INITIAL_LIQ);
        assertEq(f1Before, INITIAL_LIQ / 2);
        assertEq(f2Before, INITIAL_LIQ / 2);

        // Deposit totalAmount proportionally via enterLiquidity
        uint256 totalAmount = 1_000_000; // user adds another 1.0 mUSD

        // Compute contract's per-leg minted shares (buggy, using (allFunds + amount_i)) via view
        (uint256[] memory sharesWrong, uint256[] memory amountsSplit) = pool.getReturnedLiquidity(totalAmount);

        // Compute correct simultaneous shares using denominator (allFunds + totalAmount)
        uint256 a1 = amountsSplit[1];
        uint256 a2 = amountsSplit[2];
        assertEq(a1, totalAmount * f1Before / allFundsBefore);
        assertEq(a2, totalAmount * f2Before / allFundsBefore);

        uint256 sharesCorrect1 = (a1 * (allFundsBefore + totalAmount)) / (f1Before + a1);
        uint256 sharesCorrect2 = (a2 * (allFundsBefore + totalAmount)) / (f2Before + a2);

        // Execute the actual liquidity entry
        vm.prank(user);
        pool.enterLiquidity(totalAmount);

        // Read actual minted shares for user
        uint256 minted1 = pool.userVotes(1, user);
        uint256 minted2 = pool.userVotes(2, user);

        // Sanity: contract mints what getReturnedLiquidity predicted (buggy path)
        assertEq(minted1, sharesWrong[1], "actual minted1 != predicted wrong shares");
        assertEq(minted2, sharesWrong[2], "actual minted2 != predicted wrong shares");

        // Demonstrate under-issuance: minted (wrong) strictly less than correct simultaneous math
        assertLt(minted1, sharesCorrect1, "no under-mint on option 1");
        assertLt(minted2, sharesCorrect2, "no under-mint on option 2");

        uint256 shortfall1 = sharesCorrect1 - minted1; // lost votes for option 1
        uint256 shortfall2 = sharesCorrect2 - minted2; // lost votes for option 2

        // Concrete economic impact: lower claim on winningPoolShare
        // Finalize pool and resolve winner = option 1
        vm.warp(pool.endTime() + 1);
        pool.closePool();
        pool.chooseWinner(1); // resolver is this contract
        vm.warp(block.timestamp + 60 minutes + 1); // pass dispute window

        // Compute expected vs actual winner payout share
        uint256 winningPoolShare = pool.winningPoolShare();
        uint256 initialVotesWinner = pool.totalVotes(1) - minted1; // before user mint, constructor set to INITIAL_LIQ
        // But we want the pre-user-mint value: that equals INITIAL_LIQ as per constructor
        assertEq(initialVotesWinner, INITIAL_LIQ, "pre-mint votes mismatch for option 1");

        uint256 actualWinnerPayout = (minted1 * winningPoolShare) / pool.totalVotes(1);
        uint256 expectedWinnerPayout = (sharesCorrect1 * winningPoolShare) / (INITIAL_LIQ + sharesCorrect1);

        assertLt(
            actualWinnerPayout,
            expectedWinnerPayout,
            "bug does not reduce winner payout proportion"
        );

        // Claim and check user's received reward equals on-chain actual (lower) value
        uint256 userBalBefore = baseToken.balanceOf(user);
        vm.prank(user);
        pool.claim();
        uint256 userBalAfter = baseToken.balanceOf(user);

        // liquidity reward + actual winner payout should be received; we ensure at least winner component is strictly less than ideal
        uint256 received = userBalAfter - userBalBefore;
        // Must be at least the actual winner payout (liquidity reward is additive and non-negative)
        assertGe(received, actualWinnerPayout, "received less than on-chain actual winner payout");

        // Prove a non-zero economic loss due to under-minting vs. correct simultaneous pricing
        uint256 minEconomicLoss = expectedWinnerPayout - actualWinnerPayout;
        assertGt(minEconomicLoss, 0, "no measurable economic loss");
        // The shortfall fraction grows with deposit size; here it is deterministic and > 0
    }
}

The PoC demonstrates a deterministic under-minting of option shares during multi-option liquidity entry. In a symmetric 2-option pool (50/50), a user adds liquidity via enterLiquidity(totalAmount). The contract splits totalAmount proportionally across options, then computes shares per leg using denominators (allFunds + amount_i).

However, state updates apply the full deposit simultaneously, preserving prices as if denominators were (allFunds + totalAmount) for all legs. The correct simultaneous pricing mints strictly more shares than the current per-leg approach.

Attachments

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