Halborn Logo

Kapital-DAO - PlayGround Labs


Prepared by:

Halborn Logo

HALBORN

Last Updated 04/26/2024

Date of Engagement by: March 28th, 2022 - April 14th, 2022

Summary

100% of all REPORTED Findings have been addressed

All findings

15

Critical

0

High

0

Medium

1

Low

8

Informational

6


1. INTRODUCTION

PlayGround Labs engaged Halborn to conduct a security audit on their smart contracts beginning on 2022-03-28 and ending on 2022-04-14. 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 a full-time security engineer to audit the security of the smart contract. 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 audit is to:

    • Ensure that the functions of the Kapital-DAO contract work as intended.

    • Identify potential security issues within the smart contracts.

In summary, Halborn identified some security risks that were mostly addressed by the PlayGround Labs 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 Kapital-DAO contract solidity code and can quickly identify items that do not follow security best practices. The following phases and associated tools were used throughout the term of 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 hotspots or bugs. (MythX).

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

    • Testnet deployment (Remix IDE).

4. SCOPE

IN-SCOPE : Kapital-DAO-Halborn-Audit IN-SCOPE COMMIT : 53d86b8933c63105112818e15705ea0d77954c47 OUT-OF-SCOPE : External libraries, test-helpers and economics attacks. FIXED-COMMIT : 35fb92524b83ff8197a7127f7c9819317ac7ea92

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

0

High

0

Medium

1

Low

8

Informational

6

Impact x Likelihood

HAL-03

HAL-02

HAL-07

HAL-09

HAL-05

HAL-01

HAL-10

HAL-11

HAL-08

HAL-04

HAL-06

HAL-12

HAL-13

HAL-14

HAL-15

Security analysisRisk levelRemediation Date
PROPOSAL LACKS MULTIPLE IMPORTANT LOGIC CHECKSMediumSolved - 05/17/2022
UNCHECKED TRANSFERLowSolved - 05/17/2022
MISSING RE-ENTRANCY PROTECTIONLowSolved - 05/17/2022
UNINITIALIZED PROPOSE COOLDOWNLowSolved - 05/17/2022
EXTERNAL FUNCTION CALLS WITHIN LOOPLowSolved - 05/17/2022
IGNORE RETURN VALUESLowRisk Accepted
WEAK GOVERNANCE OWNERSHIP TRANSFERLowSolved - 05/17/2022
MISSING LEGITIMACY OF VOTE CASTERLowSolved - 05/17/2022
USAGE OF BLOCK-TIMESTAMPLowRisk Accepted
MISSING ZERO-ADDRESS CHECKInformationalSolved - 05/17/2022
DIVIDE BEFORE MULTIPLYInformationalSolved - 05/17/2022
POSSIBLE MISUSE OF PUBLIC FUNCTIONSInformationalSolved - 05/17/2022
EXPONENTIATION IS MORE COSTLYInformationalSolved - 05/17/2022
USING ++I CONSUMES LESS GAS THAN I++ IN LOOPSInformationalSolved - 05/17/2022
CACHE ARRAY LENGTH IN FOR LOOPS CAN SAVE GASInformationalSolved - 05/17/2022

8. Findings & Tech Details

8.1 PROPOSAL LACKS MULTIPLE IMPORTANT LOGIC CHECKS

// Medium

Description

In Governance.sol contracts, the propose function lacks the multiple logical checks listed below.

  • Missing to check if the length of the provided targets is equal to values, data length or not. Thus allowing the incorrect submission of the proposals.
  • No maximum length of targets is defined, which leads to too many actions in a proposal.
  • No logic is implemented in the proposer if the proposer already has an active or pending proposal in the system before adding a new proposal.
  • If a description argument is missing from the proposal, it should be used to log the description of the proposal.
  • Insufficient event emitting, only emitting msg.sender, latestProposalID, and timestamp instead, all essential arguments supplied to the function.

Code Location

Governance.sol

    function propose(
        address[] memory targets,
        uint256[] memory values,
        bytes[] memory data,
        bool[] memory isDelegateCall,
        WeightSources memory weightSources
    ) external returns (uint256) {
        // Ensure msg.sender has enough voting weight
        (uint256 weightKAP, uint256 weightLP) = getWeights(weightSources);
        require(
            weightKAP + convertLP(weightLP) >= threshold,
            "Governance: Insufficient weight"
        );
        // Make sure haven't already created a proposal within cooldown period, Propose CoolDown
        uint256 timestamp = block.timestamp;
        require(
            timestamp >= latestPropose[msg.sender] + proposeCooldown,
            "Governance: Propose Cooldown"
        );

        // Add new proposal
        latestProposalID++;
        Proposal storage proposal = proposals[latestProposalID];
        proposal.transactParamsHash = getTransactParamsHash(
            targets,
            values,
            data,
            isDelegateCall
        );
        proposal.proposeTime = SafeCast.toUint64(timestamp);
        proposal.priceCumulativeLast = _cumulative();

        // Update msg.sender's latestProposal
        latestPropose[msg.sender] = timestamp;

        emit ProposalCreated(msg.sender, latestProposalID, timestamp);
        return latestProposalID;
    }
Score
Impact: 3
Likelihood: 4
Recommendation

SOLVED: The Playground labs team solved the above issue in commit 35fb92524b83ff8197a7127f7c9819317ac7ea92. As a result, the team added additional logical checks to the propose function, set proposeCooldown to 3 days (instead of 0 before) to protect against proposal spam, and added a proposal description field and relevant information to the event emitted.

Furthermore, the team claims that the use of proposeCooldown is sufficient because fast origin-address change is prohibited by the staking requirement and the new delegation-change procedure. Also, the team does not want to limit the target length, as different function calls will use significantly different amounts of gas. Finally, the team claims that the front-end UI will include features such as estimated gas usage and estimated transaction success or failure.

Updated propose

    function propose(
        address[] memory targets,
        uint256[] memory values,
        bytes[] memory data,
        string memory description,
        WeightSources memory weightSources
    ) external returns (uint256) {
        // Ensure msg.sender has enough voting weight
        (uint256 weightKAP, uint256 weightLP) = getWeights(weightSources);
        require(
            weightKAP + convertLP(weightLP) >= threshold,
            "Governance: Insufficient weight"
        );
        // Make sure haven't already created a proposal within cooldown period, Propose CoolDown
        uint256 timestamp = block.timestamp;
        require(
            timestamp >= latestPropose[msg.sender] + proposeCooldown,
            "Governance: Propose Cooldown"
        );
        // Logic check on proposal data
        uint256 targetsLength = targets.length;
        require(targetsLength > 0, "Governance: Invalid data");
        require(targetsLength == values.length, "Governance: Invalid data");
        require(targetsLength == data.length, "Governance: Invalid data");
        require(!(bytes(description).length == 0), "Governance: No description");

        // Add new proposal
        latestProposalID++;
        Proposal storage proposal = proposals[latestProposalID];
        proposal.transactParamsHash = getTransactParamsHash(
            targets,
            values,
            data
        );
        proposal.proposeTime = SafeCast.toUint64(timestamp);
        proposal.priceCumulativeLast = _cumulative();

        // Update msg.sender's latestProposal
        latestPropose[msg.sender] = timestamp;

        emit ProposalCreated(
            msg.sender, 
            latestProposalID, 
            timestamp,
            targets,
            values,
            data,
            description);
        return latestProposalID;
    }

8.2 UNCHECKED TRANSFER

// Low

Description

In RewardsLocker.sol, Staking.sol, Vesting.sol contracts, return values from external transfer calls are not checked. It should be noted that the token is not reverted on failure and returns false. If one of these tokens is used, a deposit would not be reverted if the transfer failed and an attacker could deposit tokens for free.

Code Location

RewardsLocker.sol

    function collectRewards(uint256 lockAgreementId) external {
        LockAgreement storage lockAgreement = lockAgreements[msg.sender][
            lockAgreementId
        ];
        // make sure the beneficiary waits before collecting the rewards
        require(
            block.timestamp >= lockAgreement.availableTimestamp,
            "Collection too early"
        );
        // make sure the beneficiary has not already collected the rewards
        require(!lockAgreement.collected, "Already collected");
        // set `collected` to true, so the beneficiary cannot withdraw again
        lockAgreement.collected = true;
        // update voting weight
        weightKAP[msg.sender] -= lockAgreement.amount;
        // transfer `amount` KAP to the beneficiary
        kapToken.transfer(msg.sender, lockAgreement.amount);
    }

RewardsLocker.sol

    function transferKap(address to, uint256 amount) external {
        bool senderIsGovernance = (msg.sender ==
            governanceRegistry.governance());
        bool authorized = senderIsGovernance || hasRole(KAP_SAVER, msg.sender);
        require(authorized, "Access denied");
        kapToken.transfer(to, amount);
    }

Staking.sol

    function _transferFromAndReturnAddAmount(
        address staker,
        uint256 inputAmount
    ) internal returns (uint256) {
        uint256 previousBalance = asset.balanceOf(address(this));
        asset.transferFrom(staker, address(this), inputAmount);
        return asset.balanceOf(address(this)) - previousBalance;
    }

Staking.sol

    function unstake(uint256 removeAmount, uint256 stakingAgreementId)
        external
    {
        // update {multipliedTotalRewardsPerWeightLastSync} if necessary
        if (block.timestamp > lastSync) {
            sync();
        }

        Staker storage staker = stakers[msg.sender];
        StakingAgreement storage stakingAgreement = staker.stakingAgreements[
            stakingAgreementId
        ];

        require(
            (removeAmount > 0) && (removeAmount <= stakingAgreement.amount),
            "Staking: Invalid amount"
        );
        require(
            block.timestamp >= stakingAgreement.lockEnd,
            "Staking: Too early"
        );

        asset.transfer(msg.sender, removeAmount);
        staker.totalAmount -= Math.toUint112(removeAmount);
        stakingAgreement.amount -= Math.toUint112(removeAmount);

        uint256 unstakeWeight = _calculateStakeWeight(
            stakingAgreement.lockEnd - stakingAgreement.lockStart,
            removeAmount
        );
        totalStakingWeight -= unstakeWeight;
        staker.totalWeight -= Math.toUint136(unstakeWeight);
        // looking at {claimRewards}, the above line has instantaneously
        // decreased `claimedRewards` by
        // `(unstakeWeight * multipliedTotalRewardsPerWeightLastSync) / REWARDS_PER_WEIGHT_MULTIPLIER`.
        // we therefore need to give this amount back to the user in the form
        // of adding to `staker.addRewards`.
        staker.addRewards +=
            (unstakeWeight * multipliedTotalRewardsPerWeightLastSync) /
            REWARDS_PER_WEIGHT_MULTIPLIER;

        emit Unstake(msg.sender, removeAmount);
    }

Vesting.sol

    function collect(uint256 vestingAgreementId) external {
        VestingAgreement storage vestingAgreement = vestingAgreements[
            msg.sender
        ][vestingAgreementId];
        // make sure the vesting period has started
        require(
            block.timestamp > vestingAgreement.vestStart,
            "Vesting not started"
        );
        // calculate portion of `totalAmount` currently unlocked
        uint256 amountUnlocked;
        // if {VESTING_PERIOD} has passed, the entire `totalAmount` is unlocked
        if (block.timestamp >= (vestingAgreement.vestStart + VESTING_PERIOD)) {
            amountUnlocked = vestingAgreement.totalAmount;
        }
        // otherwise, we find the portion of `totalAmount` currently available
        else {
            amountUnlocked =
                (vestingAgreement.totalAmount *
                    (block.timestamp - vestingAgreement.vestStart)) /
                VESTING_PERIOD;
        }
        // make sure some of `amountUnlocked` has not yet been collected
        require(
            amountUnlocked > vestingAgreement.amountCollected,
            "Collection limit reached"
        );
        // calculate amount available for collection
        uint256 collectionAmount = amountUnlocked -
            vestingAgreement.amountCollected;
        // update balance
        balances[msg.sender] -= collectionAmount;
        // update voting weight
        weightKAP[delegates[msg.sender]] -= collectionAmount;
        // update vesting agreement to indicate a collection has been performed
        vestingAgreement.amountCollected += SafeCast.toUint96(collectionAmount);
        // transfer KAP tokens available for collection
        kapToken.transfer(msg.sender, collectionAmount);
    }

Vesting.sol

    function createVestingAgreement(
        address beneficiary,
        uint256 vestStart,
        uint256 amount
    ) external onlyRole(VESTING_CREATOR) {
        // caller provides KAP for the vesting agreement
        kapToken.transferFrom(msg.sender, address(this), amount);
        // update balance
        balances[beneficiary] += amount;
        // update delegate voting weight
        weightKAP[delegates[beneficiary]] += amount;
        // push a new vesting agreement for the beneficiary
        vestingAgreements[beneficiary].push(
            VestingAgreement({
                vestStart: SafeCast.toUint64(vestStart),
                totalAmount: SafeCast.toUint96(amount),
                amountCollected: SafeCast.toUint96(0)
            })
        );
    }
Score
Impact: 3
Likelihood: 1
Recommendation

SOLVED: The Playground labs team solved the above issue in commit 35fb92524b83ff8197a7127f7c9819317ac7ea92. As a result, the team now uses OpenZeppelin’s SafeERC20 to perform the transfers. However, the team claims that the tokens used in the Kapital DAO (KAP token and Uniswap V2 Pair) revert on failure, and otherwise return a hardcoded value of true in the above code.

8.3 MISSING RE-ENTRANCY PROTECTION

// Low

Description

One of the contracts included in the scope of Playground Labs Kapital-DAO was identified as missing a nonReentrant guard. In this function, persistent state read/write after an external call is identified, making it vulnerable to a Reentrancy attack.

  • The Staking.sol contract function unstake is missing nonReentrant guard.

To protect against cross-function reentrancy attacks, it may be necessary to use a mutex. By using this lock, an attacker can no longer exploit the function with a recursive call. OpenZeppelin has its own mutex implementation called ReentrancyGuard which provides a modifier to any function called “nonReentrant” that guards the function with a mutex against the Reentrancy attacks.

Code Location

Staking.sol

    function unstake(uint256 removeAmount, uint256 stakingAgreementId)
        external
    {
        // update {multipliedTotalRewardsPerWeightLastSync} if necessary
        if (block.timestamp > lastSync) {
            sync();
        }

        Staker storage staker = stakers[msg.sender];
        StakingAgreement storage stakingAgreement = staker.stakingAgreements[
            stakingAgreementId
        ];

        require(
            (removeAmount > 0) && (removeAmount <= stakingAgreement.amount),
            "Staking: Invalid amount"
        );
        require(
            block.timestamp >= stakingAgreement.lockEnd,
            "Staking: Too early"
        );

        asset.transfer(msg.sender, removeAmount);
        staker.totalAmount -= Math.toUint112(removeAmount);
        stakingAgreement.amount -= Math.toUint112(removeAmount);

        uint256 unstakeWeight = _calculateStakeWeight(
            stakingAgreement.lockEnd - stakingAgreement.lockStart,
            removeAmount
        );
        totalStakingWeight -= unstakeWeight;
        staker.totalWeight -= Math.toUint136(unstakeWeight);
        // looking at {claimRewards}, the above line has instantaneously
        // decreased `claimedRewards` by
        // `(unstakeWeight * multipliedTotalRewardsPerWeightLastSync) / REWARDS_PER_WEIGHT_MULTIPLIER`.
        // we therefore need to give this amount back to the user in the form
        // of adding to `staker.addRewards`.
        staker.addRewards +=
            (unstakeWeight * multipliedTotalRewardsPerWeightLastSync) /
            REWARDS_PER_WEIGHT_MULTIPLIER;

        emit Unstake(msg.sender, removeAmount);
    }
Score
Impact: 4
Likelihood: 1
Recommendation

SOLVED: The Playground labs team solved the above issue in commit 35fb92524b83ff8197a7127f7c9819317ac7ea92. As a result, the code now follows the checks-effects-interactions pattern. However, the team claims that the tokens used in Kapital DAO (KAP token and Uniswap V2 Pair) are not vulnerable to reentrancy into above code.

8.4 UNINITIALIZED PROPOSE COOLDOWN

// Low

Description

In the Governance.sol contract, the proposeCooldown state variable is not initialized, it defaults to 0 value, and the variable is considered in the other calculation progresses, i.e., require( timestamp >= latestPropose[msg.sender] + proposeCooldown, "Governance: Propose Cooldown"); in the propose function. If a variable must be initialized to zero, explicitly set it to zero to improve code readability.

Code Location

Governance.sol

 uint24 public proposeCooldown;

Governance.sol

    function propose(
        address[] memory targets,
        uint256[] memory values,
        bytes[] memory data,
        bool[] memory isDelegateCall,
        WeightSources memory weightSources
    ) external returns (uint256) {
        // Ensure msg.sender has enough voting weight
        (uint256 weightKAP, uint256 weightLP) = getWeights(weightSources);
        require(
            weightKAP + convertLP(weightLP) >= threshold,
            "Governance: Insufficient weight"
        );
        // Make sure haven't already created a proposal within cooldown period, Propose CoolDown
        uint256 timestamp = block.timestamp;
        require(
            timestamp >= latestPropose[msg.sender] + proposeCooldown,
            "Governance: Propose Cooldown"
        );

        // Add new proposal
Score
Impact: 2
Likelihood: 3
Recommendation

SOLVED: The Playground labs team solved the above issue in the commit 35fb92524b83ff8197a7127f7c9819317ac7ea92. As a result, the team initializes proposeCooldown to 3 days (same as the voting period) so that the voting process is never overwhelmed by repeated proposals.

Constructor Initialize propseCooldown

        // implicitly set propseCooldown to be the voting period
        proposeCooldown = _waitTo.endVote - _waitTo.startVote;

8.5 EXTERNAL FUNCTION CALLS WITHIN LOOP

// Low

Description

External calls within a loop increase Gas usage or can lead to a denial of service attack. In the Governance.sol contract functions discovered there is a for loop on the i variable that iterates through the weightSources.kapSources.length and weightSources.lpSources.length array length, and this loop has external calls within a loop. If this integer evaluates to extremely large numbers, this can cause a DoS.

Code Location

Governance.sol

    function getWeights(WeightSources memory weightSources)
        public
        view
        returns (uint256 weightKAP, uint256 weightLP)
    {
        // Calc KAP voting weight
        for (uint256 i = 0; i < weightSources.kapSources.length; i++) {
            if (weightSources.kapSources[i]) {
                weightKAP += IKAPSource(weightSourcesKAP[i]).weightKAP(
                    msg.sender
                );
            }
        }
        // Calc LP voting weight
        for (uint256 i = 0; i < weightSources.lpSources.length; i++) {
            if (weightSources.lpSources[i]) {
                weightLP += ILPSource(weightSourcesLP[i]).weightLP(msg.sender);
            }
        }
    }
Score
Impact: 3
Likelihood: 2
Recommendation

SOLVED: The Playground labs team solved the above issue in commit 35fb92524b83ff8197a7127f7c9819317ac7ea92. The team modifies the WeightSources and getWeights structs; as a result, the user can now choose specific array indices in weightSourcesKAP and weightSourcesLP. Furthermore, the team added that in the unlikely case that weightSourcesKAP and weightSourcesLP are very long, the user can choose a relatively small number of WeightSources to loop through.

8.6 IGNORE RETURN VALUES

// Low

Description

The return value of an external call is not stored in a local or state variable. In the Transactor.sol contract, there is an instance where an external method is called, and the return value is ignored.

Code Location

Transactor.sol

    function _transact(
        address[] memory targets,
        uint256[] memory values,
        bytes[] memory data,
        bool[] memory isDelegateCall
    ) internal {
        require(targets.length > 0, "Invalid array length");
        require(targets.length == values.length, "Array length mismatch");
        require(targets.length == data.length, "Array length mismatch");
        require(
            targets.length == isDelegateCall.length,
            "Array length mismatch"
        );

        for (uint256 i = 0; i < targets.length; i++) {
            if (isDelegateCall[i]) {
                Address.functionDelegateCall(targets[i], data[i]);
            } else {
                Address.functionCallWithValue(targets[i], data[i], values[i]);
            }
        }
    }
Score
Impact: 2
Likelihood: 3
Recommendation

RISK ACCEPTED: The Playground labs team accept the risk of this finding. Furthermore, the team claims that the team does not have any function-specific return value due to not having information about which functions can be called in advance. However, the team added that the Address contract confirms that success == true and reverts otherwise.

8.7 WEAK GOVERNANCE OWNERSHIP TRANSFER

// Low

Description

The supplied newGovernance is not being validated before the transfer of ownership, even though the governance access control is in place. If configured incorrectly, it will lock all governance functionality.

GovernanceRegistry.sol

    function changeGovernance(address newGovernance) external {
        require(msg.sender == governance, "Only governance");
        governance = newGovernance;
    }
Score
Impact: 3
Likelihood: 1
Recommendation

SOLVED: The Playground labs team solved the above issue in commit 35fb92524b83ff8197a7127f7c9819317ac7ea92. As a result, the team added a two-step governance change process. Furthermore, the team also added additional validation of the new governance address.

Updated changeGovernance

    function changeGovernance(address newGovernance) external {
        require(msg.sender == governance, "Only governance");
        require(
            newGovernance != address(0) && newGovernance != governance,
            "Invalid governance"
        );

        IGovernance _newGovernance = IGovernance(newGovernance);
        require(_newGovernance.votingPeriod() > 0);

        appointedNewGovernance = newGovernance;
    }

8.8 MISSING LEGITIMACY OF VOTE CASTER

// Low

Description

In the Governance.sol contract, it is noted that the vote function lacks the legitimacy of msg.sender if it is a valid voter. As a result, an unknown EOA or contract may cast a vote; therefore, the result of the vote can be manipulated, although it is unlikely since there are no benefits for said voter.

Code Location

Governance.sol

    function vote(
        uint256 proposalID,
        bool yay,
        WeightSources memory weightSources
    ) external {
        Proposal storage proposal = proposals[proposalID];

        // Enforce voting window, Voting Window
        require(_checkVoteWindow(proposal), "Governance: Voting window");

        // Mark msg.sender as having voted, Already Voted
        require(!proposal.hasVoted[msg.sender], "Governance: Already voted");
        proposal.hasVoted[msg.sender] = true;

        (uint256 weightKAP, uint256 weightLP) = getWeights(weightSources);

        // Add to vote counts
        require(weightLP <= type(uint112).max, "Governance: uint112(weightLP)");
        if (yay) {
            proposal.yaysKAP += SafeCast.toUint96(weightKAP);
            proposal.yaysLP += uint112(weightLP);
        } else {
            proposal.naysKAP += SafeCast.toUint96(weightKAP);
            proposal.naysLP += uint112(weightLP);
        }

        // Record that `msg.sender` last voted at this timestamp
        lastVoted[msg.sender] = block.timestamp;

        emit Voted(msg.sender, proposalID, yay, weightKAP, weightLP);
    }
Score
Impact: 2
Likelihood: 2
Recommendation

SOLVED: The Playground labs team solved the above issue in commit 35fb92524b83ff8197a7127f7c9819317ac7ea92. As a solution, the team a descriptive requirement require(weightKAP > 0 || weightLP > 0, "Governance: Zero weight") to prevent zero-weight accounts from voting unnecessarily. Furthermore, the team claims that the team allows contracts, particularly multisig wallets, to stake and vote. And the only requirement to propose is to meet the threshold that the team has currently initialized at 0.65% of the total KAP supply, and there is no whitelist of proposals.

8.9 USAGE OF BLOCK-TIMESTAMP

// Low

Description

During a manual review, the use of block.timestamp in some Playground Labs Kapital-DAO contracts were observed. Contract developers should note that this does not mean the current time. Miners can influence the value of block.timestamp to some degree, so testers should be warned that this may come at some risk if miners collude in time manipulation to influence price oracles. It is important to follow the 15-second rule, i.e., if the contract is not based on an interval of less than 15-seconds, it is fine to use block.timestamp.

Code Location

Vesting.sol

#101: block.timestamp > vestingAgreement.vestStart,
#107: if (block.timestamp >= (vestingAgreement.vestStart + VESTING_PERIOD)) {
#114: (block.timestamp - vestingAgreement.vestStart)) /
#148: block.timestamp > oldDelegateLastVoted + votingPeriod,

Governance.sol

#152: uint256 timestamp = block.timestamp;
#187: uint256 timeElapsed = block.timestamp - proposal.proposeTime;
#224: lastVoted[msg.sender] = block.timestamp;
#239: uint256 timeElapsed = block.timestamp - proposal.proposeTime;
#323: emit ProposalExecuted(msg.sender, proposalID, block.timestamp);
#350: (block.timestamp - proposal.proposeTime);

Staking.sol

#148: (block.timestamp <= (lastStaked[voter] + votingPeriod))
#161: if (block.timestamp <= rewardsStart) {
#182: if (block.timestamp > lastSync) {
#185: lastStaked[msg.sender] = block.timestamp;
#230: if (block.timestamp > lastSync) {
#244: block.timestamp >= stakingAgreement.lockEnd,
#288: if (block.timestamp > lastSync) {
#295: if (block.timestamp <= rewardsStart) {
#320: require(block.timestamp > lastSync, "Staking: Already syncd");
#323: if (block.timestamp > rewardsRules[rewardsRuleIndex].timeEnd) {
#419: if (block.timestamp <= rewardsRule.timeEnd) {
#470: uint256 lockStart = block.timestamp <= rewardsStart

RewardsLocker.sol

#97: block.timestamp >= lockAgreement.availableTimestamp,
Score
Impact: 3
Likelihood: 1
Recommendation

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

8.10 MISSING ZERO-ADDRESS CHECK

// Informational

Description

Several instances found where the address validation is missing. A zero address validation failure was found when assigning user-supplied address values to state variables directly.

  • In contract GovernanceRegistry.sol:

    • changeGovernance lacks a zero address check on newGovernance.
    • constructor lacks a zero address check on initialGovernance.
  • In contract MultisigFund.sol:

    • constructor lacks a zero address check on _multisig.
  • In contract RewardsLocker.sol:

    • constructor lacks a zero address check on _kapStakingPool, _kapEthStakingPool.
  • In contract Vesting.sol:

    • constructor lacks a zero address check on _teamMultisig.
  • changeGovernance lacks a zero address check on newGovernance.

  • constructor lacks a zero address check on initialGovernance.

  • constructor lacks a zero address check on _multisig.

  • constructor lacks a zero address check on _kapStakingPool, _kapEthStakingPool.

  • constructor lacks a zero address check on _teamMultisig.

Code Location

Zero Address Validation is missing before assigning addresses to these state variables.

GovernanceRegistry.sol

 governance = initialGovernance (#20)
 governance = newGovernance (#30)

MultisigFund.sol

 multisig = _multisig (#18)

RewardsLocker.sol

 kapStakingPool = _kapStakingPool (#34)
 kapEthStakingPool = _kapEthStakingPool (#35)

Vesting.sol

 teamMultisig = _teamMultisig (#32)
Score
Impact: 2
Likelihood: 1
Recommendation

SOLVED: The Playground labs team solved the above issue in commit 35fb92524b83ff8197a7127f7c9819317ac7ea92. As a result, the team added zero address checks for the above code.

8.11 DIVIDE BEFORE MULTIPLY

// Informational

Description

Solidity's integer division could be truncated. As a result, precision loss can sometimes be avoided by multiplying before dividing, although the manual implementation of the precision/decimal calculation is taken care of by the developer. In the set of smart contracts, there is an instance where the division is done before the multiplication.

Code Location

Governance.sol

        uint256 priceETH = (_cumulative() - proposal.priceCumulativeLast) /
            (block.timestamp - proposal.proposeTime);
        uint256 reserveKAP = Math.sqrt(k * priceETH);
Score
Impact: 2
Likelihood: 1
Recommendation

SOLVED: The Playground labs team solved the above issue in commit 35fb92524b83ff8197a7127f7c9819317ac7ea92. As a result, the contract now performs multiplication before division.

8.12 POSSIBLE MISUSE OF PUBLIC FUNCTIONS

// Informational

Description

In public functions, array arguments are immediately copied into memory, while external functions can read directly from calldata. Reading calldata is cheaper than allocating memory. Public functions need to write arguments to memory because public functions can be called internally. Internal calls are passed internally via pointers to memory. Thus, the function expects its arguments to be located in memory when the compiler generates the code for an internal function. Also, methods do not necessarily have to be public if they are only called within the contract; in such case, they should be marked as internal.

Code Location

Below are the smart contracts and their corresponding functions affected: \textbf{\underline{Staking.sol}:} getStakingAgreementsLength()

Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Playground labs team solved the above issue in commit 35fb92524b83ff8197a7127f7c9819317ac7ea92. As a result, the team has changed getStakingAgreementsLength() to external.

8.13 EXPONENTIATION IS MORE COSTLY

// Informational

Description

Exponentiation is more expensive than the following implementation.

1e18 is more cheap than 10**18.

Code Location

Token.sol

    constructor() ERC20("Kapital DAO Token", "KAP") {
        uint256 uiKapTotalSupply = 10**9;
        _mint(msg.sender, uiKapTotalSupply * (10**decimals()));
    }
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Playground labs team solved the above issue in commit 35fb92524b83ff8197a7127f7c9819317ac7ea92. As a result, the team replaced 10**9 with 1e9.

8.14 USING ++I CONSUMES LESS GAS THAN I++ IN LOOPS

// Informational

Description

In the loop below, the variable i is incremented using i++. It is known that, in loops, using ++i costs less gas per iteration than i++.

Code Location

Governance.sol

    function getWeights(WeightSources memory weightSources)
        public
        view
        returns (uint256 weightKAP, uint256 weightLP)
    {
        // Calc KAP voting weight
        for (uint256 i = 0; i < weightSources.kapSources.length; i++) {
            if (weightSources.kapSources[i]) {
                weightKAP += IKAPSource(weightSourcesKAP[i]).weightKAP(
                    msg.sender
                );
            }
        }
        // Calc LP voting weight
        for (uint256 i = 0; i < weightSources.lpSources.length; i++) {
            if (weightSources.lpSources[i]) {
                weightLP += ILPSource(weightSourcesLP[i]).weightLP(msg.sender);
            }
        }
    }

Transactor.sol

    function _transact(
        address[] memory targets,
        uint256[] memory values,
        bytes[] memory data,
        bool[] memory isDelegateCall
    ) internal {
        require(targets.length > 0, "Invalid array length");
        require(targets.length == values.length, "Array length mismatch");
        require(targets.length == data.length, "Array length mismatch");
        require(
            targets.length == isDelegateCall.length,
            "Array length mismatch"
        );

        for (uint256 i = 0; i < targets.length; i++) {
            if (isDelegateCall[i]) {
                Address.functionDelegateCall(targets[i], data[i]);
            } else {
                Address.functionCallWithValue(targets[i], data[i], values[i]);
            }
        }
    }

For example, based on the following test contract:

Test.sol

//SPDX-License-Identifier: MIT
pragma solidity 0.8.9;

contract test {
    function postiincrement(uint256 iterations) public {
        for (uint256 i = 0; i < iterations; i++) {
        }
    }
    function preiincrement(uint256 iterations) public {
        for (uint256 i = 0; i < iterations; ++i) {
        }
    }
}

We can see the difference in the gas costs: posti_prei.png

Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Playground labs team solved the above issue in commit 35fb92524b83ff8197a7127f7c9819317ac7ea92. As a result, for loops now uses ++i.

8.15 CACHE ARRAY LENGTH IN FOR LOOPS CAN SAVE GAS

// Informational

Description

Reading the length of the array at each iteration of the loop requires 6 gas (3 for mload and 3 to place memory_offset) onto the stack. Caching the length of the array on the stack saves about 3 gas per iteration.

Code Location

Governance.sol

    function getWeights(WeightSources memory weightSources)
        public
        view
        returns (uint256 weightKAP, uint256 weightLP)
    {
        // Calc KAP voting weight
        for (uint256 i = 0; i < weightSources.kapSources.length; i++) {
            if (weightSources.kapSources[i]) {
                weightKAP += IKAPSource(weightSourcesKAP[i]).weightKAP(
                    msg.sender
                );
            }
        }
        // Calc LP voting weight
        for (uint256 i = 0; i < weightSources.lpSources.length; i++) {
            if (weightSources.lpSources[i]) {
                weightLP += ILPSource(weightSourcesLP[i]).weightLP(msg.sender);
            }
        }
    }

Transactor.sol

    function _transact(
        address[] memory targets,
        uint256[] memory values,
        bytes[] memory data,
        bool[] memory isDelegateCall
    ) internal {
        require(targets.length > 0, "Invalid array length");
        require(targets.length == values.length, "Array length mismatch");
        require(targets.length == data.length, "Array length mismatch");
        require(
            targets.length == isDelegateCall.length,
            "Array length mismatch"
        );

        for (uint256 i = 0; i < targets.length; i++) {
            if (isDelegateCall[i]) {
                Address.functionDelegateCall(targets[i], data[i]);
            } else {
                Address.functionCallWithValue(targets[i], data[i], values[i]);
            }
        }
    }
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: The Playground labs team solved the above issue in commit 35fb92524b83ff8197a7127f7c9819317ac7ea92. As a result, the contract cache the array length.

9. Automated Testing

STATIC ANALYSIS REPORT

Description

Halborn used automated testing techniques to enhance coverage of certain areas of the scoped contract. Among the tools used was Slither, a Solidity static analysis framework. After Halborn verified all the contracts in the repository and was able to compile them correctly into their abi and binary formats. 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.

Results

slith-1.jpgslith-5.jpg

\newpage

slith-2.jpgslith-3.jpgslith-4.jpg

Based on the test results, some findings found by these tools were considered false positives, while some of these findings were real security concerns. All relevant findings were reviewed by the auditors and relevant findings were addressed in the report as security concerns.

AUTOMATED SECURITY SCAN

Description

Halborn used automated security scanners to assist with detection of well-known security issues, and to identify low-hanging fruit on the targets for this engagement. Among the tools used was MythX, a security analysis service for Ethereum smart contracts. MythX performed a scan on the testers machine and sent the compiled results to the analyzers to locate any vulnerabilities. Only security-related findings are shown below.

Results

No relevant valid findings were found.

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.

© Halborn 2024. All rights reserved.