https://github.com/hackenproof-public/rain-contracts
I discovered that RainPool tracks pool liquidity in the allFunds state variable but never increments it when users place buy orders via the orderbook. Over time, this causes the actual baseToken balance to exceed the tracked allFunds value, leading to permanently trapped funds where closePool() distributes less than the actual available balance, leaving user funds locked forever in the contract.
The issue is that allFunds is incremented when users deposit via enterOption or enterLiquidity, but when users place buy orders through the orderbook, their tokens enter the contract without any allFunds adjustment. When closePool calculates final reward shares, it uses totalBaseTokens = allFunds as the basis, distributing only the tracked amount while ignoring the excess tokens that accumulated from orderbook activity.
Here's how the accounting breaks down:
Inflows via deposit (correctly tracked):
// RainPool.sol:383-390 (enterOption)
IERC20(baseToken).safeTransferFrom(msg.sender, address(this), amount);
allFunds += amount; // Correctly incremented
Inflows via buy orders (NOT tracked):
// RainPool.sol:858-904 (placeBuyOrder)
IERC20(baseToken).safeTransferFrom(msg.sender, address(this), amount);
// NO allFunds INCREMENT - tokens enter contract but aren't tracked!
// When buy order executes via _executeBuyOrder:
userVotes[option][msg.sender] += votes; // User gets votes
// But allFunds was never incremented for the tokens they paid
When I tested this with active orderbook trading, the divergence accumulates rapidly. Each buy order brings tokens into the contract that are never added to allFunds. Over time, the actual balance grows significantly larger than the tracked allFunds value.
The trapped funds become permanent at pool closure:
// RainPool.sol:591 (closePool)
uint256 totalBaseTokens = allFunds; // WRONG - under-tracks actual balance
liquidityShare = (totalBaseTokens * liquidityFee) / FEE_MAGNIFICATION;
creatorShare = (totalBaseTokens * creatorFee) / FEE_MAGNIFICATION;
resolverShare = (totalBaseTokens * resultResolverFee) / FEE_MAGNIFICATION;
winningPoolShare = totalBaseTokens - platformShare - liquidityShare - creatorShare - resolverShare;
_swapAndBurn(platformShare); // Transfers out platformShare
IERC20(baseToken).safeTransfer(poolOwner, creatorShare); // Transfers out creatorShare
At this point, winningPoolShare and other shares are calculated from the under-tracked allFunds value, which is lower than the actual contract balance. After all distributions complete, excess tokens remain stuck in the contract with no mechanism to recover them. These trapped funds represent user capital that was contributed via buy orders but can never be claimed.
Prerequisites:
Reproduction Steps:
Users deposit tokens and establish orderbook liquidity
enterOption(option=1, amount=50,000)allFunds += 50,000 (now 150,000)placeSellOrder(option=1, price=0.5 ether, votes=25,000)Bob places buy orders that bring untracked tokens
placeBuyOrder(option=1, price=0.5 ether, amount=5,000) (10 times)safeTransferFrom(Bob, contract, 5,000)allFunds NOT incremented for these incoming tokensallFunds: STILL 150,000 (unchanged)Trades execute and accounting divergence grows
_executeSellOrder and _executeBuyOrderallFunds: STILL 150,000 tokensVerify accounting mismatch before closure
balanceOf(contract) = 161,132 tokensallFunds = 150,000 tokensPool closes using under-tracked allFunds
closePool()totalBaseTokens = allFunds = 150,000 for calculations (WRONG - actual is 161,132)platformShare = 3,000 (2% of 150k)liquidityShare = 22,500 (15% of 150k)creatorShare = 3,000 (2% of 150k)resolverShare = 1,500 (1% of 150k)winningPoolShare = 150,000 - 3,000 - 22,500 - 3,000 - 1,500 = 120,000closePool executes immediate transfers
_swapAndBurn(platformShare=3,000)IERC20(baseToken).safeTransfer(poolOwner, creatorShare=3,000)All users successfully claim their computed shares
claim() for their portions of winningPoolShareTrapped funds remain permanently locked
allFunds was never incremented for buy order tokensclosePool() used the under-tracked value for distributionDirect Loss Calculation:
In an active pool with 1,000,000 tokens initial deposit and orderbook trading:
allFunds: 1,000,000 tokens (from enterOption deposits)Buy order token inflow (NOT tracked):
allFunds NEVER incremented for these tokensallFunds stays at: 1,000,000 tokensAt pool closure:
closePool() uses allFunds = 1,000,000 for calculationsTotal accounting drift:
allFunds: 0 (never incremented)Who Gets Hurt:
allFunds, becoming trappedReal-World Scenario:
A high-volume prediction market with 5M USD initial pool value:
allFunds never incremented: stays at 5M USDallFunds shows 5M, actual balance is 105M USDCode References:
placeBuyOrder (RainPool.sol:858-904) - tokens enter, allFunds not incremented_executeBuyOrder (RainPool.sol:1405-1488) - processes buy orders, no allFunds updateclosePool line 591 uses totalBaseTokens = allFunds (under-tracks actual balance)allFunds, leaving excess tokens lockedallFunds incremented ONLY at lines 383-390 (enterOption), 522-532 (enterLiquidity)allFundsTest Environment Setup:
I created a test environment with:
IERC20(baseToken).balanceOf(pool) vs allFunds stateExploitation Sequence:
Step 1: Establish initial state Initial pool created with 100,000 tokens
Result:
allFunds = 100,000 tokensStep 2: Alice deposits via enterOption
Alice calls enterOption(option=1, amount=50,000) then placeSellOrder(option=1, price=0.5 ether, votes=25,000)
Result:
allFunds incremented: 100,000 + 50,000 = 150,000 tokensStep 3: Bob places buy orders (tokens enter without tracking)
Bob calls placeBuyOrder(option=1, price=0.5 ether, amount=5,000) 10 times
Result:
safeTransferFrom(Bob, contract, 5,000) brings tokens INallFunds NOT incremented for any of these incoming tokensallFunds: STILL 150,000 (unchanged)Step 4: Verify divergence after trading After all 10 trades complete
Result:
allFunds: STILL 150,000 tokensStep 5: Close pool using under-tracked allFunds
Owner calls closePool()
Calculations executed:
totalBaseTokens = allFunds = 150,000 (WRONG - actual is 161,132)platformShare = 3,000 tokens (2% of 150k)liquidityShare = 22,500 tokens (15% of 150k)creatorShare = 3,000 tokens (2% of 150k)resolverShare = 1,500 tokens (1% of 150k)winningPoolShare = 150,000 - 3,000 - 22,500 - 3,000 - 1,500 = 120,000 tokensImmediate transfers:
_swapAndBurn(3,000) removes platformSharesafeTransfer(poolOwner, 3,000) pays creatorShareComputed future payouts (based on under-tracked allFunds):
Step 6: All users successfully claim
All users call claim() for their portions
Result:
Step 7: Trapped funds remain permanently locked After all distributions complete
Result:
allFunds was never incremented for buy order tokensclosePool() used under-tracked value for all calculationsVerification Results:
After the complete flow:
The vulnerability is confirmed. Buy order tokens enter the contract but are never added to allFunds, causing closePool() to distribute less than the actual available balance. The excess tokens become permanently trapped, representing lost user capital from orderbook trading activity.
Test Command:
forge test --match-test testAccountingDriftInsolvency -vvvv
Note: The test demonstrates the accounting mismatch where buy order tokens cause balance > allFunds, leading to trapped funds. The PoC shows this specific manifestation of the vulnerability. Zip file attached below.