diff --git a/examples/gno.land/p/moonia/dao/admin.gno b/examples/gno.land/p/moonia/dao/admin.gno new file mode 100644 index 00000000000..769ed71d218 --- /dev/null +++ b/examples/gno.land/p/moonia/dao/admin.gno @@ -0,0 +1,97 @@ +package dao + +import ( + "std" +) + +// Admin Methods // + +func (d *DAO) TransferAdmin(addr std.Address) string { + caller := std.PreviousRealm().Address() + + if !d.IsAdmin(caller) { + panic("Only the admin can transfer admin.") + } + if d.IsAdmin(addr) { + panic("Address is already the admin.") + } + if !d.IsMember(addr) { + panic("New admin must be a member.") + } + d.Admin = addr + return "Admin transferred to " + addr.String() +} + +func (d *DAO) SetAdmin() string { + if d.Admin != "" { + panic("Admin is already set.") + } + caller := std.PreviousRealm().Address() + d.Admin = caller + d.Whitelist[caller] = true + return "Admin set to: " + caller.String() +} + +func (d *DAO) IsAdmin(addr std.Address) bool { + return addr == d.Admin +} + +func (d *DAO) AcceptRequest(addr std.Address) string { + caller := std.PreviousRealm().Address() + if !d.IsAdmin(caller) { + panic("Only admin can accept requests.") + } + if !d.Requests[addr] { + panic("No such request.") + } + delete(d.Requests, addr) + return d.AddMember(addr) +} + +func (d *DAO) DeclineRequest(addr std.Address) string { + caller := std.PreviousRealm().Address() + if !d.IsAdmin(caller) { + panic("Only admin can decline requests.") + } + if !d.Requests[addr] { + panic("No such request.") + } + delete(d.Requests, addr) + return "Request declined for " + addr.String() +} + +// Member Methods // + +func (d *DAO) IsMember(addr std.Address) bool { + return d.Whitelist[addr] +} + +func (d *DAO) AddMember(addr std.Address) string { + caller := std.PreviousRealm().Address() + + if !d.IsAdmin(caller) { + panic("Only the admin can add members.") + } + if d.IsMember(addr) { + return "Address is already a member." + } + d.Whitelist[addr] = true + return "Member added: " + addr.String() +} + +func (d *DAO) KickMember(addr std.Address) string { + caller := std.PreviousRealm().Address() + + if !d.IsAdmin(caller) { + panic("Only the admin can kick members.") + } + if !d.IsMember(addr) { + return "Address is not a member." + } + delete(d.Whitelist, addr) + return "Member kicked: " + addr.String() +} + +func (d* DAO) ListMembers() map[std.Address]bool { + return d.Whitelist +} \ No newline at end of file diff --git a/examples/gno.land/p/moonia/dao/dao.gno b/examples/gno.land/p/moonia/dao/dao.gno new file mode 100644 index 00000000000..a5ac366df21 --- /dev/null +++ b/examples/gno.land/p/moonia/dao/dao.gno @@ -0,0 +1,78 @@ +package dao + +import ( + "std" + "strconv" +) + +type DAO struct { + Admin std.Address + Whitelist map[std.Address]bool + Requests map[std.Address]bool + Name string + Description string +} + +func NewDAO(name, desc string) *DAO { + return &DAO{ + Admin: "", + Whitelist: make(map[std.Address]bool), + Requests: make(map[std.Address]bool), + Name: name, + Description: desc, + } +} + +func (d *DAO) ListRequests() map[std.Address]bool { + return d.Requests +} + +func (d *DAO) ShowAdmin() string { + if d.Admin == "" { + return "_No admin set._" + } + return "Admin: `" + d.Admin.String() + "`" +} + +func (d *DAO) RequestDAO() string { + caller := std.PreviousRealm().Address() + if d.Whitelist[caller] { + return "You are already a member." + } + if d.Requests[caller] { + return "You have already requested to join." + } + d.Requests[caller] = true + return "Request to join sent." +} + +func (d *DAO) LeaveDAO() string { + caller := std.PreviousRealm().Address() + if !d.Whitelist[caller] { + panic("You are not a member of the DAO.") + } + delete(d.Whitelist, caller) + return "You have successfully left the DAO." +} + +func (d *DAO) ShowWhitelist() string { + out := "## Whitelist Members ✅\n\n" + if len(d.Whitelist) == 0 { + return out + "_Whitelist is empty._\n" + } + for addr := range d.Whitelist { + if addr == d.Admin { + out += "- " + addr.String() + " (Admin)" + "\n" + } else { + out += "- " + addr.String() + "\n" + } + } + return out +} + +func (d *DAO) Stats(totalProposals, activeProposals int) string { + return "### Stats\n\n" + + "- Total Proposals: " + strconv.Itoa(totalProposals) + "\n" + + "- Active Proposals: " + strconv.Itoa(activeProposals) + "\n" + + "- Whitelist Members: " + strconv.Itoa(len(d.Whitelist)) + "\n" +} diff --git a/examples/gno.land/p/moonia/dao/gno.mod b/examples/gno.land/p/moonia/dao/gno.mod new file mode 100644 index 00000000000..dc32c6b67ff --- /dev/null +++ b/examples/gno.land/p/moonia/dao/gno.mod @@ -0,0 +1 @@ +module gno.land/p/moonia/dao \ No newline at end of file diff --git a/examples/gno.land/p/moonia/dao/proposals.gno b/examples/gno.land/p/moonia/dao/proposals.gno new file mode 100644 index 00000000000..4d8aca8f373 --- /dev/null +++ b/examples/gno.land/p/moonia/dao/proposals.gno @@ -0,0 +1,162 @@ +package dao + +import ( + "std" + "strconv" + + "gno.land/p/moonia/utils" + "gno.land/p/moul/txlink" + "gno.land/p/moul/md" +) + +type Proposal struct { + Title string + Description string + Creator std.Address + YesVotes int + NoVotes int + Voters map[std.Address]bool + Active bool + VotingPeriod int64 + CreatedAt int64 +} + +type ProposalStore struct { + Proposals []Proposal + DAO *DAO + +} + +func NewProposalStore(dao *DAO) *ProposalStore { + return &ProposalStore{ + Proposals: []Proposal{}, + DAO: dao, + } +} + +func (ps *ProposalStore) CreateProposal(title, description string, period int64) string { + caller := std.PreviousRealm().Address() + if !ps.DAO.Whitelist[caller] { + panic("Only whitelisted members can create proposals.") + } + if period <= 0 { + panic("Voting period must be greater than 0.") + } + + p := Proposal{ + Title: title, + Description: description, + Creator: caller, + Voters: make(map[std.Address]bool), + Active: true, + VotingPeriod: period, + CreatedAt: utils.GetBlockTime(), + } + ps.Proposals = append(ps.Proposals, p) + return "Proposal created: " + title + " Voting period: " + strconv.Itoa(int(period)) +} + +func (ps *ProposalStore) CloseProposal(indexStr string) string { + index := utils.ParseIndex(indexStr, len(ps.Proposals)) + p := &ps.Proposals[index] + if p.Creator != std.PreviousRealm().Address() { + panic("Only the proposal creator can close it.") + } + if !p.Active { + panic("Proposal is already closed.") + } + p.Active = false + return "Proposal '" + p.Title + "' has been closed." +} + +func (ps *ProposalStore) Vote(indexStr, voteYesStr string) string { + index := utils.ParseIndex(indexStr, len(ps.Proposals)) + voteYes := voteYesStr == "true" + caller := std.PreviousRealm().Address() + + if !ps.DAO.Whitelist[caller] { + panic("Only whitelisted members can vote.") + } + + p := &ps.Proposals[index] + + currentTime := utils.GetBlockTime() + if currentTime - p.CreatedAt >= p.VotingPeriod { + p.Active = false + panic("Voting period is over. Proposal is now closed.") + } + + if !p.Active { + panic("Voting is closed for this proposal.") + } + if p.Voters[caller] { + panic("You have already voted.") + } + + p.Voters[caller] = true + if voteYes { + p.YesVotes++ + return "Vote recorded: YES for '" + p.Title + "'" + } else { + p.NoVotes++ + return "Vote recorded: NO for '" + p.Title + "'" + } +} + +func (ps *ProposalStore) ShowProposals() string { + activeOut := "## Active Proposals\n\n" + closedOut := "## Closed Proposals\n\n" + hasActive := false + hasClosed := false + + for i, p := range ps.Proposals { + proposalStr := "**[" + strconv.Itoa(i) + "]** " + p.Title + "\n" + proposalStr += p.Description + "\n\n" + proposalStr += "by _" + p.Creator.String() + "_\n\n" + proposalStr += "📅 Voting ends at: " + utils.FormatTimestamp(p.CreatedAt + p.VotingPeriod) + "\n\n" + proposalStr += "✅ " + strconv.Itoa(p.YesVotes) + " | ❌ " + strconv.Itoa(p.NoVotes) + "\n" + + if p.Active { + hasActive = true + proposalStr += "(Active) — " + + md.Link("Vote Yes", txlink.Call("Vote", "args", strconv.Itoa(i), "args", "true")) + " | " + + md.Link("Vote No", txlink.Call("Vote", "args", strconv.Itoa(i), "args", "false")) + "\n\n" + + md.Link("❌ Close proposal", txlink.Call("CloseProposal", "args", strconv.Itoa(i))) + "\n\n ---" + activeOut += proposalStr + "\n\n" + } else { + hasClosed = true + proposalStr += "(Closed)\n" + closedOut += proposalStr + "\n\n ---- \n\n" + } + } + if !hasActive { + activeOut += "_No active proposals._\n\n" + } + if !hasClosed { + closedOut += "_No closed proposals._\n\n" + } + return activeOut + "\n" + closedOut +} + +func (ps *ProposalStore) EditProposal(indexStr, newTitle, newDescription string, newPeriod int64) string { + index := utils.ParseIndex(indexStr, len(ps.Proposals)) + p := &ps.Proposals[index] + if p.Creator != std.PreviousRealm().Address() { + panic("Only the creator can edit the proposal.") + } + if !p.Active { + panic("Cannot edit a closed proposal.") + } + if newPeriod <= 0 { + panic("Voting period must be greater than 0.") + } + p.Title = newTitle + p.Description = newDescription + p.VotingPeriod = newPeriod + return "Proposal updated: " + newTitle + newDescription + strconv.Itoa(int(newPeriod)) +} + +func (ps *ProposalStore) GetProposal(indexStr string) Proposal { + index := utils.ParseIndex(indexStr, len(ps.Proposals)) + return ps.Proposals[index] +} \ No newline at end of file diff --git a/examples/gno.land/p/moonia/utils/gno.mod b/examples/gno.land/p/moonia/utils/gno.mod new file mode 100644 index 00000000000..a67c87cc0d7 --- /dev/null +++ b/examples/gno.land/p/moonia/utils/gno.mod @@ -0,0 +1 @@ +module gno.land/p/moonia/utils \ No newline at end of file diff --git a/examples/gno.land/p/moonia/utils/utils.gno b/examples/gno.land/p/moonia/utils/utils.gno new file mode 100644 index 00000000000..9620d0f8d21 --- /dev/null +++ b/examples/gno.land/p/moonia/utils/utils.gno @@ -0,0 +1,44 @@ +package utils + +import ( + "strconv" + "time" + "strings" +) + +func GetBlockTime() int64 { + return time.Now().Unix() +} + +func FormatTimestamp(timestamp int64) string { + t := time.Unix(timestamp, 0) + return t.Format("02 Jan 2006, 15:04") +} + +func ParseIndex(indexStr string, max int) int { + index, err := strconv.Atoi(indexStr) + if err != nil { + panic("Invalid index.") + } + if index < 0 || index >= max { + panic("Proposal does not exist.") + } + return index +} + +func ParseQuery(path string) (daoID string, showStats bool) { + parts := strings.Split(path, "?") + if len(parts) < 2 { + return "", false + } + + args := strings.Split(parts[1], "&") + for _, arg := range args { + if strings.HasPrefix(arg, "dao=") { + daoID = arg[len("dao="):] + } else if arg == "stats" { + showStats = true + } + } + return +} diff --git a/examples/gno.land/r/moonia/home/actions.gno b/examples/gno.land/r/moonia/home/actions.gno new file mode 100644 index 00000000000..77d4a7c9cb8 --- /dev/null +++ b/examples/gno.land/r/moonia/home/actions.gno @@ -0,0 +1,95 @@ +package home + +import ( + "std" + + "gno.land/p/moonia/dao" +) + +// Admin Methods // + +func IsAdmin(addr std.Address) bool { + return ds.DAO.IsAdmin(addr) +} + +func SetAdmin() string { + return ds.DAO.SetAdmin() +} + +func TransferAdmin(addr std.Address) string { + return ds.DAO.TransferAdmin(addr) +} + +func KickMember(addr std.Address) string { + return ds.DAO.KickMember(addr) +} + +func AddMember(addr std.Address) string { + return ds.DAO.AddMember(addr) +} + +func ListMembers() map[std.Address]bool { + return ds.DAO.Whitelist +} + +func AcceptRequest(addr std.Address) string { + return ds.DAO.AcceptRequest(addr) +} + +func DeclineRequest(addr std.Address) string { + return ds.DAO.DeclineRequest(addr) +} + +func ListRequests() map[std.Address]bool { + return ds.DAO.ListRequests() +} + +// DAO Methods // + +func CreateDAO(id, name, desc string) string { + if daoMap[id] != nil { + panic("DAO with this ID already exists.") + } + newDAO := dao.NewDAO(name, desc) + daoMap[id] = newDAO + proposalMap[id] = dao.NewProposalStore(newDAO) + + newDAO.SetAdmin() + + return "DAO '" + id + "' created with name: " + name +} + +func RequestDAO(daoID string) string { + d := daoMap[daoID] + if d == nil { + panic("DAO not found: " + daoID) + } + ds.DAO = d + return ds.DAO.RequestDAO() +} + +func LeaveDAO() string { + return ds.DAO.LeaveDAO() +} + +// Proposals Methods // + +func CreateProposal(title, description string, period int64) string { + return ds.Proposals.CreateProposal(title, description, period) +} + +func Vote(indexStr, voteYesStr string) string { + return ds.Proposals.Vote(indexStr, voteYesStr) +} + +func CloseProposal(indexStr string) string { + return ds.Proposals.CloseProposal(indexStr) +} + +func EditProposal(indexStr, newTitle, newDescription string, newPeriod int64) string { + return ds.Proposals.EditProposal(indexStr, newTitle, newDescription, newPeriod) +} + +func GetProposal(indexStr string) dao.Proposal { + return ds.Proposals.GetProposal(indexStr) +} diff --git a/examples/gno.land/r/moonia/home/dashboard.gno b/examples/gno.land/r/moonia/home/dashboard.gno new file mode 100644 index 00000000000..8658a693bca --- /dev/null +++ b/examples/gno.land/r/moonia/home/dashboard.gno @@ -0,0 +1,47 @@ +package home + +import ( + "std" + + "gno.land/p/moul/md" + "gno.land/p/moul/txlink" +) + +func renderDashboardDesc() string { + out := "Welcome to the platform that lists all active DAOs on Moonia.\n\n" + out += "🔍 You can explore each DAO’s activities and proposals freely.\n\n" + out += "🗳️ To **participate in votes or submit proposals**, you must request to join the DAO.\n" + out += "The admin will review and accept or decline your request.\n\n" + return out +} + +func renderDashboard() string { + // TODO: fix empty string for caller, for the moment -> change it manually + caller := std.Address("g15dz69sch7fkhc9gk57hpe4qea77thmy20apu9x") + out := "# Welcome to the DAO Hub\n\n" + out += renderDashboardDesc() + + out += "## ➕ Create a new DAO:\n" + out += md.Link("Create DAO", txlink.Call("CreateDAO", "args", "id", "args", "title", "args", "description")) + "\n\n" + + out += "## 🗂 Existing DAOs:\n" + if len(daoMap) == 0 { + out += "_No DAOs created yet._\n" + } else { + for id, dao := range daoMap { + out += "- " + md.Link(dao.Name, "/r/moonia/home?dao="+id) + " : " + dao.Description + "\n" + } + } + out += "\n## 👥 Your DAOs:\n" + found := false + for id, dao := range daoMap { + if dao.IsAdmin(caller) || dao.IsMember(caller) { + out += "- " + md.Link(dao.Name, "/r/moonia/home?dao="+id) + "\n" + found = true + } + } + if !found { + out += "_You are not a member of any DAO._\n" + } + return out +} diff --git a/examples/gno.land/r/moonia/home/gno.mod b/examples/gno.land/r/moonia/home/gno.mod new file mode 100644 index 00000000000..990965df8ba --- /dev/null +++ b/examples/gno.land/r/moonia/home/gno.mod @@ -0,0 +1 @@ +module gno.land/r/moonia/home \ No newline at end of file diff --git a/examples/gno.land/r/moonia/home/home.gno b/examples/gno.land/r/moonia/home/home.gno new file mode 100644 index 00000000000..cac44c9a800 --- /dev/null +++ b/examples/gno.land/r/moonia/home/home.gno @@ -0,0 +1,111 @@ +package home + +import ( + "std" + "strconv" + + "gno.land/p/moul/txlink" + "gno.land/p/moul/md" + "gno.land/p/moonia/dao" + "gno.land/p/moonia/utils" +) + +var ( + ds DAOState + daoMap map[string]*dao.DAO + proposalMap map[string]*dao.ProposalStore +) + +type DAOState struct { + DAO *dao.DAO + Proposals *dao.ProposalStore +} + +func init() { + daoMap = make(map[string]*dao.DAO) + proposalMap = make(map[string]*dao.ProposalStore) +} + +func countActive() int { + count := 0 + for _, p := range ds.Proposals.Proposals { + if p.Active { + count++ + } + } + return count +} + +func renderDAOStats() string { + out := "# Statistics for `" + ds.DAO.Name + "`\n" + totalProposals := len(ds.Proposals.Proposals) + activeProposals := countActive() + out += ds.DAO.Stats(totalProposals, activeProposals) + "\n" + + return out +} + +func CreateProposalSample(daoID string) string { + d := daoMap[daoID] + if d == nil { + panic("DAO not found: " + daoID) + } + ds.DAO = d + ds.Proposals = proposalMap[daoID] + if ds.Proposals == nil { + panic("ProposalStore not found for DAO: " + daoID) + } + return ds.Proposals.CreateProposal("Survey", "Would you like to visit Guatemala?", 30) +} + +func renderDAO(daoID string) string { + // TODO: fix empty string for caller, for the moment -> change it manually + caller := std.Address("g15dz69sch7fkhc9gk57hpe4qea77thmy20apu9x") + trueCaller := std.PreviousRealm().Address() // result: empty string + out := "# " + ds.DAO.Name + "\n\n" + out += "_" + ds.DAO.Description + "_\n\n" + out += ds.DAO.ShowAdmin() + "\n\n" + out += "Caller (debug): `" + trueCaller.String() + "`\n" + + out += "## Actions:\n\n" + if !ds.DAO.IsMember(caller) && ds.DAO.Admin != "" { + out += "- " + md.Link("Request to Join DAO", txlink.Call("RequestDAO")) + "\n" + } + out += "- " + md.Link("Leave DAO", txlink.Call("LeaveDAO")) + "\n" + out += "- " + md.Link("Create Sample Proposal", txlink.Call("CreateProposalSample", "args", "0")) + "\n" + out += "- " + md.Link("View Stats", "/r/moonia/home?dao="+daoID+"&stats") + "\n" + out += "- " + md.Link("Transfer Admin", txlink.Call("TransferAdmin", "args", "addr")) + "\n" + out += ds.DAO.ShowWhitelist() + "\n" + if ds.DAO.IsAdmin(caller) { + out += "\n## 📨 Join Requests: " + strconv.Itoa(len(ds.DAO.Requests)) + "\n" + if len(ds.DAO.Requests) == 0 { + out += "_No pending requests._\n" + } else { + for addr := range ds.DAO.Requests { + out += "- " + addr.String() + " " + + md.Link("[Accept]", txlink.Call("AcceptRequest", "args", addr.String())) + " " + + md.Link("[Decline]", txlink.Call("DeclineRequest", "args", addr.String())) + "\n" + } + } + } + out += ds.Proposals.ShowProposals() + return out +} + +func Render(path string) string { + if path == "" { + return renderDashboard() + } + daoID, showStats := utils.ParseQuery(path) + d := daoMap[daoID] + if d == nil { + return "DAO not found: `" + daoID + "`" + } + ds.DAO = d + ds.Proposals = proposalMap[daoID] + + if showStats { + return renderDAOStats() + } + return renderDAO(daoID) +}