ADR 0006: Consensus Governance
Component
Oasis Core
Changelog
- 2021-03-30: Update name of the CastVote method's body
- 2021-01-06: Update API to include Proposals() method
- 2020-12-08: Updates to match the actual implementation
- 2020-10-27: Voting period in epochs, min upgrade cancellation difference, failed proposal state
- 2020-10-16: Initial draft
Status
Accepted
Context
Currently the consensus layer does not contain any on-chain governance mechanism so any network upgrades need to be carefully coordinated off-chain. An on-chain governance mechanism would allow upgrades to be handled in a more controlled (and automatable) manner without introducing the risk of corrupting state.
Decision
This proposal introduces a minimal on-chain governance mechanism where anyone can submit governance proposals and the validators can vote where one base unit of delegated stake counts as one vote.
The high-level overview is as follows:
-
A new governance API is added to the consensus layer and its Tendermint based implementation. It supports transactions for submitting proposals and voting on proposals. It supports queries for listing current proposals and votes for any given proposal.
-
Two governance proposal kinds are supported, a consensus layer upgrade proposal (where the content is basically the existing upgrade descriptor) and the cancellation of a pending upgrade.
A proposal is created through a submit proposal transaction and requires a minimum deposit (which is later refunded in case the proposal passes). Once a proposal is successfully submitted the voting period starts. Entities that are part of the validator set may cast votes for the proposal. After the voting period completes, the votes are tallied and the proposal either passes or is rejected.
In case the proposal passes, the actions specified in the content of the propsal are executed. Currently the only actions are scheduling of an upgrade by publishing an upgrade descriptor or cancelling a previously passed upgrade.
State
Staking
This proposal adds the following consensus layer state in the staking module:
- Governance deposits account balance (
0x59
), similar to the common pool.
Governance
This proposal adds the following consensus layer state in the governance module:
-
Next proposal identifier (
0x80
)The next proposal identifier is stored as a CBOR-serialized
uint64
. -
List of proposals (
0x81
)Each proposal is stored under a separate storage key with the following key format:
0x81 <proposal-id (uint64)>
And CBOR-serialized value:
// ProposalState is the state of the proposal.
type ProposalState uint8
const (
StateActive ProposalState = 1
StatePassed ProposalState = 2
StateRejected ProposalState = 3
StateFailed ProposalState = 4
)
// Proposal is a consensus upgrade proposal.
type Proposal struct {
// ID is the unique identifier of the proposal.
ID uint64 `json:"id"`
// Submitter is the address of the proposal submitter.
Submitter staking.Address `json:"submitter"`
// State is the state of the proposal.
State ProposalState `json:"state"`
// Deposit is the deposit attached to the proposal.
Deposit quantity.Quantity `json:"deposit"`
// Content is the content of the proposal.
Content ProposalContent `json:"content"`
// CreatedAt is the epoch at which the proposal was created.
CreatedAt beacon.EpochTime `json:"created_at"`
// ClosesAt is the epoch at which the proposal will close and votes will
// be tallied.
ClosesAt beacon.EpochTime `json:"closes_at"`
// Results are the final tallied results after the voting period has
// ended.
Results map[Vote]quantity.Quantity `json:"results,omitempty"`
// InvalidVotes is the number of invalid votes after tallying.
InvalidVotes uint64 `json:"invalid_votes,omitempty"`
} -
List of active proposals (
0x82
)Each active proposal (one that has not yet closed) is stored under a separate storage key with the following key format:
0x82 <closes-at-epoch (uint64)> <proposal-id (uint64)>
The value is empty as the proposal ID can be inferred from the key.
-
List of votes (
0x83
)Each vote is stored under a separate storage key with the following key format:
0x83 <proposal-id (uint64)> <voter-address (staking.Address)>
And CBOR-serialized value:
// Vote is a governance vote.
type Vote uint8
const (
VoteYes Vote = 1
VoteNo Vote = 2
VoteAbstain Vote = 3
) -
List of pending upgrades (
0x84
)Each pending upgrade is stored under a separate storage key with the following key format:
0x84 <upgrade-epoch (uint64)> <proposal-id (uint64)>
The value is empty as the proposal upgrade descriptor can be obtained via proposal that can be inferred from the key.
-
Parameters (
0x85
)Governance consensus parameters.
With CBOR-serialized value:
// ConsensusParameters are the governance consensus parameters.
type ConsensusParameters struct {
// GasCosts are the governance transaction gas costs.
GasCosts transaction.Costs `json:"gas_costs,omitempty"`
// MinProposalDeposit is the number of base units that are deposited when
// creating a new proposal.
MinProposalDeposit quantity.Quantity `json:"min_proposal_deposit,omitempty"`
// VotingPeriod is the number of epochs after which the voting for a proposal
// is closed and the votes are tallied.
VotingPeriod beacon.EpochTime `json:"voting_period,omitempty"`
// Quorum is he minimum percentage of voting power that needs to be cast on
// a proposal for the result to be valid.
Quorum uint8 `json:"quorum,omitempty"`
// Threshold is the minimum percentage of VoteYes votes in order for a
// proposal to be accepted.
Threshold uint8 `json:"threshold,omitempty"`
// UpgradeMinEpochDiff is the minimum number of epochs between the current
// epoch and the proposed upgrade epoch for the upgrade proposal to be valid.
// This is also the minimum number of epochs between two pending upgrades.
UpgradeMinEpochDiff beacon.EpochTime `json:"upgrade_min_epoch_diff,omitempty"`
// UpgradeCancelMinEpochDiff is the minimum number of epochs between the current
// epoch and the proposed upgrade epoch for the upgrade cancellation proposal to be valid.
UpgradeCancelMinEpochDiff beacon.EpochTime `json:"upgrade_cancel_min_epoch_diff,omitempty"`
}
Genesis Document
The genesis document needs to be updated to include a governance
field with
any initial state (see State) and consensus parameters (see Consensus
Parameters) for the governance service.
Transaction Methods
This proposal adds the following transaction methods in the governance module:
Submit Proposal
Proposal submission enables a new consensus layer governance proposal to be created.
Method name:
governance.SubmitProposal
Body:
// ProposalContent is a consensus layer governance proposal content.
type ProposalContent struct {
Upgrade *UpgradeProposal `json:"upgrade,omitempty"`
CancelUpgrade *CancelUpgradeProposal `json:"cancel_upgrade,omitempty"`
}
// UpgradeProposal is an upgrade proposal.
type UpgradeProposal struct {
upgrade.Descriptor
}
// CancelUpgradeProposal is an upgrade cancellation proposal.
type CancelUpgradeProposal struct {
// ProposalID is the identifier of the pending upgrade proposal.
ProposalID uint64 `json:"proposal_id"`
}
Fields:
upgrade
(optional) specifies an upgrade proposal.cancel_upgrade
(optional) specifies an upgrade cancellation proposal.
Exactly one of the proposal kind fields needs to be non-nil, otherwise the proposal is considered malformed.
Upon processing any proposal the following steps are first performed:
-
The account indicated by the signer is loaded.
-
If the account balance is less than
min_proposal_deposit
, the method call fails withErrInsufficientBalance
.
Upon processing an UpgradeProposal
the following steps are then performed:
-
The upgrade descriptor is checked for basic internal validity. If the check fails, the method call fails with
ErrInvalidArgument
. -
The upgrade descriptor's
epoch
field is compared with the current epoch. If the specified epoch is not at leastupgrade_min_epoch_diff
epochs ahead of the current epoch, the method call fails withErrUpgradeTooSoon
. -
The set of pending upgrades is checked to make sure that no upgrades are currently pending within
upgrade_min_epoch_diff
epochs of the upgrade descriptor'sepoch
field. If there is such an existing upgrade pending, the method call fails withErrUpgradeAlreadyPending
.
Upon processing a CancelUpgradeProposal
the following steps are then
performed:
-
The set of pending upgrades is checked to make sure that the given upgrade proposal is currently pending to be executed. If there is no such upgrade, the method call fails with
ErrNoSuchUpgrade
. -
The upgrade descriptor's
epoch
field is compared with the current epoch. If the specified epoch is not at leastupgrade_cancel_min_epoch_diff
epochs ahead of the current epoch, the method call fails withErrUpgradeTooSoon
.
Upon processing any proposal the following steps are then performed:
-
The
min_proposal_deposit
base units are transferred from the signer's account to the governance service's proposal deposit account. -
The signer's account is saved.
-
A new proposal is created and assigned an identifier.
-
The corresponding
ProposalSubmittedEvent
is emitted with the following structure:type ProposalSubmittedEvent struct {
// ID is the unique identifier of a proposal.
ID uint64 `json:"id"`
// Submitter is the staking account address of the submitter.
Submitter staking.Address `json:"submitter"`
} -
The corresponding
staking.TransferEvent
is emitted, indicating transfer from the submitter's account to the proposal deposit account.
Vote
Voting for submitted consensus layer governance proposals.
Method name:
governance.CastVote
Body:
type ProposalVote struct {
// ID is the unique identifier of a proposal.
ID uint64 `json:"id"`
// Vote is the vote.
Vote Vote `json:"vote"`
}
Upon processing a vote the following steps are performed:
-
The entity descriptor corresponding to the transaction signer is fetched. In case no such entity exists, the method call fails with
ErrNotEligible
. -
It is checked whether any entity's nodes are in the current validator set. In case they are not, the method call fails with
ErrNotEligible
. -
The proposal identified by
id
is loaded. If the proposal does not exist, the method call fails withErrNoSuchProposal
. -
If the proposal's state is not
StateActive
, the method call fails withErrVotingIsClosed
. -
The vote is added to the list of votes. If the vote already exists, it is overwritten.
-
The corresponding
VoteEvent
is emitted with the following structure:type VoteEvent struct {
// ID is the unique identifier of a proposal.
ID uint64 `json:"id"`
// Submitter is the staking account address of the submitter.
Submitter staking.Address `json:"submitter"`
// Vote is the cast vote.
Vote Vote `json:"vote"`
}
Queries
This proposal introduces the following query methods in the governance module:
type Backend interface {
// ActiveProposals returns a list of all proposals that have not yet closed.
ActiveProposals(ctx context.Context, height int64) ([]*Proposal, error)
// Proposals returns a list of all proposals.
Proposals(ctx context.Context, height int64) ([]*Proposal, error)
// Proposal looks up a specific proposal.
Proposal(ctx context.Context, query *ProposalQuery) (*Proposal, error)
// Votes looks up votes for a specific proposal.
Votes(ctx context.Context, query *ProposalQuery) ([]*VoteEntry, error)
// PendingUpgrades returns a list of all pending upgrades.
PendingUpgrades(ctx context.Context, height int64) ([]*upgrade.Descriptor, error)
// StateToGenesis returns the genesis state at specified block height.
StateToGenesis(ctx context.Context, height int64) (*Genesis, error)
// ConsensusParameters returns the governance consensus parameters.
ConsensusParameters(ctx context.Context, height int64) (*ConsensusParameters, error)
// GetEvents returns the events at specified block height.
GetEvents(ctx context.Context, height int64) ([]*Event, error)
// WatchEvents returns a channel that produces a stream of Events.
WatchEvents(ctx context.Context) (<-chan *Event, pubsub.ClosableSubscription, error)
}
// ProposalQuery is a proposal query.
type ProposalQuery struct {
Height int64 `json:"height"`
ID uint64 `json:"id"`
}
// VoteEntry contains data about a cast vote.
type VoteEntry struct {
Voter staking.Address `json:"voter"`
Vote Vote `json:"vote"`
}
// Event signifies a governance event, returned via GetEvents.
type Event struct {
Height int64 `json:"height,omitempty"`
TxHash hash.Hash `json:"tx_hash,omitempty"`
ProposalSubmitted *ProposalSubmittedEvent `json:"proposal_submitted,omitempty"`
ProposalExecuted *ProposalExecutedEvent `json:"proposal_executed,omitempty"`
ProposalFinalized *ProposalFinalizedEvent `json:"proposal_finalized,omitempty"`
Vote *VoteEvent `json:"vote,omitempty"`
}
Tallying
In EndBlock
the list of active proposals is checked to see if there was an
epoch transition in this block. If there was, the following steps are performed
for each proposal that should be closed at the current epoch:
-
A mapping of current validator entity addresses to their respective active escrow balances is prepared.
-
A results mapping from
Vote
to number of votes is initialized in the proposal'sresults
field. -
Votes from the list of votes for the given proposal are iterated and the address of each vote is looked up in the prepared entity address mapping. The corresponding number of votes (on the principle of 1 base unit equals one vote) are added to the results mapping based on the voted option. Any votes that are not from the current validator set are ignored and the
invalid_votes
field is incremented for each such vote. -
In case the percentage of votes relative to the total voting power is less than
quorum
, the proposal is rejected. -
In case the percentage of
VoteYes
votes relative to all valid votes is less thanthreshold
, the proposal is rejected. -
Otherwise the proposal is passed.
-
The proposal's status is changed to either
StatePassed
orStateRejected
and the proposal is saved. -
The proposal is removed from the list of active proposals.
-
In case the proposal has been passed, the proposal content is executed. If proposal execution fails, the proposal's state is changed to
StateFailed
. -
The corresponding
ProposalFinalizedEvent
is emitted with the following structure:type ProposalFinalizedEvent struct {
// ID is the unique identifier of a proposal.
ID uint64 `json:"id"`
// State is the new proposal state.
State ProposalState `json:"state"`
} -
In case the proposal has been passed, the deposit is transferred back to the proposal submitter and a corresponding
staking.TransferEvent
is emitted, indicating transfer from the proposal deposit account to the submitter's account. -
In case the proposal has been rejected, the deposit is transferred to the common pool and a corresponding
staking.TransferEvent
is emitted, indicating transfer from the proposal deposit account to the common pool account.
Proposal Content Execution
After any proposal is successfully executed the corresponding
ProposalExecutedEvent
is emitted with the following structure:
type ProposalExecutedEvent struct {
// ID is the unique identifier of a proposal.
ID uint64 `json:"id"`
}
Upgrade Proposal
The set of pending upgrades is checked to make sure that no upgrades are
currently pending within upgrade_min_epoch_diff
of the upgrade descriptor's
epoch
field. If there is such an existing pending upgrade the upgrade proposal
execution fails.
When an upgrade proposal is executed, a new entry is added to the list of
pending upgrades using epoch
as <upgrade-epoch>
.
On each epoch transition (as part of BeginBlock
) it is checked whether a
pending upgrade is scheduled for that epoch. In case it is and we are not
running the new version, the consensus layer will panic. Otherwise, the pending
upgrade proposal is removed.
Cancel Upgrade Proposal
When a cancel upgrade proposal is executed, the proposal identified by
proposal_id
is looked up and removed from the list of pending upgrades. In
case the pending upgrade does not exist anymore, no action is performed.
Consensus Parameters
This proposal introduces the following new consensus parameters in the governance module:
-
gas_costs
(transaction.Costs) are the governance transaction gas costs. -
min_proposal_deposit
(base units) specifies the number of base units that are deposited when creating a new proposal. -
voting_period
(epochs) specifies the number of epochs after which the voting for a proposal is closed and the votes are tallied. -
quorum
(uint8: [0,100]) specifies the minimum percentage of voting power that needs to be cast on a proposal for the result to be valid. -
threshold
(uint8: [0,100]) specifies the minimum percentage ofVoteYes
votes in order for a proposal to be accepted. -
upgrade_min_epoch_diff
(epochs) specifies the minimum number of epochs between the current epoch and the proposed upgrade epoch for the upgrade proposal to be valid. Additionally specifies the minimum number of epochs between two consecutive pending upgrades. -
upgrade_cancel_min_epoch_diff
(epochs) specifies the minimum number of epochs between the current epoch and the proposed upgrade epoch for the upgrade cancellation proposal to be valid.
The following parameter sanity checks are introduced:
-
Product of
quorum
andthreshold
must be 2/3+. -
voting_period
must be less thanupgrade_min_epoch_diff
andupgrade_cancel_min_epoch_diff
.
Consequences
Positive
- The consensus layer can coordinate on upgrades.