Security Audit Report
Hyperstable Perpetual Lock Audit Report
Reviewed by: 0x52 (@IAm0x52)
Prepared For: Hyperstable
Review Date(s): 3/18/25 - 3/20/25
Fix Review Date(s): 4/11/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 core update introducing a perpetual lock feature, letting users secure positions indefinitely and eliminating the need to manually relock.
Scope
Repo: hyperstable/contracts
Review Hash: 4f65012
Fix Review Hash: f0168cc
As an update audit, only changes from commit cb30a68 to commit 4f65012 were reviewed and other changes were considered out-of-scope.
In-Scope Contracts
- src/core/InterestRateStrategyV1.sol
- src/core/LiquidationBuffer.sol
- src/core/LiquidationManager.sol
- src/core/PositionManager.sol
- src/core/Vault.sol
- src/governance/EmissionScheduler.sol
- src/governance/RewardDistributor.sol
- src/governance/TokenRewardsDistributor.sol
- src/governance/vePeg.sol
- src/libraries/AdaptiveIRM.sol
- src/libraries/PegIRM.sol
Deployment Chain(s)
- HyperEVM mainnet
Summary of Findings
| High | Med | Low | |
|---|---|---|---|
| 3 | 4 | 3 | |
| 0 | 0 | 0 |
High Risk Findings
vePeg#deposit_for fails to increment perpetualLockAmount leading to incorrect supply values
Details
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);
}perpetualLockAmount is used to track the total supply of points across all tokens that have been locked perpetually and is essential for properly tracking the total supply. Above we see that the amount of tokens locked increases but perpetualLockAmount is not correctly incremented with the amount being added. This leads to an inaccurate total supply and over allocation of tokens.
Lines of Code
Recommendation
perpetualLockAmount should be incremented by _value.
Remediation
Fixed as recommended in commit 9f6591b.
PositionManager#setLiquidationManager fails to give allowances to new liquidation manager
Details
function registerVault(address _vaultAddress, uint256 _mcr, uint256 _minDebt, bytes memory _registerData)
external
onlyOwner
returns (uint8)
{
uint8 index = lastVaultIndex;
VaultData memory data;
data.addr = _vaultAddress;
data.MCR = _mcr;
data.asset = IVault(_vaultAddress).asset();
data.interestIndex = INTEREST_PRECISION;
data.lastInterestIndexUpdate = block.timestamp;
data.debtCap = type(uint256).max;
data.minDebt = _minDebt;
vaults[index] = data;
lastVaultIndex = index + 1;
@> IVault(_vaultAddress).approve(address(liquidationManager), type(uint256).max);
IERC20(data.asset).approve(_vaultAddress, type(uint256).max);
interestRateStrategy.registerVault(index, _registerData);
return index;
}When vaults are registered to the PositionManager, it grants max approval to the liquidation manager for the vault asset. This is essential for the liquidation manager to function.
function setLiquidationManager(address _newLiquidationManager) external onlyOwner {
emit NewLiquidationManager(liquidationManager, _newLiquidationManager);
liquidationManager = _newLiquidationManager;
}We notice above that when a new manager is set, no approvals are granted. As a result the new liquidation manager will be unable to functions. This will lead to bad debt that will damage the entire system.
Lines of Code
Recommendation
setLiquidationManager should grant approval to new LiquidationManager
Remediation
Fixed as recommended in commit 665d860.
vePeg#delegate lacks any access control allowing all votes to be stolen through delegation
Details
function delegate(uint256 _from, uint256 _to) public {
return _delegate(_from, _to);
}We see above that when delegating there is no access control. This means that anyone can call this function and re-delegate for anyone else. This allows a malicious user to steal all voting power. They could use this to funnel rewards towards a beneficial pool or use it to vote on governance actions.
Lines of Code
Recommendation
delegate should check that msg.sender is approved by the owner of from.
Remediation
Fix in commit 7466285 by completely removing delegation.
Medium Risk Findings
Using a value of 0 for MIN_RATE_AT_TARGET in AdaptiveIRM causes interest rate to reset incorrectly
Details
function updateInterestRateAtTarget(AdaptiveIRMStorage storage s, uint8 _vault, int256 _newInterestRateAtTarget)
internal
{
@> s.endRateAt[_vault] = _newInterestRateAtTarget;
s.lastUpdate[_vault] = int256(block.timestamp);
emit UpdateInterestRateAtTarget(_vault, _newInterestRateAtTarget);
}When using 0 for MIN_RATE_AT_TARGET, in the event that the interest rate decrease to the minimum then s.endRateAt will be set to zero.
int256 err = (utilization - targetUtilization).sDivWad(errNomFactor);
@> int256 startRateAtTarget = s.endRateAt[_vault];
int256 avgRateAtTarget;
int256 endRateAtTarget;
@> if (startRateAtTarget == 0) {
avgRateAtTarget = INITIAL_RATE_AT_TARGET;
endRateAtTarget = INITIAL_RATE_AT_TARGET;The next iteration of the interest rate changes will see this 0 value and it will function as if the vault was never initialized. This will incorrectly set the interest rates to the initial rate rather than charging the 0% interest rate.
Lines of Code
Recommendation
MIN_RATE_AT_TARGET should always be a nonzero value
Remediation
Fixed as recommended in commit eb10f23.
vePeg#increase_amount and deposit_for incorrectly revert for perpetual locks
Details
function lock_perpetually(uint256 _tokenId) external nonreentrant {
assert(_isApprovedOrOwner(msg.sender, _tokenId));
LockedBalance memory currentLock = locked[_tokenId];
require(currentLock.perpetuallyLocked == false, "Lock is perpetual");
require(currentLock.end > block.timestamp, "Lock expired");
require(currentLock.amount > 0, "Nothing is locked");
uint256 amount = uint256(int256(currentLock.amount));
LockedBalance memory newLock;
@> newLock.end = 0;
newLock.perpetuallyLocked = true;
newLock.amount = currentLock.amount;
perpetuallyLockedBalance += amount;
_checkpoint(_tokenId, currentLock, newLock);
@> locked[_tokenId] = newLock;
}We see above that when a lock is changed to a perpetual lock, that newLock.end is set to 0.
function increase_amount(uint256 _tokenId, uint256 _value) external nonreentrant {
assert(_isApprovedOrOwner(msg.sender, _tokenId));
LockedBalance memory _locked = locked[_tokenId];
assert(_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");
if (_locked.perpetuallyLocked) {
perpetuallyLockedBalance += _value;
}
_deposit_for(_tokenId, _value, 0, _locked, DepositType.INCREASE_LOCK_AMOUNT);
}The problem is that these tokens can no longer increase in value since 0 is always less than block.timestamp. This leads to issues when users wish to increase the amount or to receive their PEG staking rewards.
Lines of Code
Recommendation
Check should be changed to allow perpetually locked tokens.
Remediation
Fixed as recommended in commit fa26780.
vePeg#_delegate perpetualLocked requirement can easily be bypassed
Details
function _delegate(uint256 _from, uint256 _to) internal {
LockedBalance memory currentLock = locked[_from];
@> require(currentLock.perpetuallyLocked == true, "Lock is not perpetual");
address delegator = ownerOf(_from);
address delegatee = _to == 0 ? address(0) : ownerOf(_to);
address currentDelegate = delegates(delegator);
_delegates[delegator] = delegatee;
@> _moveAllDelegates(delegator, currentDelegate, delegatee);
}We see above that when delegating a token, it is required the from token is perpetually locked. However we see in the lines below that all tokens from the delegator are moved. As a result, all tokens perpetual or not are delegated to the new delegatee. This makes the perpetual check useless as even a small perpetual lock can be used to bypass this check for all tokens.
Lines of Code
Recommendation
The methodology for delegation should be carefully considered and redesigned.
Remediation
Fix in commit 7466285 by completely removing delegation.
PositionManager#setInterestRateStrategy fails to re-register existing vaults with the new strategy
Details
InterestRateStrategyV1.sol#L43-L59
function registerVault(uint8 _vaultId, bytes memory _registerData) external {
_onlyPositionManager();
(uint256 targetUtilization) = abi.decode(_registerData, (uint256));
AdaptiveIRMStorage storage adaptiveIRM = AdaptiveIRM.getStorage();
IPositionManager.VaultData memory vaultData = IPositionManager(positionManagerAddress).getVault(_vaultId);
@> adaptiveIRM.setTargetUtilization(_vaultId, targetUtilization);
(, int256 end) = adaptiveIRM.interestRate(_vaultId, 0, 0, vaultData.MCR);
@> adaptiveIRM.updateInterestRateAtTarget(_vaultId, end);
emit VaultRegistered(_vaultId, _registerData);
}We see above that when vaults are registered, essential information about the vaults such as target utilization is communicated with the interest rate strategy.
function setInterestRateStrategy(address _newInterestRateStrategy) external onlyOwner {
if (_newInterestRateStrategy == address(0)) {
revert ZeroAddress();
}
emit NewInterestRateStrategy(address(interestRateStrategy), address(_newInterestRateStrategy));
@> interestRateStrategy = IInterestRateStrategy(_newInterestRateStrategy);
}When switching interest rate strategies, notice that this is not done for any of the existing vaults. As a result the new interestRateStrategy will be unable to function correctly.
Lines of Code
Recommendation
All existing vaults should be re-registered with the new interest rate strategy
Remediation
Fixed in commit eec38e8 by passing through _initData
Low Risk Findings
PositionManger#setLiquidationManager fails to revoke allowances to replaced liquidation manager
Details
function setLiquidationManager(address _newLiquidationManager) external onlyOwner {
emit NewLiquidationManager(liquidationManager, _newLiquidationManager);
liquidationManager = _newLiquidationManager;
}Above we see the liquidation manager is changed but none of the previous allowances are revoked. In the event that the liquidation manager contract has some vulnerability it may still be able to drain the position manager even though it has been changed.
Recommendation
All allowances to the previous liquidation manager should be revoked
Remediation
Fixed as recommended in commit 665d860.
InterestRateStrategyV1 has no method to adjust risk parameters such as targetUtilization and MCR
Details
A vaults target utilization and MCR are set on vault registration but there is no way to adjust these parameters at a later time. Given these are key risk factors for a vault it may be useful to allow changing them later.
Recommendation
Create a method on InterestRateStrategyV1 to change a vaults target utilization and create a method on PositionManager to adjust a vault's MCR.
Remediation
Fixed as recommended in commit 498f00c
vePeg#unlock_perpetual will redelegate all tokens
Details
function unlock_perpetual(uint256 _tokenId) external nonreentrant {
assert(_isApprovedOrOwner(msg.sender, _tokenId));
LockedBalance memory currentLock = locked[_tokenId];
require(currentLock.perpetuallyLocked == true, "Lock is not perpetual");
uint256 amount = uint256(int256(currentLock.amount));
LockedBalance memory newLock;
newLock.end = (block.timestamp + MAXTIME) / WEEK * WEEK;
newLock.amount = currentLock.amount;
perpetuallyLockedBalance -= amount;
@> _delegate(_tokenId, 0);
_checkpoint(_tokenId, currentLock, newLock);
locked[_tokenId] = newLock;
}When unlocking a perpetual token, _delegate will remove delegation from ALL tokens including those that are still perpetual.
Recommendation
The methodology for delegation should be carefully considered and redesigned.
Remediation
Fix in commit 7466285 by completely removing delegation.