Click here to Skip to main content
15,887,027 members
Articles / Security / Blockchain

DAO. Short and Clear. Let's Create Our Own.

Rate me:
Please Sign up or sign in to vote.
5.00/5 (1 vote)
12 Oct 2023CPOL6 min read 3.1K   12   1   3
An introduction to the concept of DAO and a workshop on how to create your own
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.

Image 1

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.

TypeScript
pragma solidity ^0.8.20;

contract  DAO {

   constructor(
       address _voteToken){

   }

   /// @notice Deposit vote tokens
   /// @param amount Amount tokens which you want to send
   function deposit(uint256 amount) public {

   }

   /// @notice Withdrawal vote tokens
   /// @param amount Amount tokens which you want to get
   function withdrawal(uint256 amount) public {

   }

   /// @notice Start new proposal
   /// @param callData Signature call recipient method
   /// @param recipient Contract which we will call
   /// @param debatingPeriodDuration Voting duration
   /// @param description Proposal description
   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;
   }

   /// @notice Vote to some proposal
   /// @param proposalId Proposal id
   /// @param decision Your decision
   function vote(bytes32 proposalId, bool decision) public{

   }

   /// @notice Finish proposal
   /// @param proposalId Proposal id
   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.

TypeScript
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.

TypeScript
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:

  • Voting token address so we can withdraw money from there. It's just a field with type address
  • Balances of our users to know the weight of votes and how much the user can be to withdraw. We will store them in a mapping, where the key will be the address, and the value will be the user’s balance
  • The voting itself with the number of votes. It's a little more complicated. It is clear that this will be a mapping with a key in the form of a voting id, but you need to create a special structure for proposals. Voters are needed here to prevent repeat voting.
    TypeScript
    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;
    }
  • We also need a floor that will help us understand how much money is hold in voting. This is necessary to calculate the amount available for withdrawal to the user. Everything here is similar to storing balances.

For me, the most pleasant thing at this stage is the gradual successful execution of tests. Here's what I got.

TypeScript
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract  DAO {

   /// @notice Token which you need to deposit for can vote
   address public voteToken;

   /// @notice Balances of users
   mapping(address => uint256) public balances;

   /// @notice Frozen balances of users
   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;
   }

   /// @notice Deposit vote tokens
   /// @param amount Amount tokens which you want to send
   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);
   }

   /// @notice Withdrawal vote tokens
   /// @param amount Amount tokens which you want to get
   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);
   }

   /// @notice Start new proposal
   /// @param callData Signature call recipient method
   /// @param recipient Contract which we will call
   /// @param debatingPeriodDuration Voting duration
   /// @param description Proposal description
   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;
   }

   /// @notice Vote to some proposal
   /// @param proposalId Proposal id
   /// @param decision Your decision
   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);
   }

   /// @notice Finish proposal
   /// @param proposalId Proposal id
   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

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Team Leader Itez
Costa Rica Costa Rica
I am a team leader in the blockchain and .Net industry where I have designed architectures, led teams, and developed many projects from scratch.
I worked with large blockchain startups such as Itez and Tokenbox, which helped people exchange their money for cryptocurrency and invest in new crypto coins. The team under my leadership implemented a project that was used by tens of thousands of people.
I am a very sociable and cheerful person. Lover of swimming and ergonomic keyboards. To get in touch, write to the email address waksund@gmail.com or to the linkedIn profile http://www.linkedin.com/in/vdolzhenko.

Comments and Discussions

 
SuggestionRename your DAO Pin
Chris Hyett Nov202115-Oct-23 8:31
Chris Hyett Nov202115-Oct-23 8:31 
GeneralRe: Rename your DAO Pin
Viktoria Dolzhenko15-Oct-23 12:31
Viktoria Dolzhenko15-Oct-23 12:31 
GeneralMy vote of 5 Pin
Ștefan-Mihai MOGA12-Oct-23 18:20
professionalȘtefan-Mihai MOGA12-Oct-23 18:20 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.