Prepared by:
HALBORN
Last Updated 04/25/2024
Date of Engagement by: December 19th, 2022 - January 6th, 2023
75% of all REPORTED Findings have been addressed
All findings
16
Critical
0
High
1
Medium
2
Low
3
Informational
10
GammaSwap allows users to provide liquidity into an existing AMM (e.g. Uniswap, Sushiswap, Balancer). At the same time, it allows other users to short LP tokens (borrow the Uniswap LP Token and retrieve the reserve tokens it represents) from users that have provided liquidity to an existing AMM though GammaSwap and therefore profit from the change in price. What is normally impermanent loss to liquidity providers becomes impermanent gains to liquidity shorters.
\client engaged Halborn
to conduct a security audit on their smart contracts beginning on 2022-12-19 and ending on 2023-01-06. The security assessment was scoped to the smart contracts provided in the V1-core, V1-strategies and V1-periphery GitHub repositories. Commit hashes and further details can be found in the Scope section of this report.
The team at Halborn was provided 3 weeks for the engagement and assigned 3 full-time security engineer/engineers to audit the security of the smart contracts in scope. The security engineers are blockchain and smart contract security experts with advanced penetration testing and smart contract hacking skills, and deep knowledge of multiple blockchain protocols.
The purpose of the audits 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 mostly addressed by \client. The main ones are the following:
All functions with mathematical operations should have sanity checks against division by zero findings
Token decimals should be considered during price/fee calculations
Other chain specifications should be considered
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 code and can quickly identify items that do not follow the security best practices. The following phases and associated tools were used during 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 hot-spots or bugs. (MythX
)
Static Analysis of security for scoped contract, and imported functions. (Slither
)
Testnet deployment (Brownie
, Foundry
)
The security assessment was scoped to the following smart contracts on these repositories:
\begin{enumerate} \item v1-core Repository: \href{https://github.com/gammaswap/v1-core}{v1-core main branch} \item Commit ID: \href{https://github.com/gammaswap/v1-core/tree/dbda72a563622afda678e8373f4a3e0cd091bb43}{dbda72a563622afda678e8373f4a3e0cd091bb43} \item Smart contracts in scope: \begin{itemize} \item contracts/pools/CPMMGammaPool.sol \item contracts/libraries/LibStorage.sol \item contracts/libraries/AddressCalculator.sol \item contracts/storage/AppStorage.sol \item contracts/GammaPoolFactory.sol \item contracts/base/AbstractGammaPoolFactory.sol \item contracts/base/GammaPoolERC4626.sol \item contracts/base/GammaPool.sol \item contracts/base/GammaPoolERC20.sol \end{itemize} \end{enumerate}
\begin{enumerate} \item v1-strategies Repository: \href{https://github.com/gammaswap/v1-strategies}{v1-strategies main branch} \item Commit ID: \href{https://github.com/gammaswap/v1-strategies/tree/f60285582b2b1178e36a8ebc8dddc6deaeb5c7f8}{f60285582b2b1178e36a8ebc8dddc6deaeb5c7f8} \item Smart contracts in scope: \begin{itemize} \item contracts/strategies/cpmm/CPMMBaseLongStrategy.sol \item contracts/strategies/cpmm/CPMMLongStrategy.sol \item contracts/strategies/cpmm/CPMMShortStrategy.sol \item contracts/strategies/cpmm/CPMMBaseStrategy.sol \item contracts/strategies/cpmm/CPMMLiquidationStrategy.sol \item contracts/strategies/base/LongStrategy.sol \item contracts/strategies/base/BaseLongStrategy.sol \item contracts/strategies/base/BaseStrategy.sol \item contracts/strategies/base/LiquidationStrategy.sol \item contracts/strategies/base/ShortStrategy.sol \item contracts/strategies/base/ShortStrategyERC4626.sol \item contracts/rates/LogDerivativeRateModel.sol \item contracts/rates/LinearKinkedRateModel.sol \end{itemize} \end{enumerate}
\begin{enumerate} \item v1-periphery Repository: \href{https://github.com/gammaswap/v1-periphery}{v1-periphery main branch} \item Commit ID: \href{https://github.com/gammaswap/v1-periphery/tree/1e28654c4ed123f543ef22927e26630929ca4680}{1e28654c4ed123f543ef22927e26630929ca4680} \item Smart contracts in scope: \begin{itemize} \item contracts/base/GammaPoolERC721.sol \item contracts/base/Transfers.sol \item contracts/PositionManager.sol \item contracts/storage/AppStorage.sol \item contracts/interfaces/ITransfers.sol \item contracts/interfaces/ISendTokensCallback.sol \item contracts/interfaces/IPositionManager.sol \end{itemize} \end{enumerate}
The minor code differences mentioned in the following commits and implemented during the audit were also reviewed:
Out-of-Scope Changes:
Balancer Contracts
Critical
0
High
1
Medium
2
Low
3
Informational
10
Impact x Likelihood
HAL-03
HAL-10
HAL-06
HAL-04
HAL-01
HAL-11
HAL-08
HAL-02
HAL-05
HAL-07
HAL-09
HAL-12
HAL-13
HAL-14
HAL-15
HAL-16
Security analysis | Risk level | Remediation Date |
---|---|---|
INITIAL LP DEPOSIT IS IMPOSSIBLE DUE TO MISCALCULATION | High | Solved - 01/19/2023 |
INCORRECT INVARIANT FACTOR CALCULATION MAY LEAD TO A LOSS OF ACCRUED FUNDS | Medium | Solved - 01/19/2023 |
CALLING THE BATCHLIQUIDATIONS FUNCTION WITH TOKENID 0 ALWAYS REVERTS | Medium | Not Applicable |
CENTRALIZATION RISK | Low | Risk Accepted |
TOKEN SWAPPING CAN FAIL DUE TO DIVISION BY ZERO | Low | Not Applicable |
MISSING ZERO ADDRESS CHECKS | Low | Solved - 01/23/2023 |
FIRST LIQUIDITY PROVIDER LOSES FUNDS DUE TO ROUNDING ISSUE | Informational | - |
INCOMPATIBILITY WITH FEE-ON-TRANSFER OR DEFLATIONARY TOKENS | Informational | - |
LOAN CALCULATION CAN BE MISLEADING FOR DIFFERENT CHAINS DUE TO HARDCODED VARIABLES | Informational | - |
LACK OF ZERO AMOUNT CHECK MAY LEAD DIVISION BY ZERO | Informational | - |
MISSING UPPER/LOWER BOUND CHECKS | Informational | Solved - 01/23/2023 |
FOR LOOP OPTIMIZATIONS | Informational | Solved - 01/23/2023 |
THE IMMUTABLE KEYWORD COSTS LESS GAS FOR CONSTANT VARIABLES | Informational | Solved - 01/23/2023 |
UNUSED IMPORTS | Informational | Solved - 01/23/2023 |
MISSING NATSPEC DOCUMENTATION | Informational | Solved - 02/14/2023 |
OPEN TODOs | Informational | Solved - 01/23/2023 |
// High
The _depositNoPull()
function in the BaseStrategy
contract does not work properly. The first deposit is reverted with the ZeroAmount()
error.
In the _depositNoPull()
function, the updateIndex()
internal function is invoked with accFeeIndex
, lastFeeIndex
and lastCFMMFeeIndex
call parameters. If the value of the s.BORROWED_INVARIANT
variable is positive, then some LP tokens are minted to developers, since the protocol charges a handling fee. It has been observed however that the contract also tries to mint LP tokens to developers even if the s.BORROWED_INVARIANT
is zero. In this case, the devShares
variable returns zero since there are no deposits in the contract and s.totalSupply
is equal to zero, which prevents users from depositing funds in the contract.
function test_depositNoPullPoC() public {
vm.roll(16392000 + 1); // deployment block + 1
_addLiquidityWithUniRouter(deployer, address(usdt), address(weth), 50 * 1e6, 10 * 1e18);
vm.startPrank(deployer);
usdt.transfer(user1, 5 * 1e6);
weth.transfer(user1, 2 * 1e18);
usdt.transfer(user2, 6 * 1e6);
weth.transfer(user2, 10 * 1e18);
uint256 loanId = positionManager.createLoan(1, address(cfmm_usdt_weth), deployer, type(uint).max);
uint256 loanId2 = positionManager.createLoan(1, address(cfmm_usdt_weth), user2, type(uint).max);
positionManager.transferFrom(deployer, user1, loanId);
IPositionManager.DepositWithdrawParams memory depositData = IPositionManager.DepositWithdrawParams({
protocolId: 1,
cfmm: address(cfmm_usdt_weth),
to: user1,
lpTokens: cfmm_usdt_weth.balanceOf(deployer),
deadline: 99999
});
cfmm_usdt_weth.approve(address(positionManager), cfmm_usdt_weth.balanceOf(deployer));
positionManager.depositNoPull(depositData);
vm.stopPrank();
}
Screenshot:
function updateIndex() internal virtual returns(uint256 accFeeIndex, uint256 lastFeeIndex, uint256 lastCFMMFeeIndex) {
lastCFMMFeeIndex = updateCFMMIndex();
lastFeeIndex = updateFeeIndex(lastCFMMFeeIndex);
accFeeIndex = updateStore(lastFeeIndex);
if(s.BORROWED_INVARIANT >= 0) {
mintToDevs(lastFeeIndex, lastCFMMFeeIndex);
}
}
function _mint(address account, uint256 amount) internal virtual {
if(amount == 0) {
revert ZeroAmount();
}
s.totalSupply += amount;
s.balanceOf[account] += amount;
emit Transfer(address(0), account, amount);
}
SOLVED: This finding was identified in a live code walkthrough jointly by the GammaSwap team
and the Halborn
team, and the existence of the finding was confirmed by the Halborn team
.
The greater than or equal to (>=)
relation was replaced with greater than (>)
relation.
Commit ID:
v1-strategies::6f6a7ba1f0fe8b9d7a7cb9b756ef5b3e6bfa55ab
// Medium
The invariant factor formula makes use of the number of decimals of the first token in token pair. The result of this calculation is further used in multiple places across the contracts. For example, when you borrow liquidity from a GammaSwap pool, the protocol calculates that factor to update some storage variables such as lastFeeIndex
and BORROWED_INVARIANT
. Instead of focusing on s.decimal[0]
only, both decimals should be considered.
As a result, calculating this variable based on a wrong number of decimals affects borrowed/repaid liquidity and many storage variables in the protocol.
// replace the s.decimals[0] variable in the getInvariantFactor() with 1e18 and 1e6 to see difference.
function test_BorrowAndRepayLifeCycleTask01() public {
vm.roll(16392000 + 1200);
_addLiquidityWithUniRouter(deployer, address(usdt), address(weth), 10 * 1e6, 5 * 1e18);
vm.startPrank(deployer);
usdt.transfer(user1, 5 * 1e6);
weth.transfer(user1, 2 * 1e18);
usdt.transfer(user2, 6 * 1e6);
weth.transfer(user2, 10 * 1e18);
uint256 loanId = positionManager.createLoan(1, address(cfmm_usdt_weth), deployer, type(uint).max);
uint256 loanId2 = positionManager.createLoan(1, address(cfmm_usdt_weth), user2, type(uint).max);
positionManager.transferFrom(deployer, user1, loanId);
IPositionManager.DepositWithdrawParams memory depositData = IPositionManager.DepositWithdrawParams({
protocolId: 1,
cfmm: address(cfmm_usdt_weth),
to: user1,
lpTokens: cfmm_usdt_weth.balanceOf(deployer),
deadline: 99999
});
cfmm_usdt_weth.approve(address(positionManager), cfmm_usdt_weth.balanceOf(deployer));
positionManager.depositNoPull(depositData);
vm.stopPrank();
_addLiquidityWithUniRouter(user1, address(usdt), address(weth), 1e6, 1e18);
vm.startPrank(user1);
uint256[] memory amountsDesired = new uint256[](2);
amountsDesired[0] = 1e18;
amountsDesired[1] = 1e6;
IPositionManager.AddRemoveCollateralParams memory increaseCollData = IPositionManager.AddRemoveCollateralParams({
protocolId: 1,
cfmm: address(cfmm_usdt_weth),
to: user1,
tokenId: loanId,
deadline: 99999,
amounts: amountsDesired
});
usdt.approve(address(positionManager), amountsDesired[1]);
weth.approve(address(positionManager), amountsDesired[0]);
vm.roll(16392000 + 1252);
positionManager.increaseCollateral(increaseCollData);
vm.stopPrank();
vm.startPrank(user2);
increaseCollData.tokenId = loanId2;
increaseCollData.to = user2;
amountsDesired[0] = 1e18;
amountsDesired[1] = 1e6;
increaseCollData.amounts = amountsDesired;
usdt.approve(address(positionManager), amountsDesired[1]);
weth.approve(address(positionManager), amountsDesired[0]);
vm.roll(16392000 + 1304);
positionManager.increaseCollateral(increaseCollData);
vm.stopPrank();
vm.startPrank(user1);
IPositionManager.BorrowLiquidityParams memory borrowLqtyData = IPositionManager.BorrowLiquidityParams({
protocolId: 1,
cfmm: address(cfmm_usdt_weth),
to: user1,
tokenId: loanId,
lpTokens: uint256(1e12) * 800 / 1000,
deadline: 99999,
minBorrowed: new uint256[](2)
});
positionManager.borrowLiquidity(borrowLqtyData);
vm.roll(16392000 + 1340);
amountsDesired[0] = 1e18;
amountsDesired[1] = 1e6;
increaseCollData.amounts = amountsDesired;
vm.stopPrank();
vm.prank(user2);
positionManager.decreaseCollateral(increaseCollData);
IPositionManager.RepayLiquidityParams memory repayData = IPositionManager.RepayLiquidityParams({
protocolId: 1,
cfmm: address(cfmm_usdt_weth),
to: user1,
tokenId: loanId,
liquidity: 1000000 / 2,
deadline: 99999,
minRepaid: new uint256[](2)
});
vm.stopPrank();
vm.startPrank(deployer);
usdt.transfer(address(cfmm_usdt_weth), 10 * 1e6);
weth.transfer(address(cfmm_usdt_weth), 5 * 1e18);
vm.stopPrank();
}
Screenshot:
function getInvariantFactor() internal virtual override view returns(uint256) {
return 10 ** s.decimals[0];
}
function calcUtilizationRate(uint256 lpInvariant, uint256 borrowedInvariant) internal virtual view returns(uint256) {
uint256 totalInvariant = lpInvariant + borrowedInvariant;
if(totalInvariant == 0)
return 0;
return borrowedInvariant * getInvariantFactor() / totalInvariant;
}
SOLVED: This finding was identified in a code walkthrough jointly by the GammaSwap team
and the Halborn team
, and the existence of the finding was confirmed by the Halborn team
.
The invariant factor is now calculated as 10**18
.
Commit ID:
v1-strategies::85864193924b7d1299dd99a27ded752c807ae2cb
// Medium
The _batchLiquidations
function implemented in the LiquidationStrategy
contract is designed to perform more than one liquidation in a go. The tokenId > 0
check on the payLoanAndRefundLiquidator
function assures that users can use 0
as tokenId
for batch liquidation operation. However, it is not possible to use 0
as tokenId
in the _batchLiquidations
function.
The _batchLiquidations
function makes an internal call to the sumLiquidity
function. During the calculation of the liquidity
variable, the execution is reverted with the Division or modulo by 0 error since the _loan.rateIndex
is also zero for tokenId
0.
function sumLiquidity(uint256[] calldata tokenIds) internal virtual returns(uint256 liquidityTotal, uint256 collateralTotal, uint256 lpTokensPrincipalTotal, uint128[] memory tokensHeldTotal) {
address[] memory tokens = s.tokens;
uint128[] memory tokensHeld;
address cfmm = s.cfmm;
tokensHeldTotal = new uint128[](tokens.length);
(uint256 accFeeIndex,,) = updateIndex();
for(uint256 i = 0; i < tokenIds.length; i++) {
LibStorage.Loan storage _loan = s.loans[tokenIds[i]];
uint256 liquidity = uint128((_loan.liquidity * accFeeIndex) / _loan.rateIndex);
tokensHeld = _loan.tokensHeld;
lpTokensPrincipalTotal = lpTokensPrincipalTotal + _loan.lpTokens;
_loan.liquidity = 0;
_loan.initLiquidity = 0;
_loan.rateIndex = 0;
_loan.lpTokens = 0;
uint256 collateral = calcInvariant(cfmm, tokensHeld);
canLiquidate(collateral, liquidity, 950);
collateralTotal = collateralTotal + collateral;
liquidityTotal = liquidityTotal + liquidity;
for(uint256 j = 0; j < tokens.length; j++) {
tokensHeldTotal[j] = tokensHeldTotal[j] + tokensHeld[j];
_loan.tokensHeld[j] = 0;
}
}
}
NOT APPLICABLE: The GammaSwap team
confirmed that throwing the "Division or modulo by zero" error is the intended behavior.
// Low
The extra condition in the if statement described in the Code Location section poses a centralization risk. The isRestricted
function is designed to check if a protocolId
is restricted. However, the _owner
of the contract is excluded from this control. This increases the centralization of the contract.
function isRestricted(uint16 protocolId, address _owner) internal virtual view {
if(isProtocolRestricted[protocolId] == true && msg.sender != _owner) {
revert ProtocolRestricted();
}
}
RISK ACCEPTED: The GammaSwap team
accepted the risk, and they confirmed a multisig wallet will be the owner of the contract.
// Low
There is an edge case scenario of token swapping operation which leads to the division by zero
error. The beforeSwapTokens
function in the CPMMBaseLongStrategy
contract is a function which calculates the exact in and out amounts of the swap operation. If amountIn
and reserveIn
parameters of calcAmtOut()
function are equal, then the denominator is equal to zero. As a result, the swapping operation fails in this case.
function calcAmtOut(uint256 amountIn, uint256 reserveOut, uint256 reserveIn) internal view returns (uint256) {
if(reserveOut == 0 || reserveIn == 0) {
revert ZeroReserves();
}
uint256 denominator = (reserveIn - amountIn) * tradingFee1;
return (reserveOut * amountIn * tradingFee2 / denominator) + 1;
}
NOT APPLICABLE: The GammaSwap
team confirmed the delta of reserveIn
and amountIn
will not be equal to 0 for Uniswap swaps. Therefore, reaching the "Division by zero" revert is impossible in this edge case.
// Low
Contracts in-scope are missing address validation in constructors and setter functions. It is possible to configure the address(0)
, which may cause issues during execution.
For instance, if address(0)
is passed to setFeeToSetter
function, it will not be possible to change this address in the future.
Following functions are not validating, that given address is different from zero:
payee
setFeeToSetter
to
to
to
SOLVED: This finding was solved by the GammaSwap team
by requiring the payee
and setFeeToSetter
variables not to be equal to the zero address in the PositionManager
and GammaPoolFactory
contracts.
This finding is not applicable to the (to
) variables because the GammaSwap team
will allow users to send their funds to address(0)
in order to burn assets.
// Informational
// Informational
// Informational
// Informational
// Informational
The _baseRate
, _factor
, _maxApy
parameters of the LogDerivativeRateModel
contract and _baseRate
, _slope1
, _slope2
, _optimalUtilRate
of the LinearKinkedRateModel
contract are not checked to fall in the expected ranges. Some function calls might be therefore reverted due to possible arithmetic overflow issues, since no sanity checks are performed.
constructor(uint64 _baseRate, uint80 _factor, uint80 _maxApy) {
baseRate = _baseRate;
factor = _factor;
maxApy = _maxApy;
}
constructor(uint64 _baseRate, uint64 _optimalUtilRate, uint64 _slope1, uint64 _slope2) {
baseRate = _baseRate;
optimalUtilRate = _optimalUtilRate;
slope1 = _slope1;
slope2 = _slope2;
}
SOLVED: The GammaSwap team
fixed this issue by adding sanity checks in the above contracts.
Commit ID:
v1-strategies::db000bdedb35ce0c7c6e93f892f552c21be286d4
// Informational
In for loops, the variable i
is incremented using i++
. It is known that in loops, using ++i
costs less gas per iteration than i++
.
It has also been detected that some uint256
variables are initialized to 0. These variables are already initialized to 0 by default, so uint256 i = 0
would reassign the 0 to i
which wastes gas.
In addition, starting from pragma 0.8.0, adding unchecked
keyword for arithmetical operations can reduce gas usage on contracts where underflow/underflow is unrealistic.
SOLVED: The GammaSwap team
solved this issue by optimizing the for loops in contracts.
Commit IDs:
// Informational
The _name
and _symbol
variables in the GammaPoolERC721
contract were designed to be set only once. The immutable
keyword consumes less gas. It might be useful to declare these variables as immutable
to optimize gas usage in the protocol.
string private _name;
// Token symbol
string private _symbol;
SOLVED: This finding was solved by the GammaSwap team
by moving the _name
and _symbol
variables to the PositionManager
contract and declaring them constant
.
Commit ID:
v1-periphery::4e2b377de6888c1f7845cbd9485605ecfca053f1
// Informational
There are a few unused imports on the code base. These imports should be cleaned up from the code if they have no purpose. Clearing these imports will increase the readability of contracts.
SOLVED: This finding was solved by the GammaSwap team
by removing unused imports from the above contracts.
Commit ID:
v1-strategies::8e9aa10f9a6ece1c4fb0b8b1b25a1f8e7644bcc0
// Informational
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).
SOLVED: This finding was fixed by the GammaSwap team
by adding natspecs to all contracts.
// Informational
Open TO-DOs can point to architecture or programming issues that still need to be resolved. Often these kinds of comments indicate areas of complexity or confusion for developers. This provides value and insight to an attacker who aims to cause damage to the protocol.
References:
SOLVED: This issue was solved by the GammaSwap team
by closing open TODOs.
Commit IDs:
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.
Core contracts:
Periphery contracts:
Strategies contracts:
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 the smart contracts and sent the compiled results to the analyzers in order to locate any vulnerabilities.
The findings obtained as a result of the MythX scan were examined and the findings were not included in the report because they were false positive.
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