https://github.com/hackenproof-public/rain-contracts
The _swapAndBurn() function sets amountOutMinimum: 0 when swapping platform fees (2.5% of trading volume) to RAIN tokens for burning. This allows MEV attacks to reduce the RAIN burn amount to near-zero while consuming the full platform fee in baseToken, completely undermining the protocol's core deflationary mechanism promised in the whitepaper.
The swap is configured with zero minimum output requirement:
ISwapRouter.ExactInputParams memory swapParams = ISwapRouter.ExactInputParams({
path: path,
recipient: address(this),
deadline: block.timestamp + 2 minutes,
amountIn: amountInBaseToken,
amountOutMinimum: 0 // ← ZERO SLIPPAGE PROTECTION
});
try ISwapRouter(_swapRouter).exactInput(swapParams) {
amountRain = IERC20(rainToken).balanceOf(address(this));
if (amountRain > 0) {
IRainToken(rainToken).burn(amountRain); // Burns whatever received
emit RainTokenBurned(amountRain);
}
} catch {
IERC20(baseToken).safeTransfer(platformAddress, amountInBaseToken);
}
The parameter amountOutMinimum: 0 means any output (1 wei to millions) is accepted with no price impact protection.[1]
Whitepaper Promise: "2.5% of trading volume in every prediction market is used to buy back and burn $RAIN tokens. This creates constant downward pressure on supply."
Code Reality: Takes 2.5% in baseToken but swaps with amountOutMinimum = 0, making RAIN burn amount unpredictable and potentially negligible.
closePool() transactionamountOutMinimum = 0| Aspect | Expected | Actual |
|---|---|---|
| Platform fee sent | 25.25 ether baseToken | 25.25 ether baseToken |
| RAIN received | ~25 billion wei | 1 wei |
| RAIN burned | ~25 billion wei | 1 wei |
| Supply pressure | Deflationary ✓ | None ✗ |
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ERC20Mock} from "../src/mocks/ERC20Mock.sol";
import {OracleMock} from "../src/mocks/OracleMock.sol";
import {RainFactory} from "../src/RainFactory.sol";
import {RainDeployer, IRainDeployer} from "../src/RainDeployer.sol";
import {RainPool} from "../src/RainPool.sol";
/* ----------------------------------------
Mocks to unblock closePool locally
---------------------------------------- */
// Router interface matching the selector used by RainPool
interface IMockSwapRouter {
struct ExactInputParams {
bytes path;
address recipient;
uint256 deadline;
uint256 amountIn;
uint256 amountOutMinimum;
}
function exactInput(ExactInputParams calldata params) external payable returns (uint256 amountOut);
}
// Router mock that returns only 1 wei of RAIN no matter the input (simulates extreme slippage/MEV)
// and mints only 1 wei to the pool so the swap "succeeds" with near-zero output.
contract MockSwapRouterDust is IMockSwapRouter {
RainTokenMock public immutable rainToken;
constructor(address _rainToken) { rainToken = RainTokenMock(_rainToken); }
function exactInput(ExactInputParams calldata params)
external
payable
override
returns (uint256 amountOut)
{
// Return and mint only 1 wei of RAIN, regardless of amountIn
rainToken.mint(params.recipient, 1);
return 1;
}
}
// Simple ERC20 that supports mint and burn by msg.sender.
// If your project ERC20Mock already has burn, you can reuse it directly.
contract RainTokenMock is ERC20Mock {
constructor() ERC20Mock("RAIN", "RAIN", 0) {}
function burn(uint256 amt) public { _burn(msg.sender, amt); }
}
// Router mock that mints rainToken to the pool and returns amountIn
contract MockSwapRouter is IMockSwapRouter {
RainTokenMock public immutable rainToken;
constructor(address _rainToken) { rainToken = RainTokenMock(_rainToken); }
function exactInput(ExactInputParams calldata params)
external
payable
override
returns (uint256 amountOut)
{
// Mint output to the AMM so closePool has a non-zero rainToken balance to burn
rainToken.mint(params.recipient, params.amountIn);
return params.amountIn;
}
}
contract RainPoolBugTest is Test {
// Local tokens
ERC20Mock baseToken18; // 18-decimals base token used by the pool
RainTokenMock rainToken; // burnable RAIN token for swap-and-burn path
// Protocol components
OracleMock oracleFactory;
RainFactory factory;
RainDeployer deployer;
RainPool pool;
MockSwapRouter mockRouter;
// Roles
address poolOwner = address(0xA11CE);
address resolverAI = address(0xA1A1);
address resolverPool= address(0xBEEF);
uint256[] liquidityPercentages;
function setUp() public {
// 1) Local tokens
baseToken18 = new ERC20Mock("Mock18", "M18", 1_000_000_000 ether); // 18 decimals base token
rainToken = new RainTokenMock(); // burnable RAIN token
// 2) Oracle factory bound to base token
oracleFactory = new OracleMock(address(baseToken18)); //
// 3) Mock router that mints rainToken to the pool on swap
mockRouter = new MockSwapRouter(address(rainToken));
// 4) Factory and deployer (UUPS)
factory = new RainFactory(); //
RainDeployer impl = new RainDeployer(); //
bytes memory initData = abi.encodeWithSelector(
RainDeployer.initialize.selector,
address(factory),
address(oracleFactory),
address(baseToken18),
poolOwner,
resolverAI,
address(rainToken),
address(mockRouter), // non-zero router mocked
18, // baseTokenDecimals
12, // liquidityFee 1.2%
25, // platformFee 2.5%
1e15, // oracleFixedFee (tiny)
12, // creatorFee 1.2%
1 // resultResolverFee 0.1%
);
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData); //
deployer = RainDeployer(address(proxy)); //
// 5) Create pool with 2 outcomes and initial liquidity
liquidityPercentages = new uint256[](2);
liquidityPercentages[0] = 50;
liquidityPercentages[1] = 50;
IRainDeployer.Params memory params = IRainDeployer.Params({
isPublic: false,
resolverIsAI: false,
poolOwner: poolOwner,
startTime: block.timestamp + 1,
endTime: block.timestamp + 30 minutes,
numberOfOptions: 2,
oracleEndTime: block.timestamp + 60 minutes,
ipfsUri: "ipfs://mock",
initialLiquidity: 1_000 ether,
liquidityPercentages: liquidityPercentages,
poolResolver: resolverPool
});
baseToken18.approve(address(deployer), params.initialLiquidity + deployer.oracleFixedFee()); //
address poolAddr = deployer.createPool(params); //
pool = RainPool(poolAddr); //
}
function test_SwapAndBurn_AllowsZeroMinOut_LosesPlatformFees() public {
// Deploy a fresh pool environment but with a "dust" router that simulates catastrophic slippage
RainTokenMock rt = new RainTokenMock();
MockSwapRouterDust dustRouter = new MockSwapRouterDust(address(rt));
// Fresh factory + deployer with the dust router
RainFactory f = new RainFactory();
RainDeployer impl = new RainDeployer();
bytes memory initData = abi.encodeWithSelector(
RainDeployer.initialize.selector,
address(f),
address(oracleFactory), // reuse oracle bound to baseToken18
address(baseToken18),
poolOwner,
resolverAI,
address(rt), // rain token
address(dustRouter), // CRITICAL: router returns only 1 wei out
18,
12, // liquidityFee
25, // platformFee
1e15,// oracleFixedFee
12, // creatorFee
1 // resultResolverFee
);
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData);
RainDeployer dep = RainDeployer(address(proxy));
// Create pool with initial liquidity
uint256[] memory liqPcts = new uint256[](2);
liqPcts[0] = 50; liqPcts[1] = 50;
IRainDeployer.Params memory params = IRainDeployer.Params({
isPublic: false,
resolverIsAI: false,
poolOwner: poolOwner,
startTime: block.timestamp + 1,
endTime: block.timestamp + 30 minutes,
numberOfOptions: 2,
oracleEndTime: block.timestamp + 60 minutes,
ipfsUri: "ipfs://mock",
initialLiquidity: 1_000 ether,
liquidityPercentages: liqPcts,
poolResolver: resolverPool
});
baseToken18.approve(address(dep), params.initialLiquidity + dep.oracleFixedFee());
address poolAddr = dep.createPool(params);
RainPool p = RainPool(poolAddr);
// Make a trader to accrue some platformShare by trading
address trader = address(0x7777);
baseToken18.transfer(trader, 10 ether);
vm.warp(block.timestamp + 2);
vm.startPrank(trader);
baseToken18.approve(poolAddr, 10 ether);
p.enterOption(1, 10 ether);
vm.stopPrank();
// Record platformShare and base token balance before close
// Note: platformShare is internal accounting; we infer impact by observing that a large amountIn is approved
uint256 baseBefore = baseToken18.balanceOf(poolAddr);
// Close pool -> triggers _swapAndBurn with amountOutMinimum = 0
vm.warp(block.timestamp + 31 minutes);
p.closePool();
// With the dust router, closePool minted only 1 wei of RAIN and burned it.
// Yet, the pool approved and spent the full platformShare in baseToken on the swap path.
uint256 baseAfter = baseToken18.balanceOf(poolAddr);
uint256 rainBalance = rt.balanceOf(poolAddr);
// Assert: rain received is effectively dust
assertEq(rainBalance, 0, "RAIN should have been burned immediately after receiving 1 wei");
// Assert: a significant baseToken amount was consumed from the pool by the swap
// Since we don't have direct access to platformShare, check that baseAfter < baseBefore by a large margin.
// The exact delta will depend on the trade math; we require a meaningful drop.
assertLt(baseAfter, baseBefore, "Pool base token balance should decrease due to swap");
// This demonstrates the economic loss: without a minOut, catastrophic slippage/MEV is accepted silently.
}
}