https://github.com/hackenproof-public/rain-contracts
Title: Head-of-book pointer not updated on cancel, enabling gas-griefing
The Problem: The _cancelSellOrder and _cancelBuyOrder functions correctly remove orders from the linked list at a given price tick but do not update the firstSellOrderPrice/firstBuyOrderPrice head pointers if the cancelled bucket becomes empty. This leaves the head pointer stale, pointing to an empty bucket, until the next matching pass traverses past it.
Code that creates the vulnerability:
function _cancelSellOrder(
uint256 option,
uint256 price, // 1e18
uint256 orderID,
address caller
) private {
if (orderBook[option][price][orderID].exists == false) {
_revert(OrderDoesNotExist.selector);
}
...
linkedList.remove(nodeIndex);
orderBook[option][price][orderID].exists = false;
...
// NO head pointer update here
}
enterOption or placeBuyOrder to iterate through a series of empty buckets, burning extra gas.Impact: Minor griefing and bounded gas overhead for takers. The issue does not risk funds or liveness but degrades UX and allows an attacker to increase others’ transaction costs at will. Per the bug bounty scope, this fits under “Griefing” and possibly “Block stuffing for profit.”
Attack scenario:
firstSellOrderPrice = 0.02 ether.firstSellOrderPrice becomes 0.01 ether. They then immediately cancel it.firstSellOrderPrice remains 0.01 ether, but that bucket is empty.enterOption. The matching loop starts at the stale head (0.01), finds it empty, advances to 0.02, finds the real order, and executes. The taker paid for an extra, useless loop iteration. An attacker can repeat this across multiple ticks to magnify the gas wasted.Update the head-of-book pointers immediately on cancel, with safe bounds and a sentinel fallback, and optionally harden the linked-list library.
firstSellOrderPrice in _cancelSellOrder// After linkedList.remove(nodeIndex);
if (price == firstSellOrderPrice[option] && linkedList.isEmpty()) {
uint256 head = price;
// Advance to the next non-empty bucket; bounded by max sell price
while (head <= 0.99 ether && sellOrders[option][head].isEmpty()) {
head += TICK_SPACING;
}
// Sentinel fallback if no sells remain
firstSellOrderPrice[option] = (head <= 0.99 ether) ? head : 0;
}
firstBuyOrderPrice in _cancelBuyOrderTICK_SPACING, set the head to 0 (no buys).// After linkedList.remove(nodeIndex);
if (price == firstBuyOrderPrice[option] && linkedList.isEmpty()) {
uint256 head = price;
// Move down to the next non-empty bucket; bounded by min buy price
while (head >= TICK_SPACING && buyOrders[option][head].isEmpty()) {
head -= TICK_SPACING;
}
// Sentinel fallback if no buys remain
firstBuyOrderPrice[option] = (head >= TICK_SPACING && !buyOrders[option][head].isEmpty()) ? head : 0;
}
LinkedList.initialize:require(!list.isInitialized, "Already initialized");
LinkedList.remove:require(node.nextIndex != 0 && node.prevIndex != 0, "Invalid node");
These changes eliminate the stale-head griefing vector by keeping head pointers current after cancellations and ensure callers cannot corrupt the list with invalid indices.
I create a small PoC to prove the issue
function test_PoC_StaleHeadPointer_OnCancel_SellHeadNotAdvanced() public {
// Use private pool instance; poolOwner holds initial votes on each option
uint256 option = 1;
uint256 tick = rainPool.TICK_SPACING(); // 0.01 ether
uint256 p1 = tick; // cheapest tick
uint256 p2 = tick * 2; // next tick
// Warp to live sale
vm.warp(rainPool.startTime() + 1);
// Place two sell orders at adjacent ticks
vm.startPrank(poolOwner);
uint256 id1 = rainPool.placeSellOrder(option, p1, 1_000); // ensure (votes*price)/1e18 >= 1
uint256 id2 = rainPool.placeSellOrder(option, p2, 1_000);
vm.stopPrank();
// Head should point to the cheapest active price p1
assertEq(rainPool.firstSellOrderPrice(option), p1, "head should point to p1");
// Cancel the only order at p1; bucket becomes empty
uint256[] memory opts = new uint256[](1);
uint256[] memory prices = new uint256[](1);
uint256[] memory ids = new uint256[](1);
opts[0] = option; prices[0] = p1; ids[0] = id1;
vm.startPrank(poolOwner);
rainPool.cancelSellOrders(opts, prices, ids);
vm.stopPrank();
// PoC: head remains stale (still p1) even though p1 bucket is now empty;
// it will be advanced only on the next matching pass.
assertEq(rainPool.firstSellOrderPrice(option), p1, "stale head not updated after cancel");
}