Adding DAO Governance to Existing Token Contracts

Written by tally | Published 2022/06/07
Tech Story Tags: blockchain | tatum_io | blockchain-writing-contest | good-company | dao | non-fungible-tokens | tokenization | blockchain-governance | hackernoon-es

TLDRNatacha De la Rosa explains how to turn your NFT or ERC20 token contract into a DAO. You can add a [Governor Contract] to manage proposals and votes for your DAO using the [OpenZeppelin] contracts library. Your token contract needs a **delegate()**, **delegates()** functions to delegate votes from one user to another. It needs to have a clear definition of how to calculate the voting power of each token holder. It also needs to emit event logs for vote changes, token transfers, and delegation changes.via the TL;DR App

In my previous post, I talked about how to make your NFT contract DAO-ready from day one. But what if you have already deployed your NFT or ERC20 token contract without a future DAO? How can you add DAO governance using that existing token? Let's find out.

You can turn your token contract into a DAO by adding a Governor Contract to manage proposals and votes for your DAO. But before you can do that, you'll need to make sure your token contract is compatible with the governor.

The governor expects a particular interface from the token contract. Here's a summary of what it needs:

  • Your token contract needs a delegate(), delegateBySig(), and delegates() functions to delegate votes from one user to another.

  • Your token contract needs to have a clear definition of how to calculate the voting power of each token holder. Usually, one token = one vote, but you can also customize a formula based on the token supply. It needs to have these function definitions getVotes(), getPastVotes() and getPastTotalSupply().

  • Last but not least, your token contract needs to emit event logs for vote changes, token transfers, and delegation changes. It needs to have these specific events definitions DelegateChanged, DelegateVotesChanged, and Transfer.

To get more information about what these functions and events signatures look like and what they should return, please read the Open Zeppelin IVotes definition.

Now that you know what your token contract needs, I can explain how you can achieve this. There are two ways, depending on how you created your token contract in the first place.

With the OpenZeppelin contracts library, I can add ERC20Votes or ERC721Votes accordingly depending on my token type and override some necessary methods even if my contract is not upgradable.

If your contract is upgradable, you'll need to create a new implementation and update the proxy so it implements the additional libraries your token contract needs.

If your contract is not upgradable, you'll create an additional contract with the features I've mentioned above. Then you'll need to allow users to stake the tokens they hold to mint the new ones.

1. Your Token Contract is Upgradable

If this is your case, you have the easiest path forward.

You need to have administration permissions of the contract to perform these changes. If you don't, you won't be able to update the token implementation of the upgradable contract.

First, let's say I have the following token contract that I've deployed on Rinkeby. It doesn't support delegation or any other voting capabilities, but it is upgradable:

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

import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract OldToken is Initializable, ERC20Upgradeable, OwnableUpgradeable {
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() initializer {}

    function initialize() public initializer {
        __ERC20_init("OldToken", "OTK");
        __Ownable_init();

        _mint(msg.sender, 20 * 10**decimals());
    }

    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }
}

Now, I need to update my token to have a new implementation that supports delegation and other voting capabilities like so:

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

import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/draft-ERC20PermitUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20VotesUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract NewToken is
    Initializable,
    ERC20Upgradeable,
    OwnableUpgradeable,
    ERC20PermitUpgradeable,
    ERC20VotesUpgradeable
{
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() initializer {}

    function initialize() public initializer {
        __ERC20_init("NewToken", "NTK");
        __Ownable_init();
        __ERC20Permit_init("NewToken");

        _mint(msg.sender, 20 * 10**decimals());
    }

    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }

    // The following functions are overrides required by Solidity.

    function _afterTokenTransfer(
        address from,
        address to,
        uint256 amount
    ) internal override(ERC20Upgradeable, ERC20VotesUpgradeable) {
        super._afterTokenTransfer(from, to, amount);
    }

    function _mint(address to, uint256 amount) internal override(ERC20Upgradeable, ERC20VotesUpgradeable) {
        super._mint(to, amount);
    }

    function _burn(address account, uint256 amount) internal override(ERC20Upgradeable, ERC20VotesUpgradeable) {
        super._burn(account, amount);
    }
}

After that, I can run the update task, and my contract will have the new implementation I need:

import { task } from "hardhat/config";
import type { TaskArguments } from "hardhat/types";

import { NewToken } from "../../src/types/contracts";
import { NewToken__factory } from "../../src/types/factories/contracts";
import { getTokenInfo, saveJSON } from "../../test/utils";

task("deploy:updateToken").setAction(async function (_: TaskArguments, { ethers, upgrades, run }) {
  // get current proxy address
  const oldToken = getTokenInfo("./oldTokenAddress.json");

  // token upgrade
  const UpgradedToken: NewToken__factory = await ethers.getContractFactory("NewToken");
  const upgraded: NewToken = <NewToken>await upgrades.upgradeProxy(oldToken.proxy, UpgradedToken);

  await upgraded.deployed();

  const tokenImplementationAddress = await upgrades.erc1967.getImplementationAddress(upgraded.address);

  // write to local
  const data = {
    token: { proxy: upgraded.address, implementation: tokenImplementationAddress },
  };

  saveJSON(data, "./newTokenAddress.json");

  // etherscan verification
  await run("verify:verify", {
    address: tokenImplementationAddress,
  });
});

That's it, and now my token contract has everything it needs to be used with my governor contract!

2. Your Token Contract is Not Upgradable

If this is your case, don't worry. Everything has a solution. You'll need a few more steps but nothing too complicated.

I'll first create a new contract; you can make it upgradable or not, but in this example, I'll make it not upgradable to show both sides. If you want to create it to be upgradable, you can refer to the previous one I created.

I will implement the features I need to make my token compatible with a governor contract in this new token contract. Then I'll create two additional functions to allow my token users to stake their tokens and withdraw them from this contract.

You will also need to create a simple UI to allow your users to do these actions. That's all you'll need to do. Let's jump right into it.

Let's say I have this ERC721 token that is already deployed in Rinkeby, but it doesn't have any upgradable capabilities:

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

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";

contract OldNFTToken is ERC721, Ownable {
    using Counters for Counters.Counter;

    Counters.Counter private _tokenIdCounter;

    constructor() ERC721("OldNFTToken", "ONTK") {}

    function safeMint(address to) public onlyOwner {
        uint256 tokenId = _tokenIdCounter.current();
        _tokenIdCounter.increment();
        _safeMint(to, tokenId);
    }
}

In this case, I'll need to create a new ERC721 token that has the DAO capabilities I need. This new contract will allow current token holders to deposit their owned tokens. Then, they can receive the new voting token and participate in my DAO. The token holders will also have the option to withdraw their staked tokens.

Let's start by creating the new ERC721 token like so:

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

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/draft-EIP712.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/draft-ERC721Votes.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";

contract NewNFTToken is ERC721, Ownable, EIP712, ERC721Votes {

    constructor() ERC721("NewNFTToken", "NNTK") EIP712("NewNFTToken", "1") {}

    function safeMint(address to, uint256 tokenId) public onlyOwner {
        _safeMint(to, tokenId);
    }

    // The following functions are overrides required by Solidity.

    function _afterTokenTransfer(
        address from,
        address to,
        uint256 tokenId
    ) internal override(ERC721, ERC721Votes) {
        super._afterTokenTransfer(from, to, tokenId);
    }
}

My new ERC721 token contract should have these functionalities:

  • Mintable: I'll use this functionality to mint a unique token for my token holders each time they stake a token they hold of OldNFTToken into the NewNFTToken.

  • Burnable: I'll use this functionality to burn a minted NewNFTToken when the holder withdraws NewNFTToken.

Now I'll add two new functions to my new contract to manage the staking and withdrawing processes:

  1. First, create a state variable to receive the current NFT contract address.

contract NewNFTToken is ERC721, Ownable, EIP712, ERC721Votes {
    // ...
    IERC721 public oldNFTToken;
    // ...
  }

  1. Now, I'll update the constructor to receive the address of my OldNFTToken.

contract NewNFTToken is ERC721, Ownable, EIP712, ERC721Votes {
    // ...

    constructor(address _oldNFTTokenAddress) ERC721("NewNFTToken", "NNTK") EIP712("NewNFTToken", "1") {
        oldNFTToken = IERC721(_oldNFTTokenAddress);
    }
    
    // ...
}

  1. I'll also update the safeMint function to look like so:

contract NewNFTToken is ERC721, Ownable, EIP712, ERC721Votes {
  // ...
  function safeMint(address _to, uint256 _tokenId) private {
       _safeMint(_to, _tokenId);
   }
  // ...
}

  1. Then, I'll add the functions to stake and withdraw

contract NewNFTToken is ERC721, Ownable, EIP712, ERC721Votes {
    // ...
  
    // holder needs to approve this contract address before calling this method
    function stake(uint256 _tokenId) public {
        oldNFTToken.safeTransferFrom(msg.sender, address(this), _tokenId, "0x00"); // transfer token to this contract - stake
        safeMint(msg.sender, _tokenId); // mint a new vote token for staker
    }

    function withdraw(uint256 _tokenId) public {
        oldNFTToken.safeTransferFrom(address(this), msg.sender, _tokenId, "0x00");

        _burn(_tokenId); // burn voteToken after withdraw
    }
    
    // ...
}

  1. Last but not least, I'll add ERC721Receiver implementation to my contract so that it can support safe transfers:

contract NewNFTToken is ERC721, Ownable, EIP712, ERC721Votes {
    // ...
  
    // holder needs to approve this contract address before calling this method
    function stake(uint256 _tokenId) public {
        oldNFTToken.safeTransferFrom(msg.sender, address(this), _tokenId, "0x00"); // transfer token to this contract - stake
        safeMint(msg.sender, _tokenId); // mint a new vote token for staker
    }

    function withdraw(uint256 _tokenId) public {
        oldNFTToken.safeTransferFrom(address(this), msg.sender, _tokenId, "0x00");

        _burn(_tokenId); // burn voteToken after withdraw
    }
    
    // ...
}

Now the new ERC721 token should look like this:

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

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/draft-EIP712.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/draft-ERC721Votes.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";

contract NewNFTToken is ERC721, Ownable, EIP712, ERC721Votes {
    IERC721 public oldNFTToken;

    constructor(address _oldNFTTokenAddress) ERC721("NewNFTToken", "NNTK") EIP712("NewNFTToken", "1") {
        oldNFTToken = IERC721(_oldNFTTokenAddress);
    }

    // holder needs to approve this contract address before calling this method
    function stake(uint256 _tokenId) public {
        oldNFTToken.safeTransferFrom(msg.sender, address(this), _tokenId, "0x00"); // transfer token to this contract - stake
        safeMint(msg.sender, _tokenId); // mint a new vote token for staker
    }

    function withdraw(uint256 _tokenId) public {
        oldNFTToken.safeTransferFrom(address(this), msg.sender, _tokenId, "0x00");

        _burn(_tokenId); // burn voteToken after withdraw
    }

    function safeMint(address _to, uint256 _tokenId) private {
        _safeMint(_to, _tokenId);
    }

    function onERC721Received(
        address operator,
        address from,
        uint256 id,
        uint256 value,
        bytes calldata data
    ) external returns (bytes4) {
        return bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"));
    }

    // The following functions are overrides required by Solidity.

    function _afterTokenTransfer(
        address from,
        address to,
        uint256 tokenId
    ) internal override(ERC721, ERC721Votes) {
        super._afterTokenTransfer(from, to, tokenId);
    }
}

That's it. Now you can use the NewNFTToken in your governor.

3. Adding a Governor

Now that we have seen how to update both types of tokens to support voting and delegation so they work with the governor contract, I'll create the contracts I need to deploy my governor. First, I'll create a Timelock contract:

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

import "@openzeppelin/contracts/governance/TimelockController.sol";

contract Timelock is TimelockController {
    constructor(
        uint256 _minDelay,
        address[] memory _proposers,
        address[] memory _executors
    ) TimelockController(_minDelay, _proposers, _executors) {}
}

  1. Now, I'll create my governor contract. It should look like so:

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

import "@openzeppelin/contracts/governance/Governor.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorSettings.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorCountingSimple.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorVotes.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorVotesQuorumFraction.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorTimelockControl.sol";

contract MyGovernor is
    Governor,
    GovernorSettings,
    GovernorCountingSimple,
    GovernorVotes,
    GovernorVotesQuorumFraction,
    GovernorTimelockControl
{
    constructor(IVotes _token, TimelockController _timelock)
        Governor("MyGovernor")
        GovernorSettings(
            1, /* 1 block */
            45818, /* 1 week */
            0
        )
        GovernorVotes(_token)
        GovernorVotesQuorumFraction(4)
        GovernorTimelockControl(_timelock)
    {}

    // The following functions are overrides required by Solidity.

    function votingDelay() public view override(IGovernor, GovernorSettings) returns (uint256) {
        return super.votingDelay();
    }

    function votingPeriod() public view override(IGovernor, GovernorSettings) returns (uint256) {
        return super.votingPeriod();
    }

    function quorum(uint256 blockNumber)
        public
        view
        override(IGovernor, GovernorVotesQuorumFraction)
        returns (uint256)
    {
        return super.quorum(blockNumber);
    }

    function state(uint256 proposalId) public view override(Governor, GovernorTimelockControl) returns (ProposalState) {
        return super.state(proposalId);
    }

    function propose(
        address[] memory targets,
        uint256[] memory values,
        bytes[] memory calldatas,
        string memory description
    ) public override(Governor, IGovernor) returns (uint256) {
        return super.propose(targets, values, calldatas, description);
    }

    function proposalThreshold() public view override(Governor, GovernorSettings) returns (uint256) {
        return super.proposalThreshold();
    }

    function _execute(
        uint256 proposalId,
        address[] memory targets,
        uint256[] memory values,
        bytes[] memory calldatas,
        bytes32 descriptionHash
    ) internal override(Governor, GovernorTimelockControl) {
        super._execute(proposalId, targets, values, calldatas, descriptionHash);
    }

    function _cancel(
        address[] memory targets,
        uint256[] memory values,
        bytes[] memory calldatas,
        bytes32 descriptionHash
    ) internal override(Governor, GovernorTimelockControl) returns (uint256) {
        return super._cancel(targets, values, calldatas, descriptionHash);
    }

    function _executor() internal view override(Governor, GovernorTimelockControl) returns (address) {
        return super._executor();
    }

    function supportsInterface(bytes4 interfaceId)
        public
        view
        override(Governor, GovernorTimelockControl)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}

We can now compile and generate the typings of our contracts:

yarn compile & yarn typechain

  1. Finally, I'll create a hardhat task to deploy our contracts. It should look like so:

import { task } from "hardhat/config";

import { NewNFTToken, Timelock } from "../../src/types/contracts";
import { MyGovernor } from "../../src/types/contracts/Governor.sol";
import { NewNFTToken__factory, Timelock__factory } from "../../src/types/factories/contracts";
import { MyGovernor__factory } from "../../src/types/factories/contracts/Governor.sol";

task("deploy:Governance").setAction(async function (_, { ethers, run }) {
  const timelockDelay = 2;

  const tokenFactory: NewNFTToken__factory = await ethers.getContractFactory("NewNFTToken");

  // replace with your existing token address
  const oldTokenAddress = ethers.constants.AddressZero; // old NFT token address

  const token: NewNFTToken = <NewNFTToken>await tokenFactory.deploy(oldTokenAddress);
  await token.deployed();

  // deploy timelock
  const timelockFactory: Timelock__factory = await ethers.getContractFactory("Timelock");
  const timelock: Timelock = <Timelock>(
    await timelockFactory.deploy(timelockDelay, [ethers.constants.AddressZero], [ethers.constants.AddressZero])
  );
  await timelock.deployed();

  // deploy governor
  const governorFactory: MyGovernor__factory = await ethers.getContractFactory("MyGovernor");
  const governor: MyGovernor = <MyGovernor>await governorFactory.deploy(token.address, timelock.address);
  await governor.deployed();

  // get timelock roles
  const timelockExecuterRole = await timelock.EXECUTOR_ROLE();
  const timelockProposerRole = await timelock.PROPOSER_ROLE();
  const timelockCancellerRole = await timelock.CANCELLER_ROLE();

  // grant timelock roles to governor contract
  await timelock.grantRole(timelockExecuterRole, governor.address);
  await timelock.grantRole(timelockProposerRole, governor.address);
  await timelock.grantRole(timelockCancellerRole, governor.address);

  console.log("Dao deployed to: ", {
    governor: governor.address,
    timelock: timelock.address,
    token: token.address,
  });

  // etherscan verification
  await run("verify:verify", {
    address: token.address,
    constructorArguments: [oldTokenAddress],
  });

  await run("verify:verify", {
    address: timelock.address,
    constructorArguments: [timelockDelay, [ethers.constants.AddressZero], [ethers.constants.AddressZero]],
    contract: "@openzeppelin/contracts/governance/TimelockController.sol:TimelockController",
  });

  await run("verify:verify", {
    address: governor.address,
    constructorArguments: [token.address, timelock.address],
  });
});

Now to deploy our new governor, we can run:

yarn hardhat deploy:Governance

We have completed this small tutorial, and we've learned how to update our token contract to be DAO-ready and deploy a governor contract for them. You can create a DAO and list it on Tally with your new governor and token.

You can find the sample code for this tutorial here.

Also published here.


Written by tally | Tally is a DAO operations platform
Published by HackerNoon on 2022/06/07