https://github.com/hackenproof-public/rain-contracts
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.
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_iHowever, 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.
// 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.