Halborn Logo

Gains Trade


Prepared by:

Halborn Logo

HALBORN

Last Updated 04/25/2024

Date of Engagement by: October 31st, 2022 - November 21st, 2022

Summary

100% of all REPORTED Findings have been addressed

All findings

8

Critical

1

High

0

Medium

1

Low

1

Informational

5


1. INTRODUCTION

Gains Trade is a decentralized trading platform for cryptocurrencies, stocks, and forex, fully on-chain.

\client engaged Halborn to conduct a security audit on their smart contracts beginning on 2022-10-31 and ending on 2022-11-21. The security assessment was scoped to the smart contracts provided to the Halborn team.

2. AUDIT SUMMARY

The team at Halborn was provided three weeks for the engagement and assigned a full-time security engineer to audit the security of the smart contracts. The security engineer is a blockchain and smart-contract security expert with advanced penetration testing, smart-contract hacking, and deep knowledge of multiple blockchain protocols.

The purpose of this audit is to:

    • Ensure that smart contract functions operate as intended

    • Identify potential security issues with the smart contracts

In summary, Halborn identified some security risks that were addressed and accepted by the Gains Trade team.

3. TEST APPROACH & METHODOLOGY

Halborn performed a combination of manual and automated security testing to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of this audit. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance coverage of the bridge code 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 audit:

    • Research into architecture and purpose

    • Smart contract manual code review and walkthrough

    • Graphing out functionality and contract logic/connectivity/functions. (solgraph)

    • Manual assessment of use and safety for the critical Solidity variables and functions in scope to identify any arithmetic related vulnerability classes

    • Manual testing by custom scripts

    • Scanning of solidity files for vulnerabilities, security hotspots or bugs. (MythX)

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

    • Testnet deployment (Brownie, Remix IDE, Visual Studio Code)

4. SCOPE

IN-SCOPE: The security assessment was scoped to the following contracts:

    • GToken.sol

    • GTokenOpenPnlFeed.sol

    • TWAPPriceGetter.sol

    • GNSTokenBridge.sol

    • NFTMintingBridge.sol

Commit ID: d6c7fe7f386b5678ee2597a17e866cee189f056e

Bridge commit ID: d70b3e5a47cf0f62bedd1363b7a10c2d9acc310e

OUT-OF-SCOPE: Other smart contracts in the repository, external libraries and economical attacks.

Fixed Commit ID: a9a18643d0278886c79b56e0171b38a8a2945d08

Bridge fixed Commit ID: 2f709cdbd2cafbdac5bbb21d736502681c36ab4

5. RISK METHODOLOGY

Vulnerabilities or issues observed by Halborn are ranked based on the risk assessment methodology by measuring the LIKELIHOOD of a security incident and the IMPACT should an incident occur. This framework works for communicating the characteristics and impacts of technology vulnerabilities. The quantitative model ensures repeatable and accurate measurement while enabling users to see the underlying vulnerability characteristics that were used to generate the Risk scores. For every vulnerability, a risk level will be calculated on a scale of 5 to 1 with 5 being the highest likelihood or impact.
RISK SCALE - LIKELIHOOD
  • 5 - Almost certain an incident will occur.
  • 4 - High probability of an incident occurring.
  • 3 - Potential of a security incident in the long term.
  • 2 - Low probability of an incident occurring.
  • 1 - Very unlikely issue will cause an incident.
RISK SCALE - IMPACT
  • 5 - May cause devastating and unrecoverable impact or loss.
  • 4 - May cause a significant level of impact or loss.
  • 3 - May cause a partial impact or loss to many.
  • 2 - May cause temporary impact or loss.
  • 1 - May cause minimal or un-noticeable impact.
The risk level is then calculated using a sum of these two values, creating a value of 10 to 1 with 10 being the highest level of security risk.
Critical
High
Medium
Low
Informational
  • 10 - CRITICAL
  • 9 - 8 - HIGH
  • 7 - 6 - MEDIUM
  • 5 - 4 - LOW
  • 3 - 1 - VERY LOW AND INFORMATIONAL

6. SCOPE

Out-of-Scope: New features/implementations after the remediation commit IDs.

7. Assessment Summary & Findings Overview

Critical

1

High

0

Medium

1

Low

1

Informational

5

Impact x Likelihood

HAL-01

HAL-03

HAL-04

HAL-05

HAL-06

HAL-07

HAL-08

HAL-02

Security analysisRisk levelRemediation Date
BRIDGENFT CALL CAN BE FRONTRUNCriticalSolved - 12/12/2022
WRONG FEE SENT TO CHAINLINK NODESMediumRisk Accepted
MISSING ZERO ADDRESS CHECKSLowRisk Accepted
THE TRANSFEROWNERSHIP PATTERN IS NOT FOLLOWEDInformationalRisk Accepted
GAS OVER-CONSUMPTION IN LOOPSInformationalAcknowledged
REDUNDANT INITIALIZATION OF UINT VARIABLES TO 0InformationalSolved - 12/12/2022
USE OF REVERT STRINGS INSTEAD OF CUSTOM ERRORSInformationalAcknowledged
MISSING/INCOMPLETE NATSPEC COMMENTSInformationalAcknowledged

8. Findings & Tech Details

8.1 BRIDGENFT CALL CAN BE FRONTRUN

// Critical

Description

In the NFTMintingBridge contract, the function bridgeNft() is used to bridge ERC721 tokens from one chain to another:

NFTMintingBridge.sol

/**
 * bridgeNft
 * @param dstChainId     The **LayerZero** destination chain ID.
 * @param tokenId        The tokenId of the NFT
 *
 * Burns the NFT with ID <tokenID>. A cross-chain message is then sent to our counterpart on the destination chain to mint/release this NFT.
 */
function bridgeNft(uint16 dstChainId, uint256 tokenId)
    external
    payable
    whenNotPaused
{
    require(msg.value != 0, "!fee"); // Make sure a native fee is supplied for the cross-chain message.

    // Burn the NFT - This will only work if the bridge was approved by the token owner
    nft.burn(tokenId);

    bytes memory payloadBytes = _buildReleaseMesssage(msg.sender, tokenId);

    // send LayerZero message
    _lzSend(
        dstChainId, // destination chainId
        payloadBytes, // abi.encode()'ed bytes
        payable(msg.sender), // refund address (LayerZero will refund any extra gas back to caller
        address(0x0), // unused
        bytes(""), // unused
        msg.value // native fee amount
    );

    emit NftBurnReceived(dstChainId, msg.sender, tokenId);
}

\color{black} \color{white}

The function burns the tokenId passed as parameter, but it does not check that the caller of the function actually owns that NFT. This opens up the following attack vector:

  1. Alice owns NFT #1337.
  2. Alice approves the NFTMintingBridge so then she can call bridgeNft() to bridge the NFT to a different chain.
  3. Alice calls NFTMintingBridge.bridgeNft(2, 1337).
  4. Bob is a malicious user who is monitoring the mempool for any NFTMintingBridge.bridgeNft() calls.
  5. Bob detects Alice's call and front-runs her transaction by paying a higher gas fee. Bob's transaction is identical to Alice's, NFTMintingBridge.bridgeNft(2, 1337).
  6. Bob's transaction is mined before Alice's. Alice's transaction reverts as the token was already burnt by Bob.
  7. Bob gets the NFT minted in the new chain.
Score
Impact: 5
Likelihood: 5
Recommendation

SOLVED: The Gains Trade team solved the issue in the Commit ID: a349e7438543d57b5845a6771d4e642a1a4868f4

NFTMintingBridge.sol

/**
 * bridgeNft
 * @param dstChainId     The **LayerZero** destination chain ID.
 * @param tokenId        The tokenId of the NFT
 *
 * Burns the NFT with ID <tokenID>. A cross-chain message is then sent to our counterpart on the destination chain to mint/release this NFT.
 */
function bridgeNft(uint16 dstChainId, uint256 tokenId)
    external
    payable
    whenNotPaused
{
    require(msg.value != 0, "!fee"); // Make sure a native fee is supplied for the cross-chain message.
    require(nft.ownerOf(tokenId) == msg.sender, "!owner");

    // Burn the NFT - This will only work if the bridge was approved by the token owner
    nft.burn(tokenId);

    bytes memory payloadBytes = _buildReleaseMesssage(msg.sender, tokenId);

    // send LayerZero message
    _lzSend(
        dstChainId, // destination chainId
        payloadBytes, // abi.encode()'ed bytes
        payable(msg.sender), // refund address (LayerZero will refund any extra gas back to caller
        address(0x0), // unused
        bytes(""), // unused
        msg.value // native fee amount
    );

    emit NftBurnReceived(dstChainId, msg.sender, tokenId);
}

\color{black} \color{white}

8.2 WRONG FEE SENT TO CHAINLINK NODES

// Medium

Description

In the GTokenOpenPnlFeed contract, the makeOpenPnlRequest() function is used to send requests to a pool of Chainlink oracles:

GTokenOpenPnlFeed.sol

// Create requests
function makeOpenPnlRequest() private{
    Chainlink.Request memory linkRequest = buildChainlinkRequest(
        job,
        address(this),
        this.fulfill.selector
    );

    uint linkFeePerNode = IERC20(chainlinkTokenAddress())
        .balanceOf(address(this))
        / LINK_FEE_BALANCE_DIVIDER
        / oracles.length;

    requests[++lastRequestId] = Request({
        initiated: true,
        active: true,
        linkFeePerNode: linkFeePerNode
    });

    nextEpochValuesRequestCount++;
    nextEpochValuesLastRequest = block.timestamp;

    for(uint i; i < oracles.length; i ++){
        requestIds[sendChainlinkRequestTo(
            oracles[i],
            linkRequest,
            linkFeePerNode
        )] = lastRequestId;
    }

    emit NextEpochValueRequested(
        gToken.currentEpoch(),
        lastRequestId,
        job,
        oracles.length,
        linkFeePerNode
    );
}

\color{black} \color{white}

The linkFeePerNode variable is wrongly calculated, as it depends on the contract's balance. If the Link balance of the GTokenOpenPnlFeed contract is too high, more Link than needed would be sent to the Chainlink oracles as fees. In case that the Link balance of the GTokenOpenPnlFeed contract is not high enough, the contract will not send enough Link to the Chainlink oracles and the transactions will revert.

Score
Impact: 1
Likelihood: 5
Recommendation

RISK ACCEPTED: The Gains Trade team accepted the risk and stated: "We will send enough to the contract so that divided by 1000 it represents the amount we want, and then we will refill it every few months. This pattern allows everyone to send a link to the contract in a decentralized manner. It also means the transaction can never revert, unlike what the issue says because it can never run out of LINK."

8.3 MISSING ZERO ADDRESS CHECKS

// Low

Description

Contracts in-scope are missing address validation in some functions. It is possible to configure the zero address, which may cause issues during the contract execution.

Code Location

The following function is not validating, that the given addresses in the newValues array are different from zero:

GTokenOpenPnlFeed.sol

    function updateOracles(address[] memory newValues) external onlyOwner{
        require(newValues.length >= minAnswers, "ARRAY_TOO_SMALL");

        oracles = newValues;
        emit OraclesUpdated(newValues);
    }

The constructor is not validating that the address of the _token argument is different from zero:

TWAPPriceGetter.sol

    constructor(IUniswapV3Pool _pool, address _token, uint32 _twapInterval, uint _precision){
        require(address(_pool) != address(0) && _twapInterval > 0 && _precision > 0, "WRONG_TWAP_CONSTRUCTOR");

        pool = _pool;
        twapInterval = _twapInterval;
        precision = _precision;

        isGnsToken0InLp = pool.token0() == _token;
    }
Score
Impact: 2
Likelihood: 2
Recommendation

RISK ACCEPTED: The Gains Trade team accepted the risk of this finding.

8.4 THE TRANSFEROWNERSHIP PATTERN IS NOT FOLLOWED

// Informational

Description

The current ownership transfer process for the GToken contract inheriting from Ownable involves the current owner/timelock contract, calling the transferOwnership function.

If the nominated account is not a valid account, it is entirely possible that the owner may accidentally transfer ownership to an uncontrolled account, losing the access to all functions with the onlyOwner modifier.

Score
Impact: 2
Likelihood: 1
Recommendation

RISK ACCEPTED: The Gains Trade team accepted the risk of this finding.

8.5 GAS OVER-CONSUMPTION IN LOOPS

// Informational

Description

In all the loops, the counter variable is incremented using i++. In loops, using ++i costs less gas per iteration than i++.

Code Location

GTokenOpenPnlFeed.sol

  • Line 197: for(uint i = 0; i < reqToResetCount; i++)
  • Line 248: for(uint i = 0; i < oracles.length; i ++)
  • Line 368: for(uint i = 0; i < array.length; i++)

For example, based in the following test contract:

Test.sol

//SPDX-License-Identifier: MIT
pragma solidity 0.8.9;

contract test {
    function postiincrement(uint256 iterations) public {
        for (uint256 i = 0; i < iterations; i++) {
        }
    }
    function preiincrement(uint256 iterations) public {
        for (uint256 i = 0; i < iterations; ++i) {
        }
    }
}

The difference in the gas costs:

Score
Impact: 1
Likelihood: 1
Recommendation

ACKNOWLEDGED: The Gains Trade team acknowledged this finding.

8.6 REDUNDANT INITIALIZATION OF UINT VARIABLES TO 0

// Informational

Description

As the variable is a uint, it is already initialized to 0. For example, uint256 i = 0 reassigns the 0 to i, which wastes gas.

Code Location

GTokenOpenPnlFeed.sol

  • Line 197: for(uint i = 0; i < reqToResetCount; i++)
  • Line 248: for(uint i = 0; i < oracles.length; i ++
  • Line 368: for(uint i = 0; i < array.length; i++)
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Gains Trade team solved the issue by reducing the gas costs in the Commit ID: f1bcbb4da9efdd8a8be2d28a297cfc9b999d2e1a

8.7 USE OF REVERT STRINGS INSTEAD OF CUSTOM ERRORS

// Informational

Description

Failed operations in the smart contracts in scope are reverted with accompanying messages selected from a set of hard-coded strings.

In the EVM, emitting a hard-coded string in an error message costs ~50 more gas than emitting a custom error. Additionally, hard-coded strings increase the gas required to deploy the contract.

Code Location

GToken.sol

  • Line 182: require(_contractAddresses.asset != address(0)...
  • Line 231: require(_msgSender() == manager, "ONLY_MANAGER");
  • Line 236: require(shareToAssetsPrice > 0, "PRICE_0");
  • Line 237: require(assetsOrShares > 0, "VALUE_0");
  • Line 242: require(maxDiscountP > 0, "NO_ACTIVE_DISCOUNT");
  • Line 243: require(lockDuration >= MIN_LOCK_DURATION, "BELOW_MIN_LOCK_DURATION");
  • Line 244: require(lockDuration <= MAX_LOCK_DURATION, "ABOVE_MAX_LOCK_DURATION");
  • Line 250: require(newOwner != address(0), "Ownable: new owner is the zero address");
  • Line 251: require(newOwner != manager && newOwner != admin, "WRONG_VALUE");
  • Line 256: require(newValue != address(0), "ADDRESS_0");
  • Line 257: require(newValue != owner() && newValue != admin, "WRONG_VALUE");
  • Line 263: require(newValue != address(0), "ADDRESS_0");
  • Line 264: require(newValue != owner() && newValue != manager, "WRONG_VALUE");
  • Line 270: require(newValue != address(0), "ADDRESS_0");
  • Line 276: require(newValue.addr != address(0), "ADDRESS_0");
  • Line 277: require(newValue.signature.length > 0, "BYTES_0");
  • Line 283: require(newValue != address(0), "ADDRESS_0");
  • Line 295: require(newValue >= MIN_DAILY_ACC_PNL_DELTA, "BELOW_MIN");
  • Line 301: require(newValue[1] > newValue[0], "WRONG_VALUES");
  • Line 307: require(newValue <= MAX_SUPPLY_INCREASE_DAILY_P, "ABOVE_MAX");
  • Line 313: require(newValue <= MAX_LOSSES_BURN_P, "ABOVE_MAX");
  • Line 319: require(newValue <= MAX_GNS_SUPPLY_MINT_DAILY_P, "ABOVE_MAX");
  • Line 325: require(newValue <= MAX_DISCOUNT_P, "ABOVE_MAX_DISCOUNT");
  • Line 331: require(newValue >= 100 * PRECISION, "BELOW_MIN");
  • Line 348: require(success == true, "GNS_PRICE_CALL_FAILED");
  • Line 351: require(price > 0, "GNS_TOKEN_PRICE_0");
  • Line 433: require(totalSharesBeingWithdrawn(sender) <= balanceOf(sender) - amount, "PENDING_WITHDRAWAL");
  • Line 443: require(totalSharesBeingWithdrawn(from) <= balanceOf(from) - amount, "PENDING_WITHDRAWAL");
  • Line 499: require(assets <= maxDeposit(receiver), "ERC4626: deposit more than max");
  • Line 512: require(shares <= maxMint(receiver), "ERC4626: mint more than max");
  • Line 526: require(assets <= maxWithdraw(owner), "ERC4626: withdraw more than max");
  • Line 542: require(shares <= maxRedeem(owner), "ERC4626: redeem more than max");
  • Line 578: require(openTradesPnlFeed.nextEpochValuesRequestCount() == 0, "END_OF_EPOCH");
  • Line 582: require(sender == owner || allowance > 0 && allowance >= shares, "NOT_ALLOWED");
  • Line 584: require(totalSharesBeingWithdrawn(owner) + shares <= balanceOf(owner), "MORE_THAN_BALANCE");
  • Line 593: require(shares <= withdrawRequests[owner][unlockEpoch], "MORE_THAN_WITHDRAW_AMOUNT");
  • Line 597: require(sender == owner || allowance > 0 && allowance >= shares, "NOT_ALLOWED");
  • Line 614: require(simulatedAssets <= maxDeposit(receiver), "DEPOSIT_MORE_THAN_MAX");
  • Line 630: require(shares <= maxMint(receiver), "MINT_MORE_THAN_MAX");
  • Line 651: require(assets > assetsDeposited, "NO_DISCOUNT");
  • Line 683: require(owner == sender...
  • Line 686: require(block.timestamp >= d.atTimestamp + d.lockDuration, "NOT_UNLOCKED");
  • Line 695: require(accPnlPerToken <= int(PRECISION), "NOT_ENOUGH_ASSETS");
  • Line 725: require(sender == pnlHandler, "ONLY_TRADING_PNL_HANDLER");
  • Line 734: require(accPnlPerToken <= int(PRECISION), "NOT_ENOUGH_ASSETS");
  • Line 738: require(dailyAccPnlDelta <= int(maxDailyAccPnlDelta), "MAX_DAILY_PNL");
  • Line 780: require(assets <= assetsToDeplete, "AMOUNT_TOO_BIG");
  • Line 801: require(accPnlPerTokenUsed > 0, "NOT_UNDER_COLLATERALIZED");
  • Line 804: require(assets <= uint(accPnlPerTokenUsed) * supply / PRECISION, "AMOUNT_TOO_BIG");
  • Line 814: require(dailyMintedGns <= maxGnsSupplyMintDailyP...
  • Line 839: require(sender == address(openTradesPnlFeed), "ONLY_PNL_FEED");

GTokenOpenPnlFeed.sol

  • Line 104: require(_linkToken != address(0)
  • Line 122: require(msg.sender == IOwnable(address(gToken)).owner(), "ONLY_OWNER");
  • Line 127: require(msg.sender == gToken.manager(), "ONLY_MANAGER");
  • Line 132: require(msg.sender == gToken.admin(), "ONLY_ADMIN");
  • Line 138: require(newValue >= MIN_REQUESTS_START, "BELOW_MIN");
  • Line 139: require(newValue <= MAX_REQUESTS_START, "ABOVE_MAX");
  • Line 145: require(newValue >= MIN_REQUESTS_EVERY, "BELOW_MIN");
  • Line 146: require(newValue <= MAX_REQUESTS_EVERY, "ABOVE_MAX");
  • Line 152: require(newValue >= MIN_REQUESTS_COUNT, "BELOW_MIN");
  • Line 153: require(newValue <= MAX_REQUESTS_COUNT, "ABOVE_MAX");
  • Line 169: require(newValue >= MIN_ANSWERS, "BELOW_MIN");
  • Line 170: require(newValue % 2 == 1, "EVEN");
  • Line 171: require(newValue <= oracles.length / 2, "ABOVE_MAX");
  • Line 177: require(_index < oracles.length, "INDEX_TOO_BIG");
  • Line 178: require(newValue != address(0), "VALUE_0");
  • Line 184: require(newValues.length >= minAnswers * 2, "ARRAY_TOO_SMALL");
  • Line 190: require(newValue != bytes32(0), "VALUE_0");
  • Line 198: require(reqToResetCount > 0, "NO_REQUEST_TO_RESET");
  • Line 218: require(block.timestamp - gToken.currentEpochStart()

TWAPPriceGetter.sol

  • Line 34: require(address(_uniV3Pool) != address(0)...
  • Line 49: require(address(_uniV3Pool) != address(0), "WRONG_VALUE");
  • Line 56: require(_twapInterval >= MIN_TWAP_PERIOD && _twapInterval <= MAX_TWAP_PERIOD, "WRONG_VALUE");

GNSTokenBridge.sol

  • Line 104: require(msg.value != 0, "!fee");

NFTMintingBridge.sol

  • Line 86: require(msg.value != 0, "!fee");
Score
Impact: 1
Likelihood: 1
Recommendation

ACKNOWLEDGED: The Gains Trade team acknowledged this finding.

8.8 MISSING/INCOMPLETE NATSPEC COMMENTS

// Informational

Description

It was identified that the contracts are missing or have incomplete code documentation, which affects the understandability, auditability, and usability of the code.

Solidity contracts can use a special form of comments to provide rich documentation for functions, return variables, and more. This special form is named the Ethereum Natural Language Specification Format (NatSpec*).

Score
Impact: 1
Likelihood: 1
Recommendation

ACKNOWLEDGED: The Gains Trade team acknowledged this finding.

9. Automated Testing

STATIC ANALYSIS REPORT

Description

Halborn used automated testing techniques to enhance the coverage of certain areas of the scoped contracts. Among the tools used was Slither, a Solidity static analysis framework. After Halborn verified all the contracts in the repository and was able to compile them correctly into their ABI and binary formats, Slither was run on the all-scoped 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.

Slither results

GToken.sol

GTokenOpenPnlFeed.sol

TWAPPriceGetter.sol

GNSTokenBridge.sol

NFTMintingBridge.sol

  • The majority of the issues identified are related to reentrancy, low level calls, multiplication on the result of a division or timestamp usage for comparison.

  • Block.timestamp should not be used because it can be manipulated by miners.

  • All the reentrancies flagged are false positives.

  • Several informational issues related to solidity naming convention were identified.

  • No major issues were found by Slither.

AUTOMATED SECURITY SCAN

Description

Halborn used automated security scanners to assist with detection of well-known security issues, and to identify low-hanging fruits on the targets for this engagement. Among the tools used was MythX, a security analysis service for Ethereum smart contracts. MythX performed a scan on all the contracts and sent the compiled results to the analyzers to locate any vulnerabilities.

MythX results

GToken.sol

GTokenOpenPnlFeed.sol

TWAPPriceGetter.sol

GNSTokenBridge.sol

NFTMintingBridge.sol No issues found by MythX.

  • The majority of the issues identified are related to arithmetic operations, using of block.timestamp, variable visibility and out of bounds array access.

  • Integer Overflows and Underflows flagged by MythX are false positives, as those contracts are using Solidity ^0.8.0 version. After the Solidity version 0.8.0 Arithmetic operations revert to underflow and overflow by default.

  • No major issues were found by MythX.

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 2024. All rights reserved.