Community DAOs
How DAOs Work

How DAOs Work

A community DAO turns trading activity into a treasury and gives the people holding the token a way to spend it. Each step below names the contract and entrypoint responsible, so you can follow a real proposal on-chain.

The lifecycle

  1. Trading fees accumulate in the DAO treasury: 0.5% of every buy on the community's bonding curve.
  2. Any holder at or above the community threshold opens a vote through DAO.add_vote(metadata), paying 555 AE to the DAO.
  3. The vote stays open for two weeks, measured in key-blocks.
  4. Holders cast a binary vote by locking tokens in the newly deployed DAOVote contract.
  5. It passes with at least 55% of the voted stake in favour and at least 10% of total token supply participating.
  6. A passed vote must be applied within roughly two days of closing, via DAO.apply_vote_subject(vote_seq_id). Any account can trigger it.
  7. Voters call withdraw() to reclaim their locked tokens.

Step 1: deployment

The community's contracts are deployed together by the factory:

CommunityFactory.create_community(collection_name, name, initial_buy_count,
                                  is_private, meta_info)
  -> (DAO, TokenSale, CommunityManagement)

The DAO is created as both the owner and the beneficiary of the token sale. The same call deploys CommunityManagement with an initial moderator and an initial minimum token threshold, plus the sale contract, the bonding curve and the AEX-9 token.

The DAO's state:

DAO.state = { token_sale   : option(TokenSale)
            , votes        : map(int, (bool, DAOVote))
            , vote_timeout : int
            , factory      : Factory }

votes is the registry of every vote opened on this DAO, keyed by a sequential id starting at zero. The boolean is the applied flag: apply_vote_subject requires it to be false (VOTE_ALREADY_APPLIED otherwise) and sets it to true once the subject executes. Read the map with votes(), or the whole record with get_state().

vote_timeout is set once at deployment to (60 / 3) * 24 * 2, which is 960 key-blocks.

Step 2: funding

The token sale forwards the DAO's share of each buy by calling:

stateful payable entrypoint record_sale_transaction(buyer: address, buy_price: int) : unit

The AE arrives with the call; the buyer and the price arrive as arguments. The stock DAO keeps the AE and does not store the arguments. The signature is part of the ReplaceableDAO interface, so a custom DAO can act on income as it lands. See The Treasury and Replaceable DAOs.

The other source of treasury funds is the 555 AE paid by whoever opens a vote. It is a donation to the DAO, not a refundable deposit.

Step 3: opening a proposal

stateful payable entrypoint add_vote(metadata: DAOVote.metadata) : (int, DAOVote)

The metadata carries the whole proposal:

DAOVote.metadata = { subject     : VoteSubjects.subject
                   , description : string
                   , link        : string }

subject is the machine-readable action, drawn from a closed set of seven constructors. description and link are free text for humans; nothing on-chain validates the link. add_vote deploys a fresh DAOVote contract, registers it in DAO.votes under the next sequential id, and returns both the id and the contract.

add_vote enforces two conditions:

payable stateful entrypoint add_vote(metadata : DAOVote.metadata) =
  require(Call.value == 555 * (10 ^ 18), "ADD_VOTE_DAO_DONATION_MISSMATCH")
  let token = get_token()
  let minimum_token_threshold = switch(get_community_management())
    None => abort("NO_COMMUNITY_MANAGEMENT_AVAILABLE")
    Some(community_management) => community_management.minimum_token_threshold()
  require(Option.default(0, token.balance(Call.caller)) >= minimum_token_threshold,
          "ADD_VOTE_TOKEN_HOLDER_MISSMATCH")
  • Exactly 555 AE. Call.value must equal 555 * 10^18 aettos. Attaching more fails the same way as attaching less.
  • The caller's balance is read at call time, live from the AEX-9 contract. There is no snapshot height and no holding period.
  • A missing balance counts as zero, so a community whose threshold is 0 lets any account with 555 AE open a proposal.

See Proposals and Vote Subjects for what you can put in subject.

Where the threshold comes from

The threshold is the minimum_token_threshold field on the community's CommunityManagement contract:

CommunityManagement.minimum_token_threshold() : int

Reach that contract from the DAO with DAO.get_community_management(), which returns an option(CommunityManagement). The value is in the token's smallest unit, so divide by 10^decimals (read decimals from the token's meta_info()) before comparing it to a displayed balance. A ChangeMinimumTokenThreshold vote changes it.

Step 4: voting

Each DAOVote contract holds its own ballot:

DAOVote.state = { metadata      : DAOVote.metadata
                , close_height  : int
                , create_height : int
                , vote_state    : map(bool, int)
                , vote_accounts : map(address, (int, bool, bool))
                , token         : FungibleTokenFull
                , author        : address }

vote_state is the running tally: total stake in favour under true, total stake against under false. vote_accounts records, per voter, the amount staked, which way they voted, and whether they have withdrawn.

Voting requires an AEX-9 allowance and locks your tokens. See Voting.

Step 5: the outcome

When Chain.block_height reaches close_height, is_closed() returns true and final_vote_state() stops returning None. apply_vote_subject then checks:

ConditionTestAbort message
Any turnouttotal_vote_stake > 0NO_VOTE_STAKE
Any supplytoken_total_supply > 0NO_TOKEN_SUPPLY
Approval(vote_state[true] * 100) / total_vote_stake >= 55GREATER_55_PERCENT_AGREEMENT_REQUIRED
Participation(total_vote_stake * 100) / token_total_supply >= 10GREATER_10_PERCENT_STAKE_REQUIRED

Both thresholds are needed. A proposal with 100% approval and 4% turnout does not pass.

  • total_vote_stake is yes plus no, so revoked votes reduce turnout as well as approval.
  • token_total_supply is read at execution time, not at the close. A large buy between closing and execution mints new supply and can push a passed vote below the 10% quorum. This is why the execution window is short.

Step 6: execution

stateful entrypoint apply_vote_subject(vote_seq_id: int) : unit

Execution is a separate transaction from the vote, and it is permissionless: any account or script can call apply_vote_subject with the vote's sequential id. The DAO checks the result itself before acting.

The window is vote.close_height() + state.vote_timeout >= Chain.block_height, otherwise VOTE_RESULT_TIMEOUTED. vote_timeout is 960 key-blocks (about two days) and is readable with vote_timeout().

A passed vote that is not applied in time does nothing. The treasury is not paid out, the moderator is not appointed, and the 555 AE is not returned. Plan for somebody to call apply_vote_subject promptly. The vote's page in the app shows an Apply action once the vote is applicable.

Step 7: withdrawal

After the closing height, every voter calls withdraw() on the vote contract to unlock their tokens. Withdrawing does not change the recorded outcome. Tokens stay locked until their owner withdraws; nothing sweeps them automatically.

Following a proposal in the app

PageRoute
Every community DAO, sortable by treasury/trends/daos
One community's treasury and its proposals/trends/dao/:saleAddress
A single proposal, with voters and actions/trends/dao/:saleAddress/vote/:voteId/:voteAddress

The proposal route carries both the sequential voteId and the DAOVote contract address: the DAO needs the id to apply the subject, and the vote contract address is where the ballot is read.

Related