Realm
gno.land/r/g1747t5m2f08plqjlrjk2q0qld7465hxz8gkx59c/friends_surf_club
Overview
Realm Path
gno.land/r/g1747t5m2f08plqjlrjk2q0qld7465hxz8gkx59c/friends_surf_club
Exported Functions
19
State Entries
46
Source Files
2
Total Package Entries
97
Exported Functions
19 exported functions
State
46 state entries
Source Code
FILES
friends_surf_club.gno
go
1package friends_surf_club
2
3import (
4 "chain"
5 "strconv"
6 "strings"
7 "time"
8 "unicode"
9 "unicode/utf8"
10
11 "gno.land/p/nt/avl/v0"
12)
13
14// DAO realm generated by Memba, template memba-dao/2.
15//
16// Every change to membership, roles or the archived flag is a proposal that
17// members vote on. No member and no role has special powers. This realm holds
18// no funds.
19
20const (
21 TemplateVersion = "memba-dao/2"
22 APIVersion = "2.0"
23 maxMembers = 100
24 maxPower = 1_000_000_000 // total <= 1e11, so x100 stays far below MaxInt64
25 maxTitleLen = 128
26 maxDescLen = 8000
27 maxOpenPerMember = 10
28 maxPageSize = 50
29 renderPageSize = 20
30
31 kindText = "text"
32 kindAddMember = "add_member"
33 kindRemoveMember = "remove_member"
34 kindSetRoles = "set_roles"
35 kindArchive = "archive"
36
37 statusActive = "ACTIVE"
38 statusAccepted = "ACCEPTED"
39 statusRejected = "REJECTED"
40 statusExecuted = "EXECUTED"
41 statusExpired = "EXPIRED"
42 statusLapsed = "LAPSED"
43 statusInvalidated = "INVALIDATED"
44 statusArchived = "ARCHIVED"
45)
46
47type Member struct {
48 Address address
49 Power int
50 Roles []string
51}
52
53// Action is stored by value inside its proposal at propose time. It holds
54// primitives only and is never modified afterwards. The handler for an action
55// is chosen by Kind in Execute; there is no other dispatch.
56type Action struct {
57 Kind string
58 Target address
59 Power int
60 Roles []string
61}
62
63type VoteRecord struct {
64 Choice string
65 Power int
66}
67
68type Proposal struct {
69 ID uint64
70 Title string
71 Category string
72 Author address
73 Action Action
74 Status string // stored: ACTIVE, ACCEPTED, REJECTED, EXECUTED
75 Votes *avl.Tree // voter address -> VoteRecord
76 Yes int
77 No int
78 Abstain int
79 ElectorateVersion int
80 ElectoratePower int
81 CreatedAt int64 // unix seconds (block time)
82 VotingEndsAt int64
83 AcceptedAt int64 // 0 until accepted
84 FixedJSON string // JSON of the fields that never change, built once at propose time
85}
86
87var (
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 generator
91 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 YES
93 quorum = 0 // % of all voting power that must vote (0 = none)
94 votingPeriod = int64(259200) // seconds
95 executionDelay = int64(3600) // seconds after acceptance
96 executionWindow = int64(604800) // seconds after the delay
97
98 allowedCategories = []string{"governance", "membership", "operations"}
99 allowedRoles = []string{"admin", "member"}
100
101 members = avl.NewTree() // address -> *Member
102 memberTotalPower = 0
103 proposals = avl.NewTree() // zero-padded id -> *Proposal
104 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 removed
108 archived = false
109)
110
111func 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}
120
121// ── Genesis ───────────────────────────────────────────────
122
123func 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 += power
140}
141
142func 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 a
156 // 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}
167
168// ── Validation (no writes) ────────────────────────────────
169
170func now() int64 { return time.Now().Unix() }
171
172func assertNotArchived() {
173 if archived {
174 panic("DAO is archived")
175 }
176}
177
178func getMember(addr address) *Member {
179 m, ok := members.Get(string(addr)).(*Member)
180 if !ok {
181 return nil
182 }
183 return m
184}
185
186func 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 m
192}
193
194// 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 an
196// address that passed IsValid: bech32 refuses mixed case, so the first
197// 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}
202
203func contains(list []string, s string) bool {
204 for _, v := range list {
205 if v == s {
206 return true
207 }
208 }
209 return false
210}
211
212func 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}
224
225func 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 roles
235}
236
237func isControl(r rune) bool {
238 return r < 0x20 || (r >= 0x7f && r <= 0x9f) || r == 0x2028 || r == 0x2029
239}
240
241func 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) are
254 // 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}
263
264func 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}
277
278// validateAction checks an action against the current state. Propose calls it
279// 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}
320
321func padID(id uint64) string {
322 s := strconv.FormatUint(id, 10)
323 for len(s) < 20 {
324 s = "0" + s
325 }
326 return s
327}
328
329func getProposal(id uint64) *Proposal {
330 p, ok := proposals.Get(padID(id)).(*Proposal)
331 if !ok {
332 return nil
333 }
334 return p
335}
336
337func proposalDescription(id uint64) string {
338 desc, _ := descriptions.Get(padID(id)).(string)
339 return desc
340}
341
342func mustProposal(id uint64) *Proposal {
343 p := getProposal(id)
344 if p == nil {
345 panic("proposal not found")
346 }
347 return p
348}
349
350// derivedStatus is the status every read and write relies on. Time-based and
351// 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 statusArchived
356 }
357 t := now()
358 if p.Status == statusActive {
359 if p.ElectorateVersion != electorateVersion {
360 return statusInvalidated
361 }
362 if t >= p.VotingEndsAt {
363 return statusExpired
364 }
365 }
366 if p.Status == statusAccepted && t > p.AcceptedAt+executionDelay+executionWindow {
367 return statusLapsed
368 }
369 return p.Status
370}
371
372// openIDs returns the author's proposals that are still open for voting. The
373// 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 open
384}
385
386// ── Propose ───────────────────────────────────────────────
387
388func 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 := lastID
401 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 id
421}
422
423// 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}
431
432// 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}
438
439// 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}
444
445// 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}
451
452// 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}
457
458// ── Vote ──────────────────────────────────────────────────
459
460// reached reports part/whole >= pct/100 without division. part and whole are
461// at most maxMembers*maxPower (1e11), so part*100 cannot overflow.
462func reached(part, whole, pct int) bool {
463 return part*100 >= whole*pct
464}
465
466// Vote records the caller's YES, NO or ABSTAIN. A proposal is ACCEPTED once
467// YES alone reaches the threshold of all voting power (and quorum is met), and
468// 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.Power
494 case "NO":
495 p.No += m.Power
496 default:
497 p.Abstain += m.Power
498 }
499 cast := p.Yes + p.No + p.Abstain
500 ep := p.ElectoratePower
501 if (quorum == 0 || reached(cast, ep, quorum)) && reached(p.Yes, ep, threshold) {
502 p.Status = statusAccepted
503 p.AcceptedAt = now()
504 } else if !reached(p.Yes+(ep-cast), ep, threshold) {
505 p.Status = statusRejected
506 }
507 chain.Emit("VoteCast", "id", strconv.FormatUint(id, 10), "voter", string(caller), "choice", choice)
508}
509
510// ── Execute ───────────────────────────────────────────────
511
512// Execute applies an accepted proposal after the execution delay and before
513// the execution window closes. The stored action is checked again against the
514// 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.Action
531 validateAction(a)
532 // Every check passed; writes start here and cannot fail.
533 switch a.Kind {
534 case kindText:
535 // no on-chain effect
536 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.Power
541 electorateVersion++
542 case kindRemoveMember:
543 // Removing a member frees storage; by chain rules the storage deposit it
544 // 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.Power
548 electorateVersion++
549 case kindSetRoles:
550 roles := make([]string, len(a.Roles))
551 copy(roles, a.Roles)
552 getMember(a.Target).Roles = roles
553 case kindArchive:
554 archived = true
555 }
556 p.Status = statusExecuted
557 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}
563
564// ── Reads (non-crossing, bounded, no calls to other realms) ──
565
566func GetTemplateVersion() string { return TemplateVersion }
567
568func GetAPIVersion() string { return APIVersion }
569
570// IsMember lets a companion realm check membership.
571func IsMember(addr address) bool {
572 m := getMember(addr)
573 return m != nil && m.Power >= 1
574}
575
576func IsArchived() bool { return archived }
577
578func HasVoted(id uint64, addr address) bool {
579 p := getProposal(id)
580 return p != nil && p.Votes.Has(string(addr))
581}
582
583type jsonBuf struct{ b strings.Builder }
584
585func (j *jsonBuf) raw(s string) { j.b.WriteString(s) }
586
587func (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" + h
605 }
606 j.b.WriteString("\\u" + h)
607 default:
608 j.b.WriteRune(r)
609 }
610 }
611 j.b.WriteByte('"')
612}
613
614func (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}
622
623func (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)) }
626
627func (j *jsonBuf) flag(v bool) {
628 if v {
629 j.b.WriteString("true")
630 } else {
631 j.b.WriteString("false")
632 }
633}
634
635// 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 time
638// 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}
644
645func (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}
655
656func 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}
664
665func 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}
701
702// GetConfigJSON returns the DAO's settings and current totals.
703func GetConfigJSON() string {
704 j := &jsonBuf{}
705 writeConfig(j)
706 return j.b.String()
707}
708
709func 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 := true
719 members.IterateByOffset(offset, limit, func(_ string, v any) bool {
720 m := v.(*Member)
721 if !first {
722 j.raw(",")
723 }
724 first = false
725 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 false
734 })
735 j.raw("]}")
736}
737
738// 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}
744
745// fixedJSON encodes the proposal fields that never change. The action's
746// 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}
778
779func 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 + executionDelay
795 executeBy = executableAt + executionWindow
796 }
797 j.key("executable_at", false)
798 j.num64(executableAt)
799 j.key("execute_by", false)
800 j.num64(executeBy)
801}
802
803// GetProposalsJSON lists proposals newest first (limit 1..50). before=0 starts
804// at the newest; pass the returned next_before to get the following page
805// (next_before 0 means there is none). Descriptions are only returned by
806// 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 := 0
818 nextBefore := uint64(0)
819 proposals.ReverseIterate("", end, func(_ string, v any) bool {
820 p := v.(*Proposal)
821 if count == limit {
822 nextBefore = p.ID + 1
823 return true
824 }
825 if count > 0 {
826 j.raw(",")
827 }
828 j.raw("{")
829 writeProposalFields(j, p)
830 j.raw("}")
831 count++
832 return false
833 })
834 j.raw("]")
835 j.key("next_before", false)
836 j.id(nextBefore)
837 j.raw("}")
838 return j.b.String()
839}
840
841// 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}
852
853// 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 := true
866 p.Votes.IterateByOffset(offset, limit, func(k string, v any) bool {
867 vote := v.(VoteRecord)
868 if !first {
869 j.raw(",")
870 }
871 first = false
872 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 false
881 })
882 j.raw("]}")
883 return j.b.String()
884}
885
886// GetStateJSON exports the settings and one page of members, so the DAO can
887// 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}
898
899// ── Render (gnoweb) ───────────────────────────────────────
900
901// escapeInline makes a user string safe on one markdown line: markdown
902// punctuation is backslash-escaped and control characters become spaces.
903func escapeInline(s string) string {
904 var b strings.Builder
905 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}
920
921// quoteBlock renders multi-line text as a quote, one escaped line at a time.
922func quoteBlock(s string) string {
923 var b strings.Builder
924 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}
931
932func 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}
951
952func renderHome(page int) string {
953 var b strings.Builder
954 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 false
967 })
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 false
979 })
980 pages := (total + renderPageSize - 1) / renderPageSize
981 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}
990
991func renderProposal(p *Proposal) string {
992 var b strings.Builder
993 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}
1007Raw Package Data
Raw JSON data