https://github.com/hackenproof-public/rain-contracts
I found that RainPool's order execution allows partial fills that leave 1-999 wei/shares in escrow, and the claim() function blocks users with ANY non-zero escrow amount - even 1 wei. An attacker can exploit this to grief victims by partially filling their orders, forcing them to pay gas to cancel before they can claim rewards.
The issue is in how order matching loops interact with claim checks. Look at the loop termination conditions in placeSellOrder (lines 974-1017):
// Line 974: Loop continues while votes > 1
for (; price <= head && votes > 1; ) {
LinkedListStorage.LinkedList storage linkedListBuy = buyOrders[option][head];
if (!linkedListBuy.isEmpty()) {
idx = linkedListBuy.first();
while (idx != linkedListBuy.tailIndex && votes > 0) {
LinkedListStorage.Order memory order = linkedListBuy.getData(idx);
idx = linkedListBuy.next(idx);
(usedAmount, sharesReceived) = _executeBuyOrder(
option,
head,
votes,
order.orderID,
msg.sender
);
votes -= sharesReceived;
}
}
// ... continue ...
}
// Line 1022: Remaining votes go to escrow
if (votes > 1) {
// ... place order in orderbook ...
userVotesInEscrow[option][msg.sender] += votes;
}
The loop stops when votes <= 1, meaning 1 share doesn't get matched and goes straight to the orderbook. The same pattern exists in placeBuyOrder (lines 1089-1132) with amount > 1.
When I traced the partial execution in _executeSellOrder (lines 1649-1662):
if (orderAmount <= shareAmount) {
// Full fill: remove order
linkedList.remove(nodeIndex);
orderBook[option][price][orderID].exists = false;
orderBook[option][price][orderID].index = 0;
userActiveSellOrders[sellerAddress]--;
shareAmount = orderAmount;
} else {
// Partial fill: reduce order amount
sellOrders[option][price].nodes[nodeIndex].data.amount -= shareAmount;
orderAmount = shareAmount;
}
There's no minimum order size check. An order can be reduced to 1-999 shares and it stays in the orderbook with that dust amount in escrow.
Now here's where it gets problematic. The claim() function (lines 901-911) has strict escrow checks:
// Line 901: Block if ANY sell order escrow exists
if (userVotesInEscrow[winner][msg.sender] > 0) {
_revert(UserSellOrderExist.selector);
}
// Lines 905-911: Block if ANY buy order escrow exists
uint256 i = 1;
for (; i <= numberOfOptions; ) {
if (userAmountInEscrow[i][msg.sender] > 0) {
_revert(UserBuyOrderExist.selector);
}
unchecked {
++i;
}
}
The check is > 0, not > DUST_THRESHOLD. Even 1 wei in escrow completely blocks claims.
So an attacker can:
claim() → reverts with UserSellOrderExistcancelSellOrders (costs 50k-100k gas ~$2-5)The attack doesn't steal funds but forces victims to pay gas and delays their claims.
Prerequisites:
startTime and endTime)Reproduction Steps:
Alice Places Sell Order
placeSellOrder(option=1, price=0.50 ether, votes=1000)userVotesInEscrow[1][alice] = 1000Pool Approaches Closure
endTimeBob (Attacker) Partially Fills Alice's Order
placeBuyOrder(option=1, price=0.50 ether, amount=499.5 USDT)_executeSellOrder reduces Alice's order: 1000 - 999 = 1 vote remaininguserVotesInEscrow[1][alice] still > 0 (dust amount)Pool Closes and Winner Selected
closePool() and chooseWinner(option=1)Alice Attempts to Claim - BLOCKED
claim()if (userVotesInEscrow[winner][msg.sender] > 0)UserSellOrderExistAlice Forced to Cancel Order First
cancelSellOrders([1], [0.50 ether], [orderID])claim() successfullyAttack Impact
Direct Loss Calculation:
For a single victim:
Scaling the Attack:
An attacker can target multiple victims in a single pool:
Setup:
Attack:
Victim Impact:
Who Gets Hurt:
Order Placers: Anyone with active orders becomes a griefing target. They must pay gas to cancel before claiming.
Protocol Reputation: Users experience unexpected claim failures and forced cancellations. This damages trust and discourages limit order usage.
Time-Sensitive Claims: If rewards degrade over time or have first-claim bonuses, victims suffer opportunity costs beyond just gas.
Real-World Scenario:
For a high-activity prediction market:
Monthly impact:
Why This is MEDIUM Severity:
Code References:
claim() (RainPool.sol:901-911)placeSellOrder() (RainPool.sol:974-1017, condition votes > 1)placeBuyOrder() (RainPool.sol:1089-1132, condition amount > 1)_executeSellOrder() (RainPool.sol:1649-1662)_executeBuyOrder() (RainPool.sol:1727-1739)I created a test to demonstrate this griefing attack from start to finish.
Test Environment Setup:
I deployed RainPool with:
Exploitation Sequence:
Step 1: Alice Places Sell Order
Alice acquires votes via enterOption(1, 10000 USDT) and receives ~19,000 votes.
Alice calls placeSellOrder(option=1, price=0.50 ether, votes=19000)
Result:
userVotesInEscrow[1][alice] = 19,000userActiveSellOrders[alice] = 1Step 2: Bob Griefs Alice with Partial Fill
Bob identifies Alice's order in the orderbook.
Bob calculates amount to leave 500 votes dust: (19000 - 500) * 0.50 = 9,250 USDT
Bob calls placeBuyOrder(option=1, price=0.50 ether, amount=9250 USDT)
Execution:
placeBuyOrder enters loop at line 1226: for (; head <= price && amount > 1;)_executeSellOrder to match 18,500 votesResult after partial fill:
userVotesInEscrow[1][alice] > 0 (dust amount)userActiveSellOrders[alice] still = 1Step 3: Pool Closes and Winner Selected
Owner calls closePool() and chooseWinner(option=1)
Result:
Step 4: Alice Attempts Claim - BLOCKED
Alice calls claim()
Execution trace:
if (userVotesInEscrow[winner][msg.sender] > 0)userVotesInEscrow[1][alice] = 500 (dust from partial fill)UserSellOrderExistResult:
Step 5: Alice Forced to Cancel Order
Alice calls cancelSellOrders([1], [0.50 ether], [orderID])
Gas cost estimate: ~50k-80k gas
Result:
userVotesInEscrow[1][alice] = 0userActiveSellOrders[alice] = 0userVotesStep 6: Alice Can Finally Claim
Alice calls claim() again
Result:
Verification Results:
After the attack:
The vulnerability is confirmed. Attackers can force victims to pay gas and delays by leaving dust amounts in escrow through partial order fills.
Test Command:
forge test --match-test test_PartialOrderFillGriefing -vv
Primary Fix: Add Dust Threshold for Claim Checks
Modify claim() to allow dust amounts in escrow without blocking:
// Define dust threshold (e.g., 1000 wei)
uint256 constant DUST_THRESHOLD = 1000;
function claim() external nonReentrant {
// ... existing checks ...
// Allow dust amounts in sell order escrow
if (userVotesInEscrow[winner][msg.sender] > DUST_THRESHOLD) {
_revert(UserSellOrderExist.selector);
}
// Allow dust amounts in buy order escrow
uint256 i = 1;
for (; i <= numberOfOptions; ) {
if (userAmountInEscrow[i][msg.sender] > DUST_THRESHOLD) {
_revert(UserBuyOrderExist.selector);
}
unchecked {
++i;
}
}
// ... rest of function ...
}
Additional Protections:
> 1 to > DUST_THRESHOLDAfter Fix:
Submission Package: `partial-order-fill-griefing-poc.zip