Registry & Poll
These two contracts are the æternity network's own governance system, not Superhero's
community DAOs. They differ from DAO.aes and DAOVote.aes in almost every respect: polls
are multiple-choice, votes are weighted off chain by AE balance rather than by locked tokens,
and delegation exists.
Superhero surfaces them at https://superhero.com/voting.
| Contract | Mainnet | Testnet | ACI |
|---|---|---|---|
| Registry | ct_ouZib4wT9cNwgRA1pxgA63XEUd8eQRrG8PcePDEYogBc1VYTq | ct_2nritSnqW6zooEL4g2SMW5pf12GUbrNyZ17osTLrap7wXiSSjf | RegistryWithEventsACI.json |
| Poll | one per poll, deployed on creation | one per poll, deployed on creation | PollACI.json, PollBytecode.json |
version() returns 1 on the original build of each contract and 2 on the compiler-6
build; the logic is identical.
Registry
One deployed instance per network. It indexes polls and records delegations.
| Entrypoint | Signature | Flags |
|---|---|---|
init | () => Registry.state | |
add_poll | (poll: Poll, is_listed: bool) => int | stateful |
delegate | (delegatee: address) => unit | stateful |
revoke_delegation | () => unit | stateful |
polls | () => Registry.polls | |
poll | (poll_id: int) => Registry.poll_data | |
delegations | () => Registry.delegations | |
delegatee | (account: address) => option(address) | |
delegators | (account: address) => Registry.delegations | |
created_height | () => int | |
version | () => int |
Registry types
record poll_data =
{ poll : Poll // the poll contract address
, is_listed : bool // whether it appears in the default listing
, title : string // cached from the poll
, close_height : option(int) } // cached from the poll
type polls = map(int, poll_data)
type delegations = map(address, address) // delegator -> delegatee
record state =
{ polls : polls
, seq_id : int
, delegations : delegations
, created_height : int }poll_data caches the title and close height so a listing UI can render without calling into
every poll contract. add_poll returns the new poll_id, which is seq_id at the time of
registration.
is_listed is a listing flag, not access control. An unlisted poll is still on chain, still
readable, and still votable. It just does not appear in the default listing.
Registering a poll
add_poll(poll, is_listed) registers a poll contract and returns its id:
stateful entrypoint add_poll(poll : Poll, is_listed : bool) : int =
let poll_seq_id = state.seq_id
let poll_data = { poll = poll, is_listed = is_listed,
title = poll.title(), close_height = poll.close_height() }
put(state{ polls[state.seq_id] = poll_data, seq_id = state.seq_id + 1 })
Chain.event(AddPoll(poll, poll_seq_id))
poll_seq_idIt reads poll.title() and poll.close_height() to cache them, so the target must expose
both entrypoints with compatible types. is_listed is chosen at registration and fixed from
then on: the registry's only stateful calls are add_poll, delegate and
revoke_delegation.
The governance backend computes stake-weighted results only for polls deployed from the
standard Poll bytecode. Deploy from PollBytecode.json unchanged, and do not treat presence
in polls() alone as evidence that a poll is a standard one.
Registry events
datatype event =
AddPoll(Poll, int) // poll contract, poll id
| Delegation(address, address) // delegator, delegatee
| RevokeDelegation(address) // delegatorThe delegation events are more than a convenience: the governance backend reconstructs the
delegation map by replaying Delegation and RevokeDelegation events up to a target
height, rather than reading delegations(). See
Delegation and stake weighting.
Poll
A fresh Poll contract is deployed for every poll, then registered. Its address is the
poll's identity; the registry's integer id is a convenience.
| Entrypoint | Signature | Flags |
|---|---|---|
init | (metadata: Poll.metadata, vote_options: Poll.vote_option, close_height: option(int)) => Poll.state | |
vote | (option: int) => unit | stateful |
revoke_vote | () => unit | stateful |
title | () => string | |
metadata | () => Poll.metadata | |
close_height | () => option(int) | |
votes | () => Poll.votes | |
has_voted | (voter: address) => bool | |
voted_option | (voter: address) => option(int) | |
is_author | (author: address) => bool | |
is_closed | () => bool | |
version | () => int | |
get_state | () => Poll.state |
Poll types
record metadata =
{ title : string
, description : string
, link : string
, spec_ref : option(hash) } // optional hash of a specification document
type vote_option = map(int, string) // option index -> label
type votes = map(address, int) // voter -> chosen option index
record state =
{ metadata : metadata
, vote_options : vote_option
, close_height : option(int)
, create_height : int
, votes : votes
, author : address }vote_option is the whole option set, not one option. It is map(int, string), the
index-to-label table for the entire poll. The vote(option: int) entrypoint takes an index
into it. (In DAOVote, vote_option is a single bool.)
close_height is option(int). None means the poll never closes. Pass undefined from
JavaScript for a poll with no close height.
spec_ref is an option(hash) for pinning a specification document to the poll. Pass
undefined when you have none.
Poll events
datatype event =
Vote(address, address, int) // three args
| RevokeVote(address, address) // two argsThese collide by name with DAOVote's Vote and RevokeVote, have different argument
counts, and, because the hash is computed from the name, the same middleware topic hash. An indexer
covering both governance polls and community DAOs must filter by contract address before
decoding. See Events.
Creating a poll
Poll creation is two transactions: deploy the poll from bytecode, then register it. You need
PollBytecode.json as well as the ACI.
import { AeSdk, Node, MemoryAccount, Contract } from '@aeternity/aepp-sdk';
import fs from 'fs';
const pollAci = JSON.parse(fs.readFileSync('./contracts/PollACI.json', 'utf8'));
const { bytecode } = JSON.parse(fs.readFileSync('./contracts/PollBytecode.json', 'utf8'));
// 1. Deploy the poll.
const poll = await Contract.initialize({
aci: pollAci,
bytecode,
onAccount: aeSdk,
onNode: aeSdk.api,
});
await poll.init(
{
title: 'Should we do the thing?',
description: 'A description of the thing, at most 300 characters.',
link: 'https://forum.example.com/thread/1',
spec_ref: undefined, // None
},
new Map([[0, 'Yes'], [1, 'No'], [2, 'Abstain']]),
undefined, // None = never closes
);
const pollAddress = poll.$options.address;
// 2. Register it so it shows up in the listing.
const registry = await Contract.initialize({
aci: JSON.parse(fs.readFileSync('./contracts/RegistryWithEventsACI.json', 'utf8')),
address: 'ct_ouZib4wT9cNwgRA1pxgA63XEUd8eQRrG8PcePDEYogBc1VYTq',
onAccount: aeSdk,
onNode: aeSdk.api,
});
const pollId = await registry.add_poll(pollAddress, true).then((r) => r.decodedResult);vote_options must be a Map, keyed by integer. Options are indexed from zero and the index
is what vote() takes.
Poll.init enforces two limits on chain:
require(String.length(metadata.title) =< 50, "TITLE_STRING_TO_LONG")
require(String.length(metadata.description) =< 300, "DESCRIPTION_STRING_TO_LONG")(The abort strings read TO_LONG, not TOO_LONG; match on that spelling.) Nothing on chain
checks that link is a URL or that there are two or more options; a poll deployed with an
empty vote_options map is legal but unvotable, because vote requires the index to
be a member of it.
A non-None close height must be in the future. Check it against the current top block
height before deploying, because a poll deployed already closed never accepts a vote.
Voting
await poll.vote(1); // vote for option index 1
await poll.revoke_vote(); // retractThere is no stake, no allowance and no lock. A vote is a single map entry keyed by your
address, and revoke_vote deletes it. Voting on a poll costs only gas.
vote(option) requires that the poll is open (POLL_ALREADY_CLOSED) and that the option
exists (VOTE_OPTION_NOT_KNOWN). It overwrites any earlier vote by the same address, so
changing your mind before close is one call, not a revoke-then-vote pair. is_closed() is
Chain.block_height >= close_height for Some(h) and false for None: a poll with no
close height accepts votes indefinitely.
revoke_vote() deletes the caller's entry from votes and emits RevokeVote.
A closed poll's result is the state at its close height. The governance backend tallies at that height, from the event log and historical balances, as described below.
Delegation and stake weighting
The registry records delegation as a plain map(address, address). delegate(delegatee)
records you as delegating to that address; revoke_delegation() removes the entry.
delegatee(account) reads one direction, delegators(account) the other. The one on-chain
constraint is require(Call.caller != delegatee, "CALLER_IS_DELEGATEE_DISALLOWED"): you may
not delegate to yourself.
The contracts do not compute weighted results.
Poll.votesrecords which option each address chose. It does not record an amount.Registry.delegationsrecords who delegated to whom. It does not record an amount either.- Neither contract reads any account's AE balance.
The weighted outcome is computed off chain by the governance backend:
| Network | Governance backend |
|---|---|
| Mainnet | https://governance-server-mainnet.prd.service.aepps.com/ |
| Testnet | https://governance-server-testnet.prd.service.aepps.com/ |
The snapshot height
| Poll | Snapshot height | Meaning |
|---|---|---|
Closed: close_height set and already reached | that close_height | a fixed historical snapshot |
Open: close_height set but still in the future | none | live, recomputed at every read |
Open-ended: close_height = None | none | live, forever |
A closed poll's tally is frozen at its close height: every balance is read at that height, and the delegation map is reconstructed at that same height. A poll that is still open, or that can never close, is tallied against current balances and the current delegation map, so the displayed result moves whenever anyone's balance does.
Two edge cases:
- An account that did not exist at the snapshot height contributes zero.
- If the node cannot serve the requested height, the backend falls back to the current balance for that account.
Delegation resolution
For a closed poll, delegations are not read from delegations(). The backend pulls the
registry's Delegation and RevokeDelegation events, keeps those at or below the snapshot
height, sorts by height then nonce, and replays them into a map (set on Delegation, delete
on RevokeDelegation). That is what makes a historical snapshot possible, since the contract
only stores the current map.
Weighting then walks the tree upward from each voter (who delegated to them) and sums
those accounts' balances recursively, so a chain A -> B -> C gives C both A's and B's stake.
Three rules govern the walk:
| Rule | Effect |
|---|---|
| Skip the account the walk started from | a cycle back to the voter terminates instead of looping |
| Skip any address already visited on this walk | longer cycles terminate too |
| Skip any address that voted on this poll itself | a delegator who voted for themselves does not also count toward their delegatee |
Delegation is global and persists until revoked; voting yourself overrides it for that one poll only.
Final voting power for a voter is balance at snapshot height + sum of delegators' balances at that height, and the percentage of total supply divides the total against the AE supply at
the same height.
Three consequences for anyone integrating:
- A tally you compute yourself from
Poll.votes()is unweighted. It counts addresses, not stake, and it ignores delegation. It will not match what superhero.com shows. - The governance backend is the source of the weighted result. Everything above is reproducible from chain data; the contracts do not do it for you.
- An open-ended poll has no final result. With
close_height = Nonethere is no snapshot, votes can still arrive, and existing votes can still be revoked. Treat such a poll as a rolling signal, not a decision.
Related
- Polls: the same system for a non-developer audience
- Create a poll: a step-by-step walkthrough
- Delegation: what delegating does to your weight
- DAOVote: the community DAO voting contract, which works quite differently
- Events: why the
Votename collision matters for indexers - aeternity/aepp-governance (opens in a new tab): the contracts and the tally server