Audit · firm’s term: audit report
QuillAudits reviewed Spheron Network (Spheron V2) on 5 Mar 2025: 13 findings, 0 critical; 10 marked fixed, 3 open.
Report hosted on quillaudits.com.
Trustblock does not review code.
Statuses are the security firm’s own, from its report.
| Severity | Open | Acknowledged | Fixed |
|---|---|---|---|
| Critical | 0 | 0 | 0 |
| High | 0 | 0 | 3 |
| Medium | 0 | 0 | 4 |
| Low | 3 | 0 | 3 |
accureInterest() should be called whenever totalDebt is being used because it updates the totalDebt. Now debtOf(agent) uses totalDebt to calculate the debtOf(agent), which means totaltotalDebt should be updated before calling debtOf(agent) in startLiquidation(). ie missing accureIntest()<br><code>solidity function startLiquidation(address agent) external nonReentrant whenNotPaused { //@audit this should also call accureIntest() if (msg.sender != core) revert CallerNotCore(); @> uint256 liquidatingAmount = debtOf(agent); removeDebt(agent, liquidatingAmount); totalLiquidatingAmount += liquidatingAmount; liquidatingAmounts[agent] += liquidatingAmount; invariantCheck(); emit LiquidationStarted(agent, liquidatingAmount); } </code>
In verifySignature, the check if (nonce != core.signatureNonces(agent)) revert InvalidNonce(); was to help prevent replays as the purpose of nonce logic is to prevent signature replay however this nonce is also a user argument, what that means is user specifies this value and not the protocol. The issue is the nonce specified by the user is not included when generating the hash to be verified allowing user to specify a different nonces but on the same signature.
In the bootstrapOffchainCollateral(), you'll notice it checks if (block.timestamp > deadline) revert ExpiredSignature(deadline); for expired signature however also note this deadline is a user argument, the issue here is that the deadline was not included when generating the hash to verify, so even if the signature signed by the core signers is expired, user can just pass in a future time which will always pass since the deadline won't get validated
The changeAgentOwner() function allows owner to change it's agent owner, it has the isAgentCall modifier what this means is that only the Agent can call this function and not the owner, also meaning the owner would have to call this through the agent contract which is the intended design. However, an issue arises in the corelogic.changeAgentOwner, notice the check if (agentDetail.owner != msg.sender) revert CallerNotAgentOwner(); this means caller must be the owner itself and not the agent, this is a conflict check to the isAgentCall modifier causes the entire function to always revert
User can initiate withdrawRequest on behalf of other user by passing _to, and this will increase the<br>unlockTime of withdrawRequest unlockTime is checked in withdraw().<br><code>solidity function withdrawRequest(uint256 _amount, address _to) external whenNotPaused { require(_to != address(0), "Invalid recipient"); require(_amount > 0, "Invalid amount"); WithdrawRequest storage request = withdrawRequests[_to]; pToken.transferFrom(msg.sender, address(this), _amount); uint256 shares = pToken.convertToShares(_amount); request.shares += shares; //@audit this can be used to grief other users by initiating a withdrawRequest and below line will increase the unlockTime by vestingTime @> request.unlockTime = block.timestamp + vestingTime; emit WithdrawRequested(_to, shares); } function withdraw() external whenNotPaused { address _to = msg.sender; WithdrawRequest storage request = withdrawRequests[_to]; require(request.shares > 0, "No withdraw request found"); @> require(block.timestamp >= request.unlockTime, "Vesting time not reached"); uint256 amount = pToken.convertToAssets(request.shares); delete withdrawRequests[_to]; pToken.approve(address(delegationPool), amount); delegationPool.withdraw(amount, _to); emit Withdraw(_to, amount); } </code>
DelegationPool.sol.updateFee() doesn't accrue old fee before updating it to the new one<br><code>solidity function updateFeeRate(uint256 _newFeeRate) external onlyOwner { feeRate = _newFeeRate; emit FeeRateUpdated(_newFeeRate); } </code><br>as the updateFeeRate function doesn't accrue fees before the update, there will be problem in distributeLiquidationSurplus and accrueInterest functions as both distribute pTokens w.r.t fee. Although<br>distributeLiquidationSurplus won't be affected much since it is only called when liquidation is called but accrued interest will be affected since it is dependent on the fee.<br><code>solidity function distributeLiquidationSurplus(uint256 amount) internal { uint256 fee = (amount * feeRate) / 10_000; pToken.mint(feeReceiver, fee); pToken.distribute(amount - fee); } function accrueInterest() public { uint256 timeElapsed = block.timestamp - lastAccrueTimeStamp; if (timeElapsed == 0) return; uint256 totalInterestIncrement = ( interestRateModel.getBorrowRate(pToken.totalSupply(), totalDebt + totalLiquidatingAmount) * totalDebt * timeElapsed ) / SECONDS_PER_YEAR / 1e18; totalDebt += totalInterestIncrement; uint256 fee = (totalInterestIncrement * feeRate) / 10_000; lastAccrueTimeStamp = block.timestamp; pToken.mint(feeReceiver, fee); pToken.distribute(totalInterestIncrement - fee);//@audit if fee is increased then less pTokens will be distributed here invariantCheck(); } </code><br>The correct way would be to collect the fee first and then update the fee, or call the accrueInterest()<br>before updating the fee.<br>
There should be slippage protection for users otherwise while withdrawing they could lose significant amount if exchange rate is high/low<br><br><code>solidity function withdrawRequest(uint256 _amount, address _to) external whenNotPaused { require(_to != address(0), "Invalid recipient"); require(_amount > 0, "Invalid amount"); WithdrawRequest storage request = withdrawRequests[_to]; pToken.transferFrom(msg.sender, address(this), _amount); @> uint256 shares = pToken.convertToShares(_amount); request.shares += shares; request.unlockTime = block.timestamp + vestingTime; emit WithdrawRequested(_to, shares); } function withdraw() external whenNotPaused { address _to = msg.sender; WithdrawRequest storage request = withdrawRequests[_to]; require(request.shares > 0, "No withdraw request found"); require(block.timestamp >= request.unlockTime, "Vesting time not reached"); uint256 amount = pToken.convertToAssets(request.shares); delete withdrawRequests[_to]; pToken.approve(address(delegationPool), amount); delegationPool.withdraw(amount, _to); emit Withdraw(_to, amount); } </code><br>
In AirdropPool.sol the constructor sets the claimDeadline as claimDeadline = _launchTime + (8 * 30 days); which is hardcoded as 240 days from the launchTime, However the function claimVestingAssets has a check : require(block.timestamp <= claimDeadline, "Claim period has ended"); Which means the claim can only be made when the claimDeadline is not passed but the claim deadline is hardcoded to 240 days in the constructor, and for the periods of more than 240 days vesting schedule users won't be able to claim vesting assets via claimVestingAssets function.<br><code>solidity function claimVestingAssets() external whenNotPaused { UserInfo storage user = claimInfo[msg.sender]; require(user.claimed, "Not claimed"); require(block.timestamp <= claimDeadline, "Claim period has ended"); require(user.totalShares > user.claimedShares, "No shares to claim"); uint256 claimableShares = _claimAfterLockTime(); user.lastClaimTime = block.timestamp; pToken.transfer(msg.sender, pToken.convertToAssets(claimableShares)); emit AirdropClaimed(msg.sender, pToken.convertToAssets() </code>
The repay() function in delegation pool emits wrong event<br><code>diff- emit Repaid(agent, payingAmount, payingAmount); + emit Repaid(agent, tokenAmount, payingAmount); </code>
The check if (getAgentAddress(minerId, _msgSender()) != address(0)) revert AgentAlreadyCreated(); in registerMiner() is implemented wrongly, notice getAgentAddress(minerId, _msgSender()), it passes minerId first then _msgSender() , However this is the getAgentAddress() function: function getAgentAddress(address _owner, address _minerId) public view returns (address) {<@ note owner first then minerId<br>} As we can see the parameters where passed in wrongly leading to wrong value returned<br>
The report names a repository or files. Open the report to see its scope.
Repository and files the report names (6)
350 projects reviewed · 459 reports · latest Apr 2025
See QuillAudits’s auditsSomething wrong on this page? A missing report, a wrong date, a finding that was fixed since. Tell us and we check it against its source.
Tell us