Smart Contracts - Fraction AI


Prepared by:

Halborn Logo

HALBORN

Last Updated 05/02/2025

Date of Engagement: April 15th, 2025 - April 22nd, 2025

Summary

100% of all REPORTED Findings have been addressed

All findings

15

Critical

0

High

1

Medium

1

Low

4

Informational

9


1. Introduction

Fraction AI engaged Halborn to conduct a security assessment on their smart contracts beginning on April 15th, 2025 and ending on April 22nd, 2025. The security assessment was scoped to the smart contracts provided to Halborn. Commit hashes and further details can be found in the Scope section of this report.


The Fraction AI codebase in scope consists of a set of smart contracts that enables the creation, ownership, and competitive evaluation of AI agents through session-based competitions with on-chain reward distribution and verification.

2. Assessment Summary

Halborn was provided 4 days for the engagement and assigned 1 full-time security engineer to review the security of the smart contracts in scope. The engineer is a blockchain and smart contract security expert with advanced penetration testing and smart contract hacking skills, and deep knowledge of multiple blockchain protocols.


The purpose of the assessment is to:

    • Identify potential security issues within the smart contracts.

    • Ensure that smart contract functionality operates as intended.


In summary, Halborn identified some improvements to reduce the likelihood and impact of risks, which were partially addressed by the Fraction AI team. The main ones were the following:

    • Include the intended user's address in the signed message hash to bind signatures to specific users.

    • Include the chain ID in the signature message to make signatures chain-specific.

    • Consider enforcing the use of safeTransferFrom() instead of transferFrom() to ensure that the contract recipients can handle the transfers correctly.


3. Test Approach and Methodology

Halborn performed a combination of manual review of the code and automated security testing to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of this assessment. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance coverage of smart contracts and can quickly identify items that do not follow security best practices.

The following phases and associated tools were used throughout the term of the assessment:

    • Research into architecture, purpose and use of the platform.

    • Smart contract manual code review and walkthrough to identify any logic issue.

    • Thorough assessment of safety and usage of critical Solidity variables and functions in scope that could led to arithmetic related vulnerabilities.

    • Local testing with custom scripts (Foundry).

    • Fork testing against main networks (Foundry).

    • Static analysis of security for scoped contract, and imported functions (Slither).


4. Static Analysis Report

4.1 Description

Halborn used automated testing techniques to enhance the coverage of certain areas of the smart contracts in scope. Among the tools used was Slither, a Solidity static analysis framework. After Halborn verified the smart contracts in the repository and was able to compile them correctly into their abis and binary format, Slither was run against the contracts. This tool can statically verify mathematical relationships between Solidity variables to detect invalid or inconsistent usage of the contracts' APIs across the entire code-base.


The security team assessed all findings identified by the Slither software, however, findings with related to external dependencies are not included in the below results for the sake of report readability.

4.2 Output

The findings obtained as a result of the Slither scan were reviewed, and many were not included in the report because they were determined as false positives.








5. RISK METHODOLOGY

Every vulnerability and issue observed by Halborn is ranked based on two sets of Metrics and a Severity Coefficient. This system is inspired by the industry standard Common Vulnerability Scoring System.
The two Metric sets are: Exploitability and Impact. Exploitability captures the ease and technical means by which vulnerabilities can be exploited and Impact describes the consequences of a successful exploit.
The Severity Coefficients is designed to further refine the accuracy of the ranking with two factors: Reversibility and Scope. These capture the impact of the vulnerability on the environment as well as the number of users and smart contracts affected.
The final score is a value between 0-10 rounded up to 1 decimal place and 10 corresponding to the highest security risk. This provides an objective and accurate rating of the severity of security vulnerabilities in smart contracts.
The system is designed to assist in identifying and prioritizing vulnerabilities based on their level of risk to address the most critical issues in a timely manner.

5.1 EXPLOITABILITY

Attack Origin (AO):
Captures whether the attack requires compromising a specific account.
Attack Cost (AC):
Captures the cost of exploiting the vulnerability incurred by the attacker relative to sending a single transaction on the relevant blockchain. Includes but is not limited to financial and computational cost.
Attack Complexity (AX):
Describes the conditions beyond the attacker’s control that must exist in order to exploit the vulnerability. Includes but is not limited to macro situation, available third-party liquidity and regulatory challenges.
Metrics:
EXPLOITABILITY METRIC (mem_e)METRIC VALUENUMERICAL VALUE
Attack Origin (AO)Arbitrary (AO:A)
Specific (AO:S)
1
0.2
Attack Cost (AC)Low (AC:L)
Medium (AC:M)
High (AC:H)
1
0.67
0.33
Attack Complexity (AX)Low (AX:L)
Medium (AX:M)
High (AX:H)
1
0.67
0.33
Exploitability EE is calculated using the following formula:

E=meE = \prod m_e

5.2 IMPACT

Confidentiality (C):
Measures the impact to the confidentiality of the information resources managed by the contract due to a successfully exploited vulnerability. Confidentiality refers to limiting access to authorized users only.
Integrity (I):
Measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of data stored and/or processed on-chain. Integrity impact directly affecting Deposit or Yield records is excluded.
Availability (A):
Measures the impact to the availability of the impacted component resulting from a successfully exploited vulnerability. This metric refers to smart contract features and functionality, not state. Availability impact directly affecting Deposit or Yield is excluded.
Deposit (D):
Measures the impact to the deposits made to the contract by either users or owners.
Yield (Y):
Measures the impact to the yield generated by the contract for either users or owners.
Metrics:
IMPACT METRIC (mIm_I)METRIC VALUENUMERICAL VALUE
Confidentiality (C)None (I:N)
Low (I:L)
Medium (I:M)
High (I:H)
Critical (I:C)
0
0.25
0.5
0.75
1
Integrity (I)None (I:N)
Low (I:L)
Medium (I:M)
High (I:H)
Critical (I:C)
0
0.25
0.5
0.75
1
Availability (A)None (A:N)
Low (A:L)
Medium (A:M)
High (A:H)
Critical (A:C)
0
0.25
0.5
0.75
1
Deposit (D)None (D:N)
Low (D:L)
Medium (D:M)
High (D:H)
Critical (D:C)
0
0.25
0.5
0.75
1
Yield (Y)None (Y:N)
Low (Y:L)
Medium (Y:M)
High (Y:H)
Critical (Y:C)
0
0.25
0.5
0.75
1
Impact II is calculated using the following formula:

I=max(mI)+mImax(mI)4I = max(m_I) + \frac{\sum{m_I} - max(m_I)}{4}

5.3 SEVERITY COEFFICIENT

Reversibility (R):
Describes the share of the exploited vulnerability effects that can be reversed. For upgradeable contracts, assume the contract private key is available.
Scope (S):
Captures whether a vulnerability in one vulnerable contract impacts resources in other contracts.
Metrics:
SEVERITY COEFFICIENT (CC)COEFFICIENT VALUENUMERICAL VALUE
Reversibility (rr)None (R:N)
Partial (R:P)
Full (R:F)
1
0.5
0.25
Scope (ss)Changed (S:C)
Unchanged (S:U)
1.25
1
Severity Coefficient CC is obtained by the following product:

C=rsC = rs

The Vulnerability Severity Score SS is obtained by:

S=min(10,EIC10)S = min(10, EIC * 10)

The score is rounded up to 1 decimal places.
SeverityScore Value Range
Critical9 - 10
High7 - 8.9
Medium4.5 - 6.9
Low2 - 4.4
Informational0 - 1.9

6. SCOPE

Files and Repository
(a) Repository: smart-contracts-audit
(b) Assessed Commit ID: 4538bc9
(c) Items in scope:
  • contracts/AgentController.sol
  • contracts/AgentNFT.sol
  • contracts/NodeController.sol
↓ Expand ↓
Out-of-Scope: Third party dependencies and economic attacks.
Remediation Commit ID:
Out-of-Scope: New features/implementations after the remediation commit IDs.

7. Assessment Summary & Findings Overview

Critical

0

High

1

Medium

1

Low

4

Informational

9

Security analysisRisk levelRemediation Date
Frontrunning in createAgent function can lead to agent theftHighSolved - 04/25/2025
Signature frontrunning in report and validation functionsMediumSolved - 04/25/2025
Potential cross-chain signature replayLowSolved - 04/25/2025
Risk of lost ownership when using transferring NFTLowRisk Accepted - 04/25/2025
Inconsistent ETH balance handling in withdrawBalance functionLowSolved - 04/25/2025
Missing _disableInitializers() function callLowSolved - 04/25/2025
Single step ownership transfer processInformationalAcknowledged - 04/25/2025
Duplicate agent keys in session operations could lead to unintentional double chargingInformationalAcknowledged - 04/25/2025
Lack of token URI validation in agent NFT mintingInformationalAcknowledged - 04/25/2025
Missing eventsInformationalAcknowledged - 04/25/2025
Missing input validationInformationalPartially Solved - 04/25/2025
Single agent failure affects entire batch in agent fee processingInformationalAcknowledged - 04/25/2025
Floating pragmaInformationalSolved - 04/25/2025
Use of revert strings over custom errorsInformationalAcknowledged - 04/25/2025
Lack of named mappingsInformationalSolved - 04/25/2025

8. Findings & Tech Details

8.1 Frontrunning in createAgent function can lead to agent theft

//

High

Description

The createAgent() function in the AgentController contract uses a signature verification mechanism that doesn't bind the signature to the intended recipient address. The signature only verifies the agentKey but doesn't include the intended account's address in the signed message.

function createAgent(address account, uint256 agentKey, bytes calldata signature) external payable nonReentrant {
	require(account == msg.sender || isProtocol[msg.sender], "Invalid account");
	require(_verifyKey(agentKey, signature), "Bad Sig");
	require(agentOwners[agentKey] == address(0), "Agent exists");
	agentOwners[agentKey] = account;
	emit AgentCreated(account, agentKey);
}

function _verifyKey(uint256 key, bytes calldata signature) internal view returns (bool) {
	bytes32 hash = keccak256(abi.encode("AGENT_CREATE", address(this), key));
	return isProtocol[ECDSA.recover(MessageHashUtils.toEthSignedMessageHash(hash), signature)];
}

This allows for an attacker to frontrun a legitimate user's transaction and register the agent under their own address instead, which can cause:


  • Direct Revenue Theft: When an attacker frontruns agent creation, all future session rewards earned by that agent are redirected to the attacker's address rather than the intended user.



  • NFT Ownership Loss: The attacker can mint the corresponding AgentNFT, which has market value and represents permanent ownership rights to the agent's earnings.



  • Denial of Service: Legitimate users who prepared signatures will have their transactions fail with "Agent exists" errors, preventing them from creating agents they were authorized to create.


Proof of Concept

In the following scenario, the impact of this issue is demonstrated, where attacker can mint the NFT token, as well as obtain rewards from the session:

  it("Frontrunning createAgent", async function () {
    // Deploy AgentNFT contract
    const AgentNFT = await ethers.getContractFactory("AgentNFT")
    const agentNFT = await AgentNFT.deploy("AgentToken", "AGT", deployer, agentController.target)
    await agentNFT.waitForDeployment()

    // Set the NFT in the AgentController
    await agentController.setAgentNFT(agentNFT.target)

    // VICTIM: userSigner gets a valid signature from the protocol (deployerSigner)
    const victimAgentKey = 200
    const keyHash = ethers.keccak256(
      ethers.AbiCoder.defaultAbiCoder().encode(
        ["string", "address", "uint256"],
        ["AGENT_CREATE", agentController.target, victimAgentKey]
      )
    )
    const signature = await deployerSigner.signMessage(ethers.getBytes(keyHash))

    console.log("Victim (user) received signature for agent key:", victimAgentKey)

    // ATTACKER: otherSigner sees the pending transaction in mempool
    // and creates a front-running transaction with higher gas
    console.log("Attacker (other) front-runs with the same signature...")
    await agentController.connect(otherSigner).createAgent(otherSigner.address, victimAgentKey, signature)

    // Verify that attacker successfully claimed the agent
    expect(await agentController.agentOwners(victimAgentKey)).to.equal(otherSigner.address)
    console.log("Agent ownership claimed by attacker:", await agentController.agentOwners(victimAgentKey))

    // Attacker mints the NFT for the stolen agent
    await agentNFT.connect(otherSigner).mint(victimAgentKey, "ipfs://metadata/stolen-agent")
    expect(await agentNFT.ownerOf(victimAgentKey)).to.equal(otherSigner.address)
    console.log("NFT for agent successfully minted by attacker")

    // VICTIM: userSigner's transaction fails when it's finally processed
    console.log("Victim transaction arrives later and fails...")
    await expect(
      agentController.connect(userSigner).createAgent(userSigner.address, victimAgentKey, signature)
    ).to.be.revertedWith("Agent exists")

    // Victim cannot mint the NFT since they're not the agent owner
    await expect(agentNFT.connect(userSigner).mint(victimAgentKey, "ipfs://metadata/victim-agent")).to.be.revertedWith(
      "Already minted"
    )
    console.log("Victim cannot mint NFT for the agent")

    // FINANCIAL IMPACT DEMONSTRATION
    console.log("\nDemonstrating financial impact:")

    // Create a session where this agent participates
    await createAgent(deployerSigner, 201)
    await createAgent(userSigner, 202)

    // Fund the participants
    await agentController.addBalance(otherSigner.address, weth.target, toWei("1"))
    await agentController.addBalance(deployerSigner.address, weth.target, toWei("1"))
    await agentController.addBalance(userSigner.address, weth.target, toWei("1"))

    // Start a session with the stolen agent
    let sessionData = [
      {
        sessionId: 1,
        agentKeys: [victimAgentKey, 201, 202],
      },
    ]

    await space.startSessions(toWei("0.1"), sessionData)
    await increaseTime(ONE_DAY)

    // Record balances before rewards distribution
    const attackerBalanceBefore = parseFloat(fromWei(await agentController.userBalances(otherSigner.address, weth.target)))

    // Finish session giving highest reward to the stolen agent
    let finishData = [
      {
        sessionHash: randomBytes32(),
        sessionId: 1,
        agentKeys: [victimAgentKey, 201, 202],
        rewardPercents: [7000, 2000, 1000], // 70% to stolen agent
      },
    ]

    await space.finishSessions(toWei("0.1"), finishData)

    // Check attacker's balance after rewards
    const attackerBalanceAfter = parseFloat(fromWei(await agentController.userBalances(otherSigner.address, weth.target)))
    const stolenRewards = attackerBalanceAfter - attackerBalanceBefore + 0.1 // Add back the entry fee

    console.log(`Attacker paid 0.1 ETH entry fee and received ${stolenRewards.toFixed(4)} ETH in rewards`)
    console.log(`Profit from attack: ${(stolenRewards - 0.1).toFixed(4)} ETH`)
    console.log("Attacker also owns the NFT which has additional market value")
    console.log("These rewards and NFT would have belonged to the victim, resulting in direct financial loss")
  })


BVSS
Recommendation

Include the intended recipient's address in the signed message hash to bind signatures to specific users.


Additionally, consider adding a nonce or timestamp mechanism to the signatures to prevent signature reuse.


Remediation Comment

SOLVED: The Fraction AI team solved this finding in commit 72a4297 by following the mentioned recommendation.

Remediation Hash
References

8.2 Signature frontrunning in report and validation functions

//

Medium

Description

The reportStake() and validateStake() functions in the Space contract use a signature verification mechanism that doesn't bind the signature to the caller (msg.sender). The signature only verifies the session parameters (sessionId, participantId, amount) but doesn't include the intended user's address in the signed message.

function reportStake(
	uint256 sessionId,
	uint256 participantId,
	uint256 amount,
	bytes calldata signature
) external nonReentrant {
	Session memory contractSession = sessionMap[sessionId];
	require(contractSession.state == State.IN_PROGRESS, "Invalid session");
	require(uint32(block.timestamp) < contractSession.startedAt + sessionCooldown, "Too late");
	require(_verifyReportStake(sessionId, participantId, amount, signature), "Bad Sig");
	AgentContest storage contest = agentReports[sessionId][participantId];
	_contestStake(amount, contest);
	emit ReportStaked(msg.sender, sessionId, participantId, amount);
}

function validateStake(
	uint256 sessionId,
	uint256 participantId,
	uint256 amount,
	bytes calldata signature
) external nonReentrant {
	Session memory contractSession = sessionMap[sessionId];
	require(contractSession.state == State.IN_PROGRESS, "Invalid session");
	require(uint32(block.timestamp) < contractSession.startedAt + sessionCooldown, "Too late");
	require(_verifyValidateStake(sessionId, participantId, amount, signature), "Bad Sig");
	AgentContest storage contest = agentValidations[sessionId][participantId];
	_contestStake(amount, contest);
	emit ValidateStaked(msg.sender, sessionId, participantId, amount);
}

function _verifyReportStake(
	uint256 sessionId,
	uint256 participantId,
	uint256 amount,
	bytes calldata signature
) internal view returns (bool) {
	bytes32 hash = keccak256(abi.encode("REPORT_STAKE", address(this), sessionId, participantId, amount));
	return isProtocol[ECDSA.recover(MessageHashUtils.toEthSignedMessageHash(hash), signature)];
}

function _verifyValidateStake(
	uint256 sessionId,
	uint256 participantId,
	uint256 amount,
	bytes calldata signature
) internal view returns (bool) {
	bytes32 hash = keccak256(abi.encode("VALIDATE_STAKE", address(this), sessionId, participantId, amount));
	return isProtocol[ECDSA.recover(MessageHashUtils.toEthSignedMessageHash(hash), signature)];
}


This could allow for an attacker to frontrun legitimate users' transactions and become the recorded "contestant" for reports and validations.

function _contestStake(uint256 amount, AgentContest storage contest) internal {
	require(contest.contestant == address(0), "Already exists");
	agentController.updateUserContestStake(msg.sender, token, amount, false);
	contest.contestant = msg.sender;
	contest.stakeAmount = amount;
	contest.createdAt = block.timestamp;
}


This can cause:


  1. Direct Financial Gain: When validations are approved, the attacker (as the contestant) receives slashed funds directly from nodes through the nodeController.slashStake() function, in the validatePass() function. This creates a financial incentive for the attack.

    nodeController.slashStake(contest.contestant, nodeIds[i], taskIds[i]);


  2. Denial of Service: Legitimate users who prepared signatures will have their transactions fail with "Already exists" errors, preventing them from participating in the protocol's security mechanisms.


BVSS
Recommendation

Include the intended user's address in the signed message hash to bind signatures to specific users, and modify the verification functions to check that msg.sender matches the address included in the signed message.


Additionally, consider adding a nonce or timestamp mechanism to the signatures to prevent signature reuse.


Remediation Comment

SOLVED: The Fraction AI team solved this finding in commit 72a4297 by following the mentioned recommendation.

Remediation Hash
References

8.3 Potential cross-chain signature replay

//

Low

Description

The AgentController contract may be vulnerable to cross-chain signature replay attacks under specific deployment conditions. The _verifyKey() function, used during agent creation, incorporates the contract address but omits the chain ID in the signature verification process, creating a potential security risk.

function _verifyKey(uint256 key, bytes calldata signature) internal view returns (bool) {
    bytes32 hash = keccak256(abi.encode("AGENT_CREATE", address(this), key));
    return isProtocol[ECDSA.recover(MessageHashUtils.toEthSignedMessageHash(hash), signature)];
}

If the following conditions are met, signatures could be replayed across chains:


1. The AgentController contract is deployed at identical addresses on multiple chains (possible with deterministic deployment methods like CREATE2).


2. The same signer is authorized in the isProtocol mapping on both chains.



Under these conditions, a valid signature for agent creation on one chain could be reused on another chain to create the same agent without the signer's knowledge or consent, potentially allowing unauthorized agent creation.


Take the following scenario:


1. A protocol operator (with address X) is authorized in the isProtocol mapping on both Ethereum and Base.


2. The protocol operator signs a message to create Agent A for User B on Ethereum.


3. An attacker captures this signature and replays it on Base.


4. The signature verification passes on Base because:


  • The contract addresses are identical.


  • The signer (X) is authorized in the isProtocol mapping on Base.


  • The signature correctly corresponds to the agent key.


BVSS
Recommendation

Include the chain ID in the signature message to make signatures chain-specific.

Remediation Comment

SOLVED: The Fraction AI team solved this finding in commit 72a4297 by following the mentioned recommendation.

Remediation Hash
References

8.4 Risk of lost ownership when using transferring NFT

//

Low

Description

The AgentNFT contract enforces a call to the transferAgentOwnership() function of the AgentController contract when transferring an NFT.

function _update(address to, uint256 tokenId, address auth) internal override returns (address) {
    address from = super._update(to, tokenId, auth);
    // Transfer only
    if (from != address(0)) {
        agentController.transferAgentOwnership(tokenId, from, to);
    }
    return from;
}

However, the contract does not handle the case where the recipient is a contract that does not implement the onERC721Received() function.


This could lead to tokens being lost if a token owner executes the transfer of the NFT via the transferFrom() function instead of the safeTransferFrom() function, and the recipient is a contract that can't handle NFT tokens.

BVSS
Recommendation

Consider enforcing the use of safeTransferFrom() instead of transferFrom() to ensure that the contract recipients can handle the transfers correctly.


This could be done by overriding the transferFrom() function to make a call to the ERC721Utils.checkOnERC721Received() function and verify that the recipient contract can handle the transfer correctly.

Remediation Comment

RISK ACCEPTED: The Fraction AI team made a business decision to accept the risk of this finding and not alter the contracts, stating that:


NFTs in the current implementation will only be used for agent ownership. The users are responsible for handling their NFTs. Also, we will be creating smart wallets for all the users using our protocol interface, and will be managing all contract interactions on their behalf. Since smart wallets will be generated using third-party services, we do not wish to be limited in case it doesn't implement the ERC721 receiving functionality. So safeTransferFrom will be used in our interface, but otherwise the users are responsible for managing their NFTs.

References

8.5 Inconsistent ETH balance handling in withdrawBalance function

//

Low

Description

In the AgentController contract, there's an inconsistency in how ETH deposits and withdrawals are handled. When users deposit ETH using the addBalance() function, the contract converts address(0) to the WETH address and records the balance under address(weth). However, when users try to withdraw using withdrawBalance(), the function does not automatically handle the case where token is set to address(0) (representing ETH).


Users who deposit ETH and later try to withdraw by passing address(0) as the token will find their withdrawal requests failing. This happens because:


  1. Their ETH balance is stored under address(weth) mapping.



  2. The withdrawal attempts to read from userBalances[msg.sender][address(0)] which will be zero.



  3. The transaction will revert with "Balance too low" or "Amount too low".



This inconsistency creates a confusing user experience and could lead to users being unable to access their funds through the normal interface.

BVSS
Recommendation

Modify the withdrawBalance function to handle ETH withdrawals consistently, similarly to the way they are handled in the addBalance() function.

Remediation Comment

SOLVED: The Fraction AI team solved this finding in commit 72a4297 by following the mentioned recommendation.

Remediation Hash
References

8.6 Missing _disableInitializers() function call

//

Low

Description

The AgentController, Space and NodeController contracts in scope follow the upgradeable pattern, inheriting from the Initializable module from OpenZeppelin. In order to prevent leaving the contracts uninitialized, OpenZeppelin's documentation recommends adding the _disableInitializers function in the constructor to automatically lock the contracts when they are deployed. However, these contracts are missing this function call.


This omission can lead to potential security vulnerabilities, as an uninitialized implementation contract can be taken over by an attacker, which may impact the proxy.

BVSS
Recommendation

Add a constructor and call the _disableinitializers() method within the contracts to prevent the implementation from being initialized. For example:


/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
  _disableInitializers();
}

For more reference, see OpenZeppelin's recommendation from their documentation, or this discussion from their forum.

Remediation Comment

SOLVED: The Fraction AI team solved this finding in commit 72a4297 by following the mentioned recommendation.

Remediation Hash
References

8.7 Single step ownership transfer process

//

Informational

Description

All contracts in scope inherit the Ownable or OwnableUpgradeable contract implementation from OpenZeppelin's library and is used to restrict access to certain functions to the contract owner. The Ownable pattern allows the contract owner to transfer ownership to another address using the transferOwnership() function. However, the transferOwnership() function does not include a two-step process to transfer ownership.


Regarding this, it is crucial that the address to which ownership is transferred is verified to be active and willing to assume ownership responsibilities. Otherwise, the contract could be locked in a situation where it is no longer possible to make administrative changes to it.


Additionally, the renounceOwnership() function allows renouncing to the owner permission. Renouncing ownership before transferring it would result in the contract having no owner, eliminating the ability to call privileged functions.

BVSS
Recommendation

Consider using OpenZeppelin's Ownable2Step and Ownable2StepUpgradeable contracts over the Ownable and OwnableUpgradeable implementations. Ownable2Step provides a two-step ownership transfer process, which adds an extra layer of security to prevent accidental ownership transfers.


Additionally, it is recommended that the owner cannot call the renounceOwnership() function to avoid losing ownership of the contract.

Remediation Comment

RISK ACCEPTED: The Fraction AI team made a business decision to acknowledge this finding and not alter the contracts, stating that:


The owner will be a multisig requiring multiple signatures, so all owner functions will be thoroughly checked before execution.


References

8.8 Duplicate agent keys in session operations could lead to unintentional double charging

//

Informational

Description

The Space contract's startSessions() and finishSessions() functions allow duplicate agent keys in the session data arrays, which may leads to users being charged multiple times for the same agent and enables manipulation of reward distributions.


When the Space contract calls AgentController.takeSessionsEntryFees() during startSessions, the _deductEntryFee() function processes each agent key separately without checking for duplicates:

function _deductEntryFee(
    address token,
    uint256 entryFee,
    IAgentController.StartSessionData calldata session
) internal {
    uint256 numAgents = session.agentKeys.length;
    for (uint256 i=0; i < numAgents;) {
        uint256 agentKey = session.agentKeys[i];
        address agentUser = agentOwners[agentKey];
        uint256 userBalance = userBalances[agentUser][token];
        require(userBalance >= entryFee, "Bal low");
        userBalances[agentUser][token] = userBalance - entryFee;
        unchecked {i++;}
    }
}


Similarly, during finishSessions with AgentController.updateBatchAgentRewards(), the _updateAgentReward() function also processes duplicate agent keys separately:

function _updateAgentReward(
    address token,
    uint256 entryFee,
    uint256 treasuryFee,
    IAgentController.FinishSessionData calldata session
) internal returns (uint256 treasuryAmount) {
    uint256 numAgents = session.agentKeys.length;
    uint256 sessionFees = entryFee * numAgents;
    treasuryAmount = sessionFees * treasuryFee / 100_00;
    uint256 sessionRewards = sessionFees - treasuryAmount;
    uint256 totalRewardPercent;
    for (uint256 i=0; i < numAgents;) {
        address agentUser = agentOwners[session.agentKeys[i]];
        uint256 rewardPercent = session.rewardPercents[i];
        if (rewardPercent > 0) {
            uint256 rewardAmount = sessionRewards * rewardPercent / 100_00;
            userBalances[agentUser][token] += rewardAmount;
            totalRewardPercent += rewardPercent;
        }
        unchecked {i++;}
    }
    require(totalRewardPercent == 100_00, "Reward != 100%");
}

This could lead to:


  • Accidental overcharging of users if protocol services include duplicate agent keys.

  • Unintended reward distribution if duplicate keys have different reward percentage.


BVSS
Recommendation

Implement input validation to detect duplicated within the agent keys and ensure each agent key is only processed once per session.

Remediation Comment

ACKNOWLEDGED: The Fraction AI team made a business decision to acknowledge this finding and not alter the contracts, stating that:


Even if duplicate agent keys are handled, it will not resolve the issue of having different agent keys than expected. The bigger issue is to check whether the agent keys are correct, which cannot be done completely on-chain. It has been solved by combining on-chain event emits by nodes and checking of ipfs CIDs, which allows post-verification of sessions. The protocol will always try to ensure that no malicious activity is present.


References

8.9 Lack of token URI validation in agent NFT minting

//

Informational

Description

In the AgentNFT contract's mint() function, token URIs are set during the minting process without any validation. The function accepts any string as a URI parameter and assigns it to the token without checking if it's empty or if it's already being used by another token.


The lack of validation could lead to several issues:


  1. Empty URIs: Tokens could be minted with empty URIs, leading to metadata resolution failures in applications that interact with the NFTs.



  2. Duplicate Metadata: Multiple tokens could reference identical metadata, causing confusion for users who might expect unique representations for different agent NFTs


This creates inconsistency in the NFT collection and could hinder proper functionality in applications that rely on well-formed and unique metadata for each token.


Additionally, once a token URI is set during minting, there's no mechanism to update it. This is not necessarily an issue if immutable metadata is intended, but could be a limitation if metadata updates are needed later.


BVSS
Recommendation

Implement validation for token URIs at minting time:

  1. Add checks to ensure URIs are not empty.

  2. Consider adding an optional uniqueness check if tokens should have distinct metadata.


Remediation Comment

ACKNOWLEDGED: The Fraction AI team made a business decision to acknowledge this finding and not alter the contracts, stating that:


Agent NFTs will mainly be used to handle agent ownership by the protocol. We will not use any metadata in our core functionality.


References

8.10 Missing events

//

Informational

Description

Throughout the contracts in scope, there are several instances where administrative functions change contract state by modifying core state variables without them being reflected in event emissions.


The absence of events may hamper effective state tracking in off-chain monitoring systems.


Instances of this issue can be found in:

  • AgentController.setProtocol()

  • AgentController.setTreasury()

  • AgentController.setAgentNFT()

  • AgentController.setAuthorizedSpace()

  • AgentController.takeSessionsEntryFees()

  • AgentController.updateBatchAgentRewards()

  • AgentController.updateContestFailTreasury()

  • AgentController.withdrawTreasury()

  • AgentNFT.setAgentController()

  • NodeController.setWhitelistedNode()

  • NodeController.setAuthorizedSpace()

  • Space.setProtocol()

  • Space.setAgentController()

  • Space.setNodeController()


BVSS
Recommendation

Emit events for all state changes that occur as a result of administrative functions to facilitate off-chain monitoring of the system.

Remediation Comment

ACKNOWLEDGED: The Fraction AI team made a business decision to acknowledge this finding and not alter the contracts, stating that:


Events are present in all relevant functions. The remaining functions are administrative functions which are mostly executed once on contract creation and are irrelevant for the normal user.


References

8.11 Missing input validation

//

Informational

Description

Throughout the codebase, there are several instances where input values are assigned without proper validation. For example, ensuring that an input address is not the zero address or that an integer falls within a valid range.


Failing to validate inputs before assigning them to state variables can lead to unexpected system behavior or even complete failure.


Instances of this issue include:

  • The agentController address in the setAgentController() function of the AgentNFT contract is not checked against the zero address.


  • The _token address in the initialize() function of the NodeController contract is not checked against the zero address.


  • The minStakeAmount value in the initialize() and the setMinStakeAmount() functions of the NodeController contract is not checked to fall within a valid threshold (greater than zero).


  • The nodeHash parameter in the createNode() and updateNode() functions of the NodeController contract is not checked to not be empty.


  • The _contestCooldown and _sessionCooldown parameters in the setCooldowns() function of the Space contract is not checked to fall within a valid threshold (greater than zero).


BVSS
Recommendation

Add proper validation to ensure that the input values are within expected ranges and that addresses are not the zero address. This will help prevent unexpected behavior and improve the overall robustness of the code.

Remediation Comment

PARTIALLY SOLVED: The FractionAI team partially solved this finding in commit 72a4297 by following the mentioned recommendation, and stating that:


Input validation added for public functions. For owner functions, the multisig structure will ensure thorough checks before execution.


Remediation Hash
References

8.12 Single agent failure affects entire batch in agent fee processing

//

Informational

Description

The takeSessionsEntryFees() function processes multiple sessions in a batch operation, where each session may contain multiple agents. If any single agent has insufficient balance to cover the entry fee, the entire transaction reverts due to the check in the _deductEntryFee() function.


This vulnerability could lead to a denial-of-service condition for the Space contract's startSessions() functionality. When processing large batches of agents, if just one agent has insufficient funds, the entire transaction will revert, preventing all other legitimate agents from participating.


This inefficiency could:


  1. Block protocol operation during peak usage times.

  2. Force unnecessary gas expenditure on failed transactions.

  3. Create a poor user experience for participants with sufficient funds.

  4. Enable a malicious actor to deliberately halt sessions by participating with insufficient funds.


BVSS
Recommendation

Implement a more resilient approach that handles individual agent failures without affecting the entire batch. This could be done by either:


  1. Creating a filtering mechanism that processes only agents with sufficient balances while tracking and returning information about which agents were processed successfully.



  2. Implementing a non-blocking collection approach where agents with insufficient funds are skipped and reported in an event.


Remediation Comment

ACKNOWLEDGED: The Fraction AI team made a business decision to acknowledge this finding and not alter the contracts, stating that:


We will always check user balances before creating sessions in our system. We do not wish to add any extra on-chain checks to keep gas costs minimal.


References

8.13 Floating pragma

//

Informational

Description

All contracts in scope currently uses floating pragma versions ^0.8.27 which means that the code can be compiled by any compiler version that is greater than or equal to 0.8.27, and less than 0.9.0.


However, it is recommended that contracts should be deployed with the same compiler version and flags used during development and testing. Locking the pragma helps to ensure that contracts do not accidentally get deployed using another pragma. For example, an outdated pragma version might introduce bugs that affect the contract system negatively.


Additionally, from Solidity versions 0.8.20 through 0.8.24, the default target EVM version is set to Shanghai, which results in the generation of bytecode that includes PUSH0 opcodes. Starting with version 0.8.25, the default EVM version shifts to Cancun, introducing new opcodes for transient storage, TSTORE and TLOAD.


In this aspect, it is crucial to select the appropriate EVM version when it's intended to deploy the contracts on networks other than the Ethereum mainnet, which may not support these opcodes. Failure to do so could lead to unsuccessful contract deployments or transaction execution issues.

BVSS
Recommendation

Lock the pragma version to the same version used during development and testing and make sure to specify the target EVM version when using Solidity versions from 0.8.20 and above if deploying to chains that may not support newly introduced opcodes.


Additionally, it is crucial to stay informed about the opcode support of different chains to ensure smooth deployment and compatibility.

Remediation Comment

SOLVED: The Fraction AI team solved this finding in commit 72a4297 by following the mentioned recommendation.

Remediation Hash
References

8.14 Use of revert strings over custom errors

//

Informational

Description

Throughout the files in scope, there are several instances where revert strings are used over custom errors.


In Solidity development, replacing hard-coded revert message strings with the Error() syntax is an optimization strategy that can significantly reduce gas costs. Hard-coded strings, stored on the blockchain, increase the size and cost of deploying and executing contracts.


The Error() syntax allows for the definition of reusable, parameterized custom errors, leading to a more efficient use of storage and reduced gas consumption. This approach not only optimizes gas usage during deployment and interaction with the contract but also enhances code maintainability and readability by providing clearer, context-specific error information.

BVSS
Recommendation

Consider replacing all revert strings with custom errors. For example:


error ConditionNotMet();

if (!condition) revert ConditionNotMet();

or starting from Solidity 0.8.27 :

require(condition, ConditionNotMet());

For more references, see here and here.

Remediation Comment

ACKNOWLEDGED: The Fraction AI team made a business decision to acknowledge this finding and not alter the contracts, stating that:


No security issues here. We do not mind the increased (one-time) deployment costs. The cost on running functions is negligible. We will keep this in mind in our future versions.


References

8.15 Lack of named mappings

//

Informational

Description

The project contains several unnamed mappings despite using a Solidity version that supports named mappings.


Named mappings improve code readability and self-documentation by explicitly stating their purpose.

BVSS
Recommendation

Consider refactoring the mappings to use named arguments, which will enhance code readability and make the purpose of each mapping more explicit. For example:


mapping(address myAddress => bool myBool) public myMapping;

Remediation Comment

SOLVED: The Fraction AI team solved this finding in commit 72a4297 by following the mentioned recommendation.

Remediation Hash
References

Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.

© Halborn 2025. All rights reserved.