1package friends_surf_club23import (4 "chain"5 "strconv"6 "strings"7 "time"8 "unicode"9 "unicode/utf8"1011 "gno.land/p/nt/avl/v0"12)1314// DAO realm generated by Memba, template memba-dao/2.15//16// Every change to membership, roles or the archived flag is a proposal that17// members vote on. No member and no role has special powers. This realm holds18// no funds.1920const (21 TemplateVersion = "memba-dao/2"22 APIVersion = "2.0"23 maxMembers = 10024 maxPower = 1_000_000_000 // total <= 1e11, so x100 stays far below MaxInt6425 maxTitleLen = 12826 maxDescLen = 800027 maxOpenPerMember = 1028 maxPageSize = 5029 renderPageSize = 203031 kindText = "text"32 kindAddMember = "add_member"33 kindRemoveMember = "remove_member"34 kindSetRoles = "set_roles"35 kindArchive = "archive"3637 statusActive = "ACTIVE"38 statusAccepted = "ACCEPTED"39 statusRejected = "REJECTED"40 statusExecuted = "EXECUTED"41 statusExpired = "EXPIRED"42 statusLapsed = "LAPSED"43 statusInvalidated = "INVALIDATED"44 statusArchived = "ARCHIVED"45)4647type Member struct {48 Address address49 Power int50 Roles []string51}5253// Action is stored by value inside its proposal at propose time. It holds54// primitives only and is never modified afterwards. The handler for an action55// is chosen by Kind in Execute; there is no other dispatch.56type Action struct {57 Kind string58 Target address59 Power int60 Roles []string61}6263type VoteRecord struct {64 Choice string65 Power int66}6768type Proposal struct {69 ID uint6470 Title string71 Category string72 Author address73 Action Action74 Status string // stored: ACTIVE, ACCEPTED, REJECTED, EXECUTED75 Votes *avl.Tree // voter address -> VoteRecord76 Yes int77 No int78 Abstain int79 ElectorateVersion int80 ElectoratePower int81 CreatedAt int64 // unix seconds (block time)82 VotingEndsAt int6483 AcceptedAt int64 // 0 until accepted84 FixedJSON string // JSON of the fields that never change, built once at propose time85}8687var (88 name = "Friends Surf Club"89 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"90 nameJSON = "\"Friends Surf Club\"" // JSON-encoded once by the generator91 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\""92 threshold = 51 // % of all voting power that must vote YES93 quorum = 0 // % of all voting power that must vote (0 = none)94 votingPeriod = int64(259200) // seconds95 executionDelay = int64(3600) // seconds after acceptance96 executionWindow = int64(604800) // seconds after the delay9798 allowedCategories = []string{"governance", "membership", "operations"}99 allowedRoles = []string{"admin", "member"}100101 members = avl.NewTree() // address -> *Member102 memberTotalPower = 0103 proposals = avl.NewTree() // zero-padded id -> *Proposal104 descriptions = avl.NewTree() // zero-padded id -> string (kept apart so list reads stay cheap)105 openByAuthor = avl.NewTree() // address -> []uint64 (ids that may still be open)106 lastID = uint64(0)107 electorateVersion = 0 // bumped when a member is added or removed108 archived = false109)110111func init() {112 addGenesisMember("g1747t5m2f08plqjlrjk2q0qld7465hxz8gkx59c", 2, []string{"admin"})113 addGenesisMember("g12yg9nh4ncma44emgm8msxe8aavzywt0p95tanv", 1, []string{"member"})114 addGenesisMember("g1cvr48r7l7lkmvp77cr6zg2zhu26jgfwr0y8pew", 1, []string{"member"})115 addGenesisMember("g1rayfgklwl0aspz488wvrcrvt7t2quy6q06lgk2", 1, []string{"member"})116 addGenesisMember("g1nvxz94ukmggfx4mlldwae3cpde99qmldtcgm9u", 1, []string{"member"})117 addGenesisMember("g1jjeuv48j3pmsmnvga8jwfnc2amfyv9c30zx04y", 1, []string{"member"})118 checkGenesis()119}120121// ── Genesis ───────────────────────────────────────────────122123func addGenesisMember(addr string, power int, roles []string) {124 a := address(addr)125 if !a.IsValid() {126 panic("genesis: invalid member address")127 }128 if !isLowerCase(a) {129 panic("genesis: member address must be lower case")130 }131 if members.Has(addr) {132 panic("genesis: duplicate member")133 }134 if power < 1 || power > maxPower {135 panic("genesis: member power out of range")136 }137 checkRoles(roles)138 members.Set(addr, &Member{Address: a, Power: power, Roles: roles})139 memberTotalPower += power140}141142func checkGenesis() {143 if members.Size() < 1 || members.Size() > maxMembers {144 panic("genesis: member count out of range")145 }146 if threshold <= 50 || threshold > 100 {147 panic("genesis: threshold must be above 50 and at most 100")148 }149 if quorum < 0 || quorum > 100 {150 panic("genesis: quorum out of range")151 }152 if votingPeriod < 3600 || votingPeriod > 30*86400 {153 panic("genesis: voting period out of range")154 }155 // At least an hour between acceptance and execution, so members see a156 // decision before it applies, even when one member can pass it alone.157 if executionDelay < 3600 || executionDelay > 7*86400 {158 panic("genesis: execution delay out of range")159 }160 if executionWindow < 86400 || executionWindow > 30*86400 {161 panic("genesis: execution window out of range")162 }163 if len(allowedCategories) < 1 {164 panic("genesis: at least one category is required")165 }166}167168// ── Validation (no writes) ────────────────────────────────169170func now() int64 { return time.Now().Unix() }171172func assertNotArchived() {173 if archived {174 panic("DAO is archived")175 }176}177178func getMember(addr address) *Member {179 m, ok := members.Get(string(addr)).(*Member)180 if !ok {181 return nil182 }183 return m184}185186func assertMember(addr address) *Member {187 m := getMember(addr)188 if m == nil || m.Power < 1 {189 panic("caller is not a member")190 }191 return m192}193194// isLowerCase refuses the upper-case bech32 spelling, which IsValid accepts:195// it names the same account under a different member key. Only call it on an196// address that passed IsValid: bech32 refuses mixed case, so the first197// character decides (a byte loop would cost gas per character per member).198func isLowerCase(a address) bool {199 s := string(a)200 return len(s) > 0 && s[0] >= 'a' && s[0] <= 'z'201}202203func contains(list []string, s string) bool {204 for _, v := range list {205 if v == s {206 return true207 }208 }209 return false210}211212func checkRoles(roles []string) {213 for i, r := range roles {214 if !contains(allowedRoles, r) {215 panic("invalid role")216 }217 for j := 0; j < i; j++ {218 if roles[j] == r {219 panic("duplicate role")220 }221 }222 }223}224225func parseRoles(csv string) []string {226 if csv == "" {227 return []string{}228 }229 roles := strings.Split(csv, ",")230 if len(roles) > len(allowedRoles) {231 panic("too many roles")232 }233 checkRoles(roles)234 return roles235}236237func isControl(r rune) bool {238 return r < 0x20 || (r >= 0x7f && r <= 0x9f) || r == 0x2028 || r == 0x2029239}240241func checkTitle(title string) {242 if !utf8.ValidString(title) {243 panic("title must be valid UTF-8")244 }245 n := utf8.RuneCountInString(title)246 if n < 1 || n > maxTitleLen {247 panic("title must be 1 to 128 characters")248 }249 for _, r := range title {250 if isControl(r) {251 panic("title must be a single line without control characters")252 }253 // Format characters (zero-width, bidi controls, byte-order mark) are254 // invisible and can reorder how the rest of the line is displayed.255 if unicode.Is(unicode.Cf, r) {256 panic("title must not contain invisible formatting characters")257 }258 }259 if strings.TrimSpace(title) == "" {260 panic("title must not be blank")261 }262}263264func checkDescription(desc string) {265 if !utf8.ValidString(desc) {266 panic("description must be valid UTF-8")267 }268 if utf8.RuneCountInString(desc) > maxDescLen {269 panic("description must be at most 8000 characters")270 }271 for _, r := range desc {272 if isControl(r) && r != '\n' && r != '\t' {273 panic("description contains control characters")274 }275 }276}277278// validateAction checks an action against the current state. Propose calls it279// before storing the proposal and Execute calls it again before applying.280func validateAction(a Action) {281 switch a.Kind {282 case kindText, kindArchive:283 if a.Target != "" || a.Power != 0 || len(a.Roles) != 0 {284 panic("this action takes no target, power or roles")285 }286 case kindAddMember:287 if !a.Target.IsValid() {288 panic("invalid member address")289 }290 if !isLowerCase(a.Target) {291 panic("member address must be lower case")292 }293 if getMember(a.Target) != nil {294 panic("address is already a member")295 }296 if a.Power < 1 || a.Power > maxPower {297 panic("member power must be between 1 and 1000000000")298 }299 if members.Size()+1 > maxMembers {300 panic("member limit reached")301 }302 checkRoles(a.Roles)303 case kindRemoveMember:304 m := getMember(a.Target)305 if m == nil {306 panic("target is not a member")307 }308 if members.Size()-1 < 1 || memberTotalPower-m.Power < 1 {309 panic("cannot remove the last member")310 }311 case kindSetRoles:312 if getMember(a.Target) == nil {313 panic("target is not a member")314 }315 checkRoles(a.Roles)316 default:317 panic("unknown action kind")318 }319}320321func padID(id uint64) string {322 s := strconv.FormatUint(id, 10)323 for len(s) < 20 {324 s = "0" + s325 }326 return s327}328329func getProposal(id uint64) *Proposal {330 p, ok := proposals.Get(padID(id)).(*Proposal)331 if !ok {332 return nil333 }334 return p335}336337func proposalDescription(id uint64) string {338 desc, _ := descriptions.Get(padID(id)).(string)339 return desc340}341342func mustProposal(id uint64) *Proposal {343 p := getProposal(id)344 if p == nil {345 panic("proposal not found")346 }347 return p348}349350// derivedStatus is the status every read and write relies on. Time-based and351// membership-based transitions are computed here, never written.352func derivedStatus(p *Proposal) string {353 // Once archived, nothing can be voted or executed any more.354 if archived && (p.Status == statusActive || p.Status == statusAccepted) {355 return statusArchived356 }357 t := now()358 if p.Status == statusActive {359 if p.ElectorateVersion != electorateVersion {360 return statusInvalidated361 }362 if t >= p.VotingEndsAt {363 return statusExpired364 }365 }366 if p.Status == statusAccepted && t > p.AcceptedAt+executionDelay+executionWindow {367 return statusLapsed368 }369 return p.Status370}371372// openIDs returns the author's proposals that are still open for voting. The373// stored list never grows past maxOpenPerMember, so this stays bounded.374func openIDs(author address) []uint64 {375 ids, _ := openByAuthor.Get(string(author)).([]uint64)376 open := []uint64{}377 for _, id := range ids {378 p := getProposal(id)379 if p != nil && derivedStatus(p) == statusActive {380 open = append(open, id)381 }382 }383 return open384}385386// ── Propose ───────────────────────────────────────────────387388func propose(caller address, title, desc, category string, a Action) uint64 {389 assertNotArchived()390 assertMember(caller)391 checkTitle(title)392 checkDescription(desc)393 validateAction(a)394 open := openIDs(caller)395 if len(open) >= maxOpenPerMember {396 panic("too many open proposals for this member")397 }398 // Every check passed; writes start here.399 lastID++400 id := lastID401 t := now()402 p := &Proposal{403 ID: id,404 Title: title,405 Category: category,406 Author: caller,407 Action: a,408 Status: statusActive,409 Votes: avl.NewTree(),410 ElectorateVersion: electorateVersion,411 ElectoratePower: memberTotalPower,412 CreatedAt: t,413 VotingEndsAt: t + votingPeriod,414 }415 p.FixedJSON = fixedJSON(p)416 proposals.Set(padID(id), p)417 descriptions.Set(padID(id), desc)418 openByAuthor.Set(string(caller), append(open, id))419 chain.Emit("ProposalCreated", "id", strconv.FormatUint(id, 10), "kind", a.Kind)420 return id421}422423// ProposeText creates a proposal with no on-chain effect.424func ProposeText(cur realm, title, desc, category string) uint64 {425 caller := cur.Previous().Address()426 if !contains(allowedCategories, category) {427 panic("invalid proposal category")428 }429 return propose(caller, title, desc, category, Action{Kind: kindText})430}431432// ProposeAddMember proposes adding target with power and comma-separated roles.433func ProposeAddMember(cur realm, title, desc string, target address, power int, rolesCSV string) uint64 {434 caller := cur.Previous().Address()435 roles := parseRoles(rolesCSV)436 return propose(caller, title, desc, "membership", Action{Kind: kindAddMember, Target: target, Power: power, Roles: roles})437}438439// ProposeRemoveMember proposes removing target.440func ProposeRemoveMember(cur realm, title, desc string, target address) uint64 {441 caller := cur.Previous().Address()442 return propose(caller, title, desc, "membership", Action{Kind: kindRemoveMember, Target: target})443}444445// ProposeSetRoles proposes replacing target's role labels.446func ProposeSetRoles(cur realm, title, desc string, target address, rolesCSV string) uint64 {447 caller := cur.Previous().Address()448 roles := parseRoles(rolesCSV)449 return propose(caller, title, desc, "membership", Action{Kind: kindSetRoles, Target: target, Roles: roles})450}451452// ProposeArchive proposes archiving the DAO. Archiving is permanent.453func ProposeArchive(cur realm, title, desc string) uint64 {454 caller := cur.Previous().Address()455 return propose(caller, title, desc, "governance", Action{Kind: kindArchive})456}457458// ── Vote ──────────────────────────────────────────────────459460// reached reports part/whole >= pct/100 without division. part and whole are461// at most maxMembers*maxPower (1e11), so part*100 cannot overflow.462func reached(part, whole, pct int) bool {463 return part*100 >= whole*pct464}465466// Vote records the caller's YES, NO or ABSTAIN. A proposal is ACCEPTED once467// YES alone reaches the threshold of all voting power (and quorum is met), and468// REJECTED once that has become impossible.469func Vote(cur realm, id uint64, choice string) {470 caller := cur.Previous().Address()471 if choice != "YES" && choice != "NO" && choice != "ABSTAIN" {472 panic("invalid vote: must be YES, NO or ABSTAIN")473 }474 assertNotArchived()475 m := assertMember(caller)476 p := mustProposal(id)477 if p.Status != statusActive {478 panic("proposal is not open for voting")479 }480 if p.ElectorateVersion != electorateVersion {481 panic("proposal invalidated by a membership change")482 }483 if now() >= p.VotingEndsAt {484 panic("voting period has ended")485 }486 if p.Votes.Has(string(caller)) {487 panic("already voted")488 }489 // Every check passed; writes start here.490 p.Votes.Set(string(caller), VoteRecord{Choice: choice, Power: m.Power})491 switch choice {492 case "YES":493 p.Yes += m.Power494 case "NO":495 p.No += m.Power496 default:497 p.Abstain += m.Power498 }499 cast := p.Yes + p.No + p.Abstain500 ep := p.ElectoratePower501 if (quorum == 0 || reached(cast, ep, quorum)) && reached(p.Yes, ep, threshold) {502 p.Status = statusAccepted503 p.AcceptedAt = now()504 } else if !reached(p.Yes+(ep-cast), ep, threshold) {505 p.Status = statusRejected506 }507 chain.Emit("VoteCast", "id", strconv.FormatUint(id, 10), "voter", string(caller), "choice", choice)508}509510// ── Execute ───────────────────────────────────────────────511512// Execute applies an accepted proposal after the execution delay and before513// the execution window closes. The stored action is checked again against the514// current state first; the proposal is marked EXECUTED only after it applied.515func Execute(cur realm, id uint64) {516 caller := cur.Previous().Address()517 assertNotArchived()518 assertMember(caller)519 p := mustProposal(id)520 if derivedStatus(p) != statusAccepted {521 panic("proposal is not accepted, or its execution window has passed")522 }523 t := now()524 if t < p.AcceptedAt+executionDelay {525 panic("execution delay has not elapsed")526 }527 if t > p.AcceptedAt+executionDelay+executionWindow {528 panic("execution window has passed")529 }530 a := p.Action531 validateAction(a)532 // Every check passed; writes start here and cannot fail.533 switch a.Kind {534 case kindText:535 // no on-chain effect536 case kindAddMember:537 roles := make([]string, len(a.Roles))538 copy(roles, a.Roles)539 members.Set(string(a.Target), &Member{Address: a.Target, Power: a.Power, Roles: roles})540 memberTotalPower += a.Power541 electorateVersion++542 case kindRemoveMember:543 // Removing a member frees storage; by chain rules the storage deposit it544 // freed is refunded to whoever executes the removal, not to the payer.545 m := getMember(a.Target)546 members.Remove(string(a.Target))547 memberTotalPower -= m.Power548 electorateVersion++549 case kindSetRoles:550 roles := make([]string, len(a.Roles))551 copy(roles, a.Roles)552 getMember(a.Target).Roles = roles553 case kindArchive:554 archived = true555 }556 p.Status = statusExecuted557 idStr := strconv.FormatUint(id, 10)558 chain.Emit("ProposalExecuted", "id", idStr, "kind", a.Kind)559 if a.Kind == kindArchive {560 chain.Emit("DAOArchived", "id", idStr)561 }562}563564// ── Reads (non-crossing, bounded, no calls to other realms) ──565566func GetTemplateVersion() string { return TemplateVersion }567568func GetAPIVersion() string { return APIVersion }569570// IsMember lets a companion realm check membership.571func IsMember(addr address) bool {572 m := getMember(addr)573 return m != nil && m.Power >= 1574}575576func IsArchived() bool { return archived }577578func HasVoted(id uint64, addr address) bool {579 p := getProposal(id)580 return p != nil && p.Votes.Has(string(addr))581}582583type jsonBuf struct{ b strings.Builder }584585func (j *jsonBuf) raw(s string) { j.b.WriteString(s) }586587func (j *jsonBuf) str(s string) {588 j.b.WriteByte('"')589 for _, r := range s {590 switch {591 case r == '"':592 j.b.WriteString("\\\"")593 case r == '\\':594 j.b.WriteString("\\\\")595 case r == '\n':596 j.b.WriteString("\\n")597 case r == '\t':598 j.b.WriteString("\\t")599 case r == '\r':600 j.b.WriteString("\\r")601 case r < 0x20 || r == 0x2028 || r == 0x2029:602 h := strconv.FormatInt(int64(r), 16)603 for len(h) < 4 {604 h = "0" + h605 }606 j.b.WriteString("\\u" + h)607 default:608 j.b.WriteRune(r)609 }610 }611 j.b.WriteByte('"')612}613614func (j *jsonBuf) key(k string, first bool) {615 if !first {616 j.b.WriteByte(',')617 }618 j.b.WriteByte('"')619 j.b.WriteString(k)620 j.b.WriteString("\":")621}622623func (j *jsonBuf) num(v int) { j.b.WriteString(strconv.Itoa(v)) }624func (j *jsonBuf) num64(v int64) { j.b.WriteString(strconv.FormatInt(v, 10)) }625func (j *jsonBuf) id(v uint64) { j.b.WriteString(strconv.FormatUint(v, 10)) }626627func (j *jsonBuf) flag(v bool) {628 if v {629 j.b.WriteString("true")630 } else {631 j.b.WriteString("false")632 }633}634635// plain quotes a value that cannot contain characters needing escapes:636// addresses (validated bech32), role and category identifiers, and constants.637// Escaping rune by rune is costly in the VM, so it is done once at write time638// for user text and skipped here.639func (j *jsonBuf) plain(s string) {640 j.b.WriteByte('"')641 j.b.WriteString(s)642 j.b.WriteByte('"')643}644645func (j *jsonBuf) plains(list []string) {646 j.b.WriteByte('[')647 for i, s := range list {648 if i > 0 {649 j.b.WriteByte(',')650 }651 j.plain(s)652 }653 j.b.WriteByte(']')654}655656func checkPage(offset, limit int) {657 if offset < 0 {658 panic("offset must not be negative")659 }660 if limit < 1 || limit > maxPageSize {661 panic("limit must be between 1 and 50")662 }663}664665func writeConfig(j *jsonBuf) {666 j.raw("{")667 j.key("template_version", true)668 j.plain(TemplateVersion)669 j.key("api_version", false)670 j.plain(APIVersion)671 j.key("name", false)672 j.raw(nameJSON)673 j.key("description", false)674 j.raw(descriptionJSON)675 j.key("threshold", false)676 j.num(threshold)677 j.key("quorum", false)678 j.num(quorum)679 j.key("voting_period", false)680 j.num64(votingPeriod)681 j.key("execution_delay", false)682 j.num64(executionDelay)683 j.key("execution_window", false)684 j.num64(executionWindow)685 j.key("categories", false)686 j.plains(allowedCategories)687 j.key("roles", false)688 j.plains(allowedRoles)689 j.key("archived", false)690 j.flag(archived)691 j.key("member_count", false)692 j.num(members.Size())693 j.key("total_power", false)694 j.num(memberTotalPower)695 j.key("electorate_version", false)696 j.num(electorateVersion)697 j.key("proposal_count", false)698 j.id(lastID)699 j.raw("}")700}701702// GetConfigJSON returns the DAO's settings and current totals.703func GetConfigJSON() string {704 j := &jsonBuf{}705 writeConfig(j)706 return j.b.String()707}708709func writeMembers(j *jsonBuf, offset, limit int) {710 checkPage(offset, limit)711 j.raw("{")712 j.key("total", true)713 j.num(members.Size())714 j.key("offset", false)715 j.num(offset)716 j.key("members", false)717 j.raw("[")718 first := true719 members.IterateByOffset(offset, limit, func(_ string, v any) bool {720 m := v.(*Member)721 if !first {722 j.raw(",")723 }724 first = false725 j.raw("{")726 j.key("address", true)727 j.plain(string(m.Address))728 j.key("power", false)729 j.num(m.Power)730 j.key("roles", false)731 j.plains(m.Roles)732 j.raw("}")733 return false734 })735 j.raw("]}")736}737738// GetMembersJSON returns one page of members (limit 1..50).739func GetMembersJSON(offset, limit int) string {740 j := &jsonBuf{}741 writeMembers(j, offset, limit)742 return j.b.String()743}744745// fixedJSON encodes the proposal fields that never change. The action's746// target is a validated address (or empty) and its roles are identifiers.747func fixedJSON(p *Proposal) string {748 j := &jsonBuf{}749 j.key("id", true)750 j.id(p.ID)751 j.key("title", false)752 j.str(p.Title)753 j.key("category", false)754 j.plain(p.Category)755 j.key("author", false)756 j.plain(string(p.Author))757 j.key("action", false)758 j.raw("{")759 j.key("kind", true)760 j.plain(p.Action.Kind)761 j.key("target", false)762 j.plain(string(p.Action.Target))763 j.key("power", false)764 j.num(p.Action.Power)765 j.key("roles", false)766 j.plains(p.Action.Roles)767 j.raw("}")768 j.key("electorate_power", false)769 j.num(p.ElectoratePower)770 j.key("electorate_version", false)771 j.num(p.ElectorateVersion)772 j.key("created_at", false)773 j.num64(p.CreatedAt)774 j.key("voting_ends_at", false)775 j.num64(p.VotingEndsAt)776 return j.b.String()777}778779func writeProposalFields(j *jsonBuf, p *Proposal) {780 j.raw(p.FixedJSON)781 j.key("status", false)782 j.plain(derivedStatus(p))783 j.key("yes", false)784 j.num(p.Yes)785 j.key("no", false)786 j.num(p.No)787 j.key("abstain", false)788 j.num(p.Abstain)789 j.key("accepted_at", false)790 j.num64(p.AcceptedAt)791 executableAt := int64(0)792 executeBy := int64(0)793 if p.AcceptedAt > 0 {794 executableAt = p.AcceptedAt + executionDelay795 executeBy = executableAt + executionWindow796 }797 j.key("executable_at", false)798 j.num64(executableAt)799 j.key("execute_by", false)800 j.num64(executeBy)801}802803// GetProposalsJSON lists proposals newest first (limit 1..50). before=0 starts804// at the newest; pass the returned next_before to get the following page805// (next_before 0 means there is none). Descriptions are only returned by806// GetProposalJSON, which keeps a full page small.807func GetProposalsJSON(before uint64, limit int) string {808 checkPage(0, limit)809 j := &jsonBuf{}810 j.raw("{")811 j.key("proposals", true)812 j.raw("[")813 end := ""814 if before > 0 {815 end = padID(before - 1)816 }817 count := 0818 nextBefore := uint64(0)819 proposals.ReverseIterate("", end, func(_ string, v any) bool {820 p := v.(*Proposal)821 if count == limit {822 nextBefore = p.ID + 1823 return true824 }825 if count > 0 {826 j.raw(",")827 }828 j.raw("{")829 writeProposalFields(j, p)830 j.raw("}")831 count++832 return false833 })834 j.raw("]")835 j.key("next_before", false)836 j.id(nextBefore)837 j.raw("}")838 return j.b.String()839}840841// GetProposalJSON returns one proposal including its description.842func GetProposalJSON(id uint64) string {843 p := mustProposal(id)844 j := &jsonBuf{}845 j.raw("{")846 writeProposalFields(j, p)847 j.key("description", false)848 j.str(proposalDescription(p.ID))849 j.raw("}")850 return j.b.String()851}852853// GetVotesJSON returns one page of a proposal's votes (limit 1..50).854func GetVotesJSON(id uint64, offset, limit int) string {855 checkPage(offset, limit)856 p := mustProposal(id)857 j := &jsonBuf{}858 j.raw("{")859 j.key("total", true)860 j.num(p.Votes.Size())861 j.key("offset", false)862 j.num(offset)863 j.key("votes", false)864 j.raw("[")865 first := true866 p.Votes.IterateByOffset(offset, limit, func(k string, v any) bool {867 vote := v.(VoteRecord)868 if !first {869 j.raw(",")870 }871 first = false872 j.raw("{")873 j.key("voter", true)874 j.plain(k)875 j.key("choice", false)876 j.plain(vote.Choice)877 j.key("power", false)878 j.num(vote.Power)879 j.raw("}")880 return false881 })882 j.raw("]}")883 return j.b.String()884}885886// GetStateJSON exports the settings and one page of members, so the DAO can887// be recreated elsewhere.888func GetStateJSON(offset, limit int) string {889 j := &jsonBuf{}890 j.raw("{")891 j.key("config", true)892 writeConfig(j)893 j.key("members", false)894 writeMembers(j, offset, limit)895 j.raw("}")896 return j.b.String()897}898899// ── Render (gnoweb) ───────────────────────────────────────900901// escapeInline makes a user string safe on one markdown line: markdown902// punctuation is backslash-escaped and control characters become spaces.903func escapeInline(s string) string {904 var b strings.Builder905 for _, r := range s {906 switch r {907 case 0x5c, '*', '_', '[', ']', '(', ')', '#', '<', '>', '|', '!', 0x60:908 b.WriteByte(0x5c)909 b.WriteRune(r)910 default:911 if isControl(r) {912 b.WriteByte(' ')913 } else {914 b.WriteRune(r)915 }916 }917 }918 return b.String()919}920921// quoteBlock renders multi-line text as a quote, one escaped line at a time.922func quoteBlock(s string) string {923 var b strings.Builder924 for _, line := range strings.Split(s, "\n") {925 b.WriteString("> ")926 b.WriteString(escapeInline(line))927 b.WriteString("\n")928 }929 return b.String()930}931932func Render(path string) string {933 if path == "" {934 return renderHome(1)935 }936 if strings.HasPrefix(path, "?page=") {937 page, err := strconv.Atoi(strings.TrimPrefix(path, "?page="))938 if err == nil && page >= 1 {939 return renderHome(page)940 }941 return "# Not found\n"942 }943 id, err := strconv.ParseUint(path, 10, 64)944 if err == nil {945 if p := getProposal(id); p != nil {946 return renderProposal(p)947 }948 }949 return "# Not found\n"950}951952func renderHome(page int) string {953 var b strings.Builder954 b.WriteString("# " + escapeInline(name) + "\n\n")955 if description != "" {956 b.WriteString(quoteBlock(description) + "\n")957 }958 b.WriteString("Threshold: " + strconv.Itoa(threshold) + "% of all voting power. Quorum: " + strconv.Itoa(quorum) + "%.\n\n")959 if archived {960 b.WriteString("This DAO is archived.\n\n")961 }962 b.WriteString("## Members (" + strconv.Itoa(members.Size()) + ")\n\n")963 members.Iterate("", "", func(_ string, v any) bool {964 m := v.(*Member)965 b.WriteString("- " + string(m.Address) + " (power " + strconv.Itoa(m.Power) + ")\n")966 return false967 })968 b.WriteString("\n## Proposals\n\n")969 total := proposals.Size()970 if total == 0 {971 b.WriteString("No proposals yet.\n")972 return b.String()973 }974 proposals.ReverseIterateByOffset((page-1)*renderPageSize, renderPageSize, func(_ string, v any) bool {975 p := v.(*Proposal)976 id := strconv.FormatUint(p.ID, 10)977 b.WriteString("- [#" + id + "](:" + id + ") " + escapeInline(p.Title) + " (" + p.Action.Kind + ", " + derivedStatus(p) + ")\n")978 return false979 })980 pages := (total + renderPageSize - 1) / renderPageSize981 if pages > 1 {982 b.WriteString("\nPage " + strconv.Itoa(page) + " of " + strconv.Itoa(pages) + ":")983 for i := 1; i <= pages; i++ {984 b.WriteString(" [" + strconv.Itoa(i) + "](?page=" + strconv.Itoa(i) + ")")985 }986 b.WriteString("\n")987 }988 return b.String()989}990991func renderProposal(p *Proposal) string {992 var b strings.Builder993 id := strconv.FormatUint(p.ID, 10)994 b.WriteString("# Proposal " + id + ": " + escapeInline(p.Title) + "\n\n")995 b.WriteString("Kind: " + p.Action.Kind + "\n\n")996 if p.Action.Target != "" {997 b.WriteString("Target: " + escapeInline(string(p.Action.Target)) + "\n\n")998 }999 b.WriteString("Status: " + derivedStatus(p) + "\n\n")1000 b.WriteString("Author: " + string(p.Author) + "\n\n")1001 b.WriteString("YES " + strconv.Itoa(p.Yes) + ", NO " + strconv.Itoa(p.No) + ", ABSTAIN " + strconv.Itoa(p.Abstain) + " of " + strconv.Itoa(p.ElectoratePower) + "\n\n")1002 if desc := proposalDescription(p.ID); desc != "" {1003 b.WriteString(quoteBlock(desc))1004 }1005 return b.String()1006}1007Execute(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id uint64)
GetAPIVersion() string
GetConfigJSON() string
GetMembersJSON(offset int, limit int) string
GetProposalJSON(id uint64) string
GetProposalsJSON(before uint64, limit int) string
GetStateJSON(offset int, limit int) string
GetTemplateVersion() string
GetVotesJSON(id uint64, offset int, limit int) string
HasVoted(id uint64, addr string) bool
IsArchived() bool
IsMember(addr string) bool
ProposeAddMember(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, title string, desc string, target string, power int, rolesCSV string) uint64
ProposeArchive(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, title string, desc string) uint64
ProposeRemoveMember(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, title string, desc string, target string) uint64
ProposeSetRoles(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, title string, desc string, target string, rolesCSV string) uint64
ProposeText(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, title string, desc string, category string) uint64
Render(path string) string
Vote(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id uint64, choice string)
Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.
vm/qrender output, sanitized (docs/render-security.md) and displayed in an empty-sandbox iframe — scripts, forms and popups cannot run. Links stay inert in-preview; right-click to open.