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

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}