Security Assessment for plsKDK
Table of Contents
Summary
This report presents the findings of the security assessment conducted on the smart contracts of the plsKDK project, which allows users to deposit KDK tokens and receive plsKDK tokens in return, while earning rewards from staking activities. The review was conducted between 2026-02-01 and 2026-02-14 and mitigations and acknowledgements reviewed after fixes on 2026-03-07.
Assessment Overview
- Total Issues Found: 10
- Critical: 0
- High: 0
- Medium: 1
- Low: 2
- Informational: 7
Scope
The following files were included in the scope of this audit:
| File | Description |
|---|---|
src/KDKDepositor.sol | Handles user deposits of KDK tokens |
src/KDKStaker.sol | Manages the staking of KDK tokens |
src/KDKFeeDistributor.sol | Distributes staking rewards to various parties |
src/PlsKDKToken.sol | The plsKDK ERC20 token |
src/PlsKDKStaker.sol | Staking contract for plsKDK tokens |
src/PlsKDKLPStaker.sol | Staking contract for plsKDK LP tokens |
src/KDKWhitelist.sol | Manages whitelisted addresses |
src/interfaces/IKDKDepositor.sol | Interface for KDKDepositor |
src/interfaces/IKDKStaker.sol | Interface for KDKStaker contract |
src/interfaces/IKDKFeeDistributor.sol | Interface for KDKFeeDistributor |
src/interfaces/IPlsKDKToken.sol | Interface for PlsKDKToken |
src/interfaces/IPlsKDKStaker.sol | Interface for PlsKDKStaker |
src/interfaces/IPlsKDKLPStaker.sol | Interface for PlsKDKLPStaker |
src/interfaces/IKDKWhitelist.sol | Interface for KDKWhitelist |
src/interfaces/IKodiakRewards | Interface for KodiakRewards |
src/interfaces/ITokenMinterMulti.sol | Interface for TokenMinterMulti |
src/interfaces/IXKdkTokenUsage.sol | Interface for IXKdkTokenUsage |
Repository: https://github.com/PlutusDao/plsKDK
Assessed Commit: d7ec9a99adc7bbb5c6002a370cec45b3c717614d
Methodology
The audit process involved a combination of:
- Manual Code Review: Line-by-line inspection of logic, state changes, and external calls.
- Automated Static Analysis: Using Slither to detect common vulnerabilities.
- Unit & Integration Testing: Verifying test coverage and running specific attack vectors against the local fork.
- Mathematical Analysis: Verifying formulas for fees, rewards, and tokenomics.
Severity Classification
| Level | Description |
|---|---|
| Critical | A vulnerability that can lead to significant loss of assets or contract state manipulation. |
| High | A vulnerability that can lead to loss of assets or contract state manipulation, but requires specific conditions, has limited scope, or is difficult to exploit. |
| Medium | A vulnerability that can lead to contract state manipulation without the loss of assets, violates implementation requirements, or major deviations from best practices. |
| Low | Minor issues, deviations from best practices, or risks that do not result in immediate loss of assets. |
| Informational | Code style and readability improvements, governance risks. |
Findings Summary
| ID | Title | Severity | Status |
|---|---|---|---|
| [M-01] | Reward distribution will halt if either PlsKDKLPStaker or PlsKDKStaker pool has no tokens | Medium | Acknowledged, mitigation planned |
| [L-01] | Accounting logic causes any pre-existing plsKDK token on KDKStaker contract to be distributed along the harvested rewards | Low | Open |
| [L-02] | Modifier nonReentrant applied to internal _deposit() instead of external entry points | Low | Resolved |
| [I-01] | Inconsistent logic in KDKStaker mixes total balances and harvested delta | Informational | Open |
| [I-02] | KDKDepositor function deposit() performs token transfer before validating minimum amount | Informational | Open |
| [I-03] | Inconsistent naming convention for variables and function parameters | Informational | Open |
| [I-04] | OpenZeppelin library version mismatch between OpenZeppelin-contracts and OpenZeppelin-contracts-upgradeable | Informational | Open |
| [I-05] | Use UUPSUpgradeable and Initializable from @openzeppelin/contracts instead of @openzeppelin/contracts-upgradeable | Informational | Resolved |
| [I-06] | setLpStakerPercentage in KDKFeeDistributor allows setting the lpStakerPercentage value to over 100 | Informational | Resolved |
| [I-07] | KDKDepositor redundant kdkStaker variable assignment | Informational | Resolved |
Detailed Findings
Critical Severity
No critical severity vulnerabilities were identified during the assessment.
High Severity
No high severity vulnerabilities were identified during the assessment.
Medium Severity
[M-01] Reward distribution will halt if either PlsKDKLPStaker or PlsKDKStaker pool has no tokens
Description: The KDKFeeDistributor contract responsible for harvesting rewards and distributing them downstream calls notifyRewardAmount() function in PlsKDKLPStaker and PlsKDKStaker. The function performs a check in both contracts that if their staking pool is empty , the transaction will be reverted. This effectively freezes reward distribution for both pools if either one is empty.
Location: src/PlsKDKLPStaker.sol line(s) 154-161; src/PlsKDKStaker line(s) 146-163
function notifyRewardAmount(address _rewardsToken, uint256 reward)
external
nonReentrant
onlyRole(REWARD_DISTRIBUTOR_ROLE)
updateReward(address(0), _rewardsToken)
{
if (_totalSupply == 0) revert PLS_KDK_Staker_ZeroTotalSupply();
Recommendation: Instead of reverting, consider allowing the function to proceed and ensure the logic accounts for zero supply.
Resolution: Acknowledged, will be mitigated with a process to ensure that contracts contain tokens before enabling rewards and systematic fix issued in upgrades to all plsASSETs later.
Low Severity
[L-01] Accounting logic causes any pre-existing plsKDK token on KDKStaker contract to be distributed along the harvested rewards
Description: The handleClaim() function in KDKStaker.sol calculates the amount of plsKDK to distribute by subtracting the initial xKDK balance from the final plsKDK balance. This is caused by the switch of the token tracked by rewardTokens list from xKDK to plsKDK and keeping the original xKDK balance. Together with I-01, this means that if the contract receives extra xKDK tokens outside harvests, they will be minted into plsKDK during the next harvest, and distributed at the next harvest after that. This differs from other token types, which if received outside harvests, will stay stuck on the contract, and require manual recovery by an admin using the recoverErc20.
Location: src/KDKStaker.sol line(s) 72-83, 97-98
for (uint256 i; i < length; i++) {
rewardTokens[i] = kodiakRewards.distributedToken(i);
if (rewardTokens[i] == address(0)) {
revert KDK_STAKER_INVALID_DISTRIBUTED_TOKEN();
}
rewardAmounts[i] = IERC20(rewardTokens[i]).balanceOf(address(this));
if (rewardTokens[i] == address(xKdkToken)) {
rewardTokens[i] = address(plsKDK);
}
}
...
rewardAmounts[i] = IERC20(rewardTokens[i]).balanceOf(address(this)) - rewardAmounts[i];
IERC20(rewardTokens[i]).approve(msg.sender, rewardAmounts[i]);
Recommendation: Verify whether it is intended that the KDKStaker sweeps all available plsKDK tokens, including the pre-existing balances, during harvest. If not intended, update the logic to distribute only the amount of plsKDK equal to the harvested xKDK.
[L-02] Modifier nonReentrant applied to internal _deposit() instead of external entry points
Description The modifier nonReentrant is applied to internal _deposit() function instead of the external entry points deposit(), depositAll(), and handleDepositFor(). This pattern renders the re-entrancy guard ineffective for the initial token transfer. While the current logic doesn't seem to be vulnerable to asset theft since KDK doesn't have transfer hooks (similar to e.g. ERC-777 tokens), the re-entrancy guard use deviates from best practices.
Location src/KDKDepositor.sol line(s) 48, 54, 61, 76
function deposit(uint256 _amount) external override whenNotPaused {
...
function depositAll() external override whenNotPaused {
...
function handleDepositFor(address _user, uint256 _amount) external override whenNotPaused onlyRole(HANDLER_ROLE) {
...
function _deposit(address _user, uint256 _amount) internal nonReentrant {
Recommendation: Move the nonReentrant modifier to the external functions deposit(), depositAll(), and handleDepositFor().
Informational Severity
[I-01] Inconsistent logic in KDKStaker mixes total balances and harvested delta
Description: In the handleClaim() function in KDKStaker.sol, the contract calculates the amount of xKDK rewards harvested (xKdkTokenDelta) by comparing the contract's balance before and after calling kodiakRewards.harvestAllRewards(), but uses total contract balance xKdkTokenAmount for subsequent _stake() and plsKDK.mint() function calls. Although this acts as a "sweep" for any pre-existing xKDK on the contract, it may not be intended behavior when chained with L-01.
Location: src/KDKStaker.sol line(s) 87-89
if (xKdkTokenDelta != 0) {
_stake(xKdkTokenAmount);
plsKDK.mint(address(this), xKdkTokenAmount);
Recommendation: Align the implementation with the intention, if intended behavior is to "sweep" excess xKDK into plsKDK, remove the xKdkTokenDelta calculation to save gas.
[I-02] KDKDepositor function deposit() performs token transfer before validating minimum amount
Description: Functions deposit(), depositAll(), and handleDepositFor() transfer tokens to contract before validating that the amount meets the MIN_DEPOSIT requirement. The validation takes place in the internal _deposit() function after token transfer. This violated the standard Checks-Effects-Interaction pattern. While the transfer will revert and no funds will be lost, it results in unnecessary gas consumption if the validation fails.
Location: src/KDKDepositor.sol line(s) 48-50, 54-57, 61-65, 76-77
function deposit(uint256 _amount) external override whenNotPaused {
_isEligibleSender();
kdkToken.safeTransferFrom(msg.sender, address(kdkStaker), _amount);
...
function depositAll() external override whenNotPaused {
_isEligibleSender();
uint256 _amount = kdkToken.balanceOf(msg.sender);
kdkToken.safeTransferFrom(msg.sender, address(kdkStaker), _amount);
...
function handleDepositFor(address _user, uint256 _amount) external override whenNotPaused onlyRole(HANDLER_ROLE) {
if (!whitelist.isWhitelisted(_user)) {
revert KDK_DEPOSITOR_UNAUTHORIZED();
}
kdkToken.safeTransferFrom(msg.sender, address(kdkStaker), _amount);
...
function _deposit(address _user, uint256 _amount) internal nonReentrant {
if (_amount < MIN_DEPOSIT) revert KDK_DEPOSITOR_INVALID_AMOUNT();
Recommendation: Move the MIN_DEPOSIT check to the external functions.
[I-03] Inconsistent naming convention for variables and function parameters
Description: The codebase mixes different naming conventions for variables and function parameters, some examples are listed below.
Location: src/PlsKDKStaker.sol line(s) 45 ; src/KDKDepositor.sol: line(s) 21-25
//PlsKDKStaker function parameters
function initialize(address _plsKDKToken, address admin) public initializer {
//KDKDepositor state variables
IERC20 public override kdkToken;
IKDKStaker public override kdkStaker;
ITokenMinterMulti public override plsKDK;
IPlsKDKWhitelist public override whitelist;
uint256 public override MIN_DEPOSIT;
Recommendation: Adopt a strict naming convention (e.g. by following the standard Solidity style guide) and use it consistently across all contracts.
[I-04] OpenZeppelin library version mismatch between OpenZeppelin-contracts and OpenZeppelin-contracts-upgradeable
Description: The project utilizes both the standard @openzeppelin/contracts and @openzeppelin/contracts-upgradeable libraries. Based on foundry.lock, the contracts use version 5.5.0 of @openzeppelin/contracts and 5.4.0 of @openzeppelin/contracts-upgradeable. Although no immediate issues were identified during the assessment, this configuration increases the risk of unexpected behavior due to internal changes, bug fixes, or feature differences between releases, and aligning the versions is considered best practice.
Recommendation: Update dependencies so both packages use the same release version to maintain consistency with upstream OpenZeppelin releases.
[I-05] Use UUPSUpgradeable and Initializable from @openzeppelin/contracts instead of @openzeppelin/contracts-upgradeable
Description: According to OpenZeppelin contracts best practices starting from @openzeppelin/contracts version 5.5.0, it is recommended to import these proxy utilities from the standard library.
Recommendation: Change the import path for UUPSUpgradeable and Initializable to the standard OpenZeppelin library.
[I-06] setLpStakerPercentage in KDKFeeDistributor allows setting the lpStakerPercentage value to over 100
Description: The KDKFeeDistributor contract allows the DEFAULT_ADMIN_ROLE to set the lpStakerPercentage via the setLpStakerPercentage() function. There is no validation to ensure this value does not exceed 100%. If the admin accidentally sets this value higher, reward distribution will revert.
Location: src/KDKFeeDistributor.sol: line(s) 69-71
function setLpStakerPercentage(uint256 _lpStakerPercentage) external override onlyRole(DEFAULT_ADMIN_ROLE) {
lpStakerPercentage = _lpStakerPercentage;
}
Recommendation: Add validation to ensure the percentage is within valid bounds.
[I-07] KDKDepositor redundant kdkStaker variable assignment
Description: In the initialize function of KDKDepositor.sol, the kdkStaker state variable is assigned twice with the same input value. The second assignment is redundant and serves no purpose.
Location: src/KDKDepositor.sol: line(s) 37, 42
kdkStaker = IKDKStaker(_kdkStaker);
...
kdkStaker = IKDKStaker(_kdkStaker);
Recommendation: Remove the second assignment to clean up the code and save a small amount of deployment gas.
Disclaimer
This report is provided for informational purposes only and does not constitute investment advice. The security assessment was conducted using currently available tools and knowledge. While every effort has been made to identify potential vulnerabilities, this report does not guarantee that the smart contracts are free from all bugs or security risks. The auditor assumes no liability for any loss of funds or damages resulting from the use of the audited code.