Findings
Low Risk
6 findings
5 fixed
1 acknowledged
Informational
5 findings
5 fixed
0 acknowledged
Gas Optimizations
2 findings
2 fixed
0 acknowledged
Low Risk6 findings
claimable and claimableAt do not deduplicate epoch ids and can over-report the claimable total
Description
claimable()andclaimableAt()sum an account's payout over a list ofepochIdsdecoded fromauxData, without checking that the ids are unique. Beingviewfunctions, they never write the_claimedAtBlockledger, so a repeated id passes the_claimedAtBlock[...] != 0guard on every iteration and its_grossPayout(...)is added again. For example, a caller passing[5, 5, 5]receives three times epoch 5's payout.While no in-scope contract currently consumes these functions, if a downstream contract that treats the returned total as authoritative is introduced in the future, it could act on an arbitrarily inflated amount.
Recommendation
Consider either documenting that the total is only valid for a duplicate-free list, or enforcing uniqueness by requiring the caller to pass
epochIdspre-sorted in strictly increasing order. The latter approach can be validated with the following snippet inside theforloop:if (i != 0 && epochId <= prev) revert UnsortedEpochIds(epochId, prev);prev = epochId;Owner can pause a plugin through its deactivation window to strand user rewards
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Medium Submitted by
Kaden
Description
The deactivation window exists to give users time to claim outstanding rewards before a plugin goes dark, as stated in the
startDeactivation()docs. This protection can be bypassed by the owner. In bothSimplePluginandStreamingRewardsPlugin,claim()andincreaseClaimableBy()are gated bywhenNotPaused, but_deactivated()advances purely onblock.timestampandcleanupPostDeactivation()is gated only bywhenDeactivated, notwhenNotPaused.As a result, the owner can call
startDeactivation()and thenpause(). The deactivation timer keeps running while the contract is paused, soclaim()reverts for the entire window. Once the window closes, the owner callscleanupPostDeactivation(), which still executes while paused and sweeps all remaining reward funds to the owner. Users with outstanding rewards receive nothing.The ability to pause is a legitimate response to a security incident, so this may be an acceptable tradeoff. It is worth being aware of, as the protection the deactivation window is meant to provide ultimately depends on the trust level of the plugin owner.
Recommendation
Consider freezing the deactivation timer while the contract is paused.
increaseClaimableBy is missing an address(0) guard
Description
The single-credit
increaseClaimableBy()has noaccount != address(0)check, while the batch pathincreaseClaimableByBatch()rejects a zero address withZeroAddress. CallingincreaseClaimableBy(address(0), amount)pullsamountreward tokens into the plugin, credits_claimable[address(0)], and addsamountto_totalOwed.StakingModulealways claims on behalf ofmsg.sender, soaddress(0)can never claim, and the credited funds are permanently locked.totalClaimable()stays overstated byamount, and because that amount is counted in_totalOwedit is treated as owed rather than free, sorescueTokens()cannot recover it either.Recommendation
Consider adding the same guard used by the batch path:
+if (account == address(0)) revert ZeroAddress();Streaming rewards use a single-block snapshot rather than staking duration, enabling reward gaming
Description
Streaming rewards pay
rewardAmount * getPastVotes(account, refBlock) / totalSupplyAtRef, a single-block snapshot taken atrefBlock. An account's reward is proportional to its stake at one block per cycle, with no weighting by how long the stake was actually held. TherefBlock < block.numberrule prevents front-running a snapshot that is already known, but it does not prevent positioning for a predictable future snapshot.This design raises three concerns:
- Timing predictability. Epochs are expected to be funded frequently, but the cadence must not be predictable. If
refBlocklands at the same time each day, or even within a predictable window, a user can stake only around the anticipated block and unstake afterward, capturing a full proportional share for minimal staking duration. - Fairness. Because only the snapshot counts, rewards track when a user happens to be staked at the checkpoint rather than the total time staked. Two stakers holding the same amount are rewarded very differently based purely on timing.
- Distributor trust. The
distributorselectsrefBlock, so it could time epochs around its own stake to disproportionately reward itself.
The withdrawal delay partially mitigates the first two by keeping capital locked, with effectiveness depending on the delay length relative to epoch frequency, but it does not address the underlying design.
Recommendation
Consider a design that rewards stakers based on the duration staked rather than presence at discrete checkpoints. One approach is to give epochs a start and end time, ideally contiguous, and on each claim or stake/unstake sum the rewards for every full epoch the account was staked through, then pro-rate partially staked epochs by the time staked within them. The same effect can be achieved without epochs by accruing a constant reward rate per unit of stake per unit of time (a reward-per-token accumulator), and the rate itself can be made a function of time if a variable schedule is desired.
If the snapshot design is retained, ensure the epoch cadence and
refBlockselection are unpredictable and jittered, and do not run epochs while the withdrawal delay is0.- Timing predictability. Epochs are expected to be funded frequently, but the cadence must not be predictable. If
Batch migrator can migrate any user's V2 stake without their authorization
Description
migrateBatch(), gated byBATCH_MIGRATOR_ROLE, exits each listed user's V2 stake throughclaimAndExitFor(), swaps it to V3, and re-stakes it to the user viastakeForBatch(). No approval or signature is required from the users being migrated. Each user receives their equivalent V3 stake, so there is no loss, but a user has no say in whether or when they are migrated.This matters if some users would prefer not to migrate, for example because their V2 stake is still earning rewards, they want to remain liquid, or they do not want to be subject to the V3 withdrawal delay. The design therefore depends on the
BATCH_MIGRATOR_ROLEholder being fully trusted to migrate only users who have authorized it off-chain.Recommendation
If forced migration is not intended, consider requiring each user to sign a message authorizing their own migration and validating that signature in
migrateBatch()before exiting their V2 stake. If forced migration is intended, consider documenting this trust assumption clearly so users understand their stake can be moved without an on-chain action on their part.migrateAll stakes claimed V2 rewards as principal when v2AuxData claims rewards
Description
migrateAll()is documented to preserve each caller's staked versus unstaked split by re-staking only the previously staked portion and returning the previously unstaked portion to the wallet. However, there is one exception to this pattern.Leg 1 calls
claimAndExitFor(), which pays both the exited staked principal and any plugin rewards claimed throughv2AuxDatato the migrator.stakedPulledis measured as the aggregate balance delta, so it includes those rewards. Leg 3 then stakes the whole amount as sTEL, so reward tokens that were not staked before migration end up staked. TheMigratedAllevent'sstakedPulledandstakedV3fields likewise over-report principal. Because the resulting sTEL value is equivalent, no funds are lost. Regardless, the actual behavior differs from the documented behavior.Recommendation
Consider either measuring the exited principal and the claimed rewards separately, so the principal can be re-staked while the rewards are returned to the wallet as unstaked, or documenting that rewards claimed through
v2AuxDataare folded into the staked portion.
Informational5 findings
Constructor parameter _deactivationDelay shadows the inherited storage variable
Description
The
StreamingRewardsPluginconstructor declares a parameter named_deactivationDelay, which shares its name with the storage variable inherited fromDeactivationTimelock. The constructor uses the locally scoped parameter as intended, so behavior is correct, but the shared name makes it harder to distinguish the parameter from the storage variable.Recommendation
Consider renaming the parameter, for example to
deactivationDelay_, and updating its references in the constructor:- constructor(address _staking, address _rewardToken, uint256 _deactivationDelay)+ constructor(address _staking, address _rewardToken, uint256 deactivationDelay_) Ownable(msg.sender)- DeactivationTimelock(_deactivationDelay)+ DeactivationTimelock(deactivationDelay_)DEFAULT_ADMIN_ROLE is the top of the trust hierarchy
Description
The
_authorizeUpgrade()documentation describesUPGRADER_ROLEas the top of the trust pyramid, protected by a multisig and timelock so any malicious upgrade has a public window during which users can exit. Roles are managed with OpenZeppelinAccessControl, whereDEFAULT_ADMIN_ROLEis the admin of every role, includingUPGRADER_ROLEand itself.A
DEFAULT_ADMIN_ROLEholder can therefore grantUPGRADER_ROLEto any address, including itself, and revoke or reassign roles at will, which places it aboveUPGRADER_ROLE. IfUPGRADER_ROLEis guarded by a timelock butDEFAULT_ADMIN_ROLEis not, an admin could grant itselfUPGRADER_ROLEand upgrade without the intended public window, bypassing the protection.Recommendation
Consider ensuring
DEFAULT_ADMIN_ROLEis protected at least as strongly asUPGRADER_ROLE, for example a multisig combined with a timelock.Incorrect NatSpec @title tag on DeactivationTimelock
Description
The NatSpec block above
DeactivationTimelockuses@title TieredOwnership, which names a different contract and appears to have been copied fromTieredOwnership.sol. The title does not describe the contract it sits above.Recommendation
Update the
@titletag to match the contract it documents.updateRewardToken does not emit TokenUpdated
Description
updateRewardToken()inFeeBuybackProxySafechanges thetelcoinreward token but does not emit an event. TheTokenUpdatedevent already exists and is emitted byinitialize()when the token is first set, so the update path is inconsistent and leaves offchain monitoring unaware of any later change.Recommendation
Consider emitting
TokenUpdatedafter the assignment:function updateRewardToken( IERC20 token) public onlyRole(SUPPORT_ROLE) { telcoin = token;+ emit TokenUpdated(address(telcoin));}initialize does not emit WithdrawalDelaySet for the initial delay
Description
initialize()sets$.withdrawalDelaytoinitialWithdrawalDelay_but does not emit an event. TheWithdrawalDelaySetevent already exists and is emitted bysetWithdrawalDelay()on every later change, so the initial value is the only one not surfaced through an event.Recommendation
Consider emitting
WithdrawalDelaySetfrom the old value of0to the initial delay after the assignment:$.withdrawalDelay = initialWithdrawalDelay_;+emit WithdrawalDelaySet(0, initialWithdrawalDelay_);
Gas Optimizations2 findings
_deactivationDelay can be immutable
Description
_deactivationDelayinDeactivationTimelockis assigned only in the constructor and is never modified afterward. BothSimplePluginandStreamingRewardsPluginpass a fixed value into that constructor and are deployed directly rather than behind a proxy, so the value is set once at deployment.Declaring it
immutablestores it in contract bytecode rather than storage, replacing the storage load in_startDeactivation()and the public getter with a cheaper bytecode read.Recommendation
Consider marking the variable
immutable:- uint256 public _deactivationDelay;+ uint256 public immutable _deactivationDelay;Redundant arithmetic in the setWithdrawalDelay rate-limit check
Description
In the increase branch of
setWithdrawalDelay(), the per-day cap is computed as(elapsed * MAX_DELAY_INCREASE_PER_DAY) / 1 days. SinceMAX_DELAY_INCREASE_PER_DAYis set to1 days, this expression is always equal toelapsed, so the multiplication and division are redundant. The compiler does not fold them because it must preserve the overflow behavior of the intermediateelapsed * 1 days.Recommendation
Consider comparing against
elapseddirectly:- uint256 maxIncrease = (elapsed * MAX_DELAY_INCREASE_PER_DAY) / 1 days; uint256 requested = newDelay - oldDelay;- if (requested > maxIncrease) revert DelayIncreaseTooFast(requested, maxIncrease);+ if (requested > elapsed) revert DelayIncreaseTooFast(requested, elapsed);Note that this couples the check to
MAX_DELAY_INCREASE_PER_DAYbeing equal to1 days. If that constant is ever changed, the simplification must be revisited.