Developing conviction voting (continuous voting)
Standard snapshot voting in DAO suffers from several problems: voting period is fixed (if you miss it — you don't vote), whale can hold votes until the last moment and flip results, each proposal is a separate voting event with low turnout. Conviction voting solves these through fundamentally different mechanics: a vote accumulates "conviction" over time, and a proposal passes when accumulated conviction crosses a dynamic threshold.
Gardens (1Hive), TE Commons, Giveth use conviction voting to manage treasury grants. Jeff Emmett designed the mechanic and detailed it in Gardens whitepaper.
Mathematical model
The system's core is the conviction accumulation function:
conviction(t) = conviction(t-1) * α + votes * (1 - α)
where:
-
α(alpha) — decay coefficient, 0 < α < 1. Typical: 0.9 (slow accumulation) to 0.5 (fast) -
votes— current voting power supporting proposal -
conviction(t)— accumulated conviction at moment t
At α = 0.9: reaching 100% conviction with constant votes takes ~22 periods (days if period = 1 day). Meaning: early supporters accumulate more conviction, late manipulation is less effective.
Pass threshold is also dynamic, depends on request size relative to available treasury:
threshold = threshold_min + (total_supply² * max_ratio) / (requested_amount * (total_supply - staked_at_this_proposal)²)
Logic: the larger % of treasury requested, the higher threshold. Protects against approving huge transfers at low turnout.
Contract architecture
ConvictionVoting contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/math/Math.sol";
contract ConvictionVoting is ReentrancyGuard {
IERC20 public immutable token;
IERC20 public immutable requestToken; // treasury token (USDC, WETH)
address public immutable vault; // treasury vault
// Model parameters (managed by governance)
uint256 public constant PRECISION = 1e7;
uint256 public alpha; // decay coefficient * PRECISION
uint256 public maxRatio; // max % of treasury per proposal
uint256 public minThresholdStake; // min % stake for any proposal
struct Proposal {
uint256 requestedAmount;
address beneficiary;
uint256 stakedTokens; // total votes for this proposal
uint256 convictionLast; // accumulated conviction at last update
uint256 blockLast; // last update block
ProposalStatus status;
string title;
string link; // IPFS link to description
}
enum ProposalStatus { Active, Passed, Rejected, Cancelled }
mapping(uint256 => Proposal) public proposals;
uint256 public proposalCount;
// Voter's vote distribution: address => proposalId => amount
mapping(address => mapping(uint256 => uint256)) public voterStake;
// Total voter votes across all proposals
mapping(address => uint256) public totalVoterStake;
event ProposalAdded(uint256 indexed proposalId, address indexed beneficiary, uint256 amount);
event StakeAdded(uint256 indexed proposalId, address indexed voter, uint256 amount, uint256 conviction);
event StakeWithdrawn(uint256 indexed proposalId, address indexed voter, uint256 amount);
event ProposalExecuted(uint256 indexed proposalId, uint256 amount);
constructor(
address _token,
address _requestToken,
address _vault,
uint256 _alpha, // e.g., 9000000 (0.9 * PRECISION)
uint256 _maxRatio, // e.g., 200000 (20% * PRECISION)
uint256 _minThreshold // e.g., 20000 (2% * PRECISION)
) {
token = IERC20(_token);
requestToken = IERC20(_requestToken);
vault = _vault;
alpha = _alpha;
maxRatio = _maxRatio;
minThresholdStake = _minThreshold;
}
function addProposal(
uint256 requestedAmount,
address beneficiary,
string calldata title,
string calldata link
) external returns (uint256) {
require(requestedAmount > 0, "Amount must be positive");
require(beneficiary != address(0), "Invalid beneficiary");
uint256 vaultBalance = requestToken.balanceOf(vault);
require(
requestedAmount <= vaultBalance * maxRatio / PRECISION,
"Exceeds max ratio"
);
uint256 proposalId = ++proposalCount;
proposals[proposalId] = Proposal({
requestedAmount: requestedAmount,
beneficiary: beneficiary,
stakedTokens: 0,
convictionLast: 0,
blockLast: block.number,
status: ProposalStatus.Active,
title: title,
link: link
});
emit ProposalAdded(proposalId, beneficiary, requestedAmount);
return proposalId;
}
function stakeToProposal(uint256 proposalId, uint256 amount) external nonReentrant {
Proposal storage proposal = proposals[proposalId];
require(proposal.status == ProposalStatus.Active, "Not active");
uint256 availableBalance = token.balanceOf(msg.sender) - totalVoterStake[msg.sender];
require(amount <= availableBalance, "Insufficient unstaked tokens");
_updateConviction(proposalId);
voterStake[msg.sender][proposalId] += amount;
totalVoterStake[msg.sender] += amount;
proposal.stakedTokens += amount;
emit StakeAdded(proposalId, msg.sender, amount, proposal.convictionLast);
}
function withdrawFromProposal(uint256 proposalId, uint256 amount) external nonReentrant {
require(voterStake[msg.sender][proposalId] >= amount, "Insufficient stake");
_updateConviction(proposalId);
voterStake[msg.sender][proposalId] -= amount;
totalVoterStake[msg.sender] -= amount;
proposals[proposalId].stakedTokens -= amount;
emit StakeWithdrawn(proposalId, msg.sender, amount);
}
function executeProposal(uint256 proposalId) external nonReentrant {
Proposal storage proposal = proposals[proposalId];
require(proposal.status == ProposalStatus.Active, "Not active");
_updateConviction(proposalId);
uint256 threshold = calculateThreshold(proposal.requestedAmount);
require(proposal.convictionLast >= threshold, "Insufficient conviction");
proposal.status = ProposalStatus.Passed;
// Payout from vault through approved allowance
requestToken.transferFrom(vault, proposal.beneficiary, proposal.requestedAmount);
emit ProposalExecuted(proposalId, proposal.requestedAmount);
}
function _updateConviction(uint256 proposalId) internal {
Proposal storage proposal = proposals[proposalId];
uint256 blocksPassed = block.number - proposal.blockLast;
if (blocksPassed == 0) return;
// conviction(t) = conviction(t-1) * alpha^blocksPassed + stakedTokens * (1 - alpha^blocksPassed)
// Compute alpha^blocksPassed via fast exponentiation
uint256 alphaPow = _pow(alpha, blocksPassed);
proposal.convictionLast =
(proposal.convictionLast * alphaPow / PRECISION) +
(proposal.stakedTokens * (PRECISION - alphaPow) / PRECISION);
proposal.blockLast = block.number;
}
// Fast exponentiation for fixed-point arithmetic
function _pow(uint256 base, uint256 exponent) internal pure returns (uint256) {
if (exponent == 0) return PRECISION;
uint256 result = PRECISION;
while (exponent > 0) {
if (exponent % 2 == 1) {
result = result * base / PRECISION;
}
base = base * base / PRECISION;
exponent /= 2;
}
return result;
}
function calculateThreshold(uint256 requestedAmount) public view returns (uint256) {
uint256 vaultBalance = requestToken.balanceOf(vault);
uint256 totalSupply = token.totalSupply();
uint256 stakedTokensTotal = _getTotalStaked();
// Base threshold
uint256 baseThreshold = totalSupply * minThresholdStake / PRECISION;
if (requestedAmount == 0) return baseThreshold;
// Dynamic component
uint256 rho = maxRatio * requestedAmount / vaultBalance;
if (rho >= PRECISION) return type(uint256).max; // request exceeds max ratio
uint256 availableSupply = totalSupply - stakedTokensTotal;
// threshold grows quadratically with requestedAmount
uint256 convictionFactor = totalSupply * totalSupply / (availableSupply * availableSupply + 1);
return baseThreshold + convictionFactor * rho / PRECISION;
}
}
Conviction update function
This is the key function — calculates accumulated conviction from last update:
function _updateConviction(uint256 proposalId) internal {
Proposal storage proposal = proposals[proposalId];
uint256 blocksPassed = block.number - proposal.blockLast;
if (blocksPassed == 0) return;
// conviction(t) = conviction(t-1) * alpha^blocksPassed + stakedTokens * (1 - alpha^blocksPassed)
// Compute alpha^blocksPassed via fast exponentiation
uint256 alphaPow = _pow(alpha, blocksPassed);
proposal.convictionLast =
(proposal.convictionLast * alphaPow / PRECISION) +
(proposal.stakedTokens * (PRECISION - alphaPow) / PRECISION);
proposal.blockLast = block.number;
}
System parameters
Parameter choice is economic, not technical:
| Parameter | Low value | High value | Impact |
|---|---|---|---|
| alpha | 0.5-0.7 | 0.9-0.95 | Speed of accumulation; low = fast but easy to manipulate |
| maxRatio | 5-10% | 25-40% | Max size of single grant |
| minThreshold | 1-2% | 5-10% | Barrier for small proposals |
Standard Gardens parameters: alpha = 0.9, maxRatio = 20%, minThreshold = 2%.
Alpha is the most sensitive parameter. At alpha = 0.9 with 1 block (12 sec) on Ethereum: full conviction accumulation to 95% maximum takes ~28 days. This allows time for support to grow but makes system slow. For L2 with fast blocks — need to normalize alpha to real time, not blocks.
Development process
Design (1 week). Alpha, maxRatio, minThreshold parameters — specific to DAO and treasury size. Integration with existing token or new token. Vault architecture.
Contract development (3-4 weeks). ConvictionVoting + Vault + governance parameters (changeable via timelock) + comprehensive tests including precision edge cases.
Parameter simulation (3-5 days). Python/TypeScript simulation: how fast proposals pass at different parameters, how effective is protection against whale manipulation.
Audit (2 weeks). Special focus: overflow in conviction calculations, threshold formula correctness, reentrancy in executeProposal.
Frontend (2-3 weeks). Dashboard proposals, stake management, conviction visualization, mobile adaptation.
Full cycle: 2.5-3 months. Cost — after parameter and scope clarification.
Frontend: conviction visualization
UX is a separate challenge. User should see:
- Graph of conviction growth for each proposal (line approaching threshold)
- Current distance to threshold (% and in conviction units)
- How many days needed at current vote count
- Own stakes distributed across proposals (sum <= balance)
Conviction visualization with real-time updates — Recharts or D3.js showing current point and projected trajectory.







