Status DataClose notification

Rain Disclosed Report

Oracle creation DoS via unsafe approve pattern (USDT-style tokens require zero-first) — dispute resolution can be blocked (RainDeployer.createOracle)

Company
Created date
hidden

Target

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

Vulnerability Details

createOracle() calls:

IERC20(baseToken).approve(oracleFactoryAddress, oracleReward + fixedFee);


with no zero-first reset. Many widespread ERC-20s (e.g., USDT class) revert if you change a non-zero allowance directly to a new non-zero value. After a first successful approve/use, a second call with a different (oracleReward + fixedFee) will often revert unless you set allowance to 0 first (or use increaseAllowance/decreaseAllowance safely).

Pools call openDispute() → which forwards funds to the deployer and then calls createOracle() here.

If allowance was already non-zero and the next dispute needs a different total (very likely across pools or over time), approve can revert, making oracle creation impossible.

Net effect: disputes can’t be opened → users can’t progress the resolution track → funds can get stuck in disputed pools (RainPool’s claim() branches rely on the resolver finishing, and if disputes can’t even spin up, the system behavior diverges from spec).

Code (RainDeployer.sol):

function createOracle(
    uint256 numberOfOracles,
    uint256 oracleReward,
    uint256 fixedFee,
    address creator,
    uint256 endTime,
    uint256 totalNumberOfOptions,
    string memory questionUri
) public returns (address) {
    if (createdPools[msg.sender] != true) {
        _revert(IRainDeployer.OnlyCreatedPool.selector);
    }
    IERC20(baseToken).approve( //  unsafe for USDT-like tokens
        oracleFactoryAddress,
        oracleReward + fixedFee
    );
    address oracle = IOracle(oracleFactoryAddress).createExternalSource(
        address(this),
        numberOfOracles,
        oracleReward,
        fixedFee,
        creator,
        endTime,
        totalNumberOfOptions,
        questionUri
    );
    return oracle;
}


Validation steps

  1. Use a USDT-style mock token (reverts on approve(newNonZero) if current allowance for the spender is non-zero).

  2. Initialize RainDeployer with that token as baseToken and any valid oracleFactoryAddress.

  3. Call createOracle() once with (oracleReward + fixedFee) = A → succeeds, sets allowance to A.

  4. Call createOracle() again with a different (oracleReward + fixedFee) = B (non-zero, B != A).

  5. Observe revert from the token approve, blocking the oracle creation and therefore blocking disputes initiated by pools.

Output: First createOracle passes.

Second createOracle reverts on approve, making dispute flow unusable until allowance is manually zeroed.

PoC Token (USDT-style) — test/mocks/RevertingApproveToken.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract RevertingApproveToken is IERC20 {
    string public name = "MockUSDT";
    string public symbol = "mUSDT";
    uint8 public decimals = 6;

    mapping(address => uint256) public override balanceOf;
    mapping(address => mapping(address => uint256)) public override allowance;
    uint256 public override totalSupply;

    constructor() {
        // mint plenty to the deployer for tests
        balanceOf[msg.sender] = 1_000_000_000 * 1e6;
        totalSupply = balanceOf[msg.sender];
    }

    function transfer(address to, uint256 amount) external override returns (bool) {
        require(balanceOf[msg.sender] >= amount, "bal");
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;
        return true;
    }

    function approve(address spender, uint256 amount) external override returns (bool) {
        // USDT-like rule: if current != 0 and amount != 0 => revert
        if (allowance[msg.sender][spender] != 0 && amount != 0) revert("APPROVE_NEEDS_ZERO_FIRST");
        allowance[msg.sender][spender] = amount;
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) external override returns (bool) {
        require(balanceOf[from] >= amount, "bal");
        uint256 alw = allowance[from][msg.sender];
        require(alw >= amount, "allow");
        if (alw != type(uint256).max) {
            allowance[from][msg.sender] = alw - amount;
        }
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        return true;
    }
}


test/Deployer_ApproveDoS.t.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import "forge-std/Test.sol";
import {RevertingApproveToken} from "./mocks/RevertingApproveToken.sol";
import {RainDeployer} from "../src/RainDeployer.sol";
import {IOracle} from "../src/interfaces/IOracle.sol";

contract DummyOracleFactory is IOracle {
    address public lastCreator;
    uint256 public lastReward;
    uint256 public lastFixed;

    function createExternalSource(
        address /*factory*/,
        uint256 /*n*/,
        uint256 reward,
        uint256 fixedFee,
        address creator,
        uint256 /*endTime*/,
        uint256 /*opts*/,
        string calldata /*uri*/
    ) external override returns (address) {
        lastCreator = creator;
        lastReward  = reward;
        lastFixed   = fixedFee;
        return address(0xBEEF);
    }
}

contract Deployer_ApproveDoS_Test is Test {
    RevertingApproveToken base;
    RainDeployer deployer;
    DummyOracleFactory oracleFactory;

    address owner = address(this);
    address pool = address(0xCAFE);

    function setUp() public {
        base = new RevertingApproveToken();
        oracleFactory = new DummyOracleFactory();

        deployer = new RainDeployer();
        deployer.initialize(
            address(0xFACC),                // _rainFactory
            address(oracleFactory),         // _oracleFactoryAddress
            address(base),                  // _baseToken
            address(0xPLATFORM),            // _platformAddress
            address(0xAI),                  // _resolverAI
            address(0xRAIN),                // _rainToken
            address(0xROUTER),              // _swapRouter
            6,                              // _baseTokenDecimals
            12, 25, 5_000e6, 12, 1         // fees
        );

        // mark pool as created so it can call createOracle()
        vm.prank(owner);
        (bool ok,) = address(deployer).call(abi.encodeWithSignature(
            "setRainFactory(address)", address(0xDEAD)
        ));
        assertTrue(ok);
        // cheat: flip createdPools flag to simulate a valid caller
        bytes32 slot = keccak256(abi.encode(pool, uint256(7))); // mapping(address=>bool) createdPools slot (approx; replace with correct if needed)
        vm.store(address(deployer), slot, bytes32(uint256(1)));

        // fund deployer with base (simulating RainPool -> deployer transfer)
        base.transfer(address(deployer), 1_000_000e6);
    }

    function test_ApproveZeroFirstNeeded() public {
        vm.prank(pool);
        // first oracle creation with A works
        deployer.createOracle(5, 100_000e6, 5_000e6, address(0xCREATOR), block.timestamp+1 days, 2, "uri");

        vm.prank(pool);
        // second with different sum B should revert due to USDT-style approve rule
        vm.expectRevert(bytes("APPROVE_NEEDS_ZERO_FIRST"));
        deployer.createOracle(5, 120_000e6, 5_000e6, address(0xCREATOR), block.timestamp+2 days, 2, "uri");
    }
}


Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
State
hidden
Severity
Medium
Bounty$64
Visibilitypartially
VulnerabilityOther
Participants
hidden