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
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
-
H-01 High FixedStaking Doesn't Consider Token Decimals Unexpected Behavior Resolved
Description
Proof of concept: PoC
FixedStakingcan be deployed by anyone using any two tokens asstakeTokenandrewardToken. The earned amount of rewards are calculated incalculateRewardAmountFutureDate()asFixedPointMathLib.mulDiv(stakeAmount, (scaledInterestRate - WAD), WAD)This means the result ends up being in
staketoken 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
stakeTokenhas 18 decimals, but therewardTokenhas 6, in most cases the earned amount will be stored inpendingRewardsand 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
stakeTokenand 18 decimals forrewardToken- 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 inrewardsdecimals.Resolution
Magna Team: Resolved in commit 74b3f78a8917a425b0893a5ad09d5ef93d158d1b.
-
M-01 Medium User Can Achieve Max Boost Unexpected Behavior Resolved
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_ALLOWEDand the large lock duration will apply themaxBoostto 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.
-
M-02 Medium Underflow On Interest Percentage Calculation Math Resolved
Description
Proof of concept: PoC
FixedStaking.calculateInterestPercentageFutureDate()calculateselapsedTimeby taking the maximum between(0, currentTimestamp - startDate).The problem is that both
currentTimestampandstartDateare unsigned integers. Because of this, whenstartDate > 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.
-
M-03 Medium Fee Discount Via Multicall Logical Error Resolved
Description
DynamicStaking.unstake()andDynamicStaking.claim()require thatmsg.value = unstakeFeeandmsg.value = claimFeerespectively.However, the
Multicallcontract is inherited which allows users to execute multipleunstake()andclaim()calls in the same transaction.As a result, the
msg.valuewill 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 bothclaim()andunstake()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.
-
L-01 Low Inaccurate getClaimable() Results Unexpected Behavior Resolved
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.
-
L-02 Low Pool Capacity Validation Can Be Bypassed Validation Resolved
Description
The constructor of
FixedStakingensures thatpoolCapacity > 0, but this check is not present in thechangePoolCapacity()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.
-
L-03 Low User Stakes Can Be Spammed DoS Resolved
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()andgetClaimableIncludingPending()revert with OOG when looping through the stakes. It can also cause problems for any integrators usinggetNumberOfStakes().Recommendation
Consider requiring minimum stake amount if
initiator = onBehalfOfResolution
Magna Team: Resolved in commit 74b3f78a8917a425b0893a5ad09d5ef93d158d1b.
-
L-04 Low poolCapacity Update Can Be Frontrun Informational Acknowledged
Description
The
changePoolCapacity()function inFixedStakingallows 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 = 1000poolCapacity = 1500- the admin submits a transaction to change
poolCapacityto 1200 - A staker frontruns them (or their transaction just ends up earlier in the block) and stakes up to 1500
poolCapacityis set to1200, buttotalStaked = 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.
-
L-05 Low withdrawOnBehalf() Marked As Payable Informational Resolved
Description
The function
withdrawOnBehalf()is marked aspayablebut does not require a fee for its execution.Recommendation
Consider removing the
payablekeyword to avoid confusion.Resolution
Magna Team: Resolved in commit 74b3f78a8917a425b0893a5ad09d5ef93d158d1b.
-
L-06 Low Wrong Error Usage Validation Resolved
Description
FixedStaking.stake()incorrectly reverts with theZeroAddressPassed()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.
-
L-07 Low State Is Broken After defund Informational Resolved
Description
Once
DynamicStaking.defundContractBalance()is called, all of the tokens in the contract - stakes + unstakes + rewards are send to a recipient address, buttotalStakedand 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.
-
L-08 Low Campaign Change Can Cause Zero Reward Rate Rewards Resolved
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.
-
L-09 Low 0 Transfer Reverts Best Practices Resolved
Description
DynamicStaking.modifyCampaign()transfers the token unconditionally in theelsestatement, 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.
-
L-10 Low Contract Can Be Created Informational Resolved
Description
The
SUPER_ADMINrole 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 poolHowever, 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.
-
L-11 Low Loss Of Interest Because Of Precision Loss Rounding Acknowledged
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 passedperiodsElapsed = (elapsedTime) / compoundingPeriodLengthBecause 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
TERMINATEDor theblock.timestampis beyondcompoundingEndDate, thenlastValidTimestampwill be used to calculatetimeElapsedand 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.
-
L-12 Low Insufficient ConstructorParams Validation Validation Acknowledged
Description
A lot of the fields passed in
IFixedParams.ConstructorParamsare not sufficiently validated:compoundingPeriodLengthis not capped to a reasonable value. If it's set to a very large value like
type(uint256).max, every timeperiodElapsedwhich is calculated aselapsedTime /compoundingPeriodLengthwill end up being 0 and users will never receive rewards.interestRatePerPeriodshould be a value in a reasonable range. If it's not it's possible to grief users
by exploiting
WAD + interestRatePerPeriod > type(uint256).maxand makingcalculateInterestPercentageFutureDate()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.withdrawRewardsDeadlinemust 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
INITIALIZEDall the way toREWARD_RESCUABLEin a single block and withdraw all the rewards.boostYOffsetandboostdYdXshould also be in reasonable ranges becauseboostYOffset +
boostdYdX * extraLockSecondsshould not surpasstype(uint256).maxRecommendation
Implement the proposed validations in the
FixedStakingconstructor.Resolution
Magna Team: Acknowledged.
-
L-13 Low Misused Variable tokenAmountToRescue Informational Resolved
Description
In the
emergencyRescueStakeTokensfunction, there is the following check:if (tokenAmountToRescue > 0) {totalStaked = rescuedTokenAmount; stakeToken.safeTransfer(rescuedTokenReceiver, rescuedTokenAmount);}However, this should always evaluate to
truebecausetokenAmountToRescueis a function parameter. It would not make sense to call this function with a zero value.Recommendation
Use
rescuedTokenAmountin the if clause instead.Resolution
Magna Team: Resolved in commit 74b3f78a8917a425b0893a5ad09d5ef93d158d1b.
-
L-14 Low withdrawOnBehalf() Always Withdraws For Msg.sender Informational Resolved
Description
DynamicStaking.withdrawOnBehalf()indicates that users are able to withdraw on behalf of another user, however, the function always withdraws frommsg.sender.Recommendation
Consider renaming this function to
withdraw()to follow similar conventions in the rest of the codebase.Resolution
Magna Team: Resolved.
-
L-15 Low Pending Rewards Can Not Be Claimed Informational Resolved
Description
The
unstakeAndClaim()function allow users to receive their rewards to another address, however,claimPendingRewards()does not. The rewards are always transferred tomsg.sender.Recommendation
Consider adding a parameter for the user to specify which address should receive the rewards.
Resolution
Magna Team: Resolved.
-
L-16 Low No Max Lock Duration Best Practices Resolved
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.
-
L-17 Low Typos And Nitpicks Best Practices Acknowledged
The report lists this finding in its index without a detail page. See the PDF.
No findings match.
Invariants 23
The review's fuzzing suite asserted 23 invariants. 23 held.
Every invariant tested
| ID | Invariant | Result |
|---|---|---|
GLOB-01 | Contract balances match internal accounting | Held |
GLOB-02 | Total supply should not increase unexpectedly | Held |
GLOB-03 | Contract has sufficient tokens to cover obligations | Held |
GLOB-04 | Compounding/interest math correctness (fixed staking) | Held |
GLOB-05 | Reward calculation accounts for decimal differences | Held |
ERR-01 | Failed operations don't corrupt state | Held |
ERR-02 | State remains consistent after failures | Held |
STAKE-01 | Sum of individual stakes equals total staked | Held |
STAKE-02 | Mathematical correctness of reward formulas | Held |
STAKE-03 | Proper handling of time-based logic | Held |
ACCESS-01 | Only authorized actors can call restricted functions | Held |
ACCESS-02 | Role assignments remain consistent | Held |
ECON-01 | No value created or destroyed unexpectedly | Held |
ECON-02 | Correct fee calculations and transfers | Held |
ECON-03 | Fair and accurate reward distribution | Held |
DYN-01 | Pending stakes/unstakes/claims are properly tracked | Held |
DYN-02 | APY calculations based on total staked and rewards | Held |
DYN-03 | Campaign periods and timing constraints | Held |
DYN-04 | Unstake and claim fees properly collected | Held |
FIXED-01 | Valid state transitions only | Held |
FIXED-02 | Minimum lock periods and boost calculations | Held |
FIXED-03 | Maximum staking limits respected | Held |
FIXED-04 | Consistent fixed APY with time-based compounding | Held |
More from Magna
All 9 reportsPut 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.