GithubHelp home page GithubHelp logo

2024-01-telcoin-judging's Introduction

Issue H-1: StakingRewardsManager::topUp(...) Misallocates Funds to StakingRewards Contracts

Source: #16

Found by

Aamirusmani1552, Arz, DMoore, IvanFitro, VAD37, fibonacci, ggg_ttt_hhh, ravikiran.web3, sakshamguruji, zzykxx

Summary

The StakingRewardsManager::topUp(...) contract exhibits an issue where the specified StakingRewards contracts are not topped up at the correct indices, resulting in an incorrect distribution to different contracts.

Vulnerability Detail

The StakingRewardsManager::topUp(...) function is designed to top up multiple StakingRewards contracts simultaneously by taking the indices of the contract's addresses in the StakingRewardsManager::stakingContracts array. However, the flaw lies in the distribution process:

    function topUp(
        address source,
@>        uint256[] memory indices
    ) external onlyRole(EXECUTOR_ROLE) {
@>        for (uint i = 0; i < indices.length; i++) {
            // get staking contract and config
            StakingRewards staking = stakingContracts[i];
            StakingConfig memory config = stakingConfigs[staking];

            // will revert if block.timestamp <= periodFinish
            staking.setRewardsDuration(config.rewardsDuration);

            // pull tokens from owner of this contract to fund the staking contract
            rewardToken.transferFrom(
                source,
                address(staking),
                config.rewardAmount
            );

            // start periods
            staking.notifyRewardAmount(config.rewardAmount);

            emit ToppedUp(staking, config);
        }
    }

GitHub: [254-278]

The rewards are not appropriately distributed to the StakingRewards contracts at the specified indices. Instead, they are transferred to the contracts at the loop indices. For instance, if intending to top up contracts at indices [1, 2], the actual top-up occurs at indices [0, 1].

Impact

The consequence of this vulnerability is that rewards will be distributed to the incorrect staking contract, leading to potential misallocation and unintended outcomes

Code Snippet

Here is a test for PoC:

Add the below given test in StakingRewardsManager.test.ts File. And use the following command to run the test

npx hardhat test --grep "TopUp is not done to intended staking rewards contracts"

TEST:

        it("TopUp is not done to intended staking rewards contracts", async function () {
            // add index 2 to indices
            // so topup should be done to index 0 and 2
            indices = [0, 2];

            await rewardToken.connect(deployer).approve(await stakingRewardsManager.getAddress(), tokenAmount * indices.length);
            
            // create 3 staking contracts
            await stakingRewardsManager.createNewStakingRewardsContract(await stakingToken.getAddress(), newStakingConfig);
            await stakingRewardsManager.createNewStakingRewardsContract(await stakingToken.getAddress(), newStakingConfig);
            await stakingRewardsManager.createNewStakingRewardsContract(await stakingToken.getAddress(), newStakingConfig);

            // topup index 0 and 2
            await expect(stakingRewardsManager.connect(deployer).topUp(await deployer.address, indices))
                .to.emit(stakingRewardsManager, "ToppedUp");


            // getting the staking contract at index 0, 1 and 2
            let stakingContract0 = await stakingRewardsManager.stakingContracts(0);
            let stakingContract1 = await stakingRewardsManager.stakingContracts(1);
            let stakingContract2 = await stakingRewardsManager.stakingContracts(2);

            // Staking contract at index 2 should be empty
            expect(await rewardToken.balanceOf(stakingContract2)).to.equal(0);

            // Staking contract at index 0 and 1 should have 100 tokens
            expect(await rewardToken.balanceOf(stakingContract0)).to.equal(100);
            expect(await rewardToken.balanceOf(stakingContract1)).to.equal(100);

        });

Output:

AAMIR@Victus MINGW64 /d/telcoin-audit/telcoin-audit (main)
$ npx hardhat test --grep "TopUp is not done to intended staking rewards contracts"


  StakingRewards and StakingRewardsFactory
    topUp
      ✔ TopUp is not done to intended staking rewards contracts (112ms)


  1 passing (2s)

Tool used

  • Manual Review

Recommendation

It is recommended to do the following changes:

    function topUp(
        address source,
        uint256[] memory indices
    ) external onlyRole(EXECUTOR_ROLE) {
        for (uint i = 0; i < indices.length; i++) {
            // get staking contract and config
-            StakingRewards staking = stakingContracts[i];
+           StakingRewards staking = stakingContracts[indices[i]];
            StakingConfig memory config = stakingConfigs[staking];

            // will revert if block.timestamp <= periodFinish
            staking.setRewardsDuration(config.rewardsDuration);

            // pull tokens from owner of this contract to fund the staking contract
            rewardToken.transferFrom(
                source,
                address(staking),
                config.rewardAmount
            );

            // start periods
            staking.notifyRewardAmount(config.rewardAmount);

            emit ToppedUp(staking, config);
        }
    }

Discussion

amshirif

https://github.com/telcoin/telcoin-audit/pull/27

sherlock-admin2

1 comment(s) were left on this issue during the judging contest.

takarez commented:

valid because { I consider this a high severity and avalid issues; the watson was able to explain how the topUp function will perform an unintended actions by topping up from the 0 index of the array always due to lack of good implementation of the indices that was supposed to be added before the (i) }

nevillehuang

@amshirif Will this allow the stakers of the wrong contract funded to retrieve unintended rewards? If yes I will remain as high severity.

amshirif

@nevillehuang Yes this would potentially cause those who should have gotten rewards to have received less or non at all, and those who were not intended to get any or less than their desired amount to get more than they should have.

sherlock-admin

The protocol team fixed this issue in PR/commit https://github.com/telcoin/telcoin-audit/pull/27.

sherlock-admin

The Lead Senior Watson signed-off on the fix.

Issue H-2: Wrong parameter when retrieving causes a complete DoS of the protocol

Source: #139

Found by

0xadrii, Arz, Tricko, eeshenggoh, fibonacci

Summary

A wrong parameter in the _retrieve() prevents the protocol from properly interacting with Sablier, causing a Denial of Service in all functions calling _retrieve().

Vulnerability Detail

The CouncilMember contract is designed to interact with a Sablier stream. As time passes, the Sablier stream will unlock more TELCOIN tokens which will be available to be retrieved from CouncilMember.

The _retrieve() internal function will be used in order to fetch the rewards from the stream and distribute them among the Council Member NFT holders (snippet reduced for simplicity):

// CouncilMember.sol

function _retrieve() internal {
        ...
        // Execute the withdrawal from the _target, which might be a Sablier stream or another protocol
        _stream.execute(
            _target,
            abi.encodeWithSelector(
                ISablierV2ProxyTarget.withdrawMax.selector, 
                _target, 
                _id,
                address(this)
            )
        );

        ...
    }

The most important part in _retrieve() regarding the vulnerability that we’ll dive into is the _stream.execute() interaction and the params it receives. In order to understand such interaction, we first need understand the importance of the _stream and the _target variables.

Sablier allows developers to integrate Sablier via Periphery contracts, which prevents devs from dealing with the complexity of directly integrating Sablier’s Core contracts. Telcoin developers have decided to use these periphery contracts. Concretely, the following contracts have been used:

  • ProxyTarget (link points to an older commit because the proxy target contracts have now been deprecated from Sablier): stored in the _target variable, this contract acts as the target for a PRBProxy contract. It contains all the complex interactions with the underlying stream. Concretely, Telcoin uses the [withdrawMax()](https://github.com/sablier-labs/v2-periphery/blob/ba3926d2c3e059a230211077087b73afe46acf64/src/abstracts/SablierV2ProxyTarget.sol#L141C5-L143C6) function in the proxy target to withdraw all the available funds from the stream (as seen in the previous code snippet).
  • PRBProxy: stored in the _stream variable, this contract acts as a forwarding (non-upgradable) proxy, acting as a smart wallet that enables multiple contract calls within a single transaction.

NOTE: It is important to understand that the actual lockup linear stream will be deployed as well. The difference is that the Telcoin protocol will not interact with that contract directly. Instead, the PRBProxy and proxy target contracts will be leveraged to perform such interactions.

Knowing this, we can now move on to explaining Telcoin’s approach to withdrawing the available tokens from the stream. As seen in the code snippet above, the _retrieve() function will perform two steps to actually perform a withdraw from the stream:

It will first call the _stream's execute() function (remember _stream is a PRBProxy). This function receives a target and some data as parameter, and performs a delegatecall aiming at the target:

// https://github.com/PaulRBerg/prb-proxy/blob/main/src/PRBProxy.sol

/// @inheritdoc IPRBProxy
   function execute(address target, bytes calldata data) external payable override returns (bytes memory response) {
        ...

        // Delegate call to the target contract, and handle the response.
        response = _execute(target, data);
    }

    /*//////////////////////////////////////////////////////////////////////////
                          INTERNAL NON-CONSTANT FUNCTIONS
    //////////////////////////////////////////////////////////////////////////*/

    /// @notice Executes a DELEGATECALL to the provided target with the provided data.
    /// @dev Shared logic between the constructor and the `execute` function.
    function _execute(address target, bytes memory data) internal returns (bytes memory response) {
        // Check that the target is a contract.
        if (target.code.length == 0) {
            revert PRBProxy_TargetNotContract(target);
        }

        // Delegate call to the target contract.
        bool success;
        (success, response) = target.delegatecall(data);

        ...
    }

In the _retrieve() function, the target where the call will be forwarded to is the _target parameter, which is a ProxyTarget contract. Concretely, the delegatecall function that will be triggered in the ProxyTarget will be withdrawMax():

// https://github.com/sablier-labs/v2-periphery/blob/ba3926d2c3e059a230211077087b73afe46acf64/src/abstracts/SablierV2ProxyTarget.sol#L141C5-L143C6

function withdrawMax(ISablierV2Lockup lockup, uint256 streamId, address to) external onlyDelegateCall {
	lockup.withdrawMax(streamId, to);
}

As we can see, the withdrawMax() function has as parameters the lockup stream contract to withdraw from, the streamId and the address to which will receive the available funds from the stream. The vulnerability lies in the parameters passed when calling the withdrawMax() function in _retrieve(). As we can see, the first encoded parameter in the encodeWithSelector() call after the selector is the _target:

// CouncilMember.sol

function _retrieve() internal {
        ...
        // Execute the withdrawal from the _target, which might be a Sablier stream or another protocol
        _stream.execute(
            _target,
            abi.encodeWithSelector(
                ISablierV2ProxyTarget.withdrawMax.selector, 
                _target,   // <------- This is incorrect
                _id,
                address(this)
            )
        );

        ...
    }

This means that the proxy target’s withdrawMax() function will be triggered with the _target contract as the lockup parameter, which is incorrect. This will make all calls eventually execute withdrawMax() on the PRBProxy contract, always reverting.

The parameter needed to perform the withdrawMax() call correctly is the actual Sablier lockup contract, which is currently not stored in the CouncilMember contract.

The following diagram also summarizes the current wrong interactions for clarity: vulnerability

Impact

High. ALL withdrawals from the Sablier stream will revert, effectively causing a DoS in the _retrieve() function. Because the _retrieve() function is called in all the main protocol functions, this vulnerability essentially prevents the protocol from ever functioning correctly.

Proof of Concept

Because the current Telcoin repo does not include actual tests with the real Sablier contracts (instead, a TestStream contract is used, which has led to not unveiling this vulnerability), [I’ve created a repository](https://github.com/0xadrii/telcoin-proof-of-concept) where the poc can be executed (the repository will be public after the audit finishes (on 15 jan. 2024 at 16:00 CET)). The testPoc() function shows how any interaction (in this case, a call to the mint() function) will fail because the proper Sablier contracts are used (PRBProxy and proxy target):

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Test, console2} from "forge-std/Test.sol";
import {SablierV2Comptroller} from "@sablier/v2-core/src/SablierV2Comptroller.sol";
import {SablierV2NFTDescriptor} from "@sablier/v2-core/src/SablierV2NFTDescriptor.sol";
import {SablierV2LockupLinear} from "@sablier/v2-core/src/SablierV2LockupLinear.sol";
import {ISablierV2Comptroller} from "@sablier/v2-core/src/interfaces/ISablierV2Comptroller.sol";
import {ISablierV2NFTDescriptor} from "@sablier/v2-core/src/interfaces/ISablierV2NFTDescriptor.sol";
import {ISablierV2LockupLinear} from "@sablier/v2-core/src/interfaces/ISablierV2LockupLinear.sol";

import {CouncilMember, IPRBProxy} from "../src/core/CouncilMember.sol";
import {TestTelcoin} from "./mock/TestTelcoin.sol";
import {MockProxyTarget} from "./mock/MockProxyTarget.sol";
import {PRBProxy} from "./mock/MockPRBProxy.sol";
import {PRBProxyRegistry} from "./mock/MockPRBProxyRegistry.sol";

import {UD60x18} from "@prb/math/src/UD60x18.sol";
import {LockupLinear, Broker, IERC20} from "@sablier/v2-core/src/types/DataTypes.sol";
import {IERC20 as IERC20OZ} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract PocTest is Test {

    ////////////////////////////////////////////////////////////////
    //                        CONSTANTS                           //
    ////////////////////////////////////////////////////////////////

   bytes32 public constant GOVERNANCE_COUNCIL_ROLE =
        keccak256("GOVERNANCE_COUNCIL_ROLE");
    bytes32 public constant SUPPORT_ROLE = keccak256("SUPPORT_ROLE");

    ////////////////////////////////////////////////////////////////
    //                         STORAGE                            //
    ////////////////////////////////////////////////////////////////

    /// @notice Poc Users
    address public sablierAdmin;
    address public user;

    /// @notice Sablier contracts
    SablierV2Comptroller public comptroller;
    SablierV2NFTDescriptor public nftDescriptor;
    SablierV2LockupLinear public lockupLinear;

    /// @notice Telcoin contracts
    PRBProxyRegistry public proxyRegistry;
    PRBProxy public stream;
    MockProxyTarget public target;
    CouncilMember public councilMember;
    TestTelcoin public telcoin;

    function setUp() public {
        // Setup users
        _setupUsers();

        // Deploy token
        telcoin = new TestTelcoin(address(this));

        // Deploy Sablier 
        _deploySablier();

        // Deploy council member
        councilMember = new CouncilMember();

        // Setup stream
        _setupStream();

        // Setup the council member
        _setupCouncilMember();
    }

    function testPoc() public {
      // Step 1: Mint council NFT to user
      councilMember.mint(user);
      assertEq(councilMember.balanceOf(user), 1);

      // Step 2: Forward time 1 days
      vm.warp(block.timestamp + 1 days);
      
      // Step 3: All functions calling _retrieve() (mint(), burn(), removeFromOffice()) will fail
      vm.expectRevert(abi.encodeWithSignature("PRBProxy_ExecutionReverted()")); 
      councilMember.mint(user);
    }

    function _setupUsers() internal {
        sablierAdmin = makeAddr("sablierAdmin");
        user = makeAddr("user");
    }

    function _deploySablier() internal {
        // Deploy protocol
        comptroller = new SablierV2Comptroller(sablierAdmin);
        nftDescriptor = new SablierV2NFTDescriptor();
        lockupLinear = new SablierV2LockupLinear(
            sablierAdmin,
            ISablierV2Comptroller(address(comptroller)),
            ISablierV2NFTDescriptor(address(nftDescriptor))
        );
    }

    function _setupStream() internal {

        // Deploy proxies
        proxyRegistry = new PRBProxyRegistry();
        stream = PRBProxy(payable(address(proxyRegistry.deploy())));
        target = new MockProxyTarget();

        // Setup stream
        LockupLinear.Durations memory durations = LockupLinear.Durations({
            cliff: 0,
            total: 1 weeks
        });

        UD60x18 fee = UD60x18.wrap(0);

        Broker memory broker = Broker({account: address(0), fee: fee});
        LockupLinear.CreateWithDurations memory params = LockupLinear
            .CreateWithDurations({
                sender: address(this),
                recipient: address(stream),
                totalAmount: 100e18,
                asset: IERC20(address(telcoin)),
                cancelable: false,
                transferable: false,
                durations: durations,
                broker: broker
            });

        bytes memory data = abi.encodeWithSelector(target.createWithDurations.selector, address(lockupLinear), params, "");

        // Create the stream through the PRBProxy
        telcoin.approve(address(stream), type(uint256).max);
        bytes memory response = stream.execute(address(target), data);
        assertEq(lockupLinear.ownerOf(1), address(stream));
    }

    function _setupCouncilMember() internal {
      // Initialize
      councilMember.initialize(
            IERC20OZ(address(telcoin)),
            "Test Council",
            "TC",
            IPRBProxy(address(stream)), // stream_
            address(target), // target_
            1, // id_
            address(lockupLinear)
        );

        // Grant roles
        councilMember.grantRole(GOVERNANCE_COUNCIL_ROLE, address(this));
        councilMember.grantRole(SUPPORT_ROLE, address(this));
    }
  
}

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L275

Tool used

Manual Review, foundry

Recommendation

In order to fix the vulnerability, the proper address needs to be passed when calling withdrawMax().

Note that the actual stream address is currently NOT stored in CouncilMember.sol, so it will need to be stored (my example shows a new actualStream variable)

function _retrieve() internal {
        ...
        // Execute the withdrawal from the _target, which might be a Sablier stream or another protocol
        _stream.execute(
            _target,
            abi.encodeWithSelector(
                ISablierV2ProxyTarget.withdrawMax.selector, 
-                _target, 
+		actualStream
                _id,
                address(this)
            )
        );

        ...
    }

Discussion

amshirif

https://github.com/telcoin/telcoin-audit/pull/43

sherlock-admin2

1 comment(s) were left on this issue during the judging contest.

takarez commented:

valid because { This is valid a a dupp of 086; the watson claims its hight but will still make it meduim due to the impact mentioned in issue 086; but making it the best report as the POC is well written and implemented}

nevillehuang

@amshirif Is there anyway the admin can unblock DoS in withdrawals?

amshirif

@nevillehuang No, a new contract with these fixes would need to be deployed to prevent DoS because those two values had to be the same prior to the fix.

sherlock-admin

The protocol team fixed this issue in PR/commit https://github.com/telcoin/telcoin-audit/pull/43.

sherlock-admin

The Lead Senior Watson signed-off on the fix.

Issue H-3: CouncilMember:burn renders the contract inoperable after the first execution

Source: #199

Found by

0xAsen, 0xLogos, 0xadrii, 0xlamide, 0xmystery, 0xpep7, Aamirusmani1552, Arz, BAICE, Bauer, DenTonylifer, HonorLt, Ignite, IvanFitro, Jaraxxus, Kow, Krace, VAD37, alexbabits, almurhasan, araj, bitsurfer, dipp, fibonacci, ggg_ttt_hhh, gqrp, grearlake, jah, m4ttm, mstpr-brainbot, popeye, psb01, r0ck3tz, ravikiran.web3, sakshamguruji, sobieski, sonny2k, tives, ubl4nk, vvv, ydlee, zhuying, zzykxx

Summary

The CouncilMember contract suffers from a critical vulnerability that misaligns the balances array after a successful burn, rendering the contract inoperable.

Vulnerability Detail

The root cause of the vulnerability is that the burn function incorrectly manages the balances array, shortening it by one each time an ERC721 token is burned while the latest minted NFT still withholds its unique tokenId which maps to the previous value of balances.length.

// File: telcoin-audit/contracts/sablier/core/CouncilMember.sol
210:    function burn(
        ...
220:        balances.pop(); // <= FOUND: balances.length decreases, while latest minted nft withold its unique tokenId
221:        _burn(tokenId);
222:    }

This misalignment between existing tokenIds and the balances array results in several critical impacts:

  1. Holders with tokenId greater than the length of balances cannot claim.
  2. Subsequent burns of tokenId greater than balances length will revert.
  3. Subsequent mint operations will revert due to tokenId collision. As totalSupply now collides with the existing tokenId.
// File: telcoin-audit/contracts/sablier/core/CouncilMember.sol
173:    function mint(
        ...
179:
180:        balances.push(0);
181:        _mint(newMember, totalSupply());// <= FOUND
182:    }

This mismanagement creates a cascading effect, collectively rendering the contract inoperable. Following POC will demonstrate the issue more clearly in codes.

POC

Run git apply on the following patch then run npx hardhat test to run the POC.

diff --git a/telcoin-audit/test/sablier/CouncilMember.test.ts b/telcoin-audit/test/sablier/CouncilMember.test.ts
index 675b89d..ab96b08 100644
--- a/telcoin-audit/test/sablier/CouncilMember.test.ts
+++ b/telcoin-audit/test/sablier/CouncilMember.test.ts
@@ -1,13 +1,14 @@
 import { expect } from "chai";
 import { ethers } from "hardhat";
 import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers";
-import { CouncilMember, TestTelcoin, TestStream } from "../../typechain-types";
+import { CouncilMember, TestTelcoin, TestStream, ERC721Upgradeable__factory } from "../../typechain-types";
 
 describe("CouncilMember", () => {
     let admin: SignerWithAddress;
     let support: SignerWithAddress;
     let member: SignerWithAddress;
     let holder: SignerWithAddress;
+    let lastCouncilMember: SignerWithAddress;
     let councilMember: CouncilMember;
     let telcoin: TestTelcoin;
     let stream: TestStream;
@@ -18,7 +19,7 @@ describe("CouncilMember", () => {
     let supportRole: string = ethers.keccak256(ethers.toUtf8Bytes("SUPPORT_ROLE"));
 
     beforeEach(async () => {
-        [admin, support, member, holder, target] = await ethers.getSigners();
+        [admin, support, member, holder, target, lastCouncilMember] = await ethers.getSigners();
 
         const TestTelcoinFactory = await ethers.getContractFactory("TestTelcoin", admin);
         telcoin = await TestTelcoinFactory.deploy(admin.address);
@@ -182,6 +183,22 @@ describe("CouncilMember", () => {
                 it("the correct removal is made", async () => {
                     await expect(councilMember.burn(1, support.address)).emit(councilMember, "Transfer");
                 });
+                it.only("inoperable contract after burn", async () => {
+                    await expect(councilMember.mint(lastCouncilMember.address)).to.not.reverted;
+
+                    // This 1st burn will cause contract inoperable due to tokenId & balances misalignment
+                    await expect(councilMember.burn(1, support.address)).emit(councilMember, "Transfer");
+
+                    // Impact 1. holder with tokenId > balances length cannot claim
+                    await expect(councilMember.connect(lastCouncilMember).claim(3, 1)).to.revertedWithPanic("0x32"); // @audit-info 0x32: Array accessed at an out-of-bounds or negative index
+
+                    // Impact 2. subsequent burns of tokenId > balances length will revert
+                    await expect(councilMember.burn(3, lastCouncilMember.address)).to.revertedWithPanic("0x32"); 
+
+                    // Impact 3. subsequent mint will revert due to tokenId collision
+                    await expect(councilMember.mint(lastCouncilMember.address)).to.revertedWithCustomError(councilMember, "ERC721InvalidSender");
+
+                });
             });
         });
 

Result

CouncilMember mutative burn Success ✔ inoperable contract after burn (90ms) 1 passing (888ms)

The Passing execution of the POC confirmed that operations such as claim, burn & mint were all reverted which make the contract inoperable.

Impact

The severity of the vulnerability is high due to the high likelihood of occurence and the critical impacts on the contract's operability and token holders' ability to interact with their assets.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L220

Tool used

VsCode

Recommendation

It is recommended to avoid popping out balances to keep alignment with uniquely minted tokenId. Alternatively, consider migrating to ERC1155, which inherently manages a built-in balance for each NFT.

Discussion

sherlock-admin2

1 comment(s) were left on this issue during the judging contest.

takarez commented:

valid because { this is a valid findings because the watson explain how again the burn function will break a functionality just like the previous issue thus making it a dupp of 109}

nevillehuang

See comments here for duplication reasons.

amshirif

https://github.com/telcoin/telcoin-audit/pull/31

sherlock-admin

The protocol team fixed this issue in PR/commit https://github.com/telcoin/telcoin-audit/pull/31.

sherlock-admin

The Lead Senior Watson signed off on the fix.

Issue M-1: The CouncilMember contract DoS due to the _retrieve function revert

Source: #47

Found by

0xadrii, Arz, Ignite, Tricko, fibonacci

Summary

The _retrievefunction is called before any significant state changes. This function executes the withdrawal from the _target, which might be a Sablier stream or another protocol. SablierV2Lockup reverts if withdrawable amount equals to 0.

https://github.com/sablier-labs/v2-core/blob/b0016437ef3cc8606e1100965dd911d7e658b40b/src/abstracts/SablierV2Lockup.sol#L297-L299 https://github.com/sablier-labs/v2-core/blob/b0016437ef3cc8606e1100965dd911d7e658b40b/src/abstracts/SablierV2Lockup.sol#L270-L272

Funds are distributed over time. And even if there are always funds in the protocol for distribution, after calling the _retrieve function, a new distribution will not be available until another period of time has passed.

This means that any interaction with the CouncilMember contract will be unavailable during this time.

Vulnerability Detail

1. If the protocol for distributing funds employs a strategy that permits funds to be released once within a specific timeframe (for instance, 1 day, 1 week, or 1 month), this implies that the CouncilMember contract will execute its tasks error-free only once during this period.

2. The removeFromOffice function calls the _retrievefunction at the beginning to retrieve and distribute any pending TELCOIN for all council members, and transfer token ownership at the end.

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L267-L295

The _update function, which is called before each transfer, is overridden and also calls the _retrieve function.

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L321-L331

Thus, during the removeFromOffice function, the _retrieve function will be called twice, which will always result in revert, since after the first distribution of funds, when called again, the withdrawable amount will be 0.

3. Also, according to the sponsor's comment, the council members are semi-trusted. A malicious member can prevent others from interacting with the contract. For example:

  • Member A wants to claim their allocated amounts of TELCOIN
  • Member B fron-runs member's A transaction and call the retrieve function
  • Member's A transaction reverts because the _retrieve function is called again but there are no more withdrawable amount.

Impact

Denial of Service of the CouncilMember contract over a period of time, depending on the fund distribution strategy. The removeFromOffice function always fails, leading to the necessity to use the transferFrom function, which does not call _withdrawAll, potentially breaking the state of the contract

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L267-L295

Tool used

Manual Review

Recommendation

Check amount before executing withdrawal or wrap call in a try/catch block. Also consider abandoning the removeFromOffice function, use transferFrom instead and move _withdrawAll call to _update function.

Discussion

amshirif

https://github.com/telcoin/telcoin-audit/pull/37

sherlock-admin2

1 comment(s) were left on this issue during the judging contest.

takarez commented:

valid because { valid and a dupp of 051 with a non standard recommendation than 051}

nevillehuang

@amshirif I think this could possibly be medium severity, given there is no definite loss of funds other than when a malicious council member can be prevented from being removed. The difference between this and #139 is it doesn't affect withdrawals of council members. Also I think #141 and #98 are the most comprehensive report, with #118 highlighting a front-running issue. (but sherlock automated tool selected this)

amshirif

@nevillehuang Yes I agree

0xf1b0

Escalate

I disagree with the severity. It shares the same impact as #139, as both are results of the _retrieve function reverting. However, the root cause of the revert is different.

This issue also affects the withdrawal, as the withdrawal process itself includes the _retrieve function call. The Vulnerability Detail section provides scenarios 1 and 3, which illustrate how withdrawals can potentially be halted.

sherlock-admin

Escalate

I disagree with the severity. It shares the same impact as #139, as both are results of the _retrieve function reverting. However, the root cause of the revert is different.

This issue also affects the withdrawal, as the withdrawal process itself includes the _retrieve function call. The Vulnerability Detail section provides scenarios 1 and 3, which illustrate how withdrawals can potentially be halted.

You've created a valid escalation!

To remove the escalation from consideration: Delete your comment.

You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.

nevillehuang

@0xf1b0 Can you provide a coded PoC so that I can analyze the differences in root cause? I think this might be a duplicate of #139

0xArz

@nevillehuang The root cause in #139 is that when calling _retrieve() it will always revert because we are calling a wrong address.

The root cause here is that _retrieve() reverts when withdrawing 0 amounts, in some functions like mint() it is called 2 times - first called in the function and then its called the second time in ERC721.update() which will fail the second time because we already withdrew the max. Or for example a stream is used where the withdrawable amount is 0 for some time - unlocking in steps etc.

The impact of this issue is that we call only mint 1 CouncilMember nft because the first time mint() is called, _retrieve() is called only once, after that all calls to mint(),burn() and removeFromOffice() will revert because _retrieve() is called 2 times.

The 1 council member can still claim the rewards but if a dynamic stream is used and the council member calls the public retrieve() he can then fail to claim his rewards for some time until more rewards are unlocked. Although because we will only have 1 council member this will lead to unfair distribution of the rewards

amshirif

This is not a duplicate of #139, and it does not share the same impact. #139 is more serious as it essentially prevents the withdrawal ability from ever working.

nevillehuang

Agree with sponsor @amshirif, unless @0xArz @0xf1b0 can show a PoC of the issue showing an impact that prevents withdrawals/affects rewards claiming.

0xArz

Agree with sponsor @amshirif, unless @0xArz @0xf1b0 can show a PoC of the issue showing an impact that prevents withdrawals/affects rewards claiming.

I agree, funds can be stuck but the DoS is only temporarily. However if we only have 1 council member then 100% of the funds are distributed to him which imo is quite a big problem as council members are semitrusted and other members that were supposed to receive funds will not receive anything but its up to you to decide whether this defines high severity or no.

nevillehuang

@0xArz I am abit confused by your statement. How can there be other council members that were supposed to receive funds when there is only 1 council member decided by the governance?

0xArz

@nevillehuang Yeah there will only be 1 council member but for example lets say the governance wanted to have 3 council members, they will fail to set the other members after the first one because the retrieve reverts. So instead of having 3 council members there will only be 1 and he will receive 100% of the funds while the other members that were supposed to be set will not receive anything because they were not set

0xf1b0

Agree with sponsor @amshirif, unless @0xArz @0xf1b0 can show a PoC of the issue showing an impact that prevents withdrawals/affects rewards claiming.

Doesn't case 3 from the Vulnerability Detail, where malicious actor can front-run every transaction with retrieve call, show this impact? No one will be able to withdraw funds.

nevillehuang

@0xArz Acknowledge this possibility given mint() and burn() can possibly be bricked too. However, since the first council member still get their intended rewards, admins can then choose to not topup rewards thereafter. So I believe this is just a DoS scenario.

Evert0x

Planning to reject escalation and keep issue state as is.

The provided context and discussion fail to make the case for high severity as the impact is limited to specific actors and scenarios.

Evert0x

Result: Medium Has Duplicates

sherlock-admin2

Escalations have been resolved successfully!

Escalation status:

sherlock-admin

The protocol team fixed this issue in PR/commit https://github.com/telcoin/telcoin-audit/pull/37.

sherlock-admin

The Lead Senior Watson signed off on the fix.

Issue M-2: Sablier stream update in CouncilMember.sol can cause loss of funds if the streamed balance is not withdrawn.

Source: #99

Found by

Aamirusmani1552, Tricko

Summary

The vulnerability in the CouncilMember contract pertains to the failure to withdraw streamed tokens during a contract stream update, potentially resulting in fund loss for both the contract and the entire council.

Vulnerability Detail

Sablier Streams facilitate token streaming on a per-second basis, involving a sender who initiates the stream and a receiver who receives the streamed tokens. The receiver can withdraw tokens up to the elapsed seconds from the stream's start. The responsibility to claim streamed tokens lies with the receiver, as stated in the documentation and Sablier stream contracts (read the cancel stream docs here) . Once tokens are streamed, the sender cannot withdraw them.

Also sender has the authority to cancel the the stream and claim back the un-streamed amount. But streamed Balance upto the elapsed time can still be claimed by the receiver or person who is approved by the receiver only.

Check the SablierV2Lockup::cancel() here 👇: https://github.com/sablier-labs/v2-core/blob/b0016437ef3cc8606e1100965dd911d7e658b40b/src/abstracts/SablierV2Lockup.sol#L153-L168

Docs for the same could be find here 👇: https://docs.sablier.com/contracts/v2/guides/stream-management/cancel

As we check from the resources given above, if a stream is canceled only the un-streamed balance will be available for the sender to withdraw. Rest if for the receiver.

The issue arises in the CouncilMember contract's functions (CouncilMember::updateStream(...), CouncilMember::updateID(...), and CouncilMember::updateTarget(...)) as they do not check whether the entire streamed amount has been withdrawn from the Sablier stream before updating the stream states in the contract. Consequently, if there is an active streamed balance in the Sablier stream, the CouncilMember contract will not be able to withdraw it. And now the balance is lying idle in the Sablier stream contract.

However, the previously streamed balance can be reclaimed by adding the old stream back to the CouncilMember contract, provided the sender is aware that the streamed balance has not been withdrawn. Nonetheless, complications may arise if modifications are made to the CouncilMember contract following the stream update. For instance, the removal of a Council Member could lead to the omitted member not receiving their balance, while the addition of a new member may result in every old member receiving fewer tokens and new members gaining tokens share. This can happen because all update stream functions are handled by the role GOVERNANCE_COUNCIL_ROLE in the CouncilMember contract. And if it is a multi-sig or governance then it would required a vote to happend in order to perform the new updated. And sponsor confirmed that the multi-sig can be added for this role. Here is the conversation:

Question Asked by me:

And last one is, Governance council will be a contract or EOA ( can be multisig). If governance council will be multisig, then how often can it make updates to the contracts?

Answer from Sponsor: image

So if this is the case then new update will be done after some time and a lot of things might happen in that time.

Also the sender's awareness play important role in this. Two scenarios may unfold because of this:

  1. The stream has fully distributed its balance, and the sender assumes that the funds have been appropriately allocated to the CouncilMember contract.
  2. The stream needs to be prematurely canceled for specific reasons, requiring the addition of a new stream.

In both of the scenarios if the sender is unaware then it will be complete loss of tokens.

Impact

Council members face potential token losses due to the inability to withdraw streamed balances.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L229C3-L257C1

Tool used

  • Manual Review

Recommendation

To mitigate this potential issue, the following actions are advised:

1. Implement Sablier Hooks:

Sablier offers essential hooks to address scenarios where the receiver is a contract. These hooks enable the receiver contract to update its state accurately. While these hooks are optional, Sablier strongly recommends their implementation. Of particular relevance in this context is the onStreamCanceled hook, triggered by the Sablier stream contract when the sender cancels the stream. By incorporating this hook in the CouncilMember contract, the receiver can invoke the _retrieve() function upon stream cancellation, ensuring the withdrawal of the entire streamed balance.

File: CouncilMember.sol

+ import { ISablierV2LockupRecipient } from "@sablier/v2-core/src/interfaces/hooks/ISablierV2LockupRecipient.sol";

    contract CouncilMember is
        ERC721EnumerableUpgradeable,
        AccessControlEnumerableUpgradeable
+    ISablierV2LockupRecipient
    {

+    function onStreamCanceled(
+        uint256 streamId,
+        uint128, /* senderAmount */
+        uint128 /* recipientAmount */
+    )
+        external
+        pure
+    {
+        _retrieve();
+    }

    }

2. Check Stream Depletion in Update Function:

In the stream update function, verify whether the stream is depleted or not. If not, withdraw the streamed tokens before updating the balances. It is crucial to check if the stream is depleted because if the _retrieve() function is directly called and the stream has been depleted (all tokens withdrawn by the receiver), invoking stream.withdrawMax() will revert. This could lead to a revert in the _retrieve() function and potentially cause a denial-of-service (DoS) situation in the stream update function.

File: CouncilMember.sol

+    // Syncronize the update process
+    function updateStreamData( IPRBProxy stream_,  address target_, uint256 updateID ) external onlyRole(GOVERNANCE_COUNCIL_ROLE){
+     _checkIfDepleted();    
+     _updateStream(stream_);
+     _updateTarget(target_);
+     _updateID(updateID);
+    }

-    function updateStream(
+    function _updateStream(
        IPRBProxy stream_
-    ) external onlyRole(GOVERNANCE_COUNCIL_ROLE) {
+   ) internal {
        _stream = stream_;
        emit StreamUpdated(_stream);
    }


    /**
     * @notice Update the target address
     * @dev Restricted to the GOVERNANCE_COUNCIL_ROLE.
     * @param target_ New target address.
     */
-    function updateTarget(
+    function _updateTarget(
        address target_
-    ) external onlyRole(GOVERNANCE_COUNCIL_ROLE) {
+   ) internal {
        _target = target_;
        emit TargetUpdated(_target);
    }


    /**
     * @notice Update the ID for a council member
     * @dev Restricted to the GOVERNANCE_COUNCIL_ROLE.
     * @param id_ New ID for the council member.
     */
-       function updateID(uint256 id_) external onlyRole(GOVERNANCE_COUNCIL_ROLE) {
+      function _updateID(uint256 id_) internal {
        _id = id_;
        emit IDUpdated(_id);
      }

+   // assuming IPRBProxy will return results like given below since we have not been provided with PRBProxy in the codebase.
+  // make changes according to the interface to below given function.
+   function _checkIfDepleted() _internal view {
+        (bool success, bytes memory data) = _stream.execute(
+            _target,
+            abi.encodeWithSelector(
+                ISablierV2ProxyTarget.isDepleted.selector,
+                _id
+            )
+        );
        
+      require(success, "Call failed");
+      require(abi.decode(data, (bool)), "Stream is not depleted yet.");
+   }

Note: Make necessary adjustments in the interfaces used.

Discussion

sherlock-admin2

1 comment(s) were left on this issue during the judging contest.

takarez commented:

invalid because { This is invalid because the funcions in question (updateStream and updateId) have a governance modifier which requires the governance to execute this action; according to sherlock its invalid}

amshirif

Duplicate issue #112

amshirif

https://github.com/telcoin/telcoin-audit/pull/49

sherlock-admin

The protocol team fixed this issue in PR/commit https://github.com/telcoin/telcoin-audit/pull/49.

sherlock-admin

The Lead Senior Watson signed-off on the fix.

2024-01-telcoin-judging's People

Contributors

sherlock-admin avatar sherlock-admin2 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar Scooby avatar

Watchers

 avatar

2024-01-telcoin-judging's Issues

s1l3nt - SUPPORT_ROLE is never assigned, loss of funds

s1l3nt

high

SUPPORT_ROLE is never assigned, loss of funds

Summary

The role SUPPORT_ROLE is declared on the CouncilMember and StakingRewardsManager contracts but is never assigned to any address. This will lead to loss of funds because the operations to rescue ERC20 tokens will not work.

Vulnerability Detail

CouncilMember.sol

/* ========== ROLES ========== */
    // Role assigned for the governance council
    bytes32 public constant GOVERNANCE_COUNCIL_ROLE =
        keccak256("GOVERNANCE_COUNCIL_ROLE");
    // Support role for additional functionality
    bytes32 public constant SUPPORT_ROLE = keccak256("SUPPORT_ROLE");

 /************************************************
     *   helper functions
     ************************************************/

    /**
     * @notice Rescues any ERC20 token sent accidentally to the contract
     * @dev Only addresses with the SUPPORT_ROLE can call this function.
     * @param token ERC20 token address which needs to be rescued.
     * @param destination Address where the tokens will be sent.
     * @param amount Amount of tokens to be transferred.
     */
    function erc20Rescue(
        IERC20 token,
        address destination,
        uint256 amount
    ) external onlyRole(SUPPORT_ROLE) {                           // [x]
        token.safeTransfer(destination, amount);
    }

StakingRewardsManager.sol

    [...]
    bytes32 public constant SUPPORT_ROLE = keccak256("SUPPORT_ROLE");   //[x]
    [...]

    /// @notice Recover ERC20 tokens from THIS contract
    /// @param tokenAddress Address of the ERC20 token contract
    /// @param tokenAmount Amount of tokens to recover
    /// @param to The account to send the recovered tokens to
    function recoverERC20(
        IERC20 tokenAddress,
        uint256 tokenAmount,
        address to
    ) external onlyRole(SUPPORT_ROLE) {       // [x]
        //move funds
        tokenAddress.safeTransfer(to, tokenAmount);
    }

By cross referencing SUPPORT_ROLE we can see that it is used on certain rescue functions but is never called/intialized with _grantRole(..).

Impact

Loss of funds because does not exist any address with such role granted.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L53

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/telx/core/StakingRewardsManager.sol#L24

Tool used

Manual Review

Recommendation

Use _grantRole(...) to assert an address with SUPPORT_ROLE. This role is not even being used by the Council Members nor the Governance Council.

bareli - creation or loss of tokens

bareli

medium

creation or loss of tokens

Summary

The batchTelcoin function requires the contract to have a balance equal to the initial balance after transfers, which prevents the creation or loss of tokens. However, this check could be problematic if there are any unrelated token transfers to the contract, as it would prevent the execution of any transactions.

Vulnerability Detail

uint256 initialBalance = TELCOIN.balanceOf(address(this));
//transfers amounts
TELCOIN.safeTransferFrom(owner(), address(this), totalWithdrawl);
for (uint i = 0; i < destinations.length; i++) {
TELCOIN.safeTransfer(destinations[i], amounts[i]);
}
//initial balance is used instead of zero
//if 0 is used instead stray Telcoin could DNS operations
require(
TELCOIN.balanceOf(address(this)) == initialBalance,
"TelcoinDistributor: must not have leftovers"
);
}

Impact

It would prevent the execution of any transactions.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/protocol/core/TelcoinDistributor.sol#L185

Tool used

Manual Review

Recommendation

Duplicate of #250

Irissme - Lack of Transaction Guard in topUp Function

Irissme

medium

Lack of Transaction Guard in topUp Function

Summary

The topUp function in the StakingRewardsManager.sol file lacks proper transaction guarding before invoking the notifyRewardAmount function. This could expose the contract to reentrancy attacks.

Vulnerability Detail

The topUp function first sets the rewards duration using staking.setRewardsDuration(config.rewardsDuration) and then transfers tokens to the staking contract using rewardToken.transferFrom. However, there is a potential vulnerability as the notifyRewardAmount function is called afterward without proper transaction guarding.

Impact

Without transaction guarding, there is a risk of reentrancy attacks, where an attacker could potentially manipulate the state of the contract during the execution of the notifyRewardAmount function.

Impact on a Hypothetical Scenario

Scenario Description:
The StakingRewardsManager contract manages multiple staking contracts.
An attacker exploits the lack of transaction guarding in the topUp function.

Exploitation Steps:

The attacker initiates a topUp transaction with a malicious staking contract index.
The attacker reverts the staking.setRewardsDuration(config.rewardsDuration) by triggering a reentrancy attack, manipulating the state.
The attacker drains the staking contract's funds during the execution of notifyRewardAmount.

Consequences:

Funds Manipulation: The attacker can manipulate the staking contract's state during the setRewardsDuration call, potentially draining its funds or causing other unintended behaviors.

Reentrancy Exploitation: Exploiting the lack of transaction guarding allows the attacker to repeatedly enter the staking contract and execute malicious actions.

Potential Damage:
Financial Loss: The attacker may drain funds from the staking contracts, causing financial losses to users.
Disruption of Staking: Continuous reentrancy attacks could disrupt the normal operation of the staking contracts, affecting legitimate stakers.
Mitigation:
Implementing proper transaction guarding, such as using the ReentrancyGuard modifier, would prevent the attacker from manipulating the state of the staking contracts during the execution of the notifyRewardAmount function. This would enhance the security of the topUp function and prevent the described attack scenario.

Conclusion:
The identified lack of transaction guarding in the topUp function poses a significant risk of financial loss and disruption to the staking contracts managed by the StakingRewardsManager. Implementing the recommended mitigation is crucial to prevent potential exploits and ensure the security of the contract.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/telx/core/StakingRewardsManager.sol#L254-L277

Tool used

Manual Review

Recommendation

// Import the ReentrancyGuard from OpenZeppelin
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract StakingRewardsManager is AccessControlUpgradeable, ReentrancyGuard {
    // ... (existing contract code)

    // Add ReentrancyGuard to the contract
    modifier nonReentrant() {
        require(!inTopUp, "ReentrancyGuard: reentrant call");
        inTopUp = true;
        _;
        inTopUp = false;
    }

    // ... (existing contract code)

    function topUp(address source, uint256[] memory indices) external onlyRole(EXECUTOR_ROLE) nonReentrant {
        for (uint i = 0; i < indices.length; i++) {
            StakingRewards staking = stakingContracts[indices[i]];
            StakingConfig memory config = stakingConfigs[staking];

            // Secure against reentrancy attacks using ReentrancyGuard
            staking.setRewardsDuration(config.rewardsDuration);

            // Transfer tokens from the owner of this contract to fund the staking contract
            rewardToken.transferFrom(source, address(staking), config.rewardAmount);

            // No longer vulnerable to reentrancy attacks due to the ReentrancyGuard modifier
            staking.notifyRewardAmount(config.rewardAmount);

            emit ToppedUp(staking, config);
        }
    }
}

In the modified code, I added the ReentrancyGuard modifier to the contract and applied the nonReentrant modifier to the topUp function. This modification ensures that the function cannot be reentered until the previous execution is completed, mitigating the risk of reentrancy attacks.

fibonacci - `CouncilMember`: minting a new token is not possible after burning

fibonacci

high

CouncilMember: minting a new token is not possible after burning

Summary

The mint function utilises totalSupply value as tokenId for a new token. The value of the totalSupply decreases when a token is burned. As a result, the next mint uses a tokenId that is already taken.

Vulnerability Detail

The CouncilMember is inherited from the ERC721EnumerableUpgradeable which overrides the _update function and decreases totalSupply value on burning.

    function totalSupply() public view virtual returns (uint256) {
        ERC721EnumerableStorage storage $ = _getERC721EnumerableStorage();
        return $._allTokens.length;
    }
...    
    function _update(address to, uint256 tokenId, address auth) internal virtual override returns (address) {
        address previousOwner = super._update(to, tokenId, auth);

        if (previousOwner == address(0)) {
            _addTokenToAllTokensEnumeration(tokenId);
        } else if (previousOwner != to) {
            _removeTokenFromOwnerEnumeration(previousOwner, tokenId);
        }
        if (to == address(0)) {
-->         _removeTokenFromAllTokensEnumeration(tokenId);
        } else if (previousOwner != to) {
            _addTokenToOwnerEnumeration(to, tokenId);
        }

        return previousOwner;
    }
...
    function _removeTokenFromAllTokensEnumeration(uint256 tokenId) private {
        ERC721EnumerableStorage storage $ = _getERC721EnumerableStorage();
        // To prevent a gap in the tokens array, we store the last token in the index of the token to delete, and
        // then delete the last slot (swap and pop).

        uint256 lastTokenIndex = $._allTokens.length - 1;
        uint256 tokenIndex = $._allTokensIndex[tokenId];

        // When the token to delete is the last token, the swap operation is unnecessary. However, since this occurs so
        // rarely (when the last minted token is burnt) that we still do the swap here to avoid the gas cost of adding
        // an 'if' statement (like in _removeTokenFromOwnerEnumeration)
        uint256 lastTokenId = $._allTokens[lastTokenIndex];

        $._allTokens[tokenIndex] = lastTokenId; // Move the last token to the slot of the to-delete token
        $._allTokensIndex[lastTokenId] = tokenIndex; // Update the moved token's index

        // This also deletes the contents at the last position of the array
        delete $._allTokensIndex[tokenId];
-->     $._allTokens.pop();
    }    

POC

diff --git a/telcoin-audit/test/sablier/CouncilMember.test.ts b/telcoin-audit/test/sablier/CouncilMember.test.ts
index 675b89d..9de46d1 100644
--- a/telcoin-audit/test/sablier/CouncilMember.test.ts
+++ b/telcoin-audit/test/sablier/CouncilMember.test.ts
@@ -178,6 +178,13 @@ describe("CouncilMember", () => {
                 });
             });

+            describe("Failure", () => {
+                it("mint reverts after burn", async () => {
+                    await expect(councilMember.burn(0, member.address)).to.not.reverted;
+                    await expect(councilMember.mint(member.address)).to.reverted;
+                });
+            });
+
             describe("Success", () => {
                 it("the correct removal is made", async () => {
                     await expect(councilMember.burn(1, support.address)).emit(councilMember, "Transfer");

Impact

It is impossible to mint a new token after burning.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L181

Tool used

Manual Review

Recommendation

Use separate variable to keep tracking of the next tokenId.

diff --git a/telcoin-audit/contracts/sablier/core/CouncilMember.sol b/telcoin-au
dit/contracts/sablier/core/CouncilMember.sol
index dda827e..2ef1087 100644
--- a/telcoin-audit/contracts/sablier/core/CouncilMember.sol
+++ b/telcoin-audit/contracts/sablier/core/CouncilMember.sol
@@ -45,6 +45,8 @@ contract CouncilMember is
     // Mapping of who can send each NFT index
     mapping(uint256 => address) private _tokenApproval;

+    uint256 nextTokenId;
+
     /* ========== ROLES ========== */
     // Role assigned for the governance council
     bytes32 public constant GOVERNANCE_COUNCIL_ROLE =
@@ -178,7 +184,7 @@ contract CouncilMember is
         }

         balances.push(0);
         }
+        _mint(newMember, nextTokenId++);
     }

     /**

Duplicate of #199

sobieski - Burning CouncilMember NFTs locks the last minter from their balance

sobieski

high

Burning CouncilMember NFTs locks the last minter from their balance

Summary

The CouncilMember::burn() method deletes the accumulated TELCOIN balance from storage array balances at a given index. This deletion alters the length of the balances array, decreasing it by one. Due to the fact that CouncilMember token IDs are used to look up a holders' balances in the array, this operation effectively locks the owner of the last minted NFT from their balance.

Vulnerability Detail

The storage array uint256[] public balances contains the accumulated balances of TELCOIN tokens which can be claimed by the CouncilMember NFT holders. Inside the CouncilMember::claim() method, the CouncilMember token id is used to look up a specific balance in the array, as one can see in the code snippet below:

    /**
     * @notice Allows council members to claim their allocated amounts of TELCOIN
     * @dev Checks if the caller is the owner of the provided tokenId and if the requested amount is available.
     * @param tokenId The NFT index associated with a council member.
     * @param amount Amount of TELCOIN the council member wants to withdraw.
     */
    function claim(uint256 tokenId, uint256 amount) external {
        // Ensure the function caller is the owner of the token (council member) they're trying to claim for
        require(
            _msgSender() == ownerOf(tokenId),
            "CouncilMember: caller is not council member holding this NFT index"
        );
        // Retrieve and distribute any pending TELCOIN for all council members
        _retrieve();

        // Ensure the requested amount doesn't exceed the balance of the council member
        require(
            amount <= balances[tokenId],
            "CouncilMember: withdrawal amount is higher than balance"
        );

        // Deduct the claimed amount from the token's balance
        balances[tokenId] -= amount;
        // Safely transfer the claimed amount of TELCOIN to the function caller
        TELCOIN.safeTransfer(_msgSender(), amount);
   }

The correct behavior of this method requires the token ID always to point to the corresponding balance in the balances array. This requirement is breached in the CouncilMember::burn() method.

/**
     * @notice Burn a council member NFT
     * @dev The function retrieves and distributes TELCOIN before burning the NFT.
     * @dev Restricted to the GOVERNANCE_COUNCIL_ROLE.
     * @param tokenId Token ID of the council member NFT to be burned.
     * @param recipient Address to receive the burned NFT holder's TELCOIN allocation.
     */
    function burn(
        uint256 tokenId,
        address recipient
    ) external onlyRole(GOVERNANCE_COUNCIL_ROLE) {
        require(totalSupply() > 1, "CouncilMember: must maintain council");
        _retrieve();
        _withdrawAll(recipient, tokenId);

        uint256 balance = balances[balances.length - 1];
        balances[tokenId] = balance;
        balances.pop(); //@audit H1 - Wrong balance tracking! Indexes in balance can change
        _burn(tokenId);
    }

Before burning the NFT, the burn() method deletes the balance corresponding to the token ID. The length-altering delete is performed - the last element of the array is copied to the deleted index, and then the last element gets popped.

Let's consider the following scenario:

  1. 4 NFT's are minted. Alice gets token ID 0, Bob - 1, Charlie - 2, and Dylan - 3. The balances array can be visualized like this: [alicesBalance, bobsBalance, charliesBalance, dylansBalance]
  2. Bob's NFT gets burned and his balance deleted. The balances array after the burn() method execution can be visualized like this: [alicesBalance, dylansBalance, charliesBalance]
  3. Dylan wants to claim his TELCOINs. However, since his NFT has id 3, the claim() method will try to access his balance at balances[3]. This element does not exist in the altered array, therefore the call will revert. Dylan will be effectively locked from their balance forever.

The POC for the issue is proposed below. Please paste it into CouncilMember.test.ts test suite.

describe("POC", () => {
            it("Burning any NFT locks the last NFT owner from their balance", async () =>
            {
                /* 1. Mint the NFTs for 4 users */
                await expect(councilMember.mint(member.address)).to.not.reverted;
                await expect(councilMember.mint(support.address)).to.not.reverted;
                await expect(councilMember.mint(holder.address)).to.not.reverted;
                await expect(councilMember.mint(target.address)).to.not.reverted;
                /* 2. Verify initial NFT ownership */
                expect(await councilMember.ownerOf(0)).to.equal(member.address);
                expect(await councilMember.ownerOf(1)).to.equal(support.address);
                expect(await councilMember.ownerOf(2)).to.equal(holder.address);
                expect(await councilMember.ownerOf(3)).to.equal(target.address);
                /* 3. Distribute TELCOINS between members*/
                await councilMember.retrieve();
                /* 4. Verify starting balances */                
                expect(await councilMember.balances(0)).to.equal(208);
                expect(await councilMember.balances(1)).to.equal(108);
                expect(await councilMember.balances(2)).to.equal(58);
                expect(await councilMember.balances(3)).to.equal(25); 
                /* 5. User "support" burns their NFT - ID 1 */
                await councilMember.burn(1, support.address);
                /* 6. User "target" can't claim their TELCOINs anymore, as its balance was deleted */
                await expect(councilMember.connect(target).claim(3, 25)).to.be.reverted; 
                
            })
})

Impact

High, as the user gets locked from their tokens.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L218-L220

Tool used

Manual Review

Recommendation

The length of the balances array should not be altered in the CouncilMember::burn() method.

function burn(
        uint256 tokenId,
        address recipient
    ) external onlyRole(GOVERNANCE_COUNCIL_ROLE) {
        require(totalSupply() > 1, "CouncilMember: must maintain council");
        _retrieve();
        _withdrawAll(recipient, tokenId);

-        uint256 balance = balances[balances.length - 1];
-        balances[tokenId] = balance;
-        balances.pop(); //@audit H1 - Wrong balance tracking! Indexes in balance can change
+        delete balances[tokenId];	
        _burn(tokenId);
    }

The CouncilMember::retrieve() method has to be adjusted, so that it won't distribute TELCOINs to balances corresponsing to burned NFTs:

// Add the individual balance to each council member's balance
for (uint i = 0; i < balances.length; i++) {
+	if(this.ownerOf(i) != address(0)) {
        balances[i] += individualBalance;
+	}
}

Duplicate of #199

0x_Sanzcy - `removeFromOffice` uses `_transfer` instead of `_safeTransfer`

0x_Sanzcy

medium

removeFromOffice uses _transfer instead of _safeTransfer

Summary

Vulnerability Detail

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit%2Fcontracts%2Fsablier%2Fcore%2FCouncilMember.sol#L122-L134

    function removeFromOffice(
        address from,
        address to,
        uint256 tokenId,
        address rewardRecipient
    ) external onlyRole(GOVERNANCE_COUNCIL_ROLE) {
        // Retrieve and distribute any pending TELCOIN for all council members
        _retrieve();
        // Withdraw all the TELCOIN rewards for the specified token to the rewardRecipient
        _withdrawAll(rewardRecipient, tokenId);
        // Transfer the token (representing the council membership) from one address to another

//@audit- use safeTransfer instead 
        _transfer(from, to, tokenId);
    }

In the context of ERC721, the transfer function itself does not revert if the recipient address does not implement the onERC721Received function. The transfer function, unlike safeTransfer , does not include the additional checks to prevent potential loss of tokens in cases where the recipient address does not handle ERC721 tokens as expected.

When using the standard transfer function for ERC721 token transfers, if the recipient address does not implement the onERC721Received function or is not an ERC721 receiver contract, the transfer will still occur, potentially resulting in a loss of tokens if the recipient cannot handle the incoming tokens appropriately.

Impact

Potential loss of council membership NFT

Code Snippet

Tool used

Manual Review

Recommendation

employing safeTransfer is recommended as a best practice for ERC721 token transfers within contracts

Irissme - Lack of Security Check in recoverERC20FromStaking Function

Irissme

medium

Lack of Security Check in recoverERC20FromStaking Function

Summary

The function currently relies on the existence check (stakingExists[staking]) but does not explicitly verify that staking is of the correct contract type.

Vulnerability Detail

The recoverERC20FromStaking function in the StakingRewardsManager.sol contract lacks a security check to ensure that the staking contract is a valid instance of the StakingRewards contract. This omission may lead to execution errors and potential vulnerabilities.

Impact

Hypothetical Scenario:

The StakingRewardsManager contract is managing multiple instances of StakingRewards contracts, each associated with different tokens and reward configurations.

Exploitation:

An attacker identifies a StakingRewards contract, which is not of the correct type or is a malicious contract deployed to resemble a legitimate staking contract.

The attacker exploits the lack of a type check in the recoverERC20FromStaking function and calls it with the malicious contract as the staking parameter.

Unauthorized Operation:

The malicious contract implements the recoverERC20 function to perform unauthorized actions, such as transferring funds to an address controlled by the attacker.

The lack of a proper type check allows the attacker to invoke the recoverERC20 function on the malicious contract, leading to the unauthorized movement of funds.

Impact:

As a result of the successful exploitation, the attacker gains control over the recovery process and can divert funds intended for recovery to an unintended destination.

This can lead to financial losses, disruption of the intended operation of the staking contracts, and potential reputational damage for the entire ecosystem relying on the StakingRewardsManager contract.

Mitigation:

Implementing a proper type check in the recoverERC20FromStaking function ensures that only instances of the expected StakingRewards contract type can execute the recovery operation. This mitigation prevents unauthorized contracts from manipulating the recovery process, enhancing the security and integrity of the system.

The impact of potential unauthorized operations can be mitigated, safeguarding the functionality and security of the StakingRewardsManager contract and its associated staking contracts.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/telx/core/StakingRewardsManager.sol#L230-L237

Tool used

Manual Review

Recommendation

Add a security check in the recoverERC20FromStaking function to ensure that the staking contract is an instance of the StakingRewards contract before calling its methods. For example:

require(address(staking).isContract(), "Invalid staking contract");
require(staking instanceof StakingRewards, "Invalid staking contract type");
staking.recoverERC20(to, tokenAddress, tokenAmount);

This change adds an additional check to verify the contract type before proceeding with the recovery operation.

s1l3nt - Malicious member of the Governance Council can trigger a reentrancy attack

s1l3nt

high

Malicious member of the Governance Council can trigger a reentrancy attack

Summary

Personnel with GOVERNANCE_COUNCIL_ROLE can use the external function CouncilMember.claim() to trigger a reentrancy attack in _retrieve(). Meaning that, a malicious actor in the Governance Council can retrieve funds from all council members

Vulnerability Detail

_retrieve()

The _stream.execute(_target,...) call, can execute the withdrawal operation from an arbitrary implementation, _target, which is strcitly updated only by GOVERNANCE_COUNCIL_ROLE.

   function _retrieve() internal {
        // Get the initial TELCOIN balance of the contract
        uint256 initialBalance = TELCOIN.balanceOf(address(this));
        // Execute the withdrawal from the _target, which might be a Sablier stream or another protocol
        _stream.execute(
            _target,                        //[x]
            abi.encodeWithSelector(
                ISablierV2ProxyTarget.withdrawMax.selector,
                _target,
                _id,
                address(this)
            )
        );

        // Get the new balance after the withdrawal
        [...]

The function only checks the initialBalance and performs the withdrawal which can be any kind of implementation, just later it does boundary checking and calculate the split for the other members of the Council.

_retrieve() is called in the function claim(), the entry point.

claim() allows the reentrancy attack because it also does not keep track of the TELCOIN that are being retrieved when the vulnerable function _retrieve() is triggered, as shown below:

 function claim(uint256 tokenId, uint256 amount) external {
        // Ensure the function caller is the owner of the token (council member) they're trying to claim for
        require(
            _msgSender() == ownerOf(tokenId),
            "CouncilMember: caller is not council member holding this NFT index"
        );
        // Retrieve and distribute any pending TELCOIN for all council members
        _retrieve();

       // [...]

Impact

A malicious member of the Council stealing funds from all members.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L92

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L267

Tool used

Manual Review

Recommendation

Track the amount of tokens retrieved before the execution of an arbitrary withdrawal implementation from _target.
Do not rely on arbitrary code execution on critical operations.

krkba - `claim()` function does not check if the amount is greater than zero.

krkba

high

claim() function does not check if the amount is greater than zero.

krkba

Summary

Vulnerability Detail

If the function does not check whether the claimed amount is greater than zero, it can have potentially impact on the contract security.

Impact

Without check for a positive amount, caller might be able to claim rewards with an amount of zero or even a negative value.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L92-L111

Tool used

Manual Review

Recommendation

include a validation check in the claim function to ensure that the claimed amount is greater than zero.

IvanFitro - CouncilMember.sol :: Burning a NFT impossibilities minting new NFTs (DOS).

IvanFitro

high

CouncilMember.sol :: Burning a NFT impossibilities minting new NFTs (DOS).

Summary

mint() is used to create new NFTs for users. However, a problem arises when an NFT is burned, making it impossible to mint new NFTs due to the calculation of the nftID being dependent on the totalSupply().

Vulnerability Detail

When the mint() is called to create a new NFT for a user, the calculation of the nftID relies on the totalSupply().

function mint(
        address newMember
    ) external onlyRole(GOVERNANCE_COUNCIL_ROLE) {
        if (totalSupply() != 0) {
            _retrieve();
        }

        balances.push(0);
        _mint(newMember, totalSupply());
    }

Initially, this process works as expected. However, a problem arises when an NFT is burned using the burn() , as it decrements the totalSupply.

The issue becomes apparent when the burned NFT is not the latest one minted. In such a scenario, the subsequent call to mint() reverts. This failure occurs because it attempts to use an nftID that already exists (owned by other user).

As a consequence, the transaction reverts with the ERC721 custom error ERC721InvalidSender("0x0000000000000000000000000000000000000000").
This is because for the successful minting of a new NFT, the previous owner must be the zero address. This situation provocates a Denial of Service (DOS).

POC

To run the POC, copy the provided code into the CouncilMember.test.ts file.

describe("Burn custom", () => {

            beforeEach(async () => {
                telcoin.transfer(await stream.getAddress(), 100000);
                await expect(councilMember.mint(member.address)).to.not.reverted;
                await expect(councilMember.mint(support.address)).to.not.reverted;
                await expect(councilMember.mint(await stream.getAddress())).to.not.reverted;
            });

            it("if a NFT is burned impossibilities mint new NFT", async () => {
                await councilMember.burn(0, support.address);
                await expect(councilMember.mint(member.address)).to.revertedWithCustomError(councilMember, "ERC721InvalidSender");
            });
        });

Impact

New NFTs can't be minted (DOS).

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L173-L182
https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L210-L222

Tool used

Manual Review.

Recommendation

To address this issue, introduce a state variable that increments with each minted NFT.

uint256 nftID;
function mint(
        address newMember
    ) external onlyRole(GOVERNANCE_COUNCIL_ROLE) {
        if (totalSupply() != 0) {
            _retrieve();
        }

        balances.push(0);
-       _mint(newMember, totalSupply());
+       _mint(newMember, nftID);
+       nftID++
    }

Duplicate of #199

p-tsanev - TelcoinDistributor.sol - Pausing the contract would disable challenging transactions

p-tsanev

medium

TelcoinDistributor.sol - Pausing the contract would disable challenging transactions

Summary

The TelcoinDistributor contract allows for the proposal, execution and challenging(cancellation) of transactions. It also introduces pausing functionality, probably to deal with external integration pausing and as a security measure. This functionality can give unfair advantage to proposers.

Vulnerability Detail

The functions for executing and creating proposals are correctly safe-guarded with the whenNotPaused modifier, stopping the creation and execution of proposals. But an unfair advantage is created because the challengeTransaction function has the whenNotPaused modifier as well.
Depending the the duration of the pause it is highly possible for proposals created before the pause, either intentionally via front-running or accidentally, to pass their challenge period, giving council members no way to challenge. Thus creating an unfair advantage during the pause and potentially allowing malicious/unfavorable transactions to reach execution.

Impact

Unfair advantage during pause, potential stealing of funds from the owner() due to inability to challenge proposal

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/protocol/core/TelcoinDistributor.sol#L115-L136

Tool used

Manual Review

Recommendation

Remove the whenNotPaused modifier from the challengeTransaction function.

krkba - No zero address validation in `setRewardsDistribution` function

krkba

medium

No zero address validation in setRewardsDistribution function

krkba

Summary

Vulnerability Detail

The setRewardsDistribution function does not validate the input address.

Impact

It can be a zero address, which leads to unexpected behavior.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/telx/abstract/RewardsDistributionRecipient.sol#L42-L47

Tool used

Manual Review

Recommendation

The function should check that the address is not the zero address.

Ignite - The mint() function may fail if the totalSupply() is equal to the number of already minted NFT

Ignite

high

The mint() function may fail if the totalSupply() is equal to the number of already minted NFT

Summary

The mint() function uses totalSupply() as a token ID when minting, which may duplicate with an already minted NFT, resulting in a failed minting process.

Vulnerability Detail

The CouncilMember contract has imported the ERC721EnumerableUpgradeable abstract to make a fungible token contract.

import "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol";

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L4

The mint() function is designed to mint new NFTs for governance council members. It then mints a new NFT for the provided newMember address with a token ID equal to the current total supply.

function mint(
    address newMember
) external onlyRole(GOVERNANCE_COUNCIL_ROLE) {
    if (totalSupply() != 0) {
        _retrieve();
    }

    balances.push(0);
    _mint(newMember, totalSupply());
}

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L173-L182

However, the token ID can be duplicated if the governance burns an NFT and the totalSupply() matches an already minted NFT.

function burn(
    uint256 tokenId,
    address recipient
) external onlyRole(GOVERNANCE_COUNCIL_ROLE) {
    require(totalSupply() > 1, "CouncilMember: must maintain council");
    _retrieve();
    _withdrawAll(recipient, tokenId);

    uint256 balance = balances[balances.length - 1];
    balances[tokenId] = balance;
    balances.pop();
    _burn(tokenId);
}

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L210-L222

For example:

  1. The governance council mints 4 NFTs with token IDs 0, 1, 2, and 3, resulting in a totalSupply() of 4.
  2. The governance council burns token ID 1, reducing the totalSupply() to 3.
  3. If the governance wants to mint a new NFT, this function may be reverted at lines 316-318 in the ERC721Upgradeable contract from OpenZeppelin since the previousOwner of tokenId 3 is not the zero address.
function _mint(address to, uint256 tokenId) internal {
    if (to == address(0)) {
        revert ERC721InvalidReceiver(address(0));
    }
    address previousOwner = _update(to, tokenId, address(0));
    if (previousOwner != address(0)) {
        revert ERC721InvalidSender(address(0));
    }
}

https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/blob/dee3ce0d2ab61c1fd6641595d3067c6ca1ce117c/contracts/token/ERC721/ERC721Upgradeable.sol#L316-L318

As a result, the governance council may need to burn the NFT before minting the new NFT to control the next token ID; otherwise, they cannot mint a new NFT.

Impact

Govenance council may not be able to mint a new council member NFT.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L181

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L210-L222

Tool used

Manual Review

Recommendation

I suggest using counter instead of totalSupply() to ensure that tokenId cannot be duplicated.

Remove the balances array and directly transfer allocated Telcoin rewards to the NFT owner when called _retrieve().

Duplicate of #199

eeshenggoh - StakingRewardsManager can be DoS if not owner of StakingRewards

eeshenggoh

high

StakingRewardsManager can be DoS if not owner of StakingRewards

Summary

The StakingRewardsManager manages the staking rewards contract. The access control applied in staking rewards can deny calls of the managers.

Vulnerability Detail

In StakingRewards.sol, ownership was intended to transfer to rewardsDistribution instead of StakingRewardsManager. Functions like transferStakingOwnership, recoverERC20FromStaking, and _addStakingRewardsContract in StakingRewardsManager are unable to execute due to the onlyOwner and onlyRewardsDistribution modifiers causing function calls to revert, leading to denial of service.

Furthermore, the StakingRewards.sol::notifyRewardAmount() function has a onlyRewardsDistribution modifier which can be set by owner.

The way that the contract implements access control is unnecessarily confusing. Hence my recommendations.

Impact

Admins of StakingRewardsManager are denied the ability to make function calls.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/telx/core/StakingRewardsManager.sol#L151
https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/telx/core/StakingRewardsManager.sol#L223
https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/telx/core/StakingRewardsManager.sol#L151
https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/telx/core/StakingRewardsManager.sol#L274

Tool used

Manual Review

Recommendation

Decide if you want to transfer ownership to another contract other than StakingRewardManager, if so, ensure that the onlyOwner modifer is replaced with onlyRewardsDistribution. And call the setRewardsDistribution to set StakingRewardsManager.
or
Ensure the transfer of ownership is to StakingRewardsManager, and swap the onlyRewardsDistribution with onlyOwner

Duplicate of #100

s1l3nt - Reward token can be more valuable than the staking token

s1l3nt

high

Reward token can be more valuable than the staking token

Summary

It is possible to add a staking reward contract with a reward token different than the staking token.

Vulnerability Detail

The function createNewStakingRewardsContract() does not verify if the reward tokens matches the staking token, check [1] and [2] there and proceed with the cross reference for this function and we can confirm that there is not a check regarding this.

createNewStakingRewardsContract()

 function createNewStakingRewardsContract(
        IERC20 stakingToken,
        StakingConfig calldata config
    ) external onlyRole(BUILDER_ROLE) {
        // create the new staking contract
        // new staking will have owner and rewardsDistribution set to address(this)
        StakingRewards staking = StakingRewards(
            address(
                stakingRewardsFactory.createStakingRewards(
                    address(this),
                    IERC20(address(rewardToken)),                         // [x]
                    IERC20(stakingToken)                                      // [x]
                )
            )
        );
        //internal call to add new contract
        _addStakingRewardsContract(staking, config);

addStakingRewardsContract()

function addStakingRewardsContract(
        StakingRewards staking,
        StakingConfig calldata config
    ) external onlyRole(BUILDER_ROLE) {
        //checking if already exists
        require(
            !stakingExists[staking],
            "StakingRewardsManager: Staking contract already exists"
        );
        //internal call to add new contract
        _addStakingRewardsContract(staking, config);
    }

_addStakingRewardsContract()

 function _addStakingRewardsContract(
        StakingRewards staking,
        StakingConfig calldata config
    ) internal {
        // in order to manage this contract we have to own it
        // staking.acceptOwnership();
        // in order to top up rewards, we have to be rewardsDistribution. this is an onlyOwner function
        staking.setRewardsDistribution(address(this));

        // push staking onto stakingContracts array
        stakingContracts.push(staking);
        // set staking config
        stakingConfigs[staking] = config;
        // mark inclusion in the stakingContracts array
        stakingExists[staking] = true;

        emit StakingAdded(staking, config);
    }

Impact

The reward token can be more valuable than the staking token, i.e. staking $lol and receive $eth as token reward.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/telx/core/StakingRewardsManager.sol#L102

Tool used

Manual Review

Recommendation

Add a check at createNewStakingRewardsContract() like:

require(IERC20(stakingToken) == IERC20(address(rewardToken), "Reward token does not match Staking token");

ubl4nk - Use safeTransferFrom instead of transferFrom

ubl4nk

medium

Use safeTransferFrom instead of transferFrom

Summary

The transferFrom() function returns a boolean value indicating success. This parameter needs to be checked to see if the transfer has been successful.

Vulnerability Detail

Some tokens like EURS and BAT will not revert if the transfer failed but return false instead.
So if the rewardToken is one of these tokens, the return value is not checked:

function topUp(
        address source,
        uint256[] memory indices
    ) external onlyRole(EXECUTOR_ROLE) {
        for (uint i = 0; i < indices.length; i++) {
            // get staking contract and config
            StakingRewards staking = stakingContracts[i];
            StakingConfig memory config = stakingConfigs[staking];

            // will revert if block.timestamp <= periodFinish
            staking.setRewardsDuration(config.rewardsDuration);

            // pull tokens from owner of this contract to fund the staking contract
            rewardToken.transferFrom(
                source,
                address(staking),
                config.rewardAmount
            );

            // start periods
            staking.notifyRewardAmount(config. rewardAmount);

            emit ToppedUp(staking, config);
        }
    }

Do to that the notifyRewardAmount is called just after transferFrom and StakingRewards is out-of-scope, so any accounting/protcol-update which has not considered false-transfers inside the notifyRewardAmount, will cause major problems for the protocol.

Impact

Any update/accounting inside the notifyRewardAmount without considering false-transfers, will cause major problems for the protocol.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/telx/core/StakingRewardsManager.sol#L254-L278

Tool used

Manual Review

Recommendation

Use OZ’s SafeERC20’s safeTransferFrom() function.

Duplicate of #8

krkba - No check if `StakingRewards` contract is created successfuly and not equal zero

krkba

medium

No check if StakingRewards contract is created successfuly and not equal zero

krkba

Summary

There is no check if StakingRewards contract is created successfuly and not equal zero.

Vulnerability Detail

Impact

It can set to zero address by mistake.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/telx/core/StakingRewardsFactory.sol#L49-L53

Tool used

Manual Review

Recommendation

add a check to ensure that the new contract address is not a zero address.

Duplicate of #4

krkba - Potential Denail of Service in `removeStakingRewardsContract` function

krkba

medium

Potential Denail of Service in removeStakingRewardsContract function

krkba

Summary

Vulnerability Detail

If the given index is out of bounds either negative or greater than or equal to the array length, accessing that index will result in an error, the function call will fail.

Impact

it can lead to DOS attcak

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/telx/core/StakingRewardsManager.sol#L166-L179

Tool used

Manual Review

Recommendation

Add a require statement to check that the index is within bounds.

Duplicate of #43

fibonacci - The `CouncilMember` contract DoS due to the `_retrieve` function revert

fibonacci

high

The CouncilMember contract DoS due to the _retrieve function revert

Summary

The _retrievefunction is called before any significant state changes. This function executes the withdrawal from the _target, which might be a Sablier stream or another protocol. SablierV2Lockup reverts if withdrawable amount equals to 0.

https://github.com/sablier-labs/v2-core/blob/b0016437ef3cc8606e1100965dd911d7e658b40b/src/abstracts/SablierV2Lockup.sol#L297-L299
https://github.com/sablier-labs/v2-core/blob/b0016437ef3cc8606e1100965dd911d7e658b40b/src/abstracts/SablierV2Lockup.sol#L270-L272

Funds are distributed over time. And even if there are always funds in the protocol for distribution, after calling the _retrieve function, a new distribution will not be available until another period of time has passed.

This means that any interaction with the CouncilMember contract will be unavailable during this time.

Vulnerability Detail

1. If the protocol for distributing funds employs a strategy that permits funds to be released once within a specific timeframe (for instance, 1 day, 1 week, or 1 month), this implies that the CouncilMember contract will execute its tasks error-free only once during this period.

2. The removeFromOffice function calls the _retrievefunction at the beginning to retrieve and distribute any pending TELCOIN for all council members, and transfer token ownership at the end.

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L267-L295

The _update function, which is called before each transfer, is overridden and also calls the _retrieve function.

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L321-L331

Thus, during the removeFromOffice function, the _retrieve function will be called twice, which will always result in revert, since after the first distribution of funds, when called again, the withdrawable amount will be 0.

3. Also, according to the sponsor's comment, the council members are semi-trusted. A malicious member can prevent others from interacting with the contract. For example:

  • Member A wants to claim their allocated amounts of TELCOIN
  • Member B fron-runs member's A transaction and call the retrieve function
  • Member's A transaction reverts because the _retrieve function is called again but there are no more withdrawable amount.

Impact

Denial of Service of the CouncilMember contract over a period of time, depending on the fund distribution strategy. The removeFromOffice function always fails, leading to the necessity to use the transferFrom function, which does not call _withdrawAll, potentially breaking the state of the contract

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L267-L295

Tool used

Manual Review

Recommendation

Check amount before executing withdrawal or wrap call in a try/catch block. Also consider abandoning the removeFromOffice function, use transferFrom instead and move _withdrawAll call to _update function.

Kow - Removing any token with id less than the highest `tokenId` will block the holder from claiming their allocated TELCOIN and prevent further minting

Kow

high

Removing any token with id less than the highest tokenId will block the holder from claiming their allocated TELCOIN and prevent further minting

Summary

Flawed implementation of burn will prevent the holder of the token with the highest tokenId from claiming their allocated Telcoin.

Vulnerability Detail

In CouncilMember.sol, the burn function allows council admins to remove an existing tokenId (which indicates council member status).
https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L210-L222

    function burn(
        uint256 tokenId,
        address recipient
    ) external onlyRole(GOVERNANCE_COUNCIL_ROLE) {
        require(totalSupply() > 1, "CouncilMember: must maintain council");
        _retrieve();
        _withdrawAll(recipient, tokenId);

        uint256 balance = balances[balances.length - 1];
        balances[tokenId] = balance;
        balances.pop();
        _burn(tokenId);
    }

The balances array (which records allocated TELCOIN) is updated by swapping the balance of the tokenId being removed with the last balance then popping off the last element. The issue is tokenId also serves as an index for the balances array as it is calculated using the totalSupply at minting.
https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L180-L181

        balances.push(0);
        _mint(newMember, totalSupply());

Consequently, if the tokenId being burnt is not the highest tokenId, any attempt by the holder of the highest tokenId to call claim for their token will panic since their tokenId is now an invalid index into balances.
https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L102-L105

        require(
            amount <= balances[tokenId],
            "CouncilMember: withdrawal amount is higher than balance"
        );

Both burn and removeFromOffice will now revert for the affected tokenId since both functions attempt to access the balances array using the now invalid tokenId in a call to _withdrawAll. Recovery by calling the same functions on the removed tokenId (which is still a valid index) will also revert since the token doesn't exist preventing necessary transfer and burn respectively. This prevents recovery via these functions.
https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L339-L342

    function _withdrawAll(address from, uint256 tokenId) internal {
        TELCOIN.safeTransfer(from, balances[tokenId]);
        balances[tokenId] = 0;
    }

The support role must call erc20Rescue to manually transfer the allocated TELCOIN balance of the excluded holder now recorded and updated in the replaced tokenId index. It should be noted however that the balance will have to be manually tracked since the balance recorded in the contract can't be zeroed or changed.

Furthermore, minting of new tokens will revert since the next tokenId calculated from the token supply will equate to the highest tokenId which still exists.

Impact

Blocked claiming of TELCOIN for council members (further calls to burn will increase affected members) and DoS of minting functionality.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L210-L222
https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L180-L181
https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L102-L105
https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L339-L342

Tool used

Manual Review

Recommendation

Use a mapping to record and update TELCOIN balances while maintaining an array of token ids that are currently minted (that can be iterated through when distributing TELCOIN). Calculate the token id using an integer storage variable that is incremented on every mint.

Duplicate of #199

mstpr-brainbot - Transfer and burn skips revoking previous allowance

mstpr-brainbot

medium

Transfer and burn skips revoking previous allowance

Summary

When a token is transferred, burned, or minted, the allowance is not reset as it is in a typical ERC721.

Vulnerability Detail

The CouncilMember NFT contract uses a different storage variable to track allowances than ERC721 does.
https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L46

Governance can give an allowance to any token ID holder on behalf of anyone.
https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L191-L201

When the token is transferred from one address to another, whether triggered by governance or the approver, the allowance is not reset. So, if governance transfers Alice's NFT to Bob, Alice's NFT's approved target is the same for Bob too. This is not inconsistent with typical ERC721 behavior.

Also, not only for transfers but burns should also revoke the allowance. If governance burns the NFT without revoking the allowances then revoking the burnt NFT's allowance is impossible due to the ownerOf() check inside the event
https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L200

Impact

Since allowance can be given by governance to any arbitrary address transferring an nft could cause problems if the previous allowed target can act maliciously. Since this is fully governance controlled I'll label this as medium rather than a high.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L191-L201

https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L46

https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L200

Tool used

Manual Review

Recommendation

Inside _update revoke the current allowance

CaptainCrypto - Inadequate Validation in proposeTransaction Function

CaptainCrypto

medium

Inadequate Validation in proposeTransaction Function

Summary

The proposeTransaction function lacks sufficient validation for the input arrays, potentially leading to inconsistencies or unintended behavior.

Vulnerability Detail

The function allows a proposer to specify an array of destinations and corresponding amounts for Telcoin distribution. However, there are no checks to ensure that the lengths of these arrays are equal or that the sum of the amounts matches the total withdrawal amount. This oversight could lead to situations where the total distributed amount does not align with the intended withdrawal amount.

Impact

This vulnerability can lead to discrepancies in fund distribution, resulting in either excess distribution or funds remaining in the contract, potentially causing financial discrepancies or loss.

Code Snippet

// TelcoinDistributor.sol - Line 93
function proposeTransaction(uint256 totalWithdrawl, address[] memory destinations, uint256[] memory amounts) external onlyCouncilMember whenNotPaused {
    // ... existing code ...
    // Validation needed for destinations and amounts arrays
    proposedTransactions.push(ProposedTransaction({
        totalWithdrawl: totalWithdrawl,
        destinations: destinations,
        amounts: amounts,
        // ...
    }));
    // ... existing code ...
}

View in repository

Tool used

Manual Review

Recommendation

Implement checks to ensure that the lengths of destinations and amounts arrays are equal and that their combined total matches the totalWithdrawl amount. This will ensure consistency and prevent potential fund misallocation.

Duplicate of #2

Irissme - Incomplete Access Control in initialize Function

Irissme

high

Incomplete Access Control in initialize Function

Summary

The initialize function in the StakingRewardsManager contract lacks a thorough access control check, potentially allowing unauthorized users to call the function. This could lead to unexpected behavior and compromise the security of the contract.

Vulnerability Detail

The initialize function uses the hasRole function to check if the caller has the DEFAULT_ADMIN_ROLE. However, after the check, the function proceeds to grant the DEFAULT_ADMIN_ROLE to the caller using _grantRole. This sequence of actions leaves a window where a malicious actor could exploit the lack of a proper access control check.

Impact

Consider a scenario where the StakingRewardsManager contract is deployed, and the initialize function is not securely implemented. An attacker, aware of this vulnerability, exploits it by calling the initialize function with their own address, allowing them to gain unauthorized admin privileges.

Unauthorized Admin Access:

The attacker successfully calls the initialize function with their address.
The function, lacking a proper access control check, grants DEFAULT_ADMIN_ROLE to the attacker using _grantRole(DEFAULT_ADMIN_ROLE, _msgSender()).

Manipulation of Staking Contracts:

As an unauthorized admin, the attacker gains control over the StakingRewardsManager contract and its associated StakingRewards contracts.
The attacker can now modify the configurations, rewards, or even remove existing staking contracts, leading to financial losses for legitimate users.

Insecurity of Funds:

The attacker, having admin privileges, may attempt to recover ERC20 tokens from StakingRewards contracts or manipulate the reward distribution process.
This can result in the loss of funds, disrupt the intended functionality of the contracts, and erode user trust in the system.

Reputation Damage:

The compromise of the StakingRewardsManager contract's security can lead to a loss of reputation for the project and its developers.
Users may lose confidence in the platform, affecting adoption rates and the overall success of the project.

Legal Consequences:

Depending on the severity of the unauthorized access and its impact on users, the project may face legal consequences.
Regulatory bodies may intervene, imposing fines or taking legal action against the project and its developers.

In summary, the impact of this vulnerability goes beyond the immediate manipulation of contract functionality, extending to financial losses, damage to reputation, and potential legal ramifications for the project and its stakeholders. It underscores the critical importance of implementing robust access controls to safeguard smart contracts and their users.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/telx/core/StakingRewardsManager.sol#L70-L85

Tool used

Manual Review

Recommendation

function initialize(
    IERC20 reward,
    StakingRewardsFactory factory
) external initializer {
    require(hasRole(DEFAULT_ADMIN_ROLE, _msgSender()), "Caller is not an admin");
    require(address(factory) != address(0) && address(reward) != address(0), "StakingRewardsManager: cannot initialize to zero");
    
    // Revoke the admin role from the caller immediately after the check
    renounceRole(DEFAULT_ADMIN_ROLE, _msgSender());

    // set values
    rewardToken = reward;
    stakingRewardsFactory = factory;
    emit StakingRewardsFactoryChanged(factory);
}

ubl4nk - Risk of locked assets due to use of _mint instead of _safeMint

ubl4nk

high

Risk of locked assets due to use of _mint instead of _safeMint

Summary

ERC-721 tokens are minted via the _mint function rather than the _safeMint function.
Due to that a councilMember can be contract, the _safeMint should be used.

Vulnerability Detail

The _safeMint function includes a necessary safety check that validates a recipient contract’s ability to receive and handle ERC-721 tokens.
Without this safeguard, tokens can inadvertently be sent to an incompatible contract, causing them, and any assets they hold, to become irretrievable:

function mint(
        address newMember
    ) external onlyRole(GOVERNANCE_COUNCIL_ROLE) {
        if (totalSupply() != 0) {
            _retrieve();
        }

        balances.push(0);
        _mint(newMember, totalSupply());
    }

Impact

If councilMember contract is unable to receive ERC721 tokens (doesn't support onERC721Received), the NFT asset will be lost.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L181

Tool used

Manual Review

Recommendation

Use the _safeMint function instead of _mint.

djanerch - Denial of Service because of looping over unbounded array

djanerch

high

Denial of Service because of looping over unbounded array

Summary

Iterating over an unbounded array can result in a Denial of Service (DoS) attack due to high gas costs.

Vulnerability Detail

As the number of members increases in the CouncilMember smart contract, a new element is added to the balances array to track the individual balances of each member. This can lead to significantly more expensive transactions when invoking the _retrieve function, as it loops over the entire balances array. The high gas costs associated with these transactions may render the application unusable, as users would be reluctant to pay for such expensive transactions.

Impact

This vulnerability will make 6 functions unusable because they use _retrieve function internally.

Code Snippet

Balances Array

For-loop

Tool used

Manual Review

Recommendation

Limit the number of balances that can be created, for example maximum 25 balances.

PoC

Install and set hardhat-gas-reporter plugin. Add following code to test file to see difference with one balance and one hundred balances.

describe("Denial Of Service", () => {
            it("Testing gas with one user", async () => {
                await expect(councilMember.mint(member.address));
                expect(await telcoin.balanceOf(member.address)).to.equal(0);
                expect(await councilMember.balances(0)).to.equal(0);

                await expect(councilMember.retrieve()).to.not.reverted;
                expect(await councilMember.balances(0)).to.equal(100);
                // results here is 125473
            });
            
            it("Testing gas with one hundred users", async () => {
                for(let i = 0; i < 100; i++){
                    const wallet = ethers.Wallet.createRandom().connect(ethers.provider);
                    await expect(councilMember.mint(wallet.address));
                }

                await expect(councilMember.retrieve()).to.not.reverted;
                // results here is 628150
            });// it's 5 times expensive
        });

Duplicate of #162

Irissme - Missing Address Validation in setRewardsDistribution Function

Irissme

medium

Missing Address Validation in setRewardsDistribution Function

Summary

The vulnerability is related to the absence of a check for the zero address in the setRewardsDistribution function, allowing the assignment of an invalid address.

Vulnerability Detail

The setRewardsDistribution function in the RewardsDistributionRecipient.sol contract lacks validation to check whether the provided rewardsDistribution_ address is not the zero address (address(0)). This omission could potentially lead to issues, as the contract might lose control over the reward distribution if rewardsDistribution_ is the zero address.

Impact

If an attacker provides the zero address as the rewardsDistribution_ argument, it could lead to unexpected behavior, and the contract might lose control over the reward distribution mechanism.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/telx/abstract/RewardsDistributionRecipient.sol#L42-L51

Tool used

Manual Review

Recommendation

It is recommended to add a validation check at the beginning of the setRewardsDistribution function to ensure that the provided rewardsDistribution_ address is not the zero address. This can be achieved using the following modification:

require(rewardsDistribution_ != address(0), "Invalid rewards distribution address");

Duplicate of #4

krkba - lack of input validation for array lengths in `proposeTransaction()` function

krkba

medium

lack of input validation for array lengths in proposeTransaction() function

krkba

Summary

Vulnerability Detail

When there is a lack of input validation for array lengths, it means the contract does not verify whether the lengths of destinations array and amounts array match before proceeding with execution the function.

Impact

Mismatched array lengths can potentially exploited by attacker to manipulate the contract behavior, they may attempt to provide invalid or unexpected data, causing the contract to behave in unintended ways.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/protocol/core/TelcoinDistributor.sol#L87-L106

Tool used

Manual Review

Recommendation

The contract should check whether the lengths of destinations and amounts arrays match before proceeding.

Duplicate of #2

krkba - Reentrancy attack in `claim` function

krkba

high

Reentrancy attack in claim function

krkba

Summary

Vulnerability Detail

The claim function allows council members to withdraw their allocated amounts of TELCOIN. If the TELCOIN token contract is malicious or compromised, it could potentially trigger a reentrancy attack when TELCOIN.safeTransfer is called.

Impact

It can call the claim function repeatedly before the original claim function execution is complete, which allow the attacker to drain funds from the contract.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L92-L111

Tool used

Manual Review

Recommendation

Use a reentrancy guard.

Aamirusmani1552 - `StakingRewardsManager::topUp(...)` Misallocates Funds to `StakingRewards` Contracts

Aamirusmani1552

high

StakingRewardsManager::topUp(...) Misallocates Funds to StakingRewards Contracts

Summary

The StakingRewardsManager::topUp(...) contract exhibits an issue where the specified StakingRewards contracts are not topped up at the correct indices, resulting in an incorrect distribution to different contracts.

Vulnerability Detail

The StakingRewardsManager::topUp(...) function is designed to top up multiple StakingRewards contracts simultaneously by taking the indices of the contract's addresses in the StakingRewardsManager::stakingContracts array. However, the flaw lies in the distribution process:

    function topUp(
        address source,
@>        uint256[] memory indices
    ) external onlyRole(EXECUTOR_ROLE) {
@>        for (uint i = 0; i < indices.length; i++) {
            // get staking contract and config
            StakingRewards staking = stakingContracts[i];
            StakingConfig memory config = stakingConfigs[staking];

            // will revert if block.timestamp <= periodFinish
            staking.setRewardsDuration(config.rewardsDuration);

            // pull tokens from owner of this contract to fund the staking contract
            rewardToken.transferFrom(
                source,
                address(staking),
                config.rewardAmount
            );

            // start periods
            staking.notifyRewardAmount(config.rewardAmount);

            emit ToppedUp(staking, config);
        }
    }

GitHub: [254-278]

The rewards are not appropriately distributed to the StakingRewards contracts at the specified indices. Instead, they are transferred to the contracts at the loop indices. For instance, if intending to top up contracts at indices [1, 2], the actual top-up occurs at indices [0, 1].

Impact

The consequence of this vulnerability is that rewards will be distributed to the incorrect staking contract, leading to potential misallocation and unintended outcomes

Code Snippet

Here is a test for PoC:

Add the below given test in StakingRewardsManager.test.ts File. And use the following command to run the test

npx hardhat test --grep "TopUp is not done to intended staking rewards contracts"

TEST:

        it("TopUp is not done to intended staking rewards contracts", async function () {
            // add index 2 to indices
            // so topup should be done to index 0 and 2
            indices = [0, 2];

            await rewardToken.connect(deployer).approve(await stakingRewardsManager.getAddress(), tokenAmount * indices.length);
            
            // create 3 staking contracts
            await stakingRewardsManager.createNewStakingRewardsContract(await stakingToken.getAddress(), newStakingConfig);
            await stakingRewardsManager.createNewStakingRewardsContract(await stakingToken.getAddress(), newStakingConfig);
            await stakingRewardsManager.createNewStakingRewardsContract(await stakingToken.getAddress(), newStakingConfig);

            // topup index 0 and 2
            await expect(stakingRewardsManager.connect(deployer).topUp(await deployer.address, indices))
                .to.emit(stakingRewardsManager, "ToppedUp");


            // getting the staking contract at index 0, 1 and 2
            let stakingContract0 = await stakingRewardsManager.stakingContracts(0);
            let stakingContract1 = await stakingRewardsManager.stakingContracts(1);
            let stakingContract2 = await stakingRewardsManager.stakingContracts(2);

            // Staking contract at index 2 should be empty
            expect(await rewardToken.balanceOf(stakingContract2)).to.equal(0);

            // Staking contract at index 0 and 1 should have 100 tokens
            expect(await rewardToken.balanceOf(stakingContract0)).to.equal(100);
            expect(await rewardToken.balanceOf(stakingContract1)).to.equal(100);

        });

Output:

AAMIR@Victus MINGW64 /d/telcoin-audit/telcoin-audit (main)
$ npx hardhat test --grep "TopUp is not done to intended staking rewards contracts"


  StakingRewards and StakingRewardsFactory
    topUp
      ✔ TopUp is not done to intended staking rewards contracts (112ms)


  1 passing (2s)

Tool used

  • Manual Review

Recommendation

It is recommended to do the following changes:

    function topUp(
        address source,
        uint256[] memory indices
    ) external onlyRole(EXECUTOR_ROLE) {
        for (uint i = 0; i < indices.length; i++) {
            // get staking contract and config
-            StakingRewards staking = stakingContracts[i];
+           StakingRewards staking = stakingContracts[indices[i]];
            StakingConfig memory config = stakingConfigs[staking];

            // will revert if block.timestamp <= periodFinish
            staking.setRewardsDuration(config.rewardsDuration);

            // pull tokens from owner of this contract to fund the staking contract
            rewardToken.transferFrom(
                source,
                address(staking),
                config.rewardAmount
            );

            // start periods
            staking.notifyRewardAmount(config.rewardAmount);

            emit ToppedUp(staking, config);
        }
    }

mstpr-brainbot - When governance burns an NFT, the claimable balances of other NFT can be mixed

mstpr-brainbot

high

When governance burns an NFT, the claimable balances of other NFT can be mixed

Summary

Governance can burn any NFT; however, burning the NFT also forgets to update claimable balances. Balances will be changed, and remaining NFT holders' balances will be mixed up differently than they should be

Vulnerability Detail

When governance mints NFTs for participants, a separate storage variable, balances (a uint256 array), corresponds to the NFT holders' token IDs.
https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L173-L182

For example, if there are participants Alice, Bob, and Carol:

Alice will be minted tokenId 0, and balances[0] represents Alice.
Bob will be minted tokenId 1, and balances[1] represents Bob.
Carol will be minted tokenId 2, and balances[2] represents Carol.
The balances array looks like this: [AliceClaimable, BobClaimable, CarolClaimable].

When claiming TEL coins from the contract, the tokenId and the balances index have to be the same.
https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L92-L111

Governance also has the ability to burn an NFT. Burning an NFT will swap the last element in balances with the deleted one and pop the array.
https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L210-L222

If governance decides to burn Bob's NFT in the above scenario (tokenId 1), the balances mapping will be like this:
balances = [AliceClaimable, CarolClaimable].
Now, the length of the balances array is 2, and Carol's index is now 1 instead of 2.

If Carol decides to claim, the transaction will fail due to an "array out of bounds" error because Carol's tokenId is 2, and the balances index is 1. Thus, Carol can't claim his claimable TEL coins.

If governance mints another NFT for Dennis, then Carol can claim. However, this time Carol's claimable is not actually Carol's but Dennis's claimable.

Coded PoC:

function test_BurningNFT_MessesBalances() external {
        address newTapir = address(10);

        vm.prank(deployer);
        cm.grantRole(GOVERNANCE_COUNCIL_ROLE, counciler);

        // @dev counciler mints NFT's to the councileeeeers
        vm.startPrank(counciler);
        cm.mint(tapir);
        cm.mint(hippo);
        cm.mint(ape);

        assertEq(cm.ownerOf(0), tapir);
        assertEq(cm.ownerOf(1), hippo);
        assertEq(cm.ownerOf(2), ape);

        // @dev my test suite sends 100 * 1e2 tokens everytime stream executes
        // so I expect these values for the individuals
        assertEq(3 * 100 * 1e2, cm.balances(0));
        assertEq(2 * 100 * 1e2, cm.balances(1));
        assertEq(0.5 * 100 * 1e2, cm.balances(2));
        
        console.log("Balances0", cm.balances(0));
        console.log("Balances1", cm.balances(1));
        console.log("Balances2", cm.balances(2));

        // @dev Counciler decides to remove hippo
        ds.dontSendTel(); // this is just to get cleaner values
        cm.burn(1, hippo);

        console.log("Balances0", cm.balances(0));
        console.log("Balances1", cm.balances(1));

        vm.stopPrank();

        // @dev now ape, tokenId holder 2, wants to claim its TEL coins
        // we expect a out of bonds error because of the burn popped out the array but
        // didnt sorted the balances mapping.
        vm.startPrank(ape);
        vm.expectRevert();
        cm.claim(1, 100);
        vm.stopPrank();
    }

Impact

Claimable balances will be messed up hence, high.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L173-L182

https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L92-L111

https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L210-L222

Tool used

Manual Review

Recommendation

1- If the nft holders are not going to be too many (not more than 50 let's say) then don't pop. Popping will also require you to switch tokenId's aswell as balances indexes which is not easily doable. The gas needed to loop potential empty slots shouldn't be too much considering the NFTs are not many.

2- Store the users tokenId -> balance index and use it when claiming.
mapping (uint256 => uint256) tokenIdToIndex;
tokenId(5) -> 2 means: tokenId 5 owners claimable tokens are stored in balances[2]

Duplicate of #199

alexbabits - Array swap and pop method during burn() leads to complete loss of user rewards and breaks mint()

alexbabits

high

Array swap and pop method during burn() leads to complete loss of user rewards and breaks mint()

Summary

The swap and pop method during CouncilMembers.burn() leads to loss of user rewards from mismatching of accounting for user balances. This is because there is a fragile connection between the user's NFT tokenId and the tracking of their ERC20 TELCOIN balance rewards via the balances array.

The burn() function also results in a broken CouncilMembers.mint() function because the total supply is reduced, which leads to a state where any future mint will try to create an NFT where the tokenId already exists, since tokenId is based directly on the total supply.

Vulnerability Detail

Background: Inside CouncilMembers.mint(), the balances array length and NFT totalSupply() are increasing at the same rate of 1 per call, via balances.push(0); and _mint(). The tokenId assigned to the new member's NFT is the current totalSupply() before mint.

For example, if totalSupply() is 3 for the NFTs, their tokenId is 3, and their associated ERC20 rewards must always be found at balances[3]. The delicate accounting falls apart during a call to burn().

Exploit Example: Imagine the governor needs to call burn() for tokenId 2 council member's NFT. There are currently 5 holders with different TELCOIN ERC20 reward balances (e18 values).

Even though all rewards are distributed equally among holders every time _retrieve() is called, earlier holders will have accumulated more rewards if _retrieve() calls were made.

    function burn(uint256 tokenId, address recipient) external onlyRole(GOVERNANCE_COUNCIL_ROLE) {
        require(totalSupply() > 1, "CouncilMember: must maintain council");
        _retrieve();
        _withdrawAll(recipient, tokenId);
        uint256 balance = balances[balances.length - 1];
        balances[tokenId] = balance;
        balances.pop();
        _burn(tokenId);
    }

balances array state during burn():

[19, 17, 13, 11, 7] = State 0: NFT holders with various TELCOIN balances associated with each of their NFT tokenId's.
[20, 18, 14, 12, 8] = State 1: _retrieve() Juices up all holders balances by 1 for example.
[20, 18, 0, 12, 8] = State 2: _withdrawAll() Sets index 2's balance to 0.
[20, 18, 8, 12, 8] = State 3: balances[tokenId] = balance; Sets index 2's balance to 8.
[20, 18, 8, 12] = State 4: balances.pop(); Removes last index and burns NFT for tokenId 2, reducing the totalSupply() from 5 to 4.

Impact

  1. Holder of tokenId 4 can no longer call claim() because it will revert with out of bounds because index 4 doesn't exist in the array. His balance moved from index 4 to index 2, and is now trapped at that location where tokenId 2 was burned.
  2. There are now tokenId 0, 1, 3, 4 with a total supply of 4. This means anytime the governor calls mint(), it will attempt to mint an NFT for tokenId 4, but that tokenId already exists, so mint() will always revert and is now broken.

Code Snippet

mint(): https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L180-#L181
burn(): https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L218-#L220
claim(): https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L108-#L110

Tool used

Manual Review

Recommendation

Consider using a mapping instead of an array to track associated rewards balances for NFT holders. Requires heavy modification throughout that is not included below.

-    uint256[] public balances;
+    mapping(uint256 => uint256) // NFT tokenId --> TELCOIN rewards

Duplicate of #199

CaptainCrypto - Potential Reentrancy Vulnerability in batchTelcoin Function

CaptainCrypto

medium

Potential Reentrancy Vulnerability in batchTelcoin Function

Summary

The batchTelcoin function in the TelcoinDistributor.sol contract is potentially vulnerable to reentrancy attacks, which can lead to unexpected contract behavior or exploits.

Vulnerability Detail

The batchTelcoin function iterates over an array to distribute Telcoin tokens to multiple destinations using ERC20 safeTransfer. This function does not have a reentrancy guard, and if any destination is a contract, it might allow for unexpected reentrant calls.

Impact

Reentrancy attacks can result in loss of funds, manipulation of contract states, and compromise the integrity of the contract.

Code Snippet

// TelcoinDistributor.sol
function batchTelcoin(uint256 totalWithdrawl, address[] memory destinations, uint256[] memory amounts) internal {
    // ... existing code ...
    for (uint i = 0; i < destinations.length; i++) {
        // Potential reentrancy risk on the line below
        TELCOIN.safeTransfer(destinations[i], amounts[i]);
    }
    // ... existing code ...
}

View in repository

Tool used

Manual Review

Recommendation

Implement a reentrancy guard in the batchTelcoin function using the nonReentrant modifier from OpenZeppelin's ReentrancyGuard contract to prevent reentrant calls.

sobieski - Minting new CouncilMemeber NFTs is no longer possible after burning any but last of the NFTs

sobieski

high

Minting new CouncilMemeber NFTs is no longer possible after burning any but last of the NFTs

Summary

The CouncilMember::mint() method utilizes totalSupply() call to determine the new NFT index. Because in the process of burning NFT totalSupply is decreased, if any of the NFTs (excluding the last one) gets burned, no subsequent mints will be possible, as the contract will try to mint the already existing token ID.

Vulnerability Detail

When new CouncilMember token gets minted, total supply is used to determine which index should the new token get.

_mint(newMember, totalSupply());

The issue is that the totalSupply can be decreased by the CouncilMember::burn() method call. As such, if a token gets burned, subsequent CouncilMember::mint() will revert.

Please consider the following scenario:

  1. Alice mints NFT ID 0. Total supply gets updated to 1.
  2. Bob mints NFT ID 1. Total supply gets updated to 2.
  3. Alice burns her NFT. Total supply gets updated to 1.
  4. Charlie wants to mint a NFT. The CouncilMember::mint() will try to mint NFT ID 1 for him. This will revert, as this ID is already owned by Bob.

The POC for the issue is provided below. Please paste it into CouncilMember.test.ts test suite.

POC

describe("POC", () => {
            it.only("Burning any NFT locks the last NFT owner from their balance", async () => {
                /* 1. Mint 3 NFTs */
                await expect(councilMember.mint(member.address)).to.not.reverted;
                await expect(councilMember.mint(support.address)).to.not.reverted;
                await expect(councilMember.mint(holder.address)).to.not.reverted;
                /* 2. Burn one NFT */
                await councilMember.burn(1, support.address);
                //* 3. No new NFTs can be minted, as the contract attempts to mint ID 1 again */
                await expect(councilMember.mint(member.address)).to.be.revertedWithCustomError(councilMember, "ERC721InvalidSender");
            });            
})

Please note that burning the NFT with the highest ID so far will not have the aforementioned consequences, as this ID will no longer be owned. The issue applies to burning any but last of the NFTs.

Impact

High, as the core functionality of the protocol will be DOSed forever.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L181

Tool used

Manual Review

Recommendation

Keep track of the number of NFTs minted so far and use that number to determine the new index instead of totalSupply.

Duplicate of #199

Irissme - Missing Import for Ownable2Step Library

Irissme

medium

Missing Import for Ownable2Step Library

Summary

During a comprehensive review of the Telcoin smart contract code, it has come to my attention that the Ownable2Step library is utilized within the codebase; however, the necessary import statement for this library is absent.

Vulnerability Detail

The absence of the required import for the Ownable2Step library is a critical issue that can lead to compilation errors, potentially hindering the successful deployment of the smart contract. This oversight may result in unexpected behavior during runtime.

The vulnerability lies in the fact that the Ownable2Step library is used within the code, but the corresponding import statement is missing. This can lead to unresolved references during compilation.

Impact

The impact of this issue is two-fold:

Compilation Errors: The absence of the import statement can prevent the successful compilation of the smart contract, resulting in deployment failures.

Runtime Issues: If the code manages to compile without the necessary import, it may lead to runtime issues, as the functionality provided by the Ownable2Step library will not be correctly incorporated.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/protocol/core/TelcoinDistributor.sol#L4-L9

Tool used

Manual Review

Recommendation

import "@openzeppelin/contracts/access/Ownable2Step.sol";

Irissme - Missing Range Check in removeStakingRewardsContract Function

Irissme

medium

Missing Range Check in removeStakingRewardsContract Function

Summary

The removeStakingRewardsContract function in the StakingRewardsManager.sol file lacks a check to ensure that the provided index i is within the bounds of the stakingContracts array. This omission may lead to unexpected memory access issues.

Vulnerability Detail

The vulnerability lies in the removeStakingRewardsContract function, where the absence of a check on the index may result in accessing memory outside the valid range of the stakingContracts array.

Impact

This vulnerability could potentially lead to runtime errors, including but not limited to accessing unexpected memory locations, which may compromise the integrity and functionality of the contract.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/telx/core/StakingRewardsManager.sol#L166-L179

Tool used

Manual Review

Recommendation

function removeStakingRewardsContract(uint256 i) external onlyRole(BUILDER_ROLE) {
    require(i < stakingContracts.length, "Invalid index");

    StakingRewards staking = stakingContracts[i];

    // Un-mark this staking contract as included in stakingContracts
    stakingExists[staking] = false;

    // Replace the removed staking contract with the last item in the stakingContracts array
    if (i != stakingContracts.length - 1) {
        stakingContracts[i] = stakingContracts[stakingContracts.length - 1];
    }

    // Pop the last staking contract off the array
    stakingContracts.pop();

    emit StakingRemoved(staking);
}

s1l3nt - The owner of the TelcoinDistributor contract can freeze transactions/funds

s1l3nt

high

The owner of the TelcoinDistributor contract can freeze transactions/funds

Summary

An integer overflow using the challengePeriod value can be used by the owner that deploys the TelcoinDistributor contract and through the challengePeriod value, he is able to freeze transactions any time he wishes and even get the funds stuck in the contract.

Vulnerability Detail

How challengePeriod is defined:

// amount of time a proposal can be challenged
uint256 public challengePeriod;
 
constructor(
        IERC20 telcoin,
        uint256 period,
        IERC721 council
    ) Ownable(_msgSender()) {
        // verifies no zero values were used
        require(
            address(telcoin) != address(0) &&
                address(council) != address(0) &&
                period != 0,
            "TelcoinDistributor: cannot intialize to zero"
        );
        // initialize telcoin address
        TELCOIN = telcoin;
        // Initialize challengePeriod duration
        challengePeriod = period;                    // [here]
        // Initialize councilNft address
        councilNft = council;
	[...]
function setChallengePeriod(uint256 newPeriod) public onlyOwner {
        //update period
        challengePeriod = newPeriod;
        // Emitting an event for new period
        emit ChallengePeriodUpdated(challengePeriod);
    }
  • The first snippet of code shows that the challengePeriod is initialized in the constructor() and without any boundary checks.
  • Then in the function setChallengePeriod() it is only updated by the owner of the new instance of the TelcoinDistributor contract but also without limitations on the new period value.

challengeTransaction()

[...]
require(
            block.timestamp <=
                proposedTransactions[transactionId].timestamp + challengePeriod,
            "TelcoinDistributor: Challenge period has ended"
        );

// Sets the challenged flag of the proposed transaction to true
proposedTransactions[transactionId].challenged = true;

Using the challengePeriod, the require() check can evaluate to false any time he wishes. This will end up resulting on transactions that will never be challenged and executed, keeping in mind that the challenged flag will never be true, the function executeTransaction() will revert.

executeTransaction

 // Reverts if the challenge period has not expired
        // [1]
        require(                         
            block.timestamp >
                proposedTransactions[transactionId].timestamp + challengePeriod,
            "TelcoinDistributor: Challenge period has not ended"
        );

        // [2]
        // makes sure the transaction was not challenged
        require(
            !proposedTransactions[transactionId].challenged,
            "TelcoinDistributor: transaction has been challenged"
        );
        // makes sure the transaction was not executed previously
        require(
            !proposedTransactions[transactionId].executed,
            "TelcoinDistributor: transaction has been previously executed"
        );

	// sends out transaction
        batchTelcoin(...)
	[...]

At [1] that check will be false because of the overflow with challengePeriod.
[2] reverts as well because the challengeTransaction() as shown above.

Impact

  • A malicious actor can create new instances of the TelcoinDistributor and have full control when to challenge or execute transactions (Freezing of funds).
  • Since the challengers of proposed transactions are Council Members, this attack will also remove their control over the system.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/protocol/core/TelcoinDistributor.sol#L55
https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/protocol/core/TelcoinDistributor.sol#L115
https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/protocol/core/TelcoinDistributor.sol#L143
https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/protocol/core/TelcoinDistributor.sol#L210

Tool used

Manual Review

Recommendation

Set limitations for the challengePeriod value so that it can not overflow on the checks highlighted.

IvanFitro - StakingRewardsManager.sol :: topUp() The tokens to fund the staking contracts are sended to an incorrect contracts.

IvanFitro

high

StakingRewardsManager.sol :: topUp() The tokens to fund the staking contracts are sended to an incorrect contracts.

Summary

topUp() is used to transferring tokens from the owner to the staking contract for funding. A issue arises as the tokens are sent to an incorrect staking contract due to the use of the for variable i instead of indexes.

Vulnerability Detail

topUp() is used to fund staking contracts.

function topUp(
        address source,
        uint256[] memory indices
    ) external onlyRole(EXECUTOR_ROLE) {
        for (uint i = 0; i < indices.length; i++) {
            // get staking contract and config
            StakingRewards staking = stakingContracts[i];
            StakingConfig memory config = stakingConfigs[staking];

            // will revert if block.timestamp <= periodFinish
            staking.setRewardsDuration(config.rewardsDuration);

            // pull tokens from owner of this contract to fund the staking contract
            rewardToken.transferFrom(
                source,
                address(staking),
                config.rewardAmount
            );

            // start periods
            staking.notifyRewardAmount(config.rewardAmount);

            emit ToppedUp(staking, config);
        }
    }

The issue lies in the for loop, where the variable i is used instead of indices[i] to determine the contract that should receive the funding. This leads to tokens being sent to the wrong contract.

 StakingRewards staking = stakingContracts[i];

POC

To execute the POC, copy the provided code into the StakingRewardsManager.test.ts file.

describe("topUpCustom", function () {
        let newStakingConfig: StakingRewardsManager.StakingConfigStruct = {
            rewardsDuration: 60 * 60 * 24 * 7, // 1 week in seconds
            rewardAmount: 100
        };

        let indices: number[] = [2, 3];
        let tokenAmount: number = 100;

        it("amount is not transfered to the correct contracts", async function () {
            await rewardToken.connect(deployer).approve(await stakingRewardsManager.getAddress(), tokenAmount * indices.length);

            await stakingRewardsManager.createNewStakingRewardsContract(await stakingToken.getAddress(), newStakingConfig);
            await stakingRewardsManager.createNewStakingRewardsContract(await stakingToken.getAddress(), newStakingConfig);
            await stakingRewardsManager.createNewStakingRewardsContract(await stakingToken.getAddress(), newStakingConfig);
            await stakingRewardsManager.createNewStakingRewardsContract(await stakingToken.getAddress(), newStakingConfig);

            await expect(stakingRewardsManager.connect(deployer).topUp(await deployer.address, indices))
                .to.emit(stakingRewardsManager, "ToppedUp");

            for (let index of indices) {
                let stakingContract = await stakingRewardsManager.stakingContracts(index);
                expect(await rewardToken.balanceOf(stakingContract)).to.equal(0);
            }
        });
    });

As evident from the observation, contracts with indices 2 and 3 (contracts where we want to send the tokens) exhibit a balance of 0 due to funds being directed to contracts 0 and 1. This issue arises because the variable i in the for loop is employed instead of indices[i].

Impact

Tokens are sent to an incorrect contracts, resulting in a loss of funds.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/telx/core/StakingRewardsManager.sol#L254-L278

Tool used

Manual Review.

Recommendation

To address this issue, use indices[i] instead of i.

function topUp(
        address source,
        uint256[] memory indices
    ) external onlyRole(EXECUTOR_ROLE) {
        for (uint i = 0; i < indices.length; i++) {
            // get staking contract and config
-           StakingRewards staking = stakingContracts[i];
+           StakingRewards staking = stakingContracts[indices[i]];
            StakingConfig memory config = stakingConfigs[staking];

            // will revert if block.timestamp <= periodFinish
            staking.setRewardsDuration(config.rewardsDuration);

            // pull tokens from owner of this contract to fund the staking contract
            rewardToken.transferFrom(
                source,
                address(staking),
                config.rewardAmount
            );

            // start periods
            staking.notifyRewardAmount(config.rewardAmount);

            emit ToppedUp(staking, config);
        }
    }

Duplicate of #16

0x_Sanzcy - `removeFromOffice` doesn't check if `to` is already a council member

0x_Sanzcy

medium

removeFromOffice doesn't check if to is already a council member

Summary

When a council member becomes malicious or for some other reasons the GOVERNANCE_COUNCIL_ROLE will call removeFromOffice swapping the council member with a new council member

function removeFromOffice(
        address from,
        address to,
        uint256 tokenId,
        address rewardRecipient
    ) external onlyRole(GOVERNANCE_COUNCIL_ROLE) {
        // Retrieve and distribute any pending TELCOIN for all council members
        _retrieve();
        // Withdraw all the TELCOIN rewards for the specified token to the rewardRecipient
        _withdrawAll(rewardRecipient, tokenId);
        // Transfer the token (representing the council membership) from one address to another
        _transfer(from, to, tokenId);
    }

Vulnerability Detail

If the new council member to is already a council member they'll posses multiple tokenId representing their council membership this can have the following impacts;
1- if the council member becomes malicious removing such member will result in only removing one tokenId associated with the member, the council member will still be present in the system due to holding other tokenId

2- unfair reward distribution, whenever _retrive gets called the user will earn for all the tokenId held

Impact

Membership will become harder to track

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit%2Fcontracts%2Fsablier%2Fcore%2FCouncilMember.sol#L122-L134

Tool used

Manual Review

Recommendation

Include a check if to is already a council member

Ignite - Design Flaw in burn() Function May Impact Functions Using balances Array

Ignite

high

Design Flaw in burn() Function May Impact Functions Using balances Array

Summary

The burn() function is designed to permit the GOVERNANCE_COUNCIL_ROLE to execute the burning of the council member NFT. However, the burn logic contains a design flaw that may disrupt or negatively impact other functions, particularly those using the balances array.

In the given example, this would lead to a situation where council members are unable to execute the claim() function, resulting in a loss of their allocated amounts of TELCOIN.

Vulnerability Detail

Scenario:

Initially, there are four council members: Alice, Bob, Charlie, and Dave, each holding one NFT with token IDs 0, 1, 2, and 3, respectively. The balances of each NFT are 500 Telcoin and none of them has been claimed yet.

As time passes, a new election process is completed, and Alice is no longer a council member. Therefore, the governance council needs to remove Alice from the council members by calling the burn() function.

function burn(
    uint256 tokenId,
    address recipient
) external onlyRole(GOVERNANCE_COUNCIL_ROLE) {
    require(totalSupply() > 1, "CouncilMember: must maintain council");
    _retrieve();
    _withdrawAll(recipient, tokenId);

    uint256 balance = balances[balances.length - 1];
    balances[tokenId] = balance;
    balances.pop();
    _burn(tokenId);
}

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L210-L222

The function performs retrieval and distribution of Telcoin before precisely burning the NFT for Alice. Subsequently, the value of balances[0] is replaced with the value of the last element in the balances array, which is balances[3], and then the last element is removed.

As a result, there is no longer an element 3 in the balances array.

function claim(uint256 tokenId, uint256 amount) external {
    // Ensure the function caller is the owner of the token (council member) they're trying to claim for
    require(
        _msgSender() == ownerOf(tokenId),
        "CouncilMember: caller is not council member holding this NFT index"
    );
    // Retrieve and distribute any pending TELCOIN for all council members
    _retrieve();

    // Ensure the requested amount doesn't exceed the balance of the council member
    require(
        amount <= balances[tokenId],
        "CouncilMember: withdrawal amount is higher than balance"
    );

    // Deduct the claimed amount from the token's balance
    balances[tokenId] -= amount;
    // Safely transfer the claimed amount of TELCOIN to the function caller
    TELCOIN.safeTransfer(_msgSender(), amount);
}

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L92-L111

Later on, Dave, who is holding the NFT with tokenId 3, will be unable to claim his allocated Telcoin by calling the claim() function. This is because the claim() function will revert at line 103, as there is no longer an element 3 in the balances array.

Additionally, the allocated Telcoin for Dave, stored in balances[0] during burned, cannot be retrieved either by the claim() or removeFromOffice() function since tokenId 0 has already been burned.

Impact

Loss of allocated telcoin for council members

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L210-L222

Tool used

Manual Review

Recommendation

Remove the balances array and distribute Telcoin rewards directly by transfer to the owner of the NFT when _retrieve() is called.

Duplicate of #199

iberry - Don't _grantRole many "XXX_ROLE" within the initialize function of the StakingRewardsManager

iberry

high

Don't _grantRole many "XXX_ROLE" within the initialize function of the StakingRewardsManager

Summary

because XXX_ROLE variable don't init, onlyRole modifier function will disable.

Vulnerability Detail

Some constants, denoted as "XXX_ROLE," (BUILDER_ROLE etc) are not being properly set up within the initialize function. Consequently, when the onlyRole(onlyRole(MAINTAINER_ROLE) modifier is applied, the verification fails. This issue is impacting the functionality of the contract

Impact

high

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/telx/core/StakingRewardsManager.sol#L22-L26

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/telx/core/StakingRewardsManager.sol#L69-L85

Tool used

Manual Review

Recommendation

add _grantRole function at initialize function.
_grantRole(XXX_ROLE, _msgSender());

ubl4nk - CouncilMember::burn is not correctly implemented

ubl4nk

high

CouncilMember::burn is not correctly implemented

Summary

CouncilMember#burn is not correctly implemented and due to it, a council member will be removed suddenly.

Vulnerability Detail

Let's say there are 2 members, it means now the balances array includes 2 items which are listed below (i have named the items for easier explanation):

  • balances[0] = Bob = 100 tokens (just an example amount)
  • balances[1] = Jack = 250 tokens (just an example amount)

Now the governance decides to burn the Bob's NFT (removing Bob's NFT means removing Bob from the council members), so they call burn:

function burn(
        uint256 tokenId,
        address recipient
    ) external onlyRole(GOVERNANCE_COUNCIL_ROLE) {
        require(totalSupply() > 1, "CouncilMember: must maintain council");
        _retrieve();
        _withdrawAll(recipient, tokenId);

        uint256 balance = balances[balances.length - 1];
        balances[tokenId] = balance;
        balances.pop(); // @audit due to pop(), the User2 will be removed from the `balances` array
        _burn(tokenId);
    }

Now tokenId = 0 (because the Bob is the first NFT, so tokenId of Bob is 0) and recipient = Bob

The _retrieve function re-balances the balance of all members and then the _withdrawAll transfers all the Bob's balance to him.

Then comes here:

        uint256 balance = balances[balances.length - 1]; // <--- balance = Jack's balance = 250 tokens
        balances[tokenId] = balance; // <--- balances[0] = 250 tokens 
        balances.pop(); // <--- removes Jack from the list -> Now balances[1] doesn't exist and can't be accessible anymore
        _burn(tokenId); // burning the Bob's NFT -> ownerOf(tokenId) = ownerOf(0) = address(0)

And we see Jack is removed from the members, the Jack's balance is moved to Bob's position in the array, but the ownership of Bob's NFT is not transferred to Jack.

Jack calls claim:

function claim(uint256 tokenId, uint256 amount) external {
        // Ensure the function caller is the owner of the token (council member) they're trying to claim for
        require(
            _msgSender() == ownerOf(tokenId),
            "CouncilMember: caller is not council member holding this NFT index"
        );
        // Retrieve and distribute any pending TELCOIN for all council members
        _retrieve();

        // Ensure the requested amount doesn't exceed the balance of the council member
        require(
            amount <= balances[tokenId],
            "CouncilMember: withdrawal amount is higher than balance"
        );

        // Deduct the claimed amount from the token's balance
        balances[tokenId] -= amount;
        // Safely transfer the claimed amount of TELCOIN to the function caller
        TELCOIN.safeTransfer(_msgSender(), amount);
    }

Now:

  • If he enters tokenId = 0, then because of ownerOf(0) is equal to address(0) so it reverts.
  • If he enters tokenId = 1, then again he gets another revert -> because balances[1] doesn't exist in array and can't be accessible.

Impact

The last council member will be removed suddenly and won't be able to claim .

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L44

https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L210-L222

Tool used

Manual Review

Recommendation

While burning:

  • Jack's balance should be moved to Bob's position/index in the balances array. (This is OK)
  • The ownership of Bob's NFT should be transferred to Jack.
  • The ownership of Jack's previous NFT should be transferred to address(0).

Duplicate of #199

mstpr-brainbot - If any NFT except the last index is burnt, minting new NFT's are impossible

mstpr-brainbot

high

If any NFT except the last index is burnt, minting new NFT's are impossible

Summary

When an NFT is burned, the total supply decreases, and the new NFT to be minted uses the totalSupply as the tokenId. Since the totalSupply decreases due to burns, the tokenId assigned to the new NFT will already exist, making the function unusable.

Vulnerability Detail

When an NFT is burned, the totalSupply decreases in the ERC721Enumerable contract and increases in mints.
https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/blob/dee57da57d313b0823699d1c643d3cf461746c7f/contracts/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol#L20-L26

Assume there are Alice, Bob, and Carol, where Alice holds tokenId 0, Bob holds 1, and Carol holds 2. Hence, the total supply is 3.

When Bob's NFT is burned by governance, the totalSupply will be 2.
https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L210-L222

When a new NFT is minted by governance, the tokenId assigned to the new NFT will be the totalSupply(), which is 2. However, tokenId 2 is already in use and held by Carol. That means minting new tokens is impossible!

Coded PoC:

function test_BurnBricks_NewNftsMints() external {
        ds.dontSendTel();

        vm.prank(deployer);
        cm.grantRole(GOVERNANCE_COUNCIL_ROLE, counciler);

        // @dev counciler mints NFT's to the councileeeeers
        vm.startPrank(counciler);
        cm.mint(tapir);
        cm.mint(hippo);
        cm.mint(ape);

        assertEq(cm.ownerOf(0), tapir);
        assertEq(cm.ownerOf(1), hippo);
        assertEq(cm.ownerOf(2), ape);
        assertEq(cm.totalSupply(), 3);

        cm.burn(1, hippo);  
        assertEq(cm.totalSupply(), 2);

        vm.expectRevert();
        cm.mint(elephant);     
    }

Impact

High since new NFT's can't be minted, system is forever blocked.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L173-L182
https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L210-L222
https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/blob/dee57da57d313b0823699d1c643d3cf461746c7f/contracts/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol#L75-L196

Tool used

Manual Review

Recommendation

Don't mint new tokens to "totalSupply()" but instead mint to a counter value that is held in storage and updated accordingly.

Duplicate of #199

0x_Sanzcy - `batchTelcoin` will revert if the `totalWithdrawl` is not equal to the `amount ` being sent

0x_Sanzcy

medium

batchTelcoin will revert if the totalWithdrawl is not equal to the amount being sent

Summary

Council members can create proposalTransaction to send Telcoin to specific addresses with specified amount, the total of the amount Is the totalWithdrawl to be taken from the safe

function proposeTransaction(
        uint256 totalWithdrawl,
        address[] memory destinations,
        uint256[] memory amounts
    ) external onlyCouncilMember whenNotPaused {
        // Pushing the proposed transaction to the array
        proposedTransactions.push(
            ProposedTransaction({
                totalWithdrawl: totalWithdrawl,
                destinations: destinations,
                amounts: amounts,
                timestamp: uint64(block.timestamp),
                challenged: false,
                executed: false
            })
        );

        // Emitting an event after proposing a transaction
        emit TransactionProposed(proposedTransactions.length - 1, _msgSender());
    }

If no one challanges the transaction batchTelcoin gets called and the transfers are made to the addresses .. However there's no check if the totalWithdrawl is equal to the amount being sent

Vulnerability Detail

    function batchTelcoin(
        uint256 totalWithdrawl,
        address[] memory destinations,
        uint256[] memory amounts
    ) internal {
        // stores inital balance
        uint256 initialBalance = TELCOIN.balanceOf(address(this));
        //transfers amounts
        TELCOIN.safeTransferFrom(owner(), address(this), totalWithdrawl);
        for (uint i = 0; i < destinations.length; i++) {
            TELCOIN.safeTransfer(destinations[i], amounts[i]);
        }
        //initial balance is used instead of zero
        //if 0 is used instead stray Telcoin could DNS operations
        require(
            TELCOIN.balanceOf(address(this)) == initialBalance,
            "TelcoinDistributor: must not have leftovers"
        );
    }

The function first takes the initialBalance of the contract then transfers Telcoin from the safe into the contract before sending it to the destinations after which it checks if the balance of the contract is equal to the initialBalance this is meant to revert if there's any leftover from the transaction.

The lack of check if totalWithdrawl is equal to the amount will make successful proposal fail of there is leftovers.

If the  totalWithdrawal  is less than the sum of the transferred amounts, the  require  statement will trigger a revert, as the leftover tokens would not align with the initial balance.

Conversely, if the  totalWithdrawal  is greater than the sum of the transferred amounts, the  require  statement will also result in a revert, as the leftover tokens would not match the initial balance.

In both scenarios, the contract would revert due to the inconsistency between the  totalWithdrawal  and the actual sum of the transferred token amounts.

Impact

The revert should only happen if there is left over tokens from failed transfers only not if the totalWithdrawl is less than or greater than the total amount

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit%2Fcontracts%2Fprotocol%2Fcore%2FTelcoinDistributor.sol#L185-L203

Tool used

Manual Review

Recommendation

Include a check to confirm if the amount total is equal to the totalWithdrawl

Duplicate of #91

alexbabits - Owner must manually fund CouncilMember contract if everyone attempts to claim

alexbabits

medium

Owner must manually fund CouncilMember contract if everyone attempts to claim

Summary

Owner will have to deposit some TELCOIN into CouncilMember for users to claim their entire rewards. This breaks the devs assumption that the only source of TEL is the _stream.

Vulnerability Detail

Imagine there are 3 NFT holders and _retrieve() is called. Assume 100e18 in rewards every time _stream.execute() is called.

    function _retrieve() internal {

        uint256 initialBalance = TELCOIN.balanceOf(address(this)); 

        _stream.execute(_target, abi.encodeWithSelector(ISablierV2ProxyTarget.withdrawMax.selector, _target, _id, address(this)));

        uint256 currentBalance = TELCOIN.balanceOf(address(this)); 
        uint256 finalBalance = (currentBalance - initialBalance) + runningBalance; 
        uint256 individualBalance = finalBalance / totalSupply(); 
        runningBalance = finalBalance % totalSupply();

        for (uint i = 0; i < balances.length; i++) {
            balances[i] += individualBalance; 
        }
    }

initialBalance: 0
currentBalance: 100,000,000,000,000,000,000
finalBalance: 100,000,000,000,000,000,000
individualBalance: 33,333,333,333,333,333,333
runningBalance: 1
balances increased: 33,333,333,333,333,333,333 for each person, totaling 99,999,999,999,999,999,999

Then all 3 people claim their total 99,999,999,999,999,999,999, making TELCOIN balance go from 100e18 to 1. And _retrieve() gets called again.

initialBalance: 1
currentBalance: 100,000,000,000,000,000,001
finalBalance: 100,000,000,000,000,000,002
individualBalance: 33,333,333,333,333,333,334
runningBalance: 0
balances increased: 33,333,333,333,333,333,334 for each person, totaling 100,000,000,000,000,000,002

Then all 3 people want to claim their total of 100,000,000,000,000,000,002, but contract only has 100,000,000,000,000,000,001.
If the last person goes to claim their entire amount, they will be 1 wei short, and it will revert when they call claim() for their entire balance amount. They will have to claim 1 wei less, and will need to wait for the owner to manually fund the CouncilMember contract to be able to get their last wei.

Impact

  • Owner will have to deposit some TELCOIN for user to claim their entire reward.
  • Because removeFromOffice() calls _withdrawAll() which attempts to transfer the entire balance of a user, if the other two people have already claimed, then this transaction will revert and not allow the person to be removed from office until the owner manually deposits more funds into CouncilMember.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L267-#L295

Tool used

Manual Review

Recommendation

  • Consider removing runningBalance, which would ensure the contract always has enough funds via pessimistic accounting of user balances.
  • Consider pre-funding CouncilMember with some dust or 1e18 TELCOIN on deployment, so it takes care of any excess dust that user balances can accumulate via the runningBalance issue.
  • Find a better or more precise way to determine leftovers so that the CouncilMember contract's TELCOIN balance can never be less than the sum of the user balances.

6160.web3 - Not imported a specific member from the module

6160.web3

medium

Not imported a specific member from the module

Summary

Specific members of the modules should be imported

Vulnerability Detail

It is a better practice and more secure to import the specific member from a contract.

Impact

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/protocol/core/TelcoinDistributor.sol#L5-L8
https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L4-L9
https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/sablier/test/TestStream.sol#L4-L5
https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/telx/abstract/RewardsDistributionRecipient.sol#L4
https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/telx/core/StakingRewards.sol#L4-L8
https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/telx/core/StakingRewardsFactory.sol#L4-L7
https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/telx/core/StakingRewardsManager.sol#L4-L7
https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/test/core/TestNFT.sol#L4
https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/test/core/TestTelcoin.sol#L4
https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/test/core/TestToken.sol#L4
https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/zodiac/core/BaseGuard.sol#L4-L6
https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/zodiac/core/SafeGuard.sol#L4-L5
https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/zodiac/interfaces/IGuard.sol#L4
https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/zodiac/interfaces/IReality.sol#L4
https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/zodiac/interfaces/IRealityETH.sol#L4
https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/zodiac/test/MockSafeGuard.sol#L4-L5
https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/zodiac/test/TestReality.sol#L4-L5
https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/zodiac/test/TestSafeWallet.sol#L4

Tool used

Manual Review

Recommendation

Replace every import ".../XXXX.sol"; with import {XXXX} from ".../XXXX.sol"; defining the specific member from the module.

For example, here is how the new 'import' lines should look line in the "TelcoinDistributor.sol" file:

// imports
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";

p-tsanev - TelcoinDistributor.sol#proposeTransaction() - block-stuffing could allow unfair execution of proposals

p-tsanev

medium

TelcoinDistributor.sol#proposeTransaction() - block-stuffing could allow unfair execution of proposals

Summary

The TelcoinDistributor contract allows for the proposals, execution and challenging(cancellation) of transactions, provided a challenge period that must pass before the execution can take place. Several factors could allow a user to use block-stuffing to cheaply pass his challenge period and execute any proposal with arbitrary parameters.

Vulnerability Detail

There are several factors to this issue:

  1. Lack of input validation for proposals. It does not check whether the receiving addresses are 0, the amounts are 0, if the sum of the amounts match the totalWithdrawal or the array lengths match. All of this is possibly done by design, based on the assumption that community would always challenge propositions with such unfavorable parameters.
  2. Polygon deployment makes transactions extremely cheap. Anything between 30-300 or at top 500 gwei cost (depending on netwoek congestion), meaning average gas price of ~0.0008$ at max per txn. Blocks on polygon as of the time of writing are 2-4 seconds and average 100 transactions (50-150 so the median). So to fill up 3 seconds (1 average block) with transactions you would need 0.08 usd.
  3. Lastly, the test file specifies a challenge period of 60 seconds. For the sake of maths, I will provide calculations with different parameters(taking only the highest gas prices on Polygon, in a non-congested network these could be twice less):
    To block-stuff for 60 seconds on Polygon you would need 1.6$
    For 10 minutes 16$, an hour is less than 100$, for days it goes in the thousands.

Thus meaning that a malicious council member could craft any parameters he likes and block-stuff the entire challenge period in order to make sure his no-input-validation proposal passes through. Depending on the intention of the proposal, like to burn funds to address 0, transfer himself funds from the owner() or craft the proposal parameters in some other way, an attacker can either grief or try to take profit for himself out of the owner().

A counter-argument to my statements could be that even if he cannot be challenged, his council membership can be revoked by the community, but that's not the case since:

  1. If he stuffs the blocks, the transaction could be too late
  2. Even if it passes, he can always front-run and transfer his membership NFT to another address and execute the proposal from there.

Impact

No input validation could leave to malicious proposals passing unfairly

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/protocol/core/TelcoinDistributor.sol#L87-L106
https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/protocol/core/TelcoinDistributor.sol#L152-L156
https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/protocol/core/TelcoinDistributor.sol#L185-L203

Tool used

Manual Review

Recommendation

A minimum challenge duration of at least a day should be set in place to avoid errors either during initialization or during a later change of period.
Sufficient input validation for the proposal parameters like the destination addresses, the amounts and the lengths of the arrays.
Optionally, configure the executeTransaction such that only the sender of the proposal can execute the transaction.

Duplicate of #181

novaman33 - Unhandled return value of transferFrom in `topUp()` in `StakingRewardsManager.sol` can lead to users being denied their rewards

novaman33

medium

Unhandled return value of transferFrom in topUp() in StakingRewardsManager.sol can lead to users being denied their rewards

Summary

Unhandled return value of transferFrom in topUp() in StakingRewardsManager.sol can lead to users unable to claim their rewards, because notifyRewardAmount() will not revert if there is balance left by users who have not claimed their rewards yet.

Vulnerability Detail

topUp uses transferFrom which may return false if the transfer did succeed. However the return value of transferFrom() is not checked which will lead to the execution of the notifyRewardAmount(). In StakingRewards.sol the function notifyRewardAmount() does the following check:

 // Check the balance of the rewards token in this contract
        uint balance = rewardsToken.balanceOf(address(this));
        // Make sure that the new reward rate isn't higher than what the contract can currently pay out
        require(
            rewardRate <= balance / rewardsDuration,
            "Provided reward too high"
        );

However if there are still rewards unclaimed by users from a previous stake, the notifyRewardAmount() will not revert. As a result the contract will be put in a state in which the reward it has do not satisfy the user's needs and users will be denied their rewards.
Prove of concept:
Consider the following scenario:
---RewardsAmount is set to 100 and RewardsDuration is set to 7 days

  1. Alice stakes 1000 tokens
  2. The executor calls topUp() with source that has enough rewardToken for the transferFrom() to be successful
  3. One week later Alica calls earned() which returns 100, but does not claim the reward leaving it in the StakingRewards contract.
  4. The executor calls topUp() again but this time source does not have enough reward tokens. However the transaction does not revert because the check in notifyRewardsAmount returns true as the balance of the contract's rewardToken is equal to the RewardsAmount.
  5. One week later Alice calls earned() which returns 200, but when she tries to call claim, the transaction reverts as StakingRewards has less tokens than what Alice tries to get.

Impact

The contract is put in a state in which users cannot get their rewards. Therefore I consider it Medium.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/telx/core/StakingRewardsManager.sol?plain=1#L267

Tool used

Manual Review

Recommendation

Replace the use of transferFrom() in line 267 in StakingRewardsManager.sol with IERC20(rewardToken).safeTransferFrom()

krkba - lack of input validation for array lengths in `batchTelcoin()` function

krkba

medium

lack of input validation for array lengths in batchTelcoin() function

krkba

Summary

Vulnerability Detail

When there is a lack of input validation for array lengths, it means the contract does not verify whether the lengths of destinations array and amounts array match before proceeding with execution the function.

Impact

Mismatched array lengths can potentially exploited by attcker to manipulate the contract behavior,they may attempt to provide invalid or unexpected data, causing the contract to behave in unintended ways.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/main/telcoin-audit/contracts/protocol/core/TelcoinDistributor.sol#L185-L203

Tool used

Manual Review

Recommendation

The contract should check whether the lengths of destinations and amounts arrays match before proceeding.

sobieski - Approvals are not cleared upon CouncilMember NFT transfers, allowing malicious holder to steal undue TELCOIN tokens

sobieski

high

Approvals are not cleared upon CouncilMember NFT transfers, allowing malicious holder to steal undue TELCOIN tokens

Summary

The CouncilMember token utilizes custom approval logic that allows the owner of GOVERNANCE_COUNCIL_ROLE to approve users to spend specific tokens. These approvals are not cleared when the tokens are spent, which allows malicious user to reclaim previously spent tokens and steal the TELCOIN balance of new token owners.

Vulnerability Detail

The CouncilMember.sol contract implements custom approval logic in place of the methods inherited from OpenZeppelin's contracts. This implementation consist of a storage mapping _tokenApproval and a corresponding approve() method:

function approve(
        address to,
        uint256 tokenId
    )
        public
        override(ERC721Upgradeable, IERC721)
        onlyRole(GOVERNANCE_COUNCIL_ROLE)
    {
        _tokenApproval[tokenId] = to;
        emit Approval(ERC721Upgradeable.ownerOf(tokenId), to, tokenId);
    }

The contract overrides OpenZeppelin's _isAuthorized() method with custom implementation that checks if the msg.sender has the GOVERNANCE_COUNCIL_ROLE or is authorized for spending the specified token ID. The aforementioned _tokenApproval mapping is used for the latter.

function _isAuthorized(
        address,
        address spender,
        uint256 tokenId
    ) internal view override returns (bool) {
        return (hasRole(GOVERNANCE_COUNCIL_ROLE, spender) ||
            _tokenApproval[tokenId] == spender);
    }

The issue is the approvals in _tokenApproval mapping are not cleared upon token transfers. This is in contradiction with ERC721 specification and allows for the following scenario:

  1. Admin (GOVERNANCE_COUNCIL_ROLE) approves Alice for spending her NFT.
  2. Alice sends the NFT to Bob.
  3. Some amount of TELCOIN tokens has been retrieved from Sablier stream and distributed between token holders. Bob has the right to claim portion of it as a rightful owner of a NFT.
  4. Alice notices this and transfers the NFT back to herself, utilizing the approval which has not been cleared.
  5. Now Alice can claim the TELCOINs rightfully belonging to Bob

The POC for the issue is provided below. Please paste it into CouncilMember.test.ts test suite.

describe("POC", () => {
            it.only("Approved NFT spender can re-claim NFT", async () => {
                await telcoin.transfer(await stream.getAddress(), 100000);
                /* 1. Mint NFT to member */
                await expect(councilMember.mint(member.address)).to.not.reverted;
                /* 2. Admin approves member to spend their NFT */
                await councilMember.approve(member.address, 0);                
                expect(await councilMember.ownerOf(0)).to.equal(member.address);
                /* 3. Member sends the NFT to support */
                await councilMember.connect(member).transferFrom(member.address, support.address, 0);                
                expect(await councilMember.ownerOf(0)).to.equal(support.address);
                /* 4. The approval was not cleared - member can reclaim NFT and steal the balance */
                await councilMember.connect(member).transferFrom(support.address, member.address, 0);                
                expect(await councilMember.ownerOf(0)).to.equal(member.address);
                await expect(councilMember.connect(member).claim(0, 100)).to.not.reverted;
            });            
 })

Impact

High, as the tokens are directly at risk.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L199

Tool used

Manual Review

Recommendation

Clear the approvals on transfer. Add the following method to CouncilMember.sol contract:

+ function transferFrom(address from, address to, uint256 tokenId) public override(ERC721Upgradeable, IERC721) {
+       super.transferFrom(from, to, tokenId);
+	delete _tokenApproval[tokenId];
+ }

Duplicate of #35

IvanFitro - CouncilMember.sol :: Burning an NFT makes it impossible for some users to claim rewards.

IvanFitro

high

CouncilMember.sol :: Burning an NFT makes it impossible for some users to claim rewards.

Summary

When an NFT is burned, impossibilities other users from claiming rewards because the last index of balances is removed and added to the tokenId of the burned NFT. The issue arises because the user of the last minted NFT retains a specific tokenId that does not change when an NFT is burned, but their balance is assigned to the burned NFT, making it impossible for them to claim their rewards.

Vulnerability Detail

burn() is used to burn a specific NFT and transfer the balance of this NFT to a designated recipient.

function burn(
        uint256 tokenId,
        address recipient
    ) external onlyRole(GOVERNANCE_COUNCIL_ROLE) {
        require(totalSupply() > 1, "CouncilMember: must maintain council");
        _retrieve();
        _withdrawAll(recipient, tokenId);

        uint256 balance = balances[balances.length - 1];
        balances[tokenId] = balance;
        balances.pop();
        _burn(tokenId);
    }

The current implementation retrieves the last balance from the array, assigns this balance to the burned NFT tokenId, and subsequently removes the last balance from the array.

uint256 balance = balances[balances.length - 1];
balances[tokenId] = balance;
balances.pop();

While this practice is common when removing a value from an array, it is wrong in this case. The issue stems from setting the balance of the last tokenId to the tokenId of the burned NFT. Since the NFT always maitains the same tokenId, when the owner of the last tokenId attempts to claim their rewards using claim(), the transaction reverts. This occurs because the index needed for the claim is deleted by the balances.pop().

require(
            amount <= balances[tokenId],
            "CouncilMember: withdrawal amount is higher than balance"
        );

For claiming rewards, users are required to call the tokenId of the burned NFT because their balance is setted to this index. However, the user does not possess this tokenId, resulting in a revert due to the require statement.

require(
            _msgSender() == ownerOf(tokenId),
            "CouncilMember: caller is not council member holding this NFT index"
        );

But the transaction technically will revert with the ERC721 error ERC721NonexistentToken. You can observe this behavior in the provided POC.

POC

To execute the POC copy and paste the provided code into the CouncilMember.test.ts file.

describe("Burn custom balances", () => {

            beforeEach(async () => {
                telcoin.transfer(await stream.getAddress(), 100000);
                await expect(councilMember.mint(member.address)).to.not.reverted;
                await expect(councilMember.mint(support.address)).to.not.reverted;
                await expect(councilMember.mint(holder.address)).to.not.reverted;
                await expect(councilMember.mint(holder.address)).to.not.reverted;

                expect(await councilMember.balances(0)).to.equal(183);
                expect(await councilMember.balances(1)).to.equal(83);
                expect(await councilMember.balances(2)).to.equal(33);
                expect(await councilMember.balances(3)).to.equal(0);
            });

            it("burn a nft impossibilities claim rewards for other users", async () => {
                
                //burn tokenId 0 and 1
                await councilMember.burn(0, support.address);
                await councilMember.burn(1, support.address);
                
                //comprove holder is the owner of the tokenId 2 and 3
                expect (await councilMember.ownerOf(2)).to.eq(holder.address);
                expect (await councilMember.ownerOf(3)).to.eq(holder.address);
                
                //Try to claim 1 unit for tokenId 2
                //Transaction reverts -> VM Exception while processing transaction: reverted with panic code 0x32 (Array accessed at an out-of-bounds or negative index)
                await expect(councilMember.connect(holder).claim(2, 1)).to.be.reverted;

                //1 is higer than 0 because the rewards of 3 are setted in the balances index 0 because was the last balance index when the first burn is done
                expect(await councilMember.balances(0)).to.equal(58);
                expect(await councilMember.balances(1)).to.equal(91);

                //Try to claim for indexId 0 and 1
                await expect(councilMember.connect(holder).claim(0, 1)).to.revertedWithCustomError(councilMember, "ERC721NonexistentToken");
                await expect(councilMember.connect(holder).claim(1, 1)).to.revertedWithCustomError(councilMember, "ERC721NonexistentToken");
            });
        });

When a user attempts to claim rewards for tokenId 2, the transaction fails with the error Array accessed at an out-of-bounds or negative index. This issue arises because the index is deleted through the balance.pop() operation. Despite rewards accumulating in balance(0) and balance(1), users cannot access them as they do not possess these tokenIds. In reality, the transaction reverts with the ERC721 error ERC721NonexistentToken since these tokens have been burned.

Impact

Users are unable to claim their rewards, resulting in a loss of funds.

Code Snippet

https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L210-L222
https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L173-L182
https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L92-L111

Tool used

Manual Review.

Recommendation

Working with arrays, I can't find a solution as the index in the array of balances is directly tied to the tokenId. A potential resolution could involve switching to mappings, but implementing this change must require a substantial restructuring of the entire contract. This is mainly due to the fact that the existing functions are designed to interact with arrays.

Duplicate of #199

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.