Smart Contracts
Registry & Poll

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.

ContractMainnetTestnetACI
Registryct_ouZib4wT9cNwgRA1pxgA63XEUd8eQRrG8PcePDEYogBc1VYTqct_2nritSnqW6zooEL4g2SMW5pf12GUbrNyZ17osTLrap7wXiSSjfRegistryWithEventsACI.json
Pollone per poll, deployed on creationone per poll, deployed on creationPollACI.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.

EntrypointSignatureFlags
init() => Registry.state
add_poll(poll: Poll, is_listed: bool) => intstateful
delegate(delegatee: address) => unitstateful
revoke_delegation() => unitstateful
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_id

It 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)       // delegator

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

EntrypointSignatureFlags
init(metadata: Poll.metadata, vote_options: Poll.vote_option, close_height: option(int)) => Poll.state
vote(option: int) => unitstateful
revoke_vote() => unitstateful
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 args

These 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();     // retract

There 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.votes records which option each address chose. It does not record an amount.
  • Registry.delegations records 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:

NetworkGovernance backend
Mainnethttps://governance-server-mainnet.prd.service.aepps.com/
Testnethttps://governance-server-testnet.prd.service.aepps.com/

The snapshot height

PollSnapshot heightMeaning
Closed: close_height set and already reachedthat close_heighta fixed historical snapshot
Open: close_height set but still in the futurenonelive, recomputed at every read
Open-ended: close_height = Nonenonelive, 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:

RuleEffect
Skip the account the walk started froma cycle back to the voter terminates instead of looping
Skip any address already visited on this walklonger cycles terminate too
Skip any address that voted on this poll itselfa 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:

  1. 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.
  2. 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.
  3. An open-ended poll has no final result. With close_height = None there 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