Guardian's review of Staking Updates for Magna, published April 2026. The report records 23 findings across 2 review rounds, including 3 low and 20 informational.
- Published
- Review window
- April 8 to 15, 2026
- Rounds
- Main Review, Remediation Review
- Language
- Solidity
- Chains
- Ethereum, Base, Optimism, Polygon, Arbitrum, BNB Chain
- Sector
- Infrastructure
- 0 Critical
- 0 High
- 0 Medium
- 3 Low
- 20 Informational
Scope
Findings 23
Main Review
18 findings · April 8 to 10, 2026-
L-01 Low Parameter Mismatch In Claim Event Events Resolved
Description
IDynamicStaking.Claimnames its third parameterpenaltyAmount, implying that the value represents a penalty. However, when a user claims, the contract emits the actual reward amount:emit Claim(sender, stakeIdPart, actualClaimedAmount, claimFee).Off-chain indexers or dashboards that rely on ABI field names for semantics may misinterpret claimed rewards as penalties, leading to incorrect accounting or misleading alerts.
Recommendation
Rename the parameter and its associated comment in the interface (and any downstream consumers) to accurately reflect actual behavior.
-
L-02 Low Multiple Config Values No Longer Observable Configuration Resolved
Description
Key parameters such as
minStakeAmount, stake time bounds, withdrawal delays, penalty caps, the whitelist toggle, and the fee manager were changed from public to internal without introducing replacement getter functions. Additionally, the previous helper getMinimumStakeAmount was removed in this PR.As a result, users and off-chain tools have no way to determine minimum stake amounts, lock periods, or whether proofless staking is allowed without directly inspecting raw storage.
Recommendation
Re-expose these parameters through public getters or dedicated view functions so that external systems retain visibility into the staking configuration.
-
L-03 Low Precision loss due to integer division Math Resolved
Description
DefaultCompositeMultiplierHook.getMultipliercomposes four per-hook multipliers as(A * B / MULTIPLIER_SCALER) * C / MULTIPLIER_SCALER * D / MULTIPLIER_SCALERwithMULTIPLIER_SCALER = 1e4. Each/ MULTIPLIER_SCALERis integer division, so the result is floored three times in sequence. The mathematically exact value is(A × B × C × D) / MULTIPLIER_SCALER³with a single rounding at the end; the current form drops fractional units after every intermediate step, so the composite multiplier is systematically ≤ the exact value. That biases virtual stake and reward share slightly down versus a single final division.Recommendation
Compute in one shot after promoting to
uint256, for exampleuint256(A) * B * C * D / (uint256(MULTIPLIER_SCALER) ** 3)then cast touint32after a bounds check. With each hook capped at5 * MULTIPLIER_SCALER(Common.sol), the product fits inuint256without overflow. -
I-01 Informational Warning Regarding
multiExecuteWarning ResolvedDescription
The
multiExecutefunction is public and performs external calls to arbitrary addresses with arbitrary calldata. As a result, users can execute a wide range of external calls through the MultiExecute contract.This contract must never hold token balances and should not be granted approvals, as its entire token balance (excluding ETH) could be transferred and any approvals could be exploited via
multiExecute.Recommendation
Document this behavior and clearly warn users not to fund or approve the contract unless operations are executed atomically, as all funds held by the contract can be swept.
-
I-02 Informational Typos Typo Partially resolved
Description
A state variable in
NftMultiplierHookcontains a typo, defined as nftMulitplier instead of nftMultiplier.Additionally, the
UnexpetedEthReceived,AlreadyTermintated, andAlreadyForecefullyTermintatederrors contain typos.Recommendation
Update the variable names and errors with typos and all functions that reference them.
-
I-03 Informational Use Low Level Call Instead of Send Best Practices Resolved
Description
The
WithDefundSupport._defundfunction usessendto transfer native ETH. It is best practice to use the low-levelcall{value: amount}("")pattern instead, as send forwards only 2,300 gasRecommendation
Consider using low-level call instead of send.
-
I-04 Informational Multicall functionality offers limited benefits Best Practices Resolved
Description
The
BatchDeploycontract contains two functions -deployContract()andmultiExecute()- and it inherits from theMulticallcontract. TheMulticall.multicall()function lets users batch different function calls of the same contract by utilizingdelegatecall. In the case of the current contract, users can callmultiExecute()anddeployContract()in the same call. However,multicall()is non-payable, so it only works formultiExecute()calls that don't perform native token payments.Functionally, there are no benefits of using multicall, because
multiExecute()can be used to calldeployContract()as well and there is no caller specific logic there. The only upside of usingmulticallis that it returnsbytes[]return data, whilemultiExecute()ignores it.Recommendation
Consider whether inheriting
Multicallis necessary in this contract. If you decide to remove it, you can also modifymultiExecute()to return the internal calls return data, just like currentlymulticall()does. -
I-05 Informational Same Salt Reverts Identical Batch Deploys DoS Resolved
Description
deployContractloops overdeploymentCodesand callsCreate2.deploy(0, salt, deploymentCodes[i])with the same salt for every index.CREATE2resolves the contract address from deployer, salt, and the init code hash. If two entries share identical bytecode, they share the same init code hash, so the second deployment targets an address that already has code from the first iteration. OpenZeppelin’s Create2.deploy then reverts. Because the whole call runs in one transaction, the entire batch reverts.Recommendation
Derive a per-entry salt so identical bytecode still maps to distinct addresses
-
I-06 Informational Hook getters lack
viewInformational ResolvedDescription
IMultiplierHook.getMultiplierandIPenaltyHook.getPenaltyAmountare declared as external withreturns (...)but without the view modifier.Recommendation
Add view to both interface signatures so implementations must be read-only at compile time.
-
I-07 Informational Missing events in state change functions Events Acknowledged
Description
The following functions change critical protocol state without emitting any event:
modifyCampaign,setMerkleRoot,setEmergencyMode,setAllowStakingWithoutProof,setFeeParams,modifyMaximumRewardGenerationRate,modifyMinStakeAmount,withdrawAllPenaltyAmount,withdrawEthBalance,defundContractBalance(DynamicStaking.sol);setStakingContractAddress(PostWithdrawalHookBase.sol);setPenaltyRange(StakeTimeRangePenaltyHook.sol);setStakeAmountRange,setStakeTimeRange,setNftMultiplier(DefaultCompositeMultiplierHook.sol).Recommendation
Add events to all state-changing admin functions
-
I-08 Informational Penalty Hook / maxPenaltyPercentage Mismatch Best Practices Resolved
Description
DynamicStaking.unstake()enforces two independent penalty checks:require(penaltyAmount == expectedPenaltyAmount)user slippage protection.require(penaltyAmount * PENALTY_SCALER * 100 / actualUnstaked <= maxPenaltyPercentage)immutable protocol cap set at deployment.
These serve different purposes: the first protects the user against a rogue admin front-running the penalty hook; the second is a hard on-chain guarantee to all stakers that penalties will never exceed a fixed percentage regardless of hook configuration.
However,
maxPenaltyPercentageisimmutableit cannot be changed after deployment. If it is set lower than the maximum penalty the hook can legitimately return (e.g.maxPenaltyPercentage = 10%but hook charges 50% for early exits), the second check will always revert for early unstakers. They cannot exit early under any circumstances and must wait untilunstakableFromwhen penalty is 0.Recommendation
Document clearly that
maxPenaltyPercentagemust be >= the maximum penalty percentage the configured hook can ever return. -
I-09 Informational Sensitive roles self-administered, no admin veto Access Control Acknowledged
Description
setRole()calls_setRoleAdmin(role, role)for bothFORCE_LIQUIDATE_POSITION_ROLEandDEFUND_CONTRACT_BALANCE_ROLE. Under OpenZeppelin AccessControl, only the role admin can grant or revoke that role, soADMIN_ROLEcannot revoke a compromised holder. A compromisedDEFUND_CONTRACT_BALANCE_ROLEholder can grant the role to additional addresses before anyone can react, then calldefundContractBalance()and drain principal, rewards, penalties, and pending entries. There is no override path forADMIN_ROLEonce proliferation starts.Recommendation
Use
_setRoleAdmin(role, ADMIN_ROLE)for both roles soADMIN_ROLEcan revoke holders. ForDEFUND_CONTRACT_BALANCE_ROLEespecially, prefer a multi-sig rather than an EOA. -
I-10 Informational Warning For Users Regarding Arbitrary Hooks Warning Resolved
Description
DynamicStaking accepts arbitrary multiplier, penalty, and post-withdrawal hook addresses in its constructor and does not enforce that these addresses point to the reference implementations provided in the repository. As a result, the deployer can supply any contracts they control.
A malicious or buggy hook can reject transactions, confiscate tokens, or reroute withdrawals. Users must perform due diligence and trust the pool deployer, as well as the specific hook contracts selected. There is no on-chain registry of audited hooks, nor any restrictions preventing the deployer from setting arbitrary hooks.
Recommendation
Document this behavior clearly and warn users that deployers can freely choose hook contracts.
If only the reference hooks in this repository are intended to be used, maintain a list of approved hook contracts and enforce on-chain restrictions during deployment.
-
I-11 Informational Post-withdrawal hook treats amounts as same kind Compatibility Resolved
Description
A comment in
withdrawToRecipientsuggests that a custom post-withdrawal hook can be used to differentiate between claims and unstakes:// withdrawToRecipient handles claims and stakes without any distinction, so if // for example claims should always use direct transfer and stakes should use a hook // then the solution is to disable direct transfer and create a special hook that can differentiate // between claims and stakesHowever, the hook interface does not provide enough information to achieve this. When a caller passes multiple entry types (e.g.,
[Unstake, Claim]),removePendingEntriesprocesses both types sequentially and sums the results into a singleactuallyRemovedAmount. The hook then receives:amount: the aggregate withdrawn amount across all typespendingEntryTypes: the array of types that were requested, not what was actually consumed
There is no per-type amount breakdown passed to the hook. Given
amount = 150andpendingEntryTypes = [Unstake, Claim], the hook cannot determine whether 100 came from unstakes and 50 from claims, or any other split.A hook that needs to apply different logic to claims vs unstakes (e.g., different fee structures, routing, or access control) cannot function correctly for mixed withdrawals. The comment's suggested mitigation is incomplete — it relies on callers voluntarily making separate single-type calls, which cannot be enforced on-chain from the hook side.
Recommendation
If this is a desired feature, pass per-type amounts to the hook so it can apply type-specific logic without depending on caller cooperation. Otherwise, remove the comment.
-
I-12 Informational Hook invoked on zero-amount withdrawals Validation Resolved
Description
In
DynamicStaking.withdrawToRecipient(), whenisDirectTransfer = falseandfailOnZeroWithdrawal = false, thepostWithdrawalHook.handlePostWithdrawal()is called unconditionally; even whenactuallyRemovedAmount == 0(for example no pending entries have matured yet).if (actuallyRemovedAmount != 0) { token.safeTransfer(address(postWithdrawalHook), actuallyRemovedAmount); } // called regardless of actuallyRemovedAmount postWithdrawalHook.handlePostWithdrawal( token, actuallyRemovedAmount, sender, recipient, stakeIdPart, pendingEntryTypes, extraData );Since integrators can deploy their own hook implementations extending
PostWithdrawalHookBase, they must be aware that_handlePostWithdrawalcan be invoked withamount = 0. A hook implementation that assumes a non-zero amount on every call will behave incorrectly in this case.Recommendation
Add a prominent notice to
PostWithdrawalHookBaseand its documentation that_handlePostWithdrawalmay be called withamount = 0. Integrators should always guard their logic with an early return or explicit check. -
I-13 Informational Nft multiplier can possibly be reused Informational Resolved
Description
The
NftMultiplierHook.getMultiplier()function returns the storednftMulitplierif the staker owns the NFT. Depending on which collection this hook is used for, users can transfer the same NFT among each other in order to benefit from the stake multiplier.Recommendation
This behavior of the hook must be taken into consideration when configuring a multiplier and a collection.
-
I-14 Informational Hook owners can DOS users Trust Assumptions Resolved
Description
There is a comment in
DynamicStakingthat highlights the possibility of a malicious hook owner DOS-ing user stakes by changing the multiplier.// each staking/unstaking(with penalty) in which case the admin can DoS a particular user. // This is not a major issue as users can always submit his transaction to a node with private pool. // If DoS turns out to be an issue in the future, then an update window feature can be implemented.The comment suggest that users can use private pools to avoid this, but in reality, the owner of the penalty hook can just set the penalty to an unreasonable value that the user wouldn't agree with, for example 100%, achieving the same DOS effect.
The owner of the
PostWithdrawalHookBasecan callsetStakingContractAddress()and change the staking address to an invalid address - this would DOS withdrawals for all users.Recommendation
Make sure the users are aware of the risks related to hook owners.
-
I-15 Informational Unnecessary pre-decrement Gas Optimization Resolved
Description
StakeTimeRangePenaltyHook.getPenaltyPercentage()andRangeMultiplierBase.getRangeMultiplier()perform redundant--ion the last line, as theivariable is no longer used after that.function getPenaltyPercentage(uint32 value) internal view returns (uint32 penaltyPercentage) { uint256 rangeLength = penaltyRange.length; uint256 i = 1; while (i < rangeLength && penaltyRange[i].remainingTimePercentageLowerBound <= value) { ++i; } penaltyPercentage = penaltyRange[--i].penaltyPercentage; }function getRangeMultiplier(MultiplierRangeEntry[] storage range, uint256 value) internal view returns (uint32 multiplier) { uint256 rangeLength = range.length; uint256 i = 1; while (i < rangeLength && range[i].lowerBound <= value) { ++i; } multiplier = range[--i].multiplier; }Recommendation
Consider using
i - 1instead.- penaltyPercentage = penaltyRange[--i].penaltyPercentage; + penaltyPercentage = penaltyRange[i - 1].penaltyPercentage;- multiplier = range[--i].multiplier; + multiplier = range[i - 1].multiplier;
Remediation Review
5 findings · April 15, 2026-
I-01 Informational Typos Typo Acknowledged
Description
Previously, there were 2 typos in the following errors:
NotYetForecefullyTermintated(forEcefully and terminTated) andAlreadyForecefullyTermintated(forEcefully and terminTated). The latest commit fixed theEtypo, but theTtypo still remains.Additionally, the following typos exist in comments, NatSpec documentation, and one named return identifier:
DynamicStaking.sol#L532—rougeshould berogue("a small risk of a rouge admin").DynamicStaking.sol#L548—transferingshould betransferring.IDynamicStaking.sol#L62—migthshould bemight,vauleshould bevalue.IDynamicStaking.sol#L143—migthshould bemight,vauleshould bevalue.FixedStaking.sol#L320—exectuteshould beexecute.FixedStaking.sol#L352—rougeshould berogue.FixedStaking.sol#L460—preceedingshould bepreceding.IFixedStaking.sol#L96— named returncompoundingPeriodLenghtshould becompoundingPeriodLengthto match the function name.
Recommendation
Fix all of the typos. The
compoundingPeriodLenghtnamed return inIFixedStaking.solis purely cosmetic (named returns in interfaces are not part of the ABI selector), but should be aligned with the function namecompoundingPeriodLengthfor consistency. -
I-02 Informational Contract comparison emits compiler warning Warning Acknowledged
Description
In
WithDefundSupport._defund(), the native-token branch is selected viatokenParam == NATIVE_TOKEN. Since both operands are contract-typed values, Solidity emits a compiler warning for direct contract comparison and recommends comparing their addresses explicitly. This does not currently change runtime behavior, but it adds avoidable warning noise to builds and makes the sentinel-address intent less explicit than anaddress(...)comparison.Recommendation
Compare the sentinel values through
address(...)inWithDefundSupport._defund(), for exampleaddress(tokenParam) == address(NATIVE_TOKEN). This removes the compiler warning and makes the native-token check explicit and consistent with the surrounding zero-address validation. -
I-03 Informational Hook trust comment understates upgrade risk Documentation Acknowledged
Description
The comment in
DynamicStaking.unstake()says users only need to verify that the construction-time supplied penalty and multiplier hooks are not malicious as “a one-time check”. That guidance is only accurate if the referenced hooks are immutable and cannot be upgraded or materially reconfigured after deployment. If a hook is upgradeable, owner-controlled, or depends on mutable external state, its behavior can change over time and the trust assumption must be revisited continuously. Leaving the comment as-is may cause integrators and users to underestimate the ongoing trust and monitoring requirements of the configured hooks.Recommendation
Update the comment to clarify that hook verification is only a one-time check when the hooks are immutable and non-upgradeable. If hooks are upgradeable or admin-configurable, document that users and integrators should treat them as ongoing trust assumptions and monitor them accordingly.
-
I-04 Informational Unscoped salts blur deployment attribution Trust Assumptions Acknowledged
Description
MultiExecute.deployContract()derives eachCREATE2salt asbytes32(uint256(salt) + i)without binding it to the caller. As a result, deterministic deployment addresses are namespaced only by theMultiExecuteaddress, the derived salt, and the init code, not by the initiating user. Different users can therefore target the same deterministic deployment address by reusing the samesaltand init code, or by choosing overlapping salt ranges across batches. This means callers cannot safely assume that a predictable address is uniquely tied to their own deployment flow or initiator identity. Any integrator or deployed contract logic that informally treats such an address as being attributable to a specific user, transaction origin, or deployment attempt is relying on a false trust assumption.Recommendation
Scope the derived salt to the initiator in
deployContract(), for example by hashing the caller together with the user-supplied salt and index. This makes deterministic deployment addresses user-specific and avoids collisions or attribution ambiguity across independent callers. If shared salts are intentional, document clearly that deterministic addresses produced bydeployContract()are not bound to a particular caller. -
I-05 Informational Missing
CREATE2address computation getter Compatibility AcknowledgedDescription
MultiExecuteexposesdeployContract()for deterministicCREATE2deployments but does not expose a view/helper function to compute the resulting deployment address from the suppliedsalt, batch index, and init code. While the address can be derived off-chain, the missing getter makes integration harder for contracts and on-chain workflows that need to reason about a future deployment address before it is created. This reduces composability, forces downstream users to reimplement the derivation logic externally, and increases the chance of mismatches if the derivation scheme is replicated incorrectly.Recommendation
Add a view/helper function that returns the deployment address for a given
salt, batch index, and deployment bytecode, and optionally a batch variant for multiple bytecodes. This would make deterministic deployments easier to consume from both off-chain tooling and on-chain integrations without requiring external reimplementation of the address derivation logic.
No findings match.
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.