Kann Audits / Security Review

Mystic Finance

Mystic Finance Liquid Staking Security Review — December 2025

Review of Mystic Finance’s Plume staking, validator allocation, withdrawal, rewards, and price-feed contracts.

PlumeSolidityLiquid stakingDecember 9, 2025
Audit period
December 9, 2025
Researchers
3 listed
Scope
7 scoped paths
Technologies
Solidity, Liquid staking
Findings
5 documented

Executive summary

What was reviewed

Review of Mystic Finance’s Plume staking, validator allocation, withdrawal, rewards, and price-feed contracts.

This page reflects only the scope and review context disclosed in the published report. Fields the report does not provide are omitted rather than inferred; the PDF remains the source of record for issue detail and limitations.

Security is contextual. This report does not guarantee that the protocol is free from vulnerabilities. It applies to the review context documented in the report.

Scope & record

Engagement dossier

Researchers
Kann, Jun Wang, Pindarev
Audited commit
e7af6a3201546962eedd17229181138ac9a5830a
Technologies
Solidity, Liquid staking
Chain / ecosystem
Plume
Category
Liquid Staking

Files and paths in scope

  • stPlumeMinter.sol
  • OperatorRegistry.sol
  • stPlumeRewards.sol
  • frxETHMinter.sol
  • frxETH.sol
  • Periphery/MyPlumeFeed.sol
  • Utils/OwnedUpgradeable.sol

Findings overview

Severity distribution

The counts below are transcribed from the published report. Status and issue detail remain subject to that report’s exact terminology.

FINDINGS05Documented in the published report
Medium: 1Low: 2Informational: 2
SeverityCount
Critical0
High0
Medium1Low2Informational2

Published findings

Findings

Findings below are reproduced from the complete Kann Audits security review. View the full PDF for complete scope, methodology, assumptions, and audit context.

Medium

1 finding
4.1.1

Temporary DoS of User Withdrawals Due to Partial Plume Withdrawals Creating Artificial Shortfall

Medium
Description

When multiple users interact with the unstake and withdraw logic at different times, a user can be temporarily DoS’d from withdrawing their own funds because of how plumeStaking. withdraw() and the local deficit check are combined. Consider this flow: UserAcallsunstakewith50ETH.ThisamountgetsunstakedfromPlumeonceeitherwithdrawalQueueThreshold or nextBatchUnstakeTimePerValidator is reached. User B calls unstake with a larger amount, for example 350 ETH. It takes at least four validators to satisfy this (assuming each has around 100 ETH capacity). Eventually, the full 350 ETH for User B is also unstaked on Plume. At this point, Plume holds around 400 ETH of unstaked funds belonging to both User A and User B. User A then calls withdraw. Inside _withdraw, the contract executes: if (totalWithdrawable > 0) { uint256 balanceBefore = address(this).balance; plumeStaking.withdraw(); uint256 balanceAfter = address(this).balance; withdrawn = balanceAfter - balanceBefore; } This plumeStaking.withdraw() call pulls all 400 ETH of cooled funds from Plume into the contract’s balance. Plume now shows zero withdrawable ETH, but the contract’s internal buffers reflect that ETH. Later, other users (call them User Group M) also unstake their funds and get cooling/parked. Once their cooldowns finish, their amounts become withdrawable on Plume too. Now User B calls withdraw. The code sees totalWithdrawable > 0 again, so it calls plumeStaking. withdraw() a second time. This time, only the newly cooled funds from User Group M are withdrawn from Plume and assigned to withdrawn. This withdrawn value is less than the 350 ETH that User B expects. The function then hits the deficit logic: if (withdrawn > 0 && withdrawn < amount) { require(amount - withdrawn < fee, "Insufficient funds to cover deficit"); } Here, amount is User B’s requested 350 ETH, and withdrawn is the much smaller amount that just came from User Group M. The condition amount - withdrawn < fee will almost always be false because the fee is tiny compared to the missing difference. As a result, the withdrawal reverts.

At this point, User B is effectively DOS’ed from withdrawing. Every time they try, they will again pull only the small cooled balance from Plume, fail the same require(amount - withdrawn < fee) check, and revert. They remain stuck until either another user with a withdrawal request calls withdraw first and drains the Plume balance in a way that avoids the partial-withdraw branch, or enough new unstake events accumulate so that the next plumeStaking.withdraw() call returns at least 350 ETH in one shot. So even though User B has correctly unstaked and the system as a whole has enough ETH across buffers (currentWithheldETH, totalInstantUnstaked) and future withdrawals, the specific interaction between plumeStaking.withdraw() and the require(amount - withdrawn < fee) deficit check can temporarily lock that user out of claiming their funds. Additionally, User B would normally be allowed to withdraw using currentWithheldETH alone if the totalWithdrawable from plume was 0. However, because withdrawn is non-zero, the contract is forced into the deficit branch instead of using local liquidity because withdrawn‘‘‘ <amount‘. This guarantees a revert as soon as enters check withdrawn < amount, making the DOS unavoidable until conditions change.

Resolution

Fixed 4.2 Low

Low

2 findings
4.2.1

Inefficient Max-Percentage Check Prevents Using Valid Partial Capacity

Low
Description

In the auto-distribution path of _depositEther, the contract decides whether a validator can be used by calling getNextValidator(remainingAmount, validatorId) with the full remainingAmount: while (remainingAmount > 0 && index < numVals) { uint256 depositSize = remainingAmount; _validatorId = uint16(validators[index].validatorId); (uint256 validatorId, uint256 capacity) = getNextValidator(remainingAmount, _validatorId); if (capacity > 0 && capacity >= minStakeAmount) { if (capacity < depositSize) { depositSize = capacity; } if (depositSize < minStakeAmount) { currentWithheldETH += remainingAmount; return depositedAmount; } plumeStaking.stake{value: depositSize}(uint16(validatorId)); remainingAmount -= depositSize; depositedAmount += depositSize; emit DepositSent(uint16(validatorId)); } index++; }

Inside getNextValidator, the validator’s new percentage is computed using depositAmount equal to that full remainingAmount: function getNextValidator(uint256 depositAmount, uint16 validatorId) internal view returns (uint256 validatorId_, uint256 capacity_) { (bool active, , uint256 stakedAmount, ) = plumeStaking.getValidatorStats( validatorId); uint256 totalStaked = plumeStaking.totalAmountStaked(); if (!active) return (validatorId, 0); (, capacity_) = _getValidatorInfo(validatorId); uint256 percentage = ((stakedAmount + depositAmount) * RATIO_PRECISION) / (totalStaked + depositAmount); if (maxValidatorPercentage[validatorId] > 0 && percentage > maxValidatorPercentage[validatorId]) { return (validatorId, 0); // validator is skipped entirely } if (capacity_ > 0) { return (validatorId, capacity_); } return (validatorId, 0); } The critical line is: if (maxValidatorPercentage[validatorId] > 0 && percentage > maxValidatorPercentage[ validatorId]) { return (validatorId, 0); } If sending the entire remainingAmount would push a validator above its maxValidatorPercentage, getNextValidator returns a capacity of 0, and _depositEther skips that validator completely. However, _depositEther later caps depositSize to capacity only if capacity > 0, so this partial capping never happens for validators that are rejected at the percentage check. This means that if a validator still has some allowed headroom under both capacity and maxpercentage, but not enough to take the full remainingAmount, the contract refuses to use it at all. It does not attempt a partial deposit that would keep the validator within limits. For example, a validator with max capacity 10 ETH and current stake 8 ETH still has 2 ETH of safe room. IfremainingAmount is5ETH,thepercentageiscalculatedasif5ETHweregoingtothisvalidator. Ifthat hypothetical 5 ETH would violate the max percentage, the validator is excluded, even though putting just 2 ETH there would be perfectly valid. As a result, those 2 ETH are never used for this validator; the loop just moves to the next one with the full 5 ETH still in remainingAmount. Validators that still have valid headroom (for example, 1–2 ETH) can be skipped entirely if the full remainingAmount would break the percentage limit.

This leads to under-utilization of individual validator capacities and reduces how evenly stake is spread, since the allocator refuses to use small remaining slots on validators that are close to their limit. In practice, more ETH may end up routed to fewer validators.

StatusAcknowledged

Link to this finding
4.2.2

Restake() May Re-Stake Funds Already Reserved for User Withdrawals

Low
Description

The restake() function can reuse funds that users have already unstaked and are waitingtowithdraw. Whenuserscallunstake(), theirETHenterscooling/parkedstateunderthecontract, but restake() does not check whether those funds are already committed to pending withdrawal requests. It simply calls: plumeStaking.restake(validatorId, amount); and consumes whatever cooling balance exists. This allows the rebalancer to restake ETH that users are expecting to withdraw after their cooldown period. If this happens, users may reach the end of their cooldown and find that their withdrawal cannot be fulfilled because their cooled funds were re-staked before they could withdraw them. This can lead to temporary withdrawal delays or failed withdrawals until the protocol unstake cycle produces new cooled funds again.

Resolution

Acknowledged 4.3 Informational

StatusAcknowledged

Link to this finding

Informational

2 findings
4.3.1

Wrong Amount Check in unstakeGov Uses Total Validator Stake Instead of This Contract’s Stake

Informational
Description

The unstakeGov function currently does: function unstakeGov(uint16 validatorId, uint256 amount) external nonReentrant onlyByOwnGov returns (uint256 amountRestaked) { _rebalance(); (bool active, , uint256 stakedAmount, ) = plumeStaking.getValidatorStats(uint16(validatorId)); // total stake on this validator if (active && stakedAmount > 0 && stakedAmount >= amount) { amountRestaked = plumeStaking.unstake(uint16(validatorId), amount); } }

Here, stakedAmount is the total amount staked on the validator by all users, not the amount staked by this contract. The function should instead compare amount against this contract’s own stake on that validator, using: uint256 validatorStakedAmount = plumeStaking.getUserValidatorStake(address(this), validatorId); so the precondition matches what this contract is actually allowed to unstake. In practice, if amount is larger than this contract’s stake, plumeStaking.unstake will revert internally, so funds are not at risk. However, the local check is misleading: it may pass when this contract’s own stake is smaller, and the revert will only happen deeper in the staking contract. This makes the API less clear and can cause unnecessary reverts instead of failing fast with an accurate condition. The suggested fix is to keep active from getValidatorStats, but replace the stakedAmount comparison with a comparison against getUserValidatorStake(address(this), validatorId).

StatusAcknowledged

Link to this finding
4.3.2

ERC20 Transfer Handling in recoverERC20

Informational
Description

The function uses: require(IERC20(tokenAddress).transfer(to, tokenAmount), "recoverERC20: Transfer failed"); This assumes every ERC20 token returns a bool. Many tokens do not return a boolean at all, which will cause this call to revert even when the transfer would succeed. Because of this, the contract may be unable to recover certain ERC20 tokens sent to it by mistake.

Recommendation

Use SafeERC20.safeTransferinstead. Safe transfer helpers correctly handle tokens that do not return a boolean.

StatusAcknowledged

Link to this finding

Methodology

How Kann Audits reviews code

Kann Audits reports describe independent researcher review followed by collaborative analysis of findings and attack paths. The standard review foundation includes:

  1. 01Architecture and trust-boundary analysis
  2. 02Independent manual review
  3. 03State-transition and invariant analysis
  4. 04Access-control and integration review
  5. 05Adversarial testing and attack-path analysis
  6. 06Fix verification and regression review

Audit team

Researchers listed in the report

KannJun WangPindarev

Final assessment

Documented outcome

The medium finding was fixed; the two low and two informational findings were acknowledged.

The assessment applies only to the review context and limitations documented in the published report. Missing details are not inferred, and later changes require separate analysis.

Start a conversation

Planning your next release?

Share the system, fixed scope, and target date. Build enough time into the plan for review, remediation, and verification.