diff --git a/pkg/core/blockchain.go b/pkg/core/blockchain.go index 7336e01..c5784e8 100644 --- a/pkg/core/blockchain.go +++ b/pkg/core/blockchain.go @@ -222,6 +222,7 @@ type Blockchain struct { federation native.IFederation treasury native.ITreasury lex native.ILex + eligere native.IEligere extensible atomic.Value @@ -485,6 +486,10 @@ func NewBlockchain(s storage.Store, cfg config.Blockchain, log *zap.Logger, newN if err := validateNative(bc.lex, nativeids.Lex, nativenames.Lex, nativehashes.Lex); err != nil { return nil, err } + bc.eligere = bc.contracts.Eligere() + if err := validateNative(bc.eligere, nativeids.Eligere, nativenames.Eligere, nativehashes.Eligere); err != nil { + return nil, err + } bc.persistCond = sync.NewCond(&bc.lock) bc.gcBlockTimes, _ = lru.New[uint32, uint64](defaultBlockTimesCache) // Never errors for positive size diff --git a/pkg/core/native/contract.go b/pkg/core/native/contract.go index 7b6fa1f..0f8c824 100644 --- a/pkg/core/native/contract.go +++ b/pkg/core/native/contract.go @@ -122,6 +122,8 @@ type ( // IsAdultVerified checks if the owner has a verified "age_verified" attribute // indicating they are 18+ years old. Used for age-restricted purchases. IsAdultVerified(d *dao.Simple, owner util.Uint160) bool + // GetTotalTokenCount returns total number of Vita tokens (for quorum calculation). + GetTotalTokenCount(d *dao.Simple) uint64 } // IRoleRegistry is an interface required from native RoleRegistry contract @@ -209,6 +211,19 @@ type ( CheckLibertyRight(d *dao.Simple, subject util.Uint160, blockHeight uint32) bool // Address returns the contract's script hash. Address() util.Uint160 + // RatifyAmendmentInternal is called by Eligere when a law amendment passes. + RatifyAmendmentInternal(ic *interop.Context, proposalID uint64, contentHash util.Uint256, category state.LawCategory, jurisdiction uint32) uint64 + } + + // IEligere is an interface required from native Eligere contract for + // interaction with Blockchain and other native contracts. + // Eligere provides democratic voting infrastructure. + IEligere interface { + interop.Contract + // GetProposalInternal returns a proposal by ID. + GetProposalInternal(d *dao.Simple, proposalID uint64) *state.Proposal + // Address returns the contract's script hash. + Address() util.Uint160 } ) @@ -361,6 +376,12 @@ func (cs *Contracts) Lex() ILex { return cs.ByName(nativenames.Lex).(ILex) } +// Eligere returns native IEligere contract implementation. It panics if +// there's no contract with proper name in cs. +func (cs *Contracts) Eligere() IEligere { + return cs.ByName(nativenames.Eligere).(IEligere) +} + // NewDefaultContracts returns a new set of default native contracts. func NewDefaultContracts(cfg config.ProtocolConfiguration) []interop.Contract { mgmt := NewManagement() @@ -447,6 +468,13 @@ func NewDefaultContracts(cfg config.ProtocolConfiguration) []interop.Contract { gas.Federation = federation gas.Treasury = treasury + // Create Eligere (Democratic Voting) contract + eligere := newEligere() + eligere.NEO = neo + eligere.Vita = vita + eligere.RoleRegistry = roleRegistry + eligere.Lex = lex + return []interop.Contract{ mgmt, s, @@ -464,5 +492,6 @@ func NewDefaultContracts(cfg config.ProtocolConfiguration) []interop.Contract { vts, federation, lex, + eligere, } } diff --git a/pkg/core/native/eligere.go b/pkg/core/native/eligere.go new file mode 100644 index 0000000..8ce7fdd --- /dev/null +++ b/pkg/core/native/eligere.go @@ -0,0 +1,947 @@ +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") + 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 + + NEO INEO + Vita IVita + RoleRegistry IRoleRegistry + Lex ILex +} + +// 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.NEO == nil { + return false + } + return e.NEO.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) + } + + // 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 { + // Determine law category based on proposal category + lawCategory := state.LawCategoryFederal + if proposal.Category == state.ProposalCategoryConstitutional { + // Note: Constitutional rights are immutable in Lex + // This would create a constitutional-level law, not modify core rights + lawCategory = state.LawCategoryConstitutional + } + + // Call Lex to ratify the amendment + // The Lex contract needs to have ratifyAmendment method added + 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 +} diff --git a/pkg/core/native/lex.go b/pkg/core/native/lex.go index e056e05..7e297e0 100644 --- a/pkg/core/native/lex.go +++ b/pkg/core/native/lex.go @@ -3,6 +3,7 @@ package native import ( "encoding/binary" "errors" + "fmt" "math/big" "github.com/tutus-one/tutus-chain/pkg/config" @@ -933,3 +934,51 @@ func (l *Lex) CheckMovementRight(d *dao.Simple, subject util.Uint160, blockHeigh func (l *Lex) CheckLibertyRight(d *dao.Simple, subject util.Uint160, blockHeight uint32) bool { return l.HasRightInternal(d, subject, state.RightLiberty, blockHeight) } + +// RatifyAmendmentInternal creates a new law from a passed Eligere proposal. +// This is called by the Eligere contract when a law amendment proposal passes. +// Returns the new law ID. +func (l *Lex) RatifyAmendmentInternal(ic *interop.Context, proposalID uint64, contentHash util.Uint256, category state.LawCategory, jurisdiction uint32) uint64 { + // Category validation + if category < state.LawCategoryFederal || category > state.LawCategoryAdministrative { + panic(ErrInvalidLawCategory) + } + // Cannot create constitutional laws via amendment - they are immutable + if category == state.LawCategoryConstitutional { + panic(ErrCannotModifyConst) + } + + // Create law from ratified proposal + lawID := l.getLawCounter(ic.DAO) + name := fmt.Sprintf("Amendment_%d", proposalID) + + law := &state.Law{ + ID: lawID, + Name: name, + ContentHash: contentHash, + Category: category, + Jurisdiction: jurisdiction, + ParentID: 0, + EffectiveAt: ic.Block.Index, + ExpiresAt: 0, // Perpetual by default + EnactedAt: ic.Block.Index, + EnactedBy: ic.VM.GetCallingScriptHash(), + Status: state.LawStatusActive, + SupersededBy: 0, + RequiresVita: true, + Enforcement: state.EnforcementAutomatic, + } + + l.putLaw(ic.DAO, law) + l.putLawNameIndex(ic.DAO, name, lawID) + l.putLawCounter(ic.DAO, lawID+1) + + ic.AddNotification(l.Hash, LawEnactedEvent, stackitem.NewArray([]stackitem.Item{ + stackitem.NewBigInteger(big.NewInt(int64(lawID))), + stackitem.NewByteArray([]byte(name)), + stackitem.NewBigInteger(big.NewInt(int64(category))), + stackitem.NewByteArray(law.EnactedBy.BytesBE()), + })) + + return lawID +} diff --git a/pkg/core/native/management_neotest_test.go b/pkg/core/native/management_neotest_test.go index c743366..a27b4c8 100644 --- a/pkg/core/native/management_neotest_test.go +++ b/pkg/core/native/management_neotest_test.go @@ -24,8 +24,9 @@ func TestManagement_GetNEP17Contracts(t *testing.T) { bc, validators, committee := chain.NewMulti(t) e := neotest.NewExecutor(t, bc, validators, committee) + // Native NEP17 contracts: NEO, GAS, and VTS require.ElementsMatch(t, []util.Uint160{e.NativeHash(t, nativenames.Neo), - e.NativeHash(t, nativenames.Gas)}, bc.GetNEP17Contracts()) + e.NativeHash(t, nativenames.Gas), e.NativeHash(t, nativenames.VTS)}, bc.GetNEP17Contracts()) }) t.Run("basic chain", func(t *testing.T) { @@ -33,8 +34,9 @@ func TestManagement_GetNEP17Contracts(t *testing.T) { e := neotest.NewExecutor(t, bc, validators, committee) basicchain.Init(t, "../../../", e) + // Native NEP17 contracts: NEO, GAS, VTS + deployed contract require.ElementsMatch(t, []util.Uint160{e.NativeHash(t, nativenames.Neo), - e.NativeHash(t, nativenames.Gas), e.ContractHash(t, 1)}, bc.GetNEP17Contracts()) + e.NativeHash(t, nativenames.Gas), e.NativeHash(t, nativenames.VTS), e.ContractHash(t, 1)}, bc.GetNEP17Contracts()) }) } diff --git a/pkg/core/native/native_test/management_test.go b/pkg/core/native/native_test/management_test.go index 7ab4ae4..bde2057 100644 --- a/pkg/core/native/native_test/management_test.go +++ b/pkg/core/native/native_test/management_test.go @@ -55,6 +55,7 @@ var ( nativenames.RoleRegistry: `{"id":-13,"hash":"0x52200161c6f0b581b590d41af8ccc577dc7477a9","nef":{"magic":860243278,"compiler":"neo-core-v3.0","source":"","tokens":[],"script":"EEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQA==","checksum":174904780},"manifest":{"name":"RoleRegistry","abi":{"methods":[{"name":"assignPermission","offset":0,"parameters":[{"name":"roleID","type":"Integer"},{"name":"resource","type":"String"},{"name":"action","type":"String"},{"name":"scope","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"createRole","offset":7,"parameters":[{"name":"name","type":"String"},{"name":"description","type":"String"},{"name":"parentID","type":"Integer"}],"returntype":"Integer","safe":false},{"name":"deleteRole","offset":14,"parameters":[{"name":"roleID","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"getPermissions","offset":21,"parameters":[{"name":"roleID","type":"Integer"}],"returntype":"Array","safe":true},{"name":"getRole","offset":28,"parameters":[{"name":"roleID","type":"Integer"}],"returntype":"Array","safe":true},{"name":"getRoleByName","offset":35,"parameters":[{"name":"name","type":"String"}],"returntype":"Array","safe":true},{"name":"getRolesForAddress","offset":42,"parameters":[{"name":"address","type":"Hash160"}],"returntype":"Array","safe":true},{"name":"grantRole","offset":49,"parameters":[{"name":"address","type":"Hash160"},{"name":"roleID","type":"Integer"},{"name":"expiresAt","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"hasPermission","offset":56,"parameters":[{"name":"address","type":"Hash160"},{"name":"resource","type":"String"},{"name":"action","type":"String"},{"name":"scope","type":"Integer"}],"returntype":"Boolean","safe":true},{"name":"hasRole","offset":63,"parameters":[{"name":"address","type":"Hash160"},{"name":"roleID","type":"Integer"}],"returntype":"Boolean","safe":true},{"name":"removePermission","offset":70,"parameters":[{"name":"roleID","type":"Integer"},{"name":"resource","type":"String"},{"name":"action","type":"String"}],"returntype":"Boolean","safe":false},{"name":"revokeRole","offset":77,"parameters":[{"name":"address","type":"Hash160"},{"name":"roleID","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"totalRoles","offset":84,"parameters":[],"returntype":"Integer","safe":true}],"events":[{"name":"RoleCreated","parameters":[{"name":"roleID","type":"Integer"},{"name":"name","type":"String"},{"name":"parentID","type":"Integer"},{"name":"createdBy","type":"Hash160"}]},{"name":"RoleDeleted","parameters":[{"name":"roleID","type":"Integer"},{"name":"deletedBy","type":"Hash160"}]},{"name":"RoleGranted","parameters":[{"name":"address","type":"Hash160"},{"name":"roleID","type":"Integer"},{"name":"expiresAt","type":"Integer"},{"name":"grantedBy","type":"Hash160"}]},{"name":"RoleRevoked","parameters":[{"name":"address","type":"Hash160"},{"name":"roleID","type":"Integer"},{"name":"revokedBy","type":"Hash160"}]},{"name":"PermissionAssigned","parameters":[{"name":"roleID","type":"Integer"},{"name":"resource","type":"String"},{"name":"action","type":"String"},{"name":"scope","type":"Integer"}]},{"name":"PermissionRemoved","parameters":[{"name":"roleID","type":"Integer"},{"name":"resource","type":"String"},{"name":"action","type":"String"}]}]},"features":{},"groups":[],"permissions":[{"contract":"*","methods":"*"}],"supportedstandards":[],"trusts":[],"extra":null},"updatecounter":0}`, nativenames.VTS: `{"id":-14,"hash":"0x893659b7f9d0a383d960234841ff8a6d825e3468","nef":{"magic":860243278,"compiler":"neo-core-v3.0","source":"","tokens":[],"script":"EEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0A=","checksum":3508376793},"manifest":{"name":"VTS","abi":{"methods":[{"name":"balanceDetails","offset":0,"parameters":[{"name":"account","type":"Hash160"}],"returntype":"Array","safe":true},{"name":"balanceOf","offset":7,"parameters":[{"name":"account","type":"Hash160"}],"returntype":"Integer","safe":true},{"name":"burn","offset":14,"parameters":[{"name":"from","type":"Hash160"},{"name":"amount","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"canSpendAt","offset":21,"parameters":[{"name":"account","type":"Hash160"},{"name":"vendor","type":"Hash160"},{"name":"amount","type":"Integer"}],"returntype":"Boolean","safe":true},{"name":"convertToUnrestricted","offset":28,"parameters":[{"name":"account","type":"Hash160"},{"name":"category","type":"Integer"},{"name":"amount","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"deactivateVendor","offset":35,"parameters":[{"name":"address","type":"Hash160"}],"returntype":"Boolean","safe":false},{"name":"decimals","offset":42,"parameters":[],"returntype":"Integer","safe":true},{"name":"getDeductibleExpenses","offset":49,"parameters":[{"name":"account","type":"Hash160"},{"name":"startBlock","type":"Integer"},{"name":"endBlock","type":"Integer"},{"name":"category","type":"Integer"}],"returntype":"Integer","safe":true},{"name":"getIncomeForPeriod","offset":56,"parameters":[{"name":"account","type":"Hash160"},{"name":"startBlock","type":"Integer"},{"name":"endBlock","type":"Integer"}],"returntype":"Integer","safe":true},{"name":"getTaxConfig","offset":63,"parameters":[],"returntype":"Array","safe":true},{"name":"getTaxSummary","offset":70,"parameters":[{"name":"account","type":"Hash160"},{"name":"startBlock","type":"Integer"},{"name":"endBlock","type":"Integer"}],"returntype":"Array","safe":true},{"name":"getTaxWithheld","offset":77,"parameters":[{"name":"account","type":"Hash160"},{"name":"startBlock","type":"Integer"},{"name":"endBlock","type":"Integer"}],"returntype":"Integer","safe":true},{"name":"getTransactions","offset":84,"parameters":[{"name":"account","type":"Hash160"},{"name":"startBlock","type":"Integer"},{"name":"endBlock","type":"Integer"}],"returntype":"Array","safe":true},{"name":"getVendor","offset":91,"parameters":[{"name":"address","type":"Hash160"}],"returntype":"Array","safe":true},{"name":"getVendorCategories","offset":98,"parameters":[{"name":"address","type":"Hash160"}],"returntype":"Integer","safe":true},{"name":"isVendor","offset":105,"parameters":[{"name":"address","type":"Hash160"}],"returntype":"Boolean","safe":true},{"name":"issueTaxRefund","offset":112,"parameters":[{"name":"account","type":"Hash160"},{"name":"amount","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"mint","offset":119,"parameters":[{"name":"to","type":"Hash160"},{"name":"amount","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"mintRestricted","offset":126,"parameters":[{"name":"to","type":"Hash160"},{"name":"amount","type":"Integer"},{"name":"category","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"payWage","offset":133,"parameters":[{"name":"employer","type":"Hash160"},{"name":"employee","type":"Hash160"},{"name":"grossAmount","type":"Integer"},{"name":"taxRate","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"registerVendor","offset":140,"parameters":[{"name":"address","type":"Hash160"},{"name":"name","type":"String"},{"name":"categories","type":"Integer"},{"name":"ageRestricted","type":"Boolean"}],"returntype":"Boolean","safe":false},{"name":"restrictedBalanceOf","offset":147,"parameters":[{"name":"account","type":"Hash160"},{"name":"category","type":"Integer"}],"returntype":"Integer","safe":true},{"name":"setTaxConfig","offset":154,"parameters":[{"name":"incomeRate","type":"Integer"},{"name":"salesRate","type":"Integer"},{"name":"treasuryAddress","type":"Hash160"},{"name":"exemptCategories","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"spend","offset":161,"parameters":[{"name":"from","type":"Hash160"},{"name":"vendor","type":"Hash160"},{"name":"amount","type":"Integer"},{"name":"data","type":"Any"}],"returntype":"Boolean","safe":false},{"name":"symbol","offset":168,"parameters":[],"returntype":"String","safe":true},{"name":"totalSupply","offset":175,"parameters":[],"returntype":"Integer","safe":true},{"name":"transfer","offset":182,"parameters":[{"name":"from","type":"Hash160"},{"name":"to","type":"Hash160"},{"name":"amount","type":"Integer"},{"name":"data","type":"Any"}],"returntype":"Boolean","safe":false},{"name":"unrestrictedBalanceOf","offset":189,"parameters":[{"name":"account","type":"Hash160"}],"returntype":"Integer","safe":true},{"name":"updateVendor","offset":196,"parameters":[{"name":"address","type":"Hash160"},{"name":"name","type":"String"},{"name":"categories","type":"Integer"},{"name":"ageRestricted","type":"Boolean"}],"returntype":"Boolean","safe":false}],"events":[{"name":"Transfer","parameters":[{"name":"from","type":"Hash160"},{"name":"to","type":"Hash160"},{"name":"amount","type":"Integer"}]},{"name":"Mint","parameters":[{"name":"to","type":"Hash160"},{"name":"amount","type":"Integer"},{"name":"category","type":"Integer"}]},{"name":"Burn","parameters":[{"name":"from","type":"Hash160"},{"name":"amount","type":"Integer"}]},{"name":"Spend","parameters":[{"name":"from","type":"Hash160"},{"name":"vendor","type":"Hash160"},{"name":"amount","type":"Integer"},{"name":"categoriesUsed","type":"Integer"}]},{"name":"VendorRegistered","parameters":[{"name":"address","type":"Hash160"},{"name":"name","type":"String"},{"name":"categories","type":"Integer"},{"name":"ageRestricted","type":"Boolean"}]},{"name":"VendorUpdated","parameters":[{"name":"address","type":"Hash160"},{"name":"name","type":"String"},{"name":"categories","type":"Integer"},{"name":"ageRestricted","type":"Boolean"}]},{"name":"VendorDeactivated","parameters":[{"name":"address","type":"Hash160"}]},{"name":"TaxWithheld","parameters":[{"name":"from","type":"Hash160"},{"name":"to","type":"Hash160"},{"name":"grossAmount","type":"Integer"},{"name":"taxAmount","type":"Integer"},{"name":"taxRate","type":"Integer"}]},{"name":"TaxRefunded","parameters":[{"name":"account","type":"Hash160"},{"name":"amount","type":"Integer"}]},{"name":"ConvertedToUnrestricted","parameters":[{"name":"account","type":"Hash160"},{"name":"category","type":"Integer"},{"name":"amount","type":"Integer"}]}]},"features":{},"groups":[],"permissions":[{"contract":"*","methods":"*"}],"supportedstandards":["NEP-17"],"trusts":[],"extra":null},"updatecounter":0}`, nativenames.Lex: `{"id":-16,"hash":"0xe696922df6c6ded4c468bec2b1ef170805b73f2e","nef":{"magic":860243278,"compiler":"neo-core-v3.0","source":"","tokens":[],"script":"EEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQA==","checksum":1841570703},"manifest":{"name":"Lex","abi":{"methods":[{"name":"enactLaw","offset":0,"parameters":[{"name":"name","type":"String"},{"name":"contentHash","type":"Hash256"},{"name":"category","type":"Integer"},{"name":"jurisdiction","type":"Integer"},{"name":"parentID","type":"Integer"},{"name":"effectiveAt","type":"Integer"},{"name":"enforcement","type":"Integer"}],"returntype":"Integer","safe":false},{"name":"getAllConstitutionalRights","offset":7,"parameters":[],"returntype":"Array","safe":true},{"name":"getConstitutionalRight","offset":14,"parameters":[{"name":"rightID","type":"Integer"}],"returntype":"Array","safe":true},{"name":"getLaw","offset":21,"parameters":[{"name":"lawID","type":"Integer"}],"returntype":"Array","safe":true},{"name":"getLawByName","offset":28,"parameters":[{"name":"name","type":"String"}],"returntype":"Array","safe":true},{"name":"getLawCount","offset":35,"parameters":[],"returntype":"Integer","safe":true},{"name":"getRestriction","offset":42,"parameters":[{"name":"subject","type":"Hash160"},{"name":"rightID","type":"Integer"}],"returntype":"Array","safe":true},{"name":"getSubjectRestrictions","offset":49,"parameters":[{"name":"subject","type":"Hash160"}],"returntype":"Array","safe":true},{"name":"hasRight","offset":56,"parameters":[{"name":"subject","type":"Hash160"},{"name":"rightID","type":"Integer"}],"returntype":"Boolean","safe":true},{"name":"isLawActive","offset":63,"parameters":[{"name":"lawID","type":"Integer"}],"returntype":"Boolean","safe":true},{"name":"isRestricted","offset":70,"parameters":[{"name":"subject","type":"Hash160"},{"name":"rightID","type":"Integer"}],"returntype":"Boolean","safe":true},{"name":"liftRestriction","offset":77,"parameters":[{"name":"subject","type":"Hash160"},{"name":"rightID","type":"Integer"},{"name":"reason","type":"String"}],"returntype":"Boolean","safe":false},{"name":"reinstateLaw","offset":84,"parameters":[{"name":"lawID","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"repealLaw","offset":91,"parameters":[{"name":"lawID","type":"Integer"},{"name":"reason","type":"String"}],"returntype":"Boolean","safe":false},{"name":"restrictRight","offset":98,"parameters":[{"name":"subject","type":"Hash160"},{"name":"rightID","type":"Integer"},{"name":"restrictionType","type":"Integer"},{"name":"duration","type":"Integer"},{"name":"reason","type":"String"},{"name":"caseID","type":"Hash256"}],"returntype":"Boolean","safe":false},{"name":"suspendLaw","offset":105,"parameters":[{"name":"lawID","type":"Integer"},{"name":"reason","type":"String"},{"name":"duration","type":"Integer"}],"returntype":"Boolean","safe":false}],"events":[{"name":"LawEnacted","parameters":[{"name":"lawID","type":"Integer"},{"name":"name","type":"String"},{"name":"category","type":"Integer"},{"name":"enactedBy","type":"Hash160"}]},{"name":"LawRepealed","parameters":[{"name":"lawID","type":"Integer"},{"name":"reason","type":"String"}]},{"name":"LawSuspended","parameters":[{"name":"lawID","type":"Integer"},{"name":"reason","type":"String"},{"name":"duration","type":"Integer"}]},{"name":"LawReinstated","parameters":[{"name":"lawID","type":"Integer"}]},{"name":"RightRestricted","parameters":[{"name":"subject","type":"Hash160"},{"name":"rightID","type":"Integer"},{"name":"restrictionType","type":"Integer"},{"name":"expiresAt","type":"Integer"},{"name":"caseID","type":"Hash256"}]},{"name":"RestrictionLifted","parameters":[{"name":"subject","type":"Hash160"},{"name":"rightID","type":"Integer"},{"name":"reason","type":"String"}]},{"name":"RightViolation","parameters":[{"name":"subject","type":"Hash160"},{"name":"rightID","type":"Integer"},{"name":"action","type":"String"}]}]},"features":{},"groups":[],"permissions":[{"contract":"*","methods":"*"}],"supportedstandards":[],"trusts":[],"extra":null},"updatecounter":0}`, + nativenames.Eligere: `{"id":-17,"hash":"0x4007e5a8b3d7eace2ae0abffa0c56bab8e739401","nef":{"magic":860243278,"compiler":"neo-core-v3.0","source":"","tokens":[],"script":"EEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQBBBGvd7Z0AQQRr3e2dAEEEa93tnQA==","checksum":2135988409},"manifest":{"name":"Eligere","abi":{"methods":[{"name":"cancelProposal","offset":0,"parameters":[{"name":"proposalID","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"createProposal","offset":7,"parameters":[{"name":"title","type":"String"},{"name":"contentHash","type":"ByteArray"},{"name":"category","type":"Integer"},{"name":"votingStartsAt","type":"Integer"},{"name":"votingEndsAt","type":"Integer"},{"name":"targetContract","type":"Hash160"},{"name":"targetMethod","type":"String"},{"name":"targetParams","type":"ByteArray"}],"returntype":"Integer","safe":false},{"name":"executeProposal","offset":14,"parameters":[{"name":"proposalID","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"getConfig","offset":21,"parameters":[],"returntype":"Array","safe":true},{"name":"getProposal","offset":28,"parameters":[{"name":"proposalID","type":"Integer"}],"returntype":"Array","safe":true},{"name":"getProposalCount","offset":35,"parameters":[],"returntype":"Integer","safe":true},{"name":"getVote","offset":42,"parameters":[{"name":"proposalID","type":"Integer"},{"name":"voter","type":"Hash160"}],"returntype":"Array","safe":true},{"name":"hasVoted","offset":49,"parameters":[{"name":"proposalID","type":"Integer"},{"name":"voter","type":"Hash160"}],"returntype":"Boolean","safe":true},{"name":"tallyVotes","offset":56,"parameters":[{"name":"proposalID","type":"Integer"}],"returntype":"Boolean","safe":false},{"name":"vote","offset":63,"parameters":[{"name":"proposalID","type":"Integer"},{"name":"choice","type":"Integer"}],"returntype":"Boolean","safe":false}],"events":[{"name":"ProposalCreated","parameters":[{"name":"proposalID","type":"Integer"},{"name":"title","type":"String"},{"name":"category","type":"Integer"},{"name":"proposer","type":"Hash160"},{"name":"votingEndsAt","type":"Integer"}]},{"name":"ProposalActivated","parameters":[{"name":"proposalID","type":"Integer"}]},{"name":"VoteCast","parameters":[{"name":"proposalID","type":"Integer"},{"name":"voterVitaID","type":"Integer"},{"name":"choice","type":"Integer"}]},{"name":"ProposalPassed","parameters":[{"name":"proposalID","type":"Integer"},{"name":"supportPercent","type":"Integer"},{"name":"totalVotes","type":"Integer"}]},{"name":"ProposalRejected","parameters":[{"name":"proposalID","type":"Integer"},{"name":"supportPercent","type":"Integer"}]},{"name":"ProposalExecuted","parameters":[{"name":"proposalID","type":"Integer"},{"name":"executor","type":"Hash160"},{"name":"executedAt","type":"Integer"}]},{"name":"ProposalCancelled","parameters":[{"name":"proposalID","type":"Integer"},{"name":"cancelledBy","type":"Hash160"}]},{"name":"ProposalExpired","parameters":[{"name":"proposalID","type":"Integer"},{"name":"reason","type":"String"}]}]},"features":{},"groups":[],"permissions":[{"contract":"*","methods":"*"}],"supportedstandards":[],"trusts":[],"extra":null},"updatecounter":0}`, } // cockatriceCSS holds serialized native contract states built for genesis block (with UpdateCounter 0) // under assumption that hardforks from Aspidochelone to Cockatrice (included) are enabled. @@ -252,7 +253,9 @@ func TestManagement_NativeDeployUpdateNotifications(t *testing.T) { var expected []state.NotificationEvent for _, name := range nativenames.All { switch name { - case nativenames.Notary, nativenames.Treasury: + case nativenames.Notary, nativenames.Treasury, nativenames.Federation: + // Notary and Treasury are not deployed at genesis + // Federation activates at HFFaun, not genesis continue case nativenames.Gas: expected = append(expected, state.NotificationEvent{ @@ -345,13 +348,16 @@ func TestManagement_NativeDeployUpdateNotifications(t *testing.T) { }), }) } - expected = append(expected, state.NotificationEvent{ - ScriptHash: nativehashes.ContractManagement, - Name: "Deploy", - Item: stackitem.NewArray([]stackitem.Item{ - stackitem.Make(nativehashes.Treasury), - }), - }) + // Treasury and Federation both deploy at Faun hardfork + for _, h := range []util.Uint160{nativehashes.Treasury, nativehashes.Federation} { + expected = append(expected, state.NotificationEvent{ + ScriptHash: nativehashes.ContractManagement, + Name: "Deploy", + Item: stackitem.NewArray([]stackitem.Item{ + stackitem.Make(h), + }), + }) + } require.Equal(t, expected, aer[0].Events) } diff --git a/pkg/core/native/native_test/vita_test.go b/pkg/core/native/native_test/vita_test.go index b41cabc..3adb109 100644 --- a/pkg/core/native/native_test/vita_test.go +++ b/pkg/core/native/native_test/vita_test.go @@ -242,6 +242,10 @@ func TestVita_SuspendReinstate(t *testing.T) { invoker := c.WithSigners(acc) committeeInvoker := c.WithSigners(c.Committee) + // Create Lex invoker using same executor for liberty restriction (required for due process) + lexHash := e.NativeHash(t, nativenames.Lex) + lexCommitteeInvoker := e.CommitteeInvoker(lexHash) + // Initially token is active invoker.InvokeAndCheck(t, func(t testing.TB, stack []stackitem.Item) { require.Equal(t, 1, len(stack)) @@ -255,7 +259,18 @@ func TestVita_SuspendReinstate(t *testing.T) { // Non-committee cannot suspend invoker.InvokeFail(t, "invalid committee signature", "suspend", acc.ScriptHash().BytesBE(), "test") - // Committee can suspend + // First, create a liberty restriction via Lex (due process requirement) + caseID := hash.Sha256([]byte("case-123")).BytesBE() // 32-byte case reference + lexCommitteeInvoker.Invoke(t, true, "restrictRight", + acc.ScriptHash().BytesBE(), // subject + int64(state.RightLiberty), // rightID = 2 (Liberty) + int64(state.RestrictionSuspend), // restrictionType = 1 (Suspend) + int64(100), // duration in blocks + "judicial order", // reason + caseID, // caseID (Hash256) + ) + + // Committee can suspend (now with valid Lex restriction order) committeeInvoker.Invoke(t, true, "suspend", acc.ScriptHash().BytesBE(), "test suspension") // Token is now suspended diff --git a/pkg/core/native/native_test/vts_test.go b/pkg/core/native/native_test/vts_test.go index 509e91a..78f886f 100644 --- a/pkg/core/native/native_test/vts_test.go +++ b/pkg/core/native/native_test/vts_test.go @@ -188,6 +188,9 @@ func TestVTS_Transfer(t *testing.T) { committeeInvoker := c.WithSigners(c.Committee) senderInvoker := c.WithSigners(sender) + // Setup: Register Vita for sender (required for property rights/transfers) + registerVitaForVTS(t, e, sender) + // Mint to sender committeeInvoker.Invoke(t, true, "mint", sender.ScriptHash(), 1000_00000000) @@ -280,6 +283,9 @@ func TestVTS_Spend(t *testing.T) { committeeInvoker := c.WithSigners(c.Committee) customerInvoker := c.WithSigners(customer) + // Setup: Register Vita for customer (required for property rights/spending) + registerVitaForVTS(t, e, customer) + // Setup: Register food vendor and mint to customer committeeInvoker.Invoke(t, true, "registerVendor", foodVendor.ScriptHash(), "Food Store", state.CategoryFood, false) committeeInvoker.Invoke(t, true, "mint", customer.ScriptHash(), 300_00000000) @@ -311,6 +317,9 @@ func TestVTS_CanSpendAt(t *testing.T) { vendor := e.NewAccount(t) committeeInvoker := c.WithSigners(c.Committee) + // Setup: Register Vita for customer (required for property rights) + registerVitaForVTS(t, e, customer) + // Setup committeeInvoker.Invoke(t, true, "registerVendor", vendor.ScriptHash(), "Store", state.CategoryFood, false) committeeInvoker.Invoke(t, true, "mint", customer.ScriptHash(), 100_00000000) @@ -403,6 +412,9 @@ func TestVTS_PayWage(t *testing.T) { committeeInvoker := c.WithSigners(c.Committee) employerInvoker := c.WithSigners(employer) + // Setup: Register Vita for employer (required for property rights/transfers) + registerVitaForVTS(t, e, employer) + // Setup: Set tax config and fund employer committeeInvoker.Invoke(t, true, "setTaxConfig", 2500, 0, treasury.ScriptHash(), 0) // 25% income tax committeeInvoker.Invoke(t, true, "mint", employer.ScriptHash(), 10000_00000000) diff --git a/pkg/core/native/nativehashes/hashes.go b/pkg/core/native/nativehashes/hashes.go index 3f674b2..2cfe172 100644 --- a/pkg/core/native/nativehashes/hashes.go +++ b/pkg/core/native/nativehashes/hashes.go @@ -41,4 +41,6 @@ var ( Federation = util.Uint160{0xfd, 0x78, 0x70, 0xb, 0xa9, 0x19, 0xed, 0xb0, 0x19, 0x40, 0xdd, 0xc7, 0x97, 0x48, 0xf4, 0x25, 0x1d, 0xb0, 0x5, 0x1f} // Lex is a hash of native Lex contract. Lex = util.Uint160{0x2e, 0x3f, 0xb7, 0x5, 0x8, 0x17, 0xef, 0xb1, 0xc2, 0xbe, 0x68, 0xc4, 0xd4, 0xde, 0xc6, 0xf6, 0x2d, 0x92, 0x96, 0xe6} + // Eligere is a hash of native Eligere contract. + Eligere = util.Uint160{0x1, 0x94, 0x73, 0x8e, 0xab, 0x6b, 0xc5, 0xa0, 0xff, 0xab, 0xe0, 0x2a, 0xce, 0xea, 0xd7, 0xb3, 0xa8, 0xe5, 0x7, 0x40} ) diff --git a/pkg/core/native/nativeids/ids.go b/pkg/core/native/nativeids/ids.go index d190f30..2d96dc5 100644 --- a/pkg/core/native/nativeids/ids.go +++ b/pkg/core/native/nativeids/ids.go @@ -39,4 +39,6 @@ const ( Federation int32 = -15 // Lex is an ID of native Lex contract. Lex int32 = -16 + // Eligere is an ID of native Eligere contract. + Eligere int32 = -17 ) diff --git a/pkg/core/native/nativenames/names.go b/pkg/core/native/nativenames/names.go index ae9882e..43b2a71 100644 --- a/pkg/core/native/nativenames/names.go +++ b/pkg/core/native/nativenames/names.go @@ -18,6 +18,7 @@ const ( VTS = "VTS" Federation = "Federation" Lex = "Lex" + Eligere = "Eligere" ) // All contains the list of all native contract names ordered by the contract ID. @@ -38,6 +39,7 @@ var All = []string{ VTS, Federation, Lex, + Eligere, } // IsValid checks if the name is a valid native contract's name. @@ -57,5 +59,6 @@ func IsValid(name string) bool { name == RoleRegistry || name == VTS || name == Federation || - name == Lex + name == Lex || + name == Eligere } diff --git a/pkg/core/native/vita.go b/pkg/core/native/vita.go index ea763f3..8967bef 100644 --- a/pkg/core/native/vita.go +++ b/pkg/core/native/vita.go @@ -904,6 +904,11 @@ func (v *Vita) TokenExists(d *dao.Simple, owner util.Uint160) bool { return v.tokenExistsForOwner(d, owner) } +// GetTotalTokenCount returns the total number of tokens issued (for quorum calculations). +func (v *Vita) GetTotalTokenCount(d *dao.Simple) uint64 { + return v.getTokenCounter(d) +} + // IsAdultVerified checks if the owner has a verified "age_verified" attribute // indicating they are 18+ years old. Used for age-restricted purchases. // The attribute must be non-revoked and not expired. diff --git a/pkg/core/state/eligere.go b/pkg/core/state/eligere.go new file mode 100644 index 0000000..e8fa72b --- /dev/null +++ b/pkg/core/state/eligere.go @@ -0,0 +1,419 @@ +package state + +import ( + "errors" + "math/big" + + "github.com/tutus-one/tutus-chain/pkg/util" + "github.com/tutus-one/tutus-chain/pkg/vm/stackitem" +) + +// ProposalCategory represents the type of proposal. +type ProposalCategory uint8 + +// Proposal categories. +const ( + ProposalCategoryLawAmendment ProposalCategory = 1 // Lex integration + ProposalCategoryInvestment ProposalCategory = 2 // PIO/EIO/CIO (future) + ProposalCategoryGovernanceAction ProposalCategory = 3 // Committee/policy changes + ProposalCategoryConstitutional ProposalCategory = 4 // Supermajority required + ProposalCategoryReferendum ProposalCategory = 5 // Citizen-initiated +) + +// ProposalStatus represents the lifecycle status of a proposal. +type ProposalStatus uint8 + +// Proposal statuses. +const ( + ProposalStatusDraft ProposalStatus = 0 // Not yet open for voting + ProposalStatusActive ProposalStatus = 1 // Open for voting + ProposalStatusPassed ProposalStatus = 2 // Met threshold, awaiting execution + ProposalStatusRejected ProposalStatus = 3 // Failed to meet threshold + ProposalStatusExecuted ProposalStatus = 4 // Successfully executed + ProposalStatusCancelled ProposalStatus = 5 // Cancelled by proposer/committee + ProposalStatusExpired ProposalStatus = 6 // Voting period ended without quorum +) + +// VoteChoice represents a voting decision. +type VoteChoice uint8 + +// Vote choices. +const ( + VoteChoiceAbstain VoteChoice = 0 + VoteChoiceYes VoteChoice = 1 + VoteChoiceNo VoteChoice = 2 +) + +// Proposal represents a democratic proposal in the Eligere voting system. +type Proposal struct { + ID uint64 // Unique proposal ID + Title string // Short title (max 128 chars) + ContentHash util.Uint256 // Hash of full proposal content (stored off-chain) + Category ProposalCategory // Type of proposal + Proposer util.Uint160 // Who created the proposal + ProposerVitaID uint64 // Vita ID of proposer + + // Timing (block heights) + CreatedAt uint32 // When created + VotingStartsAt uint32 // When voting opens + VotingEndsAt uint32 // elegireDeadline - when voting closes + ExecutionDelay uint32 // Blocks after passing before execution allowed + + // Thresholds + QuorumPercent uint8 // Minimum participation percentage (e.g., 10%) + ThresholdPercent uint8 // Required yes votes percentage (50% or 67%) + + // Results + Status ProposalStatus + TotalVotes uint64 // Total votes cast + YesVotes uint64 // Votes in favor + NoVotes uint64 // Votes against + AbstainVotes uint64 // Abstentions (count toward quorum) + + // Execution target (for contract integrations like Lex) + TargetContract util.Uint160 // Contract to call on execution + TargetMethod string // Method to call + TargetParams []byte // Serialized parameters + + // Execution tracking + ExecutedAt uint32 // When executed (0 if not) + ExecutedBy util.Uint160 // Who executed +} + +// ToStackItem implements stackitem.Convertible interface. +func (p *Proposal) ToStackItem() (stackitem.Item, error) { + return stackitem.NewStruct([]stackitem.Item{ + stackitem.NewBigInteger(big.NewInt(int64(p.ID))), + stackitem.NewByteArray([]byte(p.Title)), + stackitem.NewByteArray(p.ContentHash[:]), + stackitem.NewBigInteger(big.NewInt(int64(p.Category))), + stackitem.NewByteArray(p.Proposer.BytesBE()), + stackitem.NewBigInteger(big.NewInt(int64(p.ProposerVitaID))), + stackitem.NewBigInteger(big.NewInt(int64(p.CreatedAt))), + stackitem.NewBigInteger(big.NewInt(int64(p.VotingStartsAt))), + stackitem.NewBigInteger(big.NewInt(int64(p.VotingEndsAt))), + stackitem.NewBigInteger(big.NewInt(int64(p.ExecutionDelay))), + stackitem.NewBigInteger(big.NewInt(int64(p.QuorumPercent))), + stackitem.NewBigInteger(big.NewInt(int64(p.ThresholdPercent))), + stackitem.NewBigInteger(big.NewInt(int64(p.Status))), + stackitem.NewBigInteger(big.NewInt(int64(p.TotalVotes))), + stackitem.NewBigInteger(big.NewInt(int64(p.YesVotes))), + stackitem.NewBigInteger(big.NewInt(int64(p.NoVotes))), + stackitem.NewBigInteger(big.NewInt(int64(p.AbstainVotes))), + stackitem.NewByteArray(p.TargetContract.BytesBE()), + stackitem.NewByteArray([]byte(p.TargetMethod)), + stackitem.NewByteArray(p.TargetParams), + stackitem.NewBigInteger(big.NewInt(int64(p.ExecutedAt))), + stackitem.NewByteArray(p.ExecutedBy.BytesBE()), + }), nil +} + +// FromStackItem implements stackitem.Convertible interface. +func (p *Proposal) FromStackItem(item stackitem.Item) error { + arr, ok := item.Value().([]stackitem.Item) + if !ok { + return errors.New("not a struct") + } + if len(arr) < 22 { + return errors.New("invalid proposal struct length") + } + + id, err := arr[0].TryInteger() + if err != nil { + return err + } + p.ID = id.Uint64() + + titleBytes, err := arr[1].TryBytes() + if err != nil { + return err + } + p.Title = string(titleBytes) + + contentHashBytes, err := arr[2].TryBytes() + if err != nil { + return err + } + if len(contentHashBytes) == 32 { + copy(p.ContentHash[:], contentHashBytes) + } + + category, err := arr[3].TryInteger() + if err != nil { + return err + } + p.Category = ProposalCategory(category.Uint64()) + + proposerBytes, err := arr[4].TryBytes() + if err != nil { + return err + } + p.Proposer, _ = util.Uint160DecodeBytesBE(proposerBytes) + + proposerVitaID, err := arr[5].TryInteger() + if err != nil { + return err + } + p.ProposerVitaID = proposerVitaID.Uint64() + + createdAt, err := arr[6].TryInteger() + if err != nil { + return err + } + p.CreatedAt = uint32(createdAt.Uint64()) + + votingStartsAt, err := arr[7].TryInteger() + if err != nil { + return err + } + p.VotingStartsAt = uint32(votingStartsAt.Uint64()) + + votingEndsAt, err := arr[8].TryInteger() + if err != nil { + return err + } + p.VotingEndsAt = uint32(votingEndsAt.Uint64()) + + executionDelay, err := arr[9].TryInteger() + if err != nil { + return err + } + p.ExecutionDelay = uint32(executionDelay.Uint64()) + + quorumPercent, err := arr[10].TryInteger() + if err != nil { + return err + } + p.QuorumPercent = uint8(quorumPercent.Uint64()) + + thresholdPercent, err := arr[11].TryInteger() + if err != nil { + return err + } + p.ThresholdPercent = uint8(thresholdPercent.Uint64()) + + status, err := arr[12].TryInteger() + if err != nil { + return err + } + p.Status = ProposalStatus(status.Uint64()) + + totalVotes, err := arr[13].TryInteger() + if err != nil { + return err + } + p.TotalVotes = totalVotes.Uint64() + + yesVotes, err := arr[14].TryInteger() + if err != nil { + return err + } + p.YesVotes = yesVotes.Uint64() + + noVotes, err := arr[15].TryInteger() + if err != nil { + return err + } + p.NoVotes = noVotes.Uint64() + + abstainVotes, err := arr[16].TryInteger() + if err != nil { + return err + } + p.AbstainVotes = abstainVotes.Uint64() + + targetContractBytes, err := arr[17].TryBytes() + if err != nil { + return err + } + p.TargetContract, _ = util.Uint160DecodeBytesBE(targetContractBytes) + + targetMethodBytes, err := arr[18].TryBytes() + if err != nil { + return err + } + p.TargetMethod = string(targetMethodBytes) + + targetParams, err := arr[19].TryBytes() + if err != nil { + return err + } + p.TargetParams = targetParams + + executedAt, err := arr[20].TryInteger() + if err != nil { + return err + } + p.ExecutedAt = uint32(executedAt.Uint64()) + + executedByBytes, err := arr[21].TryBytes() + if err != nil { + return err + } + p.ExecutedBy, _ = util.Uint160DecodeBytesBE(executedByBytes) + + return nil +} + +// Vote represents a single vote record in the Eligere voting system. +type Vote struct { + ProposalID uint64 // Which proposal + VoterVitaID uint64 // Vita ID of voter (ensures one-person-one-vote) + Voter util.Uint160 // Voter's address + Choice VoteChoice // How they voted + VotedAt uint32 // Block height when voted + Weight uint64 // Vote weight (1 for equal voting) +} + +// ToStackItem implements stackitem.Convertible interface. +func (v *Vote) ToStackItem() (stackitem.Item, error) { + return stackitem.NewStruct([]stackitem.Item{ + stackitem.NewBigInteger(big.NewInt(int64(v.ProposalID))), + stackitem.NewBigInteger(big.NewInt(int64(v.VoterVitaID))), + stackitem.NewByteArray(v.Voter.BytesBE()), + stackitem.NewBigInteger(big.NewInt(int64(v.Choice))), + stackitem.NewBigInteger(big.NewInt(int64(v.VotedAt))), + stackitem.NewBigInteger(big.NewInt(int64(v.Weight))), + }), nil +} + +// FromStackItem implements stackitem.Convertible interface. +func (v *Vote) FromStackItem(item stackitem.Item) error { + arr, ok := item.Value().([]stackitem.Item) + if !ok { + return errors.New("not a struct") + } + if len(arr) < 6 { + return errors.New("invalid vote struct length") + } + + proposalID, err := arr[0].TryInteger() + if err != nil { + return err + } + v.ProposalID = proposalID.Uint64() + + voterVitaID, err := arr[1].TryInteger() + if err != nil { + return err + } + v.VoterVitaID = voterVitaID.Uint64() + + voterBytes, err := arr[2].TryBytes() + if err != nil { + return err + } + v.Voter, _ = util.Uint160DecodeBytesBE(voterBytes) + + choice, err := arr[3].TryInteger() + if err != nil { + return err + } + v.Choice = VoteChoice(choice.Uint64()) + + votedAt, err := arr[4].TryInteger() + if err != nil { + return err + } + v.VotedAt = uint32(votedAt.Uint64()) + + weight, err := arr[5].TryInteger() + if err != nil { + return err + } + v.Weight = weight.Uint64() + + return nil +} + +// EligereConfig represents configurable parameters for the Eligere voting system. +type EligereConfig struct { + DefaultVotingPeriod uint32 // Default blocks for voting (~7 days at 1s blocks = 604800) + MinVotingPeriod uint32 // Minimum blocks (~1 day = 86400) + MaxVotingPeriod uint32 // Maximum blocks (~30 days = 2592000) + DefaultExecutionDelay uint32 // Blocks between passing and execution (~1 day = 86400) + DefaultQuorum uint8 // Default quorum percentage (e.g., 10%) + ConstitutionalThreshold uint8 // Supermajority threshold (e.g., 67%) + StandardThreshold uint8 // Simple majority (e.g., 51%) +} + +// ToStackItem implements stackitem.Convertible interface. +func (c *EligereConfig) ToStackItem() (stackitem.Item, error) { + return stackitem.NewStruct([]stackitem.Item{ + stackitem.NewBigInteger(big.NewInt(int64(c.DefaultVotingPeriod))), + stackitem.NewBigInteger(big.NewInt(int64(c.MinVotingPeriod))), + stackitem.NewBigInteger(big.NewInt(int64(c.MaxVotingPeriod))), + stackitem.NewBigInteger(big.NewInt(int64(c.DefaultExecutionDelay))), + stackitem.NewBigInteger(big.NewInt(int64(c.DefaultQuorum))), + stackitem.NewBigInteger(big.NewInt(int64(c.ConstitutionalThreshold))), + stackitem.NewBigInteger(big.NewInt(int64(c.StandardThreshold))), + }), nil +} + +// FromStackItem implements stackitem.Convertible interface. +func (c *EligereConfig) FromStackItem(item stackitem.Item) error { + arr, ok := item.Value().([]stackitem.Item) + if !ok { + return errors.New("not a struct") + } + if len(arr) < 7 { + return errors.New("invalid config struct length") + } + + defaultVotingPeriod, err := arr[0].TryInteger() + if err != nil { + return err + } + c.DefaultVotingPeriod = uint32(defaultVotingPeriod.Uint64()) + + minVotingPeriod, err := arr[1].TryInteger() + if err != nil { + return err + } + c.MinVotingPeriod = uint32(minVotingPeriod.Uint64()) + + maxVotingPeriod, err := arr[2].TryInteger() + if err != nil { + return err + } + c.MaxVotingPeriod = uint32(maxVotingPeriod.Uint64()) + + defaultExecutionDelay, err := arr[3].TryInteger() + if err != nil { + return err + } + c.DefaultExecutionDelay = uint32(defaultExecutionDelay.Uint64()) + + defaultQuorum, err := arr[4].TryInteger() + if err != nil { + return err + } + c.DefaultQuorum = uint8(defaultQuorum.Uint64()) + + constitutionalThreshold, err := arr[5].TryInteger() + if err != nil { + return err + } + c.ConstitutionalThreshold = uint8(constitutionalThreshold.Uint64()) + + standardThreshold, err := arr[6].TryInteger() + if err != nil { + return err + } + c.StandardThreshold = uint8(standardThreshold.Uint64()) + + return nil +} + +// DefaultEligereConfig returns the default configuration for Eligere. +func DefaultEligereConfig() *EligereConfig { + return &EligereConfig{ + DefaultVotingPeriod: 604800, // ~7 days at 1 block/second + MinVotingPeriod: 86400, // ~1 day minimum + MaxVotingPeriod: 2592000, // ~30 days maximum + DefaultExecutionDelay: 86400, // ~1 day delay after passing + DefaultQuorum: 10, // 10% participation required + ConstitutionalThreshold: 67, // 67% supermajority for constitutional + StandardThreshold: 51, // 51% simple majority + } +}