diff --git a/examples/gno.land/r/demo/gnodaos/gnodao.gno b/examples/gno.land/r/demo/gnodaos/gnodao.gno new file mode 100644 index 00000000000..6d829dab5c4 --- /dev/null +++ b/examples/gno.land/r/demo/gnodaos/gnodao.gno @@ -0,0 +1,578 @@ +package gnodao + +import ( + "gno.land/p/demo/avl" + fmt "gno.land/p/demo/ufmt" + "std" + "strconv" + "strings" + "time" +) + +type VoteOption uint32 + +const ( + YES VoteOption = 0 // Indicates approval of the proposal in its current form. + NO VoteOption = 1 // Indicates disapproval of the proposal in its current form. + NO_WITH_VETO VoteOption = 2 // Indicates stronger opposition to the proposal than simply voting No. Not available for SuperMajority-typed proposals as a simple No of 1/3 out of total votes would result in the same outcome. + ABSTAIN VoteOption = 3 // Indicates that the voter is impartial to the outcome of the proposal. Although Abstain votes are counted towards the quorum, they're excluded when calculating the ratio of other voting options above. +) + +// GNODAO VOTE +type Vote struct { + address std.Address // address of the voter + timestamp uint64 // block timestamp of the vote + option VoteOption // vote option +} + +type DAO struct { + id uint64 + uri string // DAO homepage link + metadata string // DAO metadata reference link + funds uint64 // DAO managing funds + depositHistory []string // deposit history - reserved for later use + spendHistory []string // spend history - reserved for later use + permissions []string // permissions managed on DAO - reserved for later use + permMap *avl.Tree // permission map - reserved for later use + votingPowers *avl.Tree + totalVotingPower uint64 + votingPeriod uint64 + voteQuorum uint64 + threshold uint64 + vetoThreshold uint64 +} + +type ProposalStatus uint32 + +const ( + NIL ProposalStatus = 0 + VOTING_PERIOD ProposalStatus = 1 + PASSED ProposalStatus = 2 + REJECTED ProposalStatus = 3 + FAILED ProposalStatus = 4 +) + +func (s ProposalStatus) String() string { + switch s { + case NIL: + return "Nil" + case VOTING_PERIOD: + return "VotingPeriod" + case PASSED: + return "Passed" + case REJECTED: + return "Rejected" + case FAILED: + return "Failed" + } + return "" +} + +type VotingPower struct { + address string + power uint64 +} + +type Proposal struct { + daoId uint64 // dao id of the proposal + id uint64 // unique id assigned for each proposal + title string // proposal title + summary string // proposal summary + spendAmount uint64 // amount of tokens to spend as part the proposal + spender std.Address // address to receive spending tokens + vpUpdates []VotingPower // updates on voting power - optional + newMetadata string // new metadata for the DAO - optional + newURI string // new URI for the DAO - optional + submitTime uint64 // proposal submission time + voteEndTime uint64 // vote end time for the proposal + status ProposalStatus // StatusNil | StatusVotingPeriod | StatusPassed | StatusRejected | StatusFailed + votes *avl.Tree // votes on the proposal + votingPowers []uint64 // voting power sum per voting option +} + +// GNODAO STATE +var daos []DAO +var proposals [][]Proposal + +func getDAOVotingPower(daoId uint64, address string) uint64 { + if len(daos) <= int(daoId) { + return 0 + } + res, ok := daos[daoId].votingPowers.Get(address) + if ok { + return res.(uint64) + } + return 0 +} + +func IsDAOMember(daoId uint64, address std.Address) bool { + return getDAOVotingPower(daoId, address.String()) > 0 +} + +func getVote(daoId, proposalId uint64, address std.Address) (Vote, bool) { + if int(daoId) >= len(daos) { + return Vote{}, false + } + + if int(proposalId) >= len(proposals[daoId]) { + return Vote{}, false + } + + vote, ok := proposals[daoId][proposalId].votes.Get(address.String()) + if ok { + return vote.(Vote), true + } + return Vote{}, false +} + +func parseVotingPowers(daoMembers, votingPowers string) []VotingPower { + parsedVPs := []VotingPower{} + if len(daoMembers) == 0 { + return parsedVPs + } + memberAddrs := strings.Split(daoMembers, ",") + memberPowers := strings.Split(votingPowers, ",") + if len(memberAddrs) != len(memberPowers) { + panic("mismatch between members and voting powers count") + } + for i, memberAddr := range memberAddrs { + power, err := strconv.Atoi(memberPowers[i]) + if err != nil { + panic(err) + } + parsedVPs = append(parsedVPs, VotingPower{ + address: memberAddr, + power: uint64(power), + }) + } + return parsedVPs +} + +// GNODAO FUNCTIONS +func CreateDAO( + uri string, + metadata string, + daoMembers string, + votingPowers string, + votingPeriod uint64, + voteQuorum uint64, + threshold uint64, + vetoThreshold uint64, +) { + daoId := uint64(len(daos)) + daos = append(daos, DAO{ + id: daoId, + uri: uri, + metadata: metadata, + funds: 0, + depositHistory: []string{}, + spendHistory: []string{}, + permissions: []string{}, + permMap: avl.NewTree(), + votingPowers: avl.NewTree(), + totalVotingPower: 0, + votingPeriod: votingPeriod, + voteQuorum: voteQuorum, + threshold: threshold, + vetoThreshold: vetoThreshold, + }) + + parsedVPs := parseVotingPowers(daoMembers, votingPowers) + totalVotingPower := uint64(0) + for _, vp := range parsedVPs { + daos[daoId].votingPowers.Set(vp.address, vp.power) + totalVotingPower += vp.power + } + daos[daoId].totalVotingPower = totalVotingPower + proposals = append(proposals, []Proposal{}) + // TODO: emit events +} + +func CreateProposal( + daoId uint64, + title, summary string, + spendAmount uint64, spender std.Address, + daoMembers string, + vpUpdates string, + newMetadata string, + newURI string, +) { + caller := std.GetOrigCaller() + + // if sender is not a dao member, revert + isCallerDaoMember := IsDAOMember(daoId, caller) + if !isCallerDaoMember { + panic("caller is not a dao member") + } + + parsedVPUpdates := parseVotingPowers(daoMembers, vpUpdates) + proposals[daoId] = append(proposals[daoId], Proposal{ + daoId: daoId, + id: uint64(len(proposals[daoId])), + title: title, + summary: summary, + spendAmount: spendAmount, + spender: spender, + vpUpdates: parsedVPUpdates, + newMetadata: newMetadata, + newURI: newURI, + submitTime: uint64(time.Now().Unix()), + voteEndTime: uint64(time.Now().Unix()) + daos[daoId].votingPeriod, + status: VOTING_PERIOD, + votes: avl.NewTree(), + votingPowers: []uint64{0, 0, 0, 0}, // initiate as zero for 4 vote types + }) +} + +func VoteProposal(daoId, proposalId uint64, option VoteOption) { + caller := std.GetOrigCaller() + + // if sender is not a dao member, revert + isCallerDaoMember := IsDAOMember(daoId, caller) + if !isCallerDaoMember { + panic("caller is not a gnodao member") + } + + // if invalid proposal, panic + if int(proposalId) >= len(proposals[daoId]) { + panic("invalid proposal id") + } + + // if vote end time is reached panic + if time.Now().Unix() > int64(proposals[daoId][proposalId].voteEndTime) { + panic("vote end time reached") + } + + // Original vote cancel + callerVotingPower := getDAOVotingPower(daoId, caller.String()) + vote, ok := getVote(daoId, proposalId, caller) + if ok { + if proposals[daoId][proposalId].votingPowers[int(vote.option)] > callerVotingPower { + proposals[daoId][proposalId].votingPowers[int(vote.option)] -= callerVotingPower + } else { + proposals[daoId][proposalId].votingPowers[int(vote.option)] = 0 + } + } + + // Create a vote + proposals[daoId][proposalId].votes.Set(caller.String(), Vote{ + address: caller, + timestamp: uint64(time.Now().Unix()), + option: option, + }) + + // Voting power by option update for new vote + proposals[daoId][proposalId].votingPowers[int(option)] += callerVotingPower +} + +// TODO: handle voting power change during voting period for other proposal +// TODO: experiment with gas limit +func TallyAndExecute(daoId, proposalId uint64) { + caller := std.GetOrigCaller() + + // if sender is not a dao member, revert + isCallerDaoMember := IsDAOMember(daoId, caller) + if !isCallerDaoMember { + panic("caller is not a gnodao member") + } + + // validation for proposalId + if int(proposalId) >= len(proposals[daoId]) { + panic("invalid proposal id") + } + dao := daos[daoId] + proposal := proposals[daoId][proposalId] + votingPowers := proposal.votingPowers + + if time.Now().Unix() < int64(proposal.voteEndTime) { + panic("proposal is in voting period") + } + + // reference logic for tally - https://github.com/cosmos/cosmos-sdk/blob/main/x/gov/keeper/tally.go + totalVotes := votingPowers[YES] + votingPowers[NO] + votingPowers[NO_WITH_VETO] + votingPowers[ABSTAIN] + if totalVotes < dao.totalVotingPower*dao.voteQuorum/100 { + proposals[daoId][proposalId].status = REJECTED + } + + // If no one votes (everyone abstains), proposal rejected + if totalVotes == votingPowers[ABSTAIN] { + proposals[daoId][proposalId].status = REJECTED + } + + // If more than 1/3 of voters veto, proposal rejected + vetoThreshold := dao.vetoThreshold + if votingPowers[NO_WITH_VETO] > totalVotes*vetoThreshold/100 { + proposals[daoId][proposalId].status = REJECTED + } + + // If more than 1/2 of non-abstaining voters vote Yes, proposal passes + threshold := dao.threshold + if votingPowers[YES] > (totalVotes-votingPowers[ABSTAIN])*threshold/100 { + proposals[daoId][proposalId].status = PASSED + + // TODO: spend coins when spendAmount is positive & spender is a valid address + if proposal.spendAmount > 0 { + if daos[daoId].funds >= proposal.spendAmount { + daos[daoId].funds -= proposal.spendAmount + } else { + proposals[daoId][proposalId].status = FAILED + return + } + } + + if proposal.newMetadata != "" { + daos[daoId].metadata = proposal.newMetadata + } + + if proposal.newURI != "" { + daos[daoId].uri = proposal.newURI + } + + for _, vp := range proposal.vpUpdates { + daos[daoId].totalVotingPower -= getDAOVotingPower(daoId, vp.address) + daos[daoId].votingPowers.Set(vp.address, vp.power) + daos[daoId].totalVotingPower += vp.power + } + + // TODO: contract does not own account that can hold coins - this is one of limitations + // TODO: Adena Wallet from OnBloc - investigate on how they manage coins (swap - custody?) + // Manual sending for funds (Address <-> Address) - Miloš Živković + // https://github.com/gnolang/gno/blob/e392ab51bc05a5efbceaa8dbe395bac2e01ad808/tm2/pkg/crypto/keys/client/send.go#L109-L119 + return + } + + // If more than 1/2 of non-abstaining voters vote No, proposal rejected + proposals[daoId][proposalId].status = REJECTED +} + +func DepositDAO(daoId uint64, amount uint64) { + caller := std.GetOrigCaller() + + // if sender is not a dao member, revert + isCallerDaoMember := IsDAOMember(daoId, caller) + if !isCallerDaoMember { + panic("caller is not a gnodao member") + } + + // TODO: send coins from caller to DAO + // TODO: verify received amount + // daos[daoId].depositHistory = append(daos[daoId].depositHistory, Deposit{ + // address: caller, + // amount: amount, + // }) +} + +func GetDAO(daoId uint64) DAO { + if int(daoId) >= len(daos) { + panic("invalid dao id") + } + return daos[daoId] +} + +func GetDAOs(startAfter, limit uint64) []DAO { + max := uint64(len(daos)) + if startAfter+limit < max { + max = startAfter + limit + } + return daos[startAfter:max] +} + +func GetProposal(daoId, proposalId uint64) Proposal { + if int(daoId) >= len(daos) { + panic("invalid dao id") + } + if int(proposalId) >= len(proposals[daoId]) { + panic("invalid proposal id") + } + return proposals[daoId][proposalId] +} + +func GetProposals(daoId, startAfter, limit uint64) []Proposal { + if int(daoId) >= len(daos) { + panic("invalid dao id") + } + max := uint64(len(proposals[daoId])) + if startAfter+limit < max { + max = startAfter + limit + } + return proposals[daoId][startAfter:max] +} + +func RenderVote(daoId, proposalId uint64, address std.Address) string { + vote, found := getVote(daoId, proposalId, address) + if !found { + return "" + } + + return fmt.Sprintf(`{ + "address": "%s", + "timestamp": %d, + "option": %d +}`, vote.address.String(), vote.timestamp, vote.option) +} + +type DAOEncode struct { + id uint64 + uri string // DAO homepage link + metadata string // DAO metadata reference link + funds uint64 // DAO managing funds + totalVotingPower uint64 + votingPeriod uint64 + voteQuorum uint64 + threshold uint64 + vetoThreshold uint64 +} + +type ProposalEncode struct { + daoId uint64 + id uint64 + title string + summary string + spendAmount uint64 + spender std.Address + vpUpdates []VotingPower + newMetadata string + newURI string + submitTime uint64 + voteEndTime uint64 + status ProposalStatus + votingPowers []uint64 +} + +func GetDAOEncodeObject(dao DAO) DAOEncode { + return DAOEncode{ + id: dao.id, + uri: dao.uri, + metadata: dao.metadata, + funds: dao.funds, + totalVotingPower: dao.totalVotingPower, + votingPeriod: dao.votingPeriod, + voteQuorum: dao.voteQuorum, + threshold: dao.threshold, + vetoThreshold: dao.vetoThreshold, + } +} + +func GetProposalEncodeObject(p Proposal) ProposalEncode { + return ProposalEncode{ + daoId: p.daoId, + id: p.id, + title: p.title, + summary: p.summary, + spendAmount: p.spendAmount, + spender: p.spender, + vpUpdates: p.vpUpdates, + newMetadata: p.newMetadata, + newURI: p.newURI, + submitTime: p.submitTime, + voteEndTime: p.voteEndTime, + status: p.status, + votingPowers: p.votingPowers, + } +} + +func RenderDAO(daoId uint64) string { + daoEncode := GetDAOEncodeObject(GetDAO(daoId)) + + return fmt.Sprintf(`{ + "id": %d, + "uri": "%s", + "metadata": "%s", + "funds": %d, + "totalVotingPower": %d, + "votingPeriod": %d, + "voteQuorum": %d, + "threshold": %d, + "vetoThreshold": %d +}`, daoEncode.id, daoEncode.uri, daoEncode.metadata, daoEncode.funds, daoEncode.totalVotingPower, daoEncode.votingPeriod, daoEncode.voteQuorum, daoEncode.threshold, daoEncode.vetoThreshold) +} + +func RenderDAOMembers(daoId uint64, start string, end string) string { + dao := GetDAO(daoId) + votingPowers := []VotingPower{} + dao.votingPowers.Iterate(start, end, func(tree *avl.Tree) bool { + power := tree.Value().(uint64) + votingPowers = append(votingPowers, VotingPower{ + address: tree.Key(), + power: power, + }) + return false + }) + + rendered := "[" + for index, votingPower := range votingPowers { + rendered += fmt.Sprintf(`{ + "address": "%s", + "power": %d +}`, votingPower.address, votingPower.power) + if index != len(votingPowers)-1 { + rendered += ",\n" + } + } + rendered += "]" + return rendered +} + +func RenderDAOs(startAfter, limit uint64) string { + daos := GetDAOs(startAfter, limit) + daoEncodes := []DAOEncode{} + rendered := "[" + for index, dao := range daos { + rendered += RenderDAO(dao.id) + if index != len(daos)-1 { + rendered += ",\n" + } + } + rendered += "]" + return rendered +} + +func RenderProposal(daoId, proposalId uint64) string { + p := GetProposalEncodeObject(GetProposal(daoId, proposalId)) + vpUpdatesRendered := "[" + for index, vpUpdate := range p.vpUpdates { + vpUpdatesRendered += fmt.Sprintf(`{ + "address": "%s", + "power": %d +}`, vpUpdate.address, vpUpdate.power) + if index != len(p.vpUpdates)-1 { + vpUpdatesRendered += ",\n" + } + } + vpUpdatesRendered += "]" + + votingPowersBySumRendered := fmt.Sprintf(`[%d, %d, %d, %d]`, p.votingPowers[0], p.votingPowers[1], p.votingPowers[2], p.votingPowers[3]) + + return fmt.Sprintf(`{ + "daoId": %d, + "id": %d, + "title": "%s", + "summary": "%s", + "spendAmount": %d, + "spender": "%s", + "newMetadata": "%s", + "newURI": "%s", + "submitTime": %d, + "voteEndTime": %d, + "status": %d, + "vpUpdates": %s, + "votingPowers": %s +}`, p.daoId, p.id, p.title, p.summary, p.spendAmount, p.spender.String(), p.newMetadata, p.newURI, p.submitTime, p.voteEndTime, int(p.status), vpUpdatesRendered, votingPowersBySumRendered) +} + +func RenderProposals(daoId, startAfter, limit uint64) string { + proposals := GetProposals(daoId, startAfter, limit) + rendered := "[" + for index, proposal := range proposals { + rendered += RenderProposal(proposal.daoId, proposal.id) + if index != len(proposals)-1 { + rendered += ",\n" + } + } + rendered += "]" + return rendered +} + +func Render(path string) string { + return "" +} diff --git a/examples/gno.land/r/demo/gnodaos/gnodao_public_testnet.sh b/examples/gno.land/r/demo/gnodaos/gnodao_public_testnet.sh new file mode 100644 index 00000000000..8f5678091ef --- /dev/null +++ b/examples/gno.land/r/demo/gnodaos/gnodao_public_testnet.sh @@ -0,0 +1,104 @@ +#!/bin/sh + +gnokey add gopher +- addr: g1x2xyqca98auaw9lnat2h9ycd4lx3w0jer9vjmt + +GOPHER=g1x2xyqca98auaw9lnat2h9ycd4lx3w0jer9vjmt + +# check balance +gnokey query bank/balances/$GOPHER -remote="test3.gno.land:36657" + +gnokey maketx addpkg \ + -deposit="1ugnot" \ + -gas-fee="1ugnot" \ + -gas-wanted="5000000" \ + -broadcast="true" \ + -remote="test3.gno.land:36657" \ + -chainid="test3" \ + -pkgdir="./r/gnodao" \ + -pkgpath="gno.land/r/demo/gnodao_v05" \ + gopher + +# Create DAO +gnokey maketx call \ + -gas-fee="1ugnot" \ + -gas-wanted="5000000" \ + -broadcast="true" \ + -remote="test3.gno.land:36657" \ + -chainid="test3" \ + -pkgpath="gno.land/r/demo/gnodao_v05" \ + -func="CreateDAO" \ + -args="https://gnodao1.org" \ + -args="https://metadata.gnodao1.org" \ + -args=$GOPHER \ + -args="1" \ + -args="40" \ + -args="30" \ + -args="10" \ + -args="10" \ + gopher + +# Create Proposal +gnokey maketx call \ + -gas-fee="1ugnot" \ + -gas-wanted="5000000" \ + -broadcast="true" \ + -remote="test3.gno.land:36657" \ + -chainid="test3" \ + -pkgpath="gno.land/r/demo/gnodao_v05" \ + -func="CreateProposal" \ + -args=0 \ + -args="First proposal" \ + -args="First proposal summary" \ + -args=0 \ + -args=$GOPHER \ + -args="" \ + -args="" \ + -args="https://metadata.gnodao1.com" \ + -args="https://gnodao1.com" \ + gopher + +# Vote Proposal +gnokey maketx call \ + -gas-fee="1ugnot" \ + -gas-wanted="5000000" \ + -broadcast="true" \ + -remote="test3.gno.land:36657" \ + -chainid="test3" \ + -pkgpath="gno.land/r/demo/gnodao_v05" \ + -func="VoteProposal" \ + -args=0 \ + -args=0 \ + -args=0 \ + gopher + +# Tally and execute +gnokey maketx call \ + -gas-fee="1ugnot" \ + -gas-wanted="5000000" \ + -broadcast="true" \ + -remote="test3.gno.land:36657" \ + -chainid="test3" \ + -pkgpath="gno.land/r/demo/gnodao_v05" \ + -func="TallyAndExecute" \ + -args=0 \ + -args=0 \ + gopher + +# Query DAOs +gnokey query "vm/qeval" -data="gno.land/r/demo/gnodao_v05 +RenderDAOs(0, 10)" -remote="test3.gno.land:36657" + +# Query DAO +gnokey query "vm/qeval" -data="gno.land/r/demo/gnodao_v05 +RenderDAO(0)" -remote="test3.gno.land:36657" + +# Query Proposal +gnokey query "vm/qeval" -data="gno.land/r/demo/gnodao_v05 +RenderProposal(0, 0)" -remote="test3.gno.land:36657" + +gnokey query "vm/qeval" -data="gno.land/r/demo/gnodao_v05 +RenderProposals(0, 0,10)" -remote="test3.gno.land:36657" + +gnokey query "vm/qeval" -data='gno.land/r/demo/gnodao_v05 +RenderDAOMembers(0, "", "zz")' -remote="test3.gno.land:36657" diff --git a/examples/gno.land/r/demo/gnodaos/gnodao_test.gno b/examples/gno.land/r/demo/gnodaos/gnodao_test.gno new file mode 100644 index 00000000000..fa462c1c6fd --- /dev/null +++ b/examples/gno.land/r/demo/gnodaos/gnodao_test.gno @@ -0,0 +1,433 @@ +package gnodao + +import ( + "fmt" + "std" + "strings" + "testing" + "time" +) + +var caller std.Address = "g1rel7980x4y257yh30umy3jx223efwakvnabcde" +var caller1 std.Address = "g1rel7980x4y257yh30umy3jx223efwakvnaaaaa" +var caller2 std.Address = "g1rel7980x4y257yh30umy3jx223efwakvnbbbbb" +var daoMembers = []string{ + "g1rel7980x4y257yh30umy3jx223efwakvnaaaaa", + "g1rel7980x4y257yh30umy3jx223efwakvnbbbbb", + "g1rel7980x4y257yh30umy3jx223efwakvnccccc", +} +var daoMembersPacked = strings.Join(daoMembers, ",") +var votingPowersPacked = "1,2,3" + +func assertPanic(t *testing.T, f func()) { + defer func() { + if r := recover(); r == nil { + t.Errorf("The code did not panic") + } + }() + f() +} + +func TestIsDAOMember(t *testing.T) { + daos = []DAO{} + CreateDAO( + "https://gnodao1.org", + "https://metadata.gnodao1.org", + daoMembersPacked, + votingPowersPacked, + 86400*2, + 30, + 10, + 10, + ) + + // TODO: what package is ideal to use for checks? + if IsDAOMember(0, caller) != false { + t.Errorf("Should not be false") + } + if IsDAOMember(0, caller1) != true { + t.Errorf("Should be true") + } + if IsDAOMember(0, caller2) != true { + t.Errorf("Should be true") + } +} + +func TestCreateDAO(t *testing.T) { + daos = []DAO{} + proposals = [][]Proposal{} + CreateDAO( + "https://gnodao1.org", + "https://metadata.gnodao1.org", + daoMembersPacked, + votingPowersPacked, + 86400*2, + 30, + 10, + 10, + ) + if len(daos) != 1 { + t.Errorf("Number of daos be 1") + } + if len(proposals) != 1 { + t.Errorf("Number of daos be 1") + } + dao := daos[0] + if dao.id != 0 { + t.Errorf("first DAO id should be 0") + } + if dao.uri != "https://gnodao1.org" { + t.Errorf("dao uri not set properly") + } + if dao.metadata != "https://metadata.gnodao1.org" { + t.Errorf("dao metadata not set properly") + } + if dao.funds != 0 { + t.Errorf("dao funds not set properly") + } + if len(dao.depositHistory) != 0 { + t.Errorf("dao deposit history not set properly") + } + if len(dao.spendHistory) != 0 { + t.Errorf("dao spend history not set properly") + } + if len(dao.permissions) != 0 { + t.Errorf("dao permissions not set properly") + } + if dao.permMap == nil { + t.Errorf("dao permission map not set properly") + } + if getDAOVotingPower(0, caller.String()) != 0 { + t.Errorf("voting power not set properly") + } + if getDAOVotingPower(0, caller1.String()) != 1 { + t.Errorf("voting power not set properly") + } + if getDAOVotingPower(0, caller2.String()) != 2 { + t.Errorf("voting power not set properly") + } + if dao.totalVotingPower != 6 { + t.Errorf("totalVotingPower not set properly") + } + if dao.votingPeriod != 86400*2 { + t.Errorf("votingPeriod not set properly") + } + if dao.voteQuorum != 30 { + t.Errorf("voteQuorum not set properly") + } + if dao.threshold != 10 { + t.Errorf("threshold not set properly") + } + if dao.vetoThreshold != 10 { + t.Errorf("vetoThreshold not set properly") + } +} + +func TestCreateProposal(t *testing.T) { + daos = []DAO{} + proposals = [][]Proposal{} + CreateDAO("https://gnodao1.org", "https://metadata.gnodao1.org", daoMembersPacked, votingPowersPacked, 86400*2, 30, 10, 10) + + assertPanic(t, func() { + std.TestSetOrigCaller(caller) + CreateProposal(0, "DAO fund bootstrap proposal", "Proposal to bootstrap DAO fund.", 0, caller, "", "", "", "") + }) + + std.TestSetOrigCaller(caller1) + CreateProposal(0, "DAO fund bootstrap proposal", "Proposal to bootstrap DAO fund.", 0, caller, "", "", "", "") + proposal := proposals[0][0] + if proposal.daoId != 0 { + t.Errorf("proposal daoId should be 0") + } + if proposal.id != 0 { + t.Errorf("proposal id should be 0") + } + if proposal.title != "DAO fund bootstrap proposal" { + t.Errorf("proposal title not set properly") + } + if proposal.summary != "Proposal to bootstrap DAO fund." { + t.Errorf("proposal summary not set properly") + } + if proposal.spendAmount != 0 { + t.Errorf("proposal spendAmount not set properly") + } + if proposal.spender != caller { + t.Errorf("proposal spender not set properly") + } + if len(proposal.vpUpdates) != 0 { + t.Errorf("proposal vpUpdates not set properly") + } + if proposal.newMetadata != "" { + t.Errorf("proposal newMetadata not set properly") + } + if proposal.newURI != "" { + t.Errorf("proposal newURI not set properly") + } + if proposal.submitTime != uint64(time.Now().Unix()) { + t.Errorf("proposal submitTime not set properly") + } + if proposal.voteEndTime != uint64(time.Now().Unix())+daos[0].votingPeriod { + t.Errorf("proposal voteEndTime not set properly") + } + if proposal.status != VOTING_PERIOD { + t.Errorf("proposal status not set properly") + } + if proposal.votes == nil { + t.Errorf("proposal votes not set properly") + } + if len(proposal.votingPowers) != 4 { + t.Errorf("proposal votingPowers not set properly") + } +} + +func TestVoteProposal(t *testing.T) { + daos = []DAO{} + proposals = [][]Proposal{} + CreateDAO("https://gnodao1.org", "https://metadata.gnodao1.org", daoMembersPacked, votingPowersPacked, 86400*2, 30, 10, 10) + std.TestSetOrigCaller(caller1) + CreateProposal(0, "DAO fund bootstrap proposal", "Proposal to bootstrap DAO fund.", 0, caller, "", "", "", "") + assertPanic(t, func() { // invalid dao id + std.TestSetOrigCaller(caller1) + VoteProposal(1, 0, YES) + }) + assertPanic(t, func() { // invalid proposal id + std.TestSetOrigCaller(caller1) + VoteProposal(0, 1, YES) + }) + assertPanic(t, func() { // not dao member + std.TestSetOrigCaller(caller) + VoteProposal(0, 0, YES) + }) + + // vote and check result is set properly + std.TestSetOrigCaller(caller1) + VoteProposal(0, 0, YES) + vote, found := getVote(0, 0, caller1) + if !found { + t.Errorf("proposal vote not set") + } + if vote.address != caller1 { + t.Errorf("vote address not set properly") + } + if vote.timestamp != uint64(time.Now().Unix()) { + t.Errorf("vote timestamp not set properly") + } + if vote.option != YES { + t.Errorf("vote option not set properly") + } + if proposals[0][0].votingPowers[int(YES)] != 1 { + t.Errorf("votePowers by vote option not set properly") + } + + // vote again with different option and check result + VoteProposal(0, 0, NO) + vote, found = getVote(0, 0, caller1) + if vote.option != NO { + t.Errorf("vote option not set properly") + } + if proposals[0][0].votingPowers[int(YES)] != 0 { + t.Errorf("votePowers for YES not set properly") + } + if proposals[0][0].votingPowers[int(NO)] != 1 { + t.Errorf("votePowers for NO not set properly") + } + + // test vote end time already reached + assertPanic(t, func() { // not dao member + std.TestSetOrigCaller(caller) + proposals[0][0].voteEndTime = uint64(time.Now().Unix()) - 1 + VoteProposal(0, 0, YES) + }) +} + +func TestTallyAndExecute(t *testing.T) { + daos = []DAO{} + proposals = [][]Proposal{} + CreateDAO("https://gnodao1.org", "https://metadata.gnodao1.org", daoMembersPacked, votingPowersPacked, 86400*2, 30, 10, 10) + std.TestSetOrigCaller(caller1) + CreateProposal(0, "DAO fund bootstrap proposal", "Proposal to bootstrap DAO fund.", 0, caller, caller.String(), "1", "newMetadata.com", "newURI.com") + assertPanic(t, func() { // invalid dao id + std.TestSetOrigCaller(caller1) + TallyAndExecute(1, 0) + }) + assertPanic(t, func() { // invalid proposal id + std.TestSetOrigCaller(caller1) + TallyAndExecute(0, 1) + }) + assertPanic(t, func() { // not dao member + std.TestSetOrigCaller(caller) + TallyAndExecute(0, 0) + }) + assertPanic(t, func() { // vote end time not pass + std.TestSetOrigCaller(caller1) + TallyAndExecute(0, 0) + }) + + // vote end time to be reached + proposals[0][0].voteEndTime = uint64(time.Now().Unix()) - 1 + + // quorum not reached + std.TestSetOrigCaller(caller1) + TallyAndExecute(0, 0) + if proposals[0][0].status != REJECTED { + t.Errorf("proposal should be REJECTED for vote quorum") + } + + // everyone abstains + proposals[0][0].votingPowers[ABSTAIN] = daos[0].totalVotingPower + std.TestSetOrigCaller(caller1) + TallyAndExecute(0, 0) + if proposals[0][0].status != REJECTED { + t.Errorf("proposal should be REJECTED for all abstains") + } + + // more than 1/3 vote with NO_WITH_VETO + proposals[0][0].votingPowers[ABSTAIN] = daos[0].totalVotingPower / 2 + proposals[0][0].votingPowers[NO_WITH_VETO] = daos[0].totalVotingPower / 2 + std.TestSetOrigCaller(caller1) + TallyAndExecute(0, 0) + if proposals[0][0].status != REJECTED { + t.Errorf("proposal should be REJECTED for NO_WITH_VETO") + } + + // all YES vote + proposals[0][0].votingPowers[ABSTAIN] = 0 + proposals[0][0].votingPowers[NO_WITH_VETO] = 0 + proposals[0][0].votingPowers[YES] = daos[0].totalVotingPower + std.TestSetOrigCaller(caller1) + TallyAndExecute(0, 0) + if proposals[0][0].status != PASSED { + t.Errorf("proposal should be PASSED") + } + if getDAOVotingPower(0, caller.String()) != 1 { + t.Errorf("voting power not set properly") + } + if daos[0].metadata != "newMetadata.com" { + t.Errorf("metadata not set properly") + } + if daos[0].uri != "newURI.com" { + t.Errorf("uri not set properly") + } +} + +func TestDepositDAO(t *testing.T) { + daos = []DAO{} + proposals = [][]Proposal{} + CreateDAO("https://gnodao1.org", "https://metadata.gnodao1.org", daoMembersPacked, votingPowersPacked, 86400*2, 30, 10, 10) + + // panic when not a dao member + assertPanic(t, func() { + std.TestSetOrigCaller(caller) + DepositDAO(0, 100) + }) + + // not panics + std.TestSetOrigCaller(caller1) + DepositDAO(0, 100) +} + +func TestGetDAO(t *testing.T) { + daos = []DAO{} + proposals = [][]Proposal{} + CreateDAO("https://gnodao1.org", "https://metadata.gnodao1.org", daoMembersPacked, votingPowersPacked, 86400*2, 30, 10, 10) + + // panic when invalid dao id + assertPanic(t, func() { + std.TestSetOrigCaller(caller) + GetDAO(100) + }) + + // success when valid dao id + dao := GetDAO(0) + if dao.uri != "https://gnodao1.org" { + t.Errorf("uri not set properly") + } +} + +func TestGetDAOs(t *testing.T) { + daos = []DAO{} + proposals = [][]Proposal{} + gotDaos := GetDAOs(0, 10) + if len(gotDaos) != 0 { + t.Errorf("invalid number of daos") + } + CreateDAO("https://gnodao1.org", "https://metadata.gnodao1.org", daoMembersPacked, votingPowersPacked, 86400*2, 30, 10, 10) + CreateDAO("https://gnodao2.org", "https://metadata.gnodao2.org", daoMembersPacked, votingPowersPacked, 86400*2, 30, 10, 10) + + gotDaos = GetDAOs(0, 0) + if len(gotDaos) != 0 { + t.Errorf("invalid number of daos") + } + + gotDaos = GetDAOs(0, 10) + if len(gotDaos) != 2 { + t.Errorf("invalid number of daos") + } + + gotDaos = GetDAOs(0, 1) + if len(gotDaos) != 1 { + t.Errorf("invalid number of daos") + } +} + +func TestGetProposal(t *testing.T) { + daos = []DAO{} + proposals = [][]Proposal{} + CreateDAO("https://gnodao1.org", "https://metadata.gnodao1.org", daoMembersPacked, votingPowersPacked, 86400*2, 30, 10, 10) + std.TestSetOrigCaller(caller1) + CreateProposal(0, "DAO fund bootstrap proposal", "Proposal to bootstrap DAO fund.", 0, caller, "", "", "newMetadata.com", "newURI.com") + + // panic when invalid dao id + assertPanic(t, func() { + GetProposal(1, 0) + }) + + // panic when invalid proposal id + assertPanic(t, func() { + GetProposal(0, 1) + }) + + // success when valid dao id and proposal id + proposal := GetProposal(0, 0) + if proposal.title != "DAO fund bootstrap proposal" { + t.Errorf("title not set properly") + } +} + +func TestGetProposals(t *testing.T) { + daos = []DAO{} + proposals = [][]Proposal{} + assertPanic(t, func() { // invalid dao id + GetProposals(0, 0, 10) + }) + CreateDAO("https://gnodao1.org", "https://metadata.gnodao1.org", daoMembersPacked, votingPowersPacked, 86400*2, 30, 10, 10) + std.TestSetOrigCaller(caller1) + CreateProposal(0, "proposal #1", "Proposal to bootstrap DAO fund.", 0, caller, "", "", "newMetadata.com", "newURI.com") + CreateProposal(0, "proposal #2", "Proposal to bootstrap DAO fund.", 0, caller, "", "", "newMetadata.com", "newURI.com") + + gotProposals := GetProposals(0, 0, 0) + if len(gotProposals) != 0 { + t.Errorf("invalid number of proposals") + } + + gotProposals = GetProposals(0, 0, 10) + if len(gotProposals) != 2 { + t.Errorf("invalid number of proposals") + } + + gotProposals = GetProposals(0, 0, 1) + if len(gotProposals) != 1 { + t.Errorf("invalid number of proposals") + } + + renderedProposals = RenderProposals(0, 0, 1) + if renderedProposals != "" { + t.Errorf("invalid proposal rendering") + } +} + +func TestRender(t *testing.T) { + if Render("") != "" { + t.Errorf("Render function should empty") + } +} diff --git a/examples/gno.land/r/demo/gnodaos/spec.md b/examples/gno.land/r/demo/gnodaos/spec.md new file mode 100644 index 00000000000..ce86074a8f1 --- /dev/null +++ b/examples/gno.land/r/demo/gnodaos/spec.md @@ -0,0 +1,95 @@ +# GnoDAOs Specs for v0.1 + +## Concept + +The goal of `GnoDAOs` is to support the creation and maintenance of DAOs on Gnoland as in Aragon on Ethereum or DAODAO on Juno. + +### DAO v0.1 : + +Initial version focuses on building minimum functionalities for DAOs management, and more features will be built after on. + +### Moderation DAO v0.1: + +Must allow community to moderate /boards or a feed in a decentralized way. + +### Target #1: + +1. Create a module called Moderation DAO v0.1 on Gno.land +2. Test the Moderation DAO v0.1 +3. Integrate Moderation DAO v0.1 +4. Open PR for changes & updates on gnoland/moderationdao + +### Others permanent needs: + +- Participate to Gnoland Dev Calls +- Redact a final article explaining Moderation DAO +- Redact a tutorial about Moderation DAO +- Redact full test documentation about Moderation DAO +- Integrate Gnoland in Teritori dApp +- Inform regularly all contributors about current works + +--- + +## DAO v0.1 info + +```go +type DAO struct{ + id uint64 + uri string // DAO homepage link + metadata string // DAO metadata reference link + funds Coins // DAO managing funds + depositHistory []Deposit // deposit history - reserved for later use + spendHistory []Spend // spend history - reserved for later use + permissions []string // permissions managed on DAO - reserved for later use + permMap map[string]map[string]bool // permission map - reserved for later use + votingPowers map[string]uint64 + totalVotingPower uint64 + voteQuorum uint64 + threshold uint64 + vetoThreshold uint64 +} +``` + +```go +type Proposal struct{ + daoId uint64 // dao id of the proposal + id uint64 // unique id assigned for each proposal + title string // proposal title + summary string // proposal summary + submitTime uint64 // proposal submission time + voteEndTime uint64 // vote end time for the proposal + status uint64 // StatusNil | StatusVotingPeriod | StatusPassed | StatusRejected | StatusFailed + votes map[string]Vote // votes on the proposal +} +``` + +## Proposal types + +- Text proposal +- DAO fund spend proposal +- Update Voting power proposal +- Update URI proposal +- Update metadata proposal + +## Vote options + +- `Yes`: Indicates approval of the proposal in its current form. +- `No`: Indicates disapproval of the proposal in its current form. +- `NoWithVeto`: Indicates stronger opposition to the proposal than simply voting No. Not available for SuperMajority-typed proposals as a simple No of 1/3 out of total votes would result in the same outcome. +- `Abstain`: Indicates that the voter is impartial to the outcome of the proposal. Although Abstain votes are counted towards the quorum, they're excluded when calculating the ratio of other voting options above. + +## Entrypoints + +### Txs + +- CreateDAO +- DepositIntoDAO +- SubmitDAOProposal +- VoteDAOProposal +- TallyAndExecuteDAOProposal + +### Queries + +- QueryDAO +- QueryDAOProposal +- QueryDAOProposals