Security Audit Report

    Hyperstable Core Audit Report

    Reviewed by: 0x52 (@IAm0x52)

    Prepared For: Hyperstable

    Review Date(s): 2/5/25 - 2/8/25

    Fix Review Date(s): 2/25/25

    0x52 Background

    As a professional smart contract auditor, I have conducted over 100 security reviews for public and private clients. With 30+ first-place finishes in public contests on platforms like Code4rena and Sherlock, I have been recognized as a top-performing security expert. By prioritizing rigorous analysis and providing actionable recommendations, I have contributed to securing over $1 billion in TVL across 100+ protocols. Throughout my career I have collaborated with many organizations including the prestigious Blackthorn as a founding security researcher and as a Lead Security researcher at SpearbitDAO.

    Protocol Summary

    Hyperstable is a protocol for overcollateralized stablecoin issuance and yield, featuring custom interest rate models, emissions, and reward distribution mechanisms.

    Scope

    Repo: hyperstable/contracts

    Review Hash: 35db5f2

    Fix Review Hash: 644fc1f

    In-Scope Contracts

    • src/*.sol

    Deployment Chain(s)

    • HyperEVM mainnet

    Summary of Findings

    Finding Status Overview: 8 Fixed, 0 Unfixed
    High Med
    3 5
    0 0

    High Risk Findings

    InterestRateStrategyV1 ownership can be hijacked

    Details

    Ownable.sol#L84-L85

    /// @dev Override to return true to make `_initializeOwner` prevent double-initialization.
    function _guardInitializeOwner() internal pure virtual returns (bool guard) {}

    When utilizing Solady's Ownable contract with an upgradable contract, _guardInitializeOwner must be overridden to return true as communicated in the dev comment above. InterestRateStrategyV1 is upgradable but does not do this, allowing the contract ownership to be hijacked. Consequences of this could be malicious upgrades to apply large amounts of interest to positions to liquidate all positions and the like.

    Lines of Code

    Ownable.sol#L84-L85

    Recommendation

    _guardInitializeOwner should be overridden to return true.

    Remediation

    Fixed in edb531f. Solady Ownable was replaced with OZ OwnableUpgradable.

    EmissionScheduler#epochEmission lacks access control and can be called by anyone to cause epoch to mint no emissions

    Details

    EmissionScheduler.sol#L63-L80

    function epochEmission(uint256 _pegSupply, uint256 _veSupply) external returns (uint256, uint256, uint256) {
        uint256 currentEpoch = _currentEpoch();
    
        if (currentEpoch == lastEpoch) {
            return (0, 0, 0);
        }
    
        lastEpoch = currentEpoch;
    
        uint256 toEmit = (_pegSupply - _veSupply).mulDiv(2, EMISSION_PRECISION);
    
        lastEpochEmission = toEmit;
    
        uint256 rebase = _calculateRebase(toEmit, _pegSupply, _veSupply);
        uint256 teamEmission = _calculateTeamEmission(toEmit, rebase);
    
        return (toEmit, rebase, teamEmission);
    }

    We see above that lastEpoch is updated to currentEpoch when called. Also notice that there is no access control on calls to this function and therefore it can be called by anyone. The result is that when the legitimate call is made it will return (0, 0, 0), completely wipe out all emissions owed for the epoch.

    Lines of Code

    EmissionScheduler.sol#L63-L80

    Recommendation

    epochEmission should only be callable by minter.

    Remediation

    Fixed in 7866674. Creates _onlyMinter which provides access control and applies it to EmissionScheduler#epochEmission.

    RewardDistributor#claim will revert for tokens with expired locks leading to loss of rewards

    Details

    RewardsDistributor.sol#L277-L287

        function claim(uint256 _tokenId) external returns (uint256) {
            if (block.timestamp >= time_cursor) _checkpoint_total_supply();
            uint256 _last_token_time = last_token_time;
            _last_token_time = _last_token_time / WEEK * WEEK;
            uint256 amount = _claim(_tokenId, voting_escrow, _last_token_time);
            if (amount != 0) {
    @>          IVotingEscrow(voting_escrow).deposit_for(_tokenId, amount);
                token_last_balance -= amount;
            }
            return amount;
        }

    We see above that when claiming for a token, the contract always attempts to deposit them to the current tokenId.

    vePeg.sol#L732-L739

        function deposit_for(uint256 _tokenId, uint256 _value) external nonreentrant {
            LockedBalance memory _locked = locked[_tokenId];
    
            require(_value > 0); // dev: need non-zero value
            require(_locked.amount > 0, "No existing lock found");
    @>      require(_locked.end > block.timestamp, "Cannot add to expired lock. Withdraw");
            _deposit_for(_tokenId, _value, 0, _locked, DepositType.DEPOSIT_FOR_TYPE);
        }

    Note that this call will revert if the token lock has expired. This means the above claim call will also revert.

    vePeg.sol#L795-L807

        function increase_unlock_time(uint256 _tokenId, uint256 _lock_duration) external nonreentrant {
            assert(_isApprovedOrOwner(msg.sender, _tokenId));
    
            LockedBalance memory _locked = locked[_tokenId];
            uint256 unlock_time = (block.timestamp + _lock_duration) / WEEK * WEEK; // Locktime is rounded down to weeks
    
    @>      require(_locked.end > block.timestamp, "Lock expired");
            require(_locked.amount > 0, "Nothing is locked");
            require(unlock_time > _locked.end, "Can only increase lock duration");
            require(unlock_time <= block.timestamp + MAXTIME, "Voting lock can be 4 years max");
    
            _deposit_for(_tokenId, 0, unlock_time, _locked, DepositType.INCREASE_UNLOCK_TIME);
        }

    Additionally once a token has expired the lock cannot be extended. As a result once a lock has expired, all rewards will be permanently locked and can never be claimed.

    Lines of Code

    RewardsDistributor.sol#L277-L287

    Recommendation

    RewardDistributor#claim should transfer tokens directly to owner in the event that the lock is expired.

    Remediation

    Fixed in 3012a71. Adds additional logic to RewardsDistributor#claim to transfer rewards directly to token owner if the lock has expired.

    Medium Risk Findings

    PositionManager#liquidatePosition fails to update vaultDebt and vaultCollateral

    Details

    First discovered by Dev team during audit period

    PositionManager.sol#L169-L185

        function liquidatePosition(address _vault, address _target, uint256 _debtToRepay, uint256 _sharesToLiquidate)
            external
        {
            if (msg.sender != liquidationManager) {
                revert OnlyLiquidatorManager();
            }
            // Debt and collateral shares are adjusted for the liquidated account
            _debtSnapshot[_target][_vault] -= _debtToRepay;
            collateralShares[_target][_vault] -= _sharesToLiquidate;
    
    @>      uint256 updatedVaultDebt = _vaultDebtSnapshot[_vault] - _debtToRepay;
    @>      uint256 updatedVaultCollateral = vaultCollateral[_vault] - _sharesToLiquidate;
    
            interestRateStrategy.updateVaultInterestRate(
                _vault, updatedVaultCollateral.mulWad(IVault(_vault).sharePrice()), updatedVaultDebt, IVault(_vault).MCR()
            );
        }

    In the above lines we see that _vaultDebtSnapshot and vaultCollateral are not updated when performing the liquidation. This leaves phantom collateral and debt in the system which can lead to inaccurate interest rates.

    Lines of Code

    PositionManager.sol#L179-L180

    Recommendation

    _vaultDebtSnapshot and vaultCollateral should be updated

    Remediation

    Fixed in 1277f39. Updated values are now correctly written to storage.

    Airdrop supply methodology has been changed leading to excess token emissions

    Details

    MerkleClaim.sol#L48-L69

        function claim(
            address to,
            uint256 amount,
            bytes32[] calldata proof
        ) external {
            // Throw if address has already claimed tokens
            require(!hasClaimed[to], "ALREADY_CLAIMED");
    
            // Verify merkle proof, or revert if not in tree
            bytes32 leaf = keccak256(abi.encodePacked(to, amount));
            bool isValidLeaf = MerkleProof.verify(proof, merkleRoot, leaf);
            require(isValidLeaf, "NOT_IN_MERKLE");
    
            // Set address to claimed
            hasClaimed[to] = true;
    
            // Claim tokens for address
    @>      require(VELO.claim(to, amount), "CLAIM_FAILED");
    
            // Emit claim event
            emit Claim(to, amount);
        }

    Above is the original VELO MerkleClaim code. Observe that when claiming, VELO is minted on demand. This is contrasted with PegAirdrop (MerkleClaim fork) which holds all the tokens and simply distributes them as users claim.

    EmissionScheduler.sol#L63-L80

        function epochEmission(uint256 _pegSupply, uint256 _veSupply) external returns (uint256, uint256, uint256) {
            uint256 currentEpoch = _currentEpoch();
    
            if (currentEpoch == lastEpoch) {
                return (0, 0, 0);
            }
    
    @>      lastEpoch = currentEpoch;
    
            uint256 toEmit = (_pegSupply - _veSupply).mulDiv(2, EMISSION_PRECISION);
    
            lastEpochEmission = toEmit;
    
            uint256 rebase = _calculateRebase(toEmit, _pegSupply, _veSupply);
            uint256 teamEmission = _calculateTeamEmission(toEmit, rebase);
    
            return (toEmit, rebase, teamEmission);
        }

    This difference is quite important as epochEmission is based on the float percentage of supply. As a result these tokens which are undistributed will be incorrectly counted against the floating token supply. This will lead to excess token emissions until the claim period is over and governance is able to recover them.

    Lines of Code

    EmissionScheduler.sol#L72

    Recommendation

    Consider removing the balance of PegAirdrop from the emission calculation or using an on-demand minting strategy similar to VELO.

    Remediation

    Fixed in 85be083. Distribution has been changed to on-demand minting.

    Curve gauge rewards can be griefed

    Details

    Gauge.sol#L123-L131

        function claimFees() external lock {
    @>      require(msg.sender == IVotingEscrow(_ve).team(), "only team");
    
            if (staking == address(0)) {
                return;
            }
    
            ICurveGauge(staking).claimRewards(address(this), msg.sender);
        }

    Gauge#claimFees attempts to lock down claims so that only the team can claim however if you look at the curve gauge code, the claim process is permissionless if _receiver == address(0). When claimed with _receiver == address(0) it will send the rewards to the holder which in this case is the gauge contract itself.

    LiquidityGaugeV5.vy#L416-L426

    def claim_rewards(_addr: address = msg.sender, _receiver: address = ZERO_ADDRESS):
        """
        @notice Claim available reward tokens for _addr
        @param _addr Address to claim for
        @param _receiver Address to transfer rewards to - if set to
                        ZERO_ADDRESS, uses the default reward receiver
                        for the caller
        """
        if _receiver != ZERO_ADDRESS:
            assert _addr == msg.sender  # dev: cannot redirect when claiming for another user
        self._checkpoint_rewards(_addr, self.totalSupply, True, _receiver)

    As a result the gauge rewards can be griefed and sent directly to the gauge contract instead of being sent to the team which will result in them being permanently unrecoverable.

    Lines of Code

    Gauge.sol#L123-L131

    Recommendation

    LP should be staked via a proxy contract. This way all rewards can be sent to the team and there is never any chance they are mixed with user rewards or stranded.

    Remediation

    Fixed in 1aeb762. reward_receiver is set to team address direct all permissionless claims there instead of the gauge contract.

    AdaptiveIRM#_curve multiples instead of dividing leading to "V" shaped curve instead of expected "L" shaped curve

    Details

    AdaptiveIRM.sol#L110-L115

        function _curve(int256 _rateAtTarget, int256 err) private pure returns (int256) {
            // Non negative because 1 - 1/C >= 0, C - 1 >= 0.
    @>      int256 coeff = err < 0 ? INT_WAD - INT_WAD.sMulWad(CURVE_STEEPNESS) : CURVE_STEEPNESS - INT_WAD;
            // Non negative if _rateAtTarget >= 0 because if err < 0, coeff <= 1.
            return (coeff.sMulWad(err) + INT_WAD).sMulWad(int256(_rateAtTarget));
        }

    Above is the _curve calculation for the forked adaptiveIrm. Below is the original adaptiveIRM contract from Morpho.

    AdaptiveCurveIrm.sol#L136-L141

        function _curve(int256 _rateAtTarget, int256 err) private pure returns (int256) {
            // Non negative because 1 - 1/C >= 0, C - 1 >= 0.
    @>      int256 coeff = err < 0 ? WAD - WAD.wDivToZero(ConstantsLib.CURVE_STEEPNESS) : ConstantsLib.CURVE_STEEPNESS - WAD;
            // Non negative if _rateAtTarget >= 0 because if err < 0, coeff <= 1.
            return (coeff.wMulToZero(err) + WAD).wMulToZero(int256(_rateAtTarget));
        }

    Notice that the WAD.wDivToZero has been erroneously replaced with INT_WAD.sMulWad. err is the distance between the current utilization and the target utilization. As a result of this change the interest rate curve will decrease as it's approaching from the left rather than increasing as intended. This will give a "V" shaped interest rate curve rather than an "L" shaped one.

    Lines of Code

    AdaptiveIRM.sol#L110-L115

    Recommendation

    Change the mul to a div

    Remediation

    Fixed in 6cc2ad9 as recommended.

    AdaptiveIRM has been integrated incorrectly and will not work as expected

    Details

    Morpho.sol#L483-L509

    function _accrueInterest(MarketParams memory marketParams, Id id) internal {
        uint256 elapsed = block.timestamp - market[id].lastUpdate;
        if (elapsed == 0) return;
    
        if (marketParams.irm != address(0)) {
            uint256 borrowRate = IIrm(marketParams.irm).borrowRate(marketParams, market[id]);
            uint256 interest = market[id].totalBorrowAssets.wMulDown(borrowRate.wTaylorCompounded(elapsed));
            market[id].totalBorrowAssets += interest.toUint128();
            market[id].totalSupplyAssets += interest.toUint128();
    
            uint256 feeShares;
            if (market[id].fee != 0) {
                uint256 feeAmount = interest.wMulDown(market[id].fee);
                // The fee amount is subtracted from the total supply in this calculation to compensate for the fact
                // that total supply is already increased by the full interest (including the fee amount).
                feeShares =
                    feeAmount.toSharesDown(market[id].totalSupplyAssets - feeAmount, market[id].totalSupplyShares);
                position[id][feeRecipient].supplyShares += feeShares;
                market[id].totalSupplyShares += feeShares.toUint128();
            }
    
            emit EventsLib.AccrueInterest(id, borrowRate, interest, feeShares);
        }
    
        // Safe "unchecked" cast.
        market[id].lastUpdate = uint128(block.timestamp);
    }

    First let's observe how the original Morpho markets integrate with the AdaptiveIRM. Notice that the average borrow rate is utilized directly BEFORE any state change has happened.

    AdaptiveIRM.sol#L94-L104

    } else {
        int256 linearAdaptation = ADJUSTMENT_SPEED.sMulWad(err) * (int256(block.timestamp) - s.lastUpdate[_vault]);
    
        if (linearAdaptation == 0) {
            avgRateAtTarget = startRateAtTarget;
            endRateAtTarget = startRateAtTarget;
        } else {
            endRateAtTarget = _newRateAtTarget(startRateAtTarget, linearAdaptation);
            int256 midRateAtTarget = _newRateAtTarget(startRateAtTarget, linearAdaptation / 2);
            avgRateAtTarget = (startRateAtTarget + endRateAtTarget + 2 * midRateAtTarget) / 4;
        }

    This is done because the avgRateAtTarget is a trapezoidal approximation between the start and end interest rates, which dynamically accounts for the changing interest rate.

    PositionManager.sol#L70-L87

        function deposit(address _toVault, uint256 _amountToDeposit) external {
            _isValidVault(_toVault);
    
            IVault vault = IVault(_toVault);
            IERC20 asset = IERC20(vault.asset());
    
            asset.transferFrom(msg.sender, address(this), _amountToDeposit);
            uint256 shares = vault.deposit(_amountToDeposit, address(this));
    
    @>      (, uint256 updatedVaultDebt) = _accruePositionDebt(vault, msg.sender);
    
            collateralShares[msg.sender][_toVault] += shares;
            uint256 updatedVaultCollateral = vaultCollateral[_toVault] += shares;
    
    @>      interestRateStrategy.updateVaultInterestRate(
                _toVault, updatedVaultCollateral.mulWad(vault.sharePrice()), updatedVaultDebt, vault.MCR()
            );
        }

    Take PositionManager#deposit as a comparative example. We see that the position debt is accrued before the interest rate is updated.

    InterestRate.sol#L153-L171

        function calculateInterestIndex(InterestRateStorage storage s, address _vault)
            internal
            view
            returns (uint256, uint256)
        {
            uint256 interestIndex = s.activeInterestIndex[_vault];
            if (s.lastInterestIndexUpdate[_vault] == block.timestamp) {
                return (interestIndex, 0);
            }
    
            uint256 factor;
            if (s.interestRate[_vault] > 0) {
                uint256 timeDelta = block.timestamp - s.lastInterestIndexUpdate[_vault];
    @>          factor = timeDelta * s.interestRate[_vault];
                interestIndex += interestIndex.mulDivUp(factor, INTEREST_PRECISION);
            }
    
            return (interestIndex, factor);
        }

    Notice in InterestRate#calculateInterestIndex that the cached interest rate is used. This is the stale rate based on the PREVIOUS average interest rate rather than the CURRENT. As a result an incorrect amount of interest will be charge to the users.

    Lines of Code

    InterestRate.sol#L153-L171

    Recommendation

    There is no need to cache the interest rate for a vault as it should always be calculated dynamically from the IRM. The only thing that needs to be updated is s.endRateAt and s.lastUpdate as is done in AdaptiveIRM#updateInterestRateAtTarget.

    Remediation

    Structurally fixed in fddfdec. Although debt and collateral are always 0 in changes, additional changes in next edition of contracts will utilize proper values.

    Time to Secure Your Protocol

    No fluff, no delays—just a focused audit process built around your codebase.