OpenVPP Info
OpenVPP is building the global “internet for energy,” which will allow energy utilities to harness decentralized, on-chain, 24/7 stablecoin-based accounting, usage tracking, and payment solutions optimized to the specific needs of different smart energy use cases. OpenVPP has partnered with a world leading stablecoin issuer and a multi-billion dollar energy, power & utilities infrastructure provider (both to be announced upon launch) to provide a complete end-to-end decentralized payment layer for the energy economy.
TrustNet Score
The TrustNet Score evaluates crypto projects based on audit results, security, KYC verification, and social media presence. This score offers a quick, transparent view of a project's credibility, helping users make informed decisions in the Web3 space.
Real-Time Threat Detection
Real-time threat detection, powered by Cyvers.io,
is currently not
activated
for this project.
This advanced feature provides continuous monitoring and instant alerts to safeguard your assets from potential security threats. Real-time detection enhances your project's security by proactively identifying and mitigating risks.
For more information, click here.
Security Assessments
Summary and Final Words
No crucial issues found
The contract does not contain issues of high or medium criticality. This means that no known vulnerabilities were found in the source code.
Contract owner cannot mint
It is not possible to mint new tokens.
Contract owner cannot blacklist addresses.
It is not possible to lock user funds by blacklisting addresses.
Contract owner cannot set high fees
The fees, if applicable, can be a maximum of 25% or lower. The contract can therefore not be locked. Please take a look in the comment section for more details.
Contract cannot be locked
Owner cannot lock any user funds.
Token cannot be burned
There is no burning within the contract without any allowances
Ownership is not renounced
The owner retains significant control, which could potentially be used to modify key contract parameters.
Contract is upgradeable
The contract uses a proxy pattern or similar mechanism, enabling future upgrades. This can introduce risks if the upgrade mechanism is not securely managed.
Scope of Work
This audit encompasses the evaluation of the files listed below, each verified with a SHA-1 Hash. The team referenced above has provided the necessary files for assessment.
The auditing process consists of the following systematic steps:
- Specification Review: Analyze the provided specifications, source code, and instructions to fully understand the smart contract's size, scope, and functionality.
- Manual Code Examination: Conduct a thorough line-by-line review of the source code to identify potential vulnerabilities and areas for improvement.
- Specification Alignment: Ensure that the code accurately implements the provided specifications and intended functionalities.
- Test Coverage Assessment: Evaluate the extent and effectiveness of test cases in covering the codebase, identifying any gaps in testing.
- Symbolic Execution: Analyze the smart contract to determine how various inputs affect execution paths, identifying potential edge cases and vulnerabilities.
- Best Practices Evaluation: Assess the smart contracts against established industry and academic best practices to enhance efficiency, maintainability, and security.
- Actionable Recommendations: Provide detailed, specific, and actionable steps to secure and optimize the smart contracts.
A file with a different Hash has been intentionally or otherwise modified after the security review. A different Hash may indicate a changed condition or potential vulnerability that was not within the scope of this review.
Final Words
The following provides a concise summary of the audit report, accompanied by insightful comments from the auditor. This overview captures the key findings and observations, offering valuable context and clarity.
Ownership Privileges
DEFAULT_ADMIN_ROLE
- Can grant EMISSIONS_MANAGER_ROLE to any address (immediate, no delay)
- Can revoke EMISSIONS_MANAGER_ROLE from any address
- Can grant NFT_MANAGER_ROLE to any address
- Can revoke NFT_MANAGER_ROLE from any address
- Can grant POINTS_SYSTEM_MANAGER_ROLE to any address
- Can revoke POINTS_SYSTEM_MANAGER_ROLE from any address
- Can transfer DEFAULT_ADMIN_ROLE to a new address (subject to _initialDelay timelock via AccessControlDefaultAdminRules)
- Can cancel a pending admin transfer
- Can change the admin transfer delay period.
EMISSION_MANAGER_ROLE
- Can add OVPP tokens to the reward pool — only while program is not terminated (topUpTotalPool)
- Can configure future epoch parameters — staking/points ratio and emissions rate; only future epochs, only while not terminated (configureEpoch)
- Can start the staking program — one-time, irreversible until termination (startProgram)
- Can permanently terminate both programs — only while Active; freezes reward accrual at current timestamp (terminateBothPrograms)
- Can force-exit stakers in batches after termination — transfers stake + rewards + unbonding to each user (massWithdrawUsers)
- Can sweep entire contract OVPP balance after termination — requires all stakers to be fully withdrawn first (collectFunds)
- Can add device NFT type IDs to the boost eligibility whitelist — only while not terminated (addEligibleNFTTypeId)
- Can remove device NFT type IDs from the eligibility whitelist — existing boosts persist; only while not terminated (removeEligibleNFTTypeId)
- Can claim undistributed points allocation from completed epochs — callable in any program state (claimPointsLeftover)
- Can claim unearned staking allocation — rewards allocated but never distributed to users; callable in any program state (claimStakingRemainings)
NFT_MANAGER_ROLE
- Can register an eligible NFT for a staker mid-epoch — applies 1.5x boost retroactive to epoch start (registerNFTMidEpoch)
- Can revoke a staker's NFT boost — only when user no longer holds the specified tokenId (revokeNFTBoost)
POINTS_SYSTEM_MANAGER_ROLE
- Can distribute points program OVPP to arbitrary addresses for past epochs — bounded by epoch's pointsRewardsAmount (batchDistributePointsRewards)
- Can mark an epoch's points distribution as complete — moves undistributed leftover to totalPointsLeftover for admin recovery (markPointsDistributionComplete)
Note - This Audit report consists of a security analysis of the OpenVPP Staking smart contract. This analysis did not include functional testing (or unit testing) of the contract’s logic. Moreover, we only audited the mentioned contract for the OpenVPP team. Other contracts associated with the project were not audited by our team. We recommend investors do their own research before investing.
Files and details
Functions
public
/
State variables
public
/
Total lines
of code
/
Capabilities
Hover on items
/
Findings and Audit result
high Issues | 1 findings
Resolved
#1 high Issue
Returning Staker Earns Retroactive Full-Epoch Rewards via Stale RPT Baseline
When a user who previously staked and fully unbonded via the regular unbond() function re-enters the system by calling stake(), the contract incorrectly sets their reward baseline (userRewardPerTokenPaid) to rewardPerTokenAtEpochStart — the RPT value at the beginning of the current epoch — instead of rewardPerTokenStored, which represents the current cumulative RPT at the moment of re-staking. This occurs because the first-time staker initialization branch in _updateUserRewards (line 905) checks lastUpdateEpoch == 0, but a returning staker retains a non-zero lastUpdateEpoch from their original stake since regular unbond() never clears this field. The code therefore falls through to the main path (line 922), which assigns rewardPerTokenAtEpochStart as the baseline. Subsequently, in the stake() function, the weighted-average RPT recalculation (line 569) is guarded by oldEffective > 0 && effectiveAmount > 0, and since the returning staker has oldEffective == 0 (they fully unbonded), this correction is skipped entirely. The net result is that the returning staker's RPT baseline is pinned to the start of the current epoch, meaning they retroactively earn rewards for the entire elapsed portion of the epoch — a period during which they had zero stake. In a worst-case scenario where a user re-stakes a large amount near the end of an epoch when few other stakers are present, the total rewards distributed to all stakers can exceed the epoch's staking allocation by up to 85%, with the excess drawn directly from the contract's OVPP token balance, which includes other users' staked principal, unbonding amounts, and future epoch reserves.
medium Issues | 7 findings
Resolved
#1 medium Issue
Invalid State Possible with Two Boolean FlagsUnbonding Request Spam Permanently Bricks Emergency Mass Withdrawal
Add a maximum active unbonding request cap per user to unbond(). Alternatively, mirror stake()'s minimum: if (amount < MIN_AMOUNT) revert AmountBelowMinimum(); — this limits maximum requests per user to stakedAmount / MIN_AMOUNT, naturally bounding the _fullExitUser loop to a gas-safe number.
Resolved
#2 medium Issue
NFT Mid-Epoch Registration Retroactively Credits Full-Epoch RPT to Boosted Stake
When NFT boost is activated, _updateUserRewards() sets userRewardPerTokenPaid = rewardPerTokenAtEpochStart, then _updateNFTMultiplier() increases effectiveAmount by 50% WITHOUT adjusting userRewardPerTokenPaid. The extra effective stake earns retroactively from epoch start rather than activation time. In contrast, stake() correctly applies a weighted RPT average on line 768 when adding new effective stake. This inconsistency means NFT registration on day 4 of 7 credits the extra 500K effective for all 7 days instead of 3 remaining days. Example: 1M OVPP user registering NFT on day 4 overpays ~21,430 OVPP, diluting other stakers.
Resolved
#3 medium Issue
collectFunds() Has No User-Protection Safety Check
Transfers the contract's entire OVPP balance to EMISSIONS_MANAGER with no precondition checks on outstanding user obligations. The balance includes staker principal (totalStaked), accrued rewards (totalAccruedUnclaimed), and in-flight unbonding amounts. The intended flow (terminate → massWithdraw → claimRemainings → collectFunds) is not enforced. Admin can drain all user funds immediately after termination. Example: Alice has 1,000,000 OVPP staked. Admin terminates, calls collectFunds(). Alice's subsequent unbond() reverts with insufficient balance.
Resolved
#4 medium Issue
Stale User Accounting State After Full Unbond Enables Inflated Reward Cap on Re-Entry
When a user fully withdraws their stake by calling unbond() with an amount equal to their entire staked balance, the function correctly reduces stakes[msg.sender].amount and stakes[msg.sender].effectiveAmount to zero, decrements the global totalStaked and totalEffectiveStaked counters, and creates an unbonding request. However, it does not reset the user's userStartEpoch, totalClaimedByUser, lastUpdateEpoch, or hasNFT fields. This stands in direct contrast to the _fullExitUser() function (lines 619–660), which is invoked only during post-termination exits and properly resets all of these fields via delete stakes[user], userStartEpoch[user] = 0, and totalClaimedByUser[user] = 0. The consequence is that when a user who originally staked in epoch 1 fully unbonds in epoch 5 and later re-stakes in epoch 50, the cumulative reward cap computed by _getCumulativeCap() at line 838 uses the stale userStartEpoch of 1, calculating epochsCount = 50 - 1 + 1 = 50. A fresh staker entering at epoch 50 would have epochsCount = 1. This gives the returning staker a cap that is 50 times larger than intended, effectively removing the 100%-per-epoch reward ceiling that is designed to prevent over-extraction. This issue directly amplifies the Critical finding C-01 (returning staker stale RPT baseline), because the inflated cap removes the last safety net that could have otherwise limited the amount of retroactive rewards the attacker can claim per cycle.
Resolved
#5 medium Issue
Unsafe Principal Subtraction in unbond() — Compounded Rewards Permanently Trapped
The contract tracks a user's initial deposit using principalAmount to prevent compounded rewards from inflating their geometric reward cap. However, the unbond() function blindly subtracts the total withdrawal amount from both the total amount and the principalAmount. If a user attempts to fully withdraw after compounding rewards, this causes a fatal arithmetic underflow. For example, a user stakes 1,000 OVPP (principal = 1,000) and compounds 50 OVPP in rewards (total = 1,050). If they call unbond(1050) to exit, the contract attempts to subtract 1,050 from their 1,000 principal. This reverts the transaction, permanently trapping the 50 OVPP of compounded rewards inside the protocol.
Resolved
#6 medium Issue
Ghost Epoch Points Distribution Permanently Blocked
In the _advanceEpoch function, when advancing to the next epoch, the contract deducts the required rewards for both staking and points from the totalPool. If totalPool reaches exactly 0 after this deduction, the code enters an if (totalPool == 0) block. Inside this block, the contract state is set to Terminated and it emits an EpochStarted event for storedEpoch + 1, but it immediately returns without actually updating the storedEpoch variable to nextEpoch. For example, assume the current storedEpoch is 5. The admin advances to epoch 6. The funds for epoch 6 are successfully deducted from totalPool, dropping it to exactly 0. The program terminates and returns early. The storedEpoch remains stuck at 5. However, epochConfigs[6].pointsRewardsAmount has already been funded and recorded. When the admin later calls batchDistributePointsRewards(6), the function checks if (!(epoch < storedEpoch || (storedEpoch == epoch && programState == ProgramState.Terminated))). Since storedEpoch is still 5, checking epoch 6 results in (6 < 5) which is false, and (5 == 6) which is false. The transaction reverts permanently with PointsDistributionEpochNotAllowed(6). The allocated points funds are locked in the contract forever.
Resolved
#7 medium Issue
collectFunds Sweeps Orphaned lockedCurrentEpochRewards — Permanent User Fund Loss
The collectFunds function guards against sweeping funds that are still owed to users by checking that totalAccruedUnclaimed, totalStaked, stakers.length, totalPointsLeftover, and totalStakingCapSurplus are all zero. However, the new lockedCurrentEpochRewards field is intentionally not tracked in totalAccruedUnclaimed — the locking system is designed as a two-phase mechanism where rewards are held in lockedCurrentEpochRewards first, then promoted to rewardsEarned (and totalAccruedUnclaimed) only when released via _releaseLockedCurrentEpochRewards. Because the promotion never happens in the path described in Issue 1, the 50 OVPP owed to Alice exists physically in the contract but is invisible to every accounting variable that collectFunds checks. After massWithdrawUsers incorrectly removes Alice from stakers, all five guards in collectFunds pass cleanly and the admin transfers the full contract balance — which includes Alice's 50 OVPP — to themselves. When Alice subsequently calls claimRewards, the contract correctly releases her locked rewards to rewardsEarned and increments totalAccruedUnclaimed by 50, but then attempts a safeTransfer against a zero contract balance, causing a permanent revert. Alice's tokens are irrecoverably lost.
low Issues | 8 findings
Resolved
#1 low Issue
revokeNFTBoost Does Not Validate the Original Registered TokenId
The contract stores hasNFT: bool per user but never records WHICH tokenId was registered. revokeNFTBoost(user, tokenId) checks balanceOf(user, tokenId) == 0, but the tokenId parameter is caller-controlled by NFT_MANAGER. A malicious or careless manager can pass ANY tokenId the user doesn't own (e.g., type(uint256).max), and the balance check passes — revoking the boost while the user still holds their original NFT. Example: Alice registered with tokenId 5. Manager calls revokeNFTBoost(alice, 999). balanceOf(alice, 999) == 0 passes. Boost revoked unfairly.
Resolved
#2 low Issue
claimStakingRemainings Can Over-Claim Due to Lazy Reward Accounting
getAdminClaimableRemainings() computes remainings as allocated - (distributed + accrued + adminClaimed). The totalAccruedUnclaimed counter is lazily updated — it only reflects users whose rewards were materialized via _updateUserRewards(). If admin calls before all stakers have interacted, accrued underrepresents true obligations. Admin receives tokens owed to users. Example: allocation = 100k, 3 of 10 users updated (accrued = 30k). Admin claims remainings = 70k. Remaining 7 users' later claims revert with insufficient balance.
Resolved
#3 low Issue
Missing Reentrancy Protection and CEI Violation in stake()
The contract does not inherit ReentrancyGuardUpgradeable. Additionally, stake() violates Checks-Effects-Interactions: safeTransferFrom(msg.sender, address(this), amount) executes on line 731 (interaction) before stakes[msg.sender].amount += amount on line 760 (effect). While the verified OVPP token has no transfer callbacks for non-Uniswap transfers, this is a trust assumption. If OVPP were ever replaced with a token having hooks, the ordering creates a reentrancy window where an attacker could re-enter during the callback before stake state is finalized.
Resolved
#4 low Issue
configureEpoch Allows stakingRatio=0 Redirecting All Rewards to Admin-Controlled Points
Admin can set stakingRatio=0, pointsRatio=100 for any future epoch, allocating 100% of emissions to the admin-controlled points program. With config inheritance in _advanceEpoch(), a single configureEpoch(1, 0, 100, 10) before startProgram() permanently sets all future epochs to zero staking rewards. Stakers earn nothing while funds remain locked by the 30-day unbonding period.
Resolved
#5 low Issue
batchDistributePointsRewards Push-Payment DoS via Reverting Recipient
The batchDistributePointsRewards function iterates through an array of users to distribute rewards, executing token transfers sequentially using safeTransfer. In Solidity, if a single transfer within a loop reverts, the entire transaction reverts. This creates a Denial of Service (DoS) vulnerability known as a push-payment trap. For example, if an admin attempts to distribute points to 100 users in a single batch, and the 50th user is a smart contract that rejects incoming ERC20 tokens (e.g., it lacks a proper receive function or intentionally reverts), the safeTransfer to that 50th user will fail. Consequently, the entire batch transaction reverts, meaning none of the 100 users receive their rewards. A single problematic address can indefinitely block the points distribution for the entire epoch.
Resolved
#6 low Issue
Outstanding rewardsEarned on Full Unbond Blocks Future Rewards Post-Restake
The contract's full unbond logic resets a user's totalClaimedByUser to zero but fails to clear or handle the outstanding rewardsEarned balance. This creates a state where a returning user is penalized by their own unclaimed rewards due to the geometric APY cap. For example, Alice earns 500 OVPP in rewards but unbonds her entire 1000 OVPP stake without claiming them. Her totalClaimedByUser is reset to 0, but her rewardsEarned remains 500. A month later, Alice stakes 1000 OVPP again. Her new APY cap starts at 0 and grows slowly. The reward calculation checks if (totalClaimed + rewardsEarned) < Cap. Since 0 + 500 is much larger than her starting Cap, Alice earns zero new rewards for a significant period until her cap eventually grows beyond 500. Her previous, unclaimed yield effectively "blocks" her from earning any new yield on her fresh principal.
Resolved
#7 low Issue
View‑Simulation Breakage: Pre‑Configured Epochs Return Zero Rewards in getVirtualRewards
The protocol allows admins to pre-configure future epochs using configureEpoch(), but it initializes them with a hardcoded stakingRewardsAmount = 0 until the actual epoch begins. When simulation functions like getVirtualRewards() and rewardPerTokenVirtual() attempt to project growth into these pre-configured epochs, the internal helper _effectiveStakingRewardsAmountForEpoch() simply returns this hardcoded 0. It fails to estimate rewards properly, causing the mathematical projection to assume zero emissions. Frontends and DApps relying on these view functions will inaccurately display 0% APY and 0 pending rewards for future epochs. This breaks yield-projection UIs, misleads users about the protocol's profitability, and could potentially cause user panic or discourage participation.
Resolved
#8 low Issue
massWithdrawUsers Skip Condition Ignores lockedCurrentEpochRewards — Orphaned User Rewards on Termination
The massWithdrawUsers function is designed to forcefully exit all remaining stakers when the program terminates. It contains a fast-skip path for users who appear to have fully exited — checking that their amount, rewardsEarned, and activeUnbondingRequestIds are all zero. However, this condition completely ignores the new lockedCurrentEpochRewards field introduced in this version. A user can arrive at this state legitimately: they call unbond(fullAmount) mid-epoch, which triggers _updateUserRewardsForUnbond and stores their accrued current-epoch rewards into lockedCurrentEpochRewards (line 1232). After the epoch ends, they call claimUnbonding to retrieve their principal — but claimUnbonding (line 868) contains no call to _updateUserRewards, so the locked rewards are never released to rewardsEarned. The user's state now reads amount=0, rewardsEarned=0, activeUnbondingRequestIds=[] with lockedCurrentEpochRewards=50 still set. When the program terminates and the admin calls massWithdrawUsers, the three-field check evaluates to true and the user is silently removed from the stakers array via the continue path — _fullExitUser is never called, so the 50 OVPP owed to the user is never distributed and becomes permanently orphaned inside the contract.
optimization Issues | 2 findings
Resolved
#1 optimization Issue
Linear Staker Array Scan on Individual Post-Termination Unbond
_fullExitUser() performs an O(n) linear scan through stakers[] to find and remove a user. The massWithdrawUsers() path always hits index 0 (O(1)), but unbond() post-termination passes arbitrary users. With 5,000 stakers and target near the end: ~1M gas for the search alone. The contract already uses O(1) reverse indexing for unbonding requests but doesn't apply it here.
Resolved
#2 optimization Issue
Two Bools Instead of Enum for Program State
Two independent booleans encode three states, allowing a fourth invalid combination (true,true). The invariant is maintained by convention across five code locations rather than structurally enforced. Any future modification that sets programsTerminated = true without clearing programsActive introduces an invalid state no modifier handles.
informational Issues | 9 findings
Resolved
#1 informational Issue
Missing address Validation in Initialization
The initialize() function accepts _ovppToken, _nftContract, and _admin without validating any are non-zero. If _ovppToken is set to address(0), every subsequent safeTransferFrom and safeTransfer call reverts, permanently bricking the contract. If _admin is address(0), no one can ever call admin-gated functions like startProgram(), configureEpoch(), or terminateBothPrograms(), rendering the contract ungovernable. Since initialize() runs only once (via initializer modifier), these values cannot be corrected without redeploying a new proxy.
Resolved
#2 informational Issue
topUpTotalPool() Silently Deposits Into Auto-Terminated Program
The whenNotTerminated modifier checks at function entry BEFORE _updateGlobalReward() executes. If _updateGlobalReward() triggers auto-termination (pool exhausted during epoch advance), the modifier has already passed. After auto-termination, programsActive = false, so the else branch executes totalPool += amount — depositing tokens into a terminated program. Tokens sit idle until collectFunds() sweeps the entire balance.
Resolved
#3 informational Issue
Missing Events for _fullExitUser() and collectFunds()
Two significant state-changing operations emit no events. _fullExitUser() processes forced withdrawals during massWithdrawUsers() with no contract-level event. collectFunds() sweeps the entire contract balance with no event. Both are invisible in the contract's event log, making post-incident auditing and off-chain indexing difficult.
Resolved
#4 informational Issue
Missing __gap storage reservation array
The OVPPStaking contract is deployed behind a TransparentUpgradeableProxy and inherits from Initializable and AccessControlDefaultAdminRulesUpgradeable, both of which follow OpenZeppelin's upgradeable storage pattern and include their own __gap arrays to reserve storage slots for future use. However, the OVPPStaking contract itself — which declares over 30 state variables spanning mappings, structs, arrays, and primitives from eligibleNFTTypeIds through rewardPerTokenAtEpochStart — does not include a __gap array at the end of its storage layout. In the EVM proxy pattern, state variables occupy sequential storage slots determined by their declaration order across the entire inheritance chain. When a V2 implementation is deployed to the same proxy, any new state variable must be appended strictly after the last existing variable to avoid overwriting live data. Without reserved gap slots, if a future upgrade needs to insert a new inherited base contract into the inheritance chain, or if the contract itself needs to be used as a base for a child contract, the new base contract's variables would shift all subsequent slot positions, corrupting every existing state variable — including user stakes, reward accounting, epoch configurations, and unbonding requests. While OpenZeppelin's parent contracts protect their own slots with gaps, the leaf contract (OVPPStaking) provides no such buffer, making the storage layout brittle for any upgrade scenario beyond simple variable appending.
Resolved
#5 informational Issue
Mid-Epoch Unbond Permanently Forfeits Accrued Epoch Rewards
Synthetix-style staking relies on Reward Per Token (RPT) deltas distributed over an epoch. However, when a user calls unbond(), the contract credits rewards only up to the start of the current epoch, entirely ignoring the RPT growth during the active epoch. Unbonding then reduces the user's effective stake, permanently burning their pending yield. For example, a user stakes 1,000 OVPP at the start of a 7-day epoch. After 6.9 days, significant rewards have mathematically accrued based on RPT growth. If the user unbonds their stake at this point, the contract calculates rewards only up to the epoch's start, ignoring the 6.9 days of growth. The user's effective stake is reduced to zero, causing them to forfeit an entire week's worth of generated yield into the void.
Resolved
#6 informational Issue
collectFunds Does Not Reset totalStakingCapSurplus / totalPointsLeftover
The collectFunds() function is designed to sweep the token balance from the smart contract. However, while it successfully transfers the actual tokens out, it neglects to clear the internal accounting state. Specifically, the variables totalStakingCapSurplus and totalPointsLeftover remain non-zero. This creates a state mismatch where the contract's ledger indicates funds are available, but the actual token balance is zero.
Pending
#7 informational Issue
topUpTotalPool Race Condition Permanently Blocked at Epoch-Boundary Pool Depletion
The topUpTotalPool function contains a logical deadlock that prevents the admin from refilling the reward pool if it hits zero exactly when an epoch ends. When the admin calls topUpTotalPool() to inject funds, the function internally triggers _updateGlobalReward(). If the current epoch has expired and the pool is empty, _updateGlobalReward() automatically switches the programState to Terminated. Immediately after this update, topUpTotalPool checks the state and reverts with ProgramsAlreadyTerminated(). For example, if the reward pool depletes at the end of Epoch 5, the admin will attempt to fund the contract for Epoch 6. However, the contract's internal state update declares the program "dead" (Terminated) before the new funds can be processed. Because the program is now Terminated, the contract refuses to accept the admin's deposit. This creates a "Zombie State" where the protocol is permanently bricked and cannot be revived, even though the admin is actively trying to provide the necessary liquidity.
Pending
#8 informational Issue
NFT Boost Shared Between Multiple Users via ERC1155 Transfer
The contract checks for NFT ownership only at the time of the stake() or registerNFTMidEpoch() call but fails to verify ongoing ownership during reward accrual. Since ERC1155 tokens are transferable, a user can "double-spend" the multiplier. For example, Alice owns 1 NFT and stakes 1000 OVPP to get a 1.5x multiplier. Immediately after staking, Alice transfers the NFT to Bob. Bob then stakes 1000 OVPP using the same NFT. The contract verifies Bob currently holds the NFT and grants him the 1.5x boost as well. Now, both Alice and Bob are earning boosted rewards simultaneously using a single NFT. This process can be repeated across infinite accounts, effectively "cloning" the boost.
Pending
#9 informational Issue
getVirtualRewards Displays Claimable Rewards That Can Never Be Redeemed After Admin Sweep
The updated early-exit guard in getVirtualRewards was improved to handle users who have lockedCurrentEpochRewards > 0 but zero effectiveAmount. However, when Issues 1 and 2 have occurred — Alice's locked rewards were orphaned and then swept by the admin via collectFunds — the view function still correctly identifies her 50 OVPP via _splitLockedRewardsForView and returns claimableEarned = 50. The view is arithmetically accurate given the on-chain state: lockedCurrentEpochRewards = 50 is still stored in Alice's StakeInfo struct because the skip path in massWithdrawUsers does not delete stakes[user]. The problem is that the 50 OVPP no longer exists in the contract's token balance. Any frontend or wallet display that calls getVirtualRewards for Alice will show 50 OVPP as claimable, leading her to attempt claimRewards, which will revert. This creates a misleading user experience on top of the fund loss and makes the failure mode significantly harder to diagnose.