<- ALL ARTICLES
Bug BountyLive protocolPrediction MarketsAccess Control

Anyone Can Drain This Prediction Market Contract

PYRO/ JUN 17 2026 / 5 MIN READ

How it's meant to work

The system has a converter (TokenConverter) whose job is to cash out leftover market tokens. A market hands out YES/NO tokens, the protocol ends up with some, and the converter redeems them back into the underlying, so it basically does this:

  1. Ask the market for its status and who won
TOKENCONVERTER.SOLSOLIDITY
    function _convertMarketTokens(address market) internal {
        ITruthMarketV2 truthMarket = ITruthMarketV2(market);
        MarketStatus status = truthMarket.getCurrentStatus();
        
        address yesToken = truthMarket.yesToken();
        address noToken = truthMarket.noToken();
        uint256 yesBalance = IERC20(yesToken).balanceOf(address(this));
        uint256 noBalance = IERC20(noToken).balanceOf(address(this));
  1. If it's Finalized and YES won, grab its balance of the market's winning token, approve the market over it, and call redeem
TOKENCONVERTER.SOLSOLIDITY
        if (status == MarketStatus.Finalized) {
            // Market is finalized, use redeem/withdraw logic
            uint256 winningPosition = truthMarket.winningPosition();
            
            if (winningPosition == 1) { // YES won
                if (yesBalance > 0) {
                    IERC20(yesToken).approve(market, yesBalance);
                    truthMarket.redeem(yesBalance);
                    convertedAmount = yesBalance;
                }
  1. The underlying comes back as shares, and later an operator sweeps it to the safeBox with withdrawAllToken

The market minted these tokens and it's the one that knows its own outcome, so the converter asks the market. For a real market this is perfectly fine, which is exactly why it shipped.

But it never checks the market is real

And that's the one thing it forgot to do, check if market is even a real market. It takes whatever address you give it and believes everything that this contract returns.

No fancy math here, no flashloans, no inflation. Just giving approval to a contract that we have not checked. nonReentrant is slapped on top like that's gonna help D:

convertSingleMarket(market) (or convertTokens for a batch) is permissionless and passes whatever you give it straight into _convertMarketTokens, which then asks the contract for everything it needs to know:

TOKENCONVERTER.SOLSOLIDITY
ITruthMarketV2 truthMarket = ITruthMarketV2(market);
MarketStatus status = truthMarket.getCurrentStatus();

address yesToken = truthMarket.yesToken();
address noToken = truthMarket.noToken();
uint256 yesBalance = IERC20(yesToken).balanceOf(address(this));

getCurrentStatus, yesToken, winningPosition, all of it comes from market. So you can deploy a contract that returns what you want, like if the market is Finalized, that YES won, and yesToken is some token the converter is actually holding. It checks its own balance, sees it's not zero, and then does the funny part:

TOKENCONVERTER.SOLSOLIDITY
if (winningPosition == 1) { // YES won
    if (yesBalance > 0) {
        IERC20(yesToken).approve(market, yesBalance);
        truthMarket.redeem(yesBalance);

It approves your contract over its full balance of that token, then calls your redeem. Your redeem just does transferFrom(converter, you, balance). The theft happens inside your contract, so nonReentrant is just placebo.

The attack

  1. Deploy FakeMarket that returns getCurrentStatus = Finalized, winningPosition = 1, yesToken = <the shares the converter is holding>, and whose redeem just does token.transferFrom(converter, attacker, amount)
  2. Call convertSingleMarket(fakeMarket)
  3. Converter approves FakeMarket over its full balance of that token
  4. Converter calls FakeMarket.redeem(balance)
  5. FakeMarket pulls the balance, converter is now empty

The fix

Just check that market is real before you touch it. Keep a TruthMarketManager reference on the converter (set it in initialize or behind DEFAULT_ADMIN_ROLE):

THE ONE-LINE FIXDIFF
+    ITruthMarketManager public marketManager;
+    error InvalidMarket(address market);

     function _convertMarketTokens(address market) internal {
+        if (!marketManager.isActiveMarket(market)) revert InvalidMarket(market);
         ITruthMarketV2 truthMarket = ITruthMarketV2(market);
         MarketStatus status = truthMarket.getCurrentStatus();

One line of "is this even real" and the whole thing is dead.

The damage is capped to whatever the converter is holding at the time, so it's protocol revenue in transit, not user collateral. Not exactly Wormhole money. On Base it's currently guarding a fat 69$ (nice), but the door is wide open and that balance only goes up.

Read the actual bug report (and POC) here.

SECURE YOUR PROTOCOL

Building something that moves other people's tokens? If you have a mainnet launch, upgrade, or token event coming and your contracts have not been re-audited, you are running on luck.