Transaction

D5DD54554AEAE5…DBBA04E55A29

Block 421,108 · index 0 · indexed

Summary

Hash
D5DD54554AEAE5B710B9EC3E1ED5AAFACE7DFBE3980D4F7C3244DBBA04E55A29
Block
421,108
Size
29124 bytes
Gas used
39,003,996 / 42,904,362
Fee
42905 ugnot
Memo
Deploy realm gno.land/r/g1747t5m2f08plqjlrjk2q0qld7465hxz8gkx59c/friends_surf_club (storage deposit up to 17 GNOT)
Status
success

Messages

Attached funds
17000000 ugnot

Arguments · 5

  1. #1friends_surf_club
  2. #2friends_surf_club.gno
  3. #3package friends_surf_club import ( "chain" "strconv" "strings" "time" "unicode" "unicode/utf8" "gno.land/p/nt/avl/v0" ) // DAO realm generated by Memba, template memba-dao/2. // // Every change to membership, roles or the archived flag is a proposal that // members vote on. No member and no role has special powers. This realm holds // no funds. const ( TemplateVersion = "memba-dao/2" APIVersion = "2.0" maxMembers = 100 maxPower = 1_000_000_000 // total <= 1e11, so x100 stays far below MaxInt64 maxTitleLen = 128 maxDescLen = 8000 maxOpenPerMember = 10 maxPageSize = 50 renderPageSize = 20 kindText = "text" kindAddMember = "add_member" kindRemoveMember = "remove_member" kindSetRoles = "set_roles" kindArchive = "archive" statusActive = "ACTIVE" statusAccepted = "ACCEPTED" statusRejected = "REJECTED" statusExecuted = "EXECUTED" statusExpired = "EXPIRED" statusLapsed = "LAPSED" statusInvalidated = "INVALIDATED" statusArchived = "ARCHIVED" ) type Member struct { Address address Power int Roles []string } // Action is stored by value inside its proposal at propose time. It holds // primitives only and is never modified afterwards. The handler for an action // is chosen by Kind in Execute; there is no other dispatch. type Action struct { Kind string Target address Power int Roles []string } type VoteRecord struct { Choice string Power int } type Proposal struct { ID uint64 Title string Category string Author address Action Action Status string // stored: ACTIVE, ACCEPTED, REJECTED, EXECUTED Votes *avl.Tree // voter address -> VoteRecord Yes int No int Abstain int ElectorateVersion int ElectoratePower int CreatedAt int64 // unix seconds (block time) VotingEndsAt int64 AcceptedAt int64 // 0 until accepted FixedJSON string // JSON of the fields that never change, built once at propose time } var ( name = "Friends Surf Club" description = "Let's surf & ride with friends.\n#SurfTrip #Waves\n\nThis DAO is the made for testing purpose of Memba OS implementation, in order to experiment cross-features interactions, UX architecture, and UI implementations in a fun context.\n" nameJSON = "\"Friends Surf Club\"" // JSON-encoded once by the generator descriptionJSON = "\"Let's surf & ride with friends.\\n#SurfTrip #Waves\\n\\nThis DAO is the made for testing purpose of Memba OS implementation, in order to experiment cross-features interactions, UX architecture, and UI implementations in a fun context.\\n\"" threshold = 51 // % of all voting power that must vote YES quorum = 0 // % of all voting power that must vote (0 = none) votingPeriod = int64(259200) // seconds executionDelay = int64(3600) // seconds after acceptance executionWindow = int64(604800) // seconds after the delay allowedCategories = []string{"governance", "membership", "operations"} allowedRoles = []string{"admin", "member"} members = avl.NewTree() // address -> *Member memberTotalPower = 0 proposals = avl.NewTree() // zero-padded id -> *Proposal descriptions = avl.NewTree() // zero-padded id -> string (kept apart so list reads stay cheap) openByAuthor = avl.NewTree() // address -> []uint64 (ids that may still be open) lastID = uint64(0) electorateVersion = 0 // bumped when a member is added or removed archived = false ) func init() { addGenesisMember("g1747t5m2f08plqjlrjk2q0qld7465hxz8gkx59c", 2, []string{"admin"}) addGenesisMember("g12yg9nh4ncma44emgm8msxe8aavzywt0p95tanv", 1, []string{"member"}) addGenesisMember("g1cvr48r7l7lkmvp77cr6zg2zhu26jgfwr0y8pew", 1, []string{"member"}) addGenesisMember("g1rayfgklwl0aspz488wvrcrvt7t2quy6q06lgk2", 1, []string{"member"}) addGenesisMember("g1nvxz94ukmggfx4mlldwae3cpde99qmldtcgm9u", 1, []string{"member"}) addGenesisMember("g1jjeuv48j3pmsmnvga8jwfnc2amfyv9c30zx04y", 1, []string{"member"}) checkGenesis() } // ── Genesis ─────────────────────────────────────────────── func addGenesisMember(addr string, power int, roles []string) { a := address(addr) if !a.IsValid() { panic("genesis: invalid member address") } if !isLowerCase(a) { panic("genesis: member address must be lower case") } if members.Has(addr) { panic("genesis: duplicate member") } if power < 1 || power > maxPower { panic("genesis: member power out of range") } checkRoles(roles) members.Set(addr, &Member{Address: a, Power: power, Roles: roles}) memberTotalPower += power } func checkGenesis() { if members.Size() < 1 || members.Size() > maxMembers { panic("genesis: member count out of range") } if threshold <= 50 || threshold > 100 { panic("genesis: threshold must be above 50 and at most 100") } if quorum < 0 || quorum > 100 { panic("genesis: quorum out of range") } if votingPeriod < 3600 || votingPeriod > 30*86400 { panic("genesis: voting period out of range") } // At least an hour between acceptance and execution, so members see a // decision before it applies, even when one member can pass it alone. if executionDelay < 3600 || executionDelay > 7*86400 { panic("genesis: execution delay out of range") } if executionWindow < 86400 || executionWindow > 30*86400 { panic("genesis: execution window out of range") } if len(allowedCategories) < 1 { panic("genesis: at least one category is required") } } // ── Validation (no writes) ──────────────────────────────── func now() int64 { return time.Now().Unix() } func assertNotArchived() { if archived { panic("DAO is archived") } } func getMember(addr address) *Member { m, ok := members.Get(string(addr)).(*Member) if !ok { return nil } return m } func assertMember(addr address) *Member { m := getMember(addr) if m == nil || m.Power < 1 { panic("caller is not a member") } return m } // isLowerCase refuses the upper-case bech32 spelling, which IsValid accepts: // it names the same account under a different member key. Only call it on an // address that passed IsValid: bech32 refuses mixed case, so the first // character decides (a byte loop would cost gas per character per member). func isLowerCase(a address) bool { s := string(a) return len(s) > 0 && s[0] >= 'a' && s[0] <= 'z' } func contains(list []string, s string) bool { for _, v := range list { if v == s { return true } } return false } func checkRoles(roles []string) { for i, r := range roles { if !contains(allowedRoles, r) { panic("invalid role") } for j := 0; j < i; j++ { if roles[j] == r { panic("duplicate role") } } } } func parseRoles(csv string) []string { if csv == "" { return []string{} } roles := strings.Split(csv, ",") if len(roles) > len(allowedRoles) { panic("too many roles") } checkRoles(roles) return roles } func isControl(r rune) bool { return r < 0x20 || (r >= 0x7f && r <= 0x9f) || r == 0x2028 || r == 0x2029 } func checkTitle(title string) { if !utf8.ValidString(title) { panic("title must be valid UTF-8") } n := utf8.RuneCountInString(title) if n < 1 || n > maxTitleLen { panic("title must be 1 to 128 characters") } for _, r := range title { if isControl(r) { panic("title must be a single line without control characters") } // Format characters (zero-width, bidi controls, byte-order mark) are // invisible and can reorder how the rest of the line is displayed. if unicode.Is(unicode.Cf, r) { panic("title must not contain invisible formatting characters") } } if strings.TrimSpace(title) == "" { panic("title must not be blank") } } func checkDescription(desc string) { if !utf8.ValidString(desc) { panic("description must be valid UTF-8") } if utf8.RuneCountInString(desc) > maxDescLen { panic("description must be at most 8000 characters") } for _, r := range desc { if isControl(r) && r != '\n' && r != '\t' { panic("description contains control characters") } } } // validateAction checks an action against the current state. Propose calls it // before storing the proposal and Execute calls it again before applying. func validateAction(a Action) { switch a.Kind { case kindText, kindArchive: if a.Target != "" || a.Power != 0 || len(a.Roles) != 0 { panic("this action takes no target, power or roles") } case kindAddMember: if !a.Target.IsValid() { panic("invalid member address") } if !isLowerCase(a.Target) { panic("member address must be lower case") } if getMember(a.Target) != nil { panic("address is already a member") } if a.Power < 1 || a.Power > maxPower { panic("member power must be between 1 and 1000000000") } if members.Size()+1 > maxMembers { panic("member limit reached") } checkRoles(a.Roles) case kindRemoveMember: m := getMember(a.Target) if m == nil { panic("target is not a member") } if members.Size()-1 < 1 || memberTotalPower-m.Power < 1 { panic("cannot remove the last member") } case kindSetRoles: if getMember(a.Target) == nil { panic("target is not a member") } checkRoles(a.Roles) default: panic("unknown action kind") } } func padID(id uint64) string { s := strconv.FormatUint(id, 10) for len(s) < 20 { s = "0" + s } return s } func getProposal(id uint64) *Proposal { p, ok := proposals.Get(padID(id)).(*Proposal) if !ok { return nil } return p } func proposalDescription(id uint64) string { desc, _ := descriptions.Get(padID(id)).(string) return desc } func mustProposal(id uint64) *Proposal { p := getProposal(id) if p == nil { panic("proposal not found") } return p } // derivedStatus is the status every read and write relies on. Time-based and // membership-based transitions are computed here, never written. func derivedStatus(p *Proposal) string { // Once archived, nothing can be voted or executed any more. if archived && (p.Status == statusActive || p.Status == statusAccepted) { return statusArchived } t := now() if p.Status == statusActive { if p.ElectorateVersion != electorateVersion { return statusInvalidated } if t >= p.VotingEndsAt { return statusExpired } } if p.Status == statusAccepted && t > p.AcceptedAt+executionDelay+executionWindow { return statusLapsed } return p.Status } // openIDs returns the author's proposals that are still open for voting. The // stored list never grows past maxOpenPerMember, so this stays bounded. func openIDs(author address) []uint64 { ids, _ := openByAuthor.Get(string(author)).([]uint64) open := []uint64{} for _, id := range ids { p := getProposal(id) if p != nil && derivedStatus(p) == statusActive { open = append(open, id) } } return open } // ── Propose ─────────────────────────────────────────────── func propose(caller address, title, desc, category string, a Action) uint64 { assertNotArchived() assertMember(caller) checkTitle(title) checkDescription(desc) validateAction(a) open := openIDs(caller) if len(open) >= maxOpenPerMember { panic("too many open proposals for this member") } // Every check passed; writes start here. lastID++ id := lastID t := now() p := &Proposal{ ID: id, Title: title, Category: category, Author: caller, Action: a, Status: statusActive, Votes: avl.NewTree(), ElectorateVersion: electorateVersion, ElectoratePower: memberTotalPower, CreatedAt: t, VotingEndsAt: t + votingPeriod, } p.FixedJSON = fixedJSON(p) proposals.Set(padID(id), p) descriptions.Set(padID(id), desc) openByAuthor.Set(string(caller), append(open, id)) chain.Emit("ProposalCreated", "id", strconv.FormatUint(id, 10), "kind", a.Kind) return id } // ProposeText creates a proposal with no on-chain effect. func ProposeText(cur realm, title, desc, category string) uint64 { caller := cur.Previous().Address() if !contains(allowedCategories, category) { panic("invalid proposal category") } return propose(caller, title, desc, category, Action{Kind: kindText}) } // ProposeAddMember proposes adding target with power and comma-separated roles. func ProposeAddMember(cur realm, title, desc string, target address, power int, rolesCSV string) uint64 { caller := cur.Previous().Address() roles := parseRoles(rolesCSV) return propose(caller, title, desc, "membership", Action{Kind: kindAddMember, Target: target, Power: power, Roles: roles}) } // ProposeRemoveMember proposes removing target. func ProposeRemoveMember(cur realm, title, desc string, target address) uint64 { caller := cur.Previous().Address() return propose(caller, title, desc, "membership", Action{Kind: kindRemoveMember, Target: target}) } // ProposeSetRoles proposes replacing target's role labels. func ProposeSetRoles(cur realm, title, desc string, target address, rolesCSV string) uint64 { caller := cur.Previous().Address() roles := parseRoles(rolesCSV) return propose(caller, title, desc, "membership", Action{Kind: kindSetRoles, Target: target, Roles: roles}) } // ProposeArchive proposes archiving the DAO. Archiving is permanent. func ProposeArchive(cur realm, title, desc string) uint64 { caller := cur.Previous().Address() return propose(caller, title, desc, "governance", Action{Kind: kindArchive}) } // ── Vote ────────────────────────────────────────────────── // reached reports part/whole >= pct/100 without division. part and whole are // at most maxMembers*maxPower (1e11), so part*100 cannot overflow. func reached(part, whole, pct int) bool { return part*100 >= whole*pct } // Vote records the caller's YES, NO or ABSTAIN. A proposal is ACCEPTED once // YES alone reaches the threshold of all voting power (and quorum is met), and // REJECTED once that has become impossible. func Vote(cur realm, id uint64, choice string) { caller := cur.Previous().Address() if choice != "YES" && choice != "NO" && choice != "ABSTAIN" { panic("invalid vote: must be YES, NO or ABSTAIN") } assertNotArchived() m := assertMember(caller) p := mustProposal(id) if p.Status != statusActive { panic("proposal is not open for voting") } if p.ElectorateVersion != electorateVersion { panic("proposal invalidated by a membership change") } if now() >= p.VotingEndsAt { panic("voting period has ended") } if p.Votes.Has(string(caller)) { panic("already voted") } // Every check passed; writes start here. p.Votes.Set(string(caller), VoteRecord{Choice: choice, Power: m.Power}) switch choice { case "YES": p.Yes += m.Power case "NO": p.No += m.Power default: p.Abstain += m.Power } cast := p.Yes + p.No + p.Abstain ep := p.ElectoratePower if (quorum == 0 || reached(cast, ep, quorum)) && reached(p.Yes, ep, threshold) { p.Status = statusAccepted p.AcceptedAt = now() } else if !reached(p.Yes+(ep-cast), ep, threshold) { p.Status = statusRejected } chain.Emit("VoteCast", "id", strconv.FormatUint(id, 10), "voter", string(caller), "choice", choice) } // ── Execute ─────────────────────────────────────────────── // Execute applies an accepted proposal after the execution delay and before // the execution window closes. The stored action is checked again against the // current state first; the proposal is marked EXECUTED only after it applied. func Execute(cur realm, id uint64) { caller := cur.Previous().Address() assertNotArchived() assertMember(caller) p := mustProposal(id) if derivedStatus(p) != statusAccepted { panic("proposal is not accepted, or its execution window has passed") } t := now() if t < p.AcceptedAt+executionDelay { panic("execution delay has not elapsed") } if t > p.AcceptedAt+executionDelay+executionWindow { panic("execution window has passed") } a := p.Action validateAction(a) // Every check passed; writes start here and cannot fail. switch a.Kind { case kindText: // no on-chain effect case kindAddMember: roles := make([]string, len(a.Roles)) copy(roles, a.Roles) members.Set(string(a.Target), &Member{Address: a.Target, Power: a.Power, Roles: roles}) memberTotalPower += a.Power electorateVersion++ case kindRemoveMember: // Removing a member frees storage; by chain rules the storage deposit it // freed is refunded to whoever executes the removal, not to the payer. m := getMember(a.Target) members.Remove(string(a.Target)) memberTotalPower -= m.Power electorateVersion++ case kindSetRoles: roles := make([]string, len(a.Roles)) copy(roles, a.Roles) getMember(a.Target).Roles = roles case kindArchive: archived = true } p.Status = statusExecuted idStr := strconv.FormatUint(id, 10) chain.Emit("ProposalExecuted", "id", idStr, "kind", a.Kind) if a.Kind == kindArchive { chain.Emit("DAOArchived", "id", idStr) } } // ── Reads (non-crossing, bounded, no calls to other realms) ── func GetTemplateVersion() string { return TemplateVersion } func GetAPIVersion() string { return APIVersion } // IsMember lets a companion realm check membership. func IsMember(addr address) bool { m := getMember(addr) return m != nil && m.Power >= 1 } func IsArchived() bool { return archived } func HasVoted(id uint64, addr address) bool { p := getProposal(id) return p != nil && p.Votes.Has(string(addr)) } type jsonBuf struct{ b strings.Builder } func (j *jsonBuf) raw(s string) { j.b.WriteString(s) } func (j *jsonBuf) str(s string) { j.b.WriteByte('"') for _, r := range s { switch { case r == '"': j.b.WriteString("\\\"") case r == '\\': j.b.WriteString("\\\\") case r == '\n': j.b.WriteString("\\n") case r == '\t': j.b.WriteString("\\t") case r == '\r': j.b.WriteString("\\r") case r < 0x20 || r == 0x2028 || r == 0x2029: h := strconv.FormatInt(int64(r), 16) for len(h) < 4 { h = "0" + h } j.b.WriteString("\\u" + h) default: j.b.WriteRune(r) } } j.b.WriteByte('"') } func (j *jsonBuf) key(k string, first bool) { if !first { j.b.WriteByte(',') } j.b.WriteByte('"') j.b.WriteString(k) j.b.WriteString("\":") } func (j *jsonBuf) num(v int) { j.b.WriteString(strconv.Itoa(v)) } func (j *jsonBuf) num64(v int64) { j.b.WriteString(strconv.FormatInt(v, 10)) } func (j *jsonBuf) id(v uint64) { j.b.WriteString(strconv.FormatUint(v, 10)) } func (j *jsonBuf) flag(v bool) { if v { j.b.WriteString("true") } else { j.b.WriteString("false") } } // plain quotes a value that cannot contain characters needing escapes: // addresses (validated bech32), role and category identifiers, and constants. // Escaping rune by rune is costly in the VM, so it is done once at write time // for user text and skipped here. func (j *jsonBuf) plain(s string) { j.b.WriteByte('"') j.b.WriteString(s) j.b.WriteByte('"') } func (j *jsonBuf) plains(list []string) { j.b.WriteByte('[') for i, s := range list { if i > 0 { j.b.WriteByte(',') } j.plain(s) } j.b.WriteByte(']') } func checkPage(offset, limit int) { if offset < 0 { panic("offset must not be negative") } if limit < 1 || limit > maxPageSize { panic("limit must be between 1 and 50") } } func writeConfig(j *jsonBuf) { j.raw("{") j.key("template_version", true) j.plain(TemplateVersion) j.key("api_version", false) j.plain(APIVersion) j.key("name", false) j.raw(nameJSON) j.key("description", false) j.raw(descriptionJSON) j.key("threshold", false) j.num(threshold) j.key("quorum", false) j.num(quorum) j.key("voting_period", false) j.num64(votingPeriod) j.key("execution_delay", false) j.num64(executionDelay) j.key("execution_window", false) j.num64(executionWindow) j.key("categories", false) j.plains(allowedCategories) j.key("roles", false) j.plains(allowedRoles) j.key("archived", false) j.flag(archived) j.key("member_count", false) j.num(members.Size()) j.key("total_power", false) j.num(memberTotalPower) j.key("electorate_version", false) j.num(electorateVersion) j.key("proposal_count", false) j.id(lastID) j.raw("}") } // GetConfigJSON returns the DAO's settings and current totals. func GetConfigJSON() string { j := &jsonBuf{} writeConfig(j) return j.b.String() } func writeMembers(j *jsonBuf, offset, limit int) { checkPage(offset, limit) j.raw("{") j.key("total", true) j.num(members.Size()) j.key("offset", false) j.num(offset) j.key("members", false) j.raw("[") first := true members.IterateByOffset(offset, limit, func(_ string, v any) bool { m := v.(*Member) if !first { j.raw(",") } first = false j.raw("{") j.key("address", true) j.plain(string(m.Address)) j.key("power", false) j.num(m.Power) j.key("roles", false) j.plains(m.Roles) j.raw("}") return false }) j.raw("]}") } // GetMembersJSON returns one page of members (limit 1..50). func GetMembersJSON(offset, limit int) string { j := &jsonBuf{} writeMembers(j, offset, limit) return j.b.String() } // fixedJSON encodes the proposal fields that never change. The action's // target is a validated address (or empty) and its roles are identifiers. func fixedJSON(p *Proposal) string { j := &jsonBuf{} j.key("id", true) j.id(p.ID) j.key("title", false) j.str(p.Title) j.key("category", false) j.plain(p.Category) j.key("author", false) j.plain(string(p.Author)) j.key("action", false) j.raw("{") j.key("kind", true) j.plain(p.Action.Kind) j.key("target", false) j.plain(string(p.Action.Target)) j.key("power", false) j.num(p.Action.Power) j.key("roles", false) j.plains(p.Action.Roles) j.raw("}") j.key("electorate_power", false) j.num(p.ElectoratePower) j.key("electorate_version", false) j.num(p.ElectorateVersion) j.key("created_at", false) j.num64(p.CreatedAt) j.key("voting_ends_at", false) j.num64(p.VotingEndsAt) return j.b.String() } func writeProposalFields(j *jsonBuf, p *Proposal) { j.raw(p.FixedJSON) j.key("status", false) j.plain(derivedStatus(p)) j.key("yes", false) j.num(p.Yes) j.key("no", false) j.num(p.No) j.key("abstain", false) j.num(p.Abstain) j.key("accepted_at", false) j.num64(p.AcceptedAt) executableAt := int64(0) executeBy := int64(0) if p.AcceptedAt > 0 { executableAt = p.AcceptedAt + executionDelay executeBy = executableAt + executionWindow } j.key("executable_at", false) j.num64(executableAt) j.key("execute_by", false) j.num64(executeBy) } // GetProposalsJSON lists proposals newest first (limit 1..50). before=0 starts // at the newest; pass the returned next_before to get the following page // (next_before 0 means there is none). Descriptions are only returned by // GetProposalJSON, which keeps a full page small. func GetProposalsJSON(before uint64, limit int) string { checkPage(0, limit) j := &jsonBuf{} j.raw("{") j.key("proposals", true) j.raw("[") end := "" if before > 0 { end = padID(before - 1) } count := 0 nextBefore := uint64(0) proposals.ReverseIterate("", end, func(_ string, v any) bool { p := v.(*Proposal) if count == limit { nextBefore = p.ID + 1 return true } if count > 0 { j.raw(",") } j.raw("{") writeProposalFields(j, p) j.raw("}") count++ return false }) j.raw("]") j.key("next_before", false) j.id(nextBefore) j.raw("}") return j.b.String() } // GetProposalJSON returns one proposal including its description. func GetProposalJSON(id uint64) string { p := mustProposal(id) j := &jsonBuf{} j.raw("{") writeProposalFields(j, p) j.key("description", false) j.str(proposalDescription(p.ID)) j.raw("}") return j.b.String() } // GetVotesJSON returns one page of a proposal's votes (limit 1..50). func GetVotesJSON(id uint64, offset, limit int) string { checkPage(offset, limit) p := mustProposal(id) j := &jsonBuf{} j.raw("{") j.key("total", true) j.num(p.Votes.Size()) j.key("offset", false) j.num(offset) j.key("votes", false) j.raw("[") first := true p.Votes.IterateByOffset(offset, limit, func(k string, v any) bool { vote := v.(VoteRecord) if !first { j.raw(",") } first = false j.raw("{") j.key("voter", true) j.plain(k) j.key("choice", false) j.plain(vote.Choice) j.key("power", false) j.num(vote.Power) j.raw("}") return false }) j.raw("]}") return j.b.String() } // GetStateJSON exports the settings and one page of members, so the DAO can // be recreated elsewhere. func GetStateJSON(offset, limit int) string { j := &jsonBuf{} j.raw("{") j.key("config", true) writeConfig(j) j.key("members", false) writeMembers(j, offset, limit) j.raw("}") return j.b.String() } // ── Render (gnoweb) ─────────────────────────────────────── // escapeInline makes a user string safe on one markdown line: markdown // punctuation is backslash-escaped and control characters become spaces. func escapeInline(s string) string { var b strings.Builder for _, r := range s { switch r { case 0x5c, '*', '_', '[', ']', '(', ')', '#', '<', '>', '|', '!', 0x60: b.WriteByte(0x5c) b.WriteRune(r) default: if isControl(r) { b.WriteByte(' ') } else { b.WriteRune(r) } } } return b.String() } // quoteBlock renders multi-line text as a quote, one escaped line at a time. func quoteBlock(s string) string { var b strings.Builder for _, line := range strings.Split(s, "\n") { b.WriteString("> ") b.WriteString(escapeInline(line)) b.WriteString("\n") } return b.String() } func Render(path string) string { if path == "" { return renderHome(1) } if strings.HasPrefix(path, "?page=") { page, err := strconv.Atoi(strings.TrimPrefix(path, "?page=")) if err == nil && page >= 1 { return renderHome(page) } return "# Not found\n" } id, err := strconv.ParseUint(path, 10, 64) if err == nil { if p := getProposal(id); p != nil { return renderProposal(p) } } return "# Not found\n" } func renderHome(page int) string { var b strings.Builder b.WriteString("# " + escapeInline(name) + "\n\n") if description != "" { b.WriteString(quoteBlock(description) + "\n") } b.WriteString("Threshold: " + strconv.Itoa(threshold) + "% of all voting power. Quorum: " + strconv.Itoa(quorum) + "%.\n\n") if archived { b.WriteString("This DAO is archived.\n\n") } b.WriteString("## Members (" + strconv.Itoa(members.Size()) + ")\n\n") members.Iterate("", "", func(_ string, v any) bool { m := v.(*Member) b.WriteString("- " + string(m.Address) + " (power " + strconv.Itoa(m.Power) + ")\n") return false }) b.WriteString("\n## Proposals\n\n") total := proposals.Size() if total == 0 { b.WriteString("No proposals yet.\n") return b.String() } proposals.ReverseIterateByOffset((page-1)*renderPageSize, renderPageSize, func(_ string, v any) bool { p := v.(*Proposal) id := strconv.FormatUint(p.ID, 10) b.WriteString("- [#" + id + "](:" + id + ") " + escapeInline(p.Title) + " (" + p.Action.Kind + ", " + derivedStatus(p) + ")\n") return false }) pages := (total + renderPageSize - 1) / renderPageSize if pages > 1 { b.WriteString("\nPage " + strconv.Itoa(page) + " of " + strconv.Itoa(pages) + ":") for i := 1; i <= pages; i++ { b.WriteString(" [" + strconv.Itoa(i) + "](?page=" + strconv.Itoa(i) + ")") } b.WriteString("\n") } return b.String() } func renderProposal(p *Proposal) string { var b strings.Builder id := strconv.FormatUint(p.ID, 10) b.WriteString("# Proposal " + id + ": " + escapeInline(p.Title) + "\n\n") b.WriteString("Kind: " + p.Action.Kind + "\n\n") if p.Action.Target != "" { b.WriteString("Target: " + escapeInline(string(p.Action.Target)) + "\n\n") } b.WriteString("Status: " + derivedStatus(p) + "\n\n") b.WriteString("Author: " + string(p.Author) + "\n\n") b.WriteString("YES " + strconv.Itoa(p.Yes) + ", NO " + strconv.Itoa(p.No) + ", ABSTAIN " + strconv.Itoa(p.Abstain) + " of " + strconv.Itoa(p.ElectoratePower) + "\n\n") if desc := proposalDescription(p.ID); desc != "" { b.WriteString(quoteBlock(desc)) } return b.String() }
  4. #4gnomod.toml
  5. #5module = "gno.land/r/g1747t5m2f08plqjlrjk2q0qld7465hxz8gkx59c/friends_surf_club" gno = "0.9"

Result log

msg:0,success:true,log:,events:[]