Prepared by:
HALBORN
Last Updated 06/04/2024
Date of Engagement by: April 11th, 2024 - May 10th, 2024
100% of all REPORTED Findings have been addressed
All findings
12
Critical
1
High
2
Medium
2
Low
1
Informational
6
GoldLink
engaged Halborn to conduct a security assessment on their smart contracts beginning on April 11th, 2024 and ending on May 10th, 2024. The security assessment was scoped to the smart contracts provided to the Halborn team.
The team at Halborn assigned a full-time security engineer to verify 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 assessment is to:
Ensure that smart contract functions operate as intended
Identify potential security issues with the smart contracts
In summary, Halborn identified some improvements to reduce the likelihood and impact of risks, which were successfully addressed by the GoldLink team
. The main ones were the following:
Update the logic of the 'getOrderValueUSD' function to return 0 only when the order type is different than a market increase.
Set the value of the position size when creating decrease orders.
Assign correctly the addresses for the long and short tokens.
Implement adequately the conditions regarding the size of the swap rebalances.
Ensure that all initial values are set in an initializer function.
Halborn performed a combination of manual 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 the code and can quickly identify items that do not follow the security best practices. The following phases and associated tools were used during the assessment:
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.
Static Analysis of security for scoped contract, and imported functions (slither
).
Testnet deployment (Foundry
).
EXPLOITABILIY METRIC () | METRIC VALUE | NUMERICAL 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 |
IMPACT METRIC () | METRIC VALUE | NUMERICAL 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 |
SEVERITY COEFFICIENT () | COEFFICIENT VALUE | NUMERICAL VALUE |
---|---|---|
Reversibility () | None (R:N) Partial (R:P) Full (R:F) | 1 0.5 0.25 |
Scope () | Changed (S:C) Unchanged (S:U) | 1.25 1 |
Severity | Score Value Range |
---|---|
Critical | 9 - 10 |
High | 7 - 8.9 |
Medium | 4.5 - 6.9 |
Low | 2 - 4.4 |
Informational | 0 - 1.9 |
Critical
1
High
2
Medium
2
Low
1
Informational
6
Security analysis | Risk level | Remediation Date |
---|---|---|
Miscalculation of the accounts total value in terms of USD | Critical | Solved - 05/16/2024 |
Position size is not set when creating decrease orders | High | Solved - 05/16/2024 |
Addresses of the short and long tokens are incorrectly assigned | High | Solved - 05/17/2024 |
Executing swap rebalances could revert | Medium | Solved - 05/17/2024 |
Variable is not correctly initialized in an upgradeable contract | Medium | Solved - 05/13/2024 |
Implementation contract uninitialized | Low | Solved - 05/16/2024 |
Caching array length in loops can save gas | Informational | Solved - 05/16/2024 |
Too strict condition when rebalancing positions | Informational | Solved - 05/16/2024 |
Lack of zero address check | Informational | Solved - 05/16/2024 |
Some functions do not verify if markets are approved | Informational | Solved - 05/17/2024 |
Function with misleading name | Informational | Solved - 05/16/2024 |
Inaccurate comments in the code | Informational | Solved - 05/16/2024 |
// Critical
The getOrderValueUSD
function in the AccountGetters library calculates the value of an order in USD, considering that the value of any non-increase order is 0. To verify if an order is a non-increase one, the following conditional expression is evaluated:
if (
orderType != IGmxV2OrderTypes.OrderType.MarketIncrease ||
orderType != IGmxV2OrderTypes.OrderType.LimitIncrease ||
orderType != IGmxV2OrderTypes.OrderType.MarketSwap ||
orderType != IGmxV2OrderTypes.OrderType.LimitSwap
) {
return 0;
}
Currently, the protocol only supports the following order types:
MarketDecrease
: For this order type, the function will return 0, as expected.
MarketIncrease
: For this order type, the function will also return 0, instead of the correct value. It happens because the order type is always different from LimitIncrease
, MarketSwap
or LimitSwap
, so the conditional expression will evaluate to true and the function will return 0.
As a result, the accounts total value will be miscalculated, having a value much lower than expected, which triggers the following consequences:
The value of health score for accounts will be below the expected one, which could allow that many of them are unfairly liquidated.
Limitations to borrow only a lesser amount of funds than expected from the strategy reserve.
Limitations to withdraw only a lesser amount of collaterals than expected from the strategy bank.
Limitations to withdraw only a lesser amount of profit than expected from the account.
It is recommended to update the logic of the mentioned function to return 0 only when the order type is different from a market increase.
SOLVED: The GoldLink team solved the issue in the specified commit id.
// High
The creation of decrease orders mainly involves the execution of the OrderLogic.createDecreaseOrder
function (i.e.: the caller) and the DeltaConvergenceMath.getDecreaseOrderValues
function (i.e.: the callee). This latter function does not set the value of the position size (result.positionSizeNextUsd
), which means that its value is 0. As a result, the following consequences are triggered:
Consequence #1:
Bypass of the order size validation during the execution of the OrderLogic.createDecreaseOrder
function when creating decrease orders:
if (result.positionSizeNextUsd != 0 && sizeDeltaUsd != 0) {
// Validate the order size. Only do this if remainingPositionSize != 0, since it may be impossible to
// reduce the position size in the event the remaining size is less than the min order size.
OrderValidation.validateOrderSize(
marketConfig.orderPricingParameters.minOrderSizeUsd,
marketConfig.orderPricingParameters.maxOrderSizeUsd,
order.numbers.sizeDeltaUsd
);
}
Consequence #2:
Bypass of the position size validation during the execution of the OrderValidation.validatePositionSize
function when creating decrease orders:
The getDecreaseOrderValues
function does not set the value of result.positionSizeNextUsd
:
function getDecreaseOrderValues(
IGmxFrfStrategyManager manager,
uint256 sizeDeltaUsd,
DeltaCalculationParameters memory values
) internal view returns (DecreasePositionResult memory result) {
PositionTokenBreakdown memory breakdown = getAccountMarketDelta(
manager,
values.account,
values.marketAddress,
sizeDeltaUsd,
false
);
// The total cost amount is equal to the sum of the fees associated with the decrease, in terms of the collateral token.
// This accounts for negative funding fees, borrowing fees,
uint256 collateralLostInDecrease = breakdown
.positionInfo
.fees
.totalCostAmount;
{
uint256 profitInCollateralToken = SignedMath.abs(
breakdown.positionInfo.pnlAfterPriceImpactUsd
) / values.longTokenPrice;
if (breakdown.positionInfo.pnlAfterPriceImpactUsd > 0) {
collateralLostInDecrease -= Math.min(
collateralLostInDecrease,
profitInCollateralToken
); // Offset the loss in collateral with position profits.
} else {
collateralLostInDecrease += profitInCollateralToken; // adding because this variable is meant to represent a net loss in collateral.
}
}
uint256 sizeDeltaActual = Math.min(
sizeDeltaUsd,
breakdown.positionInfo.position.numbers.sizeInUsd
);
uint256 shortTokensAfterDecrease;
{
uint256 proportionalDecrease = sizeDeltaActual.fractionToPercent(
breakdown.positionInfo.position.numbers.sizeInUsd
);
shortTokensAfterDecrease =
breakdown.tokensShort -
breakdown
.positionInfo
.position
.numbers
.sizeInTokens
.percentToFraction(proportionalDecrease);
}
uint256 longTokensAfterDecrease = breakdown.tokensLong -
collateralLostInDecrease;
// This is the difference in long vs short tokens currently.
uint256 imbalance = Math.max(
shortTokensAfterDecrease,
longTokensAfterDecrease
) - Math.min(shortTokensAfterDecrease, longTokensAfterDecrease);
if (shortTokensAfterDecrease < longTokensAfterDecrease) {
// We need to remove long tokens equivalent to the imbalance to make the position delta neutral.
// However, it is possible that there are a significant number of long tokens in the contract that are impacting the imbalance.
// If this is the case, then if we were to simply remove the imbalance, it can result in a position with very high leverage. Therefore, we will simply remove
// the minimum of `collateralAmount - collateralLostInDecrease` the difference in the longCollateral and shortTokens. The rest of the delta imbalance can be left to rebalancers.
uint256 remainingCollateral = breakdown
.positionInfo
.position
.numbers
.collateralAmount - collateralLostInDecrease;
if (remainingCollateral > shortTokensAfterDecrease) {
result.collateralToRemove = Math.min(
remainingCollateral - shortTokensAfterDecrease,
imbalance
);
}
}
if (result.collateralToRemove != 0) {
(uint256 expectedSwapOutput, , ) = manager
.gmxV2Reader()
.getSwapAmountOut(
manager.gmxV2DataStore(),
values.market,
_makeMarketPrices(
values.shortTokenPrice,
values.longTokenPrice
),
values.market.longToken,
result.collateralToRemove,
values.uiFeeReceiver
);
result.estimatedOutputUsd =
expectedSwapOutput *
values.shortTokenPrice;
}
if (breakdown.positionInfo.pnlAfterPriceImpactUsd > 0) {
result.estimatedOutputUsd += SignedMath.abs(
breakdown.positionInfo.pnlAfterPriceImpactUsd
);
}
result.executionPrice = breakdown
.positionInfo
.executionPriceResult
.executionPrice;
}
It is recommended to correctly set the value of the position size when creating decrease orders.
SOLVED: The GoldLink team solved the issue in the specified commit id.
// High
The isLiquidationFinished
function in the AccountGetters library incorrectly assigns the addresses for the long and short tokens. It happens because when calling the GmxMarketGetters.getMarketTokens
function, the returned values are assigned to the addresses in the opposite order, i.e.: short token has the address of the long one and viceversa.
As a consequence, the isLiquidationFinished
function won't return accurate results, which means that some lenders wouldn't receive their payments even if the liquidation process had indeed finished.
The AccountGetters.isLiquidationFinished
function incorrectly assigns the addresses for the long and short tokens when calling GmxMarketGetters.getMarketTokens
:
// Get all available markets to check funding fees for.
address[] memory markets = manager.getAvailableMarkets();
for (uint256 i = 0; i < markets.length; ++i) {
(address longToken, address shortToken) = GmxMarketGetters
.getMarketTokens(dataStore, markets[i]);
The GmxMarketGetters.getMarketTokens
function returns the values of the short and long tokens respectively, which is the opposite from what is expected in the function above:
function getMarketTokens(
IGmxV2DataStore dataStore,
address market
) internal view returns (address shortToken, address longToken) {
return (
getShortToken(dataStore, market),
getLongToken(dataStore, market)
);
}
It is recommended to assign the addresses correctly for the long and short tokens.
SOLVED: The GoldLink team solved the issue in the specified commit id.
// Medium
In order to prevent gaming and token rebalances by continuously sending tokens to the account, the swapRebalancePosition
function in the LiquidationLogic library requires that the size of the swap rebalance must either:
Leave the account with zero delta.
Leave the account with zero tokens that can be atomically swapped.
Leave the account with a balance that is greater than or equal to the minimum swap rebalance size.
However, the first condition is not correctly implemented because it compares the value of breakdown.tokensLong - breakdown.tokensShort
against the remaining
variable, instead of comparing it against the rebalanceAmount
variable. As a consequence, when executing swap rebalances, the transaction could unnecessarily revert, making the operation temporarily unavailable.
The swapRebalancePosition
function does not correctly implement the first condition in the require
function:
require(
remaining == breakdown.tokensLong - breakdown.tokensShort ||
remaining == 0 ||
remaining >= unwindConfig.minSwapRebalanceSize,
GmxFrfStrategyErrors
.LIQUIDATION_MANAGEMENT_REBALANCE_AMOUNT_LEAVE_TOO_LITTLE_REMAINING_ASSETS
);
It is recommended to correctly implement the conditions regarding the size of the swap rebalances.
SOLVED: The GoldLink team solved the issue in the specified commit id.
// Medium
The isInMulticall_
variable has a initial value of 1 when declared in the GmxStrategyStorage contract. This is equivalent to setting this value in the constructor, and as such, will not work for upgradeable contracts, which means that any upgradeable instances will not have this field set.
In this particular case, the multicall
function in the GmxFrfStrategyAccount will always revert with the error message Nested multicalls are not allowed
when invoking it because the isInMulticall_
variable will remain with 0 as a value, instead of 1 as expected. The described issue directly affects the logic for paying execution fee / gas.
The isInMulticall_
variable has a initial value of 1 when declared in the GmxStrategyStorage contract:
/// @notice Should be set when a multicall is active to prevent nested multicalls.
/// The value `1` implies the contract is not current executing a multicall.
/// The value `2` implies that the contract is currently executing a multicall.
uint256 internal isInMulticall_ = 1;
It is recommended to make sure that all initial values are set in an initializer function.
SOLVED: The GoldLink team solved the issue in the specified commit id.
// Low
The GmxFrfStrategyAccount contract is using the Initializable
module from OpenZeppelin. In order to prevent leaving the contract uninitialized, OpenZeppelin's documentation recommends adding the _disableInitializers
function in the constructor to automatically lock the contract when it is deployed.
The constructor
in the GmxFrfStrategyAccount contract does not include the _disableInitializers
function:
// ============ Constructor ============
/**
* @notice Constructor for upgradeable contract, distinct from initializer.
*
* The constructor is used to set immutable variables, and for top-level upgradeable
* contracts, it is also used to disable the initializer of the logic contract.
*
* Note that since this contract is used with beacon proxies, the immutable variables will
* be constant across all proxies pointing to the same beacon.
*/
constructor(IGmxFrfStrategyManager manager) GmxStrategyStorage(manager) {}
It is recommended to call the _disableInitializers
function in the contract's constructor.
SOLVED: The GoldLink team solved the issue in the specified commit id.
// Informational
Reading the length of the array at each iteration of the loop requires 6 gas (3 for mload
and 3 to place memory_offset
) onto the stack. Caching the length of the array on the stack saves about 3 gas per iteration. The affected functions are the following:
AccountGetters.isLiquidationFinished
AccountGetters.getAccountOrdersValueUSD
AccountGetters.getAccountPositionsValueUSD
AccountGetters.getSettledFundingFeesValueUSD
AccountGetters._getAccountTokenValueUSD
OrderValidation.validateNoPendingOrdersInMarket
GmxFrfStrategyAccount.multicall
GmxFrfStrategyManager.setMarket
It is recommended to consider caching the length of the arrays.
SOLVED: The GoldLink team solved the issue in the specified commit id.
// Informational
The rebalancePosition
function in the LiquidationLogic library reverts if the position's delta proportion is less or equal than the threshold defined in the market's unwind configuration. However, in order to maintain the consistency along the codebase (e.g.: with swapRebalancePosition
function), the function should revert only when the position's delta proportion is less than the mentioned threshold.
It is recommended to update the logic of the rebalancePosition
function to revert only when the position's delta proportion is less than the configured threshold.
SOLVED: The GoldLink team solved the issue in the specified commit id.
// Informational
Some functions in the codebase do not include a zero address check for their parameters. If one of those parameters is mistakenly set to zero, it could affect the correct operation of the protocol. The affected functions are the following:
MarketConfigurationManager._setUiFeeReceiver
GmxStrategyStorage.constructor
GmxFrfStrategyAccount.initialize
GmxFrfStrategyDeployer.constructor
GmxFrfStrategyDeployer.deployAccount
It is recommended to add a zero address check in the functions mentioned above.
SOLVED: The GoldLink team solved the issue in the specified commit id.
// Informational
Some functions in the GmxFrfStrategyAccount contract do not verify if the markets they are interacting with have been approved by the GmxFrfStrategyManager contract, e.g.: by using the onlyApprovedMarket
modifier. The affected functions are the following:
GmxFrfStrategyAccount.executeClaimFundingFees
GmxFrfStrategyAccount.executeClaimCollateral
GmxFrfStrategyAccount.executeLiquidatePosition
GmxFrfStrategyAccount.executeReleveragePosition
GmxFrfStrategyAccount.executeSwapRebalance
GmxFrfStrategyAccount.executeRebalancePosition
This issue has been classified as Informational because in the mentioned functions, there cannot be position / funding fees for non-approved markets. However, it is included in the report as part of a security-in-depth approach to harden the functions if the codebase is later refactored.
It is recommended to verify in the mentioned functions if the markets are approved before further processing.
SOLVED: The GoldLink team solved the issue in the specified commit id.
// Informational
The getMinimumAcceptablePriceForDecrease
function in the OrderHelpers library calculates the maximum acceptable price used as threshold when validating the prices for decrease orders. However, the name of the function suggests that the value calculated represents a minimum threshold instead of a maximum one, which could mislead users and even developers if the codebase is later refactored.
It is recommended to update the name of the mentioned function to reflect its real behavior.
SOLVED: The GoldLink team solved the issue in the specified commit id.
// Informational
Along the codebase, there are some functions that include inaccurate comments, which could mislead users or developers (if code is later refactored) when trying to understand the expected behavior of the functions. The affected resources are the following:
DeltaConvergenceMath._getLeverage:
The comment indicates the following regarding the net collateral value:
// Net Collateral Value: Calculated using the following formula: (collateral amount tokens - funding fees tokens - borrowing fees tokens) * token price usd + pnl usd.
However the code calculates its value in a different way (using totalCostAmount
):
uint256 collateralInTokens = breakdown
.positionInfo
.position
.numbers
.collateralAmount - breakdown.positionInfo.fees.totalCostAmount;
GmxFrfStrategyAccount.executeLiquidateAssets:
The comment indicates the following regarding the executeLiquidateAssets
function:
* @notice Liquidates the specified `asset` in the amount of `amount` to the `receiever` address. Can only be called when the account has no active loan. The `callback` address must be
However, the function uses the hasActiveLoan
modifier, which indicates the opposite behavior:
function executeLiquidateAssets(
address asset,
uint256 amount,
address callback,
address receiever
) external strategyNonReentrant whenLiquidating hasActiveLoan {
It is recommended to update the comments in the code to reflect the actual behavior of the mentioned functions.
SOLVED: The GoldLink team solved the issue in the specified commit id.
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