Halborn Logo

Exchange Contracts - Pangolin


Prepared by:

Halborn Logo

HALBORN

Last Updated 04/26/2024

Date of Engagement by: February 28th, 2022 - March 1st, 2022

Summary

100% of all REPORTED Findings have been addressed

All findings

7

Critical

0

High

0

Medium

3

Low

1

Informational

3


1. INTRODUCTION

Pangolin engaged Halborn to conduct a security audit on their Exchange smart contracts beginning on February 28th, 2022 and ending on March 1st, 2022. The security assessment was scoped to some of the smart contracts provided in the GitHub repository pangolindex/exchange-contracts.

2. AUDIT SUMMARY

The team at Halborn was provided a week for the engagement and assigned a full-time security engineer to audit 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 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 Pangolin 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 bridge 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 (Brownie, Remix IDE)

4. SCOPE

IN-SCOPE: The security assessment was scoped to the following smart contracts:

    • RevenueDistributor.sol

    • TreasuryVester.sol

    • PNG.sol

    • Airdrop.sol

Commit ID: 30b5798c64f6aa29b92a5c2cf68cfb7ed0f7d506 Fixed Commit ID: bbbf14abf0283fa7ea3ccf07288fecdc177ed8f9

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

3

Low

1

Informational

3

Impact x Likelihood

HAL-03

HAL-01

HAL-02

HAL-04

HAL-05

HAL-06

HAL-07

Security analysisRisk levelRemediation Date
MINT FUNCTION IS MISSING MOVEDELEGATES CALLMediumSolved - 03/09/2022
LACK OF TRANSFEROWNERSHIP PATTERNMediumRisk Accepted
DOS WITH BLOCK GAS LIMITMediumSolved - 03/09/2022
MISSING ZERO ADDRESS CHECKSLowSolved - 03/09/2022
USING ++I CONSUMES LESS GAS THAN I++ IN LOOPSInformationalSolved - 03/09/2022
UNNEEDED INITIALIZATION OF UINT VARIABLES TO ZEROInformationalSolved - 03/09/2022
POSSIBLE MISUSE OF PUBLIC FUNCTIONSInformationalSolved - 03/09/2022

8. Findings & Tech Details

8.1 MINT FUNCTION IS MISSING MOVEDELEGATES CALL

// Medium

Description

In the Png contract, the function mint() does not call the _moveDelegates() function:

PNG.sol

function mint(address dst, uint rawAmount) external returns (bool) {
    require(msg.sender == minter && minter != address(0), "Png::mint: unauthorized");
    uint96 amount = safe96(rawAmount, "Png::mint: amount exceeds 96 bits");
    _mintTokens(dst, amount);
    return true;
}

PNG.sol

function _mintTokens(address dst, uint96 amount) internal {
    require(dst != address(0), "Png::_mintTokens: cannot mint to the zero address");

    totalSupply = SafeMath.add(totalSupply, uint(amount));
    balances[dst] = add96(balances[dst], amount, "Png::_mintTokens: mint amount overflows");
    emit Transfer(address(0), dst, amount);

    require(totalSupply <= maxSupply, "Png::_mintTokens: mint result exceeds max supply");
}

This causes that every time Png tokens are minted the users will have to manually call delegate() passing their own address as parameter so their voting power is correctly accounted/updated in the smart contract:

1.png
Score
Impact: 3
Likelihood: 3
Recommendation

SOLVED: Pangolin team solved the issue in the commit id bbbf14abf0283fa7ea3ccf07288fecdc177ed8f9.

8.2 LACK OF TRANSFEROWNERSHIP PATTERN

// Medium

Description

All functions that involve some kind of transfer of ownership require a single step, which is to set up the new privileged address. If this designated EOA account is not a valid account, it is very possible that the transfer of ownership will be made to an uncontrolled account, losing access to privileged functions.

PNG.sol

PNG.sol

function setMinter(address newMinter) external returns (bool) {
    require(msg.sender == admin, "Png::setMinter: unauthorized");
    emit MinterChanged(minter, newMinter);
    minter = newMinter;
    return true;
}

PNG.sol

function setAdmin(address newAdmin) external returns (bool) {
    require(msg.sender == admin, "Png::setAdmin: unauthorized");
    require(newAdmin != address(0), "Png::setAdmin: cannot make zero address the admin");
    emit AdminChanged(admin, newAdmin);
    admin = newAdmin;
    return true;
}

Airdrop.sol

Airdrop.sol

function setOwner(address owner_) external {
    require(owner_ != address(0), 'Airdrop::setOwner: invalid new owner');
    require(msg.sender == owner, 'Airdrop::setOwner: unauthorized');
    owner = owner_;
}

Airdrop.sol

    function setWhitelister(address addr) external {
        require(msg.sender == owner, 'Airdrop::setWhitelister: unauthorized');
        whitelister = addr;
    }
Score
Impact: 3
Likelihood: 3
Recommendation

RISK ACCEPTED: Pangolin team accepts this risk:

  • Airdrop events will last for a short time, and it is expected that ownership will only be transferred once from the deployer to the multisig.
  • PNG Ownership will only be transferred from the deployer to the Timelock contract (contracts/governance/Timelock.sol). This Timelock contract has the suggested two-step ownership transfer pattern, and any additional change of ownership would likely go through Timelock rather than PNG.

8.3 DOS WITH BLOCK GAS LIMIT

// Medium

Description

In the contract TreasuryVester the distribute() function is used to distribute the tokens to recipients based on their allocation:

TreasuryVester.sol

TreasuryVester.sol

function distribute() public {
    require(vestingEnabled, "TreasuryVester::distribute: vesting is not enabled");
    require(
        block.timestamp >= lastUpdate + VESTING_CLIFF,
        "TreasuryVester::distribute: it is too early to distribute"
    );
    lastUpdate = block.timestamp;

    // defines a vesting schedule that lasts for 30 months
    if (step % STEPS_TO_SLASH == 0) {
        uint slash = step / STEPS_TO_SLASH;
        if (slash < 5) {
            _vestingPercentage = _initialVestingPercentages[slash];
        } else if (slash < 12) {
            _vestingPercentage -= 20;
        } else if (slash < 20) {
            _vestingPercentage -= 15;
        } else if (slash < 30) {
            _vestingPercentage -= 10;
        } else {
           revert("TreasuryVester::distribute: vesting is over");
        }
        _vestingAmount = getVestingAmount();
    }
    step++;

    // distributes _vestingAmount of tokens to recipients based on their allocation
    for (uint i; i < _recipientsLength; i++) {
        Recipient memory recipient = _recipients[i];
        uint amount = recipient.allocation * _vestingAmount / DENOMINATOR;
        if (!recipient.isMiniChef) {
            // simply mints or transfer tokens to regular recipients
            vestedToken.mint(recipient.account, amount);
        } else {
            // calls fund rewards of minichef after minting tokens to self
            vestedToken.mint(address(this), amount);
            vestedToken.approve(recipient.account, amount);
            IMiniChefV2(recipient.account).fundRewards(amount, VESTING_CLIFF);
        }
    }
    emit TokensVested(_vestingAmount);
}

\color{black} \color{white}

As the length of recipients is not limited, in case that there are too many recipients, the block gas limit could be reached, causing miners to not respond to all distribute() calls, thus blocking the main purpose of the smart contract.

Score
Impact: 5
Likelihood: 1
Recommendation

SOLVED: Pangolin team solved the issue in the commit id 1e2f374c6728b998a22045a673be0ba14156b9c1.

8.4 MISSING ZERO ADDRESS CHECKS

// Low

Description

In the TreasuryVester and Airdrop contracts, the constructors are missing address validation. On the other hand, in some functions it is also critical to perform this validation. For example, in the TreasuryVester.setRecipients() function, if a 0 address recipient was set the distribute() call would always fail, since most tokens cannot be minted at the 0 address.

Each address should be validated and checked to be non-zero.

TreasuryVester.sol

TreasuryVester.sol

constructor(
    address newVestedToken,
    uint newStartingBalance,
    Recipient[] memory newRecipients,
    address newGuardian
) {
    require(newStartingBalance > 0, "TreasuryVester::Constructor: invalid starting balance");
    require(newGuardian != address(0), "TreasuryVester::Constructor: invalid guardian address");
    guardian = newGuardian;
    vestedToken = IPng(newVestedToken);
    startingBalance = newStartingBalance;
    setRecipients(newRecipients);
}

TreasuryVester.sol

function setRecipients(Recipient[] memory newRecipients) public onlyOwner {
    _recipientsLength = newRecipients.length;
    require(
        _recipientsLength != 0,
        "TreasuryVester::setRecipients: invalid recipient number"
    );
    uint allocations;
    for (uint i; i < _recipientsLength; ++i) {
        Recipient memory recipient = newRecipients[i];
        require(
            recipient.account != address(0),
            "TreasuryVester::setRecipients: invalid recipient address"
        );
        require(
            recipient.allocation != 0,
            "TreasuryVester::setRecipients: invalid recipient allocation"
        );
        _recipients[i] = recipient;
        allocations += recipient.allocation;
    }
    require(
        allocations == DENOMINATOR,
        "TreasuryVester::setRecipients: invalid total allocation"
    );
    emit RecipientsChanged(newRecipients);
}

Airdrop.sol

Airdrop.sol

constructor(
    uint supply_,
    address png_,
    address owner_,
    address remainderDestination_
) {
    airdropSupply = supply_;
    png = png_;
    owner = owner_;
    remainderDestination = remainderDestination_;
}

Airdrop.sol

function setRemainderDestination(address remainderDestination_) external {
    require(
        msg.sender == owner,
        'Airdrop::setRemainderDestination: unauthorized'
    );
    remainderDestination = remainderDestination_;
}

PNG.sol

PNG.sol

function setMinter(address newMinter) external returns (bool) {
    require(msg.sender == admin, "Png::setMinter: unauthorized");
    emit MinterChanged(minter, newMinter);
    minter = newMinter;
    return true;
}
Score
Impact: 2
Likelihood: 3
Recommendation

SOLVED: Pangolin team solved the issue in the commit id 8810acc38f27bf9de25a492ffe41d3c54c657c5f.

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

// Informational

Description

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

Code Location

TreasuryVester.sol

  • Line 149: for (uint i; i < _recipientsLength; i++) {

Airdrop.sol

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

For example, based in 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: Pangolin team solved the issue in the commit id 4aa439b03eac34bebf40c6b72e9afeb2d0fa2333.

8.6 UNNEEDED INITIALIZATION OF UINT VARIABLES TO ZERO

// Informational

Description

Since i is an uint, it is already initialized to 0. uint i = 0 reassigns the 0 to i which wastes gas. The same applies to the lower state variable shown below.

Code Location

Airdrop.sol

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

PNG.sol

  • Line 373: uint32 lower = 0;
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: Pangolin team solved the issue in the commit id 87781621d7294afeafd7a33916b4016dc1f3ed34.

8.7 POSSIBLE MISUSE OF PUBLIC FUNCTIONS

// Informational

Description

In the TreasuryVester and PNG contracts, there are some functions marked as public that are never called directly within the contract itself or in any of their descendants:

TreasuryVester.sol

  • distribute() (TreasuryVester.sol#122-163)

PNG.sol

  • delegate() (PNG.sol#314-316)
  • delegateBySig() (PNG.sol#327-336)
  • getPriorVotes() (PNG.sol#355-387)
Score
Impact: 1
Likelihood: 1
Recommendation

SOLVED: Pangolin team solved the issue in the commit id f1fff4b75db0fe70450c55234418bd445834029d.

9. Automated Testing

STATIC ANALYSIS REPORT

Description

Halborn used automated testing techniques to enhance the coverage of certain areas of the scoped contracts. 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, Slither was run on the all-scoped 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.

Slither results

RevenueDistributor.sol

TreasuryVester.sol

Airdrop.sol

PNG.sol

  • No major issues found by Slither.

AUTOMATED SECURITY SCAN

Description

Halborn used automated security scanners to assist with detection of well-known security issues, and to identify low-hanging fruits 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 all the contracts and sent the compiled results to the analyzers to locate any vulnerabilities.

MythX results

RevenueDistributor.sol

TreasuryVester.sol

Airdrop.sol

PNG.sol

  • block.number is not used as a source of randomness.

  • The pragmas are set in the hardhat.config.js file.

  • Integer Overflows and Underflows flagged by MythX are false positives, as the contracts are using Solidity ^0.8.0 version. After the Solidity version 0.8.0 Arithmetic operations revert to underflow and overflow by default.

  • Assert violations are false positives.

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.