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}