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

reads.gno

12.76 Kb · 349 lines
  1package memba_weighted_host
  2
  3import (
  4	"strconv"
  5	"strings"
  6	"time"
  7
  8	"gno.land/p/samcrew/memba_weighted_policy"
  9)
 10
 11const ReadSchema = "memba-weighted-host/v2"
 12const MaxProposalPage = 50
 13
 14func (h *Host) readSchema() string {
 15	if h.feedbackPolicy != nil {
 16		return "memba-weighted-host/v12"
 17	}
 18	if h.channelsPolicy != nil {
 19		return "memba-weighted-host/v11"
 20	}
 21	if h.feedPolicy != nil {
 22		return "memba-weighted-host/v10"
 23	}
 24	if h.badgesPolicy != nil {
 25		return "memba-weighted-host/v9"
 26	}
 27	if h.escrowPolicy != nil {
 28		return "memba-weighted-host/v8"
 29	}
 30	if h.appstorePolicy != nil {
 31		return "memba-weighted-host/v7"
 32	}
 33	if h.arcadePolicy != nil {
 34		return "memba-weighted-host/v6"
 35	}
 36	if h.questPolicy != nil {
 37		return "memba-weighted-host/v5"
 38	}
 39	if h.reviewsPolicy != nil {
 40		return "memba-weighted-host/v4"
 41	}
 42	if h.marketPolicy != nil {
 43		return "memba-weighted-host/v3"
 44	}
 45	return ReadSchema
 46}
 47
 48func (h *Host) marketConfigJSON() string {
 49	if h.marketPolicy == nil {
 50		return ""
 51	}
 52	return `,"marketPolicy":{"target":` + quoteJSON(MarketConfigPath) + `,"treasury":` + quoteJSON(string(h.marketPolicy.Treasury())) + `,"successor":` + quoteJSON(string(h.marketPolicy.Successor())) + `,"feeCategory":"financial","treasuryCategory":"financial","returnStagesOnly":true,"invalidatesOtherProposals":true}`
 53}
 54
 55// JSON strings use JSON escapes, not Go's \xNN/\a/\v escapes. Invalid UTF-8
 56// decodes as U+FFFD, matching JSON's Unicode string domain.
 57func quoteJSON(value string) string {
 58	var b strings.Builder
 59	b.WriteByte('"')
 60	const hex = "0123456789abcdef"
 61	for _, r := range value {
 62		switch r {
 63		case '"':
 64			b.WriteString(`\"`)
 65		case '\\':
 66			b.WriteString(`\\`)
 67		default:
 68			if r < 0x20 {
 69				b.WriteString(`\u00`)
 70				b.WriteByte(hex[byte(r)>>4])
 71				b.WriteByte(hex[byte(r)&15])
 72			} else {
 73				b.WriteRune(r)
 74			}
 75		}
 76	}
 77	b.WriteByte('"')
 78	return b.String()
 79}
 80
 81func timestampJSON(value time.Time) string {
 82	if value.IsZero() {
 83		return "null"
 84	}
 85	return quoteJSON(value.UTC().Format(time.RFC3339Nano))
 86}
 87
 88// ConfigJSON describes only implemented host capabilities. Fixed founding
 89// thresholds are not configurable through a role action. Operational role labels
 90// have no additional vote or execution privilege.
 91func (h *Host) ConfigJSON() string {
 92	return `{"schema":` + quoteJSON(h.readSchema()) + `,"kind":"config","realmPath":` + quoteJSON(h.path) + `,"rosterSize":7,"totalPoints":8,"founderWeight":2,"developerWeight":1,"votingPeriodSeconds":604800,"roleChanges":{"category":"critical","weightedPoints":6,"weightedPeople":4,"weightedDelaySeconds":86400,"independentDevelopers":5,"independentDelaySeconds":259200},"mutableRoles":["admin","finance"],"capabilities":{"roleProposals":true,"memberReplacement":true,"migration":false,"treasuryExecution":false,"applicationActions":` + strconv.FormatBool(h.marketPolicy != nil || h.reviewsPolicy != nil || h.questPolicy != nil || h.arcadePolicy != nil || h.appstorePolicy != nil || h.escrowPolicy != nil || h.badgesPolicy != nil || h.feedPolicy != nil || h.channelsPolicy != nil || h.feedbackPolicy != nil) + `},"maxProposalPage":50` + h.marketConfigJSON() + h.reviewsConfigJSON() + h.questConfigJSON() + h.arcadeConfigJSON() + h.appstoreConfigJSON() + h.escrowConfigJSON() + h.badgesConfigJSON() + h.feedConfigJSON() + h.channelsConfigJSON() + h.feedbackConfigJSON() + `}`
 93}
 94
 95func (h *Host) MembersJSON() string {
 96	var b strings.Builder
 97	b.WriteString(`{"schema":` + quoteJSON(h.readSchema()) + `,"kind":"members","members":[`)
 98	for i, seat := range h.seats {
 99		if i > 0 {
100			b.WriteByte(',')
101		}
102		weight := 1
103		if seat.Member.Founder {
104			weight = 2
105		}
106		b.WriteString(`{"personId":` + quoteJSON(seat.Member.PersonID) + `,"address":` + quoteJSON(string(seat.Member.Address)) + `,"founder":` + strconv.FormatBool(seat.Member.Founder) + `,"weight":` + strconv.Itoa(weight) + `,"admin":` + strconv.FormatBool(seat.Admin) + `,"finance":` + strconv.FormatBool(seat.Finance) + `}`)
107	}
108	b.WriteString(`]}`)
109	return b.String()
110}
111
112func status(s memba_weighted_policy.State) string {
113	// Successful role execution also advances the configuration generation.
114	// Consumed therefore takes precedence over subsequent invalidation.
115	if s.Consumed {
116		return "EXECUTED"
117	}
118	if s.Invalidated {
119		return "INVALIDATED"
120	}
121	if s.Expired {
122		return "EXPIRED"
123	}
124	if s.Ready {
125		return "READY"
126	}
127	if s.Qualified {
128		return "TIMELOCKED"
129	}
130	return "VOTING"
131}
132
133// viewStatus keeps EXPIRED sticky: a proposal whose voting closed without
134// qualifying reports EXPIRED even after a later reconfiguration.
135func viewStatus(v ProposalView) string {
136	if !v.State.Consumed && v.ExpiredBeforeInvalidation {
137		return "EXPIRED"
138	}
139	return status(v.State)
140}
141
142// invalidationJSON is null unless the status is INVALIDATED. cause is
143// "superseded-execution" (proposalId is the executed proposal) or "pause"
144// (proposalId is null). target is the application realm, or null for a
145// role or recovery execution.
146func invalidationJSON(v ProposalView) string {
147	if !v.Invalidated {
148		return "null"
149	}
150	id, target := "null", "null"
151	if v.InvalidationProposal != 0 {
152		id = quoteJSON(strconv.FormatUint(v.InvalidationProposal, 10))
153	}
154	if v.InvalidationTarget != "" {
155		target = quoteJSON(v.InvalidationTarget)
156	}
157	return `{"cause":` + quoteJSON(v.InvalidationCause) + `,"height":` + quoteJSON(strconv.FormatInt(v.InvalidationHeight, 10)) + `,"proposalId":` + id + `,"target":` + target + `}`
158}
159
160// Terminal policy states clear current tallies; they are not evidence of zero
161// historical votes. Do not invent a receipt the host did not retain.
162func tallyJSON(n int, s memba_weighted_policy.State) string {
163	if s.Consumed || s.Invalidated {
164		return "null"
165	}
166	return strconv.Itoa(n)
167}
168
169func proposalActionJSON(v ProposalView) string {
170	if v.Feedback {
171		return feedbackActionJSON(v.FeedbackAction, v.FeedbackBefore)
172	}
173	if v.Channels {
174		return channelsActionJSON(v.ChannelsAction, v.ChannelsBefore)
175	}
176	if v.Feed {
177		return feedActionJSON(v.FeedAction, v.FeedBefore)
178	}
179	if v.Badges {
180		return badgesActionJSON(v.BadgesAction, v.BadgesBefore)
181	}
182	if v.Escrow {
183		return escrowActionJSON(v.EscrowAction, v.EscrowBefore)
184	}
185	if v.Appstore {
186		return appstoreActionJSON(v.AppstoreAction, v.AppstoreBefore)
187	}
188	if v.Arcade {
189		return arcadeActionJSON(v.ArcadeAction, v.ArcadeBefore)
190	}
191	if v.Quest {
192		return questActionJSON(v.QuestAction, v.QuestBefore)
193	}
194	if v.Reviews {
195		return reviewsActionJSON(v.ReviewsAction, v.ReviewsBefore)
196	}
197	if v.Market {
198		a := v.MarketAction
199		b := v.MarketBefore
200		return `{"type":"market-config","target":` + quoteJSON(MarketConfigPath) + `,"operation":` + quoteJSON(string(a.Kind())) + `,"lane":` + quoteJSON(string(a.Lane())) + `,"bps":` + quoteJSON(strconv.FormatInt(a.BPS(), 10)) + `,"recipient":` + quoteJSON(string(a.Recipient())) + `,"before":{"admin":` + quoteJSON(string(b.Admin)) + `,"pendingAdmin":` + quoteJSON(string(b.PendingAdmin)) + `,"treasury":` + quoteJSON(string(b.Treasury)) + `,"bps":` + quoteJSON(strconv.FormatInt(b.BPS, 10)) + `}}`
201	}
202	if v.Recovery {
203		return `{"type":"recover-member","personId":` + quoteJSON(v.PersonID) + `,"oldAddress":` + quoteJSON(string(v.Target)) + `,"newAddress":` + quoteJSON(string(v.Replacement)) + `}`
204	}
205	return `{"type":"set-role","target":` + quoteJSON(string(v.Target)) + `,"role":` + quoteJSON(string(v.Role)) + `,"grant":` + strconv.FormatBool(v.Grant) + `}`
206}
207
208func proposalJSON(v ProposalView) string {
209	s := v.State
210	return `{"id":` + quoteJSON(strconv.FormatUint(v.ID, 10)) + `,"proposer":` + quoteJSON(string(v.Proposer)) + `,"action":` + proposalActionJSON(v) + `,"category":` + quoteJSON(string(s.Category)) + `,"status":` + quoteJSON(viewStatus(v)) + `,"qualified":` + strconv.FormatBool(s.Qualified) + `,"ready":` + strconv.FormatBool(s.Ready) + `,"votingClosed":` + strconv.FormatBool(s.VotingClosed) + `,"talliesAvailable":` + strconv.FormatBool(!s.Consumed && !s.Invalidated) + `,"weightYes":` + tallyJSON(s.WeightYes, s) + `,"peopleYes":` + tallyJSON(s.PeopleYes, s) + `,"developersYes":` + tallyJSON(s.DevelopersYes, s) + `,"createdAt":` + timestampJSON(s.CreatedAt) + `,"votingDeadline":` + timestampJSON(s.VotingDeadline) + `,"weightedAfter":` + timestampJSON(s.WeightedAfter) + `,"developerAfter":` + timestampJSON(s.DeveloperAfter) + `,"invalidation":` + invalidationJSON(v) + `}`
211}
212
213func (h *Host) ProposalJSON(id uint64) string {
214	return `{"schema":` + quoteJSON(h.readSchema()) + `,"kind":"proposal","proposal":` + proposalJSON(h.Proposal(id)) + `}`
215}
216
217// ProposalsJSON uses an exclusive descending ID cursor. before=0 means newest;
218// a returned nextBefore remains stable when newer proposals are appended.
219// IDs/cursors/counts are decimal strings so uint64 values never lose precision
220// in a JavaScript client. A null cursor ends pagination, not an empty page guess.
221func (h *Host) ProposalsJSON(before uint64, limit int) string {
222	if limit < 1 || limit > MaxProposalPage {
223		panic("invalid proposal page size")
224	}
225	count := h.ProposalCount()
226	if before > count {
227		panic("invalid proposal cursor")
228	}
229	id := count
230	if before > 0 {
231		id = before - 1
232	}
233	var b strings.Builder
234	b.WriteString(`{"schema":` + quoteJSON(h.readSchema()) + `,"kind":"proposals","total":` + quoteJSON(strconv.FormatUint(count, 10)) + `,"proposals":[`)
235	n := 0
236	last := uint64(0)
237	for id > 0 && n < limit {
238		if n > 0 {
239			b.WriteByte(',')
240		}
241		b.WriteString(proposalJSON(h.Proposal(id)))
242		last = id
243		id--
244		n++
245	}
246	b.WriteString(`],"nextBefore":`)
247	if id > 0 {
248		b.WriteString(quoteJSON(strconv.FormatUint(last, 10)))
249	} else {
250		b.WriteString("null")
251	}
252	b.WriteByte('}')
253	return b.String()
254}
255
256// Ballot reads. Pending pages hold at most MaxPendingPage proposals, and one
257// call examines at most MaxPendingScan proposals before handing back a cursor.
258const MaxPendingPage = 50
259const MaxPendingScan = 200
260
261// parseDecimalID accepts only canonical unsigned decimal strings, the same
262// form every read emits, so one ID has exactly one spelling.
263func parseDecimalID(value, what string) uint64 {
264	n, err := strconv.ParseUint(value, 10, 64)
265	if err != nil || strconv.FormatUint(n, 10) != value {
266		panic("invalid " + what)
267	}
268	return n
269}
270
271func ballotChoiceJSON(v memba_weighted_policy.Vote) string {
272	if v == "" {
273		return "null"
274	}
275	return quoteJSON(string(v))
276}
277
278// BallotJSON reports one address's ballot on one proposal. Eligibility is the
279// electorate frozen when the proposal was created, so a recovered key keeps
280// its ballot on older proposals and has none on newer ones. choice is "yes",
281// "no" or "abstain" (the host's three ballot values), or null with a null
282// votedAtHeight when the address has not voted. Ballots stay readable after a
283// proposal is executed, invalidated or expired; tallies do not.
284func (h *Host) BallotJSON(proposalID string, voter address) string {
285	id := parseDecimalID(proposalID, "proposal id")
286	p := h.proposal(id)
287	i := rosterIndex(h.rosters[p.roster], voter)
288	choice, height := "null", "null"
289	if i >= 0 && p.choices[i] != "" {
290		choice = ballotChoiceJSON(p.choices[i])
291		height = quoteJSON(strconv.FormatInt(p.votedAt[i], 10))
292	}
293	return `{"schema":` + quoteJSON(h.readSchema()) + `,"proposalId":` + quoteJSON(strconv.FormatUint(id, 10)) + `,"voter":` + quoteJSON(string(voter)) + `,"eligible":` + strconv.FormatBool(i >= 0) + `,"choice":` + choice + `,"votedAtHeight":` + height + `}`
294}
295
296// PendingVotesJSON lists, newest first, the proposals that are still open for
297// voting where voter is in the frozen electorate and has not voted. before is
298// an exclusive decimal cursor ("" or "0" for newest); limit is clamped to
299// 1..MaxPendingPage. Executing any action invalidates every older open
300// proposal, and deadlines follow creation order, so the scan stops at the first
301// proposal that no longer accepts votes: nothing older can be pending. The
302// scan also stops after MaxPendingScan proposals; next then resumes it. next
303// is null when nothing older can be pending.
304func (h *Host) PendingVotesJSON(voter address, before string, limit int) string {
305	if limit < 1 {
306		limit = 1
307	}
308	if limit > MaxPendingPage {
309		limit = MaxPendingPage
310	}
311	count := h.ProposalCount()
312	id := count
313	if before != "" && before != "0" {
314		cursor := parseDecimalID(before, "proposal cursor")
315		if cursor > count {
316			panic("invalid proposal cursor")
317		}
318		id = cursor - 1
319	}
320	var b strings.Builder
321	b.WriteString(`{"schema":` + quoteJSON(h.readSchema()) + `,"voter":` + quoteJSON(string(voter)) + `,"items":[`)
322	n, scanned, closed := 0, 0, false
323	for id > 0 && n < limit && scanned < MaxPendingScan {
324		p := h.proposals[id-1]
325		s := p.ballot.State()
326		if s.Consumed || s.Invalidated || s.VotingClosed {
327			closed = true
328			break
329		}
330		scanned++
331		i := rosterIndex(h.rosters[p.roster], voter)
332		if i >= 0 && p.choices[i] == "" {
333			if n > 0 {
334				b.WriteByte(',')
335			}
336			b.WriteString(proposalJSON(h.Proposal(id)))
337			n++
338		}
339		id--
340	}
341	b.WriteString(`],"next":`)
342	if closed || id == 0 {
343		b.WriteString("null")
344	} else {
345		b.WriteString(quoteJSON(strconv.FormatUint(id+1, 10)))
346	}
347	b.WriteByte('}')
348	return b.String()
349}