Kann Audits / Security Review

Mystic Finance

Mystic Finance RWA Security Review — June 2025

Review of stPlumeMinter and frxETH withdrawal, validator-deposit, rewards, and governance-withdrawal logic.

PlumeSolidityLiquid stakingJune 6, 2025
Audit period
June 6, 2025
Researchers
3 listed
Scope
2 scoped paths
Technologies
Solidity, Liquid staking
Findings
8 documented

Executive summary

What was reviewed

Review of stPlumeMinter and frxETH withdrawal, validator-deposit, rewards, and governance-withdrawal logic.

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, Strapontin, TradMod
Audited commit
a7a86bdc3d65c5c2512fe6a835c10b4bc2d4315d
Technologies
Solidity, Liquid staking
Chain / ecosystem
Plume
Category
RWA

Files and paths in scope

  • /src/stPlumeMinter.sol
  • /src/frxETH.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.

FINDINGS08Documented in the published report
High: 2Medium: 2Informational: 4
SeverityCount
Critical0
High2Medium2
Low0
Informational4

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.

High

2 findings
4.1.1

Incorrect Accounting of currentWithheldETH in Instant Withdrawals

High
Description

In the withdraw(address recipient) function, when the withdrawal amount is less than or equal to currentWithheldETH, the function enters the instant withdrawal path. In this case, the contract does not call plumeStaking.withdraw(), and the funds are expected to be fully covered by the ETH already held in currentWithheldETH. However, the code still sets withdrawn = amount and then unconditionally adds this value back to currentWithheldETH. Immediately after, it subtracts amount from currentWithheldETH, effectively leaving the value of currentWithheldETH unchanged. } else { fee = amount * INSTANT_REDEMPTION_FEE / RATIO_PRECISION; withdrawn = amount; } uint256 cachedWithheldETH = currentWithheldETH; currentWithheldETH += withdrawn; //adding the withdraw amount withHoldEth += fee; currentWithheldETH -= amount; //deducting the amount This logic is flawed because the ETH used to fulfill the withdrawal is not newly received; it is already presentincurrentWithheldETH,andshouldsimplybesubtracted,notfirstaddedandthensubtracted. The current logic makes it appear as if no ETH was withdrawn, which results in incorrect accounting of the withheld pool. If multiple such withdrawals occur, the system will consistently over-report the available withheld ETH, potentially leading to over-withdrawals or failed withdrawals in the future when actual funds are not available.

4.1.2

User Funds Burned With No ETH Returned Due to Missing withdrawalRequests[msg.sender] Assignment After Unstake

High
Description

The unstaking process is executed in two steps: The user initiates unstake(). If currentWithheldETH is insufficient, the contract attempts to unstake from the Plume validator. However, during the fallback logic that handles validator unstaking, the contract returns amountUnstaked without saving it in the withdrawalRequests[msg.sender] mapping. This causes a critical failure in the withdrawal flow. Users who unstake in this path will have their frxETH tokens burned but will receive no ETH in return because No withdrawalRequest is saved. The withdraw() function depends on withdrawalRequests[msg.sender] being populated.

This effectively results in loss of user funds with no means of recovery through the intended withdraw flow.

Resolution

Fixed 4.2 Medium Risk

Medium

2 findings
4.2.1

Incorrect increasing of end timestamp causes OverExtended delay in syncRewards()

Medium
Description

The syncRewards() function contains faulty logic that causes the rewardsCycleEnd to be pushed one full cycle further than necessary. When the function is called late into the current cycle—for example at timestamp 180 with a rewardsCycleLength of 100—it calculates the next rewardsCycleEnd as 200, but then adds an extra 100, setting it to 300. This happens because of the condition: if (end - timestamp < rewardsCycleLength) { end += rewardsCycleLength; } At timestamp 180, the current cycle (ending at 100) is already complete, and only 20 seconds remain to complete the next one. However, instead of moving to 200 as expected, the function pushes the end to 300, delaying the next sync by a full cycle. This causes up to nearly one full cycle of rewards to be skipped and forces users to wait longer than necessary to receive rewards.

4.2.2

_depositEther Does Not Increment Validator Index, Causing All Deposits to Funnel Into First Validator and Fail Once Full

Medium
Description

In the stPlumeMinter contract, the _depositEther() function is responsible for distributing incoming ETH deposits across available validators. This function loops through the validator set to find suitable validators with enough capacity to stake the deposited ETH. However, there is a critical issue: the index variable, initialized to 0, is never incremented inside the while loop. As a result, the loop evaluates the same validator (validators[0]) in every iteration, preventing the function from ever progressing to the next validator in the list. When the first validator reaches its staking capacity or becomes inactive, the getNextValidator() function returns a capacity of 0. Since depositSize < minStakeAmount in such cases, the full remaining- Amount is stored in the currentWithheldETH variable. This means the user’s deposit is held back and never actually staked, despite the user receiving yield-bearing tokens.

Resolution

Fixed 4.3 Informational

Informational

4 findings
4.3.1

submitForValidator() Blocked by Strategic Capacity Manipulation

Informational
Description

Intentionally people could leave capacity almost to max - (min stake value + 1) so nobody could call submitForValidator() for this exact validator. Which will result in logic going always on increasing the currentWitheldETH

StatusAcknowledged

Link to this finding
4.3.2

Missing Length Consistency Check Between amounts and tokens Arrays in claimAll()

Informational
Description

In the claimAll() function, the amounts and tokens arrays are expected to correspond to each other by index. However, the code does not validate that both arrays are of the same length before using them in a for loop. If plumeStaking.claimAll() and plumeStaking.getRewardTokens() return arrays of different lengths, this can lead to out-of-bounds access or incorrect logic execution. To prevent potential errors or misaligned reward handling, a check should be added: require(amounts.length == tokens.length, "Mismatch between amounts and tokens");

StatusAcknowledged

Link to this finding
4.3.3

AllRewardsClaimed is not emitted when all rewards are claimed

Informational
Description

In stPlumeMinter there is a function called claimAll that is used to claim rewards accumulatedfromallrewardstokens, buthereisthecasethattheeventAllRewardsCLaimed isnotemitted after all rewards have been claimed

StatusAcknowledged

Link to this finding
4.3.4

withdrawGov will always return zero

Informational
Description

This function is supposed to return how much owner or timelock(i.e Gov) withdrawed fromplumeStaking butthereturnvalueamountRestaked isnotupdatedinthefunctionandasaresult it always returns zero. function withdrawGov(uint256 amount) external nonReentrant onlyByOwnGov returns ( uint256 amountRestaked) { _rebalance(); uint256 balanceBefore = address(this).balance; plumeStaking.withdraw(); uint256 balanceAfter = address(this).balance; currentWithheldETH += balanceAfter - balanceBefore; }

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

KannStrapontinTradMod

Final assessment

Documented outcome

The two high and two medium findings were fixed; four 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.