https://github.com/hackenproof-public/rain-contracts
_swapAndBurn hardcodes a Uniswap v3 path baseToken -> 3000 -> WETH -> _RAIN_WETH_FEE -> rainToken and uses the Arbitrum WETH address:
baseToken == WETH, the first hop becomes WETH -> 3000 -> WETH, which is an invalid Uniswap v3 pool on Arbitrum.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.
src/RainPool.sol:1534-1583 _swapAndBurn:
0x82aF49447D8a07e3bd95BD0d56f35241523fBab1.bytes memory path = abi.encodePacked(baseToken, uint24(3000), WETH, _RAIN_WETH_FEE, rainToken);.IERC20(baseToken).safeTransfer(platformAddress, amountInBaseToken);.baseToken == WETH, _swapAndBurn always reverts and falls back, sending the full platformShare to platformAddress without any RAIN purchase.export ARBITRUM_RPC_URL=<https url>.forge test --match-path test/rain035_weth_fork.t.sol --fork-url $ARBITRUM_RPC_URL -vvv.RainPool with baseToken = WETH using the real Uniswap v3 router and RAIN token addresses on Arbitrum.platformShare by entering a position.closePool(), which in turn calls _swapAndBurn(platformShare).WETH/WETH pool at fee 3000, so:
ISwapRouter.exactInput reverts on the invalid WETH -> WETH hop.RainTokenBurned event.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");
}
}