The contract described here implements the DAO functionality with proposals and voting.
An existing separate FA2 contract is required and used for governance.
-
The contract must store frozen tokens.
-
The contract must store info about the external 'governance token'. This consist of the address of an FA2 contract and a
token_idin that contract. The 'governance token' is used as part of the freeze/unfreeze process by performing FA2 transfers to/from it. -
The storage of the contract must have annotations for all fields and must be documented to make its interpretation easy for users.
BaseDAO is a framework to implement various DAO smart contracts. It can be configured during build for any specific needs.
In order to do so the repo includes build time configuration that can generate slightly different contract code and storage value.
Proposal's metadata are also represented using a serialized form so that different variants can decode and process proposals as they see fit.
For example, the proposal_metadata in a "treasury" style DAO would be the
packed version of the type treasury_dao_proposal_metadata :
type xtz_transfer =
{ amount : tez
; recipient : address
}
type token_transfer =
{ contract_address : address
; transfer_list : transfer_item list
}
type transfer_type =
| Xtz_transfer_type of xtz_transfer
| Token_transfer_type of token_transfer
| Legacy_token_transfer_type of legacy_token_transfer
type treasury_dao_proposal_metadata =
{ agora_post_id : nat
; transfers : transfer_type list
}
type legacy_transfer =
[@layout:comb]
{ from : address
; target : legacy_transfer_target
}
type legacy_token_transfer =
[@layout:comb]
{ contract_address : address
; transfer : legacy_transfer
}
One can use the 'template/callback.mligoandtemplate/storage.mligo` modules
to derive a new variant by copying it and filling in the placeholders defining
custom logic and types.
DAO configuration value parameters are captured by the config type:
type config =
; max_quorum_threshold : quorum_fraction
// ^ Determine the maximum value of quorum threshold that is allowed.
; min_quorum_threshold : quorum_fraction
// ^ Determine the minimum value of quorum threshold that is allowed.
; period : period
// ^ Determines the stages length in number of blocks.
; fixed_proposal_fee_in_token : nat
// ^ A base fee paid for submitting a new proposal.
; max_quorum_change : quorum_fraction
// ^ A percentage value that limits the quorum_threshold change during
// every update of the same.
; quorum_change : quorum_fraction
// ^ A percentage value that is used in the computation of new quorum
// threshold value.
; governance_total_supply : nat
// ^ The total supply of governance tokens used in the computation of
// of new quorum threshold value at each stage.
; proposal_flush_level : blocks
// ^ The proposal age at (and above) which the proposal is considered flushable.
// Has to be bigger than `period * 2`
; proposal_expired_level : blocks
// ^ The proposal age at (and above) which the proposal is considered expired.
// Has to be bigger than `proposal_flush_level`
}Note:
- see the ligo source for more info about the types involved.
storageis the storage type of the contract.storagecan vary between variants owning to the difference in thecontract extrafield.
type proposal_key = bytes
type proposal =
{ upvotes : nat
// ^ total amount of votes in favor
; downvotes : nat
// ^ total amount of votes against
; start_level : blocks
// ^ block level of submission, used to order proposals
; voting_stage_num : nat
// ^ stage number in which it is possible to vote on this proposal
; metadata : proposal_metadata
// ^ instantiation-specific additional data
; proposer : address
// ^ address of the proposer
; proposer_frozen_token : nat
// ^ amount of frozen tokens used by the proposer, exluding the fixed fee
; quorum_threshold: quorum_threshold
// ^ quorum threshold at the cycle in which proposal was raised
}Part of the configuration is specified inside the storage and is fixed during
the contract lifetime after being set at origination.
These values are:
admin : addressis the address that can perform administrative actions.guardian : addressis the address of a contract that has permission to calldrop_proposalon any proposals. Note: theguardiancontract cannot initiate the transaction that results in a call todrop_proposal.governance_tokenis the FA2 contract address/token_id pair that will be used as the governance token.period : blocksspecifies how long the stages lasts in blocks.proposal_flush_level : blocks- Specifies, in blocks, how long it takes before a proposal can be flushed, from when it was proposed.
- IMPORTANT: Must be bigger than
period * 2.
proposal_expired_level : blocks- Specifies, in blocks, how long it takes for a proposal to be considered expired, from when it was proposed.
- IMPORTANT: Must be bigger than
proposal_flush_level.
quorum_threshold : quorum_thresholdspecifies what fraction of the frozen tokens total supply are required in total to vote for a successful proposal.fixed_proposal_fee_in_token : natspecifies the fee to be paid for submitting a proposal (in frozen tokens), if any.
This chapter provides a high-level overview of the contract's logic.
- The contract maintains a map of addresses and their frozen token balance.
- The contract maintains the address of an a FA2 contract and a
token_id, to use in the governance process. - The contract manages three special roles, the
admin,guardian, anddelegate. - The contract stores a list of proposals that can be in one of the states: "proposed", "ongoing", "pending flush", "accepted", "rejected", or "expired".
- The contract forbids transferring XTZ to it on certain entrypoints.
- The contract tracks 'stages' by counting the blocks.
The token supports two "global" user role: admin and guardian.
These roles apply to the whole contract (hence "global"):
- admin
- Can re-assign this role to a new address or to the DAO itself.
- Can perform administrative operations.
- There always must be exactly one
admin.
- guardian
- Can drop any proposal at any time.
- Cannot be an implicit address, in other words it must be a contract.
- There always must be exactly one
guardian. - Can be updated via a proposal.
Additionally, the contract also contains the delegate role:
- This role is "local" to a particular address.
- Each address can have any number of delegates and be a delegate of any number of addresses.
- This role can call
proposeandvoteon behalf of the owner.
The contract constantly cycles between two stages, a proposing stage and a voting stage.
Both have the same same length, called period, and alternate between each other,
starting from "voting" for stage number 0.
A proposing and voting couple of stages is called a cycle.
The period is specified for the whole smart contract and never changes.
The length of a period is measured by counting blocks as discrete entities. So
if the configuration value of period is 3, then the very first period only
exist for blocks 0, 1 and 2.
Similarly a proposal raised in block 100, with an expiry of 3 blocks will
remain unexpired for blocks 100, 101, 102, and will be considered expired
on the 103 th block, because at that block, the proposal will be considered to
have an age of 3.
Tokens can be frozen in any stage, but they can only be used for voting, proposing
and unfreezing starting from the one following and onwards.
For this reason the contract starts from a voting stage, because even tho there
are no proposals to vote on yet, this allows token to be frozen in it and be
usable in the first proposing stage, number 1.
To freeze, the address should have the corresponding amount of tokens of the
proper token_id in the governance FA2 contract.
During the freeze operation the DAO contract performs an FA2 transfer call
on the governance contract to transfer tokens to its own address from
the address who is freezing the tokens and then mints frozen tokens for the address.
Unfreezing does the opposite, that is, the contract makes the FA2 transfer call
on the governance contract to transfer tokens from its own address to the address
that is doing the unfreezing and burns the corresponding amount of frozen tokens.
Only frozen tokens that are not currently staked in a vote or proposal can be unfrozen.
The quorum threshold is updated at every cycle change, based on the previous
cycle participation using the formula:
previous participation = number_of_staked_tokens_last_cycle / config.governance_total_supply.
possible_new_quorum =
old_quorum * (1 - config.quorum_change) + participation * quorum_change
min_new_quorum =
old_quorum / (1 + config.max_quorum_change)
max_new_quorum =
old_quorum * (1 + config.max_quorum_change)
new_quorum =
max min_new_quorum
(min max_new_quorum
possible_new_quorum)
This will use the configuration values provided at origination, and the new quorum will be still bound by the max/min quorum threshold values provided there.
Everyone can make a new proposal, however, one has to freeze some tokens for that.
The proposer specifies how many frozen tokens they want to stake and this value
is checked by the contract according to its configuration.
Proposing can only be performed in a proposing stage, meaning one that's
odd-numbered and the proposer must have frozen his tokens in one of the preceding
stages.
Proposals are identified by a key which is a bytes value computed via the Blake2B
hashing function of a pair of propose entrypoint params and the proposer address.
Once a proposal is submitted, everyone can vote on it as long as they have enough frozen tokens to stake. One frozen token is required for each vote.
A vote can only be cast in a voting stage, meaning one that's even-numbered.
Moreover the proposal to vote on must have been submitted in the proposing stage
immediately preceding and the voter must have frozen his tokens in one of the
preceding stages.
Each vote stakes one frozen token. Staked tokens cannot be unfreezed till the
associated proposal is flushed or dropped. The tokens are unstaked by calling unstake_vote.
The number of staked tokens only depend on the number of votes, and does not depend
on whether the vote is in favor or against a proposal.
It's possible to vote positively or negatively. After the voting ends, the contract is "flushed" by calling a dedicated entrypoint.
See the Error Codes file for the list of error codes.
Full list:
defaulttransfer_contract_tokenstransfer_ownershipaccept_ownershipupdate_delegatesproposevoteflushdrop_proposalfreezeunfreezeunstake_vote
Format:
**entrypoint_name**
<optional CameLIGO definition of the argument type>
Parameter (in Michelson): X
<description>
- Top-level contract parameter type MUST have all entrypoints listed below.
- Each entrypoint MUST be callable using the standard entrypoints machinery of Michelson by specifying entrypoint_name and a value of the type
X(its argument). - The previous bullet point implies that each
Xmust have a field annotations with the corresponding entrypoint name. In the definitions below it may be omitted, but it is still implied.
Note: CameLIGO definitions are provided only for readability. If Michelson type contradicts what's written in CameLIGO definition, the Michelson definition takes precedence.
default of unitParameter (in Michelson):
(unit %default)
- This is the default entrypoint of the contract. This is provided as a way to transfer XTZ funds to the contract easily.
Functions related to token transfers.
type token_id = nat
type transfer_destination =
[@layout:comb]
{ to_ : address
; token_id : token_id
; amount : nat
}
type transfer_item =
[@layout:comb]
{ from : address
; txs : transfer_destination list
}
type transfer_params = transfer_item list
type transfer_contract_tokens_param =
{ contract_address : address
; params : transfer_params
}
Transfer_contract_tokens of transfer_contract_tokens_paramParameter (in Michelson):
(pair %transfer_contract_tokens
(address %contract_address)
(list %params
(pair (address %from)
(list %txs
(pair
(address %to_)
(pair
(nat %token_id)
(nat %amount)
)
)
)
)
)
)
- This entrypoint can be used by the administrator to transfer tokens owned (or operated) by this contract in another FA2 contract.
- Fails with
NOT_ADMINif the sender is not the administrator. - If the outermost address passed to this entrypoint is a smart contract with FA2
transferentrypoint, this entrypoint is called with supplied argument. That is, the list of operations returned from the baseDAO contract should contain oneTRANSFER_TOKENSoperation calling thetransferentrypoint. Otherwise the call fails.
type transfer_ownership_param = address
Transfer_ownership of transfer_ownership_paramParameter (in Michelson):
(address %transfer_ownership)
-
Initiate transfer of the role of administrator to a new address.
-
Fails with
NOT_ADMINif the sender is not the administrator. -
The current administrator retains his privileges up until
accept_ownershipis called, unless the proposed administrator is the DAO itself. In such case, the role is given to the DAO right away. -
Can be called multiple times, each call replaces pending administrator with the new one. Note, that if the proposed administrator is the same as the current one, then the pending administrator is simply invalidated.
Accept_ownership of unitParameter (in Michelson):
(unit %accept_ownership)
-
Accept the administrator privilege.
-
Fails with
NOT_PENDING_ADMINif the sender is not the current pending administrator, this also includes the case when pending administrator was not set. -
When pending administrator is not set, it is considered equal to the current owner, thus administrator can accept ownership of its own contract without a prior
transfer_ownershipcall.
type update_delegate =
[@layout:comb]
{ enable : bool
; delegate : address
}
type update_delegate_params = update_delegate list
Update_delegate of update_delegate_paramsParameter (in Michelson)
(list %update_delegate
(pair (bool %enable) (address %delegate)
)
)
- Add/Update or remove delegates of owners. The owner address is taken from
SENDER.
type proposal_metadata = bytes
type propose_params =
{ from : address
; frozen_token : nat
; proposal_metadata : proposal_metadata
}
Propose of propose_paramsParameter (in Michelson):
(pair %propose
(address %from)
(pair (nat %frozen_token) (bytes %proposal_metadata)))
- The
proposeraddress is taken fromfrom. - Fails with
NOT_DELEGATEif theSENDERaddress is not equal toproposeraddress or thedelegateaddress of the proposer. - The proposal is saved under
BLAKE2bhash of proposal value and proposer. - The
Naturalvalue:proposalTokenAmountdetermines how many proposer's frozen tokens will be staked in addition to the fee - Proposer MUST have enough frozen tokens (i. e.
≥ proposalTokenAmount + fee) that are not already staked for a proposal or a vote. - Fails with
NOT_ENOUGH_FROZEN_TOKENSif the unstaked frozen token balance of the proposer is less thanproposalTokenAmount + fee. - Fails with
NOT_PROPOSING_STAGEif the current stage is not a proposing one. - Fails with
FAIL_PROPOSAL_CHECKif the proposal is rejected byproposal_checkfrom the configuration. - Fails with
PROPOSAL_NOT_UNIQUEif exactly the same proposal from the same author has been proposed.
type proposal_key = bytes
type vote_type = bool
type permit =
{ key : key
; signature : signature
}
type vote_param =
[@layout:comb]
{ proposal_key : proposal_key
; vote_type : vote_type
; vote_amount : nat
; from : address
}
type vote_param_permited =
{ argument : vote_param
; permit : permit option
}
Vote of vote_param_permited listParameter (in Michelson):
(list %vote
(pair (pair %argument
(pair (address %from) (bytes %proposal_key))
(pair (nat %vote_amount) (bool %vote_type)))
(option %permit (pair (key %key) (signature %signature)))))
- This implements permits mechanism similar to the one in TZIP-017 but injected directly to the entrypoint.
- The
authoris identified by permit information, or if it is absent -SENDERis taken. - The
voteraddress is taken fromfrom. - Fails with
NOT_DELEGATEif theauthoraddress is not equal tovoteraddress or thedelegateaddress of thevoter. - The voter MUST have frozen tokens equal to
vote_amountor more (1 unstaked frozen token is needed for 1 vote) from paststages. - Fails with
NOT_ENOUGH_FROZEN_TOKENSif the frozen token balance of the voter from past stages that is not staked is less than specifiedvote_amount. - Fails with
PROPOSAL_NOT_EXISTif the proposal key is not associated with any ongoing proposals. - Fails with
VOTING_STAGE_OVERif the votingstagefor the proposal has already ended. - Fails with
MISSIGNEDif permit is incorrect with respect to the provided vote parameter and contract state. - The entrypoint accepts a list of vote params. As a result, it is possible to
voteon multiple proposals (or the same proposal multiple time) in one entrypoint call.
Flush of natParameter (in Michelson):
(nat %flush)
- Finish voting process on an amount of proposals for which their
proposal_flush_levelwas reached, but theirproposal_expire_levelwasn't yet. - The order of processing proposals are from 'the oldest' to 'the newest'. The proposals which have the same level due to being in the same block, are processed in the order of their proposal keys.
- Staked tokens from the proposer and the voters and participated on those proposals
are returned in the form of frozen tokens:
- If the proposal got rejected, because the quorum was not met or because
the upvotes are less then downvotes:
- The return amount for the proposer is equal to its staked tokens minus the
slash value calculated by
rejected_proposal_slash_valueand the fixed fee. - The return amount for each voters is equal to the voter's staked tokens.
- The return amount for the proposer is equal to its staked tokens minus the
slash value calculated by
- If the proposal got accepted:
- The return amount for the proposer is equal to the sum of the proposer staked tokens and the fixed fee paid for the proposal.
- The return amount for each voters is equal the voter's staked tokens.
- The token return to voters are not immediate.
The voters should call
unstake_votewith the proposal key to get their tokens back afterflushis called.
- If the proposal got rejected, because the quorum was not met or because
the upvotes are less then downvotes:
- If proposal is accepted, the decision callback is called.
- The
quorum_thresholdat the cycle in which the proposal was raised will be stored in the proposal, and this threshold will be used to check if the votes meet the quorum threshold. So any dynamic update to the quorum threshold shall not affect the proposal. - If no proposals can be flushed when called, fails with
EMPTY_FLUSH.
type proposal_key = bytes
Drop_proposal of proposal_keyParameter (in Michelson):
(bytes %drop_proposal)
- Delete a proposal when either:
- The
proposal_expired_levelhas been reached. - The proposer is the
SENDER, regardless of theproposal_expired_level. - The
guardianis theSENDER, regardless of theproposal_expired_level.
- The
- Fails with
DROP_PROPOSAL_CONDITION_NOT_METwhen none of the conditions above are met. - Tokens that were frozen for this proposal are returned to the proposer and voters
as if the proposal was rejected, regardless of the actual votes.
See
flushfor details.
type freeze_param = nat
Freeze of freeze_paramParameter (in Michelson):
(nat %freeze)
-
Mints the required number of frozen tokens after making an FA2 transfer on the governance token contract from the address of the sender to the address of the BaseDAO contract. The transfer is made using the governance token id. The frozen tokens can only be used from the next
stageonward. -
Author MUST have tokens equal to
freeze_paramor more, in the governance contract, with token idgovernance-token.token_id.
type unfreeze_param = nat
Unfreeze of unfreeze_paramParameter (in Michelson):
(nat %unfreeze)
- Burns the specified amount of tokens from the tokens frozen in previous
stages, after making an FA2 transfer on the governance contract from the address of the baseDAO contract to the address of the sender. - Fails with
NOT_ENOUGH_FROZEN_TOKENSif the author does not have enough tokens that can be burned.
type proposal_key = bytes
type unstake_vote_param = [proposal_key]
Unstake_vote of unstake_vote_paramParameter (in Michelson):
(list %unstake_vote bytes)
- Unstake voter's tokens for proposals that are already flushed or dropped.
- Fails with
unstake_invalid_proposalerror code if one of the proposals are not yet flushed or dropped. - Fails with
voter_does_not_existerror code if the sender did not vote on the proposal or the sender already called this entrypoint before.
BaseDAO allows DAOs to define their own additional entrypoints.
This is done by defining the type to represent the custom entrypoints, and
implementing procedure to handle custom entrypoints using the template.mligo module.
BaseDAO allows DAOs to defined their own contract extra type, and use them
in the implementation.
This is done by defining the extra field type in the variant's storage.mligo
file, by following the format presented in the template/storage.mligo file.
This contract implements TZIP-016.
The DAO contract itself won't store the metadata, rather a file on IPFS (suggested), a dedicated contract or another external storage will contain that.
The deployment of contract with metadata has an extra step, either:
- Uploading the contract metadata to IPFS.
- A dedicated contract for carrying metadata has to be originated first.
Then the baseDAO contract should include the reference to a metadata key in the contract in order to be compliant with TZIP-016.