Skip to content

How to Create Your Own Voting Vault with Council

CPSTL edited this page Mar 7, 2023 · 2 revisions

Introduction

This tutorial is at an intermediate level and is aimed at developers who have some experience with writing smart contracts in Solidity. In this tutorial, we explore the requirements of writing a voting vault and give an example of how to create an NFT voting vault.

The boundaries on what is allowed as a voting vault are endless and are only limited by the interface that the Core Voting contract must be able to use to load data from the Vault. The Core Voting contract does not make any assumptions on the allocation of votes between voting vaults, the source of the votes, or the length of time the user has had them.

WARNING: if the total number of votes in the system exceeds size u128, overflows are possible. The interface that the vault must match looks like:

interface IVotingVault {
	/// @notice Attempts to load the voting power of a user
	/// @param user The address we want to load the voting power of
	/// @param blockNumber the block number we want the user's voting power at
	/// @param extraData Abi encoded optional extra data used by some vaults, such as merkle proofs
	/// @return the number of votes
	function queryVotePower(
    	address user,
    	uint256 blockNumber,
    	bytes calldata extraData
	) external returns (uint256);
}

The voting vault must be able to retrieve the voting power for a user at the timestamp that the vote was created. Not following this temporal ordering can result in attacks on the governance system such as flashloans, or in the worst cases votes being falsely created. The vault then returns this power to the core voting contract when asked to do so. The voting vault can use the extra data provided to load or manipulate votes, or it can be used for custom instructions.

Example: https://github.com/element-fi/council/blob/main/contracts/vaults/OptimisticRewards.sol uses merkle proofs in the extra data to enable voting with unclaimed but distributed tokens.

For our first example of a voting vault, consider a vault that allows anyone who calls it to have any number of votes per request.


contract FriendlyVault is IVotingVault {

	// extraData encodes a uint128 of votes to give to the user via abi.encode
	function queryVotePower(
    	address user,
   	 uint256 blockNumber,
   	 bytes calldata extraData
	) external override returns (uint256) {
    	uint128 votes = abi.decode(extraData, (uint128));
    	return uint256(votes);
	}
}

The voting vault contracts that have been deployed for the Element DAO all use upgradable proxies to ensure that when users deposit governance tokens (or other voting power systems such as NFTs, other types of tokens, reputation or identity, etc) and a change is made to the voting algorithms, they do not need to move the governance tokens to retain their votes/voting power. This upgradability design is not mandatory. However, the tooling for historical lookups developed for the Council Protocol uses hash based data locations for storage, and as a result, may have incompatibilities with other systems.


WARNING: Council Protocol Proxies must use the hash based storage addressing for ALL variables as the proxy itself uses slots 0 and 1. Using any standard storage will corrupt the proxy system.


The History.sol library is an assembly implementation of a binary search over an array of timestamps and value pairs, with optional garbage collection. It can be used to load and store timestamped user data for your implementation.

In this tutorial, we will use it to build an implementation of an NFT vesting vault without any delegation. First, we will create a deposit function which transfers a user’s NFT into the contract. Note: users can maintain control of the NFT if the NFT contract contains a historical ownership lookup system such as the Compound (COMP) token.


// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.3;

import "../libraries/History.sol";
import "../libraries/Storage.sol";
import "./IERC721.sol";

contract NFTVault {
	// Bring our libraries into scope
	using History for *;
	using Storage for *;

	// Only immutables can be declared without using the hash locations
	IERC721 immutable token;

	constructor(IERC721 _token) {
    	token = _token;
	}

	/// @notice Returns the historical voting power tracker
	/// @return A struct which can push to and find items in block indexed storage
	function _votingPower()
    	internal
    	pure
    	returns (History.HistoricalBalances memory)
	{
    	// This call returns a storage mapping with a unique non overwrite-able storage location
    	// which can be persisted through upgrades, even if they change storage layout
    	return (History.load("votingPower"));
	}

	/// @notice Transfers one NFT of our collection to this contract and then adds one vote to the user's voting power
	/// @param tokenId The token Id, not the NFT to transfer.
	function deposit(uint256 tokenId) external {
    	// Get the token from the user
    	token.transferFrom(msg.sender, address(this), tokenId);
    	// Get the hash pointer to the history mapping
    	History.HistoricalBalances memory votingPower = _votingPower();
    	// Load the user votes
    	uint256 currentVotes = votingPower.loadTop(msg.sender);
    	// Push their new voting power
    	votingPower.push(msg.sender, currentVotes + 1);
	}
    
	/// @notice Attempts to load the voting power of a user
	/// @param user The address we want to load the voting power of
	/// @param blockNumber the block number at which we want the user's voting power
	/// @return the number of votes
	function queryVotePower(
    	address user,
    	uint256 blockNumber,
    	bytes calldata
	) external override returns (uint256) {
    	// Get our reference to historical data
    	History.HistoricalBalances memory votingPower = _votingPower();
    	// Find the historical data in our mapping
    	return
        	votingPower.find(
            	user,
            	blockNumber
        	);
	}
}


This NFT voting vault contact is defined with an immutable NFT token and then for each token added the user who transferred it gets one vote. When the user adds a new token, their sum is incremented in the history mapping, and when the vault is queried the sum of the most recent timestamp before the query is returned. It does not support withdrawals or delegation but these actions flow from this same pattern.

Some simple but possibly instructive ways to tweak this NFT voting vault to get different results: Change the addition of 1 to any other constant to make NFTs worth more votes. Replace the addition of 1 with a lookup of the traits and then map traits by rarity and add to sum to get votes based on rarity. Replace the ‘queryVotePower’ function with one which calculates percent of deposited NFTs the user holds and multiplies it by a global constant for capped votes distributed proportionally to users.

By tweaking either the storage or the vote counting functionality you can arrive at any number of possibilities from only small changes.

Clone this wiki locally