Status DataClose notification

Rain Disclosed Report

swapAndBurn Always Fails When baseToken Is WETH

Company
Created date
hidden

Target

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

Vulnerability Details

Summary

_swapAndBurn hardcodes a Uniswap v3 path baseToken -> 3000 -> WETH -> _RAIN_WETH_FEE -> rainToken and uses the Arbitrum WETH address:

  • When baseToken == WETH, the first hop becomes WETH -> 3000 -> WETH, which is an invalid Uniswap v3 pool on Arbitrum.
  • The ISwapRouter.exactInput call therefore always reverts for baseToken == WETH, so the catch block executes and transfers the entire platformShare to platformAddress without buying or burning any RAIN.

This means that for any pool configured with WETH as the base token, the buyback/burn mechanism is effectively disabled on Arbitrum: closes always follow the fallback path.

Affected Code

  • src/RainPool.sol:1534-1583 _swapAndBurn:
    • Hardcodes Arbitrum WETH address 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1.
    • Constructs bytes memory path = abi.encodePacked(baseToken, uint24(3000), WETH, _RAIN_WETH_FEE, rainToken);.
    • On any swap revert, executes IERC20(baseToken).safeTransfer(platformAddress, amountInBaseToken);.

Impact

  • No RAIN buyback/burn for WETH base pools: For any pool where baseToken == WETH, _swapAndBurn always reverts and falls back, sending the full platformShare to platformAddress without any RAIN purchase.
  • Broken tokenomics on Arbitrum for WETH pools: Platform participants may expect WETH-based pools to support buyback/burn, but in practice the mechanism cannot work with the current hard‑coded path.

Validation steps

How to Reproduce (Real Arbitrum Fork)

  1. Run the dedicated fork test:
    • Export an Arbitrum RPC: export ARBITRUM_RPC_URL=<https url>.
    • Run forge test --match-path test/rain035_weth_fork.t.sol --fork-url $ARBITRUM_RPC_URL -vvv.
  2. The test:
    • Forks Arbitrum.
    • Deploys a RainPool with baseToken = WETH using the real Uniswap v3 router and RAIN token addresses on Arbitrum.
    • Accrues a non‑zero platformShare by entering a position.
    • Calls closePool(), which in turn calls _swapAndBurn(platformShare).
  3. On Arbitrum today, there is no WETH/WETH pool at fee 3000, so:
    • ISwapRouter.exactInput reverts on the invalid WETH -> WETH hop.
    • The test observes no RainTokenBurned event.
    • The entire platformShare is transferred directly to platformAddress (treasury), confirming the fallback path is always taken.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
// RAIN-035 (fork): Prove _swapAndBurn always fails when baseToken is WETH on Arbitrum
// Requires an Arbitrum RPC. Run with:
//   ARBITRUM_RPC_URL=<https url> forge test --match-path test/rain035_weth_fork.t.sol --fork-url $ARBITRUM_RPC_URL -vvv

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 {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract Rain035_Fork_WethBase_AlwaysFallback is Test {
    // Mainnet Arbitrum addresses
    address constant WETH = 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1;
    address constant UNIV3_ROUTER = 0xE592427A0AEce92De3Edee1F18E0157C05861564;
    address constant RAIN = 0x25118290e6A5f4139381D072181157035864099d;

    function test_Close_Fallback_WhenBaseTokenIsWeth_OnFork() public {
        // Ensure we are forking Arbitrum
        string memory url = vm.envString("ARBITRUM_RPC_URL");
        vm.createSelectFork(url);

        RainFactory factory = new RainFactory();
        address owner = makeAddr("owner");
        address resolverAI = makeAddr("resolverAI");
        address treasury = makeAddr("treasury");

        RainDeployer impl = new RainDeployer();
        bytes memory init = abi.encodeWithSelector(
            RainDeployer.initialize.selector,
            address(factory),
            address(this),
            WETH,
            treasury,
            resolverAI,
            RAIN,
            UNIV3_ROUTER,
            18, // WETH decimals
            12,
            25,
            15 * 1e18,
            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://fork-arb-weth-base",
            initialLiquidity: 10_000 ether,
            liquidityPercentages: w,
            poolResolver: owner
        });

        // Fund this test address with WETH for initialLiquidity + oracleFixedFee
        deal(WETH, address(this), p.initialLiquidity + dep.oracleFixedFee());
        ERC20(WETH).approve(address(dep), p.initialLiquidity + dep.oracleFixedFee());

        RainPool pool = RainPool(dep.createPool(p));

        // Accrue platformShare via a user entry paid in WETH
        vm.warp(p.startTime + 2);
        address u = makeAddr("u");
        deal(WETH, u, 20_000 ether);
        vm.startPrank(u);
        ERC20(WETH).approve(address(pool), type(uint256).max);
        pool.enterOption(1, 20_000 ether);
        vm.stopPrank();

        // Close pool; with baseToken == WETH, the first hop in the hard-coded path is WETH->WETH at fee 3000,
        // which does not exist on Arbitrum, so the swap must revert and fallback executes.
        vm.warp(p.endTime + 1);
        uint256 plat = pool.platformShare();
        uint256 balBefore = ERC20(WETH).balanceOf(treasury);
        vm.recordLogs();
        pool.closePool();
        uint256 balAfter = ERC20(WETH).balanceOf(treasury);

        // No RainTokenBurned event should have been emitted
        bytes32 burnedSig = keccak256("RainTokenBurned(uint256)");
        Vm.Log[] memory logs = vm.getRecordedLogs();
        for (uint256 i = 0; i < logs.length; i++) {
            assertFalse(
                logs[i].topics.length > 0 && logs[i].topics[0] == burnedSig,
                "unexpected burn event when baseToken is WETH"
            );
        }

        // Fallback transfer sent platformShare to treasury in WETH
        assertEq(balAfter, balBefore + plat, "platform share not transferred via fallback for WETH baseToken");
    }
}

Attachments

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