Halborn Logo

icon

WinWin Protocol - WinWin


Prepared by:

Halborn Logo

HALBORN

Last Updated 07/23/2024

Date of Engagement by: April 3rd, 2024 - May 29th, 2024

Summary

100% of all REPORTED Findings have been addressed

All findings

11

Critical

3

High

1

Medium

1

Low

2

Informational

4


1. Introduction

WinWin engaged Halborn to conduct a security assessment on their smart contracts beginning on April 4rd, 2024 and ending on May 29th, 2024. The security assessment was scoped to the smart contracts provided in the following GitLab repository:

2. Assessment Summary

The team at Halborn was provided seven weeks for the engagement and assigned a full-time security engineer to verify the security of the smart contracts. The security engineer is a blockchain and smart-contract security expert with advanced penetration testing, smart-contract hacking, and deep knowledge of multiple blockchain protocols.

The purpose of this assessment is to:

- Ensure that smart contract functions operate as intended.

- Identify potential security issues with the smart contracts.

In summary, Halborn identified some security risks that were partially addressed by the WinWin team.

3. Test Approach and Methodology

Halborn performed a combination of manual and automated security testing to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of this assessment. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance coverage of the code and can quickly identify items that do not follow the security best practices. The following phases and associated tools were used during the assessment:

- Research into architecture and purpose.

- Smart contract manual code review and walkthrough.

- Graphing out functionality and contract logic/connectivity/functions. (solgraph)

- Manual assessment of use and safety for the critical Solidity variables and functions in scope to identify any arithmetic related vulnerability classes.

- Manual testing by custom scripts.

- Static Analysis of security for scoped contract, and imported functions. (Slither)

- Testnet deployment. (Foundry)

4. RISK METHODOLOGY

Every vulnerability and issue observed by Halborn is ranked based on two sets of Metrics and a Severity Coefficient. This system is inspired by the industry standard Common Vulnerability Scoring System.
The two Metric sets are: Exploitability and Impact. Exploitability captures the ease and technical means by which vulnerabilities can be exploited and Impact describes the consequences of a successful exploit.
The Severity Coefficients is designed to further refine the accuracy of the ranking with two factors: Reversibility and Scope. These capture the impact of the vulnerability on the environment as well as the number of users and smart contracts affected.
The final score is a value between 0-10 rounded up to 1 decimal place and 10 corresponding to the highest security risk. This provides an objective and accurate rating of the severity of security vulnerabilities in smart contracts.
The system is designed to assist in identifying and prioritizing vulnerabilities based on their level of risk to address the most critical issues in a timely manner.

4.1 EXPLOITABILITY

Attack Origin (AO):
Captures whether the attack requires compromising a specific account.
Attack Cost (AC):
Captures the cost of exploiting the vulnerability incurred by the attacker relative to sending a single transaction on the relevant blockchain. Includes but is not limited to financial and computational cost.
Attack Complexity (AX):
Describes the conditions beyond the attacker’s control that must exist in order to exploit the vulnerability. Includes but is not limited to macro situation, available third-party liquidity and regulatory challenges.
Metrics:
EXPLOITABILIY METRIC (mem_e)METRIC VALUENUMERICAL VALUE
Attack Origin (AO)Arbitrary (AO:A)
Specific (AO:S)
1
0.2
Attack Cost (AC)Low (AC:L)
Medium (AC:M)
High (AC:H)
1
0.67
0.33
Attack Complexity (AX)Low (AX:L)
Medium (AX:M)
High (AX:H)
1
0.67
0.33
Exploitability EE is calculated using the following formula:

E=meE = \prod m_e

4.2 IMPACT

Confidentiality (C):
Measures the impact to the confidentiality of the information resources managed by the contract due to a successfully exploited vulnerability. Confidentiality refers to limiting access to authorized users only.
Integrity (I):
Measures the impact to integrity of a successfully exploited vulnerability. Integrity refers to the trustworthiness and veracity of data stored and/or processed on-chain. Integrity impact directly affecting Deposit or Yield records is excluded.
Availability (A):
Measures the impact to the availability of the impacted component resulting from a successfully exploited vulnerability. This metric refers to smart contract features and functionality, not state. Availability impact directly affecting Deposit or Yield is excluded.
Deposit (D):
Measures the impact to the deposits made to the contract by either users or owners.
Yield (Y):
Measures the impact to the yield generated by the contract for either users or owners.
Metrics:
IMPACT METRIC (mIm_I)METRIC VALUENUMERICAL VALUE
Confidentiality (C)None (I:N)
Low (I:L)
Medium (I:M)
High (I:H)
Critical (I:C)
0
0.25
0.5
0.75
1
Integrity (I)None (I:N)
Low (I:L)
Medium (I:M)
High (I:H)
Critical (I:C)
0
0.25
0.5
0.75
1
Availability (A)None (A:N)
Low (A:L)
Medium (A:M)
High (A:H)
Critical (A:C)
0
0.25
0.5
0.75
1
Deposit (D)None (D:N)
Low (D:L)
Medium (D:M)
High (D:H)
Critical (D:C)
0
0.25
0.5
0.75
1
Yield (Y)None (Y:N)
Low (Y:L)
Medium (Y:M)
High (Y:H)
Critical (Y:C)
0
0.25
0.5
0.75
1
Impact II is calculated using the following formula:

I=max(mI)+mImax(mI)4I = max(m_I) + \frac{\sum{m_I} - max(m_I)}{4}

4.3 SEVERITY COEFFICIENT

Reversibility (R):
Describes the share of the exploited vulnerability effects that can be reversed. For upgradeable contracts, assume the contract private key is available.
Scope (S):
Captures whether a vulnerability in one vulnerable contract impacts resources in other contracts.
Metrics:
SEVERITY COEFFICIENT (CC)COEFFICIENT VALUENUMERICAL VALUE
Reversibility (rr)None (R:N)
Partial (R:P)
Full (R:F)
1
0.5
0.25
Scope (ss)Changed (S:C)
Unchanged (S:U)
1.25
1
Severity Coefficient CC is obtained by the following product:

C=rsC = rs

The Vulnerability Severity Score SS is obtained by:

S=min(10,EIC10)S = min(10, EIC * 10)

The score is rounded up to 1 decimal places.
SeverityScore Value Range
Critical9 - 10
High7 - 8.9
Medium4.5 - 6.9
Low2 - 4.4
Informational0 - 1.9

5. SCOPE

Files and Repository
(a) Repository: audit
(b) Assessed Commit ID: dd9e771
(c) Items in scope:
  • contracts/ChainlinkVRF.sol
  • contracts/RandomNumber.sol
  • contracts/MultiCall.sol
↓ Expand ↓
Out-of-Scope:
Remediation Commit ID:
Out-of-Scope: New features/implementations after the remediation commit IDs.

6. Assessment Summary & Findings Overview

Critical

3

High

1

Medium

1

Low

2

Informational

4

Security analysisRisk levelRemediation Date
claimWinning() function can be abused to alter the lottery resultsCriticalSolved - 06/24/2024
drawRandomNumber() call can be abused to alter the lottery resultsCriticalSolved - 06/24/2024
Whale withdrawals can get the HEXStrategy contract into a DOS stateCriticalSolved - 06/24/2024
BuyAndBurn.swap() call can be sandwichedHighRisk Accepted
USDLStrategy._swapPLSforUSDL() calls can be sandwichedMediumRisk Accepted
winningIndex can be zeroLowRisk Accepted
Multiple stakes may block the claim due to block gas limit reachedLowRisk Accepted
Lack of a double-step transferOwnership() patternInformationalSolved - 06/24/2024
Unoptimized loopsInformationalSolved - 06/24/2024
uint64 _target parameter can be declared directly as a uint32 in the StakingPool.getBalanceAt() functionInformationalSolved - 06/24/2024
Centralization risk: Random numbers that determine the winner are selected off-chainInformationalAcknowledged

7. Findings & Tech Details

7.1 claimWinning() function can be abused to alter the lottery results

// Critical

Description

The PrizePool contract implements the function claimWinning(). This function is an external function that can be called by anyone every time a draw cycle is completed. It is used by the stakers to claim the rewards of the different tiers:

claimWinning() function

2 arrays are passed as parameters to the function call: _alreadyWinners and _rangeIndexOfWinners . On the other hand, the RandomNumber contract contains an array with 25 random numbers that is used to by the claimWinning() function to determine the winner:

randomNumber array

However, this can be abused by a malicious user to place himself in a winning spot. For example:

  1. Calling claimWinning() with an _alreadyWinners array of 1 element and tier 0 will determine the ticket by using the position 1 of the randomNumber array: uint256 winningIndex = randomNumber[i] % ticketInfo.lastDrawEligibleCount;

  2. The same winner address can appear multiple times in the _alreadyWinners array. Consequently, once there is a winner for a certain tier, users can "choose" the randomNumber. For example, for tier0 they can decide either the randomNumber[0] by providing an empty _alreadyWinners array, the randomNumber[1] by providing an _alreadyWinners array with 1 element etc...

What can a malicious user do to get the tier0 prize? Fork the PulseChain state at that block number and simulate different calls to this function until finding a combination that would make him winner. Then, replay those transactions in the real blockchain. This would be possible most of the time as long as this malicious user was holding a big chunk of the total pool stake.

BVSS
Recommendation

It is recommended to only allow users to call the claimWinning() function for their own account. Moreover, it is also recommended to change the design of the claimWinning() function, so users are not allowed to select the randomNumber position to determine the winner in any way.

Remediation Plan

SOLVED: The WinWin team allowed sequential claiming in the following commit id 4b42e0bb4fc7966b804eb4165842201080d6fe33. If a tier does not have any winners, a separate function, verifyNoWinnerForTier(), must be called to confirm the absence of winners for that tier. Only after this verification can claims for lower tiers proceed.

Remediation Hash

7.2 drawRandomNumber() call can be abused to alter the lottery results

// Critical

Description

The PrizePool contract implements the drawRandomNumber() function which is used to set a draw as completed:

drawRandomNumber() function

Moreover, the function allows passing an array of _slots as parameter. However:

  1. This function is an external function that can be called by anyone.

  2. claimWinning() function can be called right away after the drawRandomNumber() call.

  3. RandomNumbers used to determine the winner will be known at this time.

Consequently, a malicious user can tamper with the final winning results by playing with the _slots parameter passed to the drawRandomNumber() call as this parameter can be used to:

  1. Fill empty ticket slots.

  2. Change the lastDrawEligibleCount() used.

For example, let's imagine the following scenario:

  1. stakeIntoWinStakingPool(user1, 100e18);

  2. stakeIntoWinStakingPool(user2, 200e18);

  3. stakeIntoWinStakingPool(user3, 100e18);

  4. unstakeFromWinStakingPool(user2, 200e18);

  5. stakeIntoWinStakingPool(user2, 200e18);

  6. stakeIntoWinStakingPool(user3, 200e18);

Calling drawRandomNumber() with an empty _slots array:

drawRandomNumber([]) call results

Calling drawRandomNumber() with a non-empty _slots array. _slots array = [0]:

drawRandomNumber([0]) call results

Notice how the lastDrawEligibleCount is different, which can be abused to tamper with the final winning results.

BVSS
Recommendation

It is recommended to add some type of access control to the drawRandomNumber() function. Moreover, consider removing the _slots array passed as parameter.

Remediation Plan

SOLVED: The WinWin team solved the issue by updating RandomNumber contract to store hashes/random number against last request Id.

Remediation Hash

7.3 Whale withdrawals can get the HEXStrategy contract into a DOS state

// Critical

Description

The HEXStrategy contract implements a FIFO withdrawal queue. Every time a user calls the unstake() function in the HEXStakingPool the withdraw() function is called in the HEXStrategy contract:

HEXStrategy.withdraw() function

This function, always, performs a call to the _clearWithdrawQueue() function in order to try to empty the withdrawal queue. This is either achieved through the first if or the else code block. We can see below the _clearWithdrawQueue() function:

HEXStrategy_clearWithdrawQueue() function

However, if in the new period the currentTotalStakeAmount is not bigger than the current amount withdrawn in the first position of the withdrawal queue, the withdrawal will not be executed. The else code block will be entered and the rest of the withdrawals in the queue will not be processed. Consequently, let's imagine the following scenario:

  1. Multiple users stake.

  2. One user stakes a very high amount, ie. 2.000.000.000 HEX tokens.

  3. 30 days later (claim is called) and many users start unstaking.

  4. First user calling unstake is the one that staked the 2.000.000.000 HEX tokens. After that the rest of the users.

  5. All the withdraw requests will be blocked until currentTotalStakeAmount is higher than the 2.000.000.000 HEX tokens.

Moreover, in the next claim cycle (30 days later), all the stakes/unstakes will be blocked as they will all try to trigger the claimAndDistributeRewards() function. This function internally calls the stakeHEX() function:

claimAndDistributeRewards() function

An underflow will occur in the _currentTotalStakeAmount - withdrawQueueTotalAmount line as the withdrawQueueTotalAmount will be higher than the _currentTotalStakeAmount:

stakeHEX() functionUnderflow
BVSS
Recommendation

Consider refactoring the withdrawal queue logic in the HEXStrategy contract to prevent the underflow and the block of all the withdrawals in the queue.

Remediation Plan

SOLVED: The WinWin team solved the issue by adding a specific check against this issue and updated the logic to calculate yield if there is no transaction for 2 cycles.

Remediation Hash

7.4 BuyAndBurn.swap() call can be sandwiched

// High

Description

The contract BuyAndBurn implements the function swap():

BuyAndBurn.swap() function

This function swaps the different reward tokens received every 30 days by the BuyAndBurn contract to Wins tokens to then send them to a burn/dead address.

These reward tokens are sent to the contract through the claimAndDistributeRewards() function implemented by the different strategies. This function is a public function that can be called by anyone. Consequently, the following exploit is possible:

  • The path for the HEX tokens received as rewards are set by an admin to HEX => WPLS => WINS.

  • A malicious user buys WINS from the WPLS/WINS pool. Pool reserves of WINS decrement, Pool reserves of WPLS increment.

  • Within the same transaction BuyAndBurn.swap() is called. Pool reserves of WINS decrement further, Pool reserves of WPLS increment further.

  • The malicious user sells all the WINS to the WPLS/WINS pool at an inflated price caused by the previous swap call.

Proof of Concept
Example sandwich attack
BVSS
Recommendation

It is recommended to add some type of access control to the BuyAndBurn.swap() function so it can only be called by an admin.

Remediation Plan

RISK ACCEPTED: The WinWin team accepted the risk of this finding.

7.5 USDLStrategy._swapPLSforUSDL() calls can be sandwiched

// Medium

Description

The USDLStrategy contract implements the function _swapPLSforUSDL():

_swapPLSForUSDL() function

This function is used to swap the PLS tokens received as yield for USDL. The amountOutMin parameter used in the swapExactETHForTokens() is calculated on-chain. Consequently this can still be subject to manipulation as if the transaction is front-run the IUniswapV2Router(routerAddress).getAmountsOut(_rewardPLS, path); call will be performed on possibly manipulated pool reserves.

With this implementation, any deposit/withdrawal/claim call (as these 3 functions internally calls the _swapPLSforUSDL() function) could be sandwiched in detriment of the stakers that would receive lower rewards.

BVSS
Recommendation

This issue can only have a significant impact if no deposits/withdrawals/claims are executed for a long time and if the pool reserves are relatively low to the rewards accrued. This scenario should be very unlikely to ever occur, however, the WinWin team should be aware that the current implementation of the USDLStrategy to avoid slippage in the swaps does not work as intended and every swap call can be sandwiched (even if the profit will be insignificant for the attackers).

Remediation Plan

RISK ACCEPTED: The WinWin team accepted the risk of this finding.

7.6 winningIndex can be zero

// Low

Description

In the claimWinning() function the winner is selected by dividing randomNumber[i] by ticketInfo.lastDrawEligibleCount:

winningIndex calculation

In case of an exact division, winningIndex will be zero. In this situation, no winner is determined and the rewards will be distributed later on through the claimPrizes() function.

BVSS
Recommendation

Consider implementing a mechanism to re-calculate winningIndex when it is zero enforcing that there will be always a winner selected for a certain tier.

Remediation Plan

RISK ACCEPTED: The WinWin team accepted the risk of this finding and stated that no fix is required as there are already 5 random numbers for each tier.

Remediation Hash
-

7.7 Multiple stakes may block the claim due to block gas limit reached

// Low

Description

The different staking contracts implement the function stake():

WinStakingPool.stake() function

Every time that this function is called and the unlockTime was not reached a new element is added to the Balance[] array that belong to the following mapping declared in the contract storage: mapping(address => Balance[]) private _balances;

However, the claim() function, used to claim the rewards for the user, iterates over all the elements of that array:

WinStakingPool.claim() function

If this array is large enough, the block gas limit may be reached. This can lead to failed transactions and a denial of service for users attempting to claim their rewards. The block gas limit is a hard limit on the amount of computation a single transaction can use. When the number of elements in the Balance[] array grows excessively, the gas required to process the entire array in the claim() function may exceed this limit, preventing the function from completing successfully.

BVSS
Recommendation

It is recommended to limit the number of stakes that a user can perform.

Remediation Plan

RISK ACCEPTED: The WinWin team accepted the risk of this finding and stated that a disclaimer will be added in the frontend.

7.8 Lack of a double-step transferOwnership() pattern

// Informational

Description

The current ownership transfer process for all the contracts inheriting from Ownable or OwnableUpgradeable involves the current owner calling the transferOwnership() function:

function transferOwnership(address newOwner) public virtual onlyOwner {
    require(newOwner != address(0), "Ownable: new owner is the zero address");
    _setOwner(newOwner);
}

If the nominated EOA account is not a valid account, it is entirely possible that the owner may accidentally transfer ownership to an uncontrolled account, losing the access to all functions with the onlyOwner modifier.

Score
Recommendation

It is recommended to implement a two-step process where the owner nominates an account and the nominated account needs to call an acceptOwnership() function for the transfer of the ownership to fully succeed. This ensures the nominated EOA account is a valid and active account. This can be easily achieved by using OpenZeppelin's Ownable2Step contract instead of Ownable:

Ownable2Step implementation

Remediation Plan

SOLVED: The WinWin team solved the issue by implementing the recommended solution.

Remediation Hash

7.9 Unoptimized loops

// Informational

Description

If a loop is iterated many times, the amount of gas required to execute the function increases significantly. In Solidity, excessive looping can cause a function to use more than the maximum allowed gas, which causes the function to fail.

RandomNumber.sol

  • Line 32: for (uint256 i = 0; i < _authorized.length; i++) {

LOANStrategy.sol

  • Line 108: for (uint8 i = 0; i < 5; i++) {

  • Line 162: for (uint256 i = 0; i < 5; i++) {

  • Line 185: for (uint256 i = 0; i < 5; i++) {

MINTRAStrategy.sol

  • Line 106: for (uint8 i = 0; i < 5; i++) {

  • Line 162: for (uint256 i = 0; i < 5; i++) {

  • Line 185: for (uint256 i = 0; i < 5; i++) {

USDLStrategy.sol

  • Line 116: for (uint8 i = 0; i < 5; i++) {

  • Line 238: for (uint256 i = 0; i < 5; i++) {

  • Line 261: for (uint256 i = 0; i < 5; i++) {

HEXStrategy.sol

  • Line 139: for (uint8 i = 0; i < 5; i++) {

  • Line 238: for (uint256 j = 0; j < totalStakeCount; j++) {

  • Line 252: for (uint256 j = 0; j < totalStakeCount; j++) {

  • Line 257: for (uint256 k = 0; k < newTotalStakeCount; k++) {

  • Line 352: for (uint256 i = 0; i < 5; i++) {

  • Line 375: for (uint256 i = 0; i < 5; i++) {

  • Line 678: for (index = startingIndex; index < (startingIndex + length); index++) {

WinStakingPool.sol

  • Line 194: for (uint256 i = 1; i < _rewardTokens.length; i++) {

  • Line 401: for (uint256 i = 1; i < _rewardTokens.length; i++) {

  • Line 456: for (uint256 i = index; i < bal.length; i++) {

  • Line 468: for (uint256 j = 1; j < _rewardTokens.length; j++) {

  • Line 556: for (uint256 i = 1; i < _rewardTokens.length; i++) {

  • Line 604: for (uint256 j = 1; j < _rewardTokens.length; j++) {

  • Line 606: for (uint256 i = index; i < bal.length; i++) {

  • Line 820: for (uint256 i = index; i < bal.length; i++) {

  • Line 868: for (uint256 j = 0; j < _rewardsData.length; j++) {

  • Line 869: for (uint256 i = index; i < bal.length; i++) {

  • Line 909: for (uint256 j = 0; j < _rewardsData.length; j++) {

  • Line 910: for (uint256 i = index; i < bal.length; i++) {

  • Line 955: for (uint256 j = 1; j < _rewardsData.length; j++) {

  • Line 995: for (uint256 i = index; i < bal.length; i++) {

  • Line 1013: for (uint256 j = 1; j < _rewardTokens.length; j++) {

  • Line 1089: for (uint256 i = index; i < bal.length; i++) {

  • Line 1292: for (uint256 i = 0; i < numberOfDays; i++) {

HEXStakingPool.sol

  • Line 142: for (uint256 i = 1; i < _rewardTokens.length; i++) {

  • Line 360: for (uint256 i = 1; i < _rewardTokens.length; i++) {

  • Line 418: for (uint256 i = index; i < bal.length; i++) {

  • Line 430: for (uint256 j = 1; j < _rewardTokens.length; j++) {

  • Line 522: for (uint256 i = 1; i < _rewardTokens.length; i++) {

  • Line 569: for (uint256 j = 1; j < _rewardTokens.length; j++) {

  • Line 572: for (uint256 i = index; i < bal.length; i++) {

  • Line 762: for (uint256 i = index; i < bal.length; i++) {

  • Line 809: for (uint256 j = 0; j < _rewardsData.length; j++) {

  • Line 811: for (uint256 i = index; i < bal.length; i++) {

  • Line 850: for (uint256 j = 0; j < _rewardsData.length; j++) {

  • Line 852: for (uint256 i = index; i < bal.length; i++) {

  • Line 897: for (uint256 j = 1; j < _rewardsData.length; j++) {

  • Line 937: for (uint256 i = index; i < bal.length; i++) {

  • Line 955: for (uint256 j = 1; j < _rewardTokens.length; j++) {

  • Line 1015: for (uint256 i = index; i < bal.length; i++) {

StakingPool.sol

  • Line 106: for (uint256 i = 0; i < _rewardTokens.length; i++) {

  • Line 289: for (uint256 i = 0; i < _rewardTokens.length; i++) {

  • Line 340: for (uint256 i = index; i < bal.length; i++) {

  • Line 354: for (uint256 j = 0; j < _rewardTokens.length; j++) {

  • Line 407: for (uint256 i = 0; i < _rewardTokens.length; i++) {

  • Line 456: for (uint256 i = 0; i < _rewardTokens.length; i++) {

  • Line 458: for (uint256 j = index; j < bal.length; j++) {

  • Line 531: for (uint256 i = index; i < bal.length; i++) {

  • Line 550: for (uint256 j = 0; j < _rewardTokens.length; j++) {

  • Line 691: for (uint256 i = 0; i < _rewardsData.length; i++) {

  • Line 693: for (uint256 j = index; j < bal.length; j++) {

  • Line 716: for (uint256 i = 0; i < _unlockReward.length; i++) {

  • Line 718: for (uint256 j = index; j < bal.length; j++) {

  • Line 744: for (uint256 j = 0; j < _rewardsData.length; j++) {

  • Line 764: for (uint256 j = index; j < bal.length; j++) {

BuyAndBurn.sol

  • Line 118: for (uint i = 0; i < tokenAddresses.length; i++) {

PrizePool.sol

  • Line 208: for (uint256 i = 0; i < pointer; i++) {

  • Line 241: for (int256 i = int256(length - 1); i >= 0; i--) {

  • Line 334: for (uint256 i = 0; i < pointer; i++) {

  • Line 353: for (uint256 j = 0; j < returnedPointer; j++) {

  • Line 364: for (uint256 i = 0; i < deletedIndexPointer; i++) {

  • Line 383: for (uint256 i = 0; i < _draw.length; i++) {

  • Line 398: for (uint256 j = 0; j < 5; j++) {

  • Line 403: for (uint256 k = 0; k < rewardInfo.rewardTokens.length; k++)

  • Line 439: for (uint256 k = 0; k < rewardInfo.rewardTokens.length; k++) {

  • Line 463: for (uint256 k = 0; k < rewardInfo.rewardTokens.length; k++) {

  • Line 513: for (uint256 k = 0; k < _alreadyWinners.length; k++) {

  • Line 544: for (uint j = 0; j < _alreadyWinners.length; j++) {

  • Line 580: for (i = 0; i < rewardInfo.rewardTokens.length; i++) {

  • Line 618: for (uint256 i = 0; i < _emptyArray.length; i++) {

  • Line 691: for (uint i = startingIndex; i < endingIndex; i++) {

  • Line 787: for (uint256 i = 0; i < _draw.length; i++) {

  • Line 800: for (uint256 j = 0; j < 5; j++) {

  • Line 805: for (uint256 k = 0; k < rewardInfo.rewardTokens.length;k++) {

  • Line 833: for (uint256 k = 0; k < rewardInfo.rewardTokens.length; k++) {

  • Line 975: for (uint256 i = 0; i < _slots.length; i++) {

  • Line 1039: for (uint i = 0; i < _slots.length; i++) {

  • Line 1057: for (uint256 j = 0; j < returnedPointer; j++) {

  • Line 1066: for (uint256 i = 0; i < pointer; i++) {

Score
Recommendation

To reduce gas consumption, it is recommended to find ways to optimize the loop or potentially break the loop into smaller batches. The following pattern can also be used:

uint256 cachedLen = array.length;
for(uint i; i < cachedLen;){
  unchecked {
    ++i;
  }
}

Remediation Plan

SOLVED: The WinWin Team solved the issue by implementing the suggested recommendation.

Remediation Hash

7.10 uint64 _target parameter can be declared directly as a uint32 in the StakingPool.getBalanceAt() function

// Informational

Description

The staking pool contracts implement the function getBalanceAt():

getBalanceAt() function

This function downcasts the uint64 parameter to an uint32 as is the type required by the TwabLib.getBalanceAt() function. Consequently, in order to avoid the downcast and any possible overflow during the type casting, it is recommended to declare the _target parameter directly as an uint32.


Score
Recommendation

Consider declaring the _target parameter directly as an uint32 in the getBalanceAt() function.

Remediation Plan

SOLVED: The WinWin team solved the issue by implementing the recommended solution.

Remediation Hash

7.11 Centralization risk: Random numbers that determine the winner are selected off-chain

// Informational

Description

In the current implementation of the protocol the winners are determined based of random numbers generated off-chain which then are added to the RandomNumber contract through the setRandomNumber() function, only callable by an admin:

RandomNumber.setRandomNumber() function

Using off-chain random number generation for determining the winners introduces several significant issues:

  1. Centralization Risk:

    • The reliance on an off-chain source for randomness and the need for an admin to set the random number means that the entire system is dependent on a central authority. This central point of control can be exploited or corrupted.

  2. Lack of Transparency:

    • Participants in the raffle have no way to verify the fairness of the random number. The process is opaque, and users must trust the admin to act honestly, which undermines the trustless nature of blockchain technology.

  3. Potential for Manipulation:

    • The admin, or anyone with control over the off-chain random number generator, can manipulate the outcome of the raffle by choosing favorable random numbers. This opens the door to fraud and reduces the integrity of the protocol.

  4. Single Point of Failure:

    • If the off-chain random number generator or the admin’s ability to set the random number is compromised, the entire raffle system can be disrupted.

Alternatives for Secure and Trustless Random Number Generation
  1. Chainlink VRF (Verifiable Random Function):

    • Description: Chainlink VRF provides a provably fair and verifiable source of randomness. It is a decentralized oracle service that generates random numbers that can be verified by anyone.

    • Implementation:

      import "@chainlink/contracts/src/v0.8/VRFConsumerBase.sol";
      
      contract Raffle is VRFConsumerBase {
          bytes32 internal keyHash;
          uint256 internal fee;
          uint256 public randomResult;
      
          constructor() 
              VRFConsumerBase(
                  0x514910771AF9Ca656af840dff83E8264EcF986CA, // VRF Coordinator
                  0x514910771AF9Ca656af840dff83E8264EcF986CA  // LINK Token
              ) {
              keyHash = 0xAA77729D3466CA35AE8D28FA0D0B6AEE3B7D2B13;
              fee = 0.1 * 10 ** 18; // 0.1 LINK
          }
      
          function getRandomNumber() public returns (bytes32 requestId) {
              require(LINK.balanceOf(address(this)) >= fee, "Not enough LINK");
              return requestRandomness(keyHash, fee);
          }
      
          function fulfillRandomness(bytes32 requestId, uint256 randomness) internal override {
              randomResult = randomness;
          }
      }
    • Pros: Decentralized, secure, and verifiable.

    • Cons: Requires LINK tokens and depends on the Chainlink network.

  2. Ethereum Beacon Chain Randomness (RANDAO):

    • Description: The Ethereum 2.0 Beacon Chain includes a randomness beacon that can be used for generating random numbers. It leverages the RANDAO protocol for secure and decentralized randomness.

    • Implementation: This method is more suitable for contracts deployed on Ethereum 2.0 and would require interfacing with the Beacon Chain’s randomness features.

  3. Commit-Reveal Scheme:

    • Description: This scheme involves participants committing to a random number without revealing it, then revealing the number in a later transaction. The final random number is derived from these committed numbers.

    • Implementation:

      contract Raffle {
          struct Commitment {
              bytes32 hash;
              bool revealed;
              uint256 number;
          }
          mapping(address => Commitment) public commitments;
      
          function commit(bytes32 hash) public {
              commitments[msg.sender] = Commitment({hash: hash, revealed: false, number: 0});
          }
      
          function reveal(uint256 number) public {
              Commitment storage commitment = commitments[msg.sender];
              require(commitment.hash == keccak256(abi.encodePacked(number)), "Invalid reveal");
              require(!commitment.revealed, "Already revealed");
              commitment.revealed = true;
              commitment.number = number;
          }
      
          function determineWinner() public view returns (address winner) {
              // Logic to determine the winner based on revealed numbers
          }
      }
    • Pros: Decentralized and trustless.

    • Cons: More complex and requires participant involvement in both commit and reveal phases.

  4. Using Blockhash:

    • Description: Use the hash of a future block as a source of randomness.

    • Example implementation:

      // This code has not been professionally audited, therefore I cannot make any promises about
      // safety or correctness. Use at own risk.
      contract Randomness {
      
          bytes32 sealedSeed;
          bool seedSet = false;
          bool betsClosed = false;
          uint storedBlockNumber;
          address trustedParty = 0xdCad3a6d3569DF655070DEd06cb7A1b2Ccd1D3AF;
      
          function setSealedSeed(bytes32 _sealedSeed) public {
              require(!seedSet);
              require (msg.sender == trustedParty);
              betsClosed = true;
              sealedSeed = _sealedSeed;
              storedBlockNumber = block.number + 1;
              seedSet = true;
          }
      
          function bet() public {
              require(!betsClosed);
              // Make bets here
          }
      
          function reveal(bytes32 _seed) public {
              require(seedSet);
              require(betMade);
              require(storedBlockNumber < block.number);
              require(keccak256(msg.sender, _seed) == sealedSeed);
              uint random = uint(keccak256(_seed, blockhash(storedBlockNumber)));
              // Insert logic for usage of random number here;
              seedSet = false;
              betsClosed = false;
          }
      }
    • Pros: Simple to implement.

    • Cons: Reveal should be done in the next 256 blocks.

Score
Recommendation

Consider implementing any of the suggested alternatives to generate the random numbers.

Remediation Plan

ACKNOWLEDGED: The WinWin team acknowledged this issue and stated that the random number generation will be kept off-chain until oracle/rng becomes available in Pulsechain.

8. Automated Testing

Halborn used automated testing techniques to enhance the coverage of certain areas of the smart contracts in scope. Among the tools used was Slither, a Solidity static analysis framework. After Halborn verified the smart contracts in the repository and was able to compile them correctly into their ABIS and binary format, Slither was run against the contracts. This tool can statically verify mathematical relationships between Solidity variables to detect invalid or inconsistent usage of the contracts' APIs across the entire code-base.

ChainlinkVRF.sol

ChainlinkVRF #1ChainlinkVRF #2

RandomNumber.sol

RandomNumber #1

WinToken.sol

WinToken #1

LOANStrategy.sol

LOANStrategy #1LOANStrategy #2LOANStrategy #3LOANStrategy #4

MINTRAStrategy.sol

MINTRAStrategy #1MINTRAStrategy #2MINTRAStrategy #3MINTRAStrategy #4

USDLStrategy.sol

USDLStrategy #1USDLStrategy #2USDLStrategy #3USDLStrategy #4USDLStrategy #5

HEXStrategy.sol

HEXStrategy #1HEXStrategy #2HEXStrategy #3HEXStrategy #4HEXStrategy #5HEXStrategy #6HEXStrategy #7HEXStrategy #8HEXStrategy #9

YieldStakingPool.sol

YieldStakingPool #1YieldStakingPool #2YieldStakingPool #3YieldStakingPool #4YieldStakingPool #5YieldStakingPool #6YieldStakingPool #7YieldStakingPool #8

WinStakingPool.sol

No issues found by Slither.

HEXStakingPool.sol

HEXStakingPool #1HEXStakingPool #2HEXStakingPool #3HEXStakingPool #4HEXStakingPool #5HEXStakingPool #6HEXStakingPool #7HEXStakingPool #8HEXStakingPool #9

BuyAndBurn.sol

BuyAndBurn #1BuyAndBurn #2

PrizePool.sol

PrizePool #1PrizePool #2PrizePool #3PrizePool #4PrizePool #5PrizePool #6
  • The weak PRNG flagged by Slither is a false positive.

  • All the reentrancies flagged by Slither were checked individually and are all false positives.

  • No major issues were found by Slither.

Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.