Platform: Code4rena
Start Date: 13/03/2023
Pot Size: $72,500 USDC
Total HM: 33
Participants: 35
Period: 7 days
Judge: Dravee
Total Solo HM: 16
Id: 222
League: ETH
Rank: 13/35
Findings: 1
Award: $956.38
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: rbserver
Also found by: 0xSmartContract, DadeKuma, GalloDaSballo, PaludoX0, RaymondFam, Rolezn, Sathish9098, adriro, auditor0517, bin2chen, btk, joestakey, juancito
956.382 USDC - $956.38
Total Low issues |
---|
Risk | Issues Details | Number |
---|---|---|
[L-01] | Critical changes should use-two step procedure | 2 |
[L-02] | Use safeMint instead of mint for ERC721 | 1 |
[L-03] | The first timestamp should be based on when funding rate was last updated | 1 |
[L-04] | Integer overflow by unsafe casting | 1 |
[L-05] | Missing checks for address(0) | 1 |
[L-06] | Use require() instead of assert() | 2 |
[L-07] | Lack of event emit | 3 |
[L-08] | Fees are not capped | 3 |
[L-09] | The nonReentrant modifier should occur before all other modifiers | 21 |
[L-10] | Loss of precision due to rounding | 2 |
[L-11] | Value is not validated to be different than the existing one | 20 |
[L-12] | ShortToken implmentation is not fully up to EIP-721's specification | 1 |
[L-13] | Avoid shadowing inherited state variables | 2 |
[L-14] | Lack of access control in setVault() function leave it vulnerable to frontrunning attack | 1 |
Total Non-Critical issues |
---|
Risk | Issues Details | Number |
---|---|---|
[NC-01] | Include return parameters in NatSpec comments | All Contracts |
[NC-02] | Function writing does not comply with the Solidity Style Guide | All Contracts |
[NC-03] | Mark visibility of init() functions as external | 1 |
[NC-04] | Reusable require statements should be changed to a modifier | 3 |
[NC-05] | The protocol should include NatSpec | All Contracts |
[NC-06] | Constants in comparisons should appear on the left side | 37 |
[NC-07] | Use a more recent version of solidity | All Contracts |
[NC-08] | Contracts should have full test coverage | All Contracts |
[NC-09] | Add a timelock to critical functions | 12 |
[NC-10] | Need Fuzzing test | All Contracts |
[NC-11] | Lock pragmas to specific compiler version | All Contracts |
[NC-12] | Consider using delete rather than assigning zero to clear values | 11 |
[NC-13] | For functions, follow Solidity standard naming conventions | 4 |
[NC-14] | Events that mark critical parameter changes should contain both the old and the new value | 12 |
[NC-15] | Add NatSpec Mapping comment | 8 |
[NC-16] | Use SMTChecker |
The following contracts (KangarooVault, LiquidityPool) have a function that allows them to change the feeReceipient
to a different address. If the sender accidentally uses an invalid address for which they do not have the private key, then the protocol will lose fees.
function setFeeReceipient(address _feeReceipient) external requiresAuth { feeReceipient = _feeReceipient; }
function setFeeReceipient(address _feeReceipient) external requiresAuth { require(_feeReceipient != address(0x0)); emit UpdateFeeReceipient(feeReceipient, _feeReceipient); feeReceipient = _feeReceipient; }
Consider adding two step procedure on the critical functions where the first is announcing a pending fee receipient and the new address should then claim its ownership.
safeMint
instead of mint for ERC721Users could lost their NFTs if msg.sender
is a contract address that does not support ERC721
, the NFT can be frozen in the contract forever.
As per the documentation of EIP-721:
A wallet/broker/auction application MUST implement the wallet interface if it will accept safe transfers.
As per the documentation of ERC721.sol by Openzeppelin:
_mint(trader, positionId);
Use _safeMint
instead of mint
to check received address support for ERC721 implementation.
The first timestamp should be based on when funding rate was last updated, so that an appropriate duration of the cycle occurs, rather than during deployment.
uint256 public fundingLastUpdated = block.timestamp;
Keep in mind that the version of solidity used, despite being greater than 0.8, does not prevent integer overflows during casting, it only does so in mathematical operations.
It is necessary to safely convert between the different numeric types.
uint256 newNormalizationFactor = normalizationFactor.mulWadDown(uint256(normalizationUpdate));
normalizationFactor = normalizationFactor.mulWadDown(uint256(normalizationUpdate));
uint256 marginWithdrawing = uint256(-marginDelta);
uint256 marginAdding = uint256(marginDelta);
positionData.lastSizeDelta = uint256(int256(position.size));
uint256 currentSize = uint256(int256(position.size));
positionData.lastSizeDelta = uint256(int256(position.size));
uint256 currentSize = uint256(int256(position.size));
uint256 availableFunds = uint256(int256(totalFunds) - usedFunds);
totalValue -= uint256((int256(amountOwed) + usedFunds));
require(usedFunds <= 0 || totalFunds >= uint256(usedFunds));
require(usedFunds <= 0 || totalFunds >= uint256(usedFunds));
require(usedFunds <= 0 || totalFunds >= uint256(usedFunds));
return uint256(signedAbs(x));
Use OpenZeppelin safeCast library.
address(0)
Check of address(0)
to protect the code from (0x0000000000000000000000000000000000000000)
address problem just in case. This is best practice or instead of suggesting that they verify _address != address(0)
, you could add some good NatSpec comments explaining what is valid and what is invalid and what are the implications of accidentally using an invalid address.
function setFeeReceipient(address _feeReceipient) external requiresAuth {
Add checks for address(0)
when assigning values to address state variables.
require()
instead of assert()
Assert should not be used except for tests, require should be used
Prior to Solidity 0.8.0, pressing a confirm consumes the remainder of the process's available gas instead of returning it, as request()/revert()
did.
The big difference between the two is that the assert()
function when false, uses up all the remaining gas and reverts all the changes made.
Meanwhile, a require()
statement when false, also reverts back all the changes made to the contract but does refund all the remaining gas fees we offered to pay. This is the most common Solidity function used by developers for debugging and error handling.
Assertion should be avoided even after solidity version 0.8.0, because its documentation states "The Assert function generates an error of type Panic(uint256)
. Code that works properly should never Panic, even on invalid external input. If this happens, you need to fix it in your contract. there's a mistake".
assert(queuedDepositHead + count - 1 < nextQueuedDepositId);
assert(queuedWithdrawalHead + count - 1 < nextQueuedWithdrawalId);
Use require()
instead of assert()
The below methods do not emit an event when the state changes, something that it's very important for dApps and users.
function setFeeReceipient(address _feeReceipient) external requiresAuth { feeReceipient = _feeReceipient; }
function setVault(address _vault) external { if (vault != address(0x0)) { revert(); } vault = _vault; }
receive() external payable { (bool success,) = feeReceipient.call{value: msg.value}(""); require(success); }
Emit event.
Fees are not capped, which makes the protocol less decentralized. Thus, increasing the likelihood of losing trust from users.
function setFees(uint256 _depositFee, uint256 _withdrawalFee) external requiresAuth { emit UpdateFees(depositFee, _depositFee, withdrawalFee, _withdrawalFee); depositFee = _depositFee; withdrawalFee = _withdrawalFee; }
function setBaseTradingFee(uint256 _baseTradingFee) external requiresAuth { emit UpdateBaseTradingFee(baseTradingFee, _baseTradingFee); baseTradingFee = _baseTradingFee; }
function setDevFee(uint256 _devFee) external requiresAuth { emit UpdateDevFee(devFee, _devFee); devFee = _devFee; }
nonReentrant
modifier should occur before all other modifiersThis is a best-practice to protect against reentrancy in other modifiers.
function openPosition(uint256 amt, uint256 minCost) external requiresAuth nonReentrant {
function closePosition(uint256 amt, uint256 maxCost) external requiresAuth nonReentrant {
function clearPendingOpenOrders(uint256 maxCost) external requiresAuth nonReentrant {
function clearPendingCloseOrders(uint256 minCost) external requiresAuth nonReentrant {
function transferPerpMargin(int256 marginDelta) external requiresAuth nonReentrant {
function addCollateral(uint256 additionalCollateral) external requiresAuth nonReentrant {
function removeCollateral(uint256 collateralToRemove) external requiresAuth nonReentrant {
function executePerpOrders(bytes[] calldata priceUpdateData) external payable requiresAuth nonReentrant {
function openLong(uint256 amount, address user, bytes32 referralCode) external override onlyExchange nonReentrant returns (uint256 totalCost) {
function closeLong(uint256 amount, address user, bytes32 referralCode) external override onlyExchange nonReentrant returns (uint256 totalCost) {
function openShort(uint256 amount, address user, bytes32 referralCode) external override onlyExchange nonReentrant returns (uint256 totalCost) {
function closeShort(uint256 amount, address user, bytes32 referralCode) external override onlyExchange nonReentrant returns (uint256 totalCost) {
function liquidate(uint256 amount) external override onlyExchange nonReentrant {
function hedgePositions() external override requiresAuth nonReentrant {
function rebalanceMargin(int256 marginDelta) external requiresAuth nonReentrant {
function increaseMargin(uint256 additionalMargin) external requiresAuth nonReentrant {
function placeQueuedOrder() external requiresAuth nonReentrant {
function executePerpOrders(bytes[] calldata priceUpdateData) external payable requiresAuth nonReentrant {
function collectCollateral(address collateral, uint256 positionId, uint256 amount) external onlyExchange nonReentrant {
function sendCollateral(uint256 positionId, uint256 amount) external override onlyExchange nonReentrant {
function liquidate(uint256 positionId, uint256 debt, address user) external override onlyExchange nonReentrant returns (uint256 totalCollateralReturned) {
Use the nonReentrant
modifier first.
Loss of precision due to the nature of arithmetics and rounding errors.
uint256 region = size / standardSize;
maxDebt = position.shortAmount / 2;
Value is not validated to be different than the existing one. Queueing the same value will cause multiple abnormal events to be emitted, will ultimately result in a no-op situation.
function setSkewNormalizationFactor(uint256 _skewNormalizationFactor) external requiresAuth {
function setMaxFundingRate(uint256 _maxFundingRate) external requiresAuth {
function setFeeReceipient(address _feeReceipient) external requiresAuth {
function setFees(uint256 _depositFee, uint256 _withdrawalFee) external requiresAuth {
function setBaseTradingFee(uint256 _baseTradingFee) external requiresAuth {
function setDevFee(uint256 _devFee) external requiresAuth {
function setPerpPriceImpactDelta(uint256 _perpPriceImpactDelta) external requiresAuth {
function setMinDelays(uint256 _minDepositDelay, uint256 _minWithdrawDelay) external requiresAuth {
function setStatusFunction(bytes32 key, bool status) public requiresAuth {
function setFeeReceipient(address _feeReceipient) external requiresAuth {
function setFees(uint256 _performanceFee, uint256 _withdrawalFee) external requiresAuth {
function setSynthetixTracking(bytes32 _code) external requiresAuth {
function setReferralCode(bytes32 _code) external requiresAuth {
function setMinDepositAmount(uint256 _minAmt) external requiresAuth {
function setMaxDepositAmount(uint256 _maxAmt) external requiresAuth {
function setDelays(uint256 _depositDelay, uint256 _withdrawDelay) external requiresAuth {
function setPriceImpactDelta(uint256 _delta) external requiresAuth {
function setLeverage(uint256 _lev) external requiresAuth {
function setCollRatio(uint256 _ratio) external requiresAuth {
Add a require()
statement to check that the new value is different than the current one.
tokenURI()
return an empty string which could cause unexpected behavior in the future due to non-compliance with EIP-721 standard.
function tokenURI(uint256 tokenId) public view override returns (string memory) { return ""; }
tokenURI()
should return something and throws if tokenId
is not a valid NFT.
In VaultToken.sol
there is a local variables named name
symbol
, but there is state variables in the inherited contract ( ERC20.sol
) with the same name. This use causes compilers to issue warnings, negatively affecting checking and code readability.
constructor(string memory name, string memory symbol) ERC20(name, symbol, 18) {}
Avoid using variables with the same name.
setVault()
function leave it vulnerable to frontrunning attacksetVault()
function has no access control and can be called by anyone which leave it vulnerable to frontrunning attack, and since this function can only be set once, thus it may force a redeployment.
function setVault(address _vault) external { if (vault != address(0x0)) { revert(); } vault = _vault; }
Add access control to setVault()
to protect the function and make more robust.
If Return parameters are declared, you must prefix them with /// @return
.
Some code analysis programs do analysis by reading NatSpec details, if they can't see the @return
tag, they do incomplete analysis.
Include @return
parameters in NatSpec comments
Solidity Style Guide
Ordering helps readers identify which functions they can call and to find the constructor and fallback definitions easier. But there are contracts in the project that do not comply with this.
Functions should be grouped according to their visibility and ordered:
constructor()
receive()
fallback()
external / public / internal / private
view / pure
Follow Solidity Style Guide.
init()
functions as externalIf someone wants to extend via inheritance, it might make more sense that the overridden init()
function calls the internal {...}_init function, not the parent public init()
function.
External instead of public would give more the sense of the init()
functions to behave like a constructor (only called on deployment, so should only be called externally)
Security point of view, it might be safer so that it cannot be called internally by accident in the child contract
It might cost a bit less gas to use external over public
It is possible to override a function from external to public ("opening it up") but it is not possible to override a function from public to external ("narrow it down").
Ref: https://github.com/OpenZeppelin/openzeppelin-contracts/issues/3750
function init( address _pool, address _powerPerp, address _exchange, address _liquidityToken, address _shortToken, address _synthetixAdapter, address _shortCollateral ) public {
Change the visibility of init()
functions to external
Reusable require statements should be changed to a modifier.
require(powerPerp.balanceOf(_to) == 0, "Receiver has long positions");
require(powerPerp.balanceOf(_to) == 0, "Receiver has long positions");
require(powerPerp.balanceOf(_to) == 0, "Receiver has long positions");
It is recommended that Solidity contracts are fully annotated using NatSpec, it is clearly stated in the Solidity official documentation.
In complex projects such as Defi, the interpretation of all functions and their arguments and returns is important for code readability and auditability.
Some code analysis programs do analysis by reading NatSpec details, if they can't see the tags (@param, @dev, @return)
, they do incomplete analysis.
Include NatSpec
comments in the codebase.
Constants in comparisons should appear on the left side, doing so will prevent typo bug.
require(holdings == 0, "Shouldn't have long positions to close short postions");
require(shortPositions == 0, "Short position must be closed before opening");
require(holdings == 0, "Long position must be closed before opening");
require(shortPositions == 0, "Shouldn't have short positions to close long postions");
require(holdings == 0, "Shouldn't have long positions to close short postions");
if (positionData.positionId == 0) {
if (positionData.positionId == 0) {
if (current.requestedTime == 0 || block.timestamp < current.requestedTime + minDepositDelay) {
if (current.requestedTime == 0 || block.timestamp < current.requestedTime + minWithdrawDelay) {
if (availableFunds == 0) {
if (totalFunds == 0) {
if (positionData.positionId == 0) {
require(positionData.pendingLongPerp == 0 && positionData.pendingShortPerp == 0);
require(delayedOrder.sizeDelta == 0);
if (positionData.positionId == 0) {
require(positionData.pendingLongPerp > 0 && positionData.pendingShortPerp == 0);
require(delayedOrder.sizeDelta == 0);
if (positionData.shortAmount == 0) {
require(positionData.positionId != 0 && positionData.pendingLongPerp == 0 && positionData.pendingShortPerp == 0);
require(delayedOrder.sizeDelta == 0);
require(positionData.pendingLongPerp == 0 && positionData.pendingShortPerp > 0);
require(delayedOrder.sizeDelta == 0);
if (positionData.shortAmount == 0) {
if (current.requestedTime == 0 || block.timestamp < current.requestedTime + minDepositDelay) {
if (current.requestedTime == 0 || block.timestamp < current.requestedTime + minWithdrawDelay) {
if (availableFunds == 0) {
if (totalFunds == 0) {
if (skew == 0) {
require(order.sizeDelta == 0);
require(shortToken.balanceOf(_to) == 0, "Receiver has short positions");
require(shortToken.balanceOf(_to) == 0, "Receiver has short positions");
if (positionId == 0) {
if (position.shortAmount == 0) {
require(powerPerp.balanceOf(_to) == 0, "Receiver has long positions");
require(powerPerp.balanceOf(_to) == 0, "Receiver has long positions");
require(powerPerp.balanceOf(_to) == 0, "Receiver has long positions");
Constants should appear on the left side:
require(0 == holdings, "Shouldn't have long positions to close short postions");
For security, it is best practice to use the latest Solidity version.
Old version of Solidity is used (^0.8.9)
, newer version can be used (0.8.19)
.
While 100% code coverage does not guarantee that there are no bugs, it often will catch easy-to-find bugs, and will ensure that there are fewer regressions when the code invariably has to be modified. Furthermore, in order to get full coverage, code authors will often have to re-organize their code so that it is more modular, so that each component can be tested separately, which reduces interdependencies between modules and layers, and makes for code that is easier to reason about and audit.
- What is the overall line coverage percentage provided by your tests?: 85
Line coverage percentage should be 100%.
It is a good practice to give time for users to react and adjust to critical changes. A timelock provides more guarantees and reduces the level of trust required, thus decreasing risk for users. It also indicates that the project is legitimate.
function setMaxFundingRate(uint256 _maxFundingRate) external requiresAuth {
function setFees(uint256 _depositFee, uint256 _withdrawalFee) external requiresAuth {
function setBaseTradingFee(uint256 _baseTradingFee) external requiresAuth {
function setDevFee(uint256 _devFee) external requiresAuth {
function setPerpPriceImpactDelta(uint256 _perpPriceImpactDelta) external requiresAuth {
function setMinDelays(uint256 _minDepositDelay, uint256 _minWithdrawDelay) external requiresAuth {
function setFees(uint256 _performanceFee, uint256 _withdrawalFee) external requiresAuth {
function setMinDepositAmount(uint256 _minAmt) external requiresAuth {
function setMaxDepositAmount(uint256 _maxAmt) external requiresAuth {
function setDelays(uint256 _depositDelay, uint256 _withdrawDelay) external requiresAuth {
function setPriceImpactDelta(uint256 _delta) external requiresAuth {
function setCollRatio(uint256 _ratio) external requiresAuth {
Consider adding a timelock to the critical changes.
As Alberto Cuesta Canada said: Fuzzing is not easy, the tools are rough, and the math is hard, but it is worth it. Fuzzing gives me a level of confidence in my smart contracts that I didn’t have before. Relying just on unit testing anymore and poking around in a testnet seems reckless now.
Ref: https://medium.com/coinmonks/smart-contract-fuzzing-d9b88e0b0a05
Use should fuzzing test like Echidna.
Pragma statements can be allowed to float when a contract is intended for consumption by other developers, as in the case with contracts in a library or EthPM package. Otherwise, the developer would need to manually update the pragma in order to compile locally.
Ethereum Smart Contract Best Practices: Lock pragmas to specific compiler version.
delete
rather than assigning zero to clear valuesThe delete
keyword more closely matches the semantics of what is being done, and draws more attention to the changing of state, which may lead to a more thorough audit of its associated logic.
current.depositedAmount = 0;
current.withdrawnTokens = 0;
current.depositedAmount = 0;
current.withdrawnTokens = 0;
positionData.premiumCollected = 0;
positionData.pendingLongPerp = 0;
positionData.premiumCollected = 0;
positionData.pendingShortPerp = 0;
positionData.positionId = 0;
positionData.premiumCollected = 0;
positionData.totalMargin = 0;
Use the delete
keyword.
The protocol don't follow solidity standard naming convention.
Ref: https://docs.soliditylang.org/en/v0.8.17/style-guide.html#naming-conventions
function signedAbs(int256 x) internal pure returns (int256) {
function abs(int256 x) internal pure returns (uint256) {
function max(int256 x, int256 y) internal pure returns (int256) {
function min(int256 x, int256 y) internal pure returns (int256) {
Follow solidity standard naming convention.
Events that mark critical parameter changes should contain both the old and the new value.
function setMaxFundingRate(uint256 _maxFundingRate) external requiresAuth {
function setFees(uint256 _depositFee, uint256 _withdrawalFee) external requiresAuth {
function setBaseTradingFee(uint256 _baseTradingFee) external requiresAuth {
function setDevFee(uint256 _devFee) external requiresAuth {
function setPerpPriceImpactDelta(uint256 _perpPriceImpactDelta) external requiresAuth {
function setMinDelays(uint256 _minDepositDelay, uint256 _minWithdrawDelay) external requiresAuth {
function setFees(uint256 _performanceFee, uint256 _withdrawalFee) external requiresAuth {
function setMinDepositAmount(uint256 _minAmt) external requiresAuth {
function setMaxDepositAmount(uint256 _maxAmt) external requiresAuth {
function setDelays(uint256 _depositDelay, uint256 _withdrawDelay) external requiresAuth {
function setPriceImpactDelta(uint256 _delta) external requiresAuth {
function setCollRatio(uint256 _ratio) external requiresAuth {
Add the old value to the event.
Add NatSpec comments describing mapping keys and values.
mapping(bytes32 => bool) public isPaused;
mapping(uint256 => QueuedDeposit) public depositQueue;
mapping(uint256 => QueuedWithdraw) public withdrawalQueue;
mapping(uint256 => QueuedDeposit) public depositQueue;
mapping(uint256 => QueuedWithdraw) public withdrawalQueue;
mapping(bytes32 => Collateral) public collaterals;
mapping(uint256 => UserCollateral) public userCollaterals;
mapping(uint256 => ShortPosition) public shortPositions;
mapping(bytes32 => bool) public isPaused;
/// @dev bytes32(Key) => bool(status) mapping(bytes32 => bool) public isPaused;
The highest tier of smart contract behavior assurance is formal mathematical verification. All assertions that are made are guaranteed to be true across all inputs → The quality of your asserts is the quality of your verification.
Ref: https://twitter.com/0xOwenThurm/status/1614359896350425088?t=dbG9gHFigBX85Rv29lOjIQ&s=19
#0 - JustDravee
2023-03-26T23:04:11Z
require(_feeReceipient != address(0x0));
(https://github.com/code-423n4/2023-03-polynomial/blob/main/src/KangarooVault.sol#L466 )Fine report
#1 - c4-judge
2023-05-03T03:28:03Z
JustDravee marked the issue as grade-b
#2 - JustDravee
2023-05-05T11:07:36Z
Compared this to other grade-Bs, and this is above in terms of quantity and quality. The signals aren't unique, but this is a good report. Will upgrade to grade-A
#3 - c4-judge
2023-05-05T11:07:41Z
JustDravee marked the issue as grade-a