Security Assessment for plsPlutus
Table of Contents
- Summary
- Scope
- Methodology
- Severity Classification
- Findings Summary
- Mitigation Review 2026-08-03
- Detailed Findings
- Disclaimer
Summary
This report presents the findings of the security assessment conducted on the smart contracts of the plsPlutus project. The system allows users to deposit PLUTUS or xPLUTUS, stake the underlying position through Plutus staking infrastructure, receive plsPLUTUS receipt tokens, and earn harvested rewards through single-sided and LP staking contracts.
This report covers the initial assessment and the subsequent remediation review:
| Review Phase | Date | Commit | Notes |
|---|---|---|---|
| Initial assessment | 2026-07-16 | d36c09f | Original manual and automated security assessment. |
| Mitigation review | 2026-08-03 | e11ee47 | Review of changes made in response to the original findings. |
| Follow-up findings | 2026-08-03 | e11ee47 | New deployment defects identified during the mitigation review. |
Assessment Overview
| Metric | Value |
|---|---|
| Total Issues Found | 26 |
| Critical | 0 |
| High | 0 |
| Medium | 7 |
| Low | 12 |
| Informational | 7 |
| Original Findings | 24 |
| Follow-up Findings | 2 |
Mitigation Status
| Result across the 24 original findings | Count |
|---|---|
| Fixed | 9 |
| Partially Fixed | 5 |
| Documented | 2 |
| Open | 8 |
Test Results
| Command or review activity | Result |
|---|---|
forge fmt --check | Passed |
forge build --offline --sizes | Passed with Solidity 0.8.30; production contracts remained below EIP-170 and EIP-3860 size limits |
forge test -vv | 73 passed, 0 failed across eight suites, including fork tests against the latest block returned by the configured public Arbitrum endpoint |
FeeDistributorTest | 8 passed, 0 failed |
StorageGapSmokeTest | 8 passed, 0 failed |
| Slither 0.11.5 | 62 contracts and 101 detectors; 98 raw results were manually triaged with no additional validated vulnerability |
Scope
The following files were included in the scope of this audit:
| File | Description |
|---|---|
src/PlutusDepositor.sol | Handles user deposits of PLUTUS and xPLUTUS and mints plsPLUTUS |
src/PlutusStaker.sol | Converts PLUTUS to xPLUTUS, stakes into xPlutusStaking, claims rewards, and votes gauges |
src/FeeDistributor.sol | Splits harvested rewards between single-sided and LP staking contracts |
src/PlsPlutusToken.sol | Upgradeable ERC20 receipt token for deposited PLUTUS/xPLUTUS |
src/PlsPlutusStaker.sol | Reward staking contract for plsPLUTUS |
src/PlsPlutusLPStaker.sol | Reward staking contract for plsPLUTUS LP tokens |
src/PlsPlutusWhitelist.sol | Whitelist for contract depositors and handler-deposited users |
src/interfaces/*.sol | Protocol and external Plutus interfaces |
Repository path: https://github.com/PlutusDao/plsPlutus (private)
Assessed Commit: d36c09f
Mitigation Review Commit: e11ee47
Methodology
The assessment and mitigation review involved:
- Manual Code Review: Line-by-line inspection of state transitions, access control, upgradeability, external calls, reward accounting, token flows, and every remediation change.
- Architecture and Deployment Review: Mapping the end-to-end deposit, staking, harvesting, distribution, governance, initialization, role-wiring, deployment, migration, and emergency assumptions.
- Finding Revalidation: Comparing each original finding against the remediation commit and classifying it as fixed, partially fixed, documented, or open.
- Automated Testing: Building the contracts and running the configured Foundry suite, including focused tests for
FeeDistributorand storage-gap initialization. - Static Analysis: Running Slither and manually triaging its raw output rather than treating automated output as confirmed vulnerabilities.
- Storage Review: Comparing baseline and remediation storage layouts for all seven upgradeable contracts.
- Exploitability Triage: Reviewing impact, preconditions, privilege requirements, and realistic attack or operational failure paths.
Automated analysis was used as review support rather than as a substitute for manual validation. Code excerpts in the original findings refer to the original assessed commit unless an update states otherwise.
Severity Classification
| Level | Description |
|---|---|
| Critical | Direct loss of user funds, protocol insolvency, or permanent denial of service exploitable without special privileges. |
| High | Conditional loss of funds, privilege escalation, or protocol bricking under realistic conditions. |
| Medium | State manipulation, reward disruption, fund stranding, or major deviations from expected protocol behavior. |
| Low | Best-practice deviations, privileged risks, edge-case denial of service, or compatibility issues. |
| Informational | Code quality, documentation, monitoring, and operational recommendations. |
Findings Summary
Initial Assessment Findings — 2026-07-16
| Finding ID | Title | Severity | Status |
|---|---|---|---|
| [M-01] | No unstake or redemption path for staked underlying assets | Medium | Open |
| [M-02] | Invalid LP percentage can halt all reward distribution | Medium | Fixed |
| [M-03] | Empty staking side can make harvest distribution revert | Medium | Partially Fixed |
| [M-04] | FeeDistributor initializer assigns ownership to the initializer caller | Medium | Fixed for New Deployments |
| [M-05] | Duplicate reward-token configuration can double-count claimed rewards | Medium | Fixed |
| [M-06] | Admins can recover user staking principal from staking contracts | Medium | Fixed |
| [L-01] | Missing storage gaps increase future upgrade collision risk | Low | Fixed |
| [L-02] | Zero critical addresses can brick deployments or reward routing | Low | Partially Fixed |
| [L-03] | Small reward amounts can be transferred but never distributed | Low | Open |
| [L-04] | Unbounded reward-token list can cause gas-based DoS | Low | Open |
| [L-05] | Raw approve calls reduce ERC20 compatibility | Low | Fixed |
| [L-06] | Single admin/owner controls upgrades and economic parameters | Low | Documented |
| [L-07] | Last admin or owner can renounce and permanently lock administration | Low | Open |
| [L-08] | Reward accounting assumes the requested reward amount was received | Low | Open |
| [L-09] | LP staking credits requested amount rather than received amount | Low | Open |
| [L-10] | Receipt minting relies on strict 1:1 PLUTUS/xPLUTUS assumptions | Low | Documented |
| [L-11] | Fee split rounding can bias repeated small reward distributions | Low | Open |
| [I-01] | plsPLUTUS has no direct user redemption path | Informational | Open |
| [I-02] | Contract wallets must be whitelisted before depositing | Informational | Fixed |
| [I-03] | Fee distributor parameter changes are not emitted | Informational | Fixed |
| [I-04] | Deployment process lacks atomic wiring and post-deploy checks | Informational | Partially Fixed |
| [I-05] | README is boilerplate and does not document protocol behavior | Informational | Partially Fixed |
| [I-06] | Withdrawals remain available while staking contracts are paused | Informational | Fixed |
| [I-07] | Handler-controlled claim timing should be documented | Informational | Partially Fixed |
Follow-up Review Findings — 2026-08-03
The following findings were identified while reviewing the remediation commit.
| Finding ID | Title | Severity | Status |
|---|---|---|---|
| [M-07] | Deployment script configures the LP pool with plsPLUTUS | Medium | Open |
| [L-12] | Deployment wiring assumes the broadcaster is the final admin and is not atomic | Low | Open |
Mitigation Review 2026-08-03
The mitigation review was performed against commit e11ee47. Four of the six original Medium findings are fixed, M-01 remains open, and M-03 is only partially fixed. Most Low findings remain unresolved. The newly identified M-07 also makes the supplied deployment script unsafe for production use.
| Finding ID | Status | Mitigation Review Notes |
|---|---|---|
| [M-02] | Fixed | LP percentages above 10,000 BPS are rejected and changes emit an event. |
| [M-03] | Partially Fixed | Empty sides retain pending allocations instead of reverting, but pending balances cannot be flushed without a later nonzero notification. |
| [M-04] | Fixed for New Deployments | Initialization accepts and validates an explicit owner; existing initialized proxies still need a separate ownership transfer. |
| [M-05] | Fixed | Duplicate xPLUTUS reward-token accounting is handled once, although a dedicated final-HEAD regression test is missing. |
| [M-06] | Fixed | Both reward stakers reject recovery of their configured staking principal. |
| [L-01] | Fixed | Storage gaps were appended without moving existing project storage, but no seeded old-to-new upgrade test was added. |
| [L-02] | Partially Fixed | Most critical addresses are validated, but token and whitelist initializers still permit a zero admin. |
| [L-05] | Fixed | Relevant raw approvals were replaced with SafeERC20.forceApprove(). |
| [L-06] | Documented | The README identifies the governance/admin multisig as the intended holder of privileged roles and describes their capabilities; no on-chain timelock or role separation was added. |
| [L-10] | Documented | The README describes 1:1 receipt minting and the deposit/staking workflow; the implementation still assumes exact transfer, conversion, and staking behavior. |
| [I-02] | Fixed | Contract-wallet whitelist requirements are documented. |
| [I-03] | Fixed | Administrative and distribution events were added. |
| [I-04] | Partially Fixed | A script and test were added, but deployment is non-atomic, assumes broadcaster/admin equality, and configures the wrong LP token. |
| [I-05] | Partially Fixed | Documentation improved but still makes inaccurate redemption, peg, backing, and atomic-deployment claims. |
| [I-06] | Fixed | Pause behavior is documented. |
| [I-07] | Partially Fixed | Handler-triggered harvesting is described, but timing policy and monitoring remain unspecified. |
All other original findings remain open as reflected in the findings summary and their original descriptions.
Verification and Coverage Limitations
- Passing tests do not establish remediation correctness or invalidate the business-logic and deployment findings in this report.
- Fork tests use the latest RPC block rather than a pinned block, reducing reproducibility.
- The FeeDistributor test mock records notifications but does not execute
transferFrom(), so it does not prove token movement or allowance behavior. - The final remediation commit lacks a focused M-05 duplicate-token regression test.
StorageGapSmokeTestcovers fresh deployment only; it does not seed an old implementation, upgrade it, and verify state preservation.- Mythril was not run.
- No deployed addresses were supplied, so deployed bytecode, ownership, roles, and live configuration were not verified.
Required Actions Before Approval
- Correct M-07 by configuring and testing the LP pool with the actual LP token.
- Resolve M-01 with governed migration and emergency unstake/exit functionality.
- Complete M-03 with an independent pending-reward distribution path and accounting invariants.
- Prevent zero-rate reward schedules and bound the reward-token lifecycle under L-03 and L-04.
- Reject zero admins in
PlsPlutusTokenandPlsPlutusWhitelistunder L-02. - Address nominal accounting in L-08 through L-10 with balance deltas or strict supported-token constraints.
- Redesign deployment under L-12 for a distinct multisig admin, external whitelist authority, and complete post-deployment verification.
- Add old-to-new upgrade tests, focused regressions, and pinned fork tests.
- Correct unsupported README claims about redemption, peg, backing, and atomic deployment.
Production deployment should remain blocked until M-07 and the remaining Medium findings are resolved or formally accepted under a documented governance and launch plan.
Detailed Findings
Original finding descriptions and code excerpts below refer to commit d36c09f. Where remediation changes were made, update notes describe their status at commit e11ee47. Findings introduced under a Follow-up review, 2026-08-03 heading were identified during the mitigation review.
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] No unstake or redemption path for staked underlying assets
| Review Phase | Initial assessment, 2026-07-16 |
| Discovered In Commit | d36c09f |
| Status | Open |
Description: PlutusStaker stakes all deposited PLUTUS/xPLUTUS into xPlutusStaking, but the contract exposes no function to call unstake() or exit() on the external staking contract. Deposited assets are therefore operationally dependent on a future upgrade if the protocol ever needs to redeem, migrate, or emergency-withdraw the underlying position.
Impact: If Plutus staking is deprecated, compromised, paused, or migrated, the protocol has no in-contract escape hatch. Users cannot redeem underlying assets without an upgrade. If upgrades are delayed, unavailable, or governance/admin access is lost, the underlying position can be stranded.
Attack/Failure Scenario:
- Users deposit PLUTUS and receive plsPLUTUS.
PlutusStakerstakes the converted xPLUTUS intoxPlutusStaking.- The external Plutus staking system announces a migration or emergency withdrawal window.
PlutusStakercannot callunstake()orexit().- The protocol must rely on a UUPS upgrade under time pressure.
Recommendation: Add carefully access-controlled emergency and migration functions for unstaking/exiting from xPlutusStaking. Gate them behind multisig/timelock governance, emit events, and define how returned assets are accounted for.
[M-02] Invalid LP percentage can halt all reward distribution
| Review Phase | Initial assessment, 2026-07-16 |
| Discovered In Commit | d36c09f |
| Status | Fixed |
Update 2026-08-03: Fixed in commit e11ee47. FeeDistributor.setLpStakerPercentage() rejects values above 10,000 BPS and emits LpStakerPercentageUpdated.
Description: FeeDistributor.setLpStakerPercentage() does not validate that _lpStakerPercentage <= 10000. If the value is set above 100%, rewardsAmountToSingleStaker = rewardsAmount - rewardsAmountToLpStaker underflows and reverts.
Location: src/FeeDistributor.sol:75-77
function setLpStakerPercentage(uint256 _lpStakerPercentage) external override onlyOwner {
lpStakerPercentage = _lpStakerPercentage;
}
Location: src/FeeDistributor.sol:50-57
uint256 rewardsAmountToLpStaker = (rewardsAmount * lpStakerPercentage) / 10000;
if (rewardsAmountToLpStaker != 0) {
token.approve(address(lpStaker), rewardsAmountToLpStaker);
lpStaker.notifyRewardAmount(address(token), rewardsAmountToLpStaker);
}
uint256 rewardsAmountToSingleStaker = rewardsAmount - rewardsAmountToLpStaker;
Impact: A bad owner transaction bricks reward distribution for every reward token until corrected. Since PlutusStaker.handleClaim() calls feeDistributor.notifyRewardAmount(), harvests can be made to revert.
Proof of Concept Scenario:
- Owner calls
setLpStakerPercentage(10001). PlutusStaker.handleClaim()harvests rewards and transfers them toFeeDistributor.FeeDistributor.notifyRewardAmount()computes an LP allocation larger thanamount.- The subtraction for the single-staker share reverts.
- Harvest/distribution is halted.
Recommendation: Enforce require(_lpStakerPercentage <= 10000, "invalid percentage") and emit an event when the value changes.
[M-03] Empty staking side can make harvest distribution revert
| Review Phase | Initial assessment, 2026-07-16 |
| Discovered In Commit | d36c09f |
| Status | Partially Fixed |
Update 2026-08-03: Partially fixed in commit e11ee47. Empty recipient pools no longer revert harvests; their allocations are retained in pendingLpRewards or pendingSingleRewards. However, pending rewards can only be flushed by a later nonzero notification of the same token. Add an independent distributePending(IERC20 token) path and enforce pendingLpRewards[token] + pendingSingleRewards[token] <= token.balanceOf(FeeDistributor).
Description: PlsPlutusStaker.notifyRewardAmount() and PlsPlutusLPStaker.notifyRewardAmount() revert when _totalSupply == 0. FeeDistributor does not check staker supply before routing rewards. If lpStakerPercentage is non-zero while the LP staking contract has no stake, or if the single-sided staking contract has no stake, distribution reverts.
Location: src/FeeDistributor.sol:52-61
FeeDistributor synchronously forwards each nonzero split to the configured staking destination:
if (rewardsAmountToLpStaker != 0) {
token.approve(address(lpStaker), rewardsAmountToLpStaker);
lpStaker.notifyRewardAmount(address(token), rewardsAmountToLpStaker);
}
uint256 rewardsAmountToSingleStaker = rewardsAmount - rewardsAmountToLpStaker;
if (rewardsAmountToSingleStaker != 0) {
token.approve(address(singleStaker), rewardsAmountToSingleStaker);
singleStaker.notifyRewardAmount(address(token), rewardsAmountToSingleStaker);
}
Location: src/PlsPlutusStaker.sol:164-165, src/PlsPlutusLPStaker.sol:165-166
Both downstream staking contracts reject reward notifications while their staking supply is zero:
if (_totalSupply == 0) revert PBS_ZeroTotalSupply();
if (reward == 0) return;
Impact: Harvested rewards can become undistributable in common bootstrapping or low-liquidity states. A single empty recipient side can revert the entire transaction, including the upstream harvest flow.
Proof of Concept Scenario:
- Owner sets
lpStakerPercentageto a positive value. - No users have staked LP tokens yet.
PlutusStaker.handleClaim()forwards harvested rewards toFeeDistributor.FeeDistributorattempts to notify the LP staker.- LP staker reverts with
PBS_ZeroTotalSupply(). - Reward harvesting cannot complete until LP supply exists or parameters are changed.
Recommendation: Either skip allocation to a staker with zero supply, retain undistributed rewards in FeeDistributor for later distribution, or require configuration only after both staking sides have non-zero supply.
[M-04] FeeDistributor initializer assigns ownership to the initializer caller
| Review Phase | Initial assessment, 2026-07-16 |
| Discovered In Commit | d36c09f |
| Status | Fixed for New Deployments |
Update 2026-08-03: Fixed for new deployments in commit e11ee47. FeeDistributor.initialize() now accepts and validates an explicit owner. Already initialized proxies are unaffected and require a separate ownership transfer.
Description: FeeDistributor.initialize() assigns ownership to msg.sender instead of accepting an explicit owner parameter. This makes correct ownership depend on the deployment path and any proxy/factory/script caller used during initialization.
Location: src/FeeDistributor.sol:29-39
function initialize(IReward _singleStaker, IReward _lpStaker) public initializer {
OwnableUpgradeable.__Ownable_init(msg.sender);
Ownable2StepUpgradeable.__Ownable2Step_init();
UUPSUpgradeable.__UUPSUpgradeable_init();
PausableUpgradeable.__Pausable_init();
ReentrancyGuardUpgradeable.__ReentrancyGuard_init();
singleStaker = _singleStaker;
lpStaker = _lpStaker;
_pause();
}
Impact: If initialization is performed by a deployer EOA, factory, or script address rather than the intended multisig/timelock, that caller receives ownership over upgrades, handlers, fee split, reward destinations, and pause control. This can leave critical controls with the wrong account until ownership is transferred.
Recommendation: Modify the initializer to accept an explicit owner_ parameter, validate it is nonzero, and pass it to __Ownable_init(owner_). Deployment scripts should still verify final ownership after initialization.
[M-05] Duplicate reward-token configuration can double-count claimed rewards
| Review Phase | Initial assessment, 2026-07-16 |
| Discovered In Commit | d36c09f |
| Status | Fixed |
Update 2026-08-03: Fixed in commit e11ee47. PlutusStaker.handleClaim() accounts for a shared rewards/xPLUTUS token once. A focused regression test existed during remediation but is absent from final HEAD.
Description: PlutusStaker.handleClaim() snapshots the external staking reward token balance and the xPLUTUS balance independently. If xPlutusStaking.rewardsToken() is ever the same token as xPlutusToken, the same balance delta is counted twice: once as rewardAmount and once as rewardXAmount.
Location: src/PlutusStaker.sol:84-97
IERC20 rewardsToken = xPlutusStaking.rewardsToken();
uint256 oldBalance = rewardsToken.balanceOf(address(this));
uint256 oldXBalance = IERC20(address(xPlutusToken)).balanceOf(address(this));
xPlutusStaking.claim(block.chainid, address(this));
uint256 newBalance = rewardsToken.balanceOf(address(this));
uint256 newXBalance = IERC20(address(xPlutusToken)).balanceOf(address(this));
rewardAmount = newBalance - oldBalance;
rewardXAmount = newXBalance - oldXBalance;
IERC20(rewardsToken).safeTransfer(address(feeDistributor), rewardAmount);
IERC20(address(xPlutusToken)).safeTransfer(address(feeDistributor), rewardXAmount);
feeDistributor.notifyRewardAmount(IERC20(rewardsToken), rewardAmount);
feeDistributor.notifyRewardAmount(IERC20(address(xPlutusToken)), rewardXAmount);
Impact: Under a duplicate-token configuration, harvests can either revert while attempting to transfer the same claimed amount twice or over-distribute if the staker contract holds enough pre-existing xPLUTUS. This can halt reward harvesting or drain unrelated xPLUTUS balances from PlutusStaker into reward distribution.
Proof of Concept Scenario:
- The external staking contract reports
rewardsToken() == address(xPlutusToken). - A claim increases the xPLUTUS balance by
X. handleClaim()calculates bothrewardAmount = XandrewardXAmount = X.- The function attempts to transfer
2Xtotal xPLUTUS toFeeDistributor. - The transaction reverts if only
Xexists, or double-distributes if idle xPLUTUS exists.
Recommendation: Add an explicit branch for address(rewardsToken) == address(xPlutusToken) and account the delta once. Alternatively reject this configuration in initialization and monitoring.
[M-06] Admins can recover user staking principal from staking contracts
| Review Phase | Initial assessment, 2026-07-16 |
| Discovered In Commit | d36c09f |
| Status | Fixed |
Update 2026-08-03: Fixed in commit e11ee47. Both reward staking contracts reject recovery of their configured staking principal.
Description: recoverERC20() in both staking contracts only prevents recovery of reward tokens. It does not prevent recovery of the staking principal token itself.
Location: src/PlsPlutusStaker.sol:206-212, src/PlsPlutusLPStaker.sol:207-213
function recoverERC20(address tokenAddress, uint256 tokenAmount) external onlyRole(DEFAULT_ADMIN_ROLE) {
if (rewardTokens.contains(tokenAddress)) {
revert PBS_WithdrawRewardToken();
}
IERC20(tokenAddress).safeTransfer(msg.sender, tokenAmount);
emit Recovered(tokenAddress, tokenAmount);
}
Impact: A compromised or malicious admin can drain staked plsPLUTUS or LP tokens from users. Users would retain internal accounting balances but the contracts would be unable to satisfy withdrawals.
Recommendation: Explicitly block recovery of plsPlutusToken and plsPlutusLPToken. Use a separate rescue function only for unrelated tokens.
Follow-up review, 2026-08-03
[M-07] Deployment script configures the LP pool with plsPLUTUS
| Review Phase | Mitigation review, 2026-08-03 |
| Discovered In Commit | e11ee47 |
| Status | Open |
Description: DeployPlsPlutus.s.sol initializes PlsPlutusLPStaker with the deployed plsPLUTUS receipt token. There is no deployment input for a distinct LP token, and the deployment test repeats the same configuration instead of using an independent mock LP token.
Location: script/DeployPlsPlutus.s.sol:53-56
abi.encodeCall(
PlsPlutusLPStaker.initialize,
(contracts.plsPlutusToken, admin)
)
Impact: The intended LP pool cannot accept the expected LP principal. A genuine LP-token holder's stake attempts to transfer plsPLUTUS and fails, while an ordinary plsPLUTUS holder can stake in the supposed LP pool and earn rewards allocated to liquidity providers. Correcting the configured principal requires an upgrade or redeployment.
Failure Scenario:
- Governance deploys through the supplied script and configures a positive LP reward percentage.
- A genuine LP-token holder approves the LP staker and attempts to stake.
- The staker tries to transfer plsPLUTUS rather than the approved LP token, causing the stake to fail.
- An ordinary plsPLUTUS holder stakes in the LP pool instead.
- LP-designated rewards are distributed to the wrong economic cohort.
Recommendation: Add and validate a PLSPLUTUS_LP_TOKEN deployment input and pass it to PlsPlutusLPStaker.initialize(). The deployment test should use a distinct mock LP token. Post-deployment checks should assert that plsPlutusLPToken() equals the expected LP token and differs from plsPlutusToken, unless equality is explicitly intended and documented.
Low Severity
[L-01] Missing storage gaps increase future upgrade collision risk
| Review Phase | Initial assessment, 2026-07-16 |
| Discovered In Commit | d36c09f |
| Status | Fixed |
Update 2026-08-03: Fixed in commit e11ee47. Storage gaps were appended to all seven upgradeable contracts without moving existing project variables. The added smoke test only covers fresh deployment; add a seeded old-implementation-to-new-implementation upgrade test.
Description: All main contracts are UUPS upgradeable and define custom storage without reserved storage gaps. OpenZeppelin v5's namespaced storage reduces the classic inherited-storage collision risk for OZ modules, so this is best treated as upgrade-safety hardening rather than an immediate vulnerability. The project still needs disciplined layout management for its own custom variables and future inheritance changes.
Location: src/PlutusDepositor.sol, src/PlutusStaker.sol, src/FeeDistributor.sol, src/PlsPlutusToken.sol, src/PlsPlutusStaker.sol, src/PlsPlutusLPStaker.sol, src/PlsPlutusWhitelist.sol
Impact: Incorrect future upgrades can corrupt balances, reward accounting, token addresses, roles, or ownership. The likelihood is reduced if storage layout checks are enforced, but the current contracts do not reserve explicit space for future custom variables.
Recommendation: Add storage gaps where appropriate for project-defined storage and enforce storage layout checks in CI using Foundry/OpenZeppelin upgrades tooling. Do not reorder existing variables or change inheritance order without layout review.
[L-02] Zero critical addresses can brick deployments or reward routing
| Review Phase | Initial assessment, 2026-07-16 |
| Discovered In Commit | d36c09f |
| Status | Partially Fixed |
Update 2026-08-03: Partially fixed in commit e11ee47. Most critical dependencies are now validated, but PlsPlutusToken.initialize(address(0)) and PlsPlutusWhitelist.initialize(address(0)) still create permanently adminless proxies.
Description: Initializers and setters do not reject zero addresses for critical dependencies such as tokens, stakers, fee distributor, whitelist, and reward recipients.
Location: src/PlutusDepositor.sol, src/PlutusStaker.sol, src/FeeDistributor.sol, src/PlsPlutusStaker.sol, src/PlsPlutusLPStaker.sol
Impact: Misconfiguration can brick deposits, staking, reward distribution, or upgrades. Rewards may also be approved to or routed through invalid addresses.
Recommendation: Add explicit address(0) validation in all initializers and setters.
[L-03] Small reward amounts can be transferred but never distributed
| Review Phase | Initial assessment, 2026-07-16 |
| Discovered In Commit | d36c09f |
| Status | Open |
Description: Reward rates are calculated using integer division: reward / _rewardsDuration. If reward < _rewardsDuration, the reward rate becomes zero after the reward token is transferred into the staking contract.
Location: src/PlsPlutusStaker.sol:167-178, src/PlsPlutusLPStaker.sol:168-179
IERC20(_rewardsToken).safeTransferFrom(msg.sender, address(this), reward);
if (block.timestamp >= rewardData[_rewardsToken].periodFinish) {
rewardData[_rewardsToken].rewardRate = reward / _rewardsDuration;
} else {
uint256 remaining = rewardData[_rewardsToken].periodFinish - block.timestamp;
uint256 leftover = remaining * rewardData[_rewardsToken].rewardRate;
rewardData[_rewardsToken].rewardRate = (reward + leftover) / _rewardsDuration;
}
rewardData[_rewardsToken].lastUpdateTime = block.timestamp;
rewardData[_rewardsToken].periodFinish = block.timestamp + _rewardsDuration;
Impact: Small reward amounts can be locked in the staking contract but never accrue to users. Since reward tokens cannot be recovered once added to rewardTokens, the funds may remain stuck.
Recommendation: Require reward >= _rewardsDuration or rewardRate > 0 before accepting the transfer, or account for dust explicitly.
[L-04] Unbounded reward-token list can cause gas-based DoS
| Review Phase | Initial assessment, 2026-07-16 |
| Discovered In Commit | d36c09f |
| Status | Open |
Update 2026-08-03: Open. Zero-value notifications now revert without permanently inserting a token, but rewardTokens remains unbounded and all user accounting paths continue to iterate over it. The attempted cap was removed before final HEAD. Enforce a protocol-justified cap and a safe deactivation lifecycle.
Description: PlsPlutusStaker and PlsPlutusLPStaker keep every reward token in rewardTokens, and every stake, withdraw, claim, and reward update loops over the full set. There is no maximum reward-token count and no removal path for inactive tokens. This can get out of hand over time as more reward tokens are added.
A concrete accelerator is that the updateReward modifier adds a new reward token before notifyRewardAmount() checks reward == 0. Therefore, a zero-value call by an authorized distributor can permanently add a token without distributing any rewards.
Location: src/PlsPlutusStaker.sol:184-199, src/PlsPlutusLPStaker.sol:185-200
modifier updateReward(address account, address rewardToken) {
if (rewardToken != address(0) && !rewardTokens.contains(rewardToken)) {
rewardTokens.add(rewardToken);
}
for (uint256 i; i < rewardTokens.length(); i++) {
address token = rewardTokens.at(i);
rewardData[token].rewardPerTokenStored = rewardPerToken(token);
rewardData[token].lastUpdateTime = lastTimeRewardApplicable(token);
if (account != address(0)) {
rewards[account][token] = earned(account, token);
userRewardPerTokenPaid[account][token] = rewardData[token].rewardPerTokenStored;
}
}
_;
}
Location: src/PlsPlutusStaker.sol:158-165, src/PlsPlutusLPStaker.sol:159-166
The reward token is added in the modifier before the function body can return on a zero reward amount:
function notifyRewardAmount(address _rewardsToken, uint256 reward)
external
nonReentrant
onlyRole(REWARD_DISTRIBUTOR_ROLE)
updateReward(address(0), _rewardsToken)
{
if (_totalSupply == 0) revert PBS_ZeroTotalSupply();
if (reward == 0) return;
Impact: As rewardTokens.length() grows, routine user operations become more expensive. In the long run, a sufficiently large reward-token set can make stake(), withdraw(), or getReward() impractical or even fail due to gas limits. Since users need withdraw() to recover principal from the staking contracts, this is a gas-based denial-of-service risk.
Recommendation: Enforce a maximum reward-token count, reject zero-value reward notifications before adding tokens, and add a carefully constrained reward-token removal/deactivation path for tokens with no active reward period and no remaining claimable balances. Also document the intended maximum number of supported reward tokens.
[L-05] Raw approve calls reduce ERC20 compatibility
| Review Phase | Initial assessment, 2026-07-16 |
| Discovered In Commit | d36c09f |
| Status | Fixed |
Update 2026-08-03: Fixed in commit e11ee47. The cited approvals in PlutusStaker and FeeDistributor use SafeERC20.forceApprove().
Description: PlutusStaker and FeeDistributor use raw approve() calls instead of SafeERC20.forceApprove() or zero-reset approval flows.
Location: src/PlutusStaker.sol:60-66, src/FeeDistributor.sol:52-61
if (!isXPlutus) {
plutusToken.approve(address(xPlutusToken), _amount);
xPlutusToken.convert(_amount, address(this));
}
IERC20(address(xPlutusToken)).approve(address(xPlutusStaking), _amount);
xPlutusStaking.stake(_amount, block.chainid, address(this));
if (rewardsAmountToLpStaker != 0) {
token.approve(address(lpStaker), rewardsAmountToLpStaker);
lpStaker.notifyRewardAmount(address(token), rewardsAmountToLpStaker);
}
uint256 rewardsAmountToSingleStaker = rewardsAmount - rewardsAmountToLpStaker;
if (rewardsAmountToSingleStaker != 0) {
token.approve(address(singleStaker), rewardsAmountToSingleStaker);
singleStaker.notifyRewardAmount(address(token), rewardsAmountToSingleStaker);
}
Impact: Tokens with non-standard approval behavior, such as requiring allowance to be reset to zero before changing it, can break staking or reward distribution.
Recommendation: Use SafeERC20.forceApprove() or reset allowance to zero before setting a new allowance.
[L-06] Single admin/owner controls upgrades and economic parameters
| Review Phase | Initial assessment, 2026-07-16 |
| Discovered In Commit | d36c09f |
| Status | Documented |
Update 2026-08-03: Documented in commit e11ee47. The README identifies the governance/admin multisig as the intended holder of privileged owner and admin roles, lists the capabilities of those roles, and describes UUPS upgrade authorization. No on-chain multisig enforcement, role separation, or timelock was added.
Description: UUPS upgrades, role grants, pause controls, whitelist changes, reward duration changes, fee split changes, and reward destination changes are controlled by a single admin/owner role per contract. This may be an intentional operational design choice, especially during early deployment, but the system remains admin-heavy and users must trust the privileged actors.
Location: Admin functions across src/*.sol
Impact: Compromise or misuse of one privileged account can upgrade contracts, mint or burn via granted roles, alter reward routing, or disrupt protocol operation.
Recommendation: Use multisig ownership, timelocks for upgrades/economic changes, explicit role separation, and monitoring for privileged actions.
[L-07] Last admin or owner can renounce and permanently lock administration
| Review Phase | Initial assessment, 2026-07-16 |
| Discovered In Commit | d36c09f |
| Status | Open |
Description: The contracts use OpenZeppelin AccessControlUpgradeable and Ownable2StepUpgradeable. There is no protection against the final admin renouncing its role or ownership being renounced.
Impact: The protocol can permanently lose upgrade, pause, whitelist, and recovery controls.
Recommendation: Override renounce flows for critical roles or use operational runbooks/multisig policies that prevent last-admin renunciation.
[L-08] Reward accounting assumes the requested reward amount was received
| Review Phase | Initial assessment, 2026-07-16 |
| Discovered In Commit | d36c09f |
| Status | Open |
Description: PlsPlutusStaker.notifyRewardAmount() and PlsPlutusLPStaker.notifyRewardAmount() schedule rewards using the caller-supplied reward amount after safeTransferFrom(). They do not measure the actual balance delta received by the contract.
Location: src/PlsPlutusStaker.sol:167-175, src/PlsPlutusLPStaker.sol:168-176
IERC20(_rewardsToken).safeTransferFrom(msg.sender, address(this), reward);
if (block.timestamp >= rewardData[_rewardsToken].periodFinish) {
rewardData[_rewardsToken].rewardRate = reward / _rewardsDuration;
} else {
uint256 remaining = rewardData[_rewardsToken].periodFinish - block.timestamp;
uint256 leftover = remaining * rewardData[_rewardsToken].rewardRate;
rewardData[_rewardsToken].rewardRate = (reward + leftover) / _rewardsDuration;
}
Impact: If a reward token is fee-on-transfer, rebasing, blocklisting, or otherwise non-standard, the contract can promise more rewards than it received. Later getReward() calls may revert or leave later claimants unpaid.
Recommendation: Snapshot balanceBefore and balanceAfter, compute received = balanceAfter - balanceBefore, and schedule rewards based on received. If only standard PLUTUS/xPLUTUS are supported, enforce and document that assumption.
[L-09] LP staking credits requested amount rather than received amount
| Review Phase | Initial assessment, 2026-07-16 |
| Discovered In Commit | d36c09f |
| Status | Open |
Description: PlsPlutusLPStaker.stake() transfers amount from the user and credits the same amount internally. It does not verify that the LP staking contract actually received that amount.
Location: src/PlsPlutusLPStaker.sol:123-124
plsPlutusLPToken.safeTransferFrom(msg.sender, address(this), amount);
_handleDeposit(msg.sender, amount);
Impact: If the LP token has transfer fees, rebasing behavior, or non-standard accounting, a user can be credited more staking balance than the contract receives. This dilutes other LP stakers and can make withdrawals insolvent.
Recommendation: Use balance-delta accounting for LP staking deposits, or explicitly restrict the staking token to standard non-rebasing LP tokens.
[L-10] Receipt minting relies on strict 1:1 PLUTUS/xPLUTUS assumptions
| Review Phase | Initial assessment, 2026-07-16 |
| Discovered In Commit | d36c09f |
| Status | Documented |
Update 2026-08-03: Documented in commit e11ee47. The README describes 1:1 receipt minting and the PLUTUS/xPLUTUS deposit, conversion, and staking workflow. The implementation still relies on exact transfer, conversion, and staking behavior rather than enforcing the backing assumption.
Description: PlutusDepositor mints plsPLUTUS 1:1 with the requested deposit amount after PlutusStaker.stake() succeeds. This assumes PLUTUS transfers, PLUTUS-to-xPLUTUS conversion, direct xPLUTUS deposits, and external staking are all exact 1:1 operations with no fees, slippage, or changing exchange rate.
Location: src/PlutusDepositor.sol:57-106
function deposit(uint256 _amount) external override whenNotPaused {
_isEligibleSender();
plutusToken.safeTransferFrom(msg.sender, address(plutusStaker), _amount);
_deposit(msg.sender, _amount, false);
}
function depositXPlutus(uint256 _amount) external override whenNotPaused {
_isEligibleSender();
xPlutusToken.safeTransferFrom(msg.sender, address(plutusStaker), _amount);
_deposit(msg.sender, _amount, true);
}
function _deposit(address _user, uint256 _amount, bool isXPlutus) internal nonReentrant {
if (_amount < MIN_DEPOSIT) revert PBD_INVALID_AMOUNT();
plutusStaker.stake(_amount, isXPlutus);
plsPlutus.mint(_user, _amount);
emit Deposited(_user, _amount);
}
Location: src/PlutusStaker.sol:59-69
function stake(uint256 _amount, bool isXPlutus) external override onlyRole(HANDLER_ROLE) {
if (!isXPlutus) {
plutusToken.approve(address(xPlutusToken), _amount);
xPlutusToken.convert(_amount, address(this));
}
IERC20(address(xPlutusToken)).approve(address(xPlutusStaking), _amount);
xPlutusStaking.stake(_amount, block.chainid, address(this));
totalStaked += _amount;
emit Staked(msg.sender, _amount);
}
Impact: If xPLUTUS is not economically equivalent to PLUTUS, or if conversion/staking ever applies fees or rounding, users can receive more plsPLUTUS than the protocol actually backs. Direct xPLUTUS deposits are especially sensitive because they are minted at the same rate as PLUTUS deposits.
Recommendation: Document and monitor the 1:1 invariant. Prefer minting based on actual received/staked amount or a validated conversion rate if the external token economics can change.
[L-11] Fee split rounding can bias repeated small reward distributions
| Review Phase | Initial assessment, 2026-07-16 |
| Discovered In Commit | d36c09f |
| Status | Open |
Description: FeeDistributor rounds the LP share down with integer division and gives the remainder to the single-sided staker.
Location: src/FeeDistributor.sol:49-50
uint256 rewardsAmountToLpStaker = (rewardsAmount * lpStakerPercentage) / 10000;
uint256 rewardsAmountToSingleStaker = rewardsAmount - rewardsAmountToLpStaker;
Impact: For small reward distributions, the LP side can repeatedly receive zero even with a positive percentage. Over many small harvests this creates a systematic allocation bias.
Recommendation: Accumulate rounding remainders per reward token or enforce a minimum distribution amount that makes the configured split meaningful.
Follow-up review, 2026-08-03
[L-12] Deployment wiring assumes the broadcaster is the final admin and is not atomic
| Review Phase | Mitigation review, 2026-08-03 |
| Discovered In Commit | e11ee47 |
| Status | Open |
Description: The deployment script initializes each proxy with ADMIN_ADDRESS, but subsequent role grants and setter calls are broadcast by PRIVATE_KEY. If the final admin is a different multisig, the broadcaster does not hold the required permissions and wiring fails. The deployment test masks this condition by making the broadcaster and configured admin the same authority.
The script also calls IXPlutusToken.updateWhitelist(), which requires authority over the external xPLUTUS contract. The deployment flow does not establish that the broadcaster or final admin holds this authority.
Finally, vm.startBroadcast() sends multiple independent on-chain transactions. It does not make deployment and wiring atomic, despite the script and README descriptions. An interrupted or partially failed broadcast can leave deployed but incompletely configured contracts.
Impact: A multisig-first deployment can fail, teams may retain an unnecessarily privileged deployer as a workaround, and partial broadcasts can leave contracts in an unsafe or unusable state.
Failure Scenario:
PRIVATE_KEYbelongs to a deployment EOA andADMIN_ADDRESSis the intended governance multisig.- Proxies initialize with the multisig as admin.
- The deployment EOA attempts the first privileged role grant or setter call and lacks authorization.
- Wiring fails after one or more independent deployment transactions may already have been broadcast.
- A manual recovery process or privileged-deployer workaround is required.
Recommendation: Use a deployment coordinator that completes wiring before transferring control, or generate an explicit multisig transaction batch for privileged setup. Establish the authority needed to update the external xPLUTUS whitelist. If broadcaster/admin equality is intentionally required, assert vm.addr(PRIVATE_KEY) == ADMIN_ADDRESS and document the operational trade-off. Add post-deployment checks for every owner, role, dependency, staking token, handler, whitelist entry, fee setting, and pause state, and remove all claims that the multi-transaction broadcast is atomic.
Informational Severity
[I-01] plsPLUTUS has no direct user redemption path
| Review Phase | Initial assessment, 2026-07-16 |
| Discovered In Commit | d36c09f |
| Status | Open |
Description: Users can mint plsPLUTUS by depositing PLUTUS or xPLUTUS, but no user-facing function burns plsPLUTUS and returns underlying assets. If this is by design, it should be prominently documented.
[I-02] Contract wallets must be whitelisted before depositing
| Review Phase | Initial assessment, 2026-07-16 |
| Discovered In Commit | d36c09f |
| Status | Fixed |
Update 2026-08-03: Fixed in commit e11ee47. The README explains that contract callers, including smart wallets and integrations, must be added to PlsPlutusWhitelist before depositing.
Description: PlutusDepositor._isEligibleSender() blocks contract callers unless whitelisted by comparing msg.sender with tx.origin. This excludes smart wallets, account-abstraction wallets, vaults, and aggregators unless explicitly whitelisted.
Location: src/PlutusDepositor.sol:108-112
function _isEligibleSender() internal view {
if (msg.sender != tx.origin && !whitelist.isWhitelisted(msg.sender)) {
revert PBD_UNAUTHORIZED();
}
}
[I-03] Fee distributor parameter changes are not emitted
| Review Phase | Initial assessment, 2026-07-16 |
| Discovered In Commit | d36c09f |
| Status | Fixed |
Update 2026-08-03: Fixed. Events were added for fee percentage, staker, handler, queue, and distribution changes. Pause state changes use OpenZeppelin events.
Description: setLpStakerPercentage(), setSingleStaker(), setLpStaker(), and pause changes should emit dedicated events for off-chain monitoring.
Location: src/FeeDistributor.sol:75-93
function setLpStakerPercentage(uint256 _lpStakerPercentage) external override onlyOwner {
lpStakerPercentage = _lpStakerPercentage;
}
function setSingleStaker(IReward _singleStaker) external override onlyOwner {
singleStaker = _singleStaker;
}
function setLpStaker(IReward _lpStaker) external override onlyOwner {
lpStaker = _lpStaker;
}
function setPaused(bool _pauseContract) external onlyOwner {
if (_pauseContract) {
_pause();
} else {
_unpause();
}
}
[I-04] Deployment process lacks atomic wiring and post-deploy checks
| Review Phase | Initial assessment, 2026-07-16 |
| Discovered In Commit | d36c09f |
| Status | Partially Fixed |
Update 2026-08-03: Partially fixed. A deployment script and test were added, but the script is non-atomic, only wires successfully when broadcaster and final admin have the same authority, and initializes the LP staker with the wrong token. See [M-07] and [L-12].
Description: Critical wiring is performed manually in tests after deployment, including granting HANDLER_ROLE, granting MINTER_ROLE, setting the whitelist, setting fee-distributor handlers, granting reward-distributor roles, and unpausing contracts. The repository does not include an equivalent production deployment script with atomic wiring and post-deployment invariant checks.
Impact: A missed deployment step can leave deposits, staking, or reward distribution unusable until admin intervention. This is primarily a deployment-process risk rather than a contract-level vulnerability.
Recommendation: Provide reviewed deployment scripts or multisig batches that initialize all contracts, wire roles, set handlers, set the final multisig/timelock owner/admin, unpause only after verification, and assert all critical post-deployment invariants.
[I-05] README is boilerplate and does not document protocol behavior
| Review Phase | Initial assessment, 2026-07-16 |
| Discovered In Commit | d36c09f |
| Status | Partially Fixed |
Update 2026-08-03: Partially fixed. The README is substantially improved but contains materially inaccurate redemption, peg, backing, and atomic-deployment claims.
Description: The README is the default Foundry README and does not describe the plsPlutus architecture, trust model, roles, deployment steps, or emergency procedures.
[I-06] Withdrawals remain available while staking contracts are paused
| Review Phase | Initial assessment, 2026-07-16 |
| Discovered In Commit | d36c09f |
| Status | Fixed |
Update 2026-08-03: Fixed in commit e11ee47. The README states that pausing blocks new deposits or reward distributions while users retain the ability to withdraw staked principal and claim pending rewards.
Description: stake() is indirectly paused through _handleDeposit(), but withdraw() and getReward() remain callable while paused.
Location: src/PlsPlutusStaker.sol:126-154, src/PlsPlutusLPStaker.sol:127-155
function withdraw(uint256 amount) external nonReentrant {
if (amount == 0) revert PBS_ZeroAmount();
_handleWithdraw(msg.sender, amount);
plsPlutusToken.safeTransfer(msg.sender, amount);
}
...
function getReward() public nonReentrant updateReward(msg.sender, address(0)) {
for (uint256 i; i < rewardTokens.length(); i++) {
address _rewardsToken = rewardTokens.at(i);
uint256 reward = rewards[msg.sender][_rewardsToken];
if (reward > 0) {
rewards[msg.sender][_rewardsToken] = 0;
IERC20(_rewardsToken).safeTransfer(msg.sender, reward);
emit RewardPaid(msg.sender, _rewardsToken, reward);
}
}
}
Impact: This may be intentional user protection, but if pause is expected to freeze all state-changing activity during an incident, withdrawals and claims remain open.
Recommendation: Document the intended pause semantics. If pause is meant as a full circuit breaker, add whenNotPaused to withdraw() and getReward().
[I-07] Handler-controlled claim timing should be documented
| Review Phase | Initial assessment, 2026-07-16 |
| Discovered In Commit | d36c09f |
| Status | Partially Fixed |
Update 2026-08-03: Partially fixed. Handler-triggered harvesting is documented, but the reward-timing effects, operating policy, and monitoring requirements are not.
Description: PlutusStaker.handleClaim() is restricted to HANDLER_ROLE. Reward claim timing therefore depends on a trusted operational actor. If rewards are claimed immediately after a new stake enters the reward stakers, currently staked users and new entrants share streamed rewards according to current balances rather than original accrual timing. This is not a permissionless exploit, but the trust assumption should be documented and monitored.
Disclaimer
This report reflects a time-bounded security review of the specified source files and commits. It does not guarantee the absence of vulnerabilities. No deployed addresses were supplied, so this assessment does not establish source-to-bytecode equivalence or validate any live ownership, role, dependency, or deployment configuration.
The system also depends on external Plutus contracts, token behavior, RPC infrastructure, upgrade governance, and operational key management that were not fully audited as part of this review. The findings and proof-of-concept scenarios are provided for defensive security purposes and to support remediation. The auditor assumes no liability for losses or damages resulting from use of the reviewed code.