Security Audit
Dec 6th, 2024
Version 1.0.0
Presented by 0xMacro
This document includes the results of the security audit for Kodiak-3's smart contract code as found in the section titled ‘Source Code’. The focus of the security audit was on incremental changes in the Kodiak core codebase. Security audit performed by the Macro security team lasted 5 audit days, started on Nov 18th and finished on Nov 25th, 2024.
The purpose of this audit is to review the source code of certain Kodiak-3 Solidity contracts, and provide feedback on the design, architecture, and quality of the source code with an emphasis on validating the correctness and security of the software in its entirety.
Disclaimer: While Macro’s review is comprehensive and has surfaced some changes that should be made to the source code, this audit should not solely be relied upon for security, as no single audit is guaranteed to catch all possible bugs.
The following is an aggregation of issues found by the Macro Audit team:
Severity | Count | Acknowledged | Won't Do | Addressed |
---|---|---|---|---|
Medium | 1 | - | - | 1 |
Low | 1 | - | - | 1 |
Code Quality | 8 | - | - | 8 |
Informational | 3 | - | - | 1 |
Kodiak-3 was quick to respond to these issues.
Our understanding of the specification was based on the following sources:
The following source code was reviewed during the audit:
85e3f306540ead4fc9632cf1c352dad2af6db77c
a3cc62552774e83ca74d2135da50705bff4cd917
Specifically, we audited the following contract within this repository:
Source Code | SHA256 |
---|---|
src/farms/FarmFactory.sol |
|
src/farms/KodiakFarm.sol |
|
src/tokens/KDK.sol |
|
src/tokens/xKDK.sol |
|
src/vaults/KodiakIsland.sol |
|
src/vaults/KodiakIslandFactory.sol |
|
src/vaults/KodiakIslandWithRouter.sol |
|
src/vaults/abstract/KodiakIslandStorage.sol |
|
src/vaults/abstract/OwnableUninitialized.sol |
|
Note: This document contains an audit solely of the Solidity contracts listed above. Specifically, the audit pertains only to the contracts themselves, and does not pertain to any other programs or scripts, including deployment scripts.
Click on an issue to jump to it, or scroll down to see them all.
setLockedStakeTimeForMinAndMaxMultiplier()
We quantify issues in three parts:
This third part – the severity level – is a summary of how much consideration the client should give to fixing the issue. We assign severity according to the table of guidelines below:
Severity | Description |
---|---|
(C-x) Critical |
We recommend the client must fix the issue, no matter what, because not fixing would mean significant funds/assets WILL be lost. |
(H-x) High |
We recommend the client must address the issue, no matter what, because not fixing would be very bad, or some funds/assets will be lost, or the code’s behavior is against the provided spec. |
(M-x) Medium |
We recommend the client to seriously consider fixing the issue, as the implications of not fixing the issue are severe enough to impact the project significantly, albiet not in an existential manner. |
(L-x) Low |
The risk is small, unlikely, or may not relevant to the project in a meaningful way. Whether or not the project wants to develop a fix is up to the goals and needs of the project. |
(Q-x) Code Quality |
The issue identified does not pose any obvious risk, but fixing could improve overall code quality, on-chain composability, developer ergonomics, or even certain aspects of protocol design. |
(I-x) Informational |
Warnings and things to keep in mind when operating the protocol. No immediate action required. |
(G-x) Gas Optimizations |
The presented optimization suggestion would save an amount of gas significant enough, in our opinion, to be worth the development cost of implementing it. |
In the KodiakFarm
contract, the initialize()
function is expected to be executed only once to perform an important initial contract setup. This setup includes setting the owner, corresponding factory and factoryOwner
, and various reward configuration parameters. To prevent multiple calls, initialize checks if stakingToken
is already set; if not, it sets it and allows initial configuration.
function initialize(
address _owner,
address _stakingToken,
address[] memory _rewardTokens,
address[] memory _rewardManagers,
uint256[] memory _rewardRates,
bytes calldata /*_data*/
) external nonReentrant {
require(address(stakingToken) == address(0), "Farm: Already initialized");
stakingToken = IERC20(_stakingToken);
...
}
However, neither the current implementation nor its caller in FarmFactory.deployFarm()
checks if the provided _stakingToken
is a non-zero address. Due to this, a malicious user may deploy a new KodiakFarm through the factory, which will be registered with KodiakFarm but may have a different factory value and corresponding factory owner. In addition, a malicious user may change the owner of previously created KodiakFarm instances without proper _stakingToken
set.
This can be achieved by calling FarmFactory.deployFarm()
with 0 address for _stakingToken
, followed by the direct call to KodiakFarm.initialize()
on a specific KodiakFarm instance.
Remediations to Consider
_stakingToken
is a non-zero address.With the most recent changes in the KodiakFarm
contract, the stakingToken
address value is not guaranteed to be a token address under Kodiak system control. As a result, the stakingToken
implementation may not comply with the ERC20 standard implementation.
For example, an operation may succeed when a token transfer fails as it returns a false value, which is not handled in the _withdrawLocked()
implementation.
Remediations to Consider
In the KodiakFarm
contract, the _getReward()
internal function processes and transfers token rewards. Processing is performed within a loop and involves unnecessary transfer calls even when the reward amount is 0.
function _getReward(address user) internal updateRewardAndBalance(user, true) returns (uint256[] memory rewards_before) {
require(!rewardsCollectionPaused, "Farm: Rewards emergency paused, use emergencyWithdraw if necessary");
// Update the rewards array and distribute rewards
rewards_before = new uint256[](rewardTokens.length);
for (uint256 i = 0; i < rewardTokens.length; i++) {
rewards_before[i] = rewards[user][i];
rewards[user][i] = 0;
TransferHelper.safeTransfer(rewardTokens[i], user, rewards_before[i]);
emit RewardPaid(user, rewards_before[i], rewardTokens[i]);
}
lastRewardClaimTime[user] = block.timestamp;
}
To reduce fault probability (tokens that revert on a 0 amount transfer) and gas cost, consider skipping 0 amount token transfers with a corresponding check within the loop.
The KodiakIslandStorage
contract inherits from the custom OwnableUninitialized
implementation. On the other hand, KodiakFarm
is already relying on OZ’s Ownable, which is already a standard for simple access control implementation.
Consider replacing the custom implementation with the OZ’s Ownable in KodiakIslandStorage
implementation for consistency and easier future maintenance.
This is ok, the custom OwnableUnitiatilized is used to effectively rename the owner to “manager” and could be useful in future upgrades to separate owner / manager etc - will leave as is.
setLockedStakeTimeForMinAndMaxMultiplier()
In the KodiakFarm
contract, there is now an unnecessary check in the setLockedStakeTimeForMinAndMaxMultiplier()
function due to recent updates.
require(_lock_time_for_max_multiplier >= 1, "Mul max time must be >= 1");
This check is unnecessary since two subsequent checks have already enforced it.
require(_lock_time_min >= 1, "Mul min time must be >= 1");
require(_lock_time_for_max_multiplier >= _lock_time_min, "Mul max time must be >= min time");
Consider removing it.
Fixed by removing:
require(_lock_time_min >= 1, "Mul min time must be >= 1");
Since
lock_time_min = 0
is actually a valid case.
The xKDK:mint()
literal constant value of 100e6 ether enforces the maximum total supply of the xKDK
token. This constant is meant to match the value of the KDK:MAX_SUPPLY
constant, which currently has the same value of 100e6 ether.
However, since KDK.sol
will be deployed in the future and the total supply may change from the one planned, consider making the limit value in xKDK:mint()
admin configurable to prevent divergence.
In the KodiakIsland
, KodiakIslandWithStorage
, and KodiakIslandWithRouter
contracts, multiple functions use the literal constant value of 10000 in checks and calculations.
Consider replacing this with BPS_PRECISION or FEE_PRECISION constant.
Related to
xKDK.mint()
this is fine - business decision, won’t change it by an order of magnitude or more, better to have it be immutable.Related to 10000 constant, also fine - tried it the other way and found that 10000 next to variables called
xyzBPS
is more readable than calling itBPS_PRECISION
The IXKdkToken
interface does not declare two recently added functions accessible to the contract owner. On the other hand, updateWhitelister()
, also an admin function, is present.
Consider updating the interface with two corresponding function declarations.
KodiakFarm
's top-level contract natspec documenting capabilities for factoryOwner
are inadequate as they do not mention that factoryOwner
is practically a super admin, with a super set of privileges available to the owner and token reward managers.
There is an obsolete inline comment in stakeLocked()
**function referencing the old variable source_address
// Pull the tokens from the source_address
TransferHelper.safeTransferFrom(address(stakingToken), user, address(this), liquidity);
FarmFactory
contract imports and inherits the ReentrancyGuard
contract. However, these contract features are not used. Consider removing them.KodiakIslandStorage
contract imports the test artifact forge-std/console.sol
. Consider removing it from production deployments.In the KodiakFarm
, the following events would benefit from having indexed attributes:
Kodiak’s FarmFactory owner has extensive privileges for each deployed KodiakFarm instance. FarmFactory owner privileges are superset of the KodiakFarm owner privileges and related reward token managers for the particular KodiakFarm instance. The only exception related to available privileges, is that only the KodiakFarm owner may invoke startFarm()
function.
These privileges provide factory owner with the following capabilities:
setWithdrawalsPaused(true)
)setRewardsCollectionPaused(true)
)setStakingPaused(true)
)setRewardsDuration
)setGreylist
)setStakesUnlocked(true)
)The factory owner controls the contract's operation and user assets. While some of these powers are necessary for emergencies, they represent a significant centralization risk. Users should know that their funds could be temporarily frozen or their rewards manipulated if the factory owner is compromised.
Recommended improvements include:
Upon review - we decided to decentralize further and remove the “factoryOwner” role entirely. Now, all controls are done by the farmOwner, which is the farm deployer - there is no control over the farms by “factoryOwner” (i.e. Kodiak team).
Also, acknowledging that the ability to permanently lock user’s funds via setting withdrawalsPaused is too big of a risk to our users, especially when there is no control over who is deploying the farm and the security characteristics of the farm Owner. Therefore, the ability to lock user withdrawals by pausing has been removed entirely.
We consider it an acceptable centralization risk for users to forfeit reward tokens as long as the stake can be withdrawn no matter what.
In the KodiakIslandWithRouter
contract, worstAmountOut()
calculates min output amount for the default slippage check. The system will stop processing the transaction if the provided min amount out is less or equal to the calculated one. It will revert with an indication to the manager to provide a more appropriate minimum amount out value.
However, there is an implementation error in how worstAmountOut is calculated. In the implementation, slippageBPS
(default value 5%) is applied directly to the avgSqrtPriceX96
(average square root price) instead of the price itself. As a result, the calculated worstAmountOut would be less than the expected value.
For example, with a price of 4.0 (as defined in test_WorstAmountOut_BasicZeroForOne
test) and slippage of 1000 (or 10%) worstAmountOut should be 3.6 for a provided amount of 1e18. However, the calculated value by the current implementation is 3.24, which is incorrect.
Remediations to Consider
averageSqrtPriceX96
properly. Consider applying a sqrt of slippageBPS to avgSqrtPriceX96
instead of directly applying slippageBPS.Not an issue, aware and intentional that the slippage applies to sqrtP rather than P - decided to keep it simple rather than apply too many transformations as its intended to be a backstop (and cleaner as it’s the dimension in which the univ3 logic operates).
In the KodiakIslandWithRouter
contract, getAvgPrice()
retrieves TWAP from the Uniswap V3 pool. This function is called from executiveRebalanceWithRouter()
with the compounderSlippageInterval
variable, which the manager can configure. The compounderSlippageInterval
defines the interval value for the TWAP.
However, when compounderSlippageInterval
is greater than the oldest observation on the oracle, pool.observe()
call in getAvgPrice()
will fail causing executiveRebalanceWithRouter()
not to be able to execute successfully.
Remediations to Consider
No need to (spend extra gas) to have graceful fallback logic since this would just fail on simulation with the error msg, and in this case since
executiveRebalanceWithRouter
cannot be called with all the adequate protections that choices are to either wait to have adequate recent pricing to have a good twap, or call executiveRebalance which doesn’t have a twap based protection.
Macro makes no warranties, either express, implied, statutory, or otherwise, with respect to the services or deliverables provided in this report, and Macro specifically disclaims all implied warranties of merchantability, fitness for a particular purpose, noninfringement and those arising from a course of dealing, usage or trade with respect thereto, and all such warranties are hereby excluded to the fullest extent permitted by law.
Macro will not be liable for any lost profits, business, contracts, revenue, goodwill, production, anticipated savings, loss of data, or costs of procurement of substitute goods or services or for any claim or demand by any other party. In no event will Macro be liable for consequential, incidental, special, indirect, or exemplary damages arising out of this agreement or any work statement, however caused and (to the fullest extent permitted by law) under any theory of liability (including negligence), even if Macro has been advised of the possibility of such damages.
The scope of this report and review is limited to a review of only the code presented by the Kodiak-3 team and only the source code Macro notes as being within the scope of Macro’s review within this report. This report does not include an audit of the deployment scripts used to deploy the Solidity contracts in the repository corresponding to this audit. Specifically, for the avoidance of doubt, this report does not constitute investment advice, is not intended to be relied upon as investment advice, is not an endorsement of this project or team, and it is not a guarantee as to the absolute security of the project. In this report you may through hypertext or other computer links, gain access to websites operated by persons other than Macro. Such hyperlinks are provided for your reference and convenience only, and are the exclusive responsibility of such websites’ owners. You agree that Macro is not responsible for the content or operation of such websites, and that Macro shall have no liability to your or any other person or entity for the use of third party websites. Macro assumes no responsibility for the use of third party software and shall have no liability whatsoever to any person or entity for the accuracy or completeness of any outcome generated by such software.