Skip to content
$1,000,000 in security audit grants are live now, Apply here →

Security review · June 2025

Fixed and Dynamic Staking

for Magna

Magna engaged Guardian to review the security of their Magna fixed and dynamic staking. From the 2nd of June to the 4th of June, a team of 3 auditors reviewed the source code in scope.

Published
Review window
June 2 to 4, 2025
Language
Solidity
Chains
Ethereum, Base, Optimism, Polygon, Arbitrum, BNB Chain
Sector
Staking
  • 0 Critical
  • 1 High
  • 3 Medium
  • 17 Low
  • 0 Informational

17 resolved · 4 acknowledged

Scope

Overview

Magna engaged Guardian to review the security of their Magna fixed and dynamic staking. From the 2nd of June to the 4th of June, a team of 3 auditors reviewed the source code in scope.

Findings 21

  1. H-01 High FixedStaking Doesn't Consider Token Decimals Unexpected Behavior Resolved
    Location
    FixedStaking.sol: 435

    Description

    Proof of concept: PoC

    FixedStaking can be deployed by anyone using any two tokens as stakeToken and rewardToken. The earned amount of rewards are calculated in calculateRewardAmountFutureDate() as

    FixedPointMathLib.mulDiv(stakeAmount, (scaledInterestRate - WAD), WAD)
    

    This means the result ends up being in stake token decimals even though it's used in the context of rewards. This mistake leads to unexpected results depending on what decimals the tokens have and how many rewards are currently available.

    For example, if the stakeToken has 18 decimals, but the rewardToken has 6, in most cases the earned amount will be stored in pendingRewards and users won't be able to claim it or if they do, they will earn a lot more than they have to.

    The other case is also possible - 6 decimals for stakeToken and 18 decimals for rewardToken - then users will receive almost nothing compared to what they are entitled to.

    Recommendation

    Store the two tokens' decimals by loading them in the constructor and modify calculateRewardAmountFutureDate() to return the result in rewards decimals.

    Resolution

    Magna Team: Resolved in commit 74b3f78a8917a425b0893a5ad09d5ef93d158d1b.

  2. M-01 Medium User Can Achieve Max Boost Unexpected Behavior Resolved
    Location
    FixedStaking.sol: 303-307

    Description

    Users can initiate last minute deposits prior to the admin updating the stake state. The user is able to enter a large lockDuration .

    They will immediately be able to claim the rewards when the stake state changes to SKIP_LOCKUP_ALLOWED and the large lock duration will apply the maxBoost to the user’s rewards, even though their funds were only locked up for a short time.

    Recommendation

    Ensure that the unlock time can not be later than the compoundEndDate to avoid last second locks.

    Resolution

    Magna Team: Resolved in commit 74b3f78a8917a425b0893a5ad09d5ef93d158d1b.

  3. M-02 Medium Underflow On Interest Percentage Calculation Math Resolved
    Location
    FixedStaking.sol: 475

    Description

    Proof of concept: PoC

    FixedStaking.calculateInterestPercentageFutureDate() calculates elapsedTime by taking the maximum between (0, currentTimestamp - startDate).

    The problem is that both currentTimestamp and startDate are unsigned integers. Because of this, when startDate > currentTimestamp, the result won't be 0, but the transaction with revert with an underflow instead.

    This can happen when a user has staked after compoundingEndDate. The user will lose their staked funds as a result.

    Recommendation

    • uint256 elapsedTime = FixedPointMathLib.max(0, currentTimestamp - startDate);
    + uint256 elapsedTime = currentTimestamp > startDate • currentTimestamp -
    startDate :  0;
    

    Resolution

    Magna Team: Resolved in commit 74b3f78a8917a425b0893a5ad09d5ef93d158d1b.

  4. M-03 Medium Fee Discount Via Multicall Logical Error Resolved
    Location
    DynamicStaking.sol

    Description

    DynamicStaking.unstake() and DynamicStaking.claim() require that msg.value = unstakeFee and msg.value = claimFee respectively.

    However, the Multicall contract is inherited which allows users to execute multiple unstake() and claim() calls in the same transaction.

    As a result, the msg.value will always be the same value for the different calls and users can execute the function many times, but pay only once.

    If claimFee = unstakeFee, they can even batch a call to both claim() and unstake() and pay only once.

    Recommendation

    An easy solution is to add a check to the constructor that if the fees are positive, they must not be equal. However, this solution limits the flexibility of the contract.

    Resolution

    Magna Team: Resolved in commit 74b3f78a8917a425b0893a5ad09d5ef93d158d1b.

  5. L-01 Low Inaccurate getClaimable() Results Unexpected Behavior Resolved
    Location
    FixedStaking.sol: 377-409

    Description

    FixedStaking.getClaimable() returns the amount of rewards earned by a given active stake or all active stakes.

    However, it doesn't check if the state is FORCEFULLY_TERMINATED. If it is, the actual claimable amount is 0, but they will return a positive amount.

    Recommendation

    Return 0 if state = State.FORCEFULLY_TERMINATED.

    Resolution

    Magna Team: Resolved in commit 74b3f78a8917a425b0893a5ad09d5ef93d158d1b.

  6. L-02 Low Pool Capacity Validation Can Be Bypassed Validation Resolved
    Location
    FixedStaking.sol: 151

    Description

    The constructor of FixedStaking ensures that poolCapacity > 0, but this check is not present in the changePoolCapacity() function.

    Calling that function right after deployment bypassed the check in the constructor and the value can be set to 0.

    Recommendation

    Consider implementing the check in changePoolCapacity() as well.

    Resolution

    Magna Team: Resolved in commit 74b3f78a8917a425b0893a5ad09d5ef93d158d1b.

  7. L-03 Low User Stakes Can Be Spammed DoS Resolved
    Location
    FixedStaking.sol: 260-276

    Description

    FixedStaking.stake() allows staking on behalf of any user without any restrictions on the staked amount besides it's positive.

    This allows anyone to spam the stakes of another user by depositing 1 wei multiple times and in result because getClaimable() and getClaimableIncludingPending() revert with OOG when looping through the stakes. It can also cause problems for any integrators using getNumberOfStakes().

    Recommendation

    Consider requiring minimum stake amount if initiator = onBehalfOf

    Resolution

    Magna Team: Resolved in commit 74b3f78a8917a425b0893a5ad09d5ef93d158d1b.

  8. L-04 Low poolCapacity Update Can Be Frontrun Informational Acknowledged
    Location
    FixedStaking.sol

    Description

    The changePoolCapacity() function in FixedStaking allows the admin to change the capacity of the pool at any given point in time.

    They can use it to change the capacity to a lower value, but anyone can frontrun their transaction and stake before the changes have taken place.

    For example:

    • totalStaked = 1000
    • poolCapacity = 1500
    • the admin submits a transaction to change poolCapacity to 1200
    • A staker frontruns them (or their transaction just ends up earlier in the block) and stakes up to 1500
    • poolCapacity is set to 1200, but totalStaked = 1500

    Recommendation

    This is a common problem encountered with ERC20.approve() as well. You can document this behavior and be careful when you change the limit.

    Resolution

    Magna Team: Acknowledged.

  9. L-05 Low withdrawOnBehalf() Marked As Payable Informational Resolved
    Location
    DynamicStaking.sol: 225-230

    Description

    The function withdrawOnBehalf() is marked as payable but does not require a fee for its execution.

    Recommendation

    Consider removing the payable keyword to avoid confusion.

    Resolution

    Magna Team: Resolved in commit 74b3f78a8917a425b0893a5ad09d5ef93d158d1b.

  10. L-06 Low Wrong Error Usage Validation Resolved
    Location
    FixedStaking.sol: 264

    Description

    FixedStaking.stake() incorrectly reverts with the ZeroAddressPassed() error if the stake is using a lock less than the minimum allowed one.

    Recommendation

    It should revert with MinimumLockDurationViolated() instead.

    • require(lockDuration = minimumLockDuration, ZeroAddressPassed());
    + require(lockDuration = minimumLockDuration, MinimumLockDurationViolated());
    

    Resolution

    Magna Team: Resolved in commit 74b3f78a8917a425b0893a5ad09d5ef93d158d1b.

  11. L-07 Low State Is Broken After defund Informational Resolved
    Location
    DynamicStaking.sol

    Description

    Once DynamicStaking.defundContractBalance() is called, all of the tokens in the contract - stakes + unstakes + rewards are send to a recipient address, but totalStaked and entries of the user stakes are not modified at all, which breaks the accounting mechanism of the contract.

    Recommendation

    Consider pausing the contract when defunding so users won't interact with it anymore.

    Resolution

    Magna Team: Resolved in commit 74b3f78a8917a425b0893a5ad09d5ef93d158d1b.

  12. L-08 Low Campaign Change Can Cause Zero Reward Rate Rewards Resolved
    Location
    DynamicStaking.sol

    Description

    DynamicStaking.modifyCampaign() doesn't check the length of the reward period - its end must only be in the future.

    If the reward period is too long, it can cause the reward rate to become too low, resulting in 0 rewards for stakers because of rounding issues.

    Recommendation

    Consider checking that reward rate is at least greater than 0 at the end of modifyCampaign.

    require(rewardRemaining * WAD / (periodFinish - lastUpdateTime) > 0);
    

    Resolution

    Magna Team: Resolved in commit 74b3f78a8917a425b0893a5ad09d5ef93d158d1b.

  13. L-09 Low 0 Transfer Reverts Best Practices Resolved
    Location
    DynamicStaking.sol

    Description

    DynamicStaking.modifyCampaign() transfers the token unconditionally in the else statement, even if the amount is 0. This may result in reverts for some tokens that fail on 0 transfer.

    It may be a desired use case to call modfyCampaign() with the same amount as the contract holds if for example it couldn't be claimed during the last distribution.

    Recommendation

    Consider transferring the tokens only if amount is not 0.

    Resolution

    Magna Team: Resolved in commit 74b3f78a8917a425b0893a5ad09d5ef93d158d1b.

  14. L-10 Low Contract Can Be Created Informational Resolved
    Location
    FixedStaking.sol: 114

    Description

    The SUPER_ADMIN role is the only role allowed to handle the following processes:

    Super admin manage other roles like admin can initiate all state transitions
    can rescue reward tokens at any time if it was allowed during the creation of
    the pool can rescue staked tokens if it was allowed during the creation of the
    pool
    

    However, the constructor only checks that there is at least 1 admin role or 1 super admin role, but does not enforce that there is at least 1 super admin role. This will disallow any of the above processes from happening.

    require((params.superAdmins.length + params.admins.length > 0)
    

    Recommendation

    Ensure that there is at least one super admin role assigned in the constructor.

    Resolution

    Magna Team: Resolved in commit 74b3f78a8917a425b0893a5ad09d5ef93d158d1b.

  15. L-11 Low Loss Of Interest Because Of Precision Loss Rounding Acknowledged
    Location
    FixedStaking.sol: 476

    Description

    FixedStaking.calculateInterestPercentageFutureDate() computes the interest percentage the user is eligible to receive when they unstake their tokens. This percentage depends on how many periods have passed

    periodsElapsed = (elapsedTime) / compoundingPeriodLength
    

    Because there is no scaling applied to these values, unstaking even a second earlier will result in a loss of interest for the user for 1 entire period.

    In addition, if the state was transition to TERMINATED or the block.timestamp is beyond compoundingEndDate, then lastValidTimestamp will be used to calculate timeElapsed and the user won't be able to time their unstake transaction even if they are aware of the issue.

    The contract configuration is permissionless, this means a period can be 7 days, a year, 4 years, etc... Losing 1 whole period of rewards is a substantial amount in these cases.

    Recommendation

    function calculateInterestPercentageFutureDate(uint256 startDate, uint256 futureDate)
         public view virtual returns (uint256 interestRate) {uint256 lastValidTimestamp =
    terminationTimestamp = 0 • FixedPointMathLib.min(futureDate, terminationTimestamp) :
    futureDate; uint256 currentTimestamp = FixedPointMathLib.min(lastValidTimestamp,
    compoundingEndDate); uint256 elapsedTime = FixedPointMathLib.max(0, currentTimestamp -
    startDate); • uint256 periodsElapsed = elapsedTime / compoundingPeriodLength; + uint256
    periodsElapsed = elapsedTime * WAD / compoundingPeriodLength; • uint256 allowedPeriodsElapsed
    = maxCompoundingPeriods = 0 • FixedPointMathLib.min(periodsElapsed, maxCompoundingPeriods) :
    periodsElapsed; + uint256 allowedPeriodsElapsed = maxCompoundingPeriods = 0 •
    FixedPointMathLib.min(periodsElapsed, maxCompoundingPeriods * WAD) : periodsElapsed; • int256
    interest = FixedPointMathLib.powWad(int256(WAD + interestRatePerPeriod),
    int256(allowedPeriodsElapsed * WAD)); + int256 interest = FixedPointMathLib.powWad(int256(WAD
    + interestRatePerPeriod), int256(allowedPeriodsElapsed));
    interestRate = uint256(interest);}
    

    Resolution

    Magna Team: Acknowledged.

  16. L-12 Low Insufficient ConstructorParams Validation Validation Acknowledged
    Location
    FixedStaking.sol

    Description

    A lot of the fields passed in IFixedParams.ConstructorParams are not sufficiently validated:

    • compoundingPeriodLength is not capped to a reasonable value. If it's set to a very large value like

    type(uint256).max, every time periodElapsed which is calculated as elapsedTime / compoundingPeriodLength will end up being 0 and users will never receive rewards.

    • interestRatePerPeriod should be a value in a reasonable range. If it's not it's possible to grief users

    by exploiting WAD + interestRatePerPeriod > type(uint256).max and making calculateInterestPercentageFutureDate() always revert. It should also be in reasonable ranges because if it's too big, there will never be enough rewards to be paid out.

    • withdrawRewardsDeadline must not be lower than a given value chosen by the protocol because

    now it can be set to 0 which allows the admin to transition the state from INITIALIZED all the way to REWARD_RESCUABLE in a single block and withdraw all the rewards.

    • boostYOffset and boostdYdX should also be in reasonable ranges because boostYOffset +

    boostdYdX * extraLockSeconds should not surpass type(uint256).max

    Recommendation

    Implement the proposed validations in the FixedStaking constructor.

    Resolution

    Magna Team: Acknowledged.

  17. L-13 Low Misused Variable tokenAmountToRescue Informational Resolved
    Location
    FixedStaking.sol: 205

    Description

    In the emergencyRescueStakeTokens function, there is the following check:

    if (tokenAmountToRescue > 0)
    {totalStaked = rescuedTokenAmount; stakeToken.safeTransfer(rescuedTokenReceiver,
    rescuedTokenAmount);}
    

    However, this should always evaluate to true because tokenAmountToRescue is a function parameter. It would not make sense to call this function with a zero value.

    Recommendation

    Use rescuedTokenAmount in the if clause instead.

    Resolution

    Magna Team: Resolved in commit 74b3f78a8917a425b0893a5ad09d5ef93d158d1b.

  18. L-14 Low withdrawOnBehalf() Always Withdraws For Msg.sender Informational Resolved
    Location
    DynamicStaking.sol: 225-244

    Description

    DynamicStaking.withdrawOnBehalf() indicates that users are able to withdraw on behalf of another user, however, the function always withdraws from msg.sender.

    Recommendation

    Consider renaming this function to withdraw() to follow similar conventions in the rest of the codebase.

    Resolution

    Magna Team: Resolved.

  19. L-15 Low Pending Rewards Can Not Be Claimed Informational Resolved
    Location
    FixedStaking.sol: 347-359

    Description

    The unstakeAndClaim() function allow users to receive their rewards to another address, however, claimPendingRewards() does not. The rewards are always transferred to msg.sender.

    Recommendation

    Consider adding a parameter for the user to specify which address should receive the rewards.

    Resolution

    Magna Team: Resolved.

  20. L-16 Low No Max Lock Duration Best Practices Resolved
    Location
    FixedStaking.sol: 260

    Description

    Users are able to create locks of any duration larger than the minimum lock duration. This may cause issues if a user initiates a deposit with an enormously large lock duration by accident.

    Recommendation

    Impose a strict max lock duration, e.g. 4 years.

    Resolution

    Magna Team: Resolved.

  21. L-17 Low Typos And Nitpicks Best Practices Acknowledged

    The report lists this finding in its index without a detail page. See the PDF.

Invariants 23

The review's fuzzing suite asserted 23 invariants. 23 held.

Every invariant tested
IDInvariantResult
GLOB-01Contract balances match internal accountingHeld
GLOB-02Total supply should not increase unexpectedlyHeld
GLOB-03Contract has sufficient tokens to cover obligationsHeld
GLOB-04Compounding/interest math correctness (fixed staking)Held
GLOB-05Reward calculation accounts for decimal differencesHeld
ERR-01Failed operations don't corrupt stateHeld
ERR-02State remains consistent after failuresHeld
STAKE-01Sum of individual stakes equals total stakedHeld
STAKE-02Mathematical correctness of reward formulasHeld
STAKE-03Proper handling of time-based logicHeld
ACCESS-01Only authorized actors can call restricted functionsHeld
ACCESS-02Role assignments remain consistentHeld
ECON-01No value created or destroyed unexpectedlyHeld
ECON-02Correct fee calculations and transfersHeld
ECON-03Fair and accurate reward distributionHeld
DYN-01Pending stakes/unstakes/claims are properly trackedHeld
DYN-02APY calculations based on total staked and rewardsHeld
DYN-03Campaign periods and timing constraintsHeld
DYN-04Unstake and claim fees properly collectedHeld
FIXED-01Valid state transitions onlyHeld
FIXED-02Minimum lock periods and boost calculationsHeld
FIXED-03Maximum staking limits respectedHeld
FIXED-04Consistent fixed APY with time-based compoundingHeld

More from Magna

All 9 reports
  1. Staking Updates

    23 findings 23 findings: 3 low, 20 informational
  2. Airdrop Updates

    2 findings 2 findings: 1 low, 1 informational
  3. Direct Transfer

    9 findings 9 findings: 1 medium, 1 low, 7 informational
  4. Merkle Vester

    13 findings 13 findings: 1 medium, 6 low, 6 informational

Put your code through the same review.

This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.

Get a quote