Prepared by:
HALBORN
Last Updated 04/25/2024
Date of Engagement by: October 31st, 2022 - November 21st, 2022
100% of all REPORTED Findings have been addressed
All findings
8
Critical
1
High
0
Medium
1
Low
1
Informational
5
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.
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.
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
)
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
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 analysis | Risk level | Remediation Date |
---|---|---|
BRIDGENFT CALL CAN BE FRONTRUN | Critical | Solved - 12/12/2022 |
WRONG FEE SENT TO CHAINLINK NODES | Medium | Risk Accepted |
MISSING ZERO ADDRESS CHECKS | Low | Risk Accepted |
THE TRANSFEROWNERSHIP PATTERN IS NOT FOLLOWED | Informational | Risk Accepted |
GAS OVER-CONSUMPTION IN LOOPS | Informational | Acknowledged |
REDUNDANT INITIALIZATION OF UINT VARIABLES TO 0 | Informational | Solved - 12/12/2022 |
USE OF REVERT STRINGS INSTEAD OF CUSTOM ERRORS | Informational | Acknowledged |
MISSING/INCOMPLETE NATSPEC COMMENTS | Informational | Acknowledged |
// Critical
In the NFTMintingBridge
contract, the function bridgeNft()
is used to bridge ERC721
tokens from one chain to another:
/**
* 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:
NFTMintingBridge
so then she can call bridgeNft()
to bridge the NFT to a different chain.NFTMintingBridge.bridgeNft(2, 1337)
.NFTMintingBridge.bridgeNft()
calls. NFTMintingBridge.bridgeNft(2, 1337)
.SOLVED: The Gains Trade team
solved the issue in the Commit ID: a349e7438543d57b5845a6771d4e642a1a4868f4
/**
* 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}
// Medium
In the GTokenOpenPnlFeed
contract, the makeOpenPnlRequest()
function is used to send requests to a pool of Chainlink oracles:
// 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.
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."
// Low
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.
The following function is not validating, that the given addresses in the newValues
array are different from zero:
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:
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;
}
RISK ACCEPTED: The Gains Trade team
accepted the risk of this finding.
// Informational
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.
RISK ACCEPTED: The Gains Trade team
accepted the risk of this finding.
// Informational
In all the loops, the counter variable is incremented using i++
. In loops, using ++i
costs less gas per iteration than i++
.
GTokenOpenPnlFeed.sol
for(uint i = 0; i < reqToResetCount; i++)
for(uint i = 0; i < oracles.length; i ++)
for(uint i = 0; i < array.length; i++)
For example, based in the following test contract:
//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:
ACKNOWLEDGED: The Gains Trade team
acknowledged this finding.
// Informational
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.
GTokenOpenPnlFeed.sol
for(uint i = 0; i < reqToResetCount; i++)
for(uint i = 0; i < oracles.length; i ++
for(uint i = 0; i < array.length; i++)
SOLVED: The Gains Trade team
solved the issue by reducing the gas costs in the Commit ID: f1bcbb4da9efdd8a8be2d28a297cfc9b999d2e1a
// Informational
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.
GToken.sol
require(_contractAddresses.asset != address(0)...
require(_msgSender() == manager, "ONLY_MANAGER");
require(shareToAssetsPrice > 0, "PRICE_0");
require(assetsOrShares > 0, "VALUE_0");
require(maxDiscountP > 0, "NO_ACTIVE_DISCOUNT");
require(lockDuration >= MIN_LOCK_DURATION, "BELOW_MIN_LOCK_DURATION");
require(lockDuration <= MAX_LOCK_DURATION, "ABOVE_MAX_LOCK_DURATION");
require(newOwner != address(0), "Ownable: new owner is the zero address");
require(newOwner != manager && newOwner != admin, "WRONG_VALUE");
require(newValue != address(0), "ADDRESS_0");
require(newValue != owner() && newValue != admin, "WRONG_VALUE");
require(newValue != address(0), "ADDRESS_0");
require(newValue != owner() && newValue != manager, "WRONG_VALUE");
require(newValue != address(0), "ADDRESS_0");
require(newValue.addr != address(0), "ADDRESS_0");
require(newValue.signature.length > 0, "BYTES_0");
require(newValue != address(0), "ADDRESS_0");
require(newValue >= MIN_DAILY_ACC_PNL_DELTA, "BELOW_MIN");
require(newValue[1] > newValue[0], "WRONG_VALUES");
require(newValue <= MAX_SUPPLY_INCREASE_DAILY_P, "ABOVE_MAX");
require(newValue <= MAX_LOSSES_BURN_P, "ABOVE_MAX");
require(newValue <= MAX_GNS_SUPPLY_MINT_DAILY_P, "ABOVE_MAX");
require(newValue <= MAX_DISCOUNT_P, "ABOVE_MAX_DISCOUNT");
require(newValue >= 100 * PRECISION, "BELOW_MIN");
require(success == true, "GNS_PRICE_CALL_FAILED");
require(price > 0, "GNS_TOKEN_PRICE_0");
require(totalSharesBeingWithdrawn(sender) <= balanceOf(sender) - amount, "PENDING_WITHDRAWAL");
require(totalSharesBeingWithdrawn(from) <= balanceOf(from) - amount, "PENDING_WITHDRAWAL");
require(assets <= maxDeposit(receiver), "ERC4626: deposit more than max");
require(shares <= maxMint(receiver), "ERC4626: mint more than max");
require(assets <= maxWithdraw(owner), "ERC4626: withdraw more than max");
require(shares <= maxRedeem(owner), "ERC4626: redeem more than max");
require(openTradesPnlFeed.nextEpochValuesRequestCount() == 0, "END_OF_EPOCH");
require(sender == owner || allowance > 0 && allowance >= shares, "NOT_ALLOWED");
require(totalSharesBeingWithdrawn(owner) + shares <= balanceOf(owner), "MORE_THAN_BALANCE");
require(shares <= withdrawRequests[owner][unlockEpoch], "MORE_THAN_WITHDRAW_AMOUNT");
require(sender == owner || allowance > 0 && allowance >= shares, "NOT_ALLOWED");
require(simulatedAssets <= maxDeposit(receiver), "DEPOSIT_MORE_THAN_MAX");
require(shares <= maxMint(receiver), "MINT_MORE_THAN_MAX");
require(assets > assetsDeposited, "NO_DISCOUNT");
require(owner == sender...
require(block.timestamp >= d.atTimestamp + d.lockDuration, "NOT_UNLOCKED");
require(accPnlPerToken <= int(PRECISION), "NOT_ENOUGH_ASSETS");
require(sender == pnlHandler, "ONLY_TRADING_PNL_HANDLER");
require(accPnlPerToken <= int(PRECISION), "NOT_ENOUGH_ASSETS");
require(dailyAccPnlDelta <= int(maxDailyAccPnlDelta), "MAX_DAILY_PNL");
require(assets <= assetsToDeplete, "AMOUNT_TOO_BIG");
require(accPnlPerTokenUsed > 0, "NOT_UNDER_COLLATERALIZED");
require(assets <= uint(accPnlPerTokenUsed) * supply / PRECISION, "AMOUNT_TOO_BIG");
require(dailyMintedGns <= maxGnsSupplyMintDailyP...
require(sender == address(openTradesPnlFeed), "ONLY_PNL_FEED");
GTokenOpenPnlFeed.sol
require(_linkToken != address(0)
require(msg.sender == IOwnable(address(gToken)).owner(), "ONLY_OWNER");
require(msg.sender == gToken.manager(), "ONLY_MANAGER");
require(msg.sender == gToken.admin(), "ONLY_ADMIN");
require(newValue >= MIN_REQUESTS_START, "BELOW_MIN");
require(newValue <= MAX_REQUESTS_START, "ABOVE_MAX");
require(newValue >= MIN_REQUESTS_EVERY, "BELOW_MIN");
require(newValue <= MAX_REQUESTS_EVERY, "ABOVE_MAX");
require(newValue >= MIN_REQUESTS_COUNT, "BELOW_MIN");
require(newValue <= MAX_REQUESTS_COUNT, "ABOVE_MAX");
require(newValue >= MIN_ANSWERS, "BELOW_MIN");
require(newValue % 2 == 1, "EVEN");
require(newValue <= oracles.length / 2, "ABOVE_MAX");
require(_index < oracles.length, "INDEX_TOO_BIG");
require(newValue != address(0), "VALUE_0");
require(newValues.length >= minAnswers * 2, "ARRAY_TOO_SMALL");
require(newValue != bytes32(0), "VALUE_0");
require(reqToResetCount > 0, "NO_REQUEST_TO_RESET");
require(block.timestamp - gToken.currentEpochStart()
TWAPPriceGetter.sol
require(address(_uniV3Pool) != address(0)...
require(address(_uniV3Pool) != address(0), "WRONG_VALUE");
require(_twapInterval >= MIN_TWAP_PERIOD && _twapInterval <= MAX_TWAP_PERIOD, "WRONG_VALUE");
GNSTokenBridge.sol
require(msg.value != 0, "!fee");
NFTMintingBridge.sol
require(msg.value != 0, "!fee");
ACKNOWLEDGED: The Gains Trade team
acknowledged this finding.
// Informational
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*).
ACKNOWLEDGED: The Gains Trade team
acknowledged this finding.
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.
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.
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.
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.
// Download the full report
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed