Back to Audits
PlutusAudited by Bulwark Security2026-03-07v1.1

Security Assessment for plsKDK

Table of Contents

  1. Summary
  2. Scope
  3. Methodology
  4. Severity Classification
  5. Findings Summary
  6. Detailed Findings
  7. Disclaimer

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:

FileDescription
src/KDKDepositor.solHandles user deposits of KDK tokens
src/KDKStaker.solManages the staking of KDK tokens
src/KDKFeeDistributor.solDistributes staking rewards to various parties
src/PlsKDKToken.solThe plsKDK ERC20 token
src/PlsKDKStaker.solStaking contract for plsKDK tokens
src/PlsKDKLPStaker.solStaking contract for plsKDK LP tokens
src/KDKWhitelist.solManages whitelisted addresses
src/interfaces/IKDKDepositor.solInterface for KDKDepositor
src/interfaces/IKDKStaker.solInterface for KDKStaker contract
src/interfaces/IKDKFeeDistributor.solInterface for KDKFeeDistributor
src/interfaces/IPlsKDKToken.solInterface for PlsKDKToken
src/interfaces/IPlsKDKStaker.solInterface for PlsKDKStaker
src/interfaces/IPlsKDKLPStaker.solInterface for PlsKDKLPStaker
src/interfaces/IKDKWhitelist.solInterface for KDKWhitelist
src/interfaces/IKodiakRewardsInterface for KodiakRewards
src/interfaces/ITokenMinterMulti.solInterface for TokenMinterMulti
src/interfaces/IXKdkTokenUsage.solInterface for IXKdkTokenUsage

Repository: https://github.com/PlutusDao/plsKDK
Assessed Commit: d7ec9a99adc7bbb5c6002a370cec45b3c717614d


Methodology

The audit process involved a combination of:

  1. Manual Code Review: Line-by-line inspection of logic, state changes, and external calls.
  2. Automated Static Analysis: Using Slither to detect common vulnerabilities.
  3. Unit & Integration Testing: Verifying test coverage and running specific attack vectors against the local fork.
  4. Mathematical Analysis: Verifying formulas for fees, rewards, and tokenomics.

Severity Classification

LevelDescription
CriticalA vulnerability that can lead to significant loss of assets or contract state manipulation.
HighA vulnerability that can lead to loss of assets or contract state manipulation, but requires specific conditions, has limited scope, or is difficult to exploit.
MediumA vulnerability that can lead to contract state manipulation without the loss of assets, violates implementation requirements, or major deviations from best practices.
LowMinor issues, deviations from best practices, or risks that do not result in immediate loss of assets.
InformationalCode style and readability improvements, governance risks.

Findings Summary

IDTitleSeverityStatus
[M-01]Reward distribution will halt if either PlsKDKLPStaker or PlsKDKStaker pool has no tokensMediumAcknowledged, mitigation planned
[L-01]Accounting logic causes any pre-existing plsKDK token on KDKStaker contract to be distributed along the harvested rewardsLowOpen
[L-02]Modifier nonReentrant applied to internal _deposit() instead of external entry pointsLowResolved
[I-01]Inconsistent logic in KDKStaker mixes total balances and harvested deltaInformationalOpen
[I-02]KDKDepositor function deposit() performs token transfer before validating minimum amountInformationalOpen
[I-03]Inconsistent naming convention for variables and function parametersInformationalOpen
[I-04]OpenZeppelin library version mismatch between OpenZeppelin-contracts and OpenZeppelin-contracts-upgradeableInformationalOpen
[I-05]Use UUPSUpgradeable and Initializable from @openzeppelin/contracts instead of @openzeppelin/contracts-upgradeableInformationalResolved
[I-06]setLpStakerPercentage in KDKFeeDistributor allows setting the lpStakerPercentage value to over 100InformationalResolved
[I-07]KDKDepositor redundant kdkStaker variable assignmentInformationalResolved

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.