This article manuscript compiles all the steps, decisions and trade-offs of the creation of a decentralized autonomous organizarion (DAO), in particular the steps followed by the developer alumni alchemist J. Valeska to create bring to life the Alumni DAO for the Alchemy University.
Disclaimer - This article contains secrets of the ancient art of Alchemy reserved for the chosen ones, those touched by the blue light. Read on at your own risk.
Let’s go ahead to define our requirements ingredients and tech alchemy stack.
Requirements:
The DAO must live in the Mumbai Polygon testnet.
Must implement the Open Zeppelin Governor framework.
Must be token-gated using the Ethereum Developer Certified issued by the Alchemy University.
Tally UI is currently the best option to implement an UI for the OZ Governor. Tally UI comes with an easy to use interface that allows us to create the DAO, proposals, vote on them and trigger the execution on chain. Create a DAO using Tally is pretty straightforward but requires to deploy our OZ Governor and our governance token first. Said that, let’s get started!
Open Zeppelin Governor documentation.
First things first, let’s deploy our governor contract. The Open Zeppelin documentation includes a complete guide on how to set up on-chain governance using the governor contract. The guide starts with a brief introduction to continue with some info about compatibility and a recommendation to use Tally. We will focus on a fresh deploy of the OpenZeppelin Governor using Tally without concern for compatibility.
The voting power of each account in our governance setup will be determined by an ERC20 token. The token has to implement the
ERC20Votesextension. This extension will keep track of historical balances so that voting power is retrieved from past snapshots rather than current balance, which is an important protection that prevents double voting. (from the OZ Governor docs)
At this point, we have a problem. The Ethereum Developer Certificate is an ERC1155 token and it obviously does not implement the ERC20Votes extension. Open Zeppelin provides a wrapper for ERC20 and ERC721 tokens but they have nothing to do with our ERC1155. The easy way to go is to create a new token for the governance and to whitelist our certificates to mint the token. The certificate is not transferable, so it is an advantage at this point. (Consider to make the Alumni Token a SBT too)
Let’s see the base code of our AlumniToken:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.2;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/draft-ERC20Permit.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
contract AlumniToken is ERC20, ERC20Permit, ERC20Votes {
constructor() ERC20("AlumniToken", "ALUMNI") ERC20Permit("AlumniToken") {}
function mint() public {
_mint(msg.sender, 1);
}
function burn() public {
_burn(msg.sender, 1);
}
// The functions below are overrides required by Solidity.
function _afterTokenTransfer(address from, address to, uint256 amount)
internal
override(ERC20, ERC20Votes)
{
super._afterTokenTransfer(from, to, amount);
}
function _mint(address to, uint256 amount)
internal
override(ERC20, ERC20Votes)
{
super._mint(to, amount);
}
function _burn(address account, uint256 amount)
internal
override(ERC20, ERC20Votes)
{
super._burn(account, amount);
}
}
The Alumni token implements the ERC20, ERC20Votes and the ERC20Permit extensions as requirement from the Governor. We are implementing the most basic minting and burning functionality trough mint and burn public functions that allows anyone to mint and burn the Alumni token. This is not a desired behavior on production but it is valid for testing purposes.
The production implementation must check whether the sender is holding our ERC1155 or not. This will be achieved trough an IERC1155 interface and a require constraint at minting. It also must allow minting only 1 token per certificate, a mapping and another require will solve this if the token is transferable, if no transfers are allowed we will be able to do that by checking if the balance of the sender is equals to zero.
interface IERC1155 {
function balanceOf(address, uint256) external;
}
address public certificateAddress;
mapping(address => bool) public alumnis;
constructor(address _certificateAddress) ERC20("AlumniToken", "ALUMNI") ERC20Permit("AlumniToken") {
certificateAddress = _certificateAddress;
}
function mint() public {
require(!alumnis[msg.sender], "You already are an Alumni");
IERC1155 certificate = IERC1155(certificateAddress);
require(
certificate.balanceOf(msg.sender, 11055) > 0,
"You are not an Alumni"
);
alumnis[msg.sender] = true;
_mint(msg.sender, 1);
}
After that we should have a governance token contract ready to deploy that allows us to integrate it on the Governor framework while met our requirement of only allow Alumnis holding the Ethereum Developer Certificate to mint our Alumni token in a 1:1 equivalence.
Currently our ERC20Votes extension uses block based time but we are able to change it to use timestamps just by overriding the clock functions (we are using block based time, so we are not including theses overrides):
// Overrides IERC6372 functions to make the token & governor timestamp-based
function clock() public view override returns (uint48) {
return uint48(block.timestamp);
}
function CLOCK_MODE() public pure override returns (string memory) {
return "mode=timestamp";
}
To continue with the OZ governance guide we need to answer some questions first:
How voting power is determined?
How many votes are needed for quorum?
What options people have when casting a vote and how those votes are counted?
What type of token should be used to vote?
Thanks to the Open Zeppelin team it is easy to solve these questions, as they provide us with the necessary extensions (GovernorVotes, GovernorCountingSimple, GovernorVotesQuorumFraction) so that we only have to worry about adapting them to our needs. We are going to set up the default settings recommended by the OZ team and make the desired changes on-chain trough DAO proposals leveraging the GovernorSettings extension and the Tally UI.
The GovernorSettings extension implements setters that allow us to configure the votingDelay, the votingPeriod and the proposalThreshold, so we are going to modify these values on-chain trough proposals.
The base code of our AlumniGovernor:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.2;
import "@openzeppelin/contracts/governance/Governor.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorSettings.sol";
import "@openzeppelin/contracts/governance/compatibility/GovernorCompatibilityBravo.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorVotes.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorVotesQuorumFraction.sol";
import "@openzeppelin/contracts/governance/extensions/GovernorTimelockControl.sol";
contract AlumniGovernor is Governor, GovernorSettings, GovernorCompatibilityBravo, GovernorVotes, GovernorVotesQuorumFraction, GovernorTimelockControl {
constructor(IVotes _token, TimelockController _timelock)
Governor("AlumniGovernor")
GovernorSettings(10 /* 2 min */, 14400 /* 2 days */, 0)
GovernorVotes(_token)
GovernorVotesQuorumFraction(4)
GovernorTimelockControl(_timelock)
{}
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);
}
// The functions below are overrides required by Solidity.
function state(uint256 proposalId)
public
view
override(Governor, IGovernor, GovernorTimelockControl)
returns (ProposalState)
{
return super.state(proposalId);
}
function propose(address[] memory targets, uint256[] memory values, bytes[] memory calldatas, string memory description)
public
override(Governor, GovernorCompatibilityBravo, 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, IERC165, GovernorTimelockControl)
returns (bool)
{
return super.supportsInterface(interfaceId);
}
}
We are going to use a TimelockController to allow members to leave the DAO before the execution of a proposal. The TimelockController will execute proposals, hold any funds, ownership and access control roles.
We are going to set up the roles following the recommendation from the OZ team:
The proposer role will be granted to the Governor contract and only to the Governor contract.
The executor role will be granted to the zero address to allow anyone to execute a previously approved proposal.
The admin role will be in charge of set up the two previous roles. This role should be granted automatically to the
TimelockControllerand maybe to a secondary account for configuration which should renounce as soon as possible.
Some of these roles must be configured at deployment, but to make things simpler, let's grant all these roles to the deployer. Then we will modify them using the polygon scan and the deployer account, previously configured as the admin.
The base code of our TimelockController:
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v4.8.2) (governance/TimelockController.sol)
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol";
/**
* @dev Contract module which acts as a timelocked controller. When set as the
* owner of an `Ownable` smart contract, it enforces a timelock on all
* `onlyOwner` maintenance operations. This gives time for users of the
* controlled contract to exit before a potentially dangerous maintenance
* operation is applied.
*
* By default, this contract is self administered, meaning administration tasks
* have to go through the timelock process. The proposer (resp executor) role
* is in charge of proposing (resp executing) operations. A common use case is
* to position this {TimelockController} as the owner of a smart contract, with
* a multisig or a DAO as the sole proposer.
*
* _Available since v3.3._
*/
contract TimelockController is AccessControl, IERC721Receiver, IERC1155Receiver {
bytes32 public constant TIMELOCK_ADMIN_ROLE = keccak256("TIMELOCK_ADMIN_ROLE");
bytes32 public constant PROPOSER_ROLE = keccak256("PROPOSER_ROLE");
bytes32 public constant EXECUTOR_ROLE = keccak256("EXECUTOR_ROLE");
bytes32 public constant CANCELLER_ROLE = keccak256("CANCELLER_ROLE");
uint256 internal constant _DONE_TIMESTAMP = uint256(1);
mapping(bytes32 => uint256) private _timestamps;
uint256 private _minDelay;
/**
* @dev Emitted when a call is scheduled as part of operation `id`.
*/
event CallScheduled(
bytes32 indexed id,
uint256 indexed index,
address target,
uint256 value,
bytes data,
bytes32 predecessor,
uint256 delay
);
/**
* @dev Emitted when a call is performed as part of operation `id`.
*/
event CallExecuted(bytes32 indexed id, uint256 indexed index, address target, uint256 value, bytes data);
/**
* @dev Emitted when new proposal is scheduled with non-zero salt.
*/
event CallSalt(bytes32 indexed id, bytes32 salt);
/**
* @dev Emitted when operation `id` is cancelled.
*/
event Cancelled(bytes32 indexed id);
/**
* @dev Emitted when the minimum delay for future operations is modified.
*/
event MinDelayChange(uint256 oldDuration, uint256 newDuration);
/**
* @dev Initializes the contract with the following parameters:
*
* - `minDelay`: initial minimum delay for operations
* - `proposers`: accounts to be granted proposer and canceller roles
* - `executors`: accounts to be granted executor role
* - `admin`: optional account to be granted admin role; disable with zero address
*
* IMPORTANT: The optional admin can aid with initial configuration of roles after deployment
* without being subject to delay, but this role should be subsequently renounced in favor of
* administration through timelocked proposals. Previous versions of this contract would assign
* this admin to the deployer automatically and should be renounced as well.
*/
constructor(uint256 minDelay, address[] memory proposers, address[] memory executors, address admin) {
_setRoleAdmin(TIMELOCK_ADMIN_ROLE, TIMELOCK_ADMIN_ROLE);
_setRoleAdmin(PROPOSER_ROLE, TIMELOCK_ADMIN_ROLE);
_setRoleAdmin(EXECUTOR_ROLE, TIMELOCK_ADMIN_ROLE);
_setRoleAdmin(CANCELLER_ROLE, TIMELOCK_ADMIN_ROLE);
// self administration
_setupRole(TIMELOCK_ADMIN_ROLE, address(this));
// optional admin
if (admin != address(0)) {
_setupRole(TIMELOCK_ADMIN_ROLE, admin);
}
// register proposers and cancellers
for (uint256 i = 0; i < proposers.length; ++i) {
_setupRole(PROPOSER_ROLE, proposers[i]);
_setupRole(CANCELLER_ROLE, proposers[i]);
}
// register executors
for (uint256 i = 0; i < executors.length; ++i) {
_setupRole(EXECUTOR_ROLE, executors[i]);
}
_minDelay = minDelay;
emit MinDelayChange(0, minDelay);
}
/**
* @dev Modifier to make a function callable only by a certain role. In
* addition to checking the sender's role, `address(0)` 's role is also
* considered. Granting a role to `address(0)` is equivalent to enabling
* this role for everyone.
*/
modifier onlyRoleOrOpenRole(bytes32 role) {
if (!hasRole(role, address(0))) {
_checkRole(role, _msgSender());
}
_;
}
/**
* @dev Contract might receive/hold ETH as part of the maintenance process.
*/
receive() external payable {}
/**
* @dev See {IERC165-supportsInterface}.
*/
function supportsInterface(bytes4 interfaceId) public view virtual override(IERC165, AccessControl) returns (bool) {
return interfaceId == type(IERC1155Receiver).interfaceId || super.supportsInterface(interfaceId);
}
/**
* @dev Returns whether an id correspond to a registered operation. This
* includes both Pending, Ready and Done operations.
*/
function isOperation(bytes32 id) public view virtual returns (bool) {
return getTimestamp(id) > 0;
}
/**
* @dev Returns whether an operation is pending or not. Note that a "pending" operation may also be "ready".
*/
function isOperationPending(bytes32 id) public view virtual returns (bool) {
return getTimestamp(id) > _DONE_TIMESTAMP;
}
/**
* @dev Returns whether an operation is ready for execution. Note that a "ready" operation is also "pending".
*/
function isOperationReady(bytes32 id) public view virtual returns (bool) {
uint256 timestamp = getTimestamp(id);
return timestamp > _DONE_TIMESTAMP && timestamp <= block.timestamp;
}
/**
* @dev Returns whether an operation is done or not.
*/
function isOperationDone(bytes32 id) public view virtual returns (bool) {
return getTimestamp(id) == _DONE_TIMESTAMP;
}
/**
* @dev Returns the timestamp at which an operation becomes ready (0 for
* unset operations, 1 for done operations).
*/
function getTimestamp(bytes32 id) public view virtual returns (uint256) {
return _timestamps[id];
}
/**
* @dev Returns the minimum delay for an operation to become valid.
*
* This value can be changed by executing an operation that calls `updateDelay`.
*/
function getMinDelay() public view virtual returns (uint256) {
return _minDelay;
}
/**
* @dev Returns the identifier of an operation containing a single
* transaction.
*/
function hashOperation(
address target,
uint256 value,
bytes calldata data,
bytes32 predecessor,
bytes32 salt
) public pure virtual returns (bytes32) {
return keccak256(abi.encode(target, value, data, predecessor, salt));
}
/**
* @dev Returns the identifier of an operation containing a batch of
* transactions.
*/
function hashOperationBatch(
address[] calldata targets,
uint256[] calldata values,
bytes[] calldata payloads,
bytes32 predecessor,
bytes32 salt
) public pure virtual returns (bytes32) {
return keccak256(abi.encode(targets, values, payloads, predecessor, salt));
}
/**
* @dev Schedule an operation containing a single transaction.
*
* Emits {CallSalt} if salt is nonzero, and {CallScheduled}.
*
* Requirements:
*
* - the caller must have the 'proposer' role.
*/
function schedule(
address target,
uint256 value,
bytes calldata data,
bytes32 predecessor,
bytes32 salt,
uint256 delay
) public virtual onlyRole(PROPOSER_ROLE) {
bytes32 id = hashOperation(target, value, data, predecessor, salt);
_schedule(id, delay);
emit CallScheduled(id, 0, target, value, data, predecessor, delay);
if (salt != bytes32(0)) {
emit CallSalt(id, salt);
}
}
/**
* @dev Schedule an operation containing a batch of transactions.
*
* Emits {CallSalt} if salt is nonzero, and one {CallScheduled} event per transaction in the batch.
*
* Requirements:
*
* - the caller must have the 'proposer' role.
*/
function scheduleBatch(
address[] calldata targets,
uint256[] calldata values,
bytes[] calldata payloads,
bytes32 predecessor,
bytes32 salt,
uint256 delay
) public virtual onlyRole(PROPOSER_ROLE) {
require(targets.length == values.length, "TimelockController: length mismatch");
require(targets.length == payloads.length, "TimelockController: length mismatch");
bytes32 id = hashOperationBatch(targets, values, payloads, predecessor, salt);
_schedule(id, delay);
for (uint256 i = 0; i < targets.length; ++i) {
emit CallScheduled(id, i, targets[i], values[i], payloads[i], predecessor, delay);
}
if (salt != bytes32(0)) {
emit CallSalt(id, salt);
}
}
/**
* @dev Schedule an operation that is to become valid after a given delay.
*/
function _schedule(bytes32 id, uint256 delay) private {
require(!isOperation(id), "TimelockController: operation already scheduled");
require(delay >= getMinDelay(), "TimelockController: insufficient delay");
_timestamps[id] = block.timestamp + delay;
}
/**
* @dev Cancel an operation.
*
* Requirements:
*
* - the caller must have the 'canceller' role.
*/
function cancel(bytes32 id) public virtual onlyRole(CANCELLER_ROLE) {
require(isOperationPending(id), "TimelockController: operation cannot be cancelled");
delete _timestamps[id];
emit Cancelled(id);
}
/**
* @dev Execute an (ready) operation containing a single transaction.
*
* Emits a {CallExecuted} event.
*
* Requirements:
*
* - the caller must have the 'executor' role.
*/
// This function can reenter, but it doesn't pose a risk because _afterCall checks that the proposal is pending,
// thus any modifications to the operation during reentrancy should be caught.
// slither-disable-next-line reentrancy-eth
function execute(
address target,
uint256 value,
bytes calldata payload,
bytes32 predecessor,
bytes32 salt
) public payable virtual onlyRoleOrOpenRole(EXECUTOR_ROLE) {
bytes32 id = hashOperation(target, value, payload, predecessor, salt);
_beforeCall(id, predecessor);
_execute(target, value, payload);
emit CallExecuted(id, 0, target, value, payload);
_afterCall(id);
}
/**
* @dev Execute an (ready) operation containing a batch of transactions.
*
* Emits one {CallExecuted} event per transaction in the batch.
*
* Requirements:
*
* - the caller must have the 'executor' role.
*/
// This function can reenter, but it doesn't pose a risk because _afterCall checks that the proposal is pending,
// thus any modifications to the operation during reentrancy should be caught.
// slither-disable-next-line reentrancy-eth
function executeBatch(
address[] calldata targets,
uint256[] calldata values,
bytes[] calldata payloads,
bytes32 predecessor,
bytes32 salt
) public payable virtual onlyRoleOrOpenRole(EXECUTOR_ROLE) {
require(targets.length == values.length, "TimelockController: length mismatch");
require(targets.length == payloads.length, "TimelockController: length mismatch");
bytes32 id = hashOperationBatch(targets, values, payloads, predecessor, salt);
_beforeCall(id, predecessor);
for (uint256 i = 0; i < targets.length; ++i) {
address target = targets[i];
uint256 value = values[i];
bytes calldata payload = payloads[i];
_execute(target, value, payload);
emit CallExecuted(id, i, target, value, payload);
}
_afterCall(id);
}
/**
* @dev Execute an operation's call.
*/
function _execute(address target, uint256 value, bytes calldata data) internal virtual {
(bool success, ) = target.call{value: value}(data);
require(success, "TimelockController: underlying transaction reverted");
}
/**
* @dev Checks before execution of an operation's calls.
*/
function _beforeCall(bytes32 id, bytes32 predecessor) private view {
require(isOperationReady(id), "TimelockController: operation is not ready");
require(predecessor == bytes32(0) || isOperationDone(predecessor), "TimelockController: missing dependency");
}
/**
* @dev Checks after execution of an operation's calls.
*/
function _afterCall(bytes32 id) private {
require(isOperationReady(id), "TimelockController: operation is not ready");
_timestamps[id] = _DONE_TIMESTAMP;
}
/**
* @dev Changes the minimum timelock duration for future operations.
*
* Emits a {MinDelayChange} event.
*
* Requirements:
*
* - the caller must be the timelock itself. This can only be achieved by scheduling and later executing
* an operation where the timelock is the target and the data is the ABI-encoded call to this function.
*/
function updateDelay(uint256 newDelay) external virtual {
require(msg.sender == address(this), "TimelockController: caller must be timelock");
emit MinDelayChange(_minDelay, newDelay);
_minDelay = newDelay;
}
/**
* @dev See {IERC721Receiver-onERC721Received}.
*/
function onERC721Received(address, address, uint256, bytes memory) public virtual override returns (bytes4) {
return this.onERC721Received.selector;
}
/**
* @dev See {IERC1155Receiver-onERC1155Received}.
*/
function onERC1155Received(
address,
address,
uint256,
uint256,
bytes memory
) public virtual override returns (bytes4) {
return this.onERC1155Received.selector;
}
/**
* @dev See {IERC1155Receiver-onERC1155BatchReceived}.
*/
function onERC1155BatchReceived(
address,
address,
uint256[] memory,
uint256[] memory,
bytes memory
) public virtual override returns (bytes4) {
return this.onERC1155BatchReceived.selector;
}
}
At this point we are ready to deploy our AlumniGovernor, AlumniToken and TimelockController contracts.
We are going to use the Hardhat framework to deploy our contracts. Let’s jump into our console, create a new directory and init a js hardhat project:
mkdir alumnidao-governor && cd alumnidao-governor && npx hardhat
After configure hardhat, we are going to edit our hardhat.config.js file to add the Polygon Mumbai network, require dotenv and enable the optimization since our governor will exceed the maximum size allowed for a contract.
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();
/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
solidity: {
version: "0.8.18",
settings: {
optimizer: {
enabled: true,
runs: 1
}
}
},
networks: {
mumbai: {
url: process.env.POLYGON_MUMBAI_ALCHEMY_URL,
accounts: [ process.env.PRIVATE_KEY]
}
},
etherscan: {
apiKey: {
polygonMumbai: process.env.ETHERSCAN_MUMBAI
}
},
settings: {
optimizer: {
enabled: true,
runs: 1
}
}
};
We are going to install some dependencies:
npm install @nomicfoundation/hardhat-toolbox @openzeppelin/contracts dotenv
Now, we are going to add our .env file and set up our environment variables:
POLYGON_MUMBAI_ALCHEMY_URL=<YOUR_ALCHEMY_URL_TO_MUMBAI>
PRIVATE_KEY=<YOUR_PRIVATE_KEY>
ETHERSCAN_MUMBAI=<POLYGON_SCAN_API_KEY>
Let’s write our deploy scripts:
scripts/deployToken.js
const hre = require("hardhat");
async function main() {
const AlumniToken = await hre.ethers.getContractFactory("AlumniToken");
const alumniToken = await AlumniToken.deploy();
await alumniToken.deployed();
console.log(
`AlumniToken deployed to ${alumniToken.address}`
);
}
// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
scripts/deployTimelock.js
const hre = require("hardhat");
async function main() {
const [owner] = await hre.ethers.getSigners();
const TimelockController = await hre.ethers.getContractFactory("contracts/TimelockController.sol:TimelockController");
const timelockController = await TimelockController.deploy(
0, [owner.address], [owner.address], owner.address
);
await timelockController.deployed();
console.log(
`TimelockController deployed to ${timelockController.address}`
);
}
// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
scripts/deployGovernor.js
const hre = require("hardhat");
async function main() {
const [owner] = await hre.ethers.getSigners();
const AlumniGovernor = await hre.ethers.getContractFactory("AlumniGovernor");
const alumniGovernor = await AlumniGovernor.deploy(
"ALUMNI_TOKEN_CONTRACT_ADDRESS",
"ALUMNI_TIMELOCK_CONTRACT_ADDRESS"
);
await alumniGovernor.deployed();
console.log(
`AlumniGovernor deployed to ${alumniGovernor.address}`
);
}
// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Let’s deploy our contracts, starting by the token and the timelock since the governor will need both contract addresses:
npx hardhat run scripts/deployToken.js --network mumbai
AlumniToken deployed to 0x5C024058279dCF063CD09bA7303DB8cfe2038570
npx hardhat run scripts/deployTimelock.js --network mumbai
TimelockController deployed to 0xc7cF92e251e35ae5888daF30A3cAE5A05e751771
After deploy, the token and the timelock we are ready to add the addresses to the scripts/deployGovernor.js file. Once added, we run the deploy governor script:
npx hardhat run scripts/deployGovernor.js --network mumbai
AlumniGovernor deployed to 0x5F649Dd060139c74d593698a286816167D4F061d
Done! We just deployed our governance contracts. Now we need to verify them to be allowed to interact with them trough Polygon scan to set up the Timelock roles. Remember that we configured every role for the deployer and this is fully centralized. The D character from DAO means Decentralized, so let’s add a D to our AO.
The verification should be pretty straightforward but we need to create a file, in the root of our project, called arguments.js to export the timelock constructor arguments:
module.exports = [
0,
["0xf21C24CB921071F886459Ba918309BEC51BF27b7"],
["0xf21C24CB921071F886459Ba918309BEC51BF27b7"],
"0xf21C24CB921071F886459Ba918309BEC51BF27b7"
];
// alumniToken
npx hardhat verify 0x5C024058279dCF063CD09bA7303DB8cfe2038570 --network mumbai
// timelock
npx hardhat verify 0xc7cF92e251e35ae5888daF30A3cAE5A05e751771 --network mumbai --constructor-args arguments.js
// alumniGovernor
npx hardhat verify 0x5F649Dd060139c74d593698a286816167D4F061d --network mumbai 0x5C024058279dCF063CD09bA7303DB8cfe2038570 0xc7cF92e251e35ae5888daF30A3cAE5A05e751771
Once our contracts are verified we are ready to set up the timelock roles using Polygon Scan and the grantRole function from our TimelockController contract.
Proposer role (0xb09aa5aeb3702cfd50b6b62bc4532604938f21248a27a1d5ca736082b6819cc1) to the Governor contract (0x5F649Dd060139c74d593698a286816167D4F061d) at transaction.
Executor role (0xd8aa0f3194971a2a116679f7c2090f6939c8d4e01a2a8d7e41d55e5351469e63) to address zero at transaction.
Admin role (0x5f58e3a2316349923ce3780f8d587db2d72378aed66a8261c916544fa6846ca) to the
TimelockControllercontract at transaction.
At this point the deployer has lost all their roles and we have a fully Decentralized Autonomous Organization living in the Polygon Mumbai network.
That’s all. Let’s go ahead to the Tally UI and start creating our first proposal.
First things first, we need to add our DAO to Tally UI. Again, this is pretty straightforward since the Tally team offers us a very complete interface with everything we need to manage our DAO. Let’s go!
Visit the Tally UI website.
Press add DAO
Connect our wallet
A list of resources and requirements appears, press continue.
Insert a name, a description and the Alumni Governor address
Press find governor, register and done!
AlumniDAO has come to life!
In order to create our first proposal we need to mint some governance tokens. There are no necessary to create a proposal since we configured a proposal threshold of Zero. But we are going to need them to vote on our proposal. It is very important to mint them before to create the proposal, since the ERC20Votes takes a snapshot of the AlumniToken balances at the moment of create the proposal. So, if you mint them after create the proposal or you want to vote in a proposal previously created, you will have a voting power of Zero.
Visit our Alumni Token contract in Polygon Mumbai Scan.
Mint a token using the
mintfunction. No params needed.This will mint 1
AlumniTokento our account.Come back to the AlumniDAO dashboard in Tally.
Press Delegate to delegate your votes to yourself.
Press create proposal. Fill the required data.
Add a custom action to interact with our
AlumniGovernorcontract.For example, the function
setVotingPeriod, add43200(1 day) as parameter.Press continue.
Submit on-chain.
Refresh the site after some seconds and check that the proposal was added.
We just created our first proposal to our recently created Alumni DAO. That’s amazing!
Time to vote on our first proposal:
Press Vote on-chain
Fill the required data with your option (for, against or abstain) and some optional thoughts.
Press vote and sign the transacion!
Refresh the site to check your vote.
We have officially voted for a proposal in the AlumniDAO. Well done!
(It is important to note that the minting amount and the voting power are the equivalent to 1 wei of the AlumniToken since I forgot to set 1*(10*18) instead 1. While for testing purposes I think it is not a real problem. It MUST be changed on production.)
There are a lot of variables that can be configured differently as needed. I followed the Open Zeppelin instructions but other configurations are possible.
On the other hand it is necessary to define how we are going to solve the mint issue (whitelist the certificate to mint), if the governance token will be transferable or not.
In my opinion those votes that do not require a direct execution on the chain can be done through snapshot or another off-chain service to save costs to the members.
The delays and periods are configurable trough proposals and the timelock is configurable too.
I think I am able to renounce to the Tally UI superadmin role, thought this have nothing to do with the governor contracts. It is only Tally UI admin. I am not sure if renouncing now could lead to any problems. I need to read more about it.
Tag me on the Alchemy University discord to discuss anything about the AlumniDAO.
J. Valeska a.k.a. MaestroCripto, alumni alchemist.

