← Back to Realms

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}
1007

Raw Package Data

Raw JSON data