Reports

Smart Contract Security Assessment

Resupply Re-Staker

The protocol is a re-staking solution for Resupply Finance. Pooled RSUP from users is forwarded to the upstream GovStaker contract, and staking rewards are managed through configurable strategies. The codebase currently offers two strategies: one that compounds rewards by restaking them as additional RSUP, and another that deposits rewards into the reUSD savings vault.

26
Issues
7
C/H/M
Period
Mar 16, 2026 - Mar 20, 2026
Auditors
ret2basic, adriro

Review Summary

Protocol Overview

The protocol is a re-staking solution for Resupply Finance. Pooled RSUP from users is forwarded to the upstream GovStaker contract, and staking rewards are managed through configurable strategies. The codebase currently offers two strategies: one that compounds rewards by restaking them as additional RSUP, and another that deposits rewards into the reUSD savings vault.

Protocol
Resupply
Timeline
Mar 16, 2026 - Mar 20, 2026
Audit Team
ret2basic, adriro

Audit Overview

Scope and Resources

Scope

This audit covers 7 smart contracts totaling approximately 1046 lines of code across 5 days of review.

Overall Assessment

The audit identified several high-severity issues in the checkpoint and governance voting logic that could corrupt voting power history and allow quorum manipulation. Multiple medium-severity findings revealed state management flaws in voter rotation handling, reward processing, and slippage enforcement. While access control and library usage were generally sound, the codebase would benefit from stronger test coverage, improved documentation of checkpoint invariants, and more robust handling of upstream configuration changes.

Evaluation Matrix

access control
Good

Role separation between operator, RESUPPLY_CORE, and users is well-defined with appropriate modifiers.

mathematics
Low

Multiple accounting mismatches were found between account-level and total-level voting power tracking, including checkpoint ordering bugs and rounding-induced phantom stake in magicPounder. Per-hop slippage compounding also produces worse-than-expected end-to-end execution.

complexity
Low

The epoch-based checkpoint system, multi-strategy weight distribution, and wrapper-layer governance introduce significant state management complexity. Several high-severity bugs stemmed from incorrect ordering of checkpoint and sync operations across interleaved state transitions.

libraries
Good

The codebase uses OpenZeppelin's SafeERC20 and standard interfaces appropriately.

decentralization
Average

The operator role holds significant power over strategy management, harvester configuration, and voter rotation. The shared proposer address and early quorum commit mechanics allow minority factions to capture governance outcomes. Upstream integration assumptions further concentrate risk.

code stability
Good

The codebase remained stable during the review.

documentation
Low

Minimal inline documentation and no specification for the checkpoint invariants or epoch-delay model. The magicHarvester routing logic was tightly coupled to specific swap paths without documenting those assumptions.

monitoring
Average

Even though monitoring is in place, not all key state changes have a corresponding event log.

testing
Low

Overall test quality is poor and should be significantly improved. Test coverage did not catch multiple high-severity checkpoint ordering bugs, the quorum snapshot issue, or the sparse rewards array termination. Several findings were demonstrated with PoCs that could have been part of the original test suite.

Key Findings

Findings Summary

0
Critical
3
High
4
Medium
9
Low
9
Informational
1
Gas
Ref Severity Title
H-1 High magicVoter uses current voting power instead of proposal-epoch snapshot
H-2 High Quorum check in `castVote()` uses live totalSupply instead of a snapshot
H-3 High `setWeights()` calls `_syncMagicBalance()` without checkpointing, corrupting voting history
M-1 Medium `_syncMagicBalance()` updates `totalPending` without checkpointing total state
M-2 Medium Sparse `positiveRewards` array causes `process()` to skip remaining rewards
M-3 Medium Per-hop slippage checks allow much larger end-to-end losses on multi-hop routes
M-4 Medium Voter rotation reuses local proposal IDs and corrupts magicVoter state
L-1 Low Shared proposer address in magicStaker causes per-account proposal delay to affect all users
L-2 Low Use `forceApprove()` instead of `approve()` for better ERC20 compatibility
L-3 Low `execute()` silently returns on failure instead of reverting
L-4 Low RSUP reward token accounting can be broken by external `getReward()` calls
L-5 Low Last strategy remainder logic can be skipped when its supply is zero
L-6 Low Adding a reward token does not sync existing harvester allowances
L-7 Low Hardcoded staker breaks wrapper governance after resupply staker upgrade
L-8 Low Upstream cooldownEpoch Changes Can Desync Local And Shared RSUP Unlock State
L-9 Low magicPounder rounding can mint dust-level phantom pending stake via syncAccount
I-1 Informational `_process()` logic is tightly coupled to the reUSD → RSUP route assumptions
I-2 Informational Last-Minute Reweight Or Stake Can Capture Previously Accrued Rewards
I-3 Informational Early quorum commit can lock the wrapper's full vote before voting closes
I-4 Informational Deployed resupply voter source does not exactly match latest upstream repository
I-5 Informational `setStrategyHarvester()` only validates routing for the first reward token
I-6 Informational Upstream votingPeriod changes can freeze wrapper vote commitment
I-7 Informational Live strategy addition can freeze existing users with stale weight arrays
I-8 Informational `addStrategy()` does not check for duplicate strategies
I-9 Informational Unbounded total checkpoint catch-up is a remote long-term liveness risk
G-1 Gas Redundant `onlyOperator` modifier on `execute()`
H-1 Finding

H-1: magicVoter uses current voting power instead of proposal-epoch snapshot

High

Summary:

magicVoter.vote() fetches voting power via magicStaker.getVotingPower(), which resolves to getVotingPowerAt(account, getEpoch()) — the current epoch. Because proposals carry a creation epoch, this allows votes to be cast with weight that did not exist when the proposal was created.

Description:

The Resupply DAO Voter uses epoch-based snapshots so that voting power is locked at proposal creation time. magicVoter breaks this invariant by always querying live voting power from MagicStaker.

A user who stakes after a proposal is created can vote with weight they did not hold at proposal time. Conversely, a user who unstakes or enters cooldown after proposal creation loses voting power they should still retain for that proposal.

Impact:

High. Users may cast votes with the incorrect voting power.

Recommendation:

Read the proposal's creation epoch when a vote is initiated and query getVotingPowerAt(account, proposalEpoch) instead of getVotingPower(). This mirrors the snapshot model already used by the Resupply DAO Voter.

Developer Response:

Applied recommended fix and now queries getVotingPowerAt with calculated epoch for proposal creation time.

Commit: https://github.com/oo-00/SecretHippoProject/commit/89ade93a750ad1b33446e007ded4721aa07bd5f8

H-2 Finding

H-2: Quorum check in `castVote()` uses live totalSupply instead of a snapshot

High

Summary:

magicStaker.castVote() computes the 20% quorum threshold against totalSupply, a live storage variable. Any stake(), cooldown(), or magicStake() call between proposal creation and vote submission changes the quorum denominator, making the threshold drift or be deliberately manipulated.

Description:

totalSupply is modified by every state-changing flow in MagicStaker: stake() and magicStake() increase it, while cooldown() decreases it. Because castVote() reads totalSupply at call time rather than at proposal creation, the quorum target is not fixed for any given proposal.

A large stake submitted after a proposal is created raises the quorum bar, potentially blocking an otherwise passing vote. A large cooldown lowers it, making quorum easier to reach. MagicPounder compounding via magicStake() also increases totalSupply on every harvest, causing continuous upward drift in the quorum requirement between harvests and vote submissions.

An attacker can exploit this by staking a large amount to raise quorum above what existing voters can meet, or by entering cooldown to lower quorum and push through a proposal that would otherwise fail.

Impact:

High. Quorum can be manipulated by any participant through normal staking and cooldown operations, undermining governance integrity.

Recommendation:

Snapshot totalSupply at proposal creation time and use the snapshotted value for the quorum check in castVote(). This ensures the quorum target is fixed and immune to post-proposal state changes.

Developer Response:

totalPowerAt was already being tracked and meant for this, but got lost in the building process.

Have switched to using totalPowerAt

Original fix commit has faulty logic: https://github.com/oo-00/SecretHippoProject/commit/fab9137d1d891d4dd90e52f342ac4f40a2bfdac2

It removed cooldown power reduction, which was originally affecting the current epoch, and was always using previous epoch instead of aligning with proposal creation. This isn't acceptable as there's no other mechanism for reducing total voting power.

Commit: https://github.com/oo-00/SecretHippoProject/commit/7cf8d5fc5df1080544dd55da9cef50678dfa5d62

Should correctly solve this, by handling cooldowns similar to new stakes, by tracking removalsPending similar to totalPending. This way, changes are only applied during checkpointTotal calls, and are static per-epoch.

magicVoter now calculates the creation epoch, and passes it to the voter at time of execution. If needed, checkpointTotal is called during the casting of the vote to ensure quorum can be calculated.

H-3 Finding

H-3: `setWeights()` calls `_syncMagicBalance()` without checkpointing, corrupting voting history

High

Summary:

In magicStaker.setWeights(), _syncMagicBalance() is called before checkpointAccount(). Because _syncMagicBalance() overwrites lastUpdateEpoch to the current epoch without seeding accountPowerAt[account][currentEpoch], the subsequent checkpoint reads a zero value and discards previously realized voting power.

Description:

_syncMagicBalance() increases pendingStake and updates magicStake to reflect compounded MagicPounder yield, then sets lastUpdateEpoch to the current epoch. However, it does not write to accountPowerAt[account][currentEpoch].

When _checkpointAccount() later runs, it reads accountPowerAt[account][lastUpdateEpoch] as its starting point. Since lastUpdateEpoch was already advanced by _syncMagicBalance() but no power was seeded at that epoch, the checkpoint starts from zero. All previously realized voting power accumulated in earlier epochs is lost.

This means any user who calls setWeights() while holding unclaimed MagicPounder yield will have their voting history corrupted. The effect is permanent for that epoch — the dropped power cannot be recovered by subsequent checkpoints.

Impact:

High. Users lose realized voting power when calling setWeights() with pending MagicPounder yield, silently reducing their governance weight.

Recommendation:

Call checkpointAccount() before _syncMagicBalance() in setWeights() to ensure accountPowerAt[account][currentEpoch] is seeded with the correct realized power before lastUpdateEpoch is overwritten.

Developer Response:

Fixed in commit e798aa3.

M-1 Finding

M-1: `_syncMagicBalance()` updates `totalPending` without checkpointing total state

Medium

Summary:

_syncMagicBalance() increments totalPending when a user has unclaimed MagicPounder yield, but neither syncAccount() nor setWeights() call _checkpointTotal() before invoking it.

Description:

When _syncMagicBalance() adds newly claimed MagicPounder yield to totalPending, that value sits in the pending bucket waiting for the next _checkpointTotal() call to process it. Because no total checkpoint precedes the write, the pending weight is attributed to whatever epoch the next _checkpointTotal() happens to run in.

If _syncMagicBalance() executes before the first _checkpointTotal() of a new epoch, the freshly added pending weight gets realized on the total side immediately when that checkpoint fires. On the account side, the same pending weight still follows normal epoch-delay rules and is not yet realized. This creates a divergence between account-level and total-level realized weight.

The mismatch distorts any calculation that divides account voting power by total voting power — most critically quorum checks and voting power ratios used in governance. Compare with stake(), which correctly calls _checkpointTotal(systemEpoch) before modifying totalPending, ensuring the previous epoch's totals are finalized first.

Impact:

Medium. Incorrect bookkeeping between account and total realized weight can skew quorum calculations and voting power ratios, though exploitation requires specific epoch-boundary timing.

Recommendation:

Call _checkpointTotal(systemEpoch) at the start of syncAccount() and setWeights(), before _syncMagicBalance() is invoked. This mirrors the ordering already used in stake().

Developer Response:

Applied the recommended fix in commit e798aa3.

In setWeights() I've also added a checkpointAccount call, since otherwise it would be the only function that breaks that flow.

M-2 Finding

M-2: Sparse `positiveRewards` array causes `process()` to skip remaining rewards

Medium

Summary:

Rewards may go unprocessed when the positiveRewards array passed to process() contains address(0) gaps between valid entries, because process() treats the first address(0) as a terminator.

Description:

In harvest(), the positiveRewards array is populated using the same index r as the rewards array. When a reward token has a zero balance, the loop continues, leaving address(0) at that index. This produces a sparse array where valid entries can appear after address(0) gaps.

for (uint256 r = 0; r < rewLength; ++r) {
    uint256 rewardBal = rewards[r].balanceOf(address(this));
    if (rewardBal == 0) {
        continue;  // positiveRewards[r] stays address(0)
    }
    positiveRewards[r] = address(rewards[r]);
    rewardBals[r] = rewardBal - callerFee;
}

However, in magicHarvester.process(), the loop uses break when it encounters address(0):

if(_tokensIn[i] == address(0)) {
    break;
}

This means any rewards positioned after an address(0) gap are silently skipped. Those reward tokens remain in the harvester contract without being swapped or forwarded to the strategy.

For example, if three reward tokens are configured and the first has zero balance but the second and third have positive balances, positiveRewards would be [address(0), tokenB, tokenC]. The process() function would immediately break at index 0 and never process tokenB or tokenC.

Impact:

Medium. Reward tokens with positive balances can be silently skipped during harvesting when earlier reward tokens have zero balances. The affected rewards remain stuck until a subsequent harvest() call where no earlier reward has a zero balance. In the worst case, rewards accumulate unprocessed across multiple harvest cycles, delaying yield distribution to strategy depositors.

Recommendation:

Replace the break with continue in process() so that address(0) entries are skipped rather than terminating the loop:

if(_tokensIn[i] == address(0)) {
    continue;
}

Developer Response:

Applied recommended fix in commit d3cf9e5.

M-3 Finding

M-3: Per-hop slippage checks allow much larger end-to-end losses on multi-hop routes

Medium

Summary:

magicHarvester._process() enforces maxSlippage independently on each route leg, but it never enforces a route-level minimum output across the full multi-hop conversion. As a result, the configured slippage bound compounds across hops, so the realized end-to-end loss can materially exceed the nominal maxSlippage value that operators may expect.

Description:

For each hop, _process() computes a local expectedOut, derives a local minOut, and executes the swap against that per-hop floor:

if (route.functionType == 0) {
    uint256 oracle = CurvePool(route.pool).price_oracle(0);
    uint256 expectedOut;
    if(route.indexIn == 1) {
        expectedOut = (bal * (oracle * SCRVUSD.pricePerShare() / (10 ** 18))) / (10 ** 18);
    } else {
        expectedOut = (bal * (10 ** 18)) / (oracle * SCRVUSD.pricePerShare() / (10 ** 18));
    }
    uint256 minOut = (expectedOut * (10000 - maxSlippage)) / 10000;

    CurvePool(route.pool).exchange{value: 0}(int128(int256(route.indexIn)), int128(int256(route.indexOut)), bal, minOut);
} else if (route.functionType == 2) {
    uint256 expectedOut;
    if(route.indexIn == 0) {
        expectedOut = (bal * (10 ** 18)) / AltCurvePool(route.pool).price_oracle(0);
    } else {
        expectedOut = (bal * AltCurvePool(route.pool).price_oracle(0)) / (10 ** 18);
    }
    uint256 minOut = (expectedOut * (10000 - maxSlippage)) / 10000;
    AltCurvePool(route.pool).exchange{value: 0}(route.indexIn, route.indexOut, bal, minOut);
} else if (route.functionType == 4) {
    uint256 expectedOut;
    if(route.indexIn == 0) {
        expectedOut = (bal * (10 ** 18)) / AltCurvePool(route.pool).price_oracle();
    } else {
        expectedOut = (bal * AltCurvePool(route.pool).price_oracle()) / (10 ** 18);
    }
    uint256 minOut = (expectedOut * (10000 - maxSlippage)) / 10000;
    AltCurvePool(route.pool).exchange{value: 0}(route.indexIn, route.indexOut, bal, minOut);
}

This means maxSlippage is a per-hop limit, not a route-level limit.

Consider a 3-hop route with maxSlippage = 500 (5%):

  1. Hop 1 may clear at 95% of its expected output.
  2. Hop 2 may then clear at 95% of that reduced amount.
  3. Hop 3 may then clear at 95% again.

The route can therefore retain as little as:

$$
0.95^3 = 0.857375
$$

or about 85.74% of the route's nominal expected output, which corresponds to roughly 14.26% total slippage.

More generally, for an n-hop route with per-hop slippage bound s, the worst-case retained fraction is:

$$
(1 - s)^n
$$

So the effective route-level loss grows with hop count even though the configuration exposes only a single global maxSlippage parameter.

This is especially relevant because the intended reUSD to RSUP path is multi-hop. Operators or users reading the 5% setting may reasonably infer that the full conversion path is capped near 5%, but the implementation does not provide that guarantee.

Impact:

The system can legally execute multi-hop conversions with end-to-end losses materially above the configured maxSlippage value, reducing harvested rewards delivered to strategies. This issue weakens price protection and can create significantly worse-than-expected execution on long routes or during stressed market conditions.

Recommendation:

Enforce slippage at the route level, not only at each hop.

Safer options include:

  • quote the entire route off-chain or via trusted route-specific pricing logic and pass a single route-level minAmountOut
  • track the starting input amount and final output amount, then require the final received amount to satisfy a route-level floor
  • scale the allowed per-hop slippage as a function of route length so the configured bound better matches the intended end-to-end protection
  • rename and document maxSlippage explicitly as a per-hop threshold if route-level protection is not intended

Developer Response:

Fixed in commits 40398e8, a812012, d0a5442, and 7aad6ea.

M-4 Finding

M-4: Voter rotation reuses local proposal IDs and corrupts magicVoter state

Medium

Summary:

magicVoter persists all local governance state only by numeric proposal id, while the upstream Resupply voter address is intentionally mutable. After a valid voter rotation, the replacement voter starts its proposal-id sequence from zero again, but magicVoter keeps the old votes, voteTotals, and executed entries alive under those same ids.

As a result, a supported upstream voter rotation can poison a whole prefix of proposals on the replacement voter and can even replay stale wrapper tallies from an old proposal onto an unrelated new proposal. Because commitVote() is public and the contract exposes no in-place reset path for the old namespace, this is a governance integrity issue rather than a mere temporary liveness problem.

Description:

magicVoter keys all local governance state only by proposalId:

mapping(address => mapping(uint256 => VoteData)) public votes; // user => proposalId => userVote
mapping(uint256 => VoteData) public voteTotals; // proposalId => VoteData
mapping(uint256 => bool) public executed; // proposalId => executed

All core paths then operate only on the bare numeric id:

function canVote(uint256 id) public view returns(bool _canVote, uint32 _createdAt) {
    require(!executed[id], "Executed");

    (,uint32 createdAt,,bool processed,) = voter.proposalData(id);
    ...
}

function vote(uint256 id, uint256 pctYes, uint256 pctNo) external {
    ...
    VoteData memory userVote = votes[msg.sender][id];
    require(userVote.yes + userVote.no == 0, "Already voted");
    ...
    votes[msg.sender][id] = userVote;

    VoteData storage totals = voteTotals[id];
    totals.yes += weightYes;
    totals.no += weightNo;
    ...
}

function commitVote(uint256 id) external {
    ...
    VoteData storage totals = voteTotals[id];
    magicStaker.castVote(id, totals.yes, totals.no);
    executed[id] = true;
    emit VoteCommitted(id);
}

At the same time, the upstream voter address is explicitly mutable and is updated through the registry:

function setResupplyVoter() external onlyOperator {
    address _voter = REGISTRY.getAddress("VOTER");
    require(isValidVoter(_voter), "!voter");
    voter = Voter(_voter);
    MagicVoter(magicVoter).setResupplyVoter(_voter);
    emit ResupplyVoterSet(_voter);
}
function setResupplyVoter(address _voter) external {
    require(msg.sender == address(magicStaker), "!auth");
    voter = Voter(_voter);
    emit NewResupplyVoter(_voter);
}

The corrected Resupply voter is a contract-local proposal registry whose ids start from zero and increase with proposalData.length:

function getProposalCount() external view returns (uint256) {
    return proposalData.length;
}

...

uint256 proposalId = proposalData.length;
proposalData.push(...);

Nothing resets or namespaces the local vote state during voter rotation. Because a replacement Resupply voter starts its own fresh proposal counter, numeric ids will be reused from zero. That creates three concrete failure modes.

First, permanent local freeze for reused ids:

  1. Proposal id 0 on the old voter was already committed, so executed[0] = true.
  2. Governance rotates to a new valid Resupply voter.
  3. The new voter creates its own proposal id 0.
  4. magicVoter.canVote(0) now reverts with Executed before even querying whether the new proposal is live.
  5. The wrapper can never participate in that new proposal through magicVoter.

Second, stale per-user vote state blocks participation on unrelated proposals:

  1. Old voter proposal id k accumulated local voteTotals[k] and per-user votes[user][k] but was never committed.
  2. Governance rotates to a new valid Resupply voter.
  3. The new voter later creates a different proposal that also gets id k.
  4. Prior voters may be blocked by Already voted, even though they never voted on the new proposal.

Third, stale wrapper tallies can be committed onto unrelated proposals:

  1. Old voter proposal id k accumulated local voteTotals[k] but was never committed.
  2. Governance rotates to a new valid Resupply voter.
  3. The new voter later creates a different proposal that also gets id k.
  4. commitVote(k) checks only the replacement voter's live proposal state and the local executionDelay.
  5. It then forwards the stale voteTotals[k] from the old proposal into the new upstream proposal id.

That replay path is directly reachable because commitVote() is public and does not verify that the local tally was created under the current upstream voter or for the current proposal payload:

function commitVote(uint256 id) external {
    (bool _canVote, uint32 _createdAt) = canVote(id);
    require(_canVote, "!ended");
    require(_createdAt + executionDelay < block.timestamp, "!time");
    VoteData storage totals = voteTotals[id];
    magicStaker.castVote(id, totals.yes, totals.no);
    executed[id] = true;
    emit VoteCommitted(id);
}

There is also no contract-level state reset for the old namespace. magicVoter provides no function to clear or invalidate stale votes, voteTotals, or executed entries after a voter rotation. Once the collision exists, the only operational recovery is to replace the entire magicVoter contract and repoint magicStaker at a fresh instance.

Impact:

This is a medium-severity governance integrity issue.

If Resupply governance or the wrapper operator rotates the upstream voter, the wrapper can:

  • permanently block voting on new proposals whose numeric ids were already executed on the previous voter
  • prevent users from voting on new proposals because stale votes[user][id] entries make them look as if they already participated
  • miscast the wrapper's aggregate voting power by replaying stale local tallies from an old proposal onto an unrelated proposal on the replacement voter

This is not just a governance liveness problem. A supported lifecycle action, voter rotation, can deterministically corrupt the wrapper's proposal namespace and cause it to cast an incorrect collective vote on a different proposal than the one local users actually voted on.

The issue still depends on a voter rotation event rather than an ordinary day-to-day user action, but the affected path is the intended upgrade path for upstream governance integration, and the contract provides no in-place recovery once stale ids have been poisoned. That combination makes the impact materially stronger than a low-severity operational footgun.

Recommendation:

Namespace local vote state by the upstream voter identity, not only by numeric proposal id.

Safer options:

  • key votes, voteTotals, and executed by (voterAddress, proposalId)
  • invalidate all local vote state on voter rotation and require proposals to restart under the new voter
  • reject voter rotation while there are still active or uncommitted local proposals
  • record a local proposal hash that includes the upstream voter address and proposal metadata before allowing votes

At minimum, the wrapper must not assume proposal ids remain globally unique across voter replacements.

Developer Response:

Recommendations applied in commit 510a1e4.

Voter is set from registry on construct. votes, voteTotals, and executed are keyed to voter.

Voter rotation is not rejected if local proposals exist, as once Resupply voter is updated, those previous proposals are voided automatically on Resupply end. A voter rotation will query a different key in votes, voteTotals, and executed, creating a new, unique proposal that begins as empty.

It should be impossible for magicStaker and magicVoter to have a desynced resupply voter address, but there is an added redundant check anyway to enforce parity at time of vote commitment.

Appendix Poc:

Minimal source-level reproduction:

  1. Run one proposal on voter A until magicVoter.executed[0] = true.
  2. Rotate magicVoter.voter to a fresh voter B via magicStaker.setResupplyVoter().
  3. Create proposal id 0 on voter B.
  4. Call magicVoter.canVote(0).
  5. Observe that the wrapper rejects the new proposal because executed[0] from voter A is still set.

A stronger replay sequence is:

  1. On voter A, local users vote on proposal k, so magicVoter.voteTotals[k] becomes nonzero, but no one calls commitVote(k) before rotation.
  2. Rotate magicVoter.voter to a fresh voter B.
  3. Voter B later creates a different proposal that also receives id k.
  4. Wait until the new proposal is past executionDelay but still within the upstream voting period.
  5. Call magicVoter.commitVote(k).
  6. Observe that the wrapper forwards the stale tally from old proposal k on voter A into the unrelated new proposal k on voter B.

An analogous sequence with a previously voted user also shows votes[user][k] carrying over and causing Already voted on the replacement voter's new proposal k.

L-1 Finding

L-1: Shared proposer address in magicStaker causes per-account proposal delay to affect all users

Low

Summary:

All proposals created through magicStaker.createProposal() use address(this) as the proposer when calling voter.createNewProposal(). The Resupply Voter enforces a per-account delay between proposals, so this shared address means a single user's proposal locks out every other magicStaker user until the delay period elapses.

Description:

createProposal() forwards the call to the Resupply Voter with address(this) as the proposer. Because the Voter tracks cooldowns per proposer address, the entire magicStaker contract is treated as one proposer. Any user who creates a proposal starts the cooldown for all users.

This creates a racing or griefing vector: a user with the minimum required weight can repeatedly create proposals to prevent other magicStaker users from proposing.

Impact:

Low.

Recommendation:

Track individual user addresses when creating proposals, or implement an internal cooldown per user rather than relying on the Voter's per-account delay applied to the contract address.

Developer Response:

Applied recommended change in commit 0466c82.

Individual accounts are tracked by proposalCreationDelay, and it enforces the upstream delay + 7 days, so multiple users have a fair chance at creating proposals / deters spam.

L-2 Finding

L-2: Use `forceApprove()` instead of `approve()` for better ERC20 compatibility

Low

Summary:

Multiple contracts use IERC20.approve() to set token allowances. Some ERC20 tokens (e.g., USDT) revert when changing a non-zero allowance to another non-zero value, which can cause these calls to fail unexpectedly.

Description:

In magicHarvester.sol, approve() is used in approveStrategy() to grant max allowance to a strategy, in setRoute() to revoke approvals on old routes and grant max allowance on new ones. In magicStaker.sol, setStrategyHarvester() uses approve() to revoke allowance from the old harvester and grant max allowance to the new one.

Tokens like USDT require the allowance to be set to zero before it can be changed to a non-zero value. If any residual allowance exists (e.g., from a partial spend), a direct approve(spender, type(uint256).max) call will revert. OpenZeppelin's SafeERC20.forceApprove() handles this by first resetting the allowance to zero when needed.

Affected locations:

Impact:

Low.

Recommendation:

Replace all IERC20.approve() calls with OpenZeppelin's SafeERC20.forceApprove(), which safely handles tokens that require the allowance to be reset to zero first.

Developer Response:

Applied recommended changes in commit c1f011b.

L-3 Finding

L-3: `execute()` silently returns on failure instead of reverting

Low

Summary:

The execute() function across multiple magic contracts performs a low-level .call() but does not revert when the call fails, silently returning success = false to the caller.

Description:

The execute() function in magicSavings, magicStaker, magicHarvester, and magicPounder performs an arbitrary low-level call on behalf of RESUPPLY_CORE:

(bool success, bytes memory result) = _to.call{value: _value}(_data);
emit Executed(_to, _value, _data, success);
return (success, result);

The success flag is returned but never enforced. If the underlying call reverts or fails, execute() still completes successfully, emitting an Executed event with success = false. Since RESUPPLY_CORE is an external Resupply protocol contract that initiates these calls, if it also does not check the returned success flag, the entire execution will be recorded as successful despite the underlying operation failing. This can lead to silent failures where critical operations (e.g., token transfers, approvals, or state changes) are assumed to have succeeded when they did not.

Impact:

Low. The execute() function is a privileged fallback callable only by RESUPPLY_CORE, so exploitation by external actors is not possible. However, silent failures in privileged operations could cause incorrect protocol state that is difficult to detect and diagnose.

Recommendation:

Require the low-level call to succeed by adding a revert on failure:

(bool success, bytes memory result) = _to.call{value: _value}(_data);
require(success, "execute failed");

Apply this fix to execute() in magicSavings, magicStaker, magicHarvester, and magicPounder.

Developer Response:

Applied recommended fix in commit 4ae8e3d.

L-4 Finding

L-4: RSUP reward token accounting can be broken by external `getReward()` calls

Low

Summary:

The harvest() function in magicStaker has special handling for when RSUP is a configured reward token, but this logic is vulnerable to front-running or permissionless external calls.

Description:

When RSUP is a reward token, harvest() snapshots the contract's RSUP balance before calling STAKER.getReward(), then subtracts the snapshot from the post-claim balance to isolate only newly-received rewards. This is necessary because the contract also holds RSUP from staking and cooldown operations.

However, STAKER.getReward() is permissionless, anyone can call it on behalf of any account, including magicStaker. If an external actor calls STAKER.getReward(magicStaker) outside of the harvest() flow, the claimed RSUP rewards land in the contract and become part of the rsupBal snapshot on the next harvest() call. Those rewards are then subtracted out and effectively lost.

Impact:

Low. The RSUP token is not currently a reward token and is unlikely to become one. If it were added as a reward in the upstream GovStaker, the accounting would silently lose rewards that were claimed externally.

Recommendation:

Forbid RSUP from being added as a reward token by adding a check in the reward token registration logic, or track claimed RSUP rewards in a dedicated state variable rather than relying on balance snapshots.

Developer Response:

Fixed in commit b9ca057.

Since RSUP is extremely unlikely to ever become a reward token, added logic to forbid it, and removed now-unnecessary RSUP balance checks in harvest.

L-5 Finding

L-5: Last strategy remainder logic can be skipped when its supply is zero

Low

Summary:

The reward distribution loop in harvest() assigns all remaining reward balances to the last strategy to avoid rounding dust. This logic can be unintentionally bypassed.

Description:

In harvest(), rewards are distributed across strategies proportionally. The last strategy in the strategies array receives the full remaining balance (instead of a proportional share) to avoid rounding losses. This is handled by the if(i == stratLength - 1) check.

However, earlier in the same loop, strategies with a totalSupply() of zero are skipped via continue. If the last strategy in the array has zero supply, it is skipped entirely, and the remainder-assignment logic never executes. The leftover reward tokens remain in the contract undistributed.

for (uint256 i = 0; i < stratLength; ++i) {
    address strategy = strategies[i];
    uint256 stratSupply = Strategy(strategy).totalSupply();
    if (stratSupply == 0) {
        continue; // skips the last strategy entirely if its supply is zero
    }
    require(strategyHarvester[strategy] != address(0), "!harvester");
    uint256[10] memory stratShares;
    for (uint256 r = 0; r < rewLength; ++r) {
        if(positiveRewards[r] == address(0)) {
            continue;
        }
        if(i == stratLength - 1) {
            // this block is never reached if the last strategy was skipped above
            uint256 lastRewardBal = rewards[r].balanceOf(address(this));
            if(positiveRewards[r] == address(RSUP)) {
                lastRewardBal -= rsupBal;
            }
            stratShares[r] = lastRewardBal;
            continue;
        }
        stratShares[r] = (rewardBals[r] * stratSupply) / staticSupply;
    }
    Harvester(strategyHarvester[strategy]).process(positiveRewards, stratShares, strategy);
}

Impact:

Low. Reward token dust accumulates in the magicStaker contract when the last strategy has zero supply.

Recommendation:

Restructure the remainder logic to assign leftover balances to the last active strategy (i.e., the last strategy with non-zero supply) rather than the last element in the array.

Developer Response:

Leaving unchanged, due to low impact of dust accumulation, and low chance of a zero-supply strategy.

L-6 Finding

L-6: Adding a reward token does not sync existing harvester allowances

Low

Summary:

addRewardToken() updates the wrapper's reward-token list, but it does not grant the newly added token's allowance to harvesters that were already configured for active strategies. As a result, even if routes for the new reward token are configured correctly, harvest can still revert later because the harvester cannot pull the token from magicStaker.

Description:

Reward tokens are added only to bookkeeping in magicStaker:

function addRewardToken(address _rewardToken) external onlyManager {
    require(_rewardToken != address(0), "!zeroAddress");
    require(rewards.length < 10, "!maxRewards");
    require(!isRewardToken[_rewardToken], "!exists");
    isRewardToken[_rewardToken] = true;
    rewards.push(IERC20(_rewardToken));
    emit NewRewardToken(_rewardToken);
}

The only place that approves reward tokens from magicStaker to a strategy harvester is setStrategyHarvester():

function setStrategyHarvester(address _strategy, address _harvester, bool _keepOldApproval) external onlyOperator {
    ...
    strategyHarvester[_strategy] = _harvester;
    for(uint256 i = 0; i<rewards.length; ++i) {
        rewards[i].approve(_harvester, type(uint256).max);
    }
    emit StrategyHarvesterSet(_strategy, _harvester);
}

That loop only covers the reward set that exists at the moment setStrategyHarvester() is called.

Later, harvest() will include any newly added reward token with a positive balance and send the intended share into the configured harvester:

Harvester(strategyHarvester[strategy]).process(positiveRewards, stratShares, strategy);

Inside the harvester, the first step for each token is to pull it from magicStaker:

function _process(address _tokenIn, address _tokenOut, uint256 _amountIn) internal {
    require(_amountIn > 0, "!amount");
    IERC20(_tokenIn).safeTransferFrom(msg.sender, address(this), _amountIn);
    ...
}

Here, msg.sender is magicStaker. If the token was added after the harvester was configured, no approval exists for that new token unless the operator explicitly re-runs harvester setup.

That creates the following sequence:

  1. Operator configures a harvester for an active strategy.
  2. Manager later adds a new reward token.
  3. Operator configures valid swap routes for that new token.
  4. The upstream staker eventually emits the new token to magicStaker.
  5. harvest() reaches Harvester.process(...) with that token.
  6. _process() tries safeTransferFrom(magicStaker, harvester, amount).
  7. The transfer reverts because the new token was never approved to the already-installed harvester.

This is distinct from the already-known route-gap issue. Even with a correct route, the harvester can still fail because approval state was not synchronized when the reward set changed.

The removal path is also incomplete. removeRewardToken() removes the token from bookkeeping, but it does not revoke that token's existing allowance from currently configured harvesters:

function removeRewardToken(uint256 _rewardIndex, address _rewardToken) external onlyManager {
    require(address(rewards[_rewardIndex]) == _rewardToken, "!mismatchId");
    isRewardToken[_rewardToken] = false;
    rewards[_rewardIndex] = rewards[rewards.length - 1];
    rewards.pop();
    emit RemoveRewardToken(_rewardToken);
}

So reward-token reconfiguration can leave both:

  • missing approvals for newly added reward tokens, and
  • stale approvals for removed reward tokens

Impact:

This is a low-severity operational integration issue.

If management expands the reward-token set after harvesters are already installed, harvest can still fail later even if routes are configured properly. Conversely, removing a reward token can leave stale allowance residue behind.

The issue is still bounded by privileged configuration changes and is recoverable by repairing approvals, which keeps the severity low rather than medium.

Recommendation:

Keep approval state synchronized with the reward-token set.

Safer options include:

  • in addRewardToken(), approve the new token to every active strategy harvester
  • in removeRewardToken(), revoke the removed token from every active strategy harvester before removing it from bookkeeping
  • add an explicit admin sync function that recomputes all reward-token approvals across all active harvesters
  • prefer exact-amount approvals during harvest instead of persistent blanket allowances

At minimum, reward-token addition should not be considered complete until both route configuration and spender approval state have been updated for all active harvesters.

Developer Response:

Fixed in commit 817ea56.

Add and remove reward token functions cycle through unique harvesters, and grant or revoke approvals

Appendix Poc:

Minimal source-level reproduction:

  1. Configure a strategy harvester while the reward set contains only token R0.
  2. Later call addRewardToken(R1).
  3. Configure routes so the harvester can process R1 correctly.
  4. Let the upstream staker send a positive balance of R1 to magicStaker.
  5. Call harvest().
  6. Observe that Harvester.process(...) reaches _process(R1, ...), but safeTransferFrom(magicStaker, harvester, amount) reverts because R1 was never approved when the harvester was originally installed.
L-7 Finding

L-7: Hardcoded staker breaks wrapper governance after resupply staker upgrade

Low

Summary:

The wrapper follows Resupply voter upgrades through the registry, but it hardcodes the underlying staking contract forever. Resupply, meanwhile, explicitly supports replacing the staker through the registry and migrating positions into the new staker. If governance upgrades both components, the wrapper can end up voting through the new voter while all of its stake remains stranded in the old staker, causing its governance weight on the new voter to drop to zero.

Description:

The wrapper pins the Resupply staker to a single constant address and approves that address once in the constructor:

Registry public constant REGISTRY = Registry(0x10101010E0C3171D894B71B3400668aF311e7D94);
Staker public constant STAKER = Staker(0x22222222E9fE38F6f1FC8C61b25228adB4D8B953);
IERC20 public constant RSUP = IERC20(0x419905009e4656fdC02418C7Df35B1E61Ed5F726);

constructor(address _magicPounder, address _magicVoter, address _operator, address _manager) OperatorManager(_operator, _manager) {
    RSUP.approve(address(STAKER), type(uint256).max);
    ...
}

All stake lifecycle operations continue to use that hardcoded staker:

RSUP.safeTransferFrom(msg.sender, address(this), _amount);
STAKER.stake(_amount);
STAKER.cooldown(address(this), _amount);
uint256 amount = STAKER.unstake(address(this), address(this));

In contrast, the wrapper's governance pointer is upgradeable from the registry:

function setResupplyVoter() external onlyOperator {
    address _voter = REGISTRY.getAddress("VOTER");
    require(isValidVoter(_voter), "!voter");
    voter = Voter(_voter);
    MagicVoter(magicVoter).setResupplyVoter(_voter);
    emit ResupplyVoterSet(_voter);
}

The corrected Resupply codebase clearly supports staker replacement through the registry:

function setStaker(address _newAddress) external onlyOwner{
    staker = _newAddress;
    _setAddress(_newAddress, STAKER, keccak256(bytes(STAKER)));
}

And the corrected GovStaker includes a migration path that explicitly targets registry.staker():

function migrateStake() external returns (uint amount) {
    require(cooldownEpochs == 0, "cooldownEpochs != 0");
    IGovStaker staker = IGovStaker(registry.staker());
    require(address(this) != address(staker), "!migrate");
    ...
    staker.stake(msg.sender, amount);
    staker.onPermaStakeMigrate(msg.sender);
    return amount;
}

The wrapper has no analogous path. It can update its voter pointer to match the registry, but it cannot migrate its own aggregate stake away from the hardcoded STAKER constant.

That creates the following upgrade failure mode:

  1. Resupply governance deploys a new staker and a new voter wired to that staker.
  2. The registry is updated to point at the new contracts.
  3. The wrapper calls setResupplyVoter() and begins using the new voter.
  4. The wrapper's actual RSUP position still lives in the old hardcoded staker.
  5. The new voter reads account weight from its own configured staker, where the wrapper has no stake.
  6. Proposal creation and vote forwarding can fail because the wrapper's effective upstream governance weight on the new voter is zero.

Impact:

This is a low-severity governance availability issue.

If Resupply governance replaces the staking system and corresponding voter, the wrapper can lose its upstream voting power even though user funds remain staked in the old underlying staker.

The trigger is a relatively uncommon upstream upgrade path rather than an ordinary live user flow, and the consequence is governance unavailability rather than direct fund loss, which keeps the realistic severity low.

Recommendation:

Make the wrapper's staker integration upgrade-aware in the same way its voter integration already is.

Safer options:

  • replace the hardcoded STAKER constant with a registry-derived staker reference
  • add an operator/governance-controlled staker migration flow for the wrapper's aggregate position
  • block setResupplyVoter() unless the wrapper is still aligned with the staker used by the new voter
  • if staker immutability is intentional, explicitly reject voter upgrades that point to a governance system using a different staker

At minimum, the wrapper should not follow voter upgrades independently of the staking system that determines upstream weight.

Developer Response:

Fixed in commit 5875269 and 9005507.

Offers a pause function that prevents new stakes, as pending stakes must mature before being migrated.

Offers an atomic migration function that relies on the original staker having its cooldownEpoochs set to 0, and relies on pause function and 1 epoch maturity of any pending stakes.

In the event atomic migration is not possible, DAO can use execute() function to carry out migration, and use manualSetStaker() to finalize the address change. This requires full withdraw of all old staked balances to be completed, and for the newly staked balance to accurately reflect totalSupply.

Appendix Poc:

Minimal source-level reproduction:

  1. Assume the wrapper has a nonzero RSUP balance staked through the hardcoded STAKER address.
  2. Deploy a new Resupply staker and a new voter wired to that staker.
  3. Update the registry's STAKER and VOTER entries.
  4. Call magicStaker.setResupplyVoter().
  5. Try to create or cast a proposal through the wrapper on the new voter.
  6. Observe that the wrapper now uses the new governance contract, but its stake remains in the old hardcoded staker and therefore no longer backs voting weight on the new voter.
L-8 Finding

L-8: Upstream cooldownEpoch Changes Can Desync Local And Shared RSUP Unlock State

Low

Summary:

magicStaker tracks user cooldown maturity with a local epoch value, but the actual RSUP remains locked in one shared upstream GovStaker cooldown position owned by address(this). If the upstream STAKER.cooldownEpochs() value changes while a shared cooldown batch is already pending, a later wrapper cooldown can rewrite the global pendingCooldownEpoch without updating earlier users' local maturity epochs.

That can leave local user state claiming a cooldown has matured before the wrapper's current shared upstream batch is actually ready to be pulled. If the wrapper already holds unrelated RSUP, an early caller can be paid from that residual balance instead of from the batch their local cooldown is supposed to represent.

Description:

The wrapper computes a local maturity epoch from the current upstream cooldown setting whenever a user starts cooldown:

uint256 cde = STAKER.cooldownEpochs();
uint256 systemEpoch = getEpoch();
uint256 coolPeriod = cde + 1;
uint256 nextCoolPeriod = systemEpoch + coolPeriod;
...
accountCooldownData[msg.sender].maturityEpoch = pendingCooldownEpoch;
STAKER.cooldown(address(this), _amount);

But the underlying GovStaker does not track separate cooldown batches per end user. It keeps one cooldown record for the wrapper account and overwrites the shared end timestamp on each new cooldown:

UserCooldown memory userCooldown = cooldowns[_account];
userCooldown.end = uint104(block.timestamp + (cooldownEpochs * epochLength));
userCooldown.amount += uint152(_amount);
cooldowns[_account] = userCooldown;

Inside magicStaker, a later cooldown can rewrite the shared pendingCooldownEpoch even when an earlier batch has not yet been pulled:

if (pendingCooldownEpoch <= systemEpoch) {
    _rsupUnstake();
    pendingCooldownEpoch = nextCoolPeriod;
} else if (pendingCooldownEpoch != nextCoolPeriod) {
    pendingCooldownEpoch = nextCoolPeriod;
}

Earlier users' local maturity epochs are not retroactively updated.

Later, unstake() only checks the user-local maturity epoch:

require(accountCooldownData[msg.sender].amount > 0, "0");
require(accountCooldownData[msg.sender].maturityEpoch <= getEpoch(), "!epoch");
_unstake();

But _unstake() only pulls from upstream if the shared pendingCooldownEpoch has matured:

if (pendingCooldownEpoch <= getEpoch()) {
    _rsupUnstake();
    pendingCooldownEpoch = type(uint256).max;
}
uint256 amount = accountCooldownData[msg.sender].amount;
accountCooldownData[msg.sender].amount = 0;
RSUP.safeTransfer(msg.sender, amount);

That means the wrapper can enter a desynchronized state:

  1. User A enters cooldown under one upstream cooldownEpochs() configuration.
  2. Before A's batch is pulled, the upstream cooldown setting changes.
  3. User B enters cooldown under the new setting during a newly valid wrapper cooldown epoch.
  4. magicStaker rewrites pendingCooldownEpoch to the new shared epoch without updating A's local maturityEpoch.
  5. User A later satisfies the local unstake() check before the shared wrapper batch is eligible for _rsupUnstake().
  6. _unstake() skips the upstream pull but still attempts to transfer RSUP out of the wrapper's current balance.

If the wrapper already holds unrelated RSUP, such as residual RSUP from a previously pulled matured batch that other users have not claimed yet, A can be paid from that liquidity instead of from the still-locked batch their cooldown logically belongs to.

If no idle RSUP is available, the transfer simply reverts and A remains blocked until the later shared maturity.

This is configuration-driven rather than a steady-state user-flow issue, because it requires the upstream STAKER.cooldownEpochs() value to change during live operation.

Impact:

This is a low-severity integration risk tied to upstream reconfiguration.

Under a live upstream cooldown-parameter change, the wrapper can temporarily lose the invariant that local cooldown maturity corresponds to actually unlocked RSUP. That can lead to either:

  • premature payment from unrelated residual RSUP already sitting in the wrapper, or
  • temporary user lockup until the rewritten shared cooldown epoch arrives

The issue requires a relatively specific upstream parameter change and, for the early-payout branch, residual idle RSUP to already exist in the wrapper. In the more common case it manifests as temporary withdrawal friction, so the realistic severity is low.

Recommendation:

Do not treat user-local cooldown maturity as authoritative when the actual RSUP is locked in one shared upstream cooldown position.

Safer options include:

  • derive local unstake eligibility from the actual upstream shared cooldown state before paying out RSUP
  • refuse to rewrite pendingCooldownEpoch while earlier local cooldowns still reference the previous batch
  • snapshot the upstream cooldown regime per batch and track users against explicit batch identifiers instead of a mutable global epoch
  • reject new wrapper cooldowns after an upstream cooldown-parameter change until the existing shared batch has been fully settled

At minimum, _unstake() should not transfer RSUP to a user unless the wrapper has actually pulled the batch that backs that user's local cooldown entitlement.

Developer Response:

Fixed in commit 95dcbb7.

Went with the last recommendation of rejecting new wrapper cooldowns until maturity/successful unstake is reached.

Instead of always syncing pendingCooldownEpoch and proceeding, it is only synced after an unstake, when the value is (uint256).max (set during successful upstream unstaking).

If the values are desynced, the cooldown request will revert until previous cooldowns reach maturity and unstaking succeeds.

Appendix Poc:

One concrete sequence is:

  1. STAKER.cooldownEpochs() initially implies wrapper cooldowns every 4 epochs.
  2. User A enters cooldown and gets local maturityEpoch = E + 4.
  3. Before E + 4, upstream changes cooldownEpochs() so wrapper cooldowns now happen every 3 epochs.
  4. User B enters cooldown at the first newly valid epoch under the new regime.
  5. magicStaker rewrites pendingCooldownEpoch to the new shared epoch without updating A's stored maturityEpoch.
  6. At epoch E + 4, A passes the local maturity check, but _unstake() does not call _rsupUnstake() because the shared pendingCooldownEpoch is still later.
  7. If the wrapper already holds residual RSUP, A is paid from that balance instead of from the upstream batch that is still not unlocked.
L-9 Finding

L-9: magicPounder rounding can mint dust-level phantom pending stake via syncAccount

Low

Description:

The magicPounder contract implements a share-based compounding strategy where user balances are represented by shares that appreciate over time. When setUserBalance() assigns a new balance to an account, it converts the requested underlying amount into shares via underlyingToShares(), which applies floor rounding. The function then updates totalSupply by the full requested _balance, but sharesTotalSupply only increases by the rounded-down newShares.

In contracts/magicPounder.sol:35-85, the core logic computes newShares from the requested underlying amount, updates share supply using that rounded value, and then sets totalSupply using the unrounded requested balance. When newShares rounds down, including to zero for sufficiently small allocations relative to the current price per share, totalSupply increases by more than the shares imply. Because balanceOf() derives each holder's underlying balance from sharesOf[user] * totalSupply / sharesTotalSupply, existing shareholders can observe an increase in reported underlying balance without receiving additional shares or corresponding compounded value.

The magicStaker contract then treats any increase in strategy-0 balance as claimable compounded yield. In contracts/magicStaker.sol:174-182,379-388,422-491,501-525,532-593, _syncMagicBalance() compares the current strategy-0 balanceOf(_account) against the stored magicStake value and credits any positive difference into pendingStake and totalPending. _setUserStrategyBalance() updates strategy 0 and stores the resulting balance, but it does not verify that the post-update strategy balance matches the requested amount. As a result, rounding-induced balance drift in magicPounder may be realized as phantom pending stake during syncAccount().

A representative sequence is:

  1. Account A holds magicPounder shares and has already synchronized magicStake.
  2. Rewards increase the share price above 1.
  3. Account B receives a small strategy-0 allocation through _setUserStrategyBalance(strategies[0], B, tinyAmount).
  4. magicPounder.setUserBalance(B, tinyAmount) computes newShares = 0 or another rounded-down value, but still increments totalSupply by tinyAmount.
  5. Account A's share count is unchanged, but its reported balanceOf(A) rises due to the inflated totalSupply.
  6. Account A calls syncAccount(), and _syncMagicBalance() credits that artificial increase as pendingStake.

Impact:

Low. Existing magicPounder holders may convert rounding discrepancies into additional pendingStake and later voting power that is not backed by actual compounded RSUP. The effect per event is bounded by share-price rounding and is typically dust-sized, which limits practical loss. The main issue is accounting divergence: syncAccount() may recognize balance growth that does not correspond to real strategy yield.

Recommendation:

Adjust magicPounder.setUserBalance() so that totalSupply tracks the balance implied by the rounded share allocation rather than the requested _balance. One approach is to derive the actual post-rounding balance after computing newShares and use that value in the supply update.

  uint256 newShares = underlyingToShares(_balance);
  if(newShares < oldShares) {
      sharesTotalSupply -= oldShares - newShares;
  } else {
      sharesTotalSupply += newShares - oldShares;
  }
  sharesOf[_account] = newShares;
+ uint256 actualBalance = (newShares * totalSupply) / sharesTotalSupply;
- totalSupply = totalSupply - userBalance + _balance;
+ totalSupply = totalSupply - userBalance + actualBalance;

Another option is to revert when rounding causes the assigned balance to differ from the requested amount.

As a defense-in-depth measure, magicStaker._setUserStrategyBalance() may also verify that strategy 0 reports the requested balance after setUserBalance() and revert if the values diverge.

Developer Response:

Applied recommended actualBalance calculation for totalSupply in feb2be5.

I-1 Finding

I-1: `_process()` logic is tightly coupled to the reUSD → RSUP route assumptions

Informational

Summary:

The _process() function in magicHarvester presents itself as a generic multi-hop swap router, but its internal logic contains several hardcoded assumptions that tie it specifically to the reUSD → scrvUSD → crvUSD → WETH → RSUP swap path (and the reUSD → sreUSD deposit path).

Description:

Despite accepting arbitrary Route[] configurations, _process() embeds assumptions about the tokens and pools involved in each functionType:

  • No token decimal normalization. All price oracle calculations assume 18-decimal tokens. The expectedOut computations divide and multiply by 10 ** 18 directly without querying IERC20Metadata.decimals(). Using tokens with non-18-decimal precision (e.g., USDC with 6 decimals) would produce incorrect slippage bounds.

  • functionType == 0 is specific to the reUSD/scrvUSD pool. The oracle price is adjusted by SCRVUSD.pricePerShare(), which only makes sense when the pool involves scrvUSD as the quote token. This logic would produce incorrect expectedOut values for any other CurveStableSwapNG pool not denominated in crvUSD underlying via scrvUSD.

  • functionType == 2 uses a hardcoded oracle index. The call AltCurvePool(route.pool).price_oracle(0) always queries oracle index 0, regardless of the actual token indices configured in the route. This is correct for the crvUSD/WETH pool used in the current route but would silently produce wrong slippage protection for pools where the relevant oracle corresponds to a different index.

  • functionType == 4 uses the parameterless price_oracle(). This variant calls price_oracle() without an index argument, which is specific to two-token Curve pools. It would revert or return incorrect values on pools that only implement the indexed variant.

The intended routes, as seen in the test suite, are:

  1. reUSD → scrvUSD (functionType 0, CurveStableSwapNG)
  2. scrvUSD → crvUSD (functionType 1, scrvUSD redeem)
  3. crvUSD → WETH (functionType 2, AltCurvePool exchange)
  4. WETH → RSUP (functionType 4, AltCurvePool exchange with parameterless oracle)

And separately: reUSD → sreUSD (functionType 3, sreUSD deposit).

Impact:

Informational. The tight coupling between the generic routing interface and the hardcoded swap logic creates a risk of misconfiguration if new routes are introduced that do not match these assumptions.

Recommendation:

Document explicitly (in code comments and deployment documentation) that magicHarvester is purpose-built for the reUSD → RSUP and reUSD → sreUSD swap paths. Warn operators that introducing new routes requires careful review of _process() internals, as the slippage protection logic, oracle calls, and decimal handling are not generalized. Any new functionType or pool configuration must be validated against these assumptions before deployment.

Developer Response:

Added documentation in project Readme as well as harvester code comments.

Commit: e61cf11.

I-2 Finding

I-2: Last-Minute Reweight Or Stake Can Capture Previously Accrued Rewards

Informational

Summary:

magicStaker updates strategy balances immediately, but harvest() allocates the wrapper's already-accrued reward pot using each strategy's live totalSupply() at harvest time. As a result, a user can increase exposure to a target strategy immediately before a permissionless harvest and capture rewards that accrued before they held that exposure.

The strongest path is an already-deposited user who reweights once near harvest and then calls harvest() themselves. A fresh-deposit variant also exists, but it is weaker because the new capital cannot be exited immediately due to cooldown timing.

Description:

The issue is a spot-balance accounting mismatch across two layers.

First, user strategy exposure changes immediately:

function setWeights(uint112[] memory _weights) public {
	...
	accountWeightData[msg.sender] = AccountWeightData({
		weights: _weights,
		lastUpdateEpoch: uint16(getEpoch())
	});

	_syncMagicBalance(msg.sender);
	_syncAccount(msg.sender);
}

function stake(uint256 _amount) external {
	...
	accountStakeData[msg.sender] = acctData;
	_syncMagicBalance(msg.sender);
	_syncAccount(msg.sender);
}

So both setWeights() and stake() can raise a strategy's live balance immediately before harvest.

Second, harvest() allocates the reward pot using the strategy's live supply at the moment harvest runs:

uint256 staticSupply = totalSupply;
for (uint256 i = 0; i < stratLength; ++i) {
	address strategy = strategies[i];
	uint256 stratSupply = Strategy(strategy).totalSupply();
	...
	stratShares[r] = (rewardBals[r] * stratSupply) / staticSupply;
	Harvester(strategyHarvester[strategy]).process(positiveRewards, stratShares, strategy);
}

There is no wrapper-level reward debt or pre-harvest checkpoint. The strategy slice is therefore:

$$
\text{strategyReward} = \text{netHarvestedReward} \times \frac{\text{strategySupplyAtHarvest}}{\text{wrapperTotalSupplyAtHarvest}}
$$

If a user increases a target strategy from 4,000 / 10,000 of wrapper supply to 6,000 / 12,000 immediately before harvest, that strategy's reward share rises from 40% to 50% even though the extra exposure was not present while the rewards were accruing upstream.

The strategy layer then compounds the same problem:

  • magicSavings distributes using live totalSupply and live balanceOf[account]
  • magicPounder increases pooled assets for current share owners only

So the late mover can capture part of the strategy reward that should have gone to incumbents.

Impact:

This causes economic dilution of incumbent users.

An already-deposited user can reweight into the target strategy shortly before a permissionless harvest and capture rewards accrued while other users held that exposure. The value transferred scales with the size of the unharvested reward pot.

Two factors reduce severity but do not remove the issue:

  • new deposits are subject to cooldown and cannot be exited immediately
  • setWeights() is limited to once per epoch

Those constraints weaken the fresh-deposit variant and reduce repeat frequency, but they do not prevent a one-shot pre-harvest rebalance by an existing staker.

Recommendation:

Do not allocate already-accrued rewards using live spot balances alone.

Safer approaches include:

  • checkpointing reward debt before stake or weight changes that alter strategy exposure
  • maintaining per-strategy reward indices at the wrapper layer
  • excluding balances added after the previous harvest from sharing in already-accrued rewards

Developer Response:

Dilution from new deposits is possible, but last-second weight changes should be a non-issue. Since all underlying RSUP are earning the same rewards, changing weight before a harvest should only affect how much of the rewards are portioned to each strategy. This is the expected result and is not diluting other users, except in the case of an entirely new deposit.

Dilution from new deposits is expected and will be advertised. Cooldown delays should prevent most abusive deposits.

On the other end, users entering a cooldown when a harvest has not just been completed, are donating unharvested rewards to the remaining stakers, and creating a reverse-dilution effect.

I-3 Finding

I-3: Early quorum commit can lock the wrapper's full vote before voting closes

Informational

Summary:

magicVoter allows anyone to commit the aggregate local vote after executionDelay, even though the upstream voting period may still be open. Once committed, magicStaker.castVote() converts the current yes/no totals into a full-strength 100% upstream vote, and the upstream Voter will not accept a second vote from the wrapper account. As a result, a faction that controls just over the 20% local quorum threshold can permanently lock the wrapper's entire external vote if it commits first after executionDelay, before later participants have finished voting.

Description:

The local vote can be finalized before the upstream voting window ends:

function canVote(uint256 id) public view returns(bool _canVote, uint32 _createdAt) {
	require(!executed[id], "Executed");

	(,uint32 createdAt,,bool processed,) = voter.proposalData(id);

	uint256 period = voter.votingPeriod();
	_createdAt = createdAt;
	if(_createdAt + period > block.timestamp && !processed) {
		_canVote = true;
	} else {
		_canVote = false;
	}
}

function vote(uint256 id, uint256 pctYes, uint256 pctNo) external {
	...
	(bool _canVote, uint32 _createdAt) = canVote(id);
	require(_canVote, "!ended");
	...
	VoteData storage totals = voteTotals[id];
	totals.yes += weightYes;
	totals.no += weightNo;
	...
	if(_createdAt + executionDelay < block.timestamp) {
		try magicStaker.castVote(id, totals.yes, totals.no) {
			executed[id] = true;
			emit VoteCommitted(id);
		} catch {
			...
		}
	}
}

function commitVote(uint256 id) external {
	(bool _canVote, uint32 _createdAt) = canVote(id);
	require(_canVote, "!ended");
	require(_createdAt + executionDelay < block.timestamp, "!time");
	VoteData storage totals = voteTotals[id];
	magicStaker.castVote(id, totals.yes, totals.no);
	executed[id] = true;
	emit VoteCommitted(id);
}

canVote() keeps local voting open until the upstream voting period ends, but both vote() and commitVote() can irreversibly commit the wrapper's upstream vote earlier, immediately after executionDelay.

The finalization is irreversible at both layers:

function castVote(uint256 id, uint256 totalYes, uint256 totalNo) external {
	require(msg.sender == magicVoter, "!voter");
	uint256 total = totalYes + totalNo;
	require((totalSupply * 2000) / DENOM <= total, "!quorum");
	uint256 weightYes = (totalYes * DENOM) / total;
	uint256 weightNo = DENOM - weightYes;
	voter.voteForProposal(address(this), id, weightYes, weightNo);
	emit VoteCast(id, weightYes, weightNo);
}

castVote() does not forward the raw local turnout. It only checks that local turnout reached 20%, then rescales the current yes/no split into a full 100% upstream vote for the wrapper account.

That means the bug is not that 20% always governs the wrapper. The bug is that once 20% has voted and executionDelay has passed, that 20% can lock the wrapper's full upstream vote if the remaining 80% has not participated yet. If the rest of the voting power already voted before commit, the early faction cannot override them. The exploitability comes from the ability to finalize early while upstream voting is still open.

function _voteForProposal(address account, uint256 id, uint256 pctYes, uint256 pctNo) internal {
	require(id < proposalData.length, "Invalid ID");
	Vote memory vote = accountVoteWeights[account][id];
	require(vote.weightYes + vote.weightNo == 0, "Already voted");

	Proposal memory proposal = proposalData[id];
	require(!proposal.processed, "Proposal already processed");
	require(proposal.createdAt + VOTING_PERIOD > block.timestamp, "Voting period has closed");

	uint256 accountWeight = staker.getAccountWeightAt(account, proposal.epoch) / 10 ** TOKEN_DECIMALS;
	require(accountWeight > 0, "Account weight is zero");

	vote.weightYes = uint40(accountWeight * pctYes / MAX_PCT);
	vote.weightNo = uint40(accountWeight * pctNo / MAX_PCT);
	accountVoteWeights[account][id] = vote;
	...
}

Upstream Voter allows each account to vote only once per proposal. Because the wrapper votes as address(this), the first successful commit consumes the wrapper's single upstream vote while the upstream proposal may still be open.

Concrete consequence:

  1. A proposal opens upstream and local users begin voting.
  2. After executionDelay, a faction representing just over the 20% local quorum threshold causes magicVoter to call magicStaker.castVote() before the rest of the users have participated.
  3. magicStaker scales that faction's current yes/no split into the wrapper's full upstream vote.
  4. The upstream voter records the wrapper as having voted and will reject any later correction.
  5. Users who intended to vote later within the still-open upstream voting period are permanently excluded from the wrapper's final decision.

This is not merely an abstention model. The local contract continues to key liveness to the upstream voting period in canVote(), but the wrapper's effective decision point is whichever transaction first commits after executionDelay. In practice, that means a whale with roughly 20% of wrapper supply can lock the wrapper's entire vote if they act first in that early-commit window, even though the remaining voting power would otherwise still be eligible to participate.

Impact:

An early-moving coalition can capture the wrapper's full governance weight before the advertised voting window has actually ended.

If the wrapper controls meaningful RSUP voting power, this allows governance capture by a minority of local participants and can materially distort proposal outcomes. The clearest case is a single whale or coordinated bloc with just over 20% of wrapper supply committing before the remaining voting power has acted.

Recommendation:

Do not make the wrapper's vote irreversible before the intended local voting window ends.

Safer options include:

  • only allow commitVote() after the upstream voting period has closed
  • define and enforce an explicit local voting deadline that matches the point of irreversible commitment
  • if early commit is intentional, record and publicize that the effective local vote deadline is executionDelay, not the upstream voting-period end

Developer Response:

Since upstream voting contract only allows 1 vote per account and does not allow it to be replaced or overwritten, the early voting is intentional to ensure the highest likelihood of a vote successfully being cast.

With the execution delay changes from issue "Upstream votingPeriod changes can freeze wrapper vote commitment" - this will be advertises as the effective deadline being 1/2 of the upstream deadline.

Appendix Poc:

Save the following code as test/poc.early-quorum-commit-locks-full-vote.test.js and run npx hardhat test test/poc.early-quorum-commit-locks-full-vote.test.js.

The PoC recreates the exact timing problem behind the finding. It gives one proposer enough local voting power to create a proposal and exceed the wrapper’s 20% local quorum by themselves, while two later voters together control more voting power than that proposer. The proposer votes 100% yes, then the test advances time past executionDelay but keeps the upstream votingPeriod still open. At that point, any caller can invoke commitVote(), which makes magicStaker.castVote() convert the current local totals into a full-strength upstream vote for the wrapper account. In other words, the wrapper casts 100% yes with its entire proposal-epoch weight even though only the early quorum faction has participated so far. The test then confirms the upstream vote is already recorded, the upstream proposal is still open, and the later higher-power voters cannot change the outcome because magicVoter now marks the proposal as Executed and the upstream Voter only allows the wrapper account to vote once.

var { ethers } = require("hardhat");
var { expect } = require("chai");
var { loadFixture } = require("@nomicfoundation/hardhat-network-helpers");
var { setUpSmartContracts } = require("./fixtures");
const {
  impersonateAccount,
  setBalance,
} = require("@nomicfoundation/hardhat-toolbox/network-helpers");

const ONE = 10n ** 18n;
const TWO_WEEKS = 14 * 24 * 60 * 60;

async function advanceTime(seconds) {
  await ethers.provider.send("evm_increaseTime", [seconds]);
  await ethers.provider.send("evm_mine", []);
}

describe("PoC - early quorum commit locks the wrapper's full vote", function () {
  this.timeout(180000);

  let MagicPounder, MagicVoter, MagicStaker;
  let voter, RSUP;
  let signers = {};
  let accounts = {};

  async function deployFixture() {
    let deployed = await loadFixture(setUpSmartContracts);
    ({ MagicPounder, MagicVoter, MagicStaker, voter, RSUP } = deployed);

    let operator = deployed.operator;
    let manager = deployed.manager;
    accounts.proposer = "0x0000000000000000000000000000000000000001";
    accounts.lateNo1 = "0x0000000000000000000000000000000000000002";
    accounts.lateNo2 = "0x0000000000000000000000000000000000000003";

    await impersonateAccount(manager);
    signers.manager = await ethers.getSigner(manager);
    await setBalance(manager, 10n * ONE);

    await impersonateAccount(operator);
    signers.operator = await ethers.getSigner(operator);
    await setBalance(operator, 10n * ONE);

    for (let key of ["proposer", "lateNo1", "lateNo2"]) {
      await impersonateAccount(accounts[key]);
      signers[key] = await ethers.getSigner(accounts[key]);
      await setBalance(accounts[key], 10n * ONE);
    }

    let rsupWhale = "0x6666666677B06CB55EbF802BB12f8876360f919c";
    await impersonateAccount(rsupWhale);
    signers.rsupWhale = await ethers.getSigner(rsupWhale);
    await setBalance(rsupWhale, 10n * ONE);

    let magicPounderAddress = await MagicPounder.getAddress();
    let magicVoterAddress = await MagicVoter.getAddress();
    let magicStakerAddress = await MagicStaker.getAddress();

    await MagicPounder.connect(signers.operator).setMagicStaker(magicStakerAddress);
    await MagicVoter.connect(signers.operator).setMagicStaker(magicStakerAddress);

    let minCreateProposalWeight = await voter.minCreateProposalWeight();
    let proposerStake = minCreateProposalWeight * 2n;
    let lateStake = proposerStake;

    await RSUP.connect(signers.rsupWhale).transfer(accounts.proposer, proposerStake);
    await RSUP.connect(signers.rsupWhale).transfer(accounts.lateNo1, lateStake);
    await RSUP.connect(signers.rsupWhale).transfer(accounts.lateNo2, lateStake);

    for (let key of ["proposer", "lateNo1", "lateNo2"]) {
      await MagicStaker.connect(signers[key]).setWeights([10000]);
      await RSUP.connect(signers[key]).approve(magicStakerAddress, ethers.MaxUint256);
    }

    await MagicStaker.connect(signers.proposer).stake(proposerStake);
    await MagicStaker.connect(signers.lateNo1).stake(lateStake);
    await MagicStaker.connect(signers.lateNo2).stake(lateStake);

    await advanceTime(TWO_WEEKS);

    return {
      magicStakerAddress,
      magicVoterAddress,
      proposerStake,
      lateStake,
    };
  }

  it("lets an early quorum commit lock the full wrapper vote before upstream voting closes", async function () {
    let { magicStakerAddress, proposerStake } = await deployFixture();

    let proposerPower = await MagicStaker.getVotingPower(accounts.proposer);
    let lateNo1Power = await MagicStaker.getVotingPower(accounts.lateNo1);
    let lateNo2Power = await MagicStaker.getVotingPower(accounts.lateNo2);
    let lateNoBlocPower = lateNo1Power + lateNo2Power;

    expect(proposerPower).to.be.gte(await voter.minCreateProposalWeight());
    expect(lateNoBlocPower).to.be.gt(proposerPower);

    let proposalId = await voter.getProposalCount();
    await MagicStaker.connect(signers.proposer).createProposal(
      [{ target: magicStakerAddress, data: "0x43676852" }],
      "early quorum commit poc"
    );

    let upstreamVoteBefore = await voter.accountVoteWeights(magicStakerAddress, proposalId);
    expect(upstreamVoteBefore.weightYes).to.equal(0n);
    expect(upstreamVoteBefore.weightNo).to.equal(0n);

    await MagicVoter.connect(signers.proposer).vote(proposalId, 10000n, 0n);

    let localTotalsBeforeCommit = await MagicVoter.voteTotals(proposalId);
    expect(localTotalsBeforeCommit.yes).to.equal(proposerPower);
    expect(localTotalsBeforeCommit.no).to.equal(0n);

    let executionDelay = await MagicVoter.executionDelay();
    let votingPeriod = await voter.votingPeriod();
    expect(votingPeriod).to.be.gt(executionDelay);

    await advanceTime(Number(executionDelay) + 1);

    await expect(MagicVoter.connect(signers.operator).commitVote(proposalId))
      .to.emit(MagicVoter, "VoteCommitted")
      .withArgs(proposalId);

    expect(await MagicVoter.executed(proposalId)).to.equal(true);

    let upstreamVoteAfter = await voter.accountVoteWeights(magicStakerAddress, proposalId);
    let fullWrapperVoteWeight = (await MagicStaker.totalSupply()) / ONE;
    let proposerOnlyWeight = proposerStake / ONE;

    expect(upstreamVoteAfter.weightYes).to.equal(fullWrapperVoteWeight);
    expect(upstreamVoteAfter.weightYes).to.be.gt(proposerOnlyWeight);
    expect(upstreamVoteAfter.weightNo).to.equal(0n);

    let proposalData = await voter.proposalData(proposalId);
    let latestBlock = await ethers.provider.getBlock("latest");
    expect(BigInt(proposalData.createdAt) + votingPeriod).to.be.gt(BigInt(latestBlock.timestamp));
    expect(proposalData.processed).to.equal(false);

    await expect(MagicVoter.connect(signers.lateNo1).vote(proposalId, 0n, 10000n)).to.be.revertedWith("Executed");
    await expect(MagicVoter.connect(signers.lateNo2).vote(proposalId, 0n, 10000n)).to.be.revertedWith("Executed");
  });
});
I-4 Finding

I-4: Deployed resupply voter source does not exactly match latest upstream repository

Informational

Summary:

The verified onchain source for the Resupply Voter deployed at 0x11111111063874cE8dC6232cb5C1C849359476E6 is not exactly the same as the latest code currently visible in the upstream Resupply repository at https://github.com/resupplyfi/resupply/blob/main/src/dao/Voter.sol.

This is not itself a vulnerability in the wrapper, but it matters for audit accuracy: integration conclusions must be based on the deployed source and ABI actually governing the live system, not on the latest upstream repository state.

Description:

Etherscan shows the deployed contract source as:

  • contract name Voter
  • compiler v0.8.28
  • verified as Exact Match
  • creation roughly 264 days ago

The live Etherscan ABI also shows mutable timing parameters and their setters, including:

  • executionDelay()
  • votingPeriod()
  • setExecutionDelay(uint256)
  • setVotingPeriod(uint256)

The deployed source/ABI is also consistent with a version where the contract contains mutable variables such as:

uint256 public executionDelay = 6 hours;
uint256 public votingPeriod = 3 days;

and owner setters for both values.

In contrast, the latest upstream main branch src/dao/Voter.sol currently shows a different timing model:

uint256 public constant VOTING_PERIOD = 1 weeks;
uint256 public constant EXECUTION_DELAY = 1 days;

and the fetched current file no longer includes the mutable setExecutionDelay(uint256) or setVotingPeriod(uint256) functions.

That is a source-level mismatch, not just metadata drift. At minimum, the latest upstream repository reflects a different governance-timing implementation than the one currently deployed at the audited onchain address.

The repository landing page also shows newer commits on main, which further supports that the public repository has moved on since the deployed contract was verified.

Impact:

This is an information-quality and audit-traceability issue.

If reviewers assume that the latest upstream repository exactly represents the deployed Resupply governance contracts, they can:

  • analyze the wrong timing assumptions
  • miss behaviors that still exist onchain
  • incorrectly dismiss findings based on code that is not yet deployed
  • overstate or understate upgrade compatibility between the wrapper and upstream governance

The practical takeaway is simple: all wrapper integration analysis should anchor to the deployed onchain source and ABI first, and only use the latest repository as supplemental context.

Recommendation:

Treat the deployed Etherscan-verified source as the authoritative integration target for the live system.

Suggested process:

  • cite the deployed contract address and verified source when describing upstream behavior
  • explicitly distinguish deployed onchain code from latest upstream repository
  • if repository code is referenced, identify whether it is historical, current-but-not-deployed, or proposed future code
  • when assessing upgrade paths, document that the wrapper may be interacting with a version different from current main

At minimum, the audit report should state that the deployed Resupply Voter at 0x11111111063874cE8dC6232cb5C1C849359476E6 does not exactly match the latest upstream repository state.

Developer Response:

Acknowledged. Will use live deployed and verified source code for all references.

I-5 Finding

I-5: `setStrategyHarvester()` only validates routing for the first reward token

Informational

Summary:

The setStrategyHarvester() function validates that the new harvester has a route configured, but only checks rewards[0].

Description:

When setting a strategy's harvester via setStrategyHarvester(), the function calls Harvester(_harvester).getRoute(address(rewards[0]), Strategy(_strategy).desiredToken()) to verify the harvester has a valid swap route. However, the rewards array can contain multiple reward tokens (the upstream GovStaker supports up to 10). Only the first token's route is validated. Additional reward tokens may lack routes, causing harvest() to revert when Harvester.process() encounters an unrouted token.

Impact:

Informational. Currently only one reward token is configured (reUSD), so the check is sufficient. If additional reward tokens are added in the future, the incomplete validation could allow setting a harvester that will fail at runtime.

Recommendation:

Iterate over all elements of the rewards array and validate that the harvester has a route for each one.

Developer Response:

Applied recommended fix in commit 5f4265f.

I-6 Finding

I-6: Upstream votingPeriod changes can freeze wrapper vote commitment

Informational

Summary:

magicVoter assumes the upstream Resupply votingPeriod will always stay longer than the wrapper's locally configured executionDelay. Resupply governance, however, can validly shorten votingPeriod. If that happens, the wrapper can enter a state where its local vote can no longer be committed upstream even though upstream governance itself is still functioning as designed.

In the worst case, if Resupply lowers votingPeriod to two days or less, magicVoter has no valid executionDelay setting at all under its current bounds, so wrapper governance becomes permanently uncommittable until the code is changed.

Description:

The wrapper can only commit while two independent timing conditions are both true.

First, canVote() requires the upstream proposal to still be open:

function canVote(uint256 id) public view returns(bool _canVote, uint32 _createdAt) {
    require(!executed[id], "Executed");

    (,uint32 createdAt,,bool processed,) = voter.proposalData(id);

    uint256 period = voter.votingPeriod();
    _createdAt = createdAt;
    if(_createdAt + period > block.timestamp && !processed) {
        _canVote = true;
    } else {
        _canVote = false;
    }
}

Second, commitVote() requires the local execution delay to have already elapsed:

function commitVote(uint256 id) external {
    (bool _canVote, uint32 _createdAt) = canVote(id);
    require(_canVote, "!ended");
    require(_createdAt + executionDelay < block.timestamp, "!time");
    VoteData storage totals = voteTotals[id];
    magicStaker.castVote(id, totals.yes, totals.no);
    executed[id] = true;
    emit VoteCommitted(id);
}

So a successful commit requires a non-empty time window where:

  • createdAt + executionDelay < block.timestamp, and
  • block.timestamp < createdAt + voter.votingPeriod()

That only works if:

executionDelay < voter.votingPeriod()

The wrapper tries to preserve that relationship when the operator manually updates local config:

function setExecutionDelay(uint256 _time) external onlyOperator {
    uint256 votingPeriod = voter.votingPeriod();
    require(_time < votingPeriod, "!tooLong");
    require(_time > 60*60*24*2, "!tooShort");
    executionDelay = _time;
    emit NewExecutionDelay(_time);
}

But the upstream Resupply voter can be reconfigured independently by governance:

function setVotingPeriod(uint256 _period) external onlyOwner {
    require(_period > 1 days, "Too low");
    require(_period <= 1 weeks, "Too high");
    votingPeriod = _period;
    emit VotingPeriodSet(_period);
}

That creates two integration failure modes.

First, temporary freeze after a valid upstream reduction:

  1. magicVoter.executionDelay is configured for a previously longer upstream voting window.
  2. Resupply governance validly lowers voter.votingPeriod() below the wrapper's current executionDelay.
  3. Local users can still vote through magicVoter.vote() while the proposal is open.
  4. Neither commitVote() nor the auto-commit path can ever succeed, because the proposal closes before executionDelay elapses.
  5. The wrapper cannot forward its aggregate vote upstream until the operator notices and repairs the local configuration.

Second, permanent incompatibility at low upstream settings:

  1. Resupply governance lowers votingPeriod to <= 2 days, which is valid upstream because the only lower bound is > 1 days.
  2. magicVoter.setExecutionDelay() requires _time > 2 days and _time < votingPeriod.
  3. No value can satisfy both constraints.
  4. The wrapper has no valid local executionDelay configuration under the new upstream timing regime.
  5. Vote commitment remains impossible for every proposal until the wrapper code itself is changed or redeployed.

This is an integration issue rather than an upstream bug. Resupply is behaving according to its own governance rules. The wrapper is the component that assumes upstream timing will remain inside a narrower range than Resupply actually guarantees.

Impact:

This is an informational governance-compatibility issue.

If Resupply governance shortens the upstream voting window, the wrapper can lose the ability to commit local votes upstream even though:

  • upstream proposals are functioning normally,
  • wrapper users can still cast local votes, and
  • the wrapper's stake and voting power remain intact.

In the milder case, governance is frozen until the operator reconfigures executionDelay.

In the stronger case, where upstream votingPeriod <= 2 days, the current wrapper deployment has no valid local timing configuration at all and cannot commit votes without a code change.

Because the trigger is a future upstream governance reconfiguration rather than a live exploit path in the current deployment, this is best treated as an integration-compatibility warning rather than a material security finding.

Recommendation:

Do not hardcode a local timing model that is stricter than the upstream voter can guarantee.

Safer options include:

  • remove the wrapper's hard lower bound of > 2 days for executionDelay
  • make vote commitment depend on the upstream proposal close time directly instead of a separately managed local delay
  • validate upstream votingPeriod() during setResupplyVoter() and reject incompatible configurations
  • add an emergency operator path that can automatically clamp local executionDelay into a range compatible with the current upstream voter

At minimum, the wrapper should not assume that Resupply's votingPeriod will always remain above a threshold that the upstream protocol itself does not enforce.

Developer Response:

Fixed in commit 4256795 by always enforcing 1/2 of upstream votingPeriod, without a local delay variable.

Appendix Poc:

One concrete sequence is:

  1. The wrapper is deployed with executionDelay = 4 days.
  2. Resupply governance later calls setVotingPeriod(3 days) on the upstream voter.
  3. A new proposal is created upstream.
  4. Wrapper users vote locally through magicVoter.
  5. Before four days pass, the upstream proposal closes because its votingPeriod is only three days.
  6. canVote() now returns false, so commitVote() reverts with !ended.
  7. The wrapper never forwards its vote for that proposal.

An even stronger version is:

  1. Resupply governance calls setVotingPeriod(2 days).
  2. The operator tries to repair the wrapper by calling setExecutionDelay(...).
  3. Every candidate value fails, because the wrapper requires executionDelay > 2 days and also < votingPeriod.
  4. The deployment has no valid local timing configuration and cannot commit votes at all.
I-7 Finding

I-7: Live strategy addition can freeze existing users with stale weight arrays

Informational

Summary:

magicStaker.addStrategy() immediately increases strategies.length, but existing users keep their old accountWeightData.weights arrays until they manually refresh them. Multiple user-facing flows later call _syncAccount(), which iterates over the new global strategy length and reads accountWeights[i] without checking that the stored user array was extended first.

As a result, adding a strategy live can make pre-existing users revert on stake(), cooldown(), and syncAccount(). In the worst case, users who already updated weights earlier in the same epoch can also be blocked from repairing the mismatch until the next epoch and may miss a cooldown window.

Description:

The issue starts in addStrategy(), which appends a new strategy to the global array but does not migrate existing user weight arrays:

function addStrategy(address _strategy) external onlyOperator {
    ...
    strategies.push(_strategy);
    emit StrategyAdded(_strategy);
}

Later, _syncAccount() reads the caller's stored weights and indexes them up to strategies.length:

function _syncAccount(address _account) internal {
    uint256 assignedBalance;
    uint112 assignedWeight;
    uint256 accountBalance = balanceOf(_account);
    uint256 stratLength = strategies.length;
    uint112[] memory accountWeights = accountWeightData[_account].weights;
    for (uint256 i = 0; i < stratLength; ++i) {
        uint112 weight = accountWeights[i];
        ...
    }
    require(assignedBalance <= accountBalance, "!bal");
    require(assignedWeight == DENOM, "!weight");
}

There is no guard that accountWeights.length == stratLength. Any user whose array was created before the new strategy was added will eventually hit an out-of-bounds read when i reaches the new index.

This stale-array path is reachable from ordinary user flows because they all call _syncAccount():

function stake(uint256 _amount) external {
    ...
    accountStakeData[msg.sender] = acctData;
    _syncMagicBalance(msg.sender);
    _syncAccount(msg.sender);
    emit Stake(msg.sender, systemEpoch, _amount);
}

function cooldown(uint256 _amount) external {
    ...
    _syncMagicBalance(msg.sender);
    _syncAccount(msg.sender);
    emit Cooldown(msg.sender, _amount);
}

function syncAccount() external {
    require(unclaimedMagicTokens(msg.sender) > 0, "0");
    checkpointAccount(msg.sender);
    _syncMagicBalance(msg.sender);
    _syncAccount(msg.sender);
}

setWeights() is also affected, and the once-per-epoch restriction can prevent an immediate repair if the user already changed weights earlier that same epoch:

function setWeights(uint112[] memory _weights) public {
    AccountWeightData memory weightData = accountWeightData[msg.sender];
    require(weightData.lastUpdateEpoch < getEpoch(), "!epoch");
    ...
    _syncMagicBalance(msg.sender);
    _syncAccount(msg.sender);
    emit SetWeights(msg.sender, _weights);
}

That means a realistic sequence is:

  1. Existing users have weight arrays sized for N strategies.
  2. The operator calls addStrategy(), so strategies.length becomes N + 1.
  3. A user later calls stake(), cooldown(), or syncAccount().
  4. _syncAccount() loops to N + 1 and reads past the end of the stale user array.
  5. The transaction reverts until the user's stored weights are repaired.

If the user already called setWeights() earlier in that epoch, the local repair path is blocked by !epoch until the next epoch. Because cooldowns are only allowed in specific epochs, that temporary freeze can extend a user's effective withdrawal timeline.

Impact:

Low. This is a user-operation DoS caused by live protocol reconfiguration.

Affected users can temporarily lose access to ordinary actions that trigger _syncAccount(), including staking, entering cooldown, and balance synchronization. If the stale-array freeze overlaps the wrapper's eligible cooldown epoch, users can miss that window and remain locked until the next allowed cooldown period.

The issue depends on a privileged addStrategy() action rather than a permissionless exploit path, which keeps the practical severity low.

Recommendation:

Do not activate new strategies for existing users without also extending or migrating their stored weight arrays.

Safer options include:

  • store weights in a mapping keyed by strategy address instead of an index-based dynamic array
  • lazily extend old user arrays with zero-weight entries before _syncAccount() reads them
  • add an explicit repair path that is not blocked by the once-per-epoch setWeights() rule
  • only activate newly added strategies at a future epoch boundary with a clear migration process

Developer Response:

Fixed in commit https://github.com/oo-00/SecretHippoProject/commit/1653667288b0c38254e14e8b0f32710e0a2a1984

During _syncAccount, it is checked that the user's weights length is not shorter than the number of strategies. If it is, a storage write repairs their weights before proceeding, ensuring the additional gas costs are one-time and only apply to affected users (aside from the initial length comparison)

Appendix Poc:

Minimal source-level reproduction:

  1. A user has already initialized weights for the current strategies.length = N.
  2. The operator calls addStrategy(), increasing strategies.length to N + 1.
  3. The user later calls stake(), cooldown(), or syncAccount().
  4. _syncAccount() reads accountWeightData[user].weights[i] for i = N.
  5. The access is out of bounds because the user's stored array still has length N, so the transaction reverts.
  6. If the user already called setWeights() earlier in the same epoch, they cannot repair the array until the next epoch because setWeights() reverts with !epoch.
I-8 Finding

I-8: `addStrategy()` does not check for duplicate strategies

Informational

Summary:

The addStrategy() function pushes a new strategy to the strategies array without checking whether it already exists, allowing the same strategy to be added multiple times.

Description:

addStrategy() validates that the strategy's desiredToken() is valid and that setUserBalance() works correctly, but it never checks if the strategy address is already present in the strategies array. If a duplicate is added, every loop over strategies, including balance distribution in _setAllUserStrategyBalances() and reward harvesting in harvest(), would process the same strategy twice, resulting in double-counting strategy balances and double-harvesting rewards from that strategy.

Impact:

Informational. The function is onlyOperator-gated, so only a trusted admin can trigger this. However, an accidental duplicate entry would silently corrupt reward and balance accounting with no on-chain mechanism to detect or correct it.

Recommendation:

Add a duplicate check before pushing to the array, for example by maintaining a mapping(address => bool) isStrategy and requiring it to be false before adding.

Developer Response:

Recommendation applied in commit c11ee96.

I-9 Finding

I-9: Unbounded total checkpoint catch-up is a remote long-term liveness risk

Informational

Description:

The magicStaker contract maintains historical voting power by writing checkpoint data for every epoch. When _checkpointTotal() in contracts/magicStaker.sol:346-370 is invoked, it backfills missed epochs by iterating from totalLastUpdateEpoch to the current system epoch and writing one storage slot per missed epoch into the totalPowerAt mapping.

This loop executes once for every weekly epoch that has passed since the last total checkpoint update. Several externally reachable flows depend on total checkpointing:

  • castVote() checks whether the vote creation epoch exceeds the last total checkpoint and may call _checkpointTotal()
  • stake() calls _checkpointTotal() during the staking flow
  • cooldown() calls _checkpointTotal() before updating pending removals
  • syncAccount() calls _checkpointTotal() before syncing balances
  • setWeights() calls _checkpointTotal() after updating account weights

Under normal usage, these calls keep totalLastUpdateEpoch current and limit the amount of work required in each transaction. If the protocol sees prolonged inactivity, the epoch gap grows linearly over time. Because epochs are weekly, reaching a gas-prohibitive backlog would require many years without any successful total-checkpointing action.

The contract already includes a bounded helper for account-level history through checkpointAccountWithLimit(), which allows account checkpoint progress to be paginated. No equivalent bounded helper exists for total checkpointing, so advancing global checkpoint state relies on the unbounded _checkpointTotal() path.

Impact:

Informational. After an extended period of inactivity, a checkpoint-dependent transaction may become difficult to execute if it must backfill a large number of historical epochs in a single call. This may temporarily affect voting and user operations that depend on refreshing global checkpoint state until the backlog is advanced. The issue is limited to operational liveness and does not indicate direct fund loss.

Recommendation:

Consider adding a bounded public helper such as checkpointTotalWithLimit(uint256 targetEpoch) so global checkpoint progress can be advanced across multiple transactions when the epoch gap is large.

It may also help to avoid writing every intermediate epoch during catch-up. For example, the system could store only epochs where total weight changes and derive unchanged intervals during reads, or allow entry points to advance checkpoint state in bounded steps.

Developer Response:

Added checkpointTotalWithLimit(uint256 targetEpoch) function in e40aaa9.

G-1 Finding

G-1: Redundant `onlyOperator` modifier on `execute()`

Gas

Summary:

The execute() function in magicStaker, magicPounder, magicSavings, and magicHarvester applies the onlyOperator modifier and then immediately performs a stricter require(msg.sender == RESUPPLY_CORE) check in the function body.

Description:

The onlyOperator modifier (defined in OperatorManager) reads operator from storage and checks msg.sender == operator || msg.sender == RESUPPLY_CORE. However, the subsequent require(msg.sender == RESUPPLY_CORE, "!auth") inside execute() restricts callers to RESUPPLY_CORE exclusively. This makes the modifier's check dead code — any caller that passes the inner require will always pass onlyOperator, and any caller that only passes onlyOperator (i.e., operator but not RESUPPLY_CORE) will revert on the inner require.

The redundant modifier forces an unnecessary SLOAD of the operator storage slot on every call to execute().

Affected functions:

Impact:

Gas Savings.

Recommendation:

Remove the onlyOperator modifier from execute() in all four contracts. The inline require(msg.sender == RESUPPLY_CORE) is sufficient and strictly more restrictive.

Developer Response:

Applied recommended fix in commit: 75d28bd.

Methodology

Severity Classification

Critical

Immediate threat to user funds or protocol integrity

Direct loss of funds, protocol compromise

High

Significant security risk requiring urgent attention

Potential fund loss, major functionality disruption

Medium

Important issue that should be addressed

Limited fund risk, functionality concerns

Low

Minor issue with minimal impact

Best practice violations, minor inefficiencies

Gas

Findings that improve gas efficiency

Increased transaction costs

Informational

Code quality and best practice recommendations

Reduced maintainability and readability