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

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}