This article explains the concept of DAO and where it can be applied. The article also provides step-by-step instructions for writing your own DAO, with detailed tools and full test coverage.
What is DAO?
DAO - Decentralized Autonomous Organization. Although the name says that this is an organization, you need to understand that in fact the DAO is not the entire organization, but only a part of it, which is essentially responsible for voting and decision-making. In fact, the purpose of a DAO is to make decisions by voting.
There are different algorithms for who can vote and even what weight their vote has. The most popular of them: the weight is directly proportional to the number of tokens that someone has deposited into a specially designated wallet. So much for equal opportunities for everyone. Don't forget that everything requires money and was built for the sake of it. So the main idea of DAO is a black box that allows you to make collective decisions. With its own logic and threshold for entering the range of people who are this group.
Where to Use
- Voting to distribute funds to an organization or startup
- Voting for an increase in commission at some automatic currency exchanger. Here, by the way, you can immediately attach the DAO to the exchanger’s contract. For example, for the latter, create a function for changing the commission, which can only be called by the DAO contract
- Collective ownership. For example, a group of people bought the rights to a song. And now he decides how to manage it with the help of DAO. The weight of your vote depends on how much money you invested in this song. If there are many, then your vote is decisive
- And many more solutions and applications are possible. Which, perhaps, could be implemented easier and cheaper, but just as not interesting
Like any blockchain solution, DAO has a huge advantage - transparency and immutability. Before voting, you can familiarize yourself with the smart contract code, understand it very quickly. Everyone knows the solidity language, really:) And know that this code will not change. Let's forget for a moment about proxy contracts and some of the ways to “change” contracts that developers came up with under the auspices of "we need to somehow fix errors and release new versions, everything changes so quickly". So, thanks to transparency and immutability, DAO is a popular mechanism for making decisions where maximum transparency is needed.
Let's Create Our Own
First, a short excursion into what tools we will use. IDE: you can use a Notepad, but something smarter is better. For example, VisualCode or WebStorm. The main component will be hardhat, because we need to write scripts for deployment, some tasks for calling the contract and, of course, tests, all of this is in hardhat.
Now I’ll describe a little what we will create. A smart contract that can:
- Take money from users to increase the weight of their vote. By user, we mean a wallet, and by money ERC20 tokens
- Provide the opportunity to add proposal
- Provide the opportunity to vote. If someone votes, then the number of his votes is equal to the number of tokens that he deposited into the contract account
- Provides an opportunity to finish a proposal. The proposal has a duration period and only after the voting time has expired can we consider it completed. If successful, we call the function of another smart contract.
- Possibility to withdrawal tokens
This is what I got after the first iteration.
pragma solidity ^0.8.20;
contract DAO {
constructor(
address _voteToken){
}
function deposit(uint256 amount) public {
}
function withdrawal(uint256 amount) public {
}
function addProposal(bytes memory callData, address recipient,
uint256 debatingPeriodDuration, string memory description)
public returns (bytes32){
bytes32 proposalId = keccak256(abi.encodePacked
(recipient, description, <code>callData</code>, block.timestamp));
return proposalId;
}
function vote(bytes32 proposalId, bool decision) public{
}
function finishProposal(bytes32 proposalId) public{
}
}
Perhaps only the addProposal
function needs explanation. Using it, anyone can create a proposal for voting. The voting duration is specified by the debatingPeriodDuration
parameter. After this period, if the decision is positive, recipient will be called with the date from callData
.
Ok, we’ve decided on the methods.
It’s Time for Tests
deposit
- Should check that the transaction will fail with an error if the user did not allow our contract to withdraw money
- Should transfer tokens from the user's wallet for the requested amount
- Should replenish our contract wallet with the requested amount
withdrawal
- Should not allow withdrawal if the user participates in voting
- Should not allow the user to withdraw more than is on his balance
- Should transfer tokens from the contract wallet for the requested amount
- Should replenish the user's wallet with the requested amount
addProposal
- Check for duplicate proposals. Users cannot create two proposals with the same description and
callData
- I can, of course, also make a method to get a list of active votes and check that our proposal appears there, but I don’t want to. If you have any other ideas, write them in the comments to the article.
vote
- Should only be available for accounts with a positive balance
- Should only be available for existing votes
- Should return an error when retrying the vote
- Should return an error if the voting time has expired
finishProposal
- Should return an error if no vote exists
- Should return an error the time allotted for voting has not yet expired
- Should call the recipient method in case of a positive decision
- Should not call the recipient method in case of a negative decision
We will vote for mint tokens. The weight of votes will be determined by the same tokens. This means that for testing, we will need a mock ERC20 token contract, which mint can do. Let's take the ERC20 contract from openzeppelin
as a basis and expand it to the method we need.
pragma solidity ^0.8.20;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract ERC20Mint is ERC20 {
constructor() ERC20("ERC20Mint", "ERC20Mint"){
}
function mint(address account, uint256 amount) public {
_mint(account, amount);
}
}
Now about npm packages. I will be writing in TypeScript so we need a package for it. Of course, we need hardhat, @nomicfoundation/hardhat-toolbox
, @nomicfoundation/hardhat-ethers
to compile and generate ts classes of our smart contracts. We also need chai
and @types/chai
for beautiful tests and a package of contracts from openzeppelin
for our @openzeppelin/contracts token
.
Here are the tests I did. Now they all fail with an error, but that’s normal, we haven’t implemented anything yet.
import { expect } from "chai";
import { ethers } from "hardhat";
import {HardhatEthersSigner} from "@nomicfoundation/hardhat-ethers/signers";
import {DAO, ERC20Mint} from "../typechain-types";
import {ContractFactory} from "ethers";
describe("Dao contract", () => {
let accounts : HardhatEthersSigner[];
let daoOwner : HardhatEthersSigner;
let voteToken : ERC20Mint;
let voteTokenAddress : string;
let recipient : ERC20Mint;
let recipientAddress : string;
let dao : DAO;
let daoAddress : string;
let proposalDuration : number;
let callData : string;
let proposalDescription : string;
let proposalTokenRecipient : HardhatEthersSigner;
let proposalMintAmount: number;
beforeEach(async () =>{
accounts = await ethers.getSigners();
[proposalTokenRecipient] = await ethers.getSigners();
proposalDuration = 100;
const erc20Factory : ContractFactory =
await ethers.getContractFactory("ERC20Mint");
voteToken = (await erc20Factory.deploy()) as ERC20Mint;
voteTokenAddress = await voteToken.getAddress();
recipient = (await erc20Factory.deploy()) as ERC20Mint;
recipientAddress = await recipient.getAddress();
const daoFactory : ContractFactory = await ethers.getContractFactory("DAO");
dao = (await daoFactory.deploy(voteTokenAddress)) as DAO;
daoAddress = await dao.getAddress();
proposalMintAmount = 200;
callData = recipient.interface.encodeFunctionData
("mint", [proposalTokenRecipient.address, proposalMintAmount]);
proposalDescription = "proposal description";
});
async function getProposalId(recipient : string,
description: string, callData: string) : Promise<string> {
let blockNumber : number = await ethers.provider.getBlockNumber();
let block = await ethers.provider.getBlock(blockNumber);
return ethers.solidityPackedKeccak256(["address", "string", "bytes"],
[recipient, description, callData]);
}
describe("deposit", () => {
it("should require allowance", async () => {
const account: HardhatEthersSigner = accounts[2];
const amount : number = 100;
await expect(dao.connect(account).deposit(amount))
.to.be.revertedWith("InsufficientAllowance");
});
it("should change balance on dao", async () => {
const account: HardhatEthersSigner = accounts[2];
const amount : number = 100;
await voteToken.mint(account.address, amount);
await voteToken.connect(account).approve(daoAddress, amount);
await dao.connect(account).deposit(amount);
expect(await voteToken.balanceOf(daoAddress))
.to.be.equal(amount);
});
it("should change token balance", async () => {
const account: HardhatEthersSigner = accounts[2];
const amount : number = 100;
await voteToken.mint(account.address, amount);
await voteToken.connect(account).approve(daoAddress, amount);
await dao.connect(account).deposit(amount);
expect(await voteToken.balanceOf(account.address))
.to.be.equal(0);
});
});
describe("withdrawal", () => {
it("should not be possible when all balances are frozen", async () => {
const account : HardhatEthersSigner = accounts[5];
const voteTokenAmount : number = 100;
const withdrawalAmount : number = voteTokenAmount;
await voteToken.mint(account.address, voteTokenAmount);
await voteToken.connect(account).approve(daoAddress, voteTokenAmount);
await dao.connect(account).deposit(voteTokenAmount);
await dao.addProposal(callData, recipientAddress,
proposalDuration, proposalDescription);
let proposalId : string = await getProposalId
(recipientAddress, proposalDescription, callData);
await dao.connect(account).vote(proposalId, true);
await expect(dao.connect(account).withdrawal(withdrawalAmount))
.to.be.revertedWith("FrozenBalance");
});
it("should be possible with a partially frozen balance", async () => {
const account : HardhatEthersSigner = accounts[5];
const voteTokenAmount1 : number = 100;
const voteTokenAmount2 : number = 100;
const withdrawalAmount : number = voteTokenAmount2;
await voteToken.mint(account.address, voteTokenAmount1 + voteTokenAmount2);
await voteToken.connect(account).approve
(daoAddress, voteTokenAmount1 + voteTokenAmount2);
await dao.connect(account).deposit(voteTokenAmount1);
await dao.addProposal(callData, recipientAddress,
proposalDuration, proposalDescription);
let proposalId : string = await getProposalId
(recipientAddress, proposalDescription, callData);
await dao.connect(account).vote(proposalId, true);
await dao.connect(account).deposit(voteTokenAmount2);
await dao.connect(account).withdrawal(withdrawalAmount);
expect(await voteToken.balanceOf(account.address))
.to.be.equal(withdrawalAmount);
});
it("shouldn't be possible with withdrawal amount more then balance", async () => {
const account : HardhatEthersSigner = accounts[5];
const voteTokenAmount : number = 100;
const withdrawalAmount : number = voteTokenAmount + 1;
await voteToken.mint(account.address, voteTokenAmount);
await voteToken.connect(account).approve(daoAddress, voteTokenAmount);
await dao.connect(account).deposit(voteTokenAmount);
await expect(dao.connect(account).withdrawal(withdrawalAmount))
.to.be.revertedWith("FrozenBalance");
});
it("should change account balance", async () => {
const account : HardhatEthersSigner = accounts[5];
const voteTokenAmount : number = 100;
const withdrawalAmount : number = voteTokenAmount - 1;
await voteToken.mint(account.address, voteTokenAmount);
await voteToken.connect(account).approve(daoAddress, voteTokenAmount);
await dao.connect(account).deposit(voteTokenAmount);
await dao.connect(account).withdrawal(withdrawalAmount);
expect(await voteToken.balanceOf(account.address))
.to.be.equal(withdrawalAmount);
});
it("should change dao balance", async () => {
const account : HardhatEthersSigner = accounts[5];
const voteTokenAmount : number = 100;
const withdrawalAmount : number = voteTokenAmount - 1;
await voteToken.mint(account.address, voteTokenAmount);
await voteToken.connect(account).approve(daoAddress, voteTokenAmount);
await dao.connect(account).deposit(voteTokenAmount);
await dao.connect(account).withdrawal(withdrawalAmount);
expect(await voteToken.balanceOf(daoAddress))
.to.be.equal(voteTokenAmount - withdrawalAmount);
});
});
describe("addProposal", () => {
it("should not be possible with duplicate proposal", async () => {
const account: HardhatEthersSigner = accounts[5];
await dao.addProposal(callData, recipientAddress,
proposalDuration, proposalDescription);
await expect(dao.addProposal(callData, recipientAddress,
proposalDuration, proposalDescription))
.to.be.revertedWith("DoubleProposal");
});
});
describe("vote", () => {
it("should be able for account with balance only", async () => {
const account : HardhatEthersSigner = accounts[5];
await dao.addProposal(callData, recipientAddress,
proposalDuration, proposalDescription);
let proposalId : string = await getProposalId
(recipientAddress, proposalDescription, callData);
await expect(dao.connect(account).vote(proposalId, true))
.to.be.revertedWith("InsufficientFounds");
});
it("shouldn't be able if proposal isn't exist", async () => {
const account : HardhatEthersSigner = accounts[5];
const voteTokenAmount : number = 100;
await voteToken.mint(account.address, voteTokenAmount);
await voteToken.connect(account).approve(daoAddress, voteTokenAmount);
await dao.connect(account).deposit(voteTokenAmount);
let proposalId : string = await getProposalId
(recipientAddress, proposalDescription, callData);
await expect(dao.connect(account).vote(proposalId, true))
.to.be.revertedWith("NotFoundProposal");
});
it("shouldn't be able double vote", async () => {
const account : HardhatEthersSigner = accounts[5];
const voteTokenAmount : number = 100;
await voteToken.mint(account.address, voteTokenAmount);
await voteToken.connect(account).approve(daoAddress, voteTokenAmount);
await dao.connect(account).deposit(voteTokenAmount);
await dao.addProposal(callData, recipientAddress,
proposalDuration, proposalDescription);
let proposalId : string = await getProposalId
(recipientAddress, proposalDescription, callData);
await dao.connect(account).vote(proposalId, true);
await expect(dao.connect(account).vote(proposalId, true))
.to.be.revertedWith("DoubleVote");
});
it("shouldn't be able after proposal duration", async () => {
const account : HardhatEthersSigner = accounts[5];
const voteTokenAmount : number = 100;
await voteToken.mint(account.address, voteTokenAmount);
await voteToken.connect(account).approve(daoAddress, voteTokenAmount);
await dao.connect(account).deposit(voteTokenAmount);
await dao.addProposal(callData, recipientAddress,
proposalDuration, proposalDescription);
let proposalId : string = await getProposalId
(recipientAddress, proposalDescription, callData);
await ethers.provider.send('evm_increaseTime', [proposalDuration]);
await expect(dao.connect(account).vote(proposalId, true))
.to.be.revertedWith("ExpiredVotingTime");
});
});
describe("finishProposal", () => {
it("shouldn't be able if proposal isn't exist", async () => {
let proposalId : string = await getProposalId
(recipientAddress, proposalDescription, callData);
await expect(dao.finishProposal(proposalId))
.to.be.revertedWith("NotFoundProposal");
});
it("shouldn't be able if proposal period isn't closed", async () => {
const account : HardhatEthersSigner = accounts[5];
const voteTokenAmount : number = 100;
await voteToken.mint(account.address, voteTokenAmount);
await voteToken.connect(account).approve(daoAddress, voteTokenAmount);
await dao.connect(account).deposit(voteTokenAmount);
await dao.addProposal(callData, recipientAddress,
proposalDuration, proposalDescription);
let proposalId : string = await getProposalId
(recipientAddress, proposalDescription, callData);
await dao.connect(account).vote(proposalId, true);
await ethers.provider.send('evm_increaseTime', [proposalDuration-2]);
await expect(dao.finishProposal(proposalId))
.to.be.revertedWith("NotExpiredVotingTime");
});
it("shouldn't call recipient when cons", async () => {
const account : HardhatEthersSigner = accounts[5];
const voteTokenAmount : number = 100;
await voteToken.mint(account.address, voteTokenAmount);
await voteToken.connect(account).approve(daoAddress, voteTokenAmount);
await dao.connect(account).deposit(voteTokenAmount);
await dao.addProposal(callData, recipientAddress,
proposalDuration, proposalDescription);
let proposalId : string = await getProposalId
(recipientAddress, proposalDescription, callData);
await dao.connect(account).vote(proposalId, false);
await ethers.provider.send('evm_increaseTime', [proposalDuration]);
await dao.finishProposal(proposalId);
expect(await recipient.balanceOf(proposalTokenRecipient.address))
.to.be.equal(0);
});
it("should call recipient when pons", async () => {
const account : HardhatEthersSigner = accounts[5];
const voteTokenAmount : number = 100;
await voteToken.mint(account.address, voteTokenAmount);
await voteToken.connect(account).approve(daoAddress, voteTokenAmount);
await dao.connect(account).deposit(voteTokenAmount);
await dao.addProposal(callData, recipientAddress,
proposalDuration, proposalDescription);
let proposalId : string = await getProposalId
(recipientAddress, proposalDescription, callData);
await dao.connect(account).vote(proposalId, true);
await ethers.provider.send('evm_increaseTime', [proposalDuration]);
await dao.finishProposal(proposalId);
expect(await recipient.balanceOf(proposalTokenRecipient.address))
.to.be.equal(proposalMintAmount);
});
});
});
Let's Start Implementation
First, let's decide what we will store in our contract:
For me, the most pleasant thing at this stage is the gradual successful execution of tests. Here's what I got.
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract DAO {
address public voteToken;
mapping(address => uint256) public balances;
mapping(address => uint256) public frozenBalances;
mapping(bytes32 => Proposal) private _proposals;
struct Proposal{
uint256 startDate;
uint256 endDate;
bytes callData;
address recipient;
string description;
uint256 pros;
uint256 cons;
mapping(address => uint256) voters;
address[] votersAddresses;
}
constructor(
address _voteToken){
voteToken = _voteToken;
}
function deposit(uint256 amount) public {
require(IERC20(voteToken).allowance(msg.sender,
address(this)) >= amount, "InsufficientAllowance");
balances[msg.sender] += amount;
SafeERC20.safeTransferFrom(IERC20(voteToken), msg.sender, address(this), amount);
}
function withdrawal(uint256 amount) public {
require(amount > 0 && balances[msg.sender] -
frozenBalances[msg.sender] >= amount, "FrozenBalance");
balances[msg.sender] -= amount;
SafeERC20.safeTransfer(IERC20(voteToken), msg.sender, amount);
}
function addProposal(bytes memory callData, address recipient,
uint256 debatingPeriodDuration, string memory description) public{
bytes32 proposalId = keccak256(abi.encodePacked(recipient, description, callData));
require(_proposals[proposalId].startDate == 0, "DoubleProposal");
_proposals[proposalId].startDate = block.timestamp;
_proposals[proposalId].endDate =
_proposals[proposalId].startDate + debatingPeriodDuration;
_proposals[proposalId].recipient = recipient;
_proposals[proposalId].callData = callData;
_proposals[proposalId].description = description;
}
function vote(bytes32 proposalId, bool decision) public{
require(balances[msg.sender] > 0, "InsufficientFounds");
require(_proposals[proposalId].startDate >0, "NotFoundProposal");
require(balances[msg.sender] > _proposals[proposalId].voters[msg.sender],
"DoubleVote");
require(_proposals[proposalId].endDate > block.timestamp, "ExpiredVotingTime");
decision ? _proposals[proposalId].pros+=balances[msg.sender] -
_proposals[proposalId].voters[msg.sender] :
_proposals[proposalId].cons+=balances[msg.sender] -
_proposals[proposalId].voters[msg.sender];
_proposals[proposalId].voters[msg.sender] = balances[msg.sender];
frozenBalances[msg.sender] += balances[msg.sender];
_proposals[proposalId].votersAddresses.push(msg.sender);
}
function finishProposal(bytes32 proposalId) public{
require(_proposals[proposalId].startDate >0, "NotFoundProposal");
require(_proposals[proposalId].endDate <= block.timestamp, "NotExpiredVotingTime");
for (uint i = 0; i < _proposals[proposalId].votersAddresses.length; i++) {
frozenBalances[_proposals[proposalId].votersAddresses[i]] -=
_proposals[proposalId].voters[_proposals[proposalId].votersAddresses[i]];
}
bool decision = _proposals[proposalId].pros > _proposals[proposalId].cons;
if (decision) callRecipient(_proposals[proposalId].recipient,
_proposals[proposalId].callData);
delete _proposals[proposalId];
}
function callRecipient(address recipient, bytes memory signature) private {
(bool success, ) = recipient.call{value: 0}(signature);
require(success, "CallRecipientError");
}
}
Well, we're done. All tests are green. In this article, you learned about what a DAO is and where it can be used. We have successfully written our DAO and completely covered it with tests. I hope the article was useful to you.
History
- 12th October, 2023: Initial version