Halborn Logo

SCProtection - Degis


Prepared by:

Halborn Logo

HALBORN

Last Updated 04/25/2024

Date of Engagement by: September 4th, 2022 - September 28th, 2022

Summary

97% of all REPORTED Findings have been addressed

All findings

29

Critical

10

High

8

Medium

4

Low

2

Informational

5


Table of Contents

1. INTRODUCTION

Degis engaged Halborn to conduct a security audit on their smart contracts beginning on 2022-09-04 and ending on 2022-09-28. The security assessment was scoped to the smart contracts provided to the Halborn team.

2. AUDIT SUMMARY

The team at Halborn was provided two weeks for the engagement and assigned two full-time security engineers to audit the security of the smart contract. The security engineers are blockchain and smart-contract security experts with advanced penetration testing, smart-contract hacking, and deep knowledge of multiple blockchain protocols.

The purpose of this audit 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 mostly addressed by the Degis team.

3. TEST APPROACH & 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 audit. 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 audit:

    • 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

    • Scanning of solidity files for vulnerabilities, security hot-spots or bugs. (MythX)

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

    • Testnet deployment (Brownie, Remix IDE)

4. SCOPE

IN-SCOPE: The security assessment was scoped on all the contracts present under the src folder on commit https://github.com/Degis-Insurance/Degis-SCProtection/tree/975aaad510ee3fd50c005eb8bf224be38c08bda0

5. RISK METHODOLOGY

Vulnerabilities or issues observed by Halborn are ranked based on the risk assessment methodology by measuring the LIKELIHOOD of a security incident and the IMPACT should an incident occur. This framework works for communicating the characteristics and impacts of technology vulnerabilities. The quantitative model ensures repeatable and accurate measurement while enabling users to see the underlying vulnerability characteristics that were used to generate the Risk scores. For every vulnerability, a risk level will be calculated on a scale of 5 to 1 with 5 being the highest likelihood or impact.
RISK SCALE - LIKELIHOOD
  • 5 - Almost certain an incident will occur.
  • 4 - High probability of an incident occurring.
  • 3 - Potential of a security incident in the long term.
  • 2 - Low probability of an incident occurring.
  • 1 - Very unlikely issue will cause an incident.
RISK SCALE - IMPACT
  • 5 - May cause devastating and unrecoverable impact or loss.
  • 4 - May cause a significant level of impact or loss.
  • 3 - May cause a partial impact or loss to many.
  • 2 - May cause temporary impact or loss.
  • 1 - May cause minimal or un-noticeable impact.
The risk level is then calculated using a sum of these two values, creating a value of 10 to 1 with 10 being the highest level of security risk.
Critical
High
Medium
Low
Informational
  • 10 - CRITICAL
  • 9 - 8 - HIGH
  • 7 - 6 - MEDIUM
  • 5 - 4 - LOW
  • 3 - 1 - VERY LOW AND INFORMATIONAL

6. SCOPE

Out-of-Scope: New features/implementations after the remediation commit IDs.

7. Assessment Summary & Findings Overview

Critical

10

High

8

Medium

4

Low

2

Informational

5

Impact x Likelihood

HAL-22

HAL-23

HAL-20

HAL-21

HAL-17

HAL-18

HAL-19

HAL-01

HAL-03

HAL-04

HAL-05

HAL-06

HAL-07

HAL-08

HAL-09

HAL-10

HAL-11

HAL-14

HAL-15

HAL-12

HAL-13

HAL-16

HAL-24

HAL-26

HAL-25

HAL-02

HAL-28

HAL-29

HAL-27

Security analysisRisk levelRemediation Date
DEPOSITING TO ANY POOL/TOKEN WITH ANY AMOUNT VIA CONTROLLED POLICY CENTERCriticalSolved - 09/30/2022
CODE NOT CHECKING IF TOKEN IS NOT PRESENTCriticalSolved - 09/30/2022
DEPOSITING ON ANY POOL USING ANY TOKENCriticalSolved - 09/30/2022
AN ATTACKER CAN WITHDRAW NOT OWNED TOKENS AND STEAL FUNDSCriticalSolved - 09/30/2022
UPDATING THE 0 INDEX TOKEN WEIGHT VIA UNREGISTERED TOKENSCriticalSolved - 09/30/2022
PUBLICLY EXPOSED FUNCTIONSCriticalSolved - 09/30/2022
DEPOSITING TO SECONDARY TOKENS DOES CAUSE THE CONTRACT TO LOCKCriticalSolved - 09/30/2022
POOL INCOMES ON REPORTER REWARD CAN BE ARBITRARILY INCREASEDCriticalSolved - 09/30/2022
INVALID VARIABLE VISIBILITY DOES CAUSE CONTRACT DEADLOCKCriticalSolved - 09/30/2022
INVALID EXTERNAL CALL DOES CAUSE CONTRACT DEADLOCKCriticalSolved - 09/30/2022
UPDATE REWARDS MAY CAUSE A DENIAL OF SERVICE BETWEEN YEARSHighSolved - 09/30/2022
BUYING COVER FOR THREE MONTHS IS NEVER COUNTED DURING THE CURRENT MONTHHighSolved - 10/01/2022
MINTING ZERO AMOUNT DEADLOCK IS PRODUCED DURING PAYOUT CLAIMINGHighSolved - 09/30/2022
COVER MAY BE UPDATED FOR MONTH'S VALUES OUT OF RANGEHighSolved - 10/01/2022
INVALID REWARD UPDATING MECHANISMHighSolved - 09/30/2022
REPORTING DEADLOCK IF QUORUM NOT REACHEDHighSolved - 09/30/2022
INVALID PERCENTAGE RESULTS IN LESS PAYED DEBTHighSolved - 10/01/2022
UNABLE TO CLAIM PAYOUTSHighSolved - 09/30/2022
ANYONE CAN DEPLOY COVER RIGHT TOKENSMediumSolved - 09/30/2022
CRTOKENS MAY BE MINTED/BURNED ARBITRARILY IF POLICY CENTER IS NOT SETMediumRisk Accepted
SAFEREWARDTRANSFER SHOULD CHECK BEFORE/AFTER BALANCEMediumSolved - 09/30/2022
OWNER CAN RENOUNCE OWNERSHIPMediumRisk Accepted
MODIFYING DEFAULT POOLSLowSolved - 09/30/2022
STRANGE CODE BEHAVIOURLowSolved - 10/01/2022
INFINITE VOTING BY BYPASSING LOCKING AND RE-CLAIMING LOCKED TOKENSInformational-
TYPO ON FUNCTION DECLARATIONInformationalSolved - 10/01/2022
DEPOSIT FUNCTION CAN BE IMPERSONATEDInformationalSolved - 09/30/2022
UNNECESSARY CALLInformationalAcknowledged
UNNECESSARY CHECKInformationalSolved - 09/30/2022

8. Findings & Tech Details

8.1 DEPOSITING TO ANY POOL/TOKEN WITH ANY AMOUNT VIA CONTROLLED POLICY CENTER

// Critical

Description

The function setter setPolicyCenter in charge of modifying the variable policyCenter, used to verify whether a function's caller is the policy center smart contract or not by checking the address stored in this variable, has no access control. Thus, any account can modify the address stored in policyCenter which is used to verify the caller in relevant functions such as depositFromPolicyCenter and withdrawFromPolicyCenter. This allows the depositFromPolicyCenter to be called without ever having to transfer tokens. This is causing an attacker to add pool liquidity to any desired pool ID with any chosen token and without any amount without ever owning a single token. This allows the attacker to gain full control on the balance of every single pool on the system.

Code Location

src/reward/WeightedFarmingPool.sol

function setPolicyCenter(address _policyCenter) public {
    policyCenter = _policyCenter;
}

src/reward/WeightedFarmingPool.sol

function depositFromPolicyCenter(
    uint256 _id,
    address _token,
    uint256 _amount,
    address _user
) external {
    require(
        msg.sender == policyCenter,
        "Only policyCenter can call stakedLiquidity"
    );

    _deposit(_id, _token, _amount, _user);
}
Score
Impact: 5
Likelihood: 5
Recommendation

SOLVED: This issue was solved by implementing access control modifiers.

8.2 CODE NOT CHECKING IF TOKEN IS NOT PRESENT

// Critical

Description

The _getIndex function under WeightedFarmingPool does not check if the token was not found and returns index 0 as default, causing not found tokens to be treated as part of the pool tokens.

Code Location

src/reward/WeightedFarmingPool.sol

function _getIndex(uint256 _id, address _token)
    internal
    view
    returns (uint256 index)
{
    address[] memory allTokens = pools[_id].tokens;
    uint256 length = allTokens.length;

    for (uint256 i; i < length; ) {
        if (allTokens[i] == _token) {
            index = i;
            break;
        } else {
            unchecked {
                ++i;
            }
        }
    }
}
Score
Impact: 5
Likelihood: 5
Recommendation

SOLVED: Instead of relying on arrays, a new mapping mapping(bytes32 => bool) public supported; was introduced to keep track of supported tokens for a given pool id.

8.3 DEPOSITING ON ANY POOL USING ANY TOKEN

// Critical

Description

The deposit function under WeightedFarmingPool does accept any token as parameter. This token is then compared against all pool tokens and the index returned. However, since the _getIndex function does return 0 when a token is not found, the first token of the pool will be used and balance incremented. Furthermore, since the system does not implement any token white-listing a malicious token can be used so the transferFrom function does not perform any real transfer allowing to increment the stacked amount of the first pool token as wished.

Code Location

src/reward/WeightedFarmingPool.sol

uint256 index = _getIndex(_id, _token);

// check if current index exists for user
if (user.amount.length < index + 1) {
    user.amount.push(0);
}
if (pool.amount.length < index + 1) {
    pool.amount.push(0);
}

user.amount[index] += _amount;
user.share += _amount * pool.weight[index];

pool.amount[index] += _amount;
pool.shares += _amount * pool.weight[index];

user.rewardDebt = (user.share * pool.accRewardPerShare) / SCALE;
Score
Impact: 5
Likelihood: 5
Recommendation

SOLVED: Instead of relying on arrays, a new mapping mapping(bytes32 => bool) public supported; was introduced to keep track of supported tokens for a given pool id.

8.4 AN ATTACKER CAN WITHDRAW NOT OWNED TOKENS AND STEAL FUNDS

// Critical

Description

The _withdraw function is using the invalid implemented _getIndex which is causing the transfer to take place into any user controlled token from the parameters.

Code Location

src/reward/WeightedFarmingPool.sol

function _withdraw(
    uint256 _id,
    address _token,
    uint256 _amount,
    address _user
) internal {
    require(_amount > 0, "Zero amount");
    require(_id <= counter, "Pool not exists");

    updatePool(_id);

    PoolInfo storage pool = pools[_id];
    UserInfo storage user = users[_id][_user];

    if (user.share > 0) {
        uint256 pending = (user.share * pool.accRewardPerShare) /
            SCALE -
            user.rewardDebt;

        uint256 actualReward = _safeRewardTransfer(
            pool.rewardToken,
            _user,
            pending
        );

        emit Harvest(_id, _user, _user, actualReward);
    }

    IERC20(_token).transfer(_user, _amount);
withdraw_any.jpg
Score
Impact: 5
Likelihood: 5
Recommendation

SOLVED: Instead of relying on arrays, a new mapping mapping(bytes32 => bool) public supported; was introduced to keep track of supported tokens for a given pool id.

8.5 UPDATING THE 0 INDEX TOKEN WEIGHT VIA UNREGISTERED TOKENS

// Critical

Description

The updateWeight function is updating the pool weight via the _getIndex index, which as stated before it is incorrectly implemented. This allows the first token weight on the pool to be updated if an unregistered or invalid token is provided as the parameter. Furthermore, the code does not check if the weight is greater than 0.

Code Location

src/reward/WeightedFarmingPool.sol

function updateWeight(
    uint256 _id,
    address _token,
    uint256 _newWeight
) external {
    updatePool(_id);

    uint256 index = _getIndex(_id, _token);

    pools[_id].weight[index] = _newWeight;
}
Score
Impact: 5
Likelihood: 5
Recommendation

SOLVED: Instead of relying on arrays, a new mapping mapping(bytes32 => bool) public supported; was introduced to keep track of supported tokens for a given pool id.

8.6 PUBLICLY EXPOSED FUNCTIONS

// Critical

Description

Several functions are being involved in the management of the WeightedFarmingPool contract, which are publicly exposed and without any sort of access control:

  • addPool: It can add an arbitrary pool into the system.
  • addToken: It can add any token, even not whitelisted, into any pool.
  • updateWeight: It does allow updating the weight of any token of any pool.
  • setWeight: Same as updateWeight but for the entire pool tokens.
  • updateRewardSpeed: It does allow updating the rewards per second for a pool.
  • setPolicyCenter: as described on "DEPOSITING TO ANY POOL/TOKEN WITH ANY AMOUNT VIA CONTROLLED POLICY CENTER".

Code Location

src/reward/WeightedFarmingPool.sol

function updateWeight(
    uint256 _id,
    address _token,
    uint256 _newWeight
) external {
    updatePool(_id);

    uint256 index = _getIndex(_id, _token);

    pools[_id].weight[index] = _newWeight;
}
Score
Impact: 5
Likelihood: 5
Recommendation

SOLVED: All the stated calls are checking if the sender is a valid-registered pool using IPriorityPoolFactory. The setPolicyCenter function was removed from the WeightedFarmingPool contract.

8.7 DEPOSITING TO SECONDARY TOKENS DOES CAUSE THE CONTRACT TO LOCK

// Critical

Description

The code under _deposit is assuming that user.amount will contain an array for all newly added tokens to the pool, which is not the case if the user performs a deposit for the first time on a token whose index is !=0.

Code Location

src/reward/WeightedFarmingPool.sol

// check if current index exists for user
if (user.amount.length < index + 1) {
    user.amount.push(0);
}
if (pool.amount.length < index + 1) {
    pool.amount.push(0);
}

user.amount[index] += _amount;
user.share += _amount * pool.weight[index];

pool.amount[index] += _amount;
pool.shares += _amount * pool.weight[index];

user.rewardDebt = (user.share * pool.accRewardPerShare) / SCALE;
out_of_index.jpg
Score
Impact: 5
Likelihood: 5
Recommendation

SOLVED: The code is now checking the user.amount length and comparing it against the extracted index. If it is required, empty values will be pushed to satisfy the difference between them.

8.8 POOL INCOMES ON REPORTER REWARD CAN BE ARBITRARILY INCREASED

// Critical

Description

The function premiumIncome allows increasing arbitrarily the array of pool incomes used to calculate the final reward for a correct reporter. Using this function to increase the value of a selected pool income before executing an incident report made from a malicious account could lead to an attacker draining the entire SHD token balance from Treasury smart contract.

Code Location

src/pools/Treasury.sol

function premiumIncome(uint256 _poolId, uint256 _amount) external {
    poolIncome[_poolId] += _amount;
}
token_drainage.png
Score
Impact: 5
Likelihood: 5
Recommendation

SOLVED: The code is now checking that the caller is only the policyCenter.

8.9 INVALID VARIABLE VISIBILITY DOES CAUSE CONTRACT DEADLOCK

// Critical

Description

When the function dynamicPremiumRatio is executed 7 days after the pool's deployment, this function tries to retrieve dynamicPoolCounter from PriorityPoolFactory during the dynamic ratio calculation. Since the visibility of this variable is internal, the function always revert at this point.

Code Location

src/pools/priorityPool/PriorityPool.sol

if (fromStart > 7 days) {
            // Covered ratio = Covered amount of this pool / Total covered amount
            uint256 coveredRatio = ((activeCovered() + _coverAmount) * SCALE) /
                (ISimpleProtectionPool(protectionPool).getTotalCovered() +
                    _coverAmount);

            address lp = currentLPAddress();
            // LP Token ratio = LP token in this pool / Total lp token
            uint256 tokenRatio = (SimpleERC20(lp).totalSupply() * SCALE) /
                SimpleERC20(protectionPool).totalSupply();

            // Total dynamic pools
            uint256 numofPools = IFactory(priorityPoolFactory)
                .dynamicPoolCounter();

            // Dynamic premium ratio
            // ( N = total dynamic pools ≤ total pools )
            //
            //                      Covered          1
            //                   --------------- + -----
            //                    TotalCovered       N
            // dynamic ratio =  -------------------------- * base ratio
            //                      LP Amount         1
            //                  ----------------- + -----
            //                   Total LP Amount      N
            //
            ratio =
                (basePremiumRatio * (coveredRatio * numofPools + SCALE)) /
                ((tokenRatio * numofPools) + SCALE);

src/pools/priorityPool/PriorityPoolFactory.sol

// Total max capacity
uint256 public totalMaxCapacity;

// Whether a pool is already dynamic
mapping(address => bool) public dynamic;

uint256 internal dynamicPoolCounter;

// Record whether a protocol token or pool address has been registered
mapping(address => bool) public poolRegistered;
mapping(address => bool) public tokenRegistered;
always_revert.png
Score
Impact: 5
Likelihood: 5
Recommendation

SOLVED: The dynamicPoolCount variable has been made public for external usages.

8.10 INVALID EXTERNAL CALL DOES CAUSE CONTRACT DEADLOCK

// Critical

Description

During the payout claiming process, some crTokens are burned in the claim function located at PayoutPool smart contract. Since the onlyPolicyCenter modifier is applied to the burn function, the smart contract associated to the address stored in policyCenter is the only one that can execute this function. Therefore, claim function always reverts at this point.

Code Location

src/pools/PayoutPool.sol

uint256 claimableBalance = ICoverRightToken(_crToken).getClaimableOf(
            _user
        );
        uint256 claimable = (claimableBalance * payout.ratio) / SCALE;

        uint256 coverIndex = IPriorityPool(payout.priorityPool).coverIndex();

        claimed = (claimable * coverIndex) / 10000;

        ICoverRightToken(_crToken).burn(
            _poolId,
            _user,
            // burns the users' crToken balance, not the payout amount,
            // since rest of the payout will be minted as a new generation token
            claimableBalance
        );

src/crTokens/CoverRightToken.sol

function burn(
        uint256 _poolId,
        address _user,
        uint256 _amount
    ) external onlyPolicyCenter nonReentrant {
        require(_amount > 0, "Zero Amount");
        require(_poolId == POOL_ID, "Wrong pool id");

        _burn(_user, _amount);
    }
always_revert_call.png
Score
Impact: 5
Likelihood: 5
Recommendation

SOLVED: The CoverRightToken smart contract now implements a modifier to identify whether a call has been made from PolicyCenter or PayoutPool smart contracts.

8.11 UPDATE REWARDS MAY CAUSE A DENIAL OF SERVICE BETWEEN YEARS

// High

Description

During the calculation of the difference of months between reward's updates, changes of years are not taken into consideration. Since lastRewardTimestamp can be a high month's value, after a new year the month's counter starts from 1 resulting in an integer overflow as soon as currentM - lastM is evaluated due this calculation is not considering the years elapsed between updates.

Code Location

src/reward/WeightedFarmingPool.sol

function _updateReward(uint256 _id)
        internal
        view
        returns (uint256 totalReward)
    {
        PoolInfo storage pool = pools[_id];

        uint256 currentTime = block.timestamp;
        uint256 lastRewardTime = pool.lastRewardTimestamp;

        (uint256 lastY, uint256 lastM, uint256 lastD) = lastRewardTime
            .timestampToDate();

        (uint256 currentY, uint256 currentM, ) = currentTime.timestampToDate();

        uint256 monthPassed = currentM - lastM;

src/pools/protectionPool/ProtectionPool.sol

function _updateReward() internal {
        uint256 currentTime = block.timestamp;

        // Last reward year & month & day
        (uint256 lastY, uint256 lastM, uint256 lastD) = lastRewardTimestamp
            .timestampToDate();

        // Current year & month & day
        (uint256 currentY, uint256 currentM, ) = currentTime.timestampToDate();

        uint256 monthPassed = currentM - lastM;
Score
Impact: 4
Likelihood: 5
Recommendation

SOLVED: In WeightedFarmingPool.sol the issue has been fixed by taking into consideration elapsed years between executions. In the case of ProtectionPool.sol, the function where the issue was located has been removed.

8.12 BUYING COVER FOR THREE MONTHS IS NEVER COUNTED DURING THE CURRENT MONTH

// High

Description

When a cover is bought, the function _updateCoverInfo is executed in the target pool, this function is responsible for storing the covered amount per month. In the case of trying to cover an amount for three months, this value will only be stored in the index currentMonth + 3 of currentYear. The main issue comes when the function activeCovered tries to retrieve the active cover amount, since this function iterates over coverInMonth array starting from currentMonth as index 0, the third month is never reached due the loop condition is i < 3.

Code Location

src/pools/priorityPool/PriorityPool.sol

(uint256 currentYear, uint256 currentMonth, ) = block
    .timestamp
    .timestampToDate();

uint256 endMonth = currentMonth + _length;

// ! Remove redundant counts
// ! Previously it is counted in multiple months
// for (uint256 i; i < _length; ) {
coverInMonth[currentYear][endMonth] += _amount;

src/pools/priorityPool/PriorityPool.sol

(uint256 currentYear, uint256 currentMonth, ) = block
            .timestamp
            .timestampToDate();

        // Only count the latest 3 months
        for (uint256 i; i < 3; ) {
            covered += coverInMonth[currentYear][currentMonth];
Score
Impact: 4
Likelihood: 5
Recommendation

SOLVED: Now _updateCoverInfo stores cover information into right year and month indexes.

8.13 MINTING ZERO AMOUNT DEADLOCK IS PRODUCED DURING PAYOUT CLAIMING

// High

Description

The newPayout function defines a new payout in the PayoutPool smart contract, one of the parameters used to calculate a payout is payout.ratio. This attribute helps to calculate the claimable shield in the payout, as well as the amount of crTokens to mint in the next generation. The issue lies when payout.ratio is equal to the constant SCALE, this happens when _amount and the return value from activeCovered function is the same during the execution of _retrievePayout, causing claimableBalance and claimable variables to be equal and setting newGenerationCRAmount variable to 0. Considering that newGenerationCRAmount is the variable being used to specify the number of tokens to mint in the next generation, the function always reverts at this point.

Code Location

src/core/PolicyCenter.sol

(uint256 claimed, uint256 newGenerationCRAmount) = IPayoutPool(
            payoutPool
        ).claim(msg.sender, _crToken, _poolId, _generation);

        emit PayoutClaimed(msg.sender, claimed);

        uint256 expiry = ICoverRightToken(_crToken).expiry();

        // Check if the new generation crToken has been deployed
        // If so, get the address
        // If not, deploy the new generation cr token
        address newCRToken = _checkNewCRToken(
            _poolId,
            poolName,
            expiry,
            _generation++
        );
        ICoverRightToken(newCRToken).mint(
            _poolId,
            msg.sender,
            newGenerationCRAmount
        );

src/pools/PayoutPool.sol

uint256 claimableBalance = ICoverRightToken(_crToken).getClaimableOf(
    _user
);
uint256 claimable = (claimableBalance * payout.ratio) / SCALE;

uint256 coverIndex = IPriorityPool(payout.priorityPool).coverIndex();

claimed = (claimable * coverIndex) / 10000;

ICoverRightToken(_crToken).burn(
    _poolId,
    _user,
    // burns the users' crToken balance, not the payout amount,
    // since rest of the payout will be minted as a new generation token
    claimableBalance
);

SimpleIERC20(shield).transfer(_user, claimed);

// Amount of new generation cr token to be minted
newGenerationCRAmount = claimableBalance - claimable;

src/pools/priorityPool/PriorityPool.sol

uint256 payoutRatio;
        activeCovered() > 0
            ? payoutRatio = (_amount * SCALE) / activeCovered()
            : payoutRatio = 0;

        IPayoutPool(payoutPool).newPayout(
            poolId,
            generation,
            _amount,
            payoutRatio,
            address(this)
        );
revert_mint.png
Score
Impact: 4
Likelihood: 4
Recommendation

SOLVED: The claimPayout function where reverts were produced now verifies if newGenerationCRAmount is equal to 0 to prevent minting 0 tokens.

8.14 COVER MAY BE UPDATED FOR MONTH'S VALUES OUT OF RANGE

// High

Description

When a new deadline is updated, the resulting month may be out of range due the function is not considering this value exceeding the highest month's value (12) which would lead to a change of year. As a consequence, new covers in this situation could never be obtained from coverInMonth since they would have been written out of months' range.

Code Location

src/pools/priorityPool/PriorityPool.sol

(uint256 currentYear, uint256 currentMonth, ) = block
    .timestamp
    .timestampToDate();

uint256 endMonth = currentMonth + _length;

// ! Remove redundant counts
// ! Previously it is counted in multiple months
// for (uint256 i; i < _length; ) {
coverInMonth[currentYear][endMonth] += _amount;
out_of_range_months.png
Score
Impact: 4
Likelihood: 4
Recommendation

SOLVED: Now _updateCoverInfo considers elapsed time between years by increasing currentYear variable when is required and controlling endMonth variable bounces.

8.15 INVALID REWARD UPDATING MECHANISM

// High

Description

The _updateReward in case of multiple months passed is not properly implemented/handled:

  • monthPassed should also consider if currentY and lastY are different and add the difference to monthPassed multiplied by 12. Otherwise, the for loop will only iterate over a maximum of 12 months and iterate 0 times if the date is the same month 2 years apart.

  • On iteration index 0: The _getDaysInMonth should be used to fill the lastD on endTimestamp. Otherwise, it is only computing the seconds that passed from the last update until that same day at midnight.

  • On iteration i == monthPassed the lastM should be using currentM instead or lastM + i

  • For any other iteration the lastM should be used with lastM + i including the fetching of _getDaysInMonth and the speed

Code Location

src/reward/WeightedFarmingPool.sol

for (uint256 i; i < monthPassed + 1; ) {
                // First month reward
                if (i == 0) {
                    // End timestamp of the first month
                    uint256 endTimestamp = DateTimeLibrary
                        .timestampFromDateTime(lastY, lastM, lastD, 23, 59, 59);
                    totalReward +=
                        (endTimestamp - lastRewardTime) *
                        speed[_id][lastY][lastM];
                }
                // Last month reward
                else if (i == monthPassed) {
                    uint256 startTimestamp = DateTimeLibrary
                        .timestampFromDateTime(lastY, lastM, 1, 0, 0, 0);

                    totalReward +=
                        (currentTime - startTimestamp) *
                        speed[_id][lastY][lastM];
                }
                // Middle month reward
                else {
                    uint256 daysInMonth = DateTimeLibrary._getDaysInMonth(
                        lastY,
                        lastM
                    );

                    totalReward +=
                        (DateTimeLibrary.SECONDS_PER_DAY * daysInMonth) *
                        speed[_id][lastY][lastM];
                }

                unchecked {
                    if (++lastM > 12) {
                        ++lastY;
                        lastM = 1;
                    }

                    ++i;
                }
            }
Score
Impact: 4
Likelihood: 5
Recommendation

SOLVED: The code is now implementing all recommended fixes described under the description section.

8.16 REPORTING DEADLOCK IF QUORUM NOT REACHED

// High

Description

The settle function under IncidentReport does not reset the reported state when the quorum is not reached those causing the contract to not accept new reports for the same poolId ever again.

Code Location

src/voting/incidentReport/IncidentReport.sol

if (_checkQuorum(currentReport.numFor + currentReport.numAgainst)) {
    // REJECT or TIED: unlock the priority pool & protection pool immediately
    if (res != PASS_RESULT) {
        uint256 poolId = currentReport.poolId;
        _unpausePools(poolId);
        reported[poolId] = false;
    }

    currentReport.result = res;
    _settleVotingReward(_id, res);
    emit ReportSettled(_id, res);
} else {
    currentReport.result = FAILED_RESULT;
    // FAILED: unlock the priority pool & protection pool immediately
    _unpausePools(currentReport.poolId);
    emit ReportFailed(_id);
}
Score
Impact: 5
Likelihood: 3
Recommendation

SOLVED: A new function was added _setReportedStatus that sets the report status. The status is now reset when the quorum is not reached as well.

8.17 INVALID PERCENTAGE RESULTS IN LESS PAYED DEBT

// High

Description

The payDebt function under IncidentReport is using an invalid numeric value to perform debt percentage calculations. The DEBT_RATIO is stated as uint256 constant DEBT_RATIO = 80; // 80% as the debt to unlock veDEG while the actual used value corresponds to 0.8% instead of the expected 80%.

Code Location

src/voting/incidentReport/IncidentReport.sol

function payDebt(uint256 _id, address _user) external {
    UserVote memory userVote = votes[_user][_id];
    uint256 finalResult = reports[_id].result;

    if (finalResult == 0) revert IncidentReport__NotSettled();
    if (userVote.choice == finalResult || finalResult == TIED_RESULT)
        revert IncidentReport__NotWrongChoice();
    if (userVote.paid) revert IncidentReport__AlreadyPaid();

    uint256 debt = (userVote.amount * DEBT_RATIO) / 10000;

percentage_issue.png
Score
Impact: 5
Likelihood: 3
Recommendation

SOLVED: The client stated the following: The debt is paid in the form of "DEG". And here user's voting amount is calculated by "veDEG". The max generation ratio between DEG and veDEG is 100(1 DEG max generate 100veDEG) and we all treat it as this max ratio. So, the 10000 is composited of 100*100.

8.18 UNABLE TO CLAIM PAYOUTS

// High

Description

The getExcludedCoverageOf and getClaimableOf can be deadlocked since the voteTimestamp is used without verification whether the last incident is on vote state or not those returning voteTimestamp of 0 and causing the _getEOD to underflow. Furthermore, the report can be created by anyone using the report function under IncidentPool which will push to poolReports and cause the getPoolReportsAmount under getExcludedCoverageOf to return that last created one which has the voteTimestamp value of 0. The only possible scenario where this is allowed is when the reported of the pool is set to false, and this is achieved when the report voting does succeed.

This scenario is preventing anyone to claim the payout under PayoutPool for an already voted report and _crToken.

Code Location

src/crTokens/CoverRightToken.sol

function getExcludedCoverageOf(address _user)
    public
    view
    returns (uint256 exclusion)
{
    IIncidentReport incident = IIncidentReport(incidentReport);

    uint256 reportAmount = incident.getPoolReportsAmount(POOL_ID);

    if (reportAmount > 0) {
        uint256 latestReportId = incident.poolReports(
            POOL_ID,
            reportAmount - 1
        );

        (, , , uint256 voteTimestamp, , , , , , , ) = incident.reports(
            latestReportId
        );

        // Check those bought within 2 days
        for (uint256 i; i < EXCLUDE_DAYS; ) {
            uint256 date = _getEOD(voteTimestamp - (i * 1 days));

            exclusion += coverStartFrom[_user][date];

            unchecked {
                ++i;
            }
        }
    }
}
Score
Impact: 5
Likelihood: 3
Recommendation

SOLVED: The code is now checking for valid reports based on the generation, it will exclude all invalid reports and only count for reports with a result of SUCCESS.

8.19 ANYONE CAN DEPLOY COVER RIGHT TOKENS

// Medium

Description

Anyone can call deployCRToken under CoverRightTokenFactory which does deploy a new CoverRight Token. On the PolicyCenter contract, the _getCRTokenAddress function is used to retrieve this token address back using only the _poolId, _expiry and _generation values. This means that anyone could deploy a future CR token before the buyCover or claimPayout on the PolicyCenter. Furthermore, the deployCRToken does allow to arbitrary set the poolName and the tokenName those allowing manipulating and tricking the users if those values are used on the frontend.

Code Location

src/crTokens/CoverRightTokenFactory.sol

function deployCRToken(
    string calldata _poolName,
    uint256 _poolId,
    string calldata _tokenName,
    uint256 _expiry,
    uint256 _generation
) external returns (address newCRToken) {
    require(_expiry > 0, "Zero expiry date");

    bytes32 salt = keccak256(
        abi.encodePacked(_poolId, _expiry, _generation)
    );

    require(!deployed[salt], "already deployed");
    deployed[salt] = true;

    bytes memory bytecode = _getCRTokenBytecode(
        _poolName,
        _poolId,
        _tokenName,
        _expiry,
        _generation
    );

    newCRToken = _deploy(bytecode, salt);
    saltToAddress[salt] = newCRToken;

    emit NewCRTokenDeployed(
        _poolId,
        _tokenName,
        _expiry,
        _generation,
        newCRToken
    );
}
Score
Impact: 5
Likelihood: 2
Recommendation

SOLVED: The code is now verifying that calls are only made though the policyCenter.

8.20 CRTOKENS MAY BE MINTED/BURNED ARBITRARILY IF POLICY CENTER IS NOT SET

// Medium

Description

The modifier onlyPolicyCenter allows the execution of a function making use of this modifier without reverting the transaction when policyCenter is not set or equals to 0. Since this modifier is used to verify the access to critical token's functions as mint and destroy, a malicious actor may mint or destroy arbitrarily crTokens of a specified priority pool in the case that policyCenter has not been set previously.

Code Location

src/crTokens/CoverRightToken.sol

modifier onlyPolicyCenter() {
    if (policyCenter != address(0)) {
        require(msg.sender == policyCenter, "Only policy center");
    }
    _;
}
Score
Impact: 5
Likelihood: 2
Recommendation

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

8.21 SAFEREWARDTRANSFER SHOULD CHECK BEFORE/AFTER BALANCE

// Medium

Description

Since the system does implement any token white-listing, the _safeRewardTransfer function should verify that the difference between the before and after balance corresponds to the actual requested amount.

Code Location

src/reward/WeightedFarmingPool.sol

function _safeRewardTransfer(
    address _token,
    address _to,
    uint256 _amount
) internal returns (uint256 actualAmount) {
    uint256 balance = IERC20(_token).balanceOf(address(this));

    if (_amount > balance) {
        actualAmount = balance;
    } else {
        actualAmount = _amount;
    }

    IERC20(_token).safeTransfer(_to, actualAmount);
}
Score
Impact: 5
Likelihood: 1
Recommendation

SOLVED: The code is now verifying the before and after balance and returning that as the actual amount.

8.22 OWNER CAN RENOUNCE OWNERSHIP

// Medium

Description

The OwnableWithoutContext does contain the renounceOwnership function, which can be called by the current owner. Calling this function does leave the contract without an owner, denying the possibility to perform any further administrative operations.

Code Location

src/pools/Treasury.sol

function premiumIncome(uint256 _poolId, uint256 _amount) external {
    poolIncome[_poolId] += _amount;
}
Score
Impact: 5
Likelihood: 1
Recommendation

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

8.23 MODIFYING DEFAULT POOLS

// Low

Description

The storePoolInformation function under storePoolInformation does allow replacing the pool ID of 0, overriding the _protectionPool and shield tokens.

Code Location

src/core/PolicyCenter.sol

function storePoolInformation(
    address _pool,
    address _token,
    uint256 _poolId
) external {
    // @audit-issue This allows replacing any pool, including the ID 0 with
    // _protectionPool and shield
    require(msg.sender == priorityPoolFactory, "Only factory can store");

    tokenByPoolId[_poolId] = _token;
    priorityPools[_poolId] = _pool;

    _approvePoolToken(_token);
}
Score
Impact: 3
Likelihood: 1
Recommendation

SOLVED: It is verifying using an assert that the protection pool information is never altered.

8.24 STRANGE CODE BEHAVIOUR

// Low

Description

The updateRewardSpeed function under WeightedFarmingPool does check for years.length == months.length which does not comply with the logic of the code on this context.

Code Location

src/reward/WeightedFarmingPool.sol

function updateRewardSpeed(
    uint256 _id,
    uint256 _newSpeed,
    uint256[] memory _years,
    uint256[] memory _months
) external {
    require(_years.length == _months.length, "Wrong length");
    uint256 length = _years.length;
    for (uint256 i; i < length; ) {
        speed[_id][_years[i]][_months[i]] += _newSpeed;

        unchecked {
            ++i;
        }
    }
}
Score
Impact: 2
Likelihood: 2
Recommendation

SOLVED: The _years argument used by the function is to indicate the year for the _months array, and values can be repeated.

8.25 INFINITE VOTING BY BYPASSING LOCKING AND RE-CLAIMING LOCKED TOKENS

// Informational

Description
Finding description placeholder
Score
Impact:
Likelihood:

8.26 TYPO ON FUNCTION DECLARATION

// Informational

Description

The setMaxCapacity under PriorityPool will always revert as it is calling updateMaxCapacity but the declared selector on PriorityPoolFactory is updateMaxCapaity.

Code Location

src/pools/priorityPool/PriorityPoolFactory.sol

function updateMaxCapaity(bool _isUp, uint256 _diff)
    external
    onlyPriorityPool
{
    if (_isUp) {
        totalMaxCapacity += _diff;
    } else totalMaxCapacity -= _diff;

    emit MaxCapacityUpdated(totalMaxCapacity);
}

src/pools/priorityPool/PriorityPool.sol

function setMaxCapacity(bool _isUp, uint256 _maxCapacity) external {
    require(msg.sender == owner);

    maxCapacity = _maxCapacity;

    uint256 diff;
    if (_isUp) {
        diff = _maxCapacity - maxCapacity;
    } else {
        diff = maxCapacity - _maxCapacity;
    }

    IFactory(priorityPoolFactory).updateMaxCapacity(_isUp, diff);
}
Score
Impact: 2
Likelihood: 1
Recommendation

SOLVED: The typo was fixed, and the interface is updated to updateMaxCapacity.

8.27 DEPOSIT FUNCTION CAN BE IMPERSONATED

// Informational

Description

During the execution of deposit, the main logic is delegated to an internal function named _deposit. This function makes use of the parameter _user to define the address which executed the deposit. Since this function does not verify which account is executing it, any account could execute deposits on behalf of other accounts.

Code Location

src/reward/WeightedFarmingPool.sol

function deposit(
        uint256 _id,
        address _token,
        uint256 _amount,
        address _user
    ) external {
        _deposit(_id, _token, _amount, _user);

        IERC20(_token).transferFrom(msg.sender, address(this), _amount);
    }
Score
Impact: 1
Likelihood: 2
Recommendation

SOLVED: The code is not checking for msg.sender, only deposits.

8.28 UNNECESSARY CALL

// Informational

Description

The _unpausePools is not needed on closeReport since the _pausePools is only called on startVoting. Once startVoting is called, which sets the currentReport.status = VOTING_STATUS, the closeReport can not be called due to the currentReport.status != PENDING_STATUS check.

Code Location

src/voting/incidentReport/IncidentReport.sol

function closeReport(uint256 _id) external onlyOwner {
    Report storage currentReport = reports[_id];
    if (currentReport.status != PENDING_STATUS)
        revert IncidentReport__WrongStatus();

    // Must close the report before pending period ends
    if (_passedPendingPeriod(currentReport.reportTimestamp))
        revert IncidentReport__WrongPeriod();

    currentReport.status = CLOSE_STATUS;

    uint256 poolId = currentReport.poolId;

    reported[poolId] = false;

    _unpausePools(poolId);

    emit ReportClosed(_id, block.timestamp);
}
Score
Impact: 1
Likelihood: 1
Recommendation

ACKNOWLEDGED

8.29 UNNECESSARY CHECK

// Informational

Description

The settle function under OnboardProposal does check if the proposal.result is greater than zero and then reverts. However, that condition will never be reachable as the status condition will always proceed first and always revert if the settle function was ever called before.

Code Location

src/voting/onboardProposal/OnboardProposal.sol

function settle(uint256 _id) external {
    Proposal storage proposal = proposals[_id];

    if (proposal.status != VOTING_STATUS)
        revert OnboardProposal__WrongStatus();

    if (!_passedVotingPeriod(proposal.voteTimestamp))
        revert OnboardProposal__WrongPeriod();

    if (proposal.result > 0) revert OnboardProposal__AlreadySettled();

    // If reached quorum, settle the result
    if (_checkQuorum(proposal.numFor + proposal.numAgainst)) {
        uint256 res = _getVotingResult(
            proposal.numFor,
            proposal.numAgainst
        );

        // If this proposal not passed, allow new proposals for the same project
        // If it passed, not allow the same proposals
        if (res != PASS_RESULT) {
            // Allow for new proposals to be proposed for this protocol
            proposed[proposal.protocolToken] = false;
        }

        proposal.result = res;
        proposal.status = SETTLED_STATUS;

        emit ProposalSettled(_id, res);
    }
    // Else, set the result as "FAILED"
    else {
        proposal.result = FAILED_RESULT;
        proposal.status = SETTLED_STATUS;

        // Allow for new proposals to be proposed for this protocol
        proposed[proposal.protocolToken] = false;

        emit ProposalFailed(_id);
    }
}
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The check was removed

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.

// Download the full report

* Use Google Chrome for best results

** Check "Background Graphics" in the print settings if needed