diff --git a/examples/gno.land/r/demo/justicedao/justicedao.gno b/examples/gno.land/r/demo/justicedao/justicedao.gno new file mode 100644 index 00000000000..84e15ca4ebb --- /dev/null +++ b/examples/gno.land/r/demo/justicedao/justicedao.gno @@ -0,0 +1,739 @@ +package justicedao + +import ( + "gno.land/p/demo/avl" + fmt "gno.land/p/demo/ufmt" + escrow "gno.land/r/demo/escrow_05" + vrf "gno.land/r/demo/vrf_08" + "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 { + 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 + numJusticeDAO uint64 // number of justice DAO members on justice proposal +} + +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 { + 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 +} + +type JusticeProposal struct { + id uint64 // unique id assigned for each proposal + title string // proposal title + summary string // proposal summary + vrfId uint64 // the vrf request id being used to determine governers + governers []string // the governers of the proposal + contractId uint64 // the escrow contract id to resolve + sellerAmount uint64 // the seller amount determined by Justice DAO + solution string // proposed result of justice DAO proposal + submitTime uint64 // solution submission time + voteEndTime uint64 // vote end time for the proposal + status ProposalStatus // StatusNil | StatusVotingPeriod | StatusPassed | StatusRejected | StatusFailed + votes []Vote +} + +// GNODAO STATE +var daoCreated bool +var dao DAO +var proposals []Proposal +var justiceProposals []JusticeProposal + +func getDAOVotingPower(address string) uint64 { + res, ok := dao.votingPowers.Get(address) + if ok { + return res.(uint64) + } + return 0 +} + +func IsDAOMember(address std.Address) bool { + return getDAOVotingPower(address.String()) > 0 +} + +func getVote(proposalId uint64, address std.Address) (Vote, bool) { + if int(proposalId) >= len(proposals) { + return Vote{}, false + } + + vote, ok := proposals[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, + numJusticeDAO uint64, +) { + if daoCreated { + panic("dao already created") + } + dao = DAO{ + 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, + numJusticeDAO: numJusticeDAO, + } + + parsedVPs := parseVotingPowers(daoMembers, votingPowers) + totalVotingPower := uint64(0) + for _, vp := range parsedVPs { + dao.votingPowers.Set(vp.address, vp.power) + totalVotingPower += vp.power + } + dao.totalVotingPower = totalVotingPower + daoCreated = true +} + +func CreateProposal( + 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(caller) + if !isCallerDaoMember { + panic("caller is not a dao member") + } + + parsedVPUpdates := parseVotingPowers(daoMembers, vpUpdates) + proposals = append(proposals, Proposal{ + id: uint64(len(proposals)), + title: title, + summary: summary, + spendAmount: spendAmount, + spender: spender, + vpUpdates: parsedVPUpdates, + newMetadata: newMetadata, + newURI: newURI, + submitTime: uint64(time.Now().Unix()), + voteEndTime: uint64(time.Now().Unix()) + dao.votingPeriod, + status: VOTING_PERIOD, + votes: avl.NewTree(), + votingPowers: []uint64{0, 0, 0, 0}, // initiate as zero for 4 vote types + }) +} + +func VoteProposal(proposalId uint64, option VoteOption) { + caller := std.GetOrigCaller() + + // if sender is not a dao member, revert + isCallerDaoMember := IsDAOMember(caller) + if !isCallerDaoMember { + panic("caller is not a gnodao member") + } + + // if invalid proposal, panic + if int(proposalId) >= len(proposals) { + panic("invalid proposal id") + } + + // if vote end time is reached panic + if time.Now().Unix() > int64(proposals[proposalId].voteEndTime) { + panic("vote end time reached") + } + + // Original vote cancel + callerVotingPower := getDAOVotingPower(caller.String()) + vote, ok := getVote(proposalId, caller) + if ok { + if proposals[proposalId].votingPowers[int(vote.option)] > callerVotingPower { + proposals[proposalId].votingPowers[int(vote.option)] -= callerVotingPower + } else { + proposals[proposalId].votingPowers[int(vote.option)] = 0 + } + } + + // Create a vote + proposals[proposalId].votes.Set(caller.String(), Vote{ + address: caller, + timestamp: uint64(time.Now().Unix()), + option: option, + }) + + // Voting power by option update for new vote + proposals[proposalId].votingPowers[int(option)] += callerVotingPower +} + +func TallyAndExecute(proposalId uint64) { + caller := std.GetOrigCaller() + + // if sender is not a dao member, revert + isCallerDaoMember := IsDAOMember(caller) + if !isCallerDaoMember { + panic("caller is not a gnodao member") + } + + // validation for proposalId + if int(proposalId) >= len(proposals) { + panic("invalid proposal id") + } + proposal := proposals[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[proposalId].status = REJECTED + } + + // If no one votes (everyone abstains), proposal rejected + if totalVotes == votingPowers[ABSTAIN] { + proposals[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[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[proposalId].status = PASSED + + if proposal.spendAmount > 0 { + if dao.funds >= proposal.spendAmount { + dao.funds -= proposal.spendAmount + } else { + proposals[proposalId].status = FAILED + return + } + } + + if proposal.newMetadata != "" { + dao.metadata = proposal.newMetadata + } + + if proposal.newURI != "" { + dao.uri = proposal.newURI + } + + for _, vp := range proposal.vpUpdates { + dao.totalVotingPower -= getDAOVotingPower(vp.address) + dao.votingPowers.Set(vp.address, vp.power) + dao.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[proposalId].status = REJECTED +} + +func DepositDAO(amount uint64) { + caller := std.GetOrigCaller() + + // if sender is not a dao member, revert + isCallerDaoMember := IsDAOMember(caller) + if !isCallerDaoMember { + panic("caller is not a gnodao member") + } + + // TODO: send coins from caller to DAO + // TODO: verify received amount + // dao.depositHistory = append(dao.depositHistory, Deposit{ + // address: caller, + // amount: amount, + // }) +} + +func GetProposal(proposalId uint64) Proposal { + if int(proposalId) >= len(proposals) { + panic("invalid proposal id") + } + return proposals[proposalId] +} + +func GetProposals(startAfter, limit uint64) []Proposal { + max := uint64(len(proposals)) + if startAfter+limit < max { + max = startAfter + limit + } + return proposals[startAfter:max] +} + +func RenderVote(proposalId uint64, address std.Address) string { + vote, found := getVote(proposalId, address) + if !found { + return "" + } + + return fmt.Sprintf(`{ + "address": "%s", + "timestamp": %d, + "option": %d +}`, vote.address.String(), vote.timestamp, vote.option) +} + +type DAOEncode struct { + 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 + numJusticeDAO uint64 +} + +type ProposalEncode struct { + 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{ + uri: dao.uri, + metadata: dao.metadata, + funds: dao.funds, + totalVotingPower: dao.totalVotingPower, + votingPeriod: dao.votingPeriod, + voteQuorum: dao.voteQuorum, + threshold: dao.threshold, + vetoThreshold: dao.vetoThreshold, + numJusticeDAO: dao.numJusticeDAO, + } +} + +func GetProposalEncodeObject(p Proposal) ProposalEncode { + return ProposalEncode{ + 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() string { + daoEncode := GetDAOEncodeObject(dao) + + return fmt.Sprintf(`{ + "uri": "%s", + "metadata": "%s", + "funds" %d, + "totalVotingPower": %d, + "votingPeriod": %d, + "voteQuorum": %d, + "threshold": %d, + "vetoThreshold": %d, + "numJusticeDAO": %d +}`, daoEncode.uri, daoEncode.metadata, daoEncode.funds, daoEncode.totalVotingPower, daoEncode.votingPeriod, daoEncode.voteQuorum, daoEncode.threshold, daoEncode.vetoThreshold, daoEncode.numJusticeDAO) +} + +func RenderDAOMembers(start string, end string) string { + votingPowers := []VotingPower{} + dao.votingPowers.Iterate(start, end, func(key string, value interface{}) bool { + power := value.(uint64) + votingPowers = append(votingPowers, VotingPower{ + address: 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 RenderProposal(proposalId uint64) string { + p := GetProposalEncodeObject(GetProposal(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(`{ + "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.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(startAfter, limit uint64) string { + proposals := GetProposals(startAfter, limit) + rendered := "[" + for index, proposal := range proposals { + rendered += RenderProposal(proposal.id) + if index != len(proposals)-1 { + rendered += ",\n" + } + } + rendered += "]" + return rendered +} + +func Render(path string) string { + return "" +} + +func CreateJusticeProposal( + title, summary string, contractId uint64, +) { + reqId := vrf.RequestRandomWords(1) + justiceProposals = append(justiceProposals, JusticeProposal{ + id: uint64(len(justiceProposals)), + title: title, + summary: summary, + vrfId: reqId, + governers: []string{}, + solution: "", + voteEndTime: 0, + status: NIL, + votes: []Vote{}, + contractId: contractId, + sellerAmount: 0, + }) +} + +func GetDAOMembers() []string { + members := []string{} + dao.votingPowers.Iterate("", "", func(key string, value interface{}) bool { + members = append(members, key) + return false + }) + return members +} + +func DetermineJusticeDAOMembers(id uint64) { + if int(id) >= len(justiceProposals) { + panic("invalid justice DAO proposal id") + } + + members := GetDAOMembers() + for i := uint64(0); i < dao.numJusticeDAO; i++ { + if len(members) == 0 { + break + } + randomNum := vrf.RandomValueFromWordsWithIndex(id, i) + index := int(randomNum) % len(members) + member := members[index] + + // remove the index from the list + members[index] = members[len(members)-1] + members = members[:len(members)-1] + justiceProposals[id].governers = append(justiceProposals[id].governers, member) + justiceProposals[id].votes = append(justiceProposals[id].votes, Vote{}) + } +} + +func ProposeJusticeDAOSolution(id uint64, sellerAmount uint64, solution string) { + if int(id) >= len(justiceProposals) { + panic("invalid justice DAO proposal id") + } + + justiceProposals[id].sellerAmount = sellerAmount + justiceProposals[id].solution = solution + justiceProposals[id].submitTime = uint64(time.Now().Unix()) + justiceProposals[id].voteEndTime = uint64(time.Now().Unix()) + dao.votingPeriod + justiceProposals[id].status = VOTING_PERIOD +} + +func IsJusticeDAOMember(id uint64, member std.Address) bool { + for _, governer := range justiceProposals[id].governers { + if governer == member.String() { + return true + } + } + return false +} + +func VoteJusticeSolutionProposal(id uint64, option VoteOption) { + caller := std.GetOrigCaller() + + // if sender is not a justice dao member, revert + isCallerJusticeDaoMember := IsJusticeDAOMember(id, caller) + if !isCallerJusticeDaoMember { + panic("caller is not a gnodao member") + } + + // if invalid proposal, panic + if int(id) >= len(justiceProposals) { + panic("invalid proposal id") + } + + // if vote end time is reached panic + if time.Now().Unix() > int64(justiceProposals[id].voteEndTime) { + panic("vote end time reached") + } + + // Create a vote + for i, govern := range justiceProposals[id].governers { + if govern == caller.String() { + justiceProposals[id].votes[i] = Vote{ + address: caller, + timestamp: uint64(time.Now().Unix()), + option: option, + } + break + } + } +} + +func TallyAndExecuteJusticeSolution(proposalId uint64) { + caller := std.GetOrigCaller() + + // if sender is not a dao member, revert + isCallerJusticeDaoMember := IsJusticeDAOMember(proposalId, caller) + if !isCallerJusticeDaoMember { + panic("caller is not a justice DAO member") + } + + // validation for proposalId + if int(proposalId) >= len(justiceProposals) { + panic("invalid justice DAO proposal id") + } + proposal := justiceProposals[proposalId] + + if time.Now().Unix() < int64(proposal.voteEndTime) { + panic("justice DAO proposal is in voting period") + } + + numYesVotes := uint64(0) + for i, govern := range proposal.governers { + vote := justiceProposals[proposalId].votes[i] + if vote.option == YES { + numYesVotes++ + } + } + + // If more than 2/3 votes Yes, let it pass + if numYesVotes > 0 && numYesVotes*3 >= uint64(len(proposal.governers))*2 { + justiceProposals[proposalId].status = PASSED + escrow.CompleteContractByDAO(proposal.contractId, proposal.sellerAmount) + } else { + justiceProposals[proposalId].status = REJECTED + } +} + +func RenderJusticeDAOProposal(proposalId uint64) string { + // if invalid proposal, panic + if int(proposalId) >= len(justiceProposals) { + panic("invalid proposal id") + } + p := justiceProposals[proposalId] + + governers := strings.Join(p.governers, ",") + + voteEncodedObjects := []string{} + for _, vote := range p.votes { + voteEncodedObjects = append(voteEncodedObjects, fmt.Sprintf(`{ + "address": "%s", + "timestamp": %d, + "option": %d +}`, vote.address.String(), vote.timestamp, vote.option)) + } + voteEncoded := strings.Join(voteEncodedObjects, ",\n") + + return fmt.Sprintf(`{ + "id": %d, + "title": "%s", + "summary": "%s", + "vrfId": %d, + "governers": [%s], + "solution": "%s", + "submitTime": %d, + "voteEndTime": %d, + "status": %d, + "votes": [%s] +}`, p.id, p.title, p.summary, p.vrfId, governers, p.solution, p.submitTime, p.voteEndTime, int(p.status), voteEncoded) +} + +func GetJusticeDAOProposals(startAfter, limit uint64) []JusticeProposal { + max := uint64(len(justiceProposals)) + if startAfter+limit < max { + max = startAfter + limit + } + return justiceProposals[startAfter:max] +} + +func RenderJusticeDAOProposals(startAfter, limit uint64) string { + proposals := GetJusticeDAOProposals(startAfter, limit) + rendered := "[" + for index, proposal := range proposals { + rendered += RenderJusticeDAOProposal(proposal.id) + if index != len(proposals)-1 { + rendered += ",\n" + } + } + rendered += "]" + return rendered +} diff --git a/examples/gno.land/r/demo/justicedao/justicedao_teritori_testnet.sh b/examples/gno.land/r/demo/justicedao/justicedao_teritori_testnet.sh new file mode 100644 index 00000000000..148ba210012 --- /dev/null +++ b/examples/gno.land/r/demo/justicedao/justicedao_teritori_testnet.sh @@ -0,0 +1,159 @@ +#!/bin/sh + +gnokey add gopher +- addr: g1x2xyqca98auaw9lnat2h9ycd4lx3w0jer9vjmt + +gnokey add gopher2 +- addr: g1c5y8jpe585uezcvlmgdjmk5jt2glfw88wxa3xq + +TERITORI=g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5 +GOPHER=g1x2xyqca98auaw9lnat2h9ycd4lx3w0jer9vjmt + +# check balance +gnokey query bank/balances/$GOPHER -remote="51.15.236.215:26657" + +gnokey maketx addpkg \ + -deposit="1ugnot" \ + -gas-fee="1ugnot" \ + -gas-wanted="5000000" \ + -broadcast="true" \ + -remote="51.15.236.215:26657" \ + -chainid="teritori-1" \ + -pkgdir="./r/demo/justicedao" \ + -pkgpath="gno.land/r/demo/justicedao_10" \ + teritori + +# Create DAO +gnokey maketx call \ + -gas-fee="1ugnot" \ + -gas-wanted="5000000" \ + -broadcast="true" \ + -remote="51.15.236.215:26657" \ + -chainid="teritori-1" \ + -pkgpath="gno.land/r/demo/justicedao_10" \ + -func="CreateDAO" \ + -args="https://gnodao1.org" \ + -args="https://metadata.gnodao1.org" \ + -args=$GOPHER,$TERITORI \ + -args="1,1" \ + -args="40" \ + -args="30" \ + -args="10" \ + -args="10" \ + -args="1" \ + teritori + +# Create Justice DAO proposal +gnokey maketx call \ + -gas-fee="1ugnot" \ + -gas-wanted="5000000" \ + -broadcast="true" \ + -remote="51.15.236.215:26657" \ + -chainid="teritori-1" \ + -pkgpath="gno.land/r/demo/justicedao_10" \ + -func="CreateJusticeProposal" \ + -args="First Justice DAO proposal" \ + -args="First Justice DAO proposal summary" \ + -args="1" \ + teritori + +# Fulfill Random Words on VRF +gnokey maketx call \ + -gas-fee="1ugnot" \ + -gas-wanted="5000000" \ + -broadcast="true" \ + -remote="51.15.236.215:26657" \ + -chainid="teritori-1" \ + -pkgpath="gno.land/r/demo/vrf_08" \ + -func="FulfillRandomWords" \ + -args="7" \ + -args="f440c4980357d8b56db87ddd50f06bd551f1319b" \ + teritori + +# Determine Juste DAO members +gnokey maketx call \ + -gas-fee="1ugnot" \ + -gas-wanted="5000000" \ + -broadcast="true" \ + -remote="51.15.236.215:26657" \ + -chainid="teritori-1" \ + -pkgpath="gno.land/r/demo/justicedao_10" \ + -func="DetermineJusticeDAOMembers" \ + -args="0" \ + teritori + +# Propose Justice DAO Solution +gnokey maketx call \ + -gas-fee="1ugnot" \ + -gas-wanted="5000000" \ + -broadcast="true" \ + -remote="51.15.236.215:26657" \ + -chainid="teritori-1" \ + -pkgpath="gno.land/r/demo/justicedao_10" \ + -func="ProposeJusticeDAOSolution" \ + -args="0" \ + -args="50" \ + -args="Split 50:50" \ + teritori + +# Vote Justice Solution Proposal +gnokey maketx call \ + -gas-fee="1ugnot" \ + -gas-wanted="5000000" \ + -broadcast="true" \ + -remote="51.15.236.215:26657" \ + -chainid="teritori-1" \ + -pkgpath="gno.land/r/demo/justicedao_10" \ + -func="VoteJusticeSolutionProposal" \ + -args="0" \ + -args="0" \ + teritori + +# Tally And Execute Justice Solution +gnokey maketx call \ + -gas-fee="1ugnot" \ + -gas-wanted="5000000" \ + -broadcast="true" \ + -remote="51.15.236.215:26657" \ + -chainid="teritori-1" \ + -pkgpath="gno.land/r/demo/justicedao_10" \ + -func="TallyAndExecuteJusticeSolution" \ + -args="0" \ + teritori + +# Create Normal Proposal +gnokey maketx call \ + -gas-fee="1ugnot" \ + -gas-wanted="5000000" \ + -broadcast="true" \ + -remote="51.15.236.215:26657" \ + -chainid="teritori-1" \ + -pkgpath="gno.land/r/demo/justicedao_10" \ + -func="CreateProposal" \ + -args="First proposal" \ + -args="First proposal summary" \ + -args=0 \ + -args=$GOPHER \ + -args="" \ + -args="" \ + -args="https://metadata.gnodao1.com" \ + -args="https://gnodao1.com" \ + teritori + +# Query proposal +gnokey query "vm/qeval" -data="gno.land/r/demo/justicedao_10 +RenderProposal(0)" -remote="51.15.236.215:26657" + +# Render Juste DAO Proposal +gnokey query "vm/qeval" -data="gno.land/r/demo/justicedao_10 +RenderJusticeDAOProposal(0)" -remote="51.15.236.215:26657" + +# Render Justice DAO Proposals +gnokey query "vm/qeval" -data="gno.land/r/demo/justicedao_10 +RenderJusticeDAOProposals(0, 1)" -remote="51.15.236.215:26657" + +gnokey query "vm/qeval" -data="gno.land/r/demo/justicedao_10 +GetDAOMembers()" -remote="51.15.236.215:26657" + +gnokey query "vm/qeval" -data="gno.land/r/demo/justicedao_10 +RenderDAOMembers(\"\",\"\")" -remote="51.15.236.215:26657" diff --git a/examples/gno.land/r/demo/justicedao/spec.md b/examples/gno.land/r/demo/justicedao/spec.md new file mode 100644 index 00000000000..67da9e397e0 --- /dev/null +++ b/examples/gno.land/r/demo/justicedao/spec.md @@ -0,0 +1,105 @@ +# Justice DAO + +The goal of Justice DAO is to select random members from the DAO members to resolve the conflict on Escrow service. + +Those random members selected all have same voting power and those members do on-chain chat and resolve the conflict between two parties. + +We consider the conflict proposal as a specific proposal called `Justice DAO Proposal`, while normal proposal is for internal DAO management. + +Justice DAO uses `VRF` realm to determine random members. + +## Data structure + +Justice DAO has 3 main sections. + +- `DAO` to manage overall members and DAO itself + +```go +type DAO struct { + 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 + numJusticeDAO uint64 // number of justice DAO members on justice proposal +} +``` + +- `Proposal` to manage interal DAO related proposals + +```go + +type Vote struct { + address std.Address // address of the voter + timestamp uint64 // block timestamp of the vote + option VoteOption // vote option +} + + +type VotingPower struct { + address string + power uint64 +} + +type Proposal struct { + 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 +} + +``` + +- `JusticeProposal` for external requests to resolve. + +```go +type JusticeProposal struct { + id uint64 // unique id assigned for each proposal + title string // proposal title + summary string // proposal summary + vrfId uint64 // the vrf request id being used to determine governers + governers []string // the governers of the proposal + contractId uint64 // the escrow contract id to resolve + sellerAmount uint64 // the seller amount determined by Justice DAO + solution string // proposed result of justice DAO proposal + submitTime uint64 // solution submission time + voteEndTime uint64 // vote end time for the proposal + status ProposalStatus // StatusNil | StatusVotingPeriod | StatusPassed | StatusRejected | StatusFailed + votes []Vote +} +``` + +## Realm configuration process + +Create DAO by usin `CreateDAO` endpoint + +## DAO internal proposals flow + +- `CreateProposal` to create an internal proposal +- `VoteProposal` to vote an internal proposal +- `TallyAndExecute` to execute the finally voted proposal + +## DAO justice proposals flow + +- `CreateJusticeProposal` to create a justice DAO proposal, this requests random number to `VRF` and it will be needed to wait until the required number of random word feeders to feed the words +- `DetermineJusticeDAOMembers` to determine random members from Justice DAO +- `ProposeJusticeDAOSolution` to propose Justice DAO solution by one of elected Justice DAO member +- `VoteJusticeSolutionProposal` to vote on Justice DAO solution +- `TallyAndExecuteJusticeSolution` to execute the finally voted justice DAO solution diff --git a/examples/gno.land/r/demo/vrf/spec.md b/examples/gno.land/r/demo/vrf/spec.md new file mode 100644 index 00000000000..1c27fe25645 --- /dev/null +++ b/examples/gno.land/r/demo/vrf/spec.md @@ -0,0 +1,53 @@ +# VRF + +For VRF 0.1 for Gnoland, we will use following mechanism. + +- VRF data feeders are available and only those can feed data +- Long data bytes will be feed into the realm by multiple feeders +- All of the values provided from those feeders will be combined to generate random data (This will make the random secure enough e.g. when 1 feeder is attacked for server attack - just need at least one trustworthy data feeder) +- Random data is written up-on the request. That way, noone knows what will be written at the time of requesting randomness. + +## Use case + +VRF can be used by offchain users to request random data or by other realms to get TRUE random value on their operations. + +The initial use case is on Justice DAO to determine random members to get voting power on the DAO to resolve conflict between service providers and customers when there are issues between them. + +## Data structure + +VRF utilize two structs, `Config` for feeders administration, and `Request` to manage the state of random requests by id. + +```go +type Config struct { + vrfAdmin string + feeders []string +} +``` + +```go +type Request struct { + id uint64 + requesterAddress string + requesterRealm string + requiredFeedersCount uint64 + fulfilledCount uint64 + randomWords []byte + fulfillers []string +} +``` + +## Realm configuration process + +After realm deployment, VRF admin is set by using `SetVRFAdmin` endpoint. + +By VRF admin, feeders are set by using `SetFeeders` endpoint. + +Feeders can be modified by VRF admin at any time. + +Note: The random data that's already feed by feeder can not be cancelled by VRF admin. + +## Random data generation process + +- A user or realm request random words from feedeers by using `RequestRandomWords` endpoint +- Feeders monioring the requests check not filled requests and add random words by running `FulfillRandomWords` endpoint +- The requester can use `RandomValueFromWordsWithIndex` or `RandomValueFromWords` based on the required number of random values diff --git a/examples/gno.land/r/demo/vrf/vrf.gno b/examples/gno.land/r/demo/vrf/vrf.gno new file mode 100644 index 00000000000..ec5fb0fc1e1 --- /dev/null +++ b/examples/gno.land/r/demo/vrf/vrf.gno @@ -0,0 +1,269 @@ +package vrf + +import ( + "crypto/sha256" + "encoding/binary" + "errors" + // "encoding/hex" + fmt "gno.land/p/demo/ufmt" + "std" + "strings" +) + +type Config struct { + vrfAdmin string + feeders []string +} + +type Request struct { + id uint64 + requesterAddress string + requesterRealm string + requiredFeedersCount uint64 + fulfilledCount uint64 + randomWords []byte + fulfillers []string +} + +var config Config +var requests []Request + +const hextable = "0123456789abcdef" +const reverseHexTable = "" + + "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" + + "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" + + "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" + + "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\xff\xff\xff\xff\xff\xff" + + "\xff\x0a\x0b\x0c\x0d\x0e\x0f\xff\xff\xff\xff\xff\xff\xff\xff\xff" + + "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" + + "\xff\x0a\x0b\x0c\x0d\x0e\x0f\xff\xff\xff\xff\xff\xff\xff\xff\xff" + + "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" + + "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" + + "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" + + "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" + + "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" + + "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" + + "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" + + "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" + + "\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff" + +// ErrLength reports an attempt to decode an odd-length input +// using Decode or DecodeString. +// The stream-based Decoder returns io.ErrUnexpectedEOF instead of ErrLength. +var ErrLength = errors.New("encoding/hex: odd length hex string") + +// InvalidByteError values describe errors resulting from an invalid byte in a hex string. +type InvalidByteError byte + +func (e InvalidByteError) Error() string { + return fmt.Sprintf("encoding/hex: invalid byte: %#U", rune(e)) +} + +func EncodeToHexString(src []byte) string { + encoded := []byte{} + j := 0 + for _, v := range src { + encoded = append(encoded, hextable[v>>4]) + encoded = append(encoded, hextable[v&0x0f]) + } + return string(encoded) +} + +func DecodeFromHexString(src []byte) ([]byte, error) { + dst := []byte{} + i, j := 0, 1 + for ; j < len(src); j += 2 { + p := src[j-1] + q := src[j] + + a := reverseHexTable[p] + b := reverseHexTable[q] + if a > 0x0f { + return dst, InvalidByteError(p) + } + if b > 0x0f { + return dst, InvalidByteError(q) + } + dst = append(dst, (a<<4)|b) + } + if len(src)%2 == 1 { + // Check for invalid char before reporting bad length, + // since the invalid char (if present) is an earlier problem. + if reverseHexTable[src[j-1]] > 0x0f { + return dst, InvalidByteError(src[j-1]) + } + return dst, ErrLength + } + return dst, nil +} + +func RenderConfig() string { + quotedFeeders := []string{} + for _, feeder := range config.feeders { + quotedFeeders = append(quotedFeeders, `"`+feeder+`"`) + } + feedersText := strings.Join(quotedFeeders, ", ") + return fmt.Sprintf(`{ + "vrfAdmin": "%s", + "feeders": [%s] +}`, config.vrfAdmin, feedersText) +} + +func GetRequests(startAfter, limit uint64) []Request { + max := uint64(len(requests)) + if startAfter+limit < max { + max = startAfter + limit + } + return requests[startAfter:max] +} + +func RenderRequest(requestId uint64) string { + if requestId >= uint64(len(requests)) { + panic("invalid request id") + } + + r := requests[requestId] + quotedFulfillers := []string{} + for _, fulfiller := range r.fulfillers { + quotedFulfillers = append(quotedFulfillers, `"`+fulfiller+`"`) + } + fulfillers := strings.Join(quotedFulfillers, ", ") + randomWords := EncodeToHexString(r.randomWords) + + return fmt.Sprintf(`{ + "id": %d, + "requesterAddress": "%s", + "requesterRealm": "%s", + "requiredFeedersCount": %d, + "fulfilledCount": %d, + "randomWords": "%s", + "fulfillers": [%s] +}`, r.id, r.requesterAddress, r.requesterRealm, r.requiredFeedersCount, r.fulfilledCount, randomWords, fulfillers) +} + +func RenderRequests(startAfter uint64, limit uint64) string { + paginatedRequests := GetRequests(startAfter, limit) + rendered := "[" + for index, req := range paginatedRequests { + rendered += RenderRequest(req.id) + if index != len(paginatedRequests)-1 { + rendered += ",\n" + } + } + rendered += "]" + return rendered +} + +func SetVRFAdmin(vrfAdmin string) { + if config.vrfAdmin == "" { + config.vrfAdmin = vrfAdmin + return + } + caller := std.GetOrigCaller() + if config.vrfAdmin != caller.String() { + panic("not allowed to update vrfAdmin") + } + + config.vrfAdmin = vrfAdmin +} + +func SetFeeders(feedersText string) { + feeders := strings.Split(feedersText, ",") + caller := std.GetOrigCaller() + if config.vrfAdmin != caller.String() { + panic("not allowed set feeders") + } + + config.feeders = feeders +} + +func RequestRandomWords(requiredFeedersCount uint64) uint64 { + requests = append(requests, Request{ + id: uint64(len(requests)), + requesterAddress: std.PrevRealm().Addr().String(), + requesterRealm: std.PrevRealm().PkgPath(), + requiredFeedersCount: requiredFeedersCount, + fulfilledCount: 0, + randomWords: []byte{}, + fulfillers: []string{}, + }) + return uint64(len(requests) - 1) +} + +func IsFeeder(feeder string) bool { + for _, f := range config.feeders { + if feeder == f { + return true + } + } + return false +} + +func Fulfilled(request Request, feeder string) bool { + for _, f := range request.fulfillers { + if feeder == f { + return true + } + } + return false +} + +func FulfillRandomWords(requestId uint64, wordsHex string) { + caller := std.GetOrigCaller() + + if requestId >= uint64(len(requests)) { + panic("invalid request id") + } + + if !IsFeeder(caller.String()) { + panic("not a feeder") + } + + request := requests[requestId] + if Fulfilled(request, caller.String()) { + panic("already fulfilled") + } + + words, err := DecodeFromHexString([]byte(wordsHex)) + if err != nil { + panic(err) + } + + if requests[requestId].fulfilledCount >= request.requiredFeedersCount { + panic("required feeders count reached") + } + + requests[requestId].fulfilledCount += 1 + requests[requestId].randomWords = append(request.randomWords, words...) + requests[requestId].fulfillers = append(request.fulfillers, caller.String()) +} + +func RandomValueFromWordsWithIndex(requestId uint64, index uint64) uint64 { + if requestId >= uint64(len(requests)) { + panic("invalid request id") + } + request := requests[requestId] + if request.fulfilledCount < request.requiredFeedersCount { + panic("not enough feeders joined yet") + } + + b := make([]byte, 8) + binary.LittleEndian.PutUint64(b, uint64(index)) + + hash := sha256.Sum256(append(request.randomWords, b...)) + value := binary.BigEndian.Uint64(hash[0:8]) + return value +} + +func RandomValueFromWords(requestId uint64) uint64 { + if requestId >= uint64(len(requests)) { + panic("invalid request id") + } + request := requests[requestId] + if request.fulfilledCount < request.requiredFeedersCount { + panic("not enough feeders joined yet") + } + hash := sha256.Sum256(request.randomWords) + value := binary.BigEndian.Uint64(hash[0:8]) + return value +} diff --git a/examples/gno.land/r/demo/vrf/vrf_teritori_testnet.sh b/examples/gno.land/r/demo/vrf/vrf_teritori_testnet.sh new file mode 100644 index 00000000000..7d3dac2dc29 --- /dev/null +++ b/examples/gno.land/r/demo/vrf/vrf_teritori_testnet.sh @@ -0,0 +1,97 @@ +#!/bin/sh + +gnokey add gopher +- addr: g1x2xyqca98auaw9lnat2h9ycd4lx3w0jer9vjmt + +gnokey add gopher2 +- addr: g1c5y8jpe585uezcvlmgdjmk5jt2glfw88wxa3xq + +TERITORI=g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5 +GOPHER=g1x2xyqca98auaw9lnat2h9ycd4lx3w0jer9vjmt + +# check balance +gnokey query bank/balances/$GOPHER -remote="51.15.236.215:26657" + +gnokey maketx addpkg \ + -deposit="1ugnot" \ + -gas-fee="1ugnot" \ + -gas-wanted="5000000" \ + -broadcast="true" \ + -remote="51.15.236.215:26657" \ + -chainid="teritori-1" \ + -pkgdir="./r/vrf" \ + -pkgpath="gno.land/r/demo/vrf_08" \ + teritori + +# Set VRF admin +gnokey maketx call \ + -gas-fee="1ugnot" \ + -gas-wanted="5000000" \ + -broadcast="true" \ + -remote="51.15.236.215:26657" \ + -chainid="teritori-1" \ + -pkgpath="gno.land/r/demo/vrf_08" \ + -func="SetVRFAdmin" \ + -args="$TERITORI" \ + teritori + +# Set feeders +gnokey maketx call \ + -gas-fee="1ugnot" \ + -gas-wanted="5000000" \ + -broadcast="true" \ + -remote="51.15.236.215:26657" \ + -chainid="teritori-1" \ + -pkgpath="gno.land/r/demo/vrf_08" \ + -func="SetFeeders" \ + -args="$TERITORI" \ + teritori + +# Request Random Words +gnokey maketx call \ + -gas-fee="1ugnot" \ + -gas-wanted="5000000" \ + -broadcast="true" \ + -remote="51.15.236.215:26657" \ + -chainid="teritori-1" \ + -pkgpath="gno.land/r/demo/vrf_08" \ + -func="RequestRandomWords" \ + -args="1" \ + teritori + +# Fulfill Random Words +gnokey maketx call \ + -gas-fee="1ugnot" \ + -gas-wanted="5000000" \ + -broadcast="true" \ + -remote="51.15.236.215:26657" \ + -chainid="teritori-1" \ + -pkgpath="gno.land/r/demo/vrf_08" \ + -func="FulfillRandomWords" \ + -args="0" \ + -args="f440c4980357d8b56db87ddd50f06bd551f1319a" \ + teritori + +# Query config +gnokey query "vm/qeval" -data="gno.land/r/demo/vrf_08 +RenderConfig()" -remote="51.15.236.215:26657" + +# Query Requests +gnokey query "vm/qeval" -data="gno.land/r/demo/vrf_08 +RenderRequests(0, 10)" -remote="51.15.236.215:26657" + +# Query request +gnokey query "vm/qeval" -data="gno.land/r/demo/vrf_08 +RenderRequest(0)" -remote="51.15.236.215:26657" + +# Query IsFeeder +gnokey query "vm/qeval" -data="gno.land/r/demo/vrf_08 +IsFeeder(\"$TERITORI\")" -remote="51.15.236.215:26657" + +# Query RandomValueFromWordsWithIndex +gnokey query "vm/qeval" -data="gno.land/r/demo/vrf_08 +RandomValueFromWordsWithIndex(0, 0)" -remote="51.15.236.215:26657" + +# Query RandomValueFromWordsWithIndex +gnokey query "vm/qeval" -data="gno.land/r/demo/vrf_08 +RandomValueFromWords(0)" -remote="51.15.236.215:26657"