Prepared by: Srishti
Date: April, 2026
Version: 1.0
- Protocol Summary
- Disclaimer
- Risk Classification
- Architecture Overview
- Audit Methodology & Workflow
- Audit Details
- Executive Summary
- Findings
The Venus Prime system is an incentive mechanism built within the Venus Protocol that rewards long-term and high-value participants. Users stake XVS tokens to qualify for a non-transferable (soulbound) Prime token, which enables them to earn a share of protocol-generated revenue.
Rewards are distributed based on a score system that combines:
Stake (locked XVS) Activity (borrow/supply usage in markets)
The system integrates multiple components such as:
Protocol income (via reserves) Bootstrap liquidity (via PrimeLiquidityProvider) External contracts (Comptroller, XVSVault)
Independent Security Review Every effort was made to identify vulnerabilities within the allotted time. However, this report does not constitute an endorsement of the underlying business or product. The audit was time-boxed and focused solely on the security aspects of the Solidity implementation. The auditor assumes no responsibility for the findings presented herein.
| Impact: High | Impact: Medium | Impact: Low | ||
|---|---|---|---|---|
| Likelihood: High | H | H/M | M | |
| Likelihood: Medium | H/M | M | M/L | |
| Likelihood: Low | M | M/L | L |
Severity is determined using the CodeHawks severity matrix.
The Venus Prime system consists of several interconnected modules responsible for eligibility, scoring, and reward distribution.
1. Prime Token Mechanism Users must stake ≥ 1000 XVS for 90 days to mint a revocable Prime token Tokens are soulbound (non-transferable) Two types: Revocable Token → burned if stake drops below threshold Irrevocable Token (OG) → cannot be revoked (governance-controlled)
2. Reward Distribution Model
Rewards are distributed using a Cobb–Douglas-based scoring system, where:
Score depends on: Stake (XVS locked) Activity (borrow + supply) Rewards are calculated via: Global index (rewardIndex) User score proportion
Mechanism:
Protocol generates income from lending markets A portion is allocated to Prime users Rewards are distributed per block
3. Reward Sources
(A) Protocol Share Reserve (PSR) Collects protocol income (interest reserves) Releases funds to Prime contract on demand Ensures rewards availability during claims
(B) Prime Liquidity Provider Provides bootstrap rewards Emits tokens at a fixed per-block rate Controlled by governance
4. Core Integrations
The Prime contract interacts with:
Comptroller (PolicyFacet) → Updates user score and interest on market actions XVSVault → Tracks staking deposits and withdrawals Protocol Share Reserve (PSR) → Supplies reward funds
These integrations ensure that:
User eligibility is tracked Scores are updated dynamically Rewards are distributed correctly
5. Score Update Mechanism Score changes when users: Stake / withdraw Borrow / supply Global parameter changes (like α or multipliers): Cannot update all users at once (gas constraint) Uses batch updates via updateScores(users[])
6. Claiming Rewards Users accumulate rewards over time On claim(): Rewards are calculated Funds are pulled from PSR if needed Claiming can be paused using a Pausable mechanism
[2023-09-venus](https://github.com/code-423n4/2023-09-venus)
│
├── contracts/Tokens/Prime/Prime.sol
│ ← Soulbound token. Main contract of the feature. Prime holders accrue rewards and can claim them anytime.
│ ├── initialize() ← Initializes contract state and dependencies
│ ├── issue() ← Issues Prime tokens (revocable or irrevocable) to users
│ ├── burn() ← Burns Prime tokens if eligibility conditions are not met
│ ├── claimRewards() ← Allows users to claim accrued rewards
│ ├── accrueInterest() ← Accumulates rewards over time for users
│ ├── updateScores() ← Updates user scores affecting reward distribution
│ └── isEligible() ← Checks whether a user satisfies staking requirements
│
├── contracts/Tokens/Prime/IPrime.sol
│ ← Interface of the Prime contract
│ ├── issue() ← Declares token issuance function
│ ├── claimRewards() ← Declares reward claiming function
│ ├── burn() ← Declares token burning function
│ └── isEligible() ← Declares eligibility check function
│
├── contracts/Tokens/Prime/PrimeStorage.sol
│ ← Storage variables of the Prime contract
│ ├── tokens ← Stores user token data (existence, type, etc.)
│ ├── markets ← Stores market-related reward distribution data
│ ├── interests ← Tracks user scores and reward accumulation
│ ├── allMarkets ← List of all supported markets
│ └── governance ← Stores admin/governance-related configurations
│
├── contracts/Tokens/Prime/PrimeLiquidityProvider.sol
│ ← Provides liquidity to the Prime token uniformly over time (one of the reward sources)
│ ├── releaseFunds() ← Releases liquidity gradually over a defined period
│ ├── getAvailableFunds() ← Returns currently available liquidity
│ ├── fund() ← Allows adding funds to the liquidity pool
│ └── distribute() ← Sends funds to Prime contract for rewards
│
├── contracts/Tokens/Prime/libs/Scores.sol
│ ← Library for calculating user scores used in reward distribution
│ └── calculateScore() ← Computes score based on stake, time, and other factors
│
├── contracts/Tokens/Prime/libs/FixedMath.sol
│ ← Library with mathematical operations
│ ├── mul() ← Fixed-point multiplication
│ ├── div() ← Fixed-point division
│ ├── add() ← Safe addition
│ └── sub() ← Safe subtraction
│
└── contracts/Tokens/Prime/libs/FixedMath0x.sol
← Library with mathematical operations (0x-style precision handling)
├── mulWad() ← Multiplication with WAD precision
├── divWad() ← Division with WAD precision
├── rpow() ← Exponentiation for fixed-point numbers
└── sqrt() ← Square root calculation
[User / XVS Staker]
│
│ stake / balance change
▼
[XVS Vault]
│
│ calls xvsUpdated(user)
▼
[Prime.sol] ──── updates ────► [PrimeStorage.sol (user score, eligibility)]
│
│ calls _updateScore()
▼
[Scores.sol] ──── calculates ────► [User Score]
------------------------------------------------------------
[User / Market Interaction]
│
│ supply / borrow
▼
[Comptroller]
│
│ triggers accrueInterest()
▼
[Prime.sol] ──── updates ────► [Interest Index / Rewards State]
│
│ calls _accrueInterestAndUpdateScore()
▼
[Scores.sol + FixedMath] ──── computes ────► [Updated Rewards]
------------------------------------------------------------
[Protocol Income / Liquidity Source]
│
│ releaseFunds()
▼
[PrimeLiquidityProvider.sol / PSR]
│
│ sends funds
▼
[Prime.sol] ──── stores ────► [Reward Pool]
------------------------------------------------------------
[User]
│
│ calls claim() / claimInterest()
▼
[Prime.sol]
│
│ calls _claimInterest()
▼
[PrimeStorage.sol] ──── reads ────► [Accrued Rewards]
│
│ transfers rewards
▼
[User Wallet]
------------------------------------------------------------
[Admin / Governance]
│
│ calls updateMultipliers() / addMarket() / setLimit()
▼
[Prime.sol] ──── updates ────► [Protocol Parameters]
| Role | Address Type | Permissions |
|---|---|---|
| Owner / Governance | Multisig / Timelock | Can call updateMultipliers(), addMarket(), setLimit(), togglePause(), updateAlpha() |
| User | EOA | Can call claim(), claimInterest(), interact with markets affecting rewards |
| XVS Vault | Contract | Calls xvsUpdated() to update eligibility/score |
| Comptroller | Contract | Triggers accrueInterest() / updateAssetsState() |
| Liquidity Provider / PSR | Contract | Sends reward funds via releaseFunds() |
| Outsider | Any | Cannot mint/burn tokens or modify protocol parameters |
| Variable | Type | Visibility | Purpose |
|---|---|---|---|
tokens |
mapping(address => Token) |
internal | Stores user token state (exists, irrevocable status) |
markets |
mapping(address => Market) |
internal | Stores market-level reward and score data |
interests |
mapping(address => mapping(address => Interest)) |
internal | Tracks user rewards and scores per market |
allMarkets |
address[] |
public | List of all supported markets |
alphaNumerator |
uint128 |
public | Parameter for score calculation weighting |
alphaDenominator |
uint128 |
public | Parameter for score normalization |
irrevocableLimit |
uint256 |
public | Max number of irrevocable tokens |
revocableLimit |
uint256 |
public | Max number of revocable tokens |
paused |
bool |
public | Controls pause state of critical functions |
| Dependency | Type | Used For |
|---|---|---|
Scores.sol |
Library | Score calculation (Cobb-Douglas function) |
FixedMath.sol |
Library | Fixed-point arithmetic operations |
FixedMath0x.sol |
Library | Advanced precision math (WAD operations) |
XVS Vault |
Contract | Provides staking balance updates (xvsUpdated) |
Comptroller |
Contract | Market interaction and interest accrual |
PrimeLiquidityProvider |
Contract | Provides liquidity rewards over time |
Protocol Share Reserve (PSR) |
Contract | Additional reward funding source |
Resilient Oracle |
Oracle | Asset price feeds for APR and scoring |
This audit followed a structured, multi-phase process to maximize coverage within the allotted time. The methodology combined manual code review with automated tooling, with emphasis on [manual review / fuzz testing / formal verification — pick what applies].
- Read all protocol documentation, README, and natspec comments
- Mapped the full contract architecture (see Architecture Overview)
- Identified entry points, trust boundaries, and privileged roles
- Noted discrepancies between specification and implementation
Automated tools were run to surface common vulnerability classes before manual review began.
| Tool | Purpose | Findings Surfaced |
|---|---|---|
| [Slither] | Static analyzer for common Solidity bugs | [e.g., 3 informational] |
| [Aderyn] | Rust-based static analyzer | [e.g., 1 low] |
| [Solhint] | Linter / code style | [e.g., 2 warnings] |
All automated findings were manually triaged. Only confirmed, exploitable issues are included in this report.
Each in-scope contract was reviewed line-by-line with focus on:
- Access control — Are all state-changing functions properly gated?
- Input validation — Are inputs sanitized? Can edge cases cause unexpected behavior?
- State transitions — Are state changes atomic and consistent?
- Business logic — Does the implementation match the specification?
- Trust assumptions — Are privileged roles minimized and clearly documented?
- Data visibility — Is sensitive data exposed on-chain or through events?
For each suspected vulnerability, a proof-of-concept test was written to confirm exploitability.
# Run the test suite
forge test
# Run fuzz tests with increased runs
forge test --fuzz-runs 10000
# Run a specific test
forge test --match-test test_[testName] -vvvvAll findings in this report include a passing PoC test or reproducible steps.
Commit Hash
[9dcc9c72514da5fb99a4b66d93938372209e85de]
./contracts/Tokens/Prime/
└── Prime.sol
└── IPrime.sol
└── PrimeStorage.sol
└── PrimeLiquidityProvider.sol
└── libs/Scores.sol
└── libs/FixedMath.sol
The Venus Prime contract was reviewed for security vulnerabilities. The audit revealed [3] High-severity, [3] Medium, [7] Low, and [7] Informational findings.
| Severity | Number of Issues |
|---|---|
| High | [3] |
| Medium | [3] |
| Low/Informational | [8] |
| Total | [14] |
Severity: HIGH
Impact: HIGH · Likelihood: HIGH
Whenever a new Prime token is created, the users stakedAt is reset to 0. This happens when the user claim a revocable token and when he is issue a revocable token, but it does not happen when a user is issue an irrevocable token.
//If we want to issue the irrevocable token(cannot be revoke by admin or anyone else) then -> check if the token exist and
//and revocable and upgrade it else mint the irrevocable token and initialize market. And if we want to issue revocable
//token then mint revocable token and initialize market and reset staking time.
function issue(bool isIrrevocable, address[] calldata users) external {
_checkAccessAllowed("issue(bool,address[])");
if (isIrrevocable) {
for (uint256 i = 0; i < users.length; ) {
Token storage userToken = tokens[users[i]];//loop through the each user
if (userToken.exists && !userToken.isIrrevocable) {//if the user has a token and it is revocable
_upgrade(users[i]);//upgrade it
} else {
_mint(true, users[i]);
_initializeMarkets(users[i]);//initialize associated market
// @audit - here `stakedAt` is not being reset
}
unchecked {
i++;
}
}
} else {//if revocable
for (uint256 i = 0; i < users.length; ) {
_mint(false, users[i]);
_initializeMarkets(users[i]);
delete stakedAt[users[i]];
unchecked {
i++;
}
}
}
}A user can claim a Prime token without having any XVS staked.
Due to stakedAt not being reset when an irrevocable token is issued, a user can later satisfy the staking time condition even after withdrawing all funds. This breaks the core requirement that users must actively stake XVS to claim a Prime token.
As a result, users can gain Prime token benefits without locking any tokens, leading to incorrect reward distribution and potential loss of protocol funds.
Step-by-step reproduction:
1. Stake XVS to initialize stakedAt:
Alice stakes ≥1000 XVS2. Issue an irrevocable Prime token (stakedAt NOT reset):
Admin calls: issue(true, [Alice])3. Withdraw all staked XVS:
Alice withdraws all XVS (stake becomes 0)4. Burn the irrevocable token:
Admin calls: burn(Alice)
5. Wait for staking period and call claim without staking:
Alice calls: claim()Expected output:
[Claim succeeds and a Prime token is minted even though Alice has 0 XVS staked.]
Or, add the following test to Test.t.sol:
function test_claimWithoutStake_dueToIrrevocableIssue() public {
address alice = address(1);
// Step 1: Alice stakes
vm.prank(alice);
prime.stake{value: 10 ether}();
uint256 initialStakedAt = prime.stakedAt(alice);
assertTrue(initialStakedAt != 0);
// Step 2: Admin issues irrevocable token (no reset)
address;
users[0] = alice;
prime.issue(true, users);
// stakedAt remains unchanged
assertEq(prime.stakedAt(alice), initialStakedAt);
// Step 3: Alice withdraws all stake
vm.prank(alice);
prime.withdrawAll();
assertEq(prime.balanceOfStake(alice), 0);
// Step 4: Admin burns token
prime.burn(alice);
// Step 5: Fast forward time
vm.warp(block.timestamp + prime.STAKING_PERIOD());
// Step 6: Alice claims without staking
vm.prank(alice);
prime.claim();
// Unexpected behavior: claim succeeds
assertTrue(prime.tokens(alice).exists);
}Reset the user's stakedAt whenever he is issued an irrevocable token.
function issue(bool isIrrevocable, address[] calldata users) external {
_checkAccessAllowed("issue(bool,address[])");
if (isIrrevocable) {
for (uint256 i = 0; i < users.length; ) {
Token storage userToken = tokens[users[i]];
if (userToken.exists && !userToken.isIrrevocable) {
_upgrade(users[i]);
} else {
_mint(true, users[i]);
_initializeMarkets(users[i]);
+ delete stakedAt[users[i]];
}
...Severity: HIGH
Impact: HIGH · Likelihood: HIGH
The protocol relies on pendingScoreUpdates to track how many users still need score updates after changes to parameters like alpha or multipliers. However, this variable can be incorrectly reduced when a user’s token is burned, even if their score was never updated.
Additionally, minting a new token via claim() does not increase pendingScoreUpdates, creating an imbalance. An attacker can exploit this by minting and burning tokens to artificially reduce pendingScoreUpdates to zero, preventing further score updates and allowing their own score to remain outdated and inflated.
function _updateRoundAfterTokenBurned(address user) internal {
// @audit - decreases pendingScoreUpdates without actual score update
if (pendingScoreUpdates > 0 && !isScoreUpdated[nextScoreUpdateRoundId][user]) {
pendingScoreUpdates--;
}
}
function claim() external {
// @audit - minting does not increase pendingScoreUpdates
_mint(false, msg.sender);
}An attacker can avoid unfavorable score updates and retain an artificially higher score, allowing them to earn disproportionately higher rewards at the expense of other users.
Step-by-step reproduction:
1. Trigger score update requirement:
Call updateAlpha() or updateMultipliers()2. Manipulate pendingScoreUpdates:
Attacker mints a token (claim)
Attacker burns token (withdraw)
Repeat to reduce pendingScoreUpdates3. Exhaust updates:
Call updateScores() on other users until pendingScoreUpdates == 0Expected output:
Further calls to updateScores() revert with NoScoreUpdatesRequired,
leaving attacker’s score unupdated (higher than intended).
Or, add the following test to Test.t.sol:
function test_freezeAttackerScore() public {
// Setup users and staking...
// Step 1: Change alpha → requires updates
prime.updateAlpha(1, 5);
// Step 2: Attacker manipulates counter
vm.prank(attacker1);
prime.claim();
vm.prank(attacker1);
xvsVault.requestWithdrawal(...);
vm.prank(attacker2);
prime.claim();
vm.prank(attacker2);
xvsVault.requestWithdrawal(...);
// Step 3: Update other users
prime.updateScores([user3]);
// Step 4: Further updates blocked
vm.expectRevert();
prime.updateScores([attacker1, attacker2]);
// Attacker score remains unupdated
}Ensure pendingScoreUpdates cannot be manipulated via mint/burn imbalance.
Fix: Increment pendingScoreUpdates on mint if updates are pending
function _mint(bool isIrrevocable, address user) internal {
if (tokens[user].exists) revert IneligibleToClaim();
tokens[user].exists = true;
tokens[user].isIrrevocable = isIrrevocable;
if (isIrrevocable) {
totalIrrevocable++;
} else {
totalRevocable++;
}
if (totalIrrevocable > irrevocableLimit || totalRevocable > revocableLimit) revert InvalidLimit();
+ if (pendingScoreUpdates != 0) {
+ unchecked { ++pendingScoreUpdates; }
+ }
emit Mint(user, isIrrevocable);
}Additionally:
Avoid decreasing pendingScoreUpdates unless an actual score update occurs Ensure mint/burn operations do not affect update accounting
Severity: HIGH
Impact: HIGH · Likelihood: HIGH
The Prime._calculateScore function incorrectly normalizes capital using vToken.decimals() instead of the underlying token’s decimals. Since capital is derived from underlying assets, this results in inflated precision (e.g., 28 decimals instead of 18). This incorrect scaling propagates into sumOfMembersScore, causing the delta calculation in Prime.accrueInterest to become extremely small or zero. As a result, rewardIndex may not increase, preventing users from accruing rewards.
function _calculateScore(...) internal view returns (uint256) {
(uint256 capital, , ) = _capitalForScore(...);
// @audit - Incorrect decimals used; should use underlying token decimals instead of vToken.decimals()
capital = capital * (10 ** (18 - vToken.decimals()));
return Scores.calculateScore(..., capital, ...);
}Users may receive zero or significantly reduced rewards despite valid participation, as rewardIndex fails to increase due to inflated sumOfMembersScore, effectively halting reward distribution.
Step-by-step reproduction:
1. Simulate a market with mismatched decimals (e.g., vToken = 8 decimals, underlying = 18):
Setup vToken.decimals() = 8
Underlying.decimals() = 182. Call _calculateScore and accrueInterest:
capital scaled = capital * 10^(18 - 8) = capital * 1e10
sumOfMembersScore becomes ~1e28Expected output:
delta = (distributionIncome * 1e18) / 1e28 = 0
rewardIndex does not increase
users receive 0 rewards
Or, add the following test to Test.t.sol:
function test_rewardIndexNotIncreasing_dueToWrongDecimals() public {
uint256 distributionIncome = 1e18;
// Simulate correct vs incorrect scaling
uint256 correctSum = 1e18;
uint256 wrongSum = 1e28;
uint256 correctDelta = (distributionIncome * 1e18) / correctSum;
uint256 wrongDelta = (distributionIncome * 1e18) / wrongSum;
// Correct behavior
assertGt(correctDelta, 0);
// Buggy behavior
assertEq(wrongDelta, 0);
}Use the underlying token decimals instead of vToken.decimals() when normalizing capital.
function _calculateScore(...) internal view returns (uint256) {
(uint256 capital, , ) = _capitalForScore(xvsBalanceForScore, borrow, supply, market);
- capital = capital * (10 ** (18 - vToken.decimals()));
+ capital = capital * (10 ** (18 - IERC20Upgradeable(_getUnderlying(market)).decimals()));
return Scores.calculateScore(..., capital, ...);
}Severity: MEDIUM
Impact: MEDIUM · Likelihood: MEDIUM
The formula for a user’s score depends on the xvs staked and the capital. One core variable in the calculation of a user’s score is alpha which represents the weight for xvs and capital. It has been stated in the documentation that alpha can range from 0 to 1 depending on what kind of incentive the protocol wants to drive (XVS Staking or supply/borrow). Please review Significance of α subsection.
When alpha is 1, xvs has all the weight when calculating a user’s score, and capital has no weight. If we see the Cobb-Douglas function, the value of capital doesn’t matter, it will always return 1 since capital^0 is always 1. So, a user does not need to borrow or lend in a given market since capital does not have any weight in the score calculation.
The issue is an inconsistency in the implementation of the Cobb-Douglas function.
Developers have added an exception: if (xvs == 0 || capital == 0) return 0; Because of this the code will always return 0 if either the xvs or the capital is zero, but we know this should not be the case when the alpha value is 1.
To check how it works:
In describe('boosted yield') add the following code:
it.only("calculate score only staking", async () => {
const xvsBalance = bigNumber18.mul(5000);
const capital = bigNumber18.mul(0);
await prime.updateAlpha(1, 1); // 1
// 5000^1 * 0^0 = 5000
// BUT IS RETURNING 0!!!
expect((await prime.calculateScore(xvsBalance, capital)).toString()).to.be.equal("0");
});Severity: MEDIUM
Impact: MEDIUM · Likelihood: MEDIUM
updateScores() is meant to be called to update the scores of many users after reward alpha is changed or reward multipliers are changed. An attacker can cause calls to Prime.updateScores() to out-of-gas revert, delaying score updates. Rewards will be distributed incorrectly until scores are properly updated.
The root cause is a misplaced i++ increment combined with a continue statement. When a user has already been updated, continue is hit before i is incremented, causing the loop to re-process the same index indefinitely until the transaction runs out of gas:
function updateScores(address[] memory users) external {
if (pendingScoreUpdates == 0) revert NoScoreUpdatesRequired();
if (nextScoreUpdateRoundId == 0) revert NoScoreUpdatesRequired();
for (uint256 i = 0; i < users.length; ) {
address user = users[i];
if (!tokens[user].exists) revert UserHasNoPrimeToken();
if (isScoreUpdated[nextScoreUpdateRoundId][user]) continue; // i never increments
...
unchecked {
i++; // increment is never reached if `continue` is hit
}
emit UserScoreUpdated(user);
}
}An attacker can frontrun any call to updateScores() by pre-updating a single address that appears in the target call's argument array. When the frontrun call is then executed, the loop gets stuck infinitely on that already-updated address and reverts out of gas. Only one user gets updated per griefing attempt, while the legitimate batch update fails entirely. Rewards will be distributed based on stale, incorrect scores until all users are eventually updated.
An attacker frontruns updateScores([user1, user2, user3]) with updateScores([user3]). When the original call executes and reaches user3, isScoreUpdated is already true, continue is hit, i is never incremented, and the transaction loops on user3 until it runs out of gas and reverts.
Paste and run the following test in the 'mint and burn' scenario in Prime.ts (line 302):
it("dos_updateScores", async () => {
// Setup 3 users
await prime.issue(true, [
user1.getAddress(),
user2.getAddress(),
user3.getAddress()
]);
await xvs.connect(user1).approve(xvsVault.address, bigNumber18.mul(1000));
await xvsVault.connect(user1).deposit(xvs.address, 0, bigNumber18.mul(1000));
await xvs.connect(user2).approve(xvsVault.address, bigNumber18.mul(1000));
await xvsVault.connect(user2).deposit(xvs.address, 0, bigNumber18.mul(1000));
await xvs.connect(user3).approve(xvsVault.address, bigNumber18.mul(1000));
await xvsVault.connect(user3).deposit(xvs.address, 0, bigNumber18.mul(1000));
// Change alpha to trigger score update round
await prime.updateAlpha(1, 5);
// Attacker frontruns the batch update with a single-user call for user3
await prime.connect(user1).updateScores([user3.getAddress()]);
// Original batch call now reverts due to infinite loop on user3
await expect(
prime.updateScores([
user1.getAddress(),
user2.getAddress(),
user3.getAddress()
])
).to.be.reverted;
});Move the increment to the for loop header so that continue no longer prevents i from advancing:
- for (uint256 i = 0; i < users.length; ) {
+ for (uint256 i = 0; i < users.length; ++i) {
address user = users[i];
if (!tokens[user].exists) revert UserHasNoPrimeToken();
if (isScoreUpdated[nextScoreUpdateRoundId][user]) continue;
...
- unchecked {
- i++;
- }
emit UserScoreUpdated(user);
}This ensures that already-updated users are simply skipped rather than causing an infinite loop, making the function resilient to griefing.
Severity: MEDIUM
Impact: MEDIUM · Likelihood: MEDIUM
Having a single EOA as the only owner of contracts is a large centralization risk and a single point of failure. A single private key may be taken in a hack, or the sole holder of the key may become unable to retrieve the key when necessary. Critical protocol functions gated behind onlyOwner would then be permanently inaccessible or, worse, fall under the control of a malicious actor.
There are 3 instances of this issue in PrimeLiquidityProvider.sol:
File: contracts/Tokens/Prime/PrimeLiquidityProvider.sol
118: function initializeTokens(address[] calldata tokens_) external onlyOwner {
177: function setPrimeToken(address prime_) external onlyOwner {
216: function sweepToken(
IERC20Upgradeable token_,
address to_,
uint256 amount_
) external onlyOwner {If the owner's private key is compromised or lost, an attacker or no one at all would have exclusive control over the following critical operations:
initializeTokens()— ability to add supported tokens to the liquidity provider, blocking protocol bootstrapping.setPrimeToken()— ability to set the Prime token address, a one-time configuration step essential for the protocol to function.sweepToken()— ability to recover tokens from the contract, potentially draining all funds if the key is stolen.
All three functions are gated behind the same single-owner check with no fallback or recovery mechanism:
modifier onlyOwner() {
require(owner() == msg.sender, "Ownable: caller is not the owner");
_;
}A single compromised or lost key grants or removes access to all three functions simultaneously, with no recourse.
Consider one or more of the following approaches:
- Use a multi-signature wallet (e.g. Gnosis Safe) as the owner address, requiring M-of-N signers to authorize sensitive calls.
- Adopt a role-based access control model using OpenZeppelin's AccessControl, separating privileges so no single key controls all critical functions:
- function initializeTokens(address[] calldata tokens_) external onlyOwner {
+ function initializeTokens(address[] calldata tokens_) external onlyRole(TOKEN_ADMIN_ROLE) {
- function setPrimeToken(address prime_) external onlyOwner {
+ function setPrimeToken(address prime_) external onlyRole(CONFIG_ROLE) {
- function sweepToken(...) external onlyOwner {
+ function sweepToken(...) external onlyRole(SWEEP_ROLE) {- Implement a timelock controller so that sensitive owner actions have a mandatory delay, giving users time to react to malicious or erroneous changes before they take effect.
[L-1] [Prime.sol's _claimInterest() Should Apply Slippage or Allow Users to Partially Claim Their Interest]
Severity: LOW
Impact: LOW · Likelihood: LOW
The _claimInterest function hardcodes a 0% slippage tolerance and does not perform a final balance check after attempting to release funds from its two reserve sources. If the contract's balance remains insufficient after both release attempts, the subsequent safeTransfer call will revert unconditionally, preventing users from claiming any of their accrued interest.
function _claimInterest(address vToken, address user) internal returns (uint256) {
uint256 amount = getInterestAccrued(vToken, user);
amount += interests[vToken][user].accrued;
interests[vToken][user].rewardIndex = markets[vToken].rewardIndex;
interests[vToken][user].accrued = 0;
address underlying = _getUnderlying(vToken);
IERC20Upgradeable asset = IERC20Upgradeable(underlying);
if (amount > asset.balanceOf(address(this))) {
address[] memory assets = new address[](1);
assets[0] = address(asset);
IProtocolShareReserve(protocolShareReserve).releaseFunds(comptroller, assets);
if (amount > asset.balanceOf(address(this))) {
IPrimeLiquidityProvider(primeLiquidityProvider).releaseFunds(address(asset));
// @audit - no final check; if balance is still insufficient,
// safeTransfer will always revert and user receives nothing
unreleasedPLPIncome[underlying] = 0;
}
}
asset.safeTransfer(user, amount); // reverts if balance < amount
emit InterestClaimed(user, vToken, amount);
return amount;
}The contract attempts to top up its balance in two steps:
- Release funds from
IProtocolShareReserve. - If still insufficient, release funds from
IPrimeLiquidityProvider.
However, there is no final guard before safeTransfer to confirm the balance is now sufficient. If both releases fail to cover the full amount, the transfer reverts and the user walks away with nothing, even if a partial payment was possible.
If both reserve sources are temporarily underfunded, users are completely blocked from claiming any accrued interest. This leads to user frustration, erodes trust in the platform, and effectively locks user funds until the contract happens to accumulate sufficient balance — with no mechanism to notify users or queue the deficit for later.
Consider the following scenario:
- A user accrues 1000 USDC in Prime interest.
- _claimInterest() is called — the contract balance is 200 USDC.
- releaseFunds() from ProtocolShareReserve adds 300 USDC → balance is now 500 USDC.
- releaseFunds() from PrimeLiquidityProvider adds 400 USDC → balance is now 900 USDC.
- safeTransfer(user, 1000) is called — reverts because 900 < 1000.
- The user receives nothing, despite 900 USDC being available and their accrued balance having already been reset to 0.
Add a final balance check after both release attempts. Transfer only what is available, and store any deficit so it can be claimed later once the contract has sufficient funds:
function _claimInterest(address vToken, address user) internal returns (uint256) {
...
if (amount > asset.balanceOf(address(this))) {
address[] memory assets = new address[](1);
assets[0] = address(asset);
IProtocolShareReserve(protocolShareReserve).releaseFunds(comptroller, assets);
if (amount > asset.balanceOf(address(this))) {
IPrimeLiquidityProvider(primeLiquidityProvider).releaseFunds(address(asset));
unreleasedPLPIncome[underlying] = 0;
}
}
+ uint256 availableBalance = asset.balanceOf(address(this));
+ uint256 transferAmount = (amount <= availableBalance) ? amount : availableBalance;
+ // Store any deficit so the user can claim the remainder later
+ if (transferAmount < amount) {
+ interests[vToken][user].accrued += amount - transferAmount;
+ }
- asset.safeTransfer(user, amount);
+ asset.safeTransfer(user, transferAmount);
- emit InterestClaimed(user, vToken, amount);
- return amount;
+ emit InterestClaimed(user, vToken, transferAmount);
+ return transferAmount;
}This ensures users always receive whatever is currently available rather than nothing, and any remaining deficit is preserved in accrued for a future claim rather than silently lost.
[L-2][getEffectiveDistributionSpeed() Should Prevent Reversion and Return 0 if Accrued Exceeds Balance]
Severity: LOW
Impact: LOW · Likelihood: LOW
The getEffectiveDistributionSpeed() function assumes that balance >= accrued is always true, but this invariant can be broken. The sweepToken() function allows the owner to withdraw tokens from the contract without updating tokenAmountAccrued, meaning the accrued amount can exceed the actual balance. When this happens, the subtraction balance - accrued underflows and the function reverts.
function getEffectiveDistributionSpeed(address token_) external view returns (uint256) {
uint256 distributionSpeed = tokenDistributionSpeeds[token_];
uint256 balance = IERC20Upgradeable(token_).balanceOf(address(this));
uint256 accrued = tokenAmountAccrued[token_];
if (balance - accrued > 0) { // underflows if accrued > balance
return distributionSpeed;
}
return 0;
}Contrast sweepToken() with releaseFunds() — the former transfers tokens without resetting tokenAmountAccrued, while the latter correctly zeroes it out:
// sweepToken - does NOT update tokenAmountAccrued
function sweepToken(IERC20Upgradeable token_, address to_, uint256 amount_) external onlyOwner {
uint256 balance = token_.balanceOf(address(this));
if (amount_ > balance) revert InsufficientBalance(amount_, balance);
token_.safeTransfer(to_, amount_);
}
// releaseFunds - correctly resets tokenAmountAccrued
function releaseFunds(address token_) external {
accrueTokens(token_);
uint256 accruedAmount = tokenAmountAccrued[token_];
tokenAmountAccrued[token_] = 0; // ✅ reset
IERC20Upgradeable(token_).safeTransfer(prime, accruedAmount);
}After a sweepToken() call that reduces the contract balance below the accrued amount, getEffectiveDistributionSpeed() will revert on every call for that token due to an arithmetic underflow. Any off-chain or on-chain caller relying on this view function to check distribution speeds will be broken until the balance is replenished.
Consider the following scenario:
- Contract holds 500 USDC;
tokenAmountAccrued[USDC] = 300. - Owner calls
sweepToken(USDC, owner, 400)— balance drops to 100 USDC, accrued remains 300. getEffectiveDistributionSpeed(USDC)is called —balance - accrued = 100 - 300underflows and reverts.
Replace the unsafe subtraction with an explicit comparison:
function getEffectiveDistributionSpeed(address token_) external view returns (uint256) {
uint256 distributionSpeed = tokenDistributionSpeeds[token_];
uint256 balance = IERC20Upgradeable(token_).balanceOf(address(this));
uint256 accrued = tokenAmountAccrued[token_];
- if (balance - accrued > 0) {
+ if (balance > accrued) {
return distributionSpeed;
}
return 0;
}This single-character change eliminates the underflow risk and correctly returns 0 whenever accrued meets or exceeds the available balance.
Severity: LOW
Impact: LOW · Likelihood: LOW
The updateAlpha() function validates its inputs through _checkAlphaArguments(), which ensures the denominator is non-zero and the numerator does not exceed the denominator. However, the logically analogous updateMultipliers() function has no equivalent validation for its supplyMultiplier and borrowMultiplier parameters. This inconsistency means invalid or illogical multiplier values can be set without any on-chain revert.
function _checkAlphaArguments(uint128 _alphaNumerator, uint128 _alphaDenominator) internal {
if (_alphaDenominator == 0 || _alphaNumerator > _alphaDenominator) {
revert InvalidAlphaArguments();
}
}
// updateAlpha validates inputs ✅
function updateAlpha(uint128 _alphaNumerator, uint128 _alphaDenominator) external {
_checkAlphaArguments(_alphaNumerator, _alphaDenominator);
...
}
// updateMultipliers has no equivalent validation ❌
function updateMultipliers(address market, uint256 supplyMultiplier, uint256 borrowMultiplier) external {
if (!markets[market].exists) revert MarketNotSupported();
...
markets[market].supplyMultiplier = supplyMultiplier;
markets[market].borrowMultiplier = borrowMultiplier;
_startScoreUpdateRound();
}Without validation, zero or otherwise illogical multiplier values can be written to a market's configuration. This could result in incorrect reward calculations or unintended score update rounds being triggered with invalid state.
Introduce a _checkMultipliersArguments() validation function and apply it inside updateMultipliers(), mirroring the pattern already established by updateAlpha():
+ function _checkMultipliersArguments(uint256 supplyMultiplier, uint256 borrowMultiplier) internal pure {
+ if (supplyMultiplier == 0 || borrowMultiplier == 0) {
+ revert InvalidMultiplierArguments();
+ }
+ }
function updateMultipliers(address market, uint256 supplyMultiplier, uint256 borrowMultiplier) external {
+ _checkMultipliersArguments(supplyMultiplier, borrowMultiplier);
if (!markets[market].exists) revert MarketNotSupported();
...
}Severity: LOW
Impact: LOW · Likelihood: LOW
The NatSpec comment above the lastAccruedBlock mapping in PrimeLiquidityProvider.sol incorrectly describes it as storing the rate at which a token is distributed to the Prime contract. In reality, this mapping stores the last block number at which an accrual was made for a given token — a fundamentally different concept.
File: contracts/Tokens/Prime/PrimeLiquidityProvider.sol
/// @notice The rate at which token is distributed to the Prime contract
mapping(address => uint256) public lastAccruedBlock; // comment is wrongThe comment describes tokenDistributionSpeeds, not lastAccruedBlock. This mismatch between documentation and implementation creates confusion for developers integrating with or auditing the protocol.
Misleading NatSpec causes developer confusion and increases the risk of misuse or incorrect integrations that rely on documentation rather than implementation.
Replace the unsafe subtraction with an explicit comparison:
- /// @notice The rate at which token is distributed to the Prime contract
+ /// @notice The last block at which tokens were accrued for a given address
mapping(address => uint256) public lastAccruedBlock;Severity: LOW
Impact: LOW · Likelihood: LOW
The calculateScore() function accepts alpha numerator and denominator as uint256 values and later casts them to int256 when computing the exponentiation. If either value exceeds type(int256).max, the cast will overflow and cause a revert, with no user-friendly error message.
While passing a value above type(int256).max is unlikely in practice, the absence of an explicit check means such a scenario produces an opaque panic revert rather than a descriptive protocol error. Adding bounds checks improves robustness and developer experience.
Validate that numerator and denominator values are within safe int256 bounds before use:
function calculateScore(...) public view returns (uint256) {
+ require(alphaNumerator <= uint256(type(int256).max), "alphaNumerator overflow");
+ require(alphaDenominator <= uint256(type(int256).max), "alphaDenominator overflow");
...
}Severity: LOW
Impact: LOW · Likelihood: LOW
getPendingInterests() iterates over all markets to fetch accrued interest for a user. If any market becomes unavailable or reverts internally (e.g., due to a paused or deprecated market), the entire loop is interrupted and the function reverts, making pending interest data for all subsequent markets inaccessible.
function getPendingInterests(address user) external returns (PendingInterest[] memory pendingInterests) {
address[] storage _allMarkets = allMarkets;
PendingInterest[] memory pendingInterests = new PendingInterest[](_allMarkets.length);
for (uint256 i = 0; i < _allMarkets.length; ) {
address market = _allMarkets[i];
uint256 interestAccrued = getInterestAccrued(market, user); // ❌ can revert
uint256 accrued = interests[market][user].accrued;
pendingInterests[i] = PendingInterest({
market: IVToken(market).underlying(),
amount: interestAccrued + accrued
});
unchecked { i++; }
}
return pendingInterests;
}A single failing market blocks retrieval of pending interest data for all other markets. This can mislead users into thinking they have no pending rewards and may affect user decisions regarding claiming or staking.
Wrap the inner calls in a try/catch block and emit an event for failed markets, allowing the loop to continue:
+ event MarketCallFailed(address indexed market);
for (uint256 i = 0; i < _allMarkets.length; ) {
address market = _allMarkets[i];
+ try this.getInterestAccrued(market, user) returns (uint256 interestAccrued) {
uint256 accrued = interests[market][user].accrued;
pendingInterests[i] = PendingInterest({
market: IVToken(market).underlying(),
amount: interestAccrued + accrued
});
+ } catch {
+ emit MarketCallFailed(market);
+ }
unchecked { i++; }
}Severity: LOW
Impact: LOW · Likelihood: LOW
The protocol defines _ensureZeroAddress() as a standard guard against zero-address inputs, and uses it in some functions such as setPrime(). However, this check is not consistently applied across all functions that accept address parameters, leaving several entry points vulnerable to misconfiguration. Additionally, no isContract() check exists for cases where the provided address must be a deployed contract.
function _ensureZeroAddress(address address_) internal pure {
if (address_ == address(0)) revert InvalidArguments();
}An admin error passing a zero address or an EOA where a contract is expected could misconfigure the protocol silently, potentially causing downstream failures when the address is later called as a contract.
Apply _ensureZeroAddress() to all functions accepting address parameters. For addresses that must be contracts, add an isContract() helper:
+ event MarketCallFailed(address indexed market);
+ function isContract(address addr) internal view returns (bool) {
+ uint256 size;
+ assembly { size := extcodesize(addr) }
+ return size > 0;
+ }Then apply both checks at all relevant entry points where a valid contract address is required.
Severity: LOW
Impact: LOW · Likelihood: LOW
The uintDiv() function guards against negative divisors but does not guard against a divisor of 0. Passing f = 0 bypasses the existing check and causes a panic error due to division by zero, rather than a clean protocol revert with a descriptive error.
function uintDiv(uint256 u, int256 f) internal pure returns (uint256) {
if (f < 0) revert InvalidFixedPoint(); // guards negative
// f == 0 is accepted and causes a panic
return uint256((u.toInt256() * FixedMath0x.FIXED_1) / f);
}A zero divisor produces an unhandled panic revert rather than a descriptive InvalidFixedPoint revert, making debugging harder and the protocol's error handling inconsistent.
Extend the existing guard to also reject zero:
function uintDiv(uint256 u, int256 f) internal pure returns (uint256) {
- if (f < 0) revert InvalidFixedPoint();
+ if (f <= 0) revert InvalidFixedPoint();
return uint256((u.toInt256() * FixedMath0x.FIXED_1) / f);
}Report generated by Srishti · [April 2026]