951 lines
31 KiB
Go
951 lines
31 KiB
Go
package native
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"errors"
|
|
"math/big"
|
|
|
|
"github.com/tutus-one/tutus-chain/pkg/config"
|
|
"github.com/tutus-one/tutus-chain/pkg/core/dao"
|
|
"github.com/tutus-one/tutus-chain/pkg/core/interop"
|
|
"github.com/tutus-one/tutus-chain/pkg/core/native/nativeids"
|
|
"github.com/tutus-one/tutus-chain/pkg/core/native/nativenames"
|
|
"github.com/tutus-one/tutus-chain/pkg/core/state"
|
|
"github.com/tutus-one/tutus-chain/pkg/crypto/hash"
|
|
"github.com/tutus-one/tutus-chain/pkg/smartcontract"
|
|
"github.com/tutus-one/tutus-chain/pkg/smartcontract/callflag"
|
|
"github.com/tutus-one/tutus-chain/pkg/smartcontract/manifest"
|
|
"github.com/tutus-one/tutus-chain/pkg/util"
|
|
"github.com/tutus-one/tutus-chain/pkg/vm/stackitem"
|
|
)
|
|
|
|
// Storage key prefixes for Eligere.
|
|
const (
|
|
eligerePrefixProposal byte = 0x01 // proposalID -> Proposal
|
|
eligerePrefixProposalTitle byte = 0x02 // titleHash -> proposalID
|
|
eligerePrefixVote byte = 0x03 // proposalID + vitaID -> Vote
|
|
eligerePrefixVoterHistory byte = 0x04 // vitaID + proposalID -> exists
|
|
eligerePrefixProposalStatus byte = 0x05 // status + proposalID -> exists
|
|
eligerePrefixCategory byte = 0x06 // category + proposalID -> exists
|
|
eligerePrefixActiveProposals byte = 0x07 // -> serialized []proposalID
|
|
eligerePrefixProposalCounter byte = 0xF0 // -> next proposalID
|
|
eligerePrefixConfig byte = 0xF1 // -> EligereConfig
|
|
)
|
|
|
|
// Event names for Eligere.
|
|
const (
|
|
ProposalCreatedEvent = "ProposalCreated"
|
|
ProposalActivatedEvent = "ProposalActivated"
|
|
VoteCastEvent = "VoteCast"
|
|
ProposalPassedEvent = "ProposalPassed"
|
|
ProposalRejectedEvent = "ProposalRejected"
|
|
ProposalExecutedEvent = "ProposalExecuted"
|
|
ProposalCancelledEvent = "ProposalCancelled"
|
|
ProposalExpiredEvent = "ProposalExpired"
|
|
)
|
|
|
|
// Errors for Eligere.
|
|
var (
|
|
ErrProposalNotFound = errors.New("proposal not found")
|
|
ErrProposalNotActive = errors.New("proposal is not active for voting")
|
|
ErrAlreadyVoted = errors.New("already voted on this proposal")
|
|
ErrVotingNotStarted = errors.New("voting period has not started")
|
|
ErrVotingEnded = errors.New("voting period has ended")
|
|
ErrNotProposer = errors.New("caller is not the proposer")
|
|
ErrProposalTitleExists = errors.New("proposal title already exists")
|
|
ErrInvalidCategory = errors.New("invalid proposal category")
|
|
ErrQuorumNotMet = errors.New("quorum not met")
|
|
ErrThresholdNotMet = errors.New("threshold not met")
|
|
ErrProposalNotPassed = errors.New("proposal has not passed")
|
|
ErrExecutionDelayNotPassed = errors.New("execution delay has not passed")
|
|
ErrProposalAlreadyExecuted = errors.New("proposal already executed")
|
|
ErrNoVitaToken = errors.New("caller must have active Vita")
|
|
ErrVotingRightRestricted = errors.New("voting right is restricted")
|
|
ErrUnderVotingAge = errors.New("voter is under voting age")
|
|
ErrInvalidVotingPeriod = errors.New("invalid voting period")
|
|
ErrTitleTooLong = errors.New("title too long (max 128 chars)")
|
|
ErrInvalidVoteChoice = errors.New("invalid vote choice")
|
|
ErrVotingPeriodNotEnded = errors.New("voting period has not ended")
|
|
ErrProposalAlreadyFinalized = errors.New("proposal already finalized")
|
|
ErrNotCommitteeOrProposer = errors.New("only proposer or committee can cancel")
|
|
ErrCannotCancelFinalized = errors.New("cannot cancel finalized proposal")
|
|
)
|
|
|
|
// Eligere represents the democratic voting native contract.
|
|
type Eligere struct {
|
|
interop.ContractMD
|
|
|
|
Tutus ITutus
|
|
Vita IVita
|
|
RoleRegistry IRoleRegistry
|
|
Lex ILex
|
|
Annos IAnnos
|
|
}
|
|
|
|
// EligereCache contains cached data for performance.
|
|
type EligereCache struct {
|
|
proposalCount uint64
|
|
}
|
|
|
|
// Copy implements dao.NativeContractCache.
|
|
func (c *EligereCache) Copy() dao.NativeContractCache {
|
|
return &EligereCache{
|
|
proposalCount: c.proposalCount,
|
|
}
|
|
}
|
|
|
|
// newEligere creates a new Eligere native contract.
|
|
func newEligere() *Eligere {
|
|
e := &Eligere{}
|
|
|
|
e.ContractMD = *interop.NewContractMD(nativenames.Eligere, nativeids.Eligere)
|
|
defer e.BuildHFSpecificMD(e.ActiveIn())
|
|
|
|
// ============ Proposal Management Methods ============
|
|
|
|
desc := NewDescriptor("createProposal", smartcontract.IntegerType,
|
|
manifest.NewParameter("title", smartcontract.StringType),
|
|
manifest.NewParameter("contentHash", smartcontract.ByteArrayType),
|
|
manifest.NewParameter("category", smartcontract.IntegerType),
|
|
manifest.NewParameter("votingStartsAt", smartcontract.IntegerType),
|
|
manifest.NewParameter("votingEndsAt", smartcontract.IntegerType),
|
|
manifest.NewParameter("targetContract", smartcontract.Hash160Type),
|
|
manifest.NewParameter("targetMethod", smartcontract.StringType),
|
|
manifest.NewParameter("targetParams", smartcontract.ByteArrayType))
|
|
md := NewMethodAndPrice(e.createProposal, 1<<15, callflag.States|callflag.AllowNotify)
|
|
e.AddMethod(md, desc)
|
|
|
|
desc = NewDescriptor("cancelProposal", smartcontract.BoolType,
|
|
manifest.NewParameter("proposalID", smartcontract.IntegerType))
|
|
md = NewMethodAndPrice(e.cancelProposal, 1<<15, callflag.States|callflag.AllowNotify)
|
|
e.AddMethod(md, desc)
|
|
|
|
// ============ Voting Methods ============
|
|
|
|
desc = NewDescriptor("vote", smartcontract.BoolType,
|
|
manifest.NewParameter("proposalID", smartcontract.IntegerType),
|
|
manifest.NewParameter("choice", smartcontract.IntegerType))
|
|
md = NewMethodAndPrice(e.vote, 1<<15, callflag.States|callflag.AllowNotify)
|
|
e.AddMethod(md, desc)
|
|
|
|
// ============ Tallying & Execution Methods ============
|
|
|
|
desc = NewDescriptor("tallyVotes", smartcontract.BoolType,
|
|
manifest.NewParameter("proposalID", smartcontract.IntegerType))
|
|
md = NewMethodAndPrice(e.tallyVotes, 1<<15, callflag.States|callflag.AllowNotify)
|
|
e.AddMethod(md, desc)
|
|
|
|
desc = NewDescriptor("executeProposal", smartcontract.BoolType,
|
|
manifest.NewParameter("proposalID", smartcontract.IntegerType))
|
|
md = NewMethodAndPrice(e.executeProposal, 1<<17, callflag.All)
|
|
e.AddMethod(md, desc)
|
|
|
|
// ============ Query Methods ============
|
|
|
|
desc = NewDescriptor("getProposal", smartcontract.ArrayType,
|
|
manifest.NewParameter("proposalID", smartcontract.IntegerType))
|
|
md = NewMethodAndPrice(e.getProposal, 1<<15, callflag.ReadStates)
|
|
e.AddMethod(md, desc)
|
|
|
|
desc = NewDescriptor("hasVoted", smartcontract.BoolType,
|
|
manifest.NewParameter("proposalID", smartcontract.IntegerType),
|
|
manifest.NewParameter("voter", smartcontract.Hash160Type))
|
|
md = NewMethodAndPrice(e.hasVoted, 1<<15, callflag.ReadStates)
|
|
e.AddMethod(md, desc)
|
|
|
|
desc = NewDescriptor("getVote", smartcontract.ArrayType,
|
|
manifest.NewParameter("proposalID", smartcontract.IntegerType),
|
|
manifest.NewParameter("voter", smartcontract.Hash160Type))
|
|
md = NewMethodAndPrice(e.getVote, 1<<15, callflag.ReadStates)
|
|
e.AddMethod(md, desc)
|
|
|
|
desc = NewDescriptor("getProposalCount", smartcontract.IntegerType)
|
|
md = NewMethodAndPrice(e.getProposalCount, 1<<15, callflag.ReadStates)
|
|
e.AddMethod(md, desc)
|
|
|
|
desc = NewDescriptor("getConfig", smartcontract.ArrayType)
|
|
md = NewMethodAndPrice(e.getConfig, 1<<15, callflag.ReadStates)
|
|
e.AddMethod(md, desc)
|
|
|
|
// ============ Events ============
|
|
|
|
eDesc := NewEventDescriptor(ProposalCreatedEvent,
|
|
manifest.NewParameter("proposalID", smartcontract.IntegerType),
|
|
manifest.NewParameter("title", smartcontract.StringType),
|
|
manifest.NewParameter("category", smartcontract.IntegerType),
|
|
manifest.NewParameter("proposer", smartcontract.Hash160Type),
|
|
manifest.NewParameter("votingEndsAt", smartcontract.IntegerType))
|
|
e.AddEvent(NewEvent(eDesc))
|
|
|
|
eDesc = NewEventDescriptor(ProposalActivatedEvent,
|
|
manifest.NewParameter("proposalID", smartcontract.IntegerType))
|
|
e.AddEvent(NewEvent(eDesc))
|
|
|
|
eDesc = NewEventDescriptor(VoteCastEvent,
|
|
manifest.NewParameter("proposalID", smartcontract.IntegerType),
|
|
manifest.NewParameter("voterVitaID", smartcontract.IntegerType),
|
|
manifest.NewParameter("choice", smartcontract.IntegerType))
|
|
e.AddEvent(NewEvent(eDesc))
|
|
|
|
eDesc = NewEventDescriptor(ProposalPassedEvent,
|
|
manifest.NewParameter("proposalID", smartcontract.IntegerType),
|
|
manifest.NewParameter("supportPercent", smartcontract.IntegerType),
|
|
manifest.NewParameter("totalVotes", smartcontract.IntegerType))
|
|
e.AddEvent(NewEvent(eDesc))
|
|
|
|
eDesc = NewEventDescriptor(ProposalRejectedEvent,
|
|
manifest.NewParameter("proposalID", smartcontract.IntegerType),
|
|
manifest.NewParameter("supportPercent", smartcontract.IntegerType))
|
|
e.AddEvent(NewEvent(eDesc))
|
|
|
|
eDesc = NewEventDescriptor(ProposalExecutedEvent,
|
|
manifest.NewParameter("proposalID", smartcontract.IntegerType),
|
|
manifest.NewParameter("executor", smartcontract.Hash160Type),
|
|
manifest.NewParameter("executedAt", smartcontract.IntegerType))
|
|
e.AddEvent(NewEvent(eDesc))
|
|
|
|
eDesc = NewEventDescriptor(ProposalCancelledEvent,
|
|
manifest.NewParameter("proposalID", smartcontract.IntegerType),
|
|
manifest.NewParameter("cancelledBy", smartcontract.Hash160Type))
|
|
e.AddEvent(NewEvent(eDesc))
|
|
|
|
eDesc = NewEventDescriptor(ProposalExpiredEvent,
|
|
manifest.NewParameter("proposalID", smartcontract.IntegerType),
|
|
manifest.NewParameter("reason", smartcontract.StringType))
|
|
e.AddEvent(NewEvent(eDesc))
|
|
|
|
return e
|
|
}
|
|
|
|
// Initialize implements the Contract interface.
|
|
func (e *Eligere) Initialize(ic *interop.Context, hf *config.Hardfork, newMD *interop.HFSpecificContractMD) error {
|
|
if hf != e.ActiveIn() {
|
|
return nil
|
|
}
|
|
|
|
// Initialize cache
|
|
cache := &EligereCache{proposalCount: 0}
|
|
ic.DAO.SetCache(e.ID, cache)
|
|
|
|
// Initialize config with defaults
|
|
cfg := state.DefaultEligereConfig()
|
|
if err := e.putConfig(ic.DAO, cfg); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Initialize proposal counter
|
|
setIntWithKey(e.ID, ic.DAO, []byte{eligerePrefixProposalCounter}, 0)
|
|
|
|
return nil
|
|
}
|
|
|
|
// InitializeCache implements the Contract interface.
|
|
func (e *Eligere) InitializeCache(_ interop.IsHardforkEnabled, blockHeight uint32, d *dao.Simple) error {
|
|
cache := &EligereCache{
|
|
proposalCount: e.getProposalCounter(d),
|
|
}
|
|
d.SetCache(e.ID, cache)
|
|
return nil
|
|
}
|
|
|
|
// OnPersist implements the Contract interface.
|
|
func (e *Eligere) OnPersist(ic *interop.Context) error {
|
|
return nil
|
|
}
|
|
|
|
// PostPersist implements the Contract interface.
|
|
func (e *Eligere) PostPersist(ic *interop.Context) error {
|
|
return nil
|
|
}
|
|
|
|
// Metadata returns contract metadata.
|
|
func (e *Eligere) Metadata() *interop.ContractMD {
|
|
return &e.ContractMD
|
|
}
|
|
|
|
// Address returns the contract's script hash.
|
|
func (e *Eligere) Address() util.Uint160 {
|
|
return e.Hash
|
|
}
|
|
|
|
// GetProposalInternal returns a proposal by ID (for cross-native access).
|
|
func (e *Eligere) GetProposalInternal(d *dao.Simple, proposalID uint64) *state.Proposal {
|
|
return e.getProposalInternal(d, proposalID)
|
|
}
|
|
|
|
// ActiveIn implements the Contract interface.
|
|
func (e *Eligere) ActiveIn() *config.Hardfork {
|
|
return nil // Active from genesis
|
|
}
|
|
|
|
// ============ Storage Helpers ============
|
|
|
|
func (e *Eligere) makeProposalKey(proposalID uint64) []byte {
|
|
key := make([]byte, 1+8)
|
|
key[0] = eligerePrefixProposal
|
|
binary.BigEndian.PutUint64(key[1:], proposalID)
|
|
return key
|
|
}
|
|
|
|
func (e *Eligere) makeTitleKey(title string) []byte {
|
|
h := hash.Hash160([]byte(title))
|
|
key := make([]byte, 1+20)
|
|
key[0] = eligerePrefixProposalTitle
|
|
copy(key[1:], h.BytesBE())
|
|
return key
|
|
}
|
|
|
|
func (e *Eligere) makeVoteKey(proposalID, vitaID uint64) []byte {
|
|
key := make([]byte, 1+8+8)
|
|
key[0] = eligerePrefixVote
|
|
binary.BigEndian.PutUint64(key[1:9], proposalID)
|
|
binary.BigEndian.PutUint64(key[9:], vitaID)
|
|
return key
|
|
}
|
|
|
|
func (e *Eligere) makeVoterHistoryKey(vitaID, proposalID uint64) []byte {
|
|
key := make([]byte, 1+8+8)
|
|
key[0] = eligerePrefixVoterHistory
|
|
binary.BigEndian.PutUint64(key[1:9], vitaID)
|
|
binary.BigEndian.PutUint64(key[9:], proposalID)
|
|
return key
|
|
}
|
|
|
|
func (e *Eligere) makeStatusIndexKey(status state.ProposalStatus, proposalID uint64) []byte {
|
|
key := make([]byte, 1+1+8)
|
|
key[0] = eligerePrefixProposalStatus
|
|
key[1] = byte(status)
|
|
binary.BigEndian.PutUint64(key[2:], proposalID)
|
|
return key
|
|
}
|
|
|
|
func (e *Eligere) makeCategoryIndexKey(category state.ProposalCategory, proposalID uint64) []byte {
|
|
key := make([]byte, 1+1+8)
|
|
key[0] = eligerePrefixCategory
|
|
key[1] = byte(category)
|
|
binary.BigEndian.PutUint64(key[2:], proposalID)
|
|
return key
|
|
}
|
|
|
|
// ============ Proposal Storage ============
|
|
|
|
func (e *Eligere) getProposalInternal(d *dao.Simple, proposalID uint64) *state.Proposal {
|
|
key := e.makeProposalKey(proposalID)
|
|
proposal := &state.Proposal{}
|
|
err := getConvertibleFromDAO(e.ID, d, key, proposal)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return proposal
|
|
}
|
|
|
|
func (e *Eligere) putProposal(d *dao.Simple, proposal *state.Proposal) error {
|
|
key := e.makeProposalKey(proposal.ID)
|
|
return putConvertibleToDAO(e.ID, d, key, proposal)
|
|
}
|
|
|
|
func (e *Eligere) titleExists(d *dao.Simple, title string) bool {
|
|
key := e.makeTitleKey(title)
|
|
si := d.GetStorageItem(e.ID, key)
|
|
return si != nil
|
|
}
|
|
|
|
func (e *Eligere) putTitleIndex(d *dao.Simple, title string, proposalID uint64) {
|
|
key := e.makeTitleKey(title)
|
|
val := make([]byte, 8)
|
|
binary.BigEndian.PutUint64(val, proposalID)
|
|
d.PutStorageItem(e.ID, key, val)
|
|
}
|
|
|
|
// ============ Vote Storage ============
|
|
|
|
func (e *Eligere) getVoteInternal(d *dao.Simple, proposalID, vitaID uint64) *state.Vote {
|
|
key := e.makeVoteKey(proposalID, vitaID)
|
|
vote := &state.Vote{}
|
|
err := getConvertibleFromDAO(e.ID, d, key, vote)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return vote
|
|
}
|
|
|
|
func (e *Eligere) putVote(d *dao.Simple, vote *state.Vote) error {
|
|
key := e.makeVoteKey(vote.ProposalID, vote.VoterVitaID)
|
|
return putConvertibleToDAO(e.ID, d, key, vote)
|
|
}
|
|
|
|
func (e *Eligere) hasVotedInternal(d *dao.Simple, proposalID, vitaID uint64) bool {
|
|
key := e.makeVoteKey(proposalID, vitaID)
|
|
si := d.GetStorageItem(e.ID, key)
|
|
return si != nil
|
|
}
|
|
|
|
func (e *Eligere) putVoterHistory(d *dao.Simple, vitaID, proposalID uint64) {
|
|
key := e.makeVoterHistoryKey(vitaID, proposalID)
|
|
d.PutStorageItem(e.ID, key, []byte{1})
|
|
}
|
|
|
|
// ============ Config Storage ============
|
|
|
|
func (e *Eligere) getConfigInternal(d *dao.Simple) *state.EligereConfig {
|
|
key := []byte{eligerePrefixConfig}
|
|
cfg := &state.EligereConfig{}
|
|
err := getConvertibleFromDAO(e.ID, d, key, cfg)
|
|
if err != nil {
|
|
return state.DefaultEligereConfig()
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
func (e *Eligere) putConfig(d *dao.Simple, cfg *state.EligereConfig) error {
|
|
key := []byte{eligerePrefixConfig}
|
|
return putConvertibleToDAO(e.ID, d, key, cfg)
|
|
}
|
|
|
|
// ============ Counter ============
|
|
|
|
func (e *Eligere) getProposalCounter(d *dao.Simple) uint64 {
|
|
key := []byte{eligerePrefixProposalCounter}
|
|
return uint64(getIntWithKey(e.ID, d, key))
|
|
}
|
|
|
|
func (e *Eligere) getAndIncrementProposalCounter(d *dao.Simple) uint64 {
|
|
key := []byte{eligerePrefixProposalCounter}
|
|
current := getIntWithKey(e.ID, d, key)
|
|
setIntWithKey(e.ID, d, key, current+1)
|
|
return uint64(current + 1)
|
|
}
|
|
|
|
// ============ Status Index ============
|
|
|
|
func (e *Eligere) updateStatusIndex(d *dao.Simple, proposalID uint64, oldStatus, newStatus state.ProposalStatus) {
|
|
// Remove from old status index
|
|
if oldStatus != newStatus {
|
|
oldKey := e.makeStatusIndexKey(oldStatus, proposalID)
|
|
d.DeleteStorageItem(e.ID, oldKey)
|
|
}
|
|
// Add to new status index
|
|
newKey := e.makeStatusIndexKey(newStatus, proposalID)
|
|
d.PutStorageItem(e.ID, newKey, []byte{1})
|
|
}
|
|
|
|
// ============ Authorization Helpers ============
|
|
|
|
func (e *Eligere) checkCommittee(ic *interop.Context) bool {
|
|
if e.Tutus == nil {
|
|
return false
|
|
}
|
|
return e.Tutus.CheckCommittee(ic)
|
|
}
|
|
|
|
func (e *Eligere) hasLegislatorRole(d *dao.Simple, addr util.Uint160, blockHeight uint32) bool {
|
|
if e.RoleRegistry == nil {
|
|
return false
|
|
}
|
|
return e.RoleRegistry.HasRoleInternal(d, addr, RoleLegislator, blockHeight)
|
|
}
|
|
|
|
// ============ Contract Methods ============
|
|
|
|
// createProposal creates a new democratic proposal.
|
|
func (e *Eligere) createProposal(ic *interop.Context, args []stackitem.Item) stackitem.Item {
|
|
title := toString(args[0])
|
|
contentHashBytes := toBytes(args[1])
|
|
category := state.ProposalCategory(toBigInt(args[2]).Uint64())
|
|
votingStartsAt := uint32(toBigInt(args[3]).Uint64())
|
|
votingEndsAt := uint32(toBigInt(args[4]).Uint64())
|
|
targetContract := toUint160(args[5])
|
|
targetMethod := toString(args[6])
|
|
targetParams := toBytes(args[7])
|
|
|
|
caller := ic.VM.GetCallingScriptHash()
|
|
|
|
// Validate caller has active Vita
|
|
token, err := e.Vita.GetTokenByOwner(ic.DAO, caller)
|
|
if err != nil || token == nil || token.Status != state.TokenStatusActive {
|
|
panic(ErrNoVitaToken)
|
|
}
|
|
|
|
// Check voting right is not restricted (via Lex)
|
|
if e.Lex != nil && !e.Lex.HasRightInternal(ic.DAO, caller, state.RightVote, ic.Block.Index) {
|
|
panic(ErrVotingRightRestricted)
|
|
}
|
|
|
|
// For law/constitutional amendments, require legislator role or committee
|
|
if category == state.ProposalCategoryLawAmendment || category == state.ProposalCategoryConstitutional {
|
|
if !e.hasLegislatorRole(ic.DAO, caller, ic.Block.Index) && !e.checkCommittee(ic) {
|
|
panic("legislative authority required for law amendments")
|
|
}
|
|
}
|
|
|
|
// Validate inputs
|
|
if len(title) > 128 {
|
|
panic(ErrTitleTooLong)
|
|
}
|
|
if len(title) == 0 {
|
|
panic("title cannot be empty")
|
|
}
|
|
if category < state.ProposalCategoryLawAmendment || category > state.ProposalCategoryReferendum {
|
|
panic(ErrInvalidCategory)
|
|
}
|
|
|
|
// Check title uniqueness
|
|
if e.titleExists(ic.DAO, title) {
|
|
panic(ErrProposalTitleExists)
|
|
}
|
|
|
|
// Validate voting period
|
|
cfg := e.getConfigInternal(ic.DAO)
|
|
if votingStartsAt < ic.Block.Index {
|
|
votingStartsAt = ic.Block.Index // Can't start in past
|
|
}
|
|
duration := votingEndsAt - votingStartsAt
|
|
if duration < cfg.MinVotingPeriod || duration > cfg.MaxVotingPeriod {
|
|
panic(ErrInvalidVotingPeriod)
|
|
}
|
|
|
|
// Determine threshold based on category
|
|
threshold := cfg.StandardThreshold
|
|
if category == state.ProposalCategoryConstitutional {
|
|
threshold = cfg.ConstitutionalThreshold
|
|
}
|
|
|
|
// Get next proposal ID
|
|
proposalID := e.getAndIncrementProposalCounter(ic.DAO)
|
|
|
|
// Create proposal
|
|
var contentHash util.Uint256
|
|
if len(contentHashBytes) == 32 {
|
|
copy(contentHash[:], contentHashBytes)
|
|
}
|
|
|
|
proposal := &state.Proposal{
|
|
ID: proposalID,
|
|
Title: title,
|
|
ContentHash: contentHash,
|
|
Category: category,
|
|
Proposer: caller,
|
|
ProposerVitaID: token.TokenID,
|
|
CreatedAt: ic.Block.Index,
|
|
VotingStartsAt: votingStartsAt,
|
|
VotingEndsAt: votingEndsAt,
|
|
ExecutionDelay: cfg.DefaultExecutionDelay,
|
|
QuorumPercent: cfg.DefaultQuorum,
|
|
ThresholdPercent: threshold,
|
|
Status: state.ProposalStatusDraft,
|
|
TargetContract: targetContract,
|
|
TargetMethod: targetMethod,
|
|
TargetParams: targetParams,
|
|
}
|
|
|
|
// If voting starts now, activate immediately
|
|
if votingStartsAt <= ic.Block.Index {
|
|
proposal.Status = state.ProposalStatusActive
|
|
}
|
|
|
|
// Store proposal
|
|
if err := e.putProposal(ic.DAO, proposal); err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
// Store indexes
|
|
e.putTitleIndex(ic.DAO, title, proposalID)
|
|
e.updateStatusIndex(ic.DAO, proposalID, state.ProposalStatusDraft, proposal.Status)
|
|
|
|
// Store category index
|
|
catKey := e.makeCategoryIndexKey(category, proposalID)
|
|
ic.DAO.PutStorageItem(e.ID, catKey, []byte{1})
|
|
|
|
// Emit event
|
|
ic.AddNotification(e.Hash, ProposalCreatedEvent, stackitem.NewArray([]stackitem.Item{
|
|
stackitem.NewBigInteger(big.NewInt(int64(proposalID))),
|
|
stackitem.NewByteArray([]byte(title)),
|
|
stackitem.NewBigInteger(big.NewInt(int64(category))),
|
|
stackitem.NewByteArray(caller.BytesBE()),
|
|
stackitem.NewBigInteger(big.NewInt(int64(votingEndsAt))),
|
|
}))
|
|
|
|
return stackitem.NewBigInteger(big.NewInt(int64(proposalID)))
|
|
}
|
|
|
|
// cancelProposal cancels a proposal (proposer or committee only).
|
|
func (e *Eligere) cancelProposal(ic *interop.Context, args []stackitem.Item) stackitem.Item {
|
|
proposalID := toBigInt(args[0]).Uint64()
|
|
|
|
caller := ic.VM.GetCallingScriptHash()
|
|
|
|
proposal := e.getProposalInternal(ic.DAO, proposalID)
|
|
if proposal == nil {
|
|
panic(ErrProposalNotFound)
|
|
}
|
|
|
|
// Check authorization: must be proposer or committee
|
|
isProposer := proposal.Proposer.Equals(caller)
|
|
isCommittee := e.checkCommittee(ic)
|
|
if !isProposer && !isCommittee {
|
|
panic(ErrNotCommitteeOrProposer)
|
|
}
|
|
|
|
// Can only cancel draft or active proposals
|
|
if proposal.Status != state.ProposalStatusDraft && proposal.Status != state.ProposalStatusActive {
|
|
panic(ErrCannotCancelFinalized)
|
|
}
|
|
|
|
oldStatus := proposal.Status
|
|
proposal.Status = state.ProposalStatusCancelled
|
|
|
|
// Update proposal
|
|
if err := e.putProposal(ic.DAO, proposal); err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
// Update status index
|
|
e.updateStatusIndex(ic.DAO, proposalID, oldStatus, proposal.Status)
|
|
|
|
// Emit event
|
|
ic.AddNotification(e.Hash, ProposalCancelledEvent, stackitem.NewArray([]stackitem.Item{
|
|
stackitem.NewBigInteger(big.NewInt(int64(proposalID))),
|
|
stackitem.NewByteArray(caller.BytesBE()),
|
|
}))
|
|
|
|
return stackitem.NewBool(true)
|
|
}
|
|
|
|
// vote casts a vote on a proposal.
|
|
func (e *Eligere) vote(ic *interop.Context, args []stackitem.Item) stackitem.Item {
|
|
proposalID := toBigInt(args[0]).Uint64()
|
|
choice := state.VoteChoice(toBigInt(args[1]).Uint64())
|
|
|
|
caller := ic.VM.GetCallingScriptHash()
|
|
|
|
// Validate vote choice
|
|
if choice > state.VoteChoiceNo {
|
|
panic(ErrInvalidVoteChoice)
|
|
}
|
|
|
|
// Validate caller has active Vita
|
|
token, err := e.Vita.GetTokenByOwner(ic.DAO, caller)
|
|
if err != nil || token == nil || token.Status != state.TokenStatusActive {
|
|
panic(ErrNoVitaToken)
|
|
}
|
|
|
|
// Check voting right is not restricted
|
|
if e.Lex != nil && !e.Lex.HasRightInternal(ic.DAO, caller, state.RightVote, ic.Block.Index) {
|
|
panic(ErrVotingRightRestricted)
|
|
}
|
|
|
|
// Check voter is of voting age (18+)
|
|
if e.Annos != nil && !e.Annos.IsVotingAgeInternal(ic.DAO, caller, ic.Block.Timestamp) {
|
|
panic(ErrUnderVotingAge)
|
|
}
|
|
|
|
// Get proposal
|
|
proposal := e.getProposalInternal(ic.DAO, proposalID)
|
|
if proposal == nil {
|
|
panic(ErrProposalNotFound)
|
|
}
|
|
|
|
// Check proposal is active
|
|
if proposal.Status != state.ProposalStatusActive {
|
|
// Auto-activate if draft and voting period has started
|
|
if proposal.Status == state.ProposalStatusDraft && ic.Block.Index >= proposal.VotingStartsAt {
|
|
proposal.Status = state.ProposalStatusActive
|
|
e.updateStatusIndex(ic.DAO, proposalID, state.ProposalStatusDraft, state.ProposalStatusActive)
|
|
} else {
|
|
panic(ErrProposalNotActive)
|
|
}
|
|
}
|
|
|
|
// Check voting period
|
|
if ic.Block.Index < proposal.VotingStartsAt {
|
|
panic(ErrVotingNotStarted)
|
|
}
|
|
if ic.Block.Index > proposal.VotingEndsAt {
|
|
panic(ErrVotingEnded)
|
|
}
|
|
|
|
// Check not already voted (using Vita ID for one-person-one-vote)
|
|
if e.hasVotedInternal(ic.DAO, proposalID, token.TokenID) {
|
|
panic(ErrAlreadyVoted)
|
|
}
|
|
|
|
// Create vote record
|
|
voteRecord := &state.Vote{
|
|
ProposalID: proposalID,
|
|
VoterVitaID: token.TokenID,
|
|
Voter: caller,
|
|
Choice: choice,
|
|
VotedAt: ic.Block.Index,
|
|
Weight: 1, // Equal voting weight
|
|
}
|
|
|
|
// Store vote
|
|
if err := e.putVote(ic.DAO, voteRecord); err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
// Store voter history
|
|
e.putVoterHistory(ic.DAO, token.TokenID, proposalID)
|
|
|
|
// Update vote counts (incremental tallying)
|
|
proposal.TotalVotes++
|
|
switch choice {
|
|
case state.VoteChoiceYes:
|
|
proposal.YesVotes++
|
|
case state.VoteChoiceNo:
|
|
proposal.NoVotes++
|
|
case state.VoteChoiceAbstain:
|
|
proposal.AbstainVotes++
|
|
}
|
|
|
|
// Update proposal
|
|
if err := e.putProposal(ic.DAO, proposal); err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
// Emit event
|
|
ic.AddNotification(e.Hash, VoteCastEvent, stackitem.NewArray([]stackitem.Item{
|
|
stackitem.NewBigInteger(big.NewInt(int64(proposalID))),
|
|
stackitem.NewBigInteger(big.NewInt(int64(token.TokenID))),
|
|
stackitem.NewBigInteger(big.NewInt(int64(choice))),
|
|
}))
|
|
|
|
return stackitem.NewBool(true)
|
|
}
|
|
|
|
// tallyVotes finalizes voting after the deadline.
|
|
func (e *Eligere) tallyVotes(ic *interop.Context, args []stackitem.Item) stackitem.Item {
|
|
proposalID := toBigInt(args[0]).Uint64()
|
|
|
|
proposal := e.getProposalInternal(ic.DAO, proposalID)
|
|
if proposal == nil {
|
|
panic(ErrProposalNotFound)
|
|
}
|
|
|
|
// Must be after voting period
|
|
if ic.Block.Index <= proposal.VotingEndsAt {
|
|
panic(ErrVotingPeriodNotEnded)
|
|
}
|
|
|
|
// Must still be active
|
|
if proposal.Status != state.ProposalStatusActive {
|
|
panic(ErrProposalAlreadyFinalized)
|
|
}
|
|
|
|
// Get total eligible voters for quorum calculation
|
|
// Use Vita token count as the voter base
|
|
totalVoters := e.Vita.GetTotalTokenCount(ic.DAO)
|
|
if totalVoters == 0 {
|
|
totalVoters = 1 // Avoid division by zero
|
|
}
|
|
|
|
oldStatus := proposal.Status
|
|
|
|
// Check quorum (total votes including abstentions)
|
|
quorumRequired := (totalVoters * uint64(proposal.QuorumPercent)) / 100
|
|
if proposal.TotalVotes < quorumRequired {
|
|
proposal.Status = state.ProposalStatusExpired
|
|
|
|
if err := e.putProposal(ic.DAO, proposal); err != nil {
|
|
panic(err)
|
|
}
|
|
e.updateStatusIndex(ic.DAO, proposalID, oldStatus, proposal.Status)
|
|
|
|
ic.AddNotification(e.Hash, ProposalExpiredEvent, stackitem.NewArray([]stackitem.Item{
|
|
stackitem.NewBigInteger(big.NewInt(int64(proposalID))),
|
|
stackitem.NewByteArray([]byte("quorum not met")),
|
|
}))
|
|
return stackitem.NewBool(false)
|
|
}
|
|
|
|
// Check threshold (yes votes vs yes+no, abstentions don't count toward threshold)
|
|
totalDecisiveVotes := proposal.YesVotes + proposal.NoVotes
|
|
var supportPercent uint64 = 0
|
|
if totalDecisiveVotes > 0 {
|
|
supportPercent = (proposal.YesVotes * 100) / totalDecisiveVotes
|
|
}
|
|
|
|
if totalDecisiveVotes == 0 || supportPercent < uint64(proposal.ThresholdPercent) {
|
|
proposal.Status = state.ProposalStatusRejected
|
|
|
|
if err := e.putProposal(ic.DAO, proposal); err != nil {
|
|
panic(err)
|
|
}
|
|
e.updateStatusIndex(ic.DAO, proposalID, oldStatus, proposal.Status)
|
|
|
|
ic.AddNotification(e.Hash, ProposalRejectedEvent, stackitem.NewArray([]stackitem.Item{
|
|
stackitem.NewBigInteger(big.NewInt(int64(proposalID))),
|
|
stackitem.NewBigInteger(big.NewInt(int64(supportPercent))),
|
|
}))
|
|
return stackitem.NewBool(false)
|
|
}
|
|
|
|
// Proposal passed
|
|
proposal.Status = state.ProposalStatusPassed
|
|
|
|
if err := e.putProposal(ic.DAO, proposal); err != nil {
|
|
panic(err)
|
|
}
|
|
e.updateStatusIndex(ic.DAO, proposalID, oldStatus, proposal.Status)
|
|
|
|
ic.AddNotification(e.Hash, ProposalPassedEvent, stackitem.NewArray([]stackitem.Item{
|
|
stackitem.NewBigInteger(big.NewInt(int64(proposalID))),
|
|
stackitem.NewBigInteger(big.NewInt(int64(supportPercent))),
|
|
stackitem.NewBigInteger(big.NewInt(int64(proposal.TotalVotes))),
|
|
}))
|
|
|
|
return stackitem.NewBool(true)
|
|
}
|
|
|
|
// executeProposal executes a passed proposal after the execution delay.
|
|
func (e *Eligere) executeProposal(ic *interop.Context, args []stackitem.Item) stackitem.Item {
|
|
proposalID := toBigInt(args[0]).Uint64()
|
|
|
|
caller := ic.VM.GetCallingScriptHash()
|
|
|
|
proposal := e.getProposalInternal(ic.DAO, proposalID)
|
|
if proposal == nil {
|
|
panic(ErrProposalNotFound)
|
|
}
|
|
|
|
// Must have passed
|
|
if proposal.Status != state.ProposalStatusPassed {
|
|
panic(ErrProposalNotPassed)
|
|
}
|
|
|
|
// Check execution delay has passed
|
|
executionBlock := proposal.VotingEndsAt + proposal.ExecutionDelay
|
|
if ic.Block.Index < executionBlock {
|
|
panic(ErrExecutionDelayNotPassed)
|
|
}
|
|
|
|
// Already executed check
|
|
if proposal.ExecutedAt > 0 {
|
|
panic(ErrProposalAlreadyExecuted)
|
|
}
|
|
|
|
oldStatus := proposal.Status
|
|
|
|
// Execute based on category
|
|
switch proposal.Category {
|
|
case state.ProposalCategoryLawAmendment:
|
|
// Call Lex.ratifyAmendment if Lex is available
|
|
if e.Lex != nil {
|
|
e.executeLawAmendment(ic, proposal)
|
|
}
|
|
case state.ProposalCategoryConstitutional:
|
|
// Constitutional amendments also go through Lex
|
|
if e.Lex != nil {
|
|
e.executeLawAmendment(ic, proposal)
|
|
}
|
|
case state.ProposalCategoryGovernanceAction:
|
|
// Governance actions may call target contract
|
|
// Implementation depends on specific action
|
|
case state.ProposalCategoryInvestment:
|
|
// Investment proposals - future integration
|
|
case state.ProposalCategoryReferendum:
|
|
// Referendums are advisory, no automatic execution
|
|
}
|
|
|
|
// Update proposal status
|
|
proposal.Status = state.ProposalStatusExecuted
|
|
proposal.ExecutedAt = ic.Block.Index
|
|
proposal.ExecutedBy = caller
|
|
|
|
if err := e.putProposal(ic.DAO, proposal); err != nil {
|
|
panic(err)
|
|
}
|
|
e.updateStatusIndex(ic.DAO, proposalID, oldStatus, proposal.Status)
|
|
|
|
// Emit event
|
|
ic.AddNotification(e.Hash, ProposalExecutedEvent, stackitem.NewArray([]stackitem.Item{
|
|
stackitem.NewBigInteger(big.NewInt(int64(proposalID))),
|
|
stackitem.NewByteArray(caller.BytesBE()),
|
|
stackitem.NewBigInteger(big.NewInt(int64(ic.Block.Index))),
|
|
}))
|
|
|
|
return stackitem.NewBool(true)
|
|
}
|
|
|
|
// executeLawAmendment calls Lex to ratify a law amendment.
|
|
func (e *Eligere) executeLawAmendment(ic *interop.Context, proposal *state.Proposal) {
|
|
// The Lex contract's ratifyAmendment method will be called
|
|
// This creates or updates a law based on the proposal
|
|
if e.Lex != nil {
|
|
// All amendments create Federal-level laws
|
|
// Constitutional rights (RightLife, RightLiberty, etc.) are immutable in code
|
|
// Constitutional proposals require 67% supermajority but still create Federal laws
|
|
lawCategory := state.LawCategoryFederal
|
|
|
|
// Call Lex to ratify the amendment
|
|
e.Lex.RatifyAmendmentInternal(ic, proposal.ID, proposal.ContentHash, lawCategory, 0)
|
|
}
|
|
}
|
|
|
|
// ============ Query Methods ============
|
|
|
|
func (e *Eligere) getProposal(ic *interop.Context, args []stackitem.Item) stackitem.Item {
|
|
proposalID := toBigInt(args[0]).Uint64()
|
|
|
|
proposal := e.getProposalInternal(ic.DAO, proposalID)
|
|
if proposal == nil {
|
|
return stackitem.Null{}
|
|
}
|
|
|
|
item, err := proposal.ToStackItem()
|
|
if err != nil {
|
|
return stackitem.Null{}
|
|
}
|
|
return item
|
|
}
|
|
|
|
func (e *Eligere) hasVoted(ic *interop.Context, args []stackitem.Item) stackitem.Item {
|
|
proposalID := toBigInt(args[0]).Uint64()
|
|
voter := toUint160(args[1])
|
|
|
|
// Get voter's Vita token
|
|
token, err := e.Vita.GetTokenByOwner(ic.DAO, voter)
|
|
if err != nil || token == nil {
|
|
return stackitem.NewBool(false)
|
|
}
|
|
|
|
return stackitem.NewBool(e.hasVotedInternal(ic.DAO, proposalID, token.TokenID))
|
|
}
|
|
|
|
func (e *Eligere) getVote(ic *interop.Context, args []stackitem.Item) stackitem.Item {
|
|
proposalID := toBigInt(args[0]).Uint64()
|
|
voter := toUint160(args[1])
|
|
|
|
// Get voter's Vita token
|
|
token, err := e.Vita.GetTokenByOwner(ic.DAO, voter)
|
|
if err != nil || token == nil {
|
|
return stackitem.Null{}
|
|
}
|
|
|
|
vote := e.getVoteInternal(ic.DAO, proposalID, token.TokenID)
|
|
if vote == nil {
|
|
return stackitem.Null{}
|
|
}
|
|
|
|
item, err := vote.ToStackItem()
|
|
if err != nil {
|
|
return stackitem.Null{}
|
|
}
|
|
return item
|
|
}
|
|
|
|
func (e *Eligere) getProposalCount(ic *interop.Context, args []stackitem.Item) stackitem.Item {
|
|
key := []byte{eligerePrefixProposalCounter}
|
|
count := getIntWithKey(e.ID, ic.DAO, key)
|
|
return stackitem.NewBigInteger(big.NewInt(count))
|
|
}
|
|
|
|
func (e *Eligere) getConfig(ic *interop.Context, args []stackitem.Item) stackitem.Item {
|
|
cfg := e.getConfigInternal(ic.DAO)
|
|
item, err := cfg.ToStackItem()
|
|
if err != nil {
|
|
return stackitem.Null{}
|
|
}
|
|
return item
|
|
}
|