Security Assessment for plsCYPH
Table of Contents
- Summary
- Scope
- Methodology
- Severity Classification
- Findings Summary
- Mitigation Review 2026-06-09
- Detailed Findings
- Disclaimer
Summary
This report presents the findings of the security assessment conducted on the smart contracts of the plsCYPH project,
which allows users to deposit CYPH tokens,
convert them into xCYPH through the external escrow system,
receive plsCYPH tokens in return,
and earn rewards from staking activities.
The review was conducted on commit 9936ffa from the main branch on 2026-05-27.
A mitigation review was conducted against commit 9ecd465 on 2026-06-09.
Assessment Overview
| Metric | Value |
|---|---|
| Total Issues Found | 27 |
| Critical | 0 |
| High | 1 |
| Medium | 5 |
| Low | 10 |
| Informational | 11 |
Scope
The following files were included in the scope of this audit:
| File | Description |
|---|---|
src/CYPHDepositor.sol | Handles user deposits of CYPH tokens |
src/CYPHStaker.sol | Manages CYPH conversion, xCYPH staking, and reward harvesting |
src/CYPHFeeDistributor.sol | Distributes harvested rewards to treasury, single stakers, and LP stakers |
src/PlsCYPHToken.sol | The plsCYPH ERC20 token |
src/PlsCYPHStaker.sol | Staking contract for plsCYPH tokens |
src/PlsCYPHLPStaker.sol | Staking contract for plsCYPH LP tokens |
src/PlsCYPHWhitelist.sol | Manages whitelisted contract depositors |
src/interfaces/IEscrow*.sol | External escrow protocol interfaces (2 files) |
src/interfaces/*.sol | Protocol interfaces (7 files) |
script/*.s.sol | Deployment scripts reviewed for wiring and initialization risks |
Repository: https://github.com/PlutusDao/plsCYPH (private)
Assessed Commit: 9936ffa
Mitigation Review Commit: 9ecd465
Methodology
The audit process involved a combination of:
- Manual Code Review: Line-by-line inspection of logic, state transitions, access control, upgradeability, and external calls.
- AI-Assisted Tooling: Using the evm-audit pipeline to support architecture extraction, complexity analysis, and vulnerability discovery.
- Automated Static Analysis: Using solidity-code-metrics and MAIA detectors to identify candidate issues for review.
- Manual Triage: Reviewing candidate findings against source code excerpts, line references, and protocol intent.
The automated component was used as review support rather than as a substitute for triage. It consisted of x-ray for architecture and invariant extraction, solidity-code-metrics for complexity prioritization, and MAIA for detector-based vulnerability discovery. Findings included in this report were selected after review for impact, exploitability, and consistency with the protocol's intended design.
Code Excerpt Note: Some code snippets include inline comments added by Bulwark to highlight relevant behavior or risk. These comments are report annotations and may not appear in the assessed source code.
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 |
|---|---|---|---|
| [H-01] | Repeated harvests can halt rewards or mint unbacked plsCYPH due to xCYPH double-counting | High | Resolved |
| [M-01] | Configured single-staker reward share is ignored during harvest | Medium | Resolved |
| [M-02] | Invalid reward percentages can halt all harvests | Medium | Open |
| [M-03] | Non-upgradeable Initializable import may corrupt proxy storage | Medium | Open |
| [M-04] | Missing storage gaps increase future upgrade collision risk | Medium | Open |
| [M-05] | Post-deployment configuration window leaves core roles and addresses unset | Medium | Partially Resolved |
| [L-01] | Admins can recover staked principal tokens from staking contracts | Low | Open |
| [L-02] | Zero staker addresses can silently discard distributed rewards | Low | Open |
| [L-03] | Zero-value rewards can permanently bloat reward-token sets | Low | Open |
| [L-04] | Single admin role controls upgrades and economic parameters | Low | Partially Resolved |
| [L-05] | Zero critical addresses can brick depositor and staker deployments | Low | Open |
| [L-06] | Last admin can renounce and permanently lock admin functions | Low | Open |
| [L-07] | No minimum lock period permits flash-staking reward dilution | Low | Open |
| [L-08] | Reward recovery can race approved reward pulls if harvest flow changes | Low | Open |
| [L-09] | Blocklisted treasury can freeze blocklistable reward harvests | Low | Open |
| [L-10] | Fixed 1e18 reward precision can lose dust rewards for low-decimal tokens | Low | Open |
| [I-01] | plsCYPH has no direct CYPH redemption path | Informational | Open |
| [I-02] | Future upgrades lack reinitializer migration pattern | Informational | Open |
| [I-03] | Withdrawals remain available while staking contracts are paused | Informational | Open |
| [I-04] | Raw approve calls reduce ERC20 compatibility in fee distribution | Informational | Open |
| [I-05] | Fee-distributor parameter changes are not emitted | Informational | Open |
| [I-06] | Raw approve call reduces ERC20 compatibility in CYPH staking | Informational | Open |
| [I-07] | Duplicate initializer assignment reduces deployment code clarity | Informational | Open |
| [I-08] | Foundry console logging remains in production code | Informational | Open |
| [I-09] | Reward update loop can reduce gas by caching storage | Informational | Open |
| [I-10] | Contract wallets require whitelist entry before depositing | Informational | Open |
| [I-11] | External harvest callback risk is theoretical but should remain documented | Informational | Open |
Mitigation Review 2026-06-09
The mitigation review was performed against commit 9ecd465.
Automated tests were executed with forge test and passed with 121 tests passing and 0 failures.
The fix for [H-01] was carefully reviewed line by line to assess its effectiveness.
| ID | Status | Mitigation Review Notes |
|---|---|---|
| [H-01] | Resolved | Fixed in commit 76234ca; CYPHStaker.handleClaim() now stakes and mints only the harvested xCYPH delta before reporting it as plsCYPH rewards. |
| [M-01] | Resolved | Fixed in commit 1a87bf3; singleStakerPercentage was removed and the single-staker allocation is now the explicit remainder after treasury and LP shares. |
| [M-05] | Partially Resolved | Partially fixed in commit 1a87bf3; DeployPlsCYPH.s.sol now performs full system wiring and ownership handoff, but required addresses and roles are still not configured atomically by initializers. |
| [L-04] | Partially Resolved | Partially fixed in commit 1a87bf3; the deployment script hands admin control to a multisig, but the contracts still use a single admin role for upgrades and sensitive parameter changes. |
All other findings remain open as of commit 9ecd465.
Detailed Findings
Critical Severity
No critical severity vulnerabilities were identified during the assessment.
High Severity
[H-01] Repeated harvests can halt rewards or mint unbacked plsCYPH due to xCYPH double-counting
Update 2026-06-09: Fixed in commit 76234ca.
CYPHStaker.handleClaim() now keeps the escrow token identity during balance accounting,
stakes only the harvested xCYPH delta,
mints only the harvested xCYPH delta,
and reports the converted reward as plsCYPH after the delta is calculated.
Description: CYPHStaker.handleClaim() currently treats the contract's entire xCYPH balance as the amount harvested in the current call,
but it should stake and mint only the xCYPH delta received from escrowStaking.harvestAllRewards().
This is problematic if allocated xCYPH remains in escrowToken.balanceOf(address(this)),
because every harvest after the first reuses previously harvested and allocated xCYPH as if it were newly received.
A second accounting issue makes the reward amount calculation fragile.
The function replaces rewardTokens[i] with address(plsCYPH) before computing the post-harvest balance delta,
but the pre-harvest snapshot was taken against escrowToken.
This compares the post-harvest plsCYPH balance against the pre-harvest xCYPH balance.
In the simple case with no stray balances this may produce the expected delta by coincidence,
but it does not directly measure the actual token movement from the harvest.
Location: src/CYPHStaker.sol:75-104
// Pre-harvest loop: snapshot taken against escrowToken
for (uint256 i; i < length; i++) {
rewardTokens[i] = escrowStaking.distributedToken(i);
...
rewardAmounts[i] = IERC20(rewardTokens[i]).balanceOf(address(this)); // escrowToken snapshot
console.log("Reward Token Before Harvest:", rewardTokens[i], rewardAmounts[i]);
if (rewardTokens[i] == address(escrowToken)) {
rewardTokens[i] = address(plsCYPH); // identity replaced BEFORE post-harvest read
}
}
escrowStaking.harvestAllRewards();
// Post-harvest: xCyphTokenAmount = FULL balance, not delta
uint256 xCyphTokenAmount = IERC20(address(escrowToken)).balanceOf(address(this));
for (uint256 i; i < length; i++) {
if (rewardTokens[i] == address(plsCYPH)) {
uint256 xCyphTokenDelta = xCyphTokenAmount - rewardAmounts[i];
if (xCyphTokenDelta != 0) {
_stake(xCyphTokenAmount); // BUG: full balance, not delta
plsCYPH.mint(address(this), xCyphTokenAmount); // BUG: full balance, not delta
}
}
// Fragile: reads plsCYPH.balanceOf(), subtracts escrowToken pre-harvest snapshot
rewardAmounts[i] = IERC20(rewardTokens[i]).balanceOf(address(this)) - rewardAmounts[i];
IERC20(rewardTokens[i]).approve(msg.sender, rewardAmounts[i]);
console.log("Reward Token After Harvest:", rewardTokens[i], rewardAmounts[i]);
}
Impact: The High severity depends on the external escrow accounting semantics.
If EscrowStaking.allocate() leaves allocated xCYPH in this contract's token balance while separately tracking available allocation,
every harvest after the first can revert and reward distribution can halt indefinitely.
If the external escrow permits repeated allocation of the same xCYPH balance,
CYPHStaker.totalStaked can be inflated and unbacked plsCYPH can be minted into the reward stream.
If allocation transfers xCYPH out of this contract or otherwise removes it from escrowToken.balanceOf(address(this)),
the repeated-balance impact is reduced,
but the implementation still relies on fragile balance accounting instead of explicit harvest deltas.
Recommendation: Snapshot escrowToken.balanceOf(address(this)) before harvestAllRewards() and stake only the post-harvest delta.
Keep the escrow token identity intact for balance accounting,
and convert the returned token address to address(plsCYPH) only after the delta is calculated.
uint256 preHarvestEscrow = IERC20(address(escrowToken)).balanceOf(address(this));
escrowStaking.harvestAllRewards();
uint256 xCyphTokenDelta = IERC20(address(escrowToken)).balanceOf(address(this)) - preHarvestEscrow;
if (xCyphTokenDelta != 0) {
_stake(xCyphTokenDelta);
plsCYPH.mint(address(this), xCyphTokenDelta);
}
Medium Severity
[M-01] Configured single-staker reward share is ignored during harvest
Update 2026-06-09: Fixed in commit 1a87bf3.
The implementation now removes singleStakerPercentage and uses the single-staker share as the explicit remainder after treasury and LP allocations.
Description: CYPHFeeDistributor._harvest() currently distributes rewards using only treasuryPercentage and lpStakerPercentage,
but it should either use singleStakerPercentage directly or clearly document that the single-staker share is always the remainder.
This is problematic because singleStakerPercentage is initialized and has a dedicated setter,
but _harvest() never reads it.
Location: src/CYPHFeeDistributor.sol:117-135
uint256 rewardsAmountToTreasury = (rewardsAmount * treasuryPercentage) / 10000;
rewardsToken.safeTransfer(treasury, rewardsAmountToTreasury);
uint256 rewardsAmountToLpStaker = (rewardsAmount * lpStakerPercentage) / 10000;
if (rewardsAmountToLpStaker != 0) {
rewardsToken.approve(address(lpStaker), rewardsAmountToLpStaker);
lpStaker.notifyRewardAmount(address(rewardsToken), rewardsAmountToLpStaker);
}
// singleStakerPercentage never used — single staker always receives the remainder
uint256 remainingRewardsAmount = rewardsAmount - rewardsAmountToTreasury - rewardsAmountToLpStaker;
if (remainingRewardsAmount != 0) {
rewardsToken.approve(address(singleStaker), remainingRewardsAmount);
singleStaker.notifyRewardAmount(address(rewardsToken), remainingRewardsAmount);
}
Impact: Admin calls to setSingleStakerPercentage() are silently ignored.
The single-staker share always equals 100% - treasury% - lpStaker%,
which makes on-chain configuration disagree with actual reward distribution.
Recommendation: Use singleStakerPercentage explicitly in _harvest() and validate that treasuryPercentage + lpStakerPercentage + singleStakerPercentage == 10000.
If the remainder model is intended,
remove singleStakerPercentage and setSingleStakerPercentage() and document the remainder rule.
[M-02] Invalid reward percentages can halt all harvests
Description: setLpStakerPercentage() and setSingleStakerPercentage() currently accept any value,
but they should bound each percentage and validate the combined reward split.
This is problematic because Solidity 0.8 checked arithmetic makes _harvest() revert when treasuryPercentage + lpStakerPercentage > 10000.
Location: src/CYPHFeeDistributor.sol:71-77
function setLpStakerPercentage(uint256 _lpStakerPercentage) external override onlyRole(DEFAULT_ADMIN_ROLE) {
lpStakerPercentage = _lpStakerPercentage;
}
function setSingleStakerPercentage(uint256 _singleStakerPercentage) external override onlyRole(DEFAULT_ADMIN_ROLE) {
singleStakerPercentage = _singleStakerPercentage;
}
Location: src/CYPHFeeDistributor.sol:117-130
// In _harvest():
uint256 rewardsAmountToTreasury = (rewardsAmount * treasuryPercentage) / 10000;
...
uint256 rewardsAmountToLpStaker = (rewardsAmount * lpStakerPercentage) / 10000;
...
uint256 remainingRewardsAmount = rewardsAmount - rewardsAmountToTreasury - rewardsAmountToLpStaker;
Impact: One mistaken admin transaction can make every subsequent harvest() call revert.
Rewards remain stuck in CYPHFeeDistributor until admin submits a corrective transaction.
Recommendation: Validate every setter against both the individual upper bound and the joint sum. Use a shared internal function so all percentage changes follow the same rule.
require(newTreasury + newSingleStaker + newLpStaker == 10000, "CYPH_FD: bad split");
[M-03] Non-upgradeable Initializable import may corrupt proxy storage
Description: PlsCYPHLPStaker currently imports Initializable from the non-upgradeable OpenZeppelin package,
but an upgradeable UUPS implementation should use the upgradeable package consistently.
This is problematic because the contract inherits upgradeable base contracts and relies on compatible initialization storage.
Location: src/PlsCYPHLPStaker.sol:8
// PlsCYPHLPStaker — NON-upgradeable variant
import { Initializable } from "@openzeppelin/contracts/proxy/utils/Initializable.sol";
// All other contracts — upgradeable variant
import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
Impact: Incompatible initialization storage can corrupt initialization state or interact unexpectedly with inherited upgradeable storage. The practical result can be a failed deployment, a contract that cannot initialize correctly, or an upgrade path that requires storage migration.
Recommendation: Replace the import with @openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol.
Run forge inspect PlsCYPHLPStaker storage-layout before and after the change to confirm the proxy storage layout is safe.
[M-04] Missing storage gaps increase future upgrade collision risk
Description: The UUPS contracts currently declare storage variables without reserving __gap slots,
but upgradeable contracts should reserve unused storage space for future state additions.
This is problematic because future implementations may need to add variables without colliding with inherited or existing storage.
Location: All src/*.sol
Impact: A future upgrade that adds storage without layout discipline can overwrite existing balances, roles, pausable state, or accounting variables. Such corruption may not revert immediately and can affect user funds or protocol control before operators notice.
Recommendation: Add reserved storage to each upgradeable contract after its declared state variables.
Before any upgrade,
compare the current and new storage layouts with forge inspect <Contract> storage-layout.
uint256[50] private __gap;
[M-05] Post-deployment configuration window leaves core roles and addresses unset
Update 2026-06-09: Partially fixed in commit 1a87bf3.
The new DeployPlsCYPH.s.sol deployment script deploys the full system,
wires cross-contract roles and addresses,
sets the treasury and whitelist,
and hands admin control to the multisig.
The underlying initializers still leave key dependencies and roles to post-deployment calls,
so the deployment is not fully atomic.
Description: Several critical dependencies are currently left unset by initialize() and require separate post-deployment admin transactions,
but deployment should configure all required addresses and roles atomically.
This is problematic because the protocol remains partially unusable until every follow-up configuration transaction succeeds.
Location: src/CYPHDepositor.sol:32-46,
src/CYPHFeeDistributor.sol:37-52,
src/PlsCYPHToken.sol:27-33
The missing configuration includes:
whitelistinCYPHDepositor, which defaults toaddress(0)and blocks non-EOA deposits.treasuryinCYPHFeeDistributor, which defaults toaddress(0)and makes treasury transfers fail.MINTER_ROLEinPlsCYPHToken, which must be granted beforeCYPHDepositororCYPHStakercan mint plsCYPH.HANDLER_ROLEandREWARD_DISTRIBUTOR_ROLE, which require additional role grants before normal protocol operation.
Impact: User-facing functions can revert between proxy deployment and full configuration. If a configuration transaction is missed, failed, or executed against the wrong address, the protocol can remain non-functional while appearing deployed. The issue increases launch risk and makes deployment harder to audit after the fact.
Recommendation: Accept all critical addresses as initialize() parameters,
validate each against address(0),
and grant required roles during initialization.
Use a deployment script assertion that verifies every required role and dependency before announcing launch.
Low Severity
[L-01] Admins can recover staked principal tokens from staking contracts
Description: recoverERC20() currently blocks recovery of registered reward tokens,
but it should also block recovery of the staking token itself.
This is problematic because plsCYPHToken and plsCYPHLPToken are user principal in their respective staking contracts.
Location: src/PlsCYPHStaker.sol:194-199,
src/PlsCYPHLPStaker.sol:202-207
Impact: A DEFAULT_ADMIN_ROLE holder can transfer staked principal out of the staking contracts without using an upgrade.
The issue depends on admin compromise or abuse,
so the severity is Low under the project's centralization trust model.
Recommendation: Reject recovery of the staking token in both contracts. Use a dedicated error or reuse the existing reward-token recovery guard.
require(tokenAddress != address(plsCYPHToken), "cannot recover staking token");
[L-02] Zero staker addresses can silently discard distributed rewards
Description: CYPHFeeDistributor.initialize() currently accepts _singleStaker and _lpStaker without zero-address validation,
but both addresses should be required to point to deployed staking contracts.
This is problematic because calls to a zero-address staker can succeed as empty external calls and leave rewards undistributed.
Location: src/CYPHFeeDistributor.sol:37-52
Impact: A deployment mistake can cause single-staker or LP-staker rewards to be skipped during every harvest. Users assigned to that bucket receive no rewards until the distributor is upgraded or reconfigured.
Recommendation: Validate both staker addresses during initialization. Also assert in deployment scripts that the addresses contain code.
require(_singleStaker != address(0), "invalid single staker");
require(_lpStaker != address(0), "invalid lp staker");
[L-03] Zero-value rewards can permanently bloat reward-token sets
Description: notifyRewardAmount(token, 0) currently registers token before returning,
but zero-value notifications should not add tokens to the reward set.
This is problematic because registered reward tokens are never removed and are iterated during user actions.
Location: src/PlsCYPHStaker.sol:172-188,
src/PlsCYPHLPStaker.sol:180-196
Impact: Repeated zero-value notifications can permanently increase gas costs for stake(),
withdraw(),
getReward(),
and future reward notifications.
At high token counts,
normal user interactions can become gas-prohibitive.
Recommendation: Check reward > 0 before adding a token to rewardTokens.
If zero-value notifications are useful for accounting,
add a token removal path controlled by governance.
[L-04] Single admin role controls upgrades and economic parameters
Update 2026-06-09: Partially fixed in commit 1a87bf3.
The deployment script grants DEFAULT_ADMIN_ROLE to the multisig and renounces the deployer admin role.
The contracts still use a single admin role for upgrades and economic parameters,
and no timelock or role separation was added.
Description: All seven contracts currently rely on DEFAULT_ADMIN_ROLE for upgrades and sensitive economic changes,
but production deployments should separate operational control and protect it with multisig or timelock controls.
This is problematic because one compromised admin key can immediately change protocol behaviour.
Location: All src/*.sol
Impact: A compromised or malicious admin can upgrade implementations, redirect treasury, change reward splits, or whitelist arbitrary contracts. The impact is limited by the project's accepted centralization model, but the operational blast radius is still large.
Recommendation: Transfer DEFAULT_ADMIN_ROLE to a Gnosis Safe before launch.
Consider a 24-48 hour timelock for _authorizeUpgrade() and economic parameter setters.
[L-05] Zero critical addresses can brick depositor and staker deployments
Description: CYPHStaker.initialize() and CYPHDepositor.initialize() currently do not validate critical dependency addresses,
but token and protocol contract addresses should never be address(0).
This is problematic because a single bad initializer argument can permanently break core deposit or staking flows.
Location: src/CYPHStaker.sol:39-51,
src/CYPHDepositor.sol:32-46
Impact: A zero token or dependency address can make deposits revert or send assets to an unrecoverable address. The issue is most likely during deployment or migration.
Recommendation: Add zero-address checks for _cyphToken,
_escrowStaking,
_xCyphToken,
_plsCYPH,
_cyphtoken,
and _cyphStaker.
Mirror the checks in deployment scripts.
[L-06] Last admin can renounce and permanently lock admin functions
Description: Inherited AccessControlUpgradeable.renounceRole() currently lets the only DEFAULT_ADMIN_ROLE holder renounce the role,
but the contract should prevent the final admin from orphaning admin-controlled functions.
This is problematic because no account can recover admin access after the last admin is removed.
Location: Inherited AccessControlUpgradeable in all contracts
Impact: Upgrades, parameter setters, pause controls, and role management can become permanently inaccessible. The protocol may be unable to respond to incidents or complete routine maintenance.
Recommendation: Use AccessControlDefaultAdminRules or override renounceRole() to reject renouncing the last DEFAULT_ADMIN_ROLE holder.
Test that at least one admin always remains after role changes.
[L-07] No minimum lock period permits flash-staking reward dilution
Description: PlsCYPHStaker and PlsCYPHLPStaker currently allow users to stake and withdraw in the same block,
but a protocol that wants long-term reward alignment should require a minimum staking period or cooldown.
This is problematic because rewards accrue linearly while capital can be supplied only briefly.
Location: src/PlsCYPHStaker.sol:108-118,
src/PlsCYPHLPStaker.sol:116-126
Impact: A short-term staker can capture a proportional share of active rewards and dilute longer-term stakers.
The impact is bounded because rewards stream linearly over _rewardsDuration and the attacker cannot extract the full reward pool instantly.
Recommendation: If same-block exit is not intended, add a one-block minimum stake duration or a configurable withdrawal cooldown. If the Synthetix-style model is intentional, document this trade-off for users.
[L-08] Reward recovery can race approved reward pulls if harvest flow changes
Description: CYPHStaker.handleClaim() currently approves reward tokens to msg.sender before CYPHFeeDistributor pulls them,
but approved-but-unpulled rewards should not be recoverable by admin.
This is problematic because recoverErc20() has no awareness of pending approvals.
Location: src/CYPHStaker.sol:119-122
Impact: In the current atomic harvest() flow,
the race window is effectively closed because approval and pull occur in one transaction.
If the flow is refactored or operated manually,
an admin recovery can remove tokens that the distributor expects to pull and make distribution fail.
Recommendation: Keep handleClaim() callable only from the atomic distributor flow,
or track pending reward amounts and exclude them from recoverErc20().
Add a code comment documenting the atomicity requirement.
[L-09] Blocklisted treasury can freeze blocklistable reward harvests
Description: _harvest() currently pushes the treasury share before distributing rewards to stakers,
but transfers to operational treasury addresses can fail for blocklistable tokens such as USDC.
This is problematic because a treasury-side transfer failure reverts the full harvest.
Location: src/CYPHFeeDistributor.sol:113-119
Impact: If the treasury address is blocklisted for a reward token, all harvests for that token revert until governance changes the treasury address. Staker rewards remain frozen even though the failure is isolated to the treasury recipient.
Recommendation: Use a pull-over-push treasury model. Accumulate treasury rewards in contract accounting and let the treasury claim them separately.
[L-10] Fixed 1e18 reward precision can lose dust rewards for low-decimal tokens
Description: rewardPerToken() currently uses a hardcoded 1e18 precision factor,
but reward precision should account for token decimals or enforce minimum reward sizes.
This is problematic because low-decimal tokens can round small reward rates down to zero.
Location: src/PlsCYPHStaker.sol:84-94
Impact: Dust-scale reward notifications for tokens such as USDC can fail to accrue meaningful rewards to users. At normal operational reward sizes, the math resolves correctly, so the risk is limited to small or accidental distributions.
Recommendation: Use a precision factor based on rewardToken.decimals() or require a minimum reward amount in notifyRewardAmount().
Document the minimum practical reward size for each supported reward token.
Informational Severity
[I-01] plsCYPH has no direct CYPH redemption path
Description: CYPHStaker currently has no function that converts plsCYPH back into CYPH,
but users may expect a direct redemption path unless the non-redeemable liquid staking model is documented.
This is by design because deposited CYPH is committed to the xCYPH escrow.
Location: src/CYPHStaker.sol
Impact: Holders can only exit through secondary markets. This is not a code vulnerability, but unclear documentation can create user confusion around liquidity and redemption assumptions.
Recommendation: Document prominently that plsCYPH is non-redeemable for CYPH through the protocol. Describe the expected secondary-market exit path in user-facing materials.
[I-02] Future upgrades lack reinitializer migration pattern
Description: The contracts currently do not define reinitializer(N) migration hooks,
but future upgrades that add state may need explicit one-time initialization.
This is problematic for maintainability because migrations must be designed from scratch during each upgrade.
Location: All src/*.sol
Impact: Upgrade execution becomes more error-prone as the protocol evolves. A missing migration can leave new variables unset or require manual recovery transactions.
Recommendation: Establish a versioned reinitializer pattern before the first state-changing upgrade. Document the migration checklist next to the upgrade deployment scripts.
[I-03] Withdrawals remain available while staking contracts are paused
Description: withdraw() currently is not gated by whenNotPaused,
but this appears intentional so users can exit during emergencies.
This is documented as an informational design note rather than a vulnerability.
Location: src/PlsCYPHStaker.sol,
src/PlsCYPHLPStaker.sol
Impact: Users retain access to staked funds while deposits and reward actions can be paused. This improves emergency safety but should be explicit for operators.
Recommendation: Document the pause policy. If the design changes, add tests that verify which actions remain available during pause.
[I-04] Raw approve calls reduce ERC20 compatibility in fee distribution
Description: CYPHFeeDistributor._harvest() currently uses raw IERC20.approve(),
but the rest of the codebase generally uses SafeERC20 wrappers.
This is problematic for compatibility with tokens that return false instead of reverting.
Location: src/CYPHFeeDistributor.sol:126,133
Impact: A non-standard reward token can fail to set allowance cleanly and make reward notification fail or behave inconsistently. This is a compatibility and code-quality issue rather than a direct exploit.
Recommendation: Replace raw approvals with SafeERC20.forceApprove().
Keep approval handling consistent across the codebase.
[I-05] Fee-distributor parameter changes are not emitted
Description: CYPHFeeDistributor setters currently mutate key parameters without emitting events,
but off-chain monitoring relies on events to detect governance changes.
This is problematic because dashboards and alerts must otherwise reconstruct state from direct storage reads.
Location: src/CYPHFeeDistributor.sol:71-111
Impact: Operators and users may miss changes to reward splits or treasury address. The issue affects observability rather than protocol correctness.
Recommendation: Emit events for setLpStakerPercentage(),
setSingleStakerPercentage(),
setTreasuryPercentage(),
and setTreasury().
Include both the old and new values.
[I-06] Raw approve call reduces ERC20 compatibility in CYPH staking
Description: CYPHStaker.stake() currently calls cyphToken.approve() directly,
but approval handling should use SafeERC20.forceApprove() for consistency.
This is problematic for tokens that implement non-standard approval return values.
Location: src/CYPHStaker.sol:54
Impact: A non-standard CYPH token implementation could fail approval handling unexpectedly. The current risk is informational because CYPH is a known dependency, but consistency reduces integration risk.
Recommendation: Replace the raw approval with SafeERC20.forceApprove().
Add a regression test for staking approval flow.
[I-07] Duplicate initializer assignment reduces deployment code clarity
Description: CYPHDepositor.initialize() currently assigns cyphStaker = _cyphStaker twice,
but each initializer assignment should be unique and intentional.
This is problematic because duplicate setup code signals a possible copy-paste error.
Location: src/CYPHDepositor.sol:38,42
Impact: The duplicate assignment is functionally harmless. It reduces reviewer confidence in deployment-critical code.
Recommendation: Remove the redundant assignment. Add initializer tests that assert all dependencies are assigned exactly once.
[I-08] Foundry console logging remains in production code
Description: CYPHStaker currently imports forge-std/console.sol and calls console.log() inside handleClaim(),
but production contracts should not include test-only logging.
This is problematic because logs add overhead and expose internal runtime values.
Location: src/CYPHStaker.sol:14,82,103
Impact: Harvest execution costs more gas and emits internal accounting values that indexers can observe. The issue is code hygiene rather than a direct security vulnerability.
Recommendation: Remove the forge-std/console.sol import and both console.log() calls before deployment.
Add a CI check that rejects console.sol imports in src/.
[I-09] Reward update loop can reduce gas by caching storage
Description: The updateReward modifier currently reads fields from _rewardData[token] individually during each iteration,
but repeated storage reads should be cached where practical.
This is problematic because gas costs grow with the number of registered reward tokens.
Location: src/PlsCYPHStaker.sol:172-188
Impact: User actions become more expensive as reward tokens are added. This is an optimization issue unless combined with unbounded token registration.
Recommendation: Cache _rewardData[token] into a storage pointer or local variables within the loop.
Benchmark stake(),
withdraw(),
and getReward() before and after the change.
[I-10] Contract wallets require whitelist entry before depositing
Description: CYPHDepositor.deposit() currently blocks contract callers unless they are whitelisted,
but many legitimate users interact through Gnosis Safe multisigs,
DAO treasuries,
or protocol integrations.
This appears intentional but creates an integration constraint.
Location: src/CYPHDepositor.sol:85-89
Impact: Contract wallets and integrations cannot deposit until governance adds them to the whitelist. This can cause onboarding failures if not communicated before launch.
Recommendation: Document the whitelist requirement for integrators. Provide a clear process for requesting whitelist entries.
[I-11] External harvest callback risk is theoretical but should remain documented
Description: CYPHStaker.handleClaim() currently calls the external escrowStaking.harvestAllRewards() function,
but any external call can theoretically re-enter through callbacks or token hooks.
This is documented for completeness because nonReentrant guards and the trusted escrow dependency substantially reduce the risk.
Location: src/CYPHStaker.sol:89
Impact: No practical exploit path was identified during the assessment. The note preserves reviewer context for future changes to the escrow integration.
Recommendation: Keep nonReentrant on both the staker and fee distributor harvest paths.
Reassess this item if the escrow dependency changes or if new callbacks are added.
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.