Voting
Voting in a community DAO is a token transfer, not a signature on a message. The tokens you vote with are moved into the vote contract and held there until you take them back, and before you can vote you have to grant the vote contract an AEX-9 allowance.
The vote call
stateful entrypoint vote(option: DAOVote.vote_option, amount: DAOVote.amount) : unitwhere
DAOVote.vote_option = bool -- true = in favour, false = against
DAOVote.amount = int -- tokens staked, in the token's smallest unitThe ballot is binary. There is no abstain option and no multi-choice ballot. For a multi-option poll, use an æternity governance poll, which is a different system.
Voting power is linear in tokens staked. The contract keeps one running total per side:
DAOVote.vote_state = map(DAOVote.vote_option, DAOVote.amount)Your amount is added to the total under true or false. Ten times the tokens is ten
times the weight. There is no quadratic discount, cap, time-weighting or minimum.
The per-voter record is:
DAOVote.vote_accounts = map(address, (DAOVote.amount, DAOVote.vote_option, bool))Each entry holds the amount you staked, which way you voted, and whether you have withdrawn.
One vote per address
vote rejects a second call outright:
stateful entrypoint vote(option : vote_option, amount : amount) =
require(!is_closed(), "VOTE_ALREADY_CLOSED")
require(!Map.member(Call.caller, state.vote_accounts), "VOTER_ALREADY_VOTED")
state.token.transfer_allowance(Call.caller, Contract.address, amount)
...VOTER_ALREADY_VOTED fires whether you are trying to add stake, reduce it, or change sides.
To change anything about your vote, call revoke_vote() first, then vote again. Revoking
returns your tokens, removes your stake from the tally, and clears your entry. Both steps are
transactions, and your weight is out of the tally in between.
This is the opposite of an æternity governance poll, where calling
vote again overwrites your choice.
amount is not checked against your balance directly; the transfer_allowance call fails if
you stake more than you hold or more than you have approved.
The allowance step
The vote contract has to pull your tokens, and an AEX-9 contract only lets a third party move tokens it has been given an allowance for. Voting is therefore two transactions:
create_allowance(for_account, value)on the community token contract, naming the vote contract as the spender.vote(option, amount)on the vote contract.
AEX-9 allowances are granted to an ak_ address, but the vote contract has a ct_
address. Rewrite the prefix and leave the rest of the string untouched. This is the standard
æternity convention for allowing a contract to spend.
vote contract ct_2a1b3c...xyz
for_account ak_2a1b3c...xyz <- same bytes, ak_ prefixThe relevant AEX-9 entrypoints:
| Entrypoint | Purpose |
|---|---|
allowance(allowance_accounts) | Read the current allowance; returns option(int) |
create_allowance(for_account, value) | Create an allowance where none exists |
change_allowance(for_account, value_change) | Adjust an existing allowance by a delta |
reset_allowance(for_account) | Remove the allowance entirely |
allowance_for_caller(from_account) | Read the allowance granted to you by another account |
allowance takes a record (allowance_accounts is from_account plus for_account) and
returns None when no allowance exists. Calling create_allowance when one already exists
fails; calling change_allowance when none exists fails too, so handle the two cases
separately.
Code
With an initialised aepp-sdk instance and the contract ACIs:
// 1. Point at the community token and the vote contract.
const token = await Contract.initialize({
...sdk.getContext(), aci: FUNGIBLE_TOKEN_FULL_ACI, address: tokenAddress,
});
const vote = await Contract.initialize({
...sdk.getContext(), aci: DAO_VOTE_ACI, address: voteAddress,
});
// 2. AEX-9 allowances are granted to an ak_ address. Rewrite the vote
// contract's ct_ prefix; the rest of the address is identical.
const spender = voteAddress.replace('ct_', 'ak_');
// 3. Create the allowance, or top up an existing one.
const amount = 1_000_000_000_000_000_000n; // 1 token at 18 decimals
const current = (await token.allowance(
{ from_account: myAddress, for_account: spender },
)).decodedResult;
if (current == null) {
await token.create_allowance(spender, amount);
} else if (current < amount) {
await token.change_allowance(spender, amount - current);
}
// 4. Vote in favour with that stake.
await vote.vote(true, amount);Both transactions have to confirm. If the allowance transaction succeeds and the vote
transaction fails, the allowance is left standing. That is harmless, but reset it with
reset_allowance if you would rather not leave a live approval in place.
From the app
Open the proposal at /trends/dao/:saleAddress/vote/:voteId/:voteAddress, connect a wallet
holding the community token, and choose Vote Yes or Vote No. Your wallet asks you to
sign the allowance and then the vote.
The app votes with your entire token balance. It passes your full balance as the
amount; there is no partial-stake option in the UI. To vote with part of your holdings, call
vote(option, amount) yourself with the amount you intend.
Locking
Once vote succeeds, the tokens are held by the vote contract. Until you revoke or withdraw
them you cannot transfer them, sell them back to the bonding curve, or use them in another
vote. This prevents one balance being voted repeatedly on the same proposal from different
addresses, or sold immediately after voting while the vote still counts. The lock has no timer
of its own; it ends when you end it.
Revoke versus withdraw
revoke_vote() | withdraw() | |
|---|---|---|
| When | While the vote is still open | After the closing height |
| Enforced by | require(!is_closed(), "VOTE_ALREADY_CLOSED") | require(is_closed(), "VOTE_NOT_CLOSED") |
| Effect on the tally | Removes your stake from the total | None; the result is already fixed |
| Effect on your record | Your entry is deleted; you count as not having voted | Your vote stands, marked as withdrawn |
| Can you vote again | Yes, while the vote is open | No, the vote is closed |
| Tokens | Returned | Returned |
| Repeatable | Yes: revoke, vote, revoke again | No: AMOUNT_ALREADY_WITHDRAWN on the second call |
| Event emitted | RevokeVote(address, option, amount) | Withdraw(address, amount) |
Both windows are enforced on chain. is_closed() is Chain.block_height >= state.close_height,
and the two entrypoints assert opposite sides of it, so exactly one of them works at any
height. A revoke transaction signed just before close_height and mined just after it
reverts, and you must call withdraw() instead. Do not leave a revoke to the last few blocks.
Revoking is a governance action: it pulls your weight out of the tally, so a proposal that
was passing can stop passing. Withdrawing is housekeeping: the outcome is settled and
final_vote_state() returns it, and taking your tokens back cannot change it. Withdrawal does
not depend on how the vote went or whether the subject was ever applied, and nothing does it
for you. Tokens left in a closed vote contract stay locked until their owner calls
withdraw.
Reading a vote
| Entrypoint | Returns |
|---|---|
get_state() | The whole DAOVote.state record |
metadata() | Subject, description and link |
subject() | Just the subject variant |
current_vote_state() | The live tally, for and against |
final_vote_state() | The tally once closed; None while open |
vote_accounts() | Every voter, with stake, side and withdrawn flag |
has_voted(voter) | Whether that address currently has a vote standing |
voted_option(voter) | Which way, as option(bool) |
has_withdrawn(voter) | Withdrawal status, as option(bool) |
is_closed() | Whether the closing height has passed |
close_height() | The key-block height at which it closes |
token() | The AEX-9 token used for voting |
version() | Contract version |
final_vote_state() returning None is the reliable open/closed test for an outcome.
Events
| Event | Fields |
|---|---|
Vote | voter address, option, amount |
RevokeVote | voter address, option, amount |
Withdraw | voter address, amount |
Indexing these three gives you the complete voting history of a proposal without replaying contract state. See Contract events.
Before you vote
- Your tokens are illiquid while locked. A two-week vote is two weeks you cannot sell into the bonding curve; revoking is the only early exit.
- Voting power is plain token weight. A single holder above 55% of participating stake decides any vote they show up for. The 10% quorum limits capture by tiny minorities only.
- The allowance persists until you reset it. It is scoped to one vote contract.
- Nothing reminds you to withdraw. Closed votes do not return tokens automatically.
Related
- Quorum and Thresholds: what your stake has to add up to for a proposal to pass.
- Proposals and Vote Subjects: what you are voting on, and how to read a subject.
- How DAOs Work: where voting sits in the lifecycle.
- Voting on governance polls: the other system, where nothing is locked.
- AEX standards: the AEX-9 allowance mechanism this depends on.
- AEX-9 specification (opens in a new tab): the fungible-token standard, including the allowance entrypoints.