Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

friends_surf_club.gno

27.88 Kb · 1006 lines
   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}