Spheron V2

QuillAuditsFiles named in the report

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.

No open critical findings
Open the reportOpen Spheron Network’s security record

Report hosted on quillaudits.com.

Trustblock does not review code.

Findings13

Statuses are the security firm’s own, from its report.

SeverityOpenAcknowledgedFixed
Critical000
High003
Medium004
Low303
HighLiquidation doesn’t accrue interestFixed

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>

HighSignature replay protection bypassed also due to wrong hashing implementationFixed

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.

HighWrong hashing implementation allows user to execute EXPIRED signatureFixed

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

MediumDOS due to check implemented in wrong function causes changeAgentOwner to always revertFixed

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

MediumHonest users can be griefed in withdrawRequest.Fixed

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>

MediumUpdateFeeRate doesn’t accrue old fee before updateFixed

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>

MediumWithdraw doesn't enforce slippage protectionFixed

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>

LowHardcoded claimDeadLine will lead to loss of vesting assets for usersFixed

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>

LowWrong event emitted in the repay functionFixed

The repay() function in delegation pool emits wrong event<br><code>diff- emit Repaid(agent, payingAmount, payingAmount); + emit Repaid(agent, tokenAmount, payingAmount); </code>

LowWrong getAgentAddress() check in registerMiner()Fixed

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>

Contracts in scope

No deployed address is recorded for this audit.

The report names a repository or files. Open the report to see its scope.

Repository and files the report names (6)

  • AirdropPool.sol
  • Wrapper.sol
  • com/spheronFdn/airdrop-staking-contracts/blob/main/src/AirdropPool.sol
  • com/spheronFdn/airdrop-staking-contracts/blob/main/src/core/Core.sol
  • DelegationPool.sol
  • Pool.sol

Part of Spheron Network’s security record

Spheron Network
Audits3 audits by 1 firmLast audit Mar 2025, by QuillAudits.
Critical findingsNo open critical findingsAcross 3 reports.
Open Spheron Network’s security record

Security firm

QuillAudits

350 projects reviewed · 459 reports · latest Apr 2025

See QuillAudits’s audits

Something 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