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

moderation.gno

19.85 Kb · 788 lines
  1package kourt
  2import (
  3	"chain"
  4	"strconv"
  5	"strings"
  6	bptree "gno.land/p/nt/bptree/v0"
  7)
  8const (
  9	maxReasonLen = 200
 10	maxModSetSize = 32
 11	reSetWindowBlocks = int64(241_920)
 12	modActEvent    = "ModAct"
 13	globalActEvent = "GlobalAct"
 14	purgeEvent     = "Purge"
 15)
 16type daoState struct {
 17	admin   address
 18	members *bptree.BPTree
 19	n       int
 20	purgeM  int
 21	pending *bptree.BPTree
 22}
 23var globalDAO *daoState
 24const (
 25	daoBootstrapN = 1
 26	daoBootstrapM = 1
 27)
 28func ensureGlobalDAO() *daoState {
 29	if globalDAO != nil {
 30		return globalDAO
 31	}
 32	if directoryAdmin == "" {
 33		panic("kourtv2: no court exists yet; the global DAO has no admin")
 34	}
 35	d := &daoState{
 36		admin:   directoryAdmin,
 37		members: bptree.NewBPTree32(),
 38		n:       daoBootstrapN,
 39		purgeM:  daoBootstrapM,
 40		pending: bptree.NewBPTree32(),
 41	}
 42	d.members.Set(directoryAdmin.String(), true)
 43	globalDAO = d
 44	return d
 45}
 46func isGlobalMod(who address) bool {
 47	d := ensureGlobalDAO()
 48	return d.members.Has(who.String())
 49}
 50func AddGlobalMod(cur realm, who address) {
 51	if !cur.IsCurrent() {
 52		panic(errStaleRealm)
 53	}
 54	d := ensureGlobalDAO()
 55	if cur.Previous().Address() != d.admin {
 56		panic("kourtv2: only the global DAO admin manages membership")
 57	}
 58	seatGlobalMember(d, who)
 59}
 60func seatGlobalMember(d *daoState, who address) {
 61	if !who.IsValid() {
 62		panic("kourtv2: not a valid address")
 63	}
 64	if d.members.Has(who.String()) {
 65		return
 66	}
 67	d.members.Set(who.String(), true)
 68	d.n++
 69	clearPendingOnMembershipChange(&d.pending)
 70}
 71func RemoveGlobalMod(cur realm, who address) {
 72	if !cur.IsCurrent() {
 73		panic(errStaleRealm)
 74	}
 75	d := ensureGlobalDAO()
 76	if cur.Previous().Address() != d.admin {
 77		panic("kourtv2: only the global DAO admin manages membership")
 78	}
 79	if who == d.admin {
 80		panic("kourtv2: the admin cannot be removed; transfer admin first")
 81	}
 82	if d.members.Has(who.String()) {
 83		d.members.Remove(who.String())
 84		d.n--
 85		if d.purgeM > d.n {
 86			d.purgeM = d.n
 87		}
 88		clearPendingOnMembershipChange(&d.pending)
 89	}
 90}
 91func SetPurgeThreshold(cur realm, m int) {
 92	if !cur.IsCurrent() {
 93		panic(errStaleRealm)
 94	}
 95	d := ensureGlobalDAO()
 96	if cur.Previous().Address() != d.admin {
 97		panic("kourtv2: only the global DAO admin sets the purge threshold")
 98	}
 99	if m < 1 || m > d.n {
100		panic("kourtv2: the purge threshold must be in 1..n")
101	}
102	if m != d.purgeM {
103		clearPendingOnMembershipChange(&d.pending)
104	}
105	d.purgeM = m
106}
107func TransferGlobalAdmin(cur realm, to address) {
108	if !cur.IsCurrent() {
109		panic(errStaleRealm)
110	}
111	d := ensureGlobalDAO()
112	if cur.Previous().Address() != d.admin {
113		panic("kourtv2: only the global DAO admin transfers the seat")
114	}
115	seatGlobalMember(d, to)
116	d.admin = to
117}
118type courtMod struct {
119	members *bptree.BPTree
120	n       int
121	m       int
122	suspended   bool
123	suspendedAt int64
124	suspendActByGlobal int64
125	suspendedSetID     string
126	suspendedM         int
127	setActHeight       int64
128	lastElectionAt     int64
129	installedByMeta bool
130	courtPurged    bool
131	courtTombstone string
132	claims  *bptree.BPTree
133	pending *bptree.BPTree
134	boardSanctionEpoch int64
135	log    []modAct
136	logSeq uint64
137	folders   *bptree.BPTree
138	folderSeq uint64
139	folderProps   *bptree.BPTree
140	folderPropSeq uint64
141	candidates            *bptree.BPTree
142	candSeq               uint64
143	election              *election
144	electionSeq           uint64
145	electionCooldownUntil     int64
146	electionCooldownUntilTime int64
147	creatorUnseated       bool
148}
149type claimMod struct {
150	court  bool
151	meta   bool
152	global bool
153	purged    bool
154	tombstone string
155	boardFrozenUntil     int64
156	boardFrozenUntilTime int64
157	boardFrozenEpoch     int64
158	boardFrozenGapUntil     int64
159	boardFrozenGapUntilTime int64
160	boardPurgeAll bool
161	globalClearedAt     int64
162	globalClearedAtTime int64
163	executedAt      int64
164	executedAtTime  int64
165	logSeq uint64
166	log    []modAct
167}
168type modAct struct {
169	rowID  uint64
170	actor  address
171	code   string
172	reason string
173	height int64
174	purged bool
175}
176func reSetWindowOpen(clearedAtTime, clearedAt, now int64) bool {
177	passed, known := pastDeadline(clearedAtTime, blocksToSecs(reSetWindowBlocks))
178	return (known && !passed) || (!known && now < clearedAt+reSetWindowBlocks)
179}
180const pendingTTLBlocks = decideWindowBlocks
181type approval struct {
182	approvers    *bptree.BPTree
183	reason       string
184	openedAt     int64
185	openedAtTime int64
186}
187func approvalStale(a *approval) bool {
188	passed, known := pastDeadline(a.openedAtTime, blocksToSecs(pendingTTLBlocks))
189	return (known && passed) || (!known && heightNow() >= a.openedAt+pendingTTLBlocks)
190}
191func ensureMod(c *Court) *courtMod {
192	if c.mod != nil {
193		return c.mod
194	}
195	cm := &courtMod{
196		members: bptree.NewBPTree32(),
197		n:       1,
198		m:       1,
199		claims:  bptree.NewBPTree32(),
200		pending: bptree.NewBPTree32(),
201		folders: bptree.NewBPTree32(),
202	}
203	cm.members.Set(c.admin.String(), true)
204	c.mod = cm
205	return cm
206}
207func AppointMods(cur realm, courtSlug string, m int, mods ...address) {
208	if !cur.IsCurrent() {
209		panic(errStaleRealm)
210	}
211	c := mustCourt(courtSlug)
212	if cur.Previous().Address() != c.admin {
213		panic("kourtv2: only the court creator appoints moderators (until an election)")
214	}
215	if c.mod != nil && c.mod.creatorUnseated {
216		panic("kourtv2: an election has installed a set; the creator's appointment power is spent")
217	}
218	if c.mod != nil && c.mod.suspended {
219		panic("kourtv2: this moderator set is suspended; only an election or a meta verdict may reinstate it")
220	}
221	canon := canonicalMembers(mods)
222	if len(canon) == 0 {
223		panic("kourtv2: a moderator set may not be empty")
224	}
225	if m < 1 || m > len(canon) {
226		panic("kourtv2: the m-of-n threshold must be in 1..n")
227	}
228	cm := ensureMod(c)
229	fresh := bptree.NewBPTree32()
230	n := 0
231	for _, a := range canon {
232		fresh.Set(a.String(), true)
233		n++
234	}
235	cm.members = fresh
236	cm.n = n
237	cm.m = m
238	cm.setActHeight = heightNow()
239	cm.installedByMeta = false
240	clearPendingOnMembershipChange(&cm.pending)
241}
242func requireMod(cm *courtMod, who address) {
243	if !cm.members.Has(who.String()) {
244		panic("kourtv2: not a moderator of this court")
245	}
246}
247func clearPendingOnMembershipChange(pending **bptree.BPTree) {
248	*pending = bptree.NewBPTree32()
249}
250func requireActiveMod(cm *courtMod, who address) {
251	requireMod(cm, who)
252	if cm.suspended {
253		panic("kourtv2: this moderator set is suspended; only clearing its own bits is allowed")
254	}
255}
256func isActiveMod(cm *courtMod, who address) bool {
257	return !cm.suspended && cm.members.Has(who.String())
258}
259func requireEdgeRemover(c *Court, author, who address) {
260	if author == who {
261		return
262	}
263	cm := ensureMod(c)
264	if !cm.members.Has(who.String()) {
265		panic("kourtv2: only the edge's author or a court moderator may remove it")
266	}
267	requireActiveMod(cm, who)
268}
269func mustCategoryCode(code string) {
270	if len(code) == 0 || runeLen(code) > maxReasonLen {
271		panic("kourtv2: purge needs a statutory category code")
272	}
273}
274func checkReason(reason string) {
275	if runeLen(reason) > maxReasonLen {
276		panic("kourtv2: a moderation reason is at most 200 characters")
277	}
278}
279func ensureClaimMod(c *Court, cm *courtMod, claimID uint64) *claimMod {
280	_ = mustClaim(c, claimID)
281	k := beClaimKey(claimID)
282	if v := cm.claims.Get(k); v != nil {
283		return v.(*claimMod)
284	}
285	clm := &claimMod{}
286	cm.claims.Set(k, clm)
287	return clm
288}
289func lookupClaimMod(cm *courtMod, claimID uint64) *claimMod {
290	if v := cm.claims.Get(beClaimKey(claimID)); v != nil {
291		return v.(*claimMod)
292	}
293	return nil
294}
295func (clm *claimMod) appendLog(actor address, code, reason string) uint64 {
296	clm.logSeq++
297	clm.log = append(clm.log, modAct{
298		rowID:  clm.logSeq,
299		actor:  actor,
300		code:   code,
301		reason: reason,
302		height: heightNow(),
303	})
304	return clm.logSeq
305}
306func (cm *courtMod) appendCourtLog(actor address, code, reason string) uint64 {
307	cm.logSeq++
308	cm.log = append(cm.log, modAct{
309		rowID:  cm.logSeq,
310		actor:  actor,
311		code:   code,
312		reason: reason,
313		height: heightNow(),
314	})
315	return cm.logSeq
316}
317func approveAction(pending *bptree.BPTree, key string, who address, reason string, m int) (bool, string) {
318	now := heightNow()
319	var a *approval
320	if v := pending.Get(key); v != nil {
321		a = v.(*approval)
322		if approvalStale(a) {
323			a = nil
324		}
325	}
326	if a == nil {
327		a = &approval{approvers: bptree.NewBPTree32(), reason: reason,
328			openedAt: now, openedAtTime: nowTime()}
329	}
330	if a.approvers.Has(who.String()) {
331		return false, ""
332	}
333	a.approvers.Set(who.String(), true)
334	if a.approvers.Size() >= m {
335		pending.Remove(key)
336		return true, a.reason
337	}
338	pending.Set(key, a)
339	return false, ""
340}
341func pendingOpenedAt(pending *bptree.BPTree, key string) int64 {
342	v := pending.Get(key)
343	if v == nil {
344		return 0
345	}
346	a := v.(*approval)
347	if approvalStale(a) {
348		return 0
349	}
350	return a.openedAt
351}
352func HideItem(cur realm, courtSlug string, claimID uint64, reason string) {
353	if !cur.IsCurrent() {
354		panic(errStaleRealm)
355	}
356	who := cur.Previous().Address()
357	c := mustCourt(courtSlug)
358	cm := ensureMod(c)
359	requireActiveMod(cm, who)
360	checkReason(reason)
361	_ = mustClaim(c, claimID)
362	if clm := lookupClaimMod(cm, claimID); clm != nil && clm.executedAt != 0 {
363		if passed, known := pastDeadline(clm.executedAtTime, blocksToSecs(c.params.votingBlocks)); (known && !passed) ||
364			(!known && heightNow() < clm.executedAt+c.params.votingBlocks) {
365			panic("kourtv2: a review verdict just cleared this; the set may not re-hide yet")
366		}
367	}
368	fire, r := approveAction(cm.pending, "hide:"+strconv.FormatUint(claimID, 10), who, reason, cm.m)
369	if !fire {
370		return
371	}
372	clm := ensureClaimMod(c, cm, claimID)
373	clm.court = true
374	clm.appendLog(who, "hide", r)
375	emitModAct(c.id, claimID, "hide", who)
376}
377func UnhideItem(cur realm, courtSlug string, claimID uint64, reason string) {
378	if !cur.IsCurrent() {
379		panic(errStaleRealm)
380	}
381	who := cur.Previous().Address()
382	c := mustCourt(courtSlug)
383	cm := ensureMod(c)
384	requireMod(cm, who)
385	checkReason(reason)
386	clm := lookupClaimMod(cm, claimID)
387	if clm == nil || !clm.court {
388		panic("kourtv2: this court is not hiding that claim")
389	}
390	fire, r := approveAction(cm.pending, "unhide:"+strconv.FormatUint(claimID, 10), who, reason, cm.m)
391	if !fire {
392		return
393	}
394	clm.court = false
395	clm.appendLog(who, "unhide", r)
396	emitModAct(c.id, claimID, "unhide", who)
397}
398func GlobalHide(cur realm, courtSlug string, claimID uint64, categoryCode string) {
399	if !cur.IsCurrent() {
400		panic(errStaleRealm)
401	}
402	who := cur.Previous().Address()
403	if !isGlobalMod(who) {
404		panic("kourtv2: only a global DAO member may global-hide")
405	}
406	checkReason(categoryCode)
407	c := mustCourt(courtSlug)
408	cm := ensureMod(c)
409	_ = mustClaim(c, claimID)
410	now := heightNow()
411	if prev := lookupClaimMod(cm, claimID); prev != nil && !prev.global &&
412		prev.globalClearedAt != 0 && reSetWindowOpen(prev.globalClearedAtTime, prev.globalClearedAt, now) {
413		d := ensureGlobalDAO()
414		fire, _ := approveAction(d.pending, "reset:"+c.id+":"+strconv.FormatUint(claimID, 10), who, categoryCode, d.purgeM)
415		if !fire {
416			return
417		}
418	}
419	clm := ensureClaimMod(c, cm, claimID)
420	clm.global = true
421	clm.appendLog(who, "global-hide:"+categoryCode, "")
422	emitGlobalAct(c.id, claimID, "global-hide", who)
423}
424func GlobalClear(cur realm, courtSlug string, claimID uint64) {
425	if !cur.IsCurrent() {
426		panic(errStaleRealm)
427	}
428	who := cur.Previous().Address()
429	if !isGlobalMod(who) {
430		panic("kourtv2: only a global DAO member may clear a global hide")
431	}
432	c := mustCourt(courtSlug)
433	cm := ensureMod(c)
434	clm := lookupClaimMod(cm, claimID)
435	if clm == nil || !clm.global {
436		panic("kourtv2: no global hide on that claim")
437	}
438	clm.global = false
439	clm.globalClearedAt = heightNow()
440	clm.globalClearedAtTime = nowTime()
441	clm.appendLog(who, "global-clear", "")
442	emitGlobalAct(c.id, claimID, "global-clear", who)
443}
444func ClearAnyBit(cur realm, courtSlug string, claimID uint64) {
445	if !cur.IsCurrent() {
446		panic(errStaleRealm)
447	}
448	who := cur.Previous().Address()
449	if !isGlobalMod(who) {
450		panic("kourtv2: only a global DAO member may clear bits (recovery)")
451	}
452	c := mustCourt(courtSlug)
453	cm := ensureMod(c)
454	clm := lookupClaimMod(cm, claimID)
455	if clm == nil {
456		panic("kourtv2: nothing to clear on that claim")
457	}
458	clm.court = false
459	clm.meta = false
460	clm.appendLog(who, "clear-any", "")
461	emitGlobalAct(c.id, claimID, "clear-any", who)
462}
463func ClearCourtSuspension(cur realm, courtSlug string) {
464	if !cur.IsCurrent() {
465		panic(errStaleRealm)
466	}
467	who := cur.Previous().Address()
468	if !isGlobalMod(who) {
469		panic("kourtv2: only a global DAO member may clear a suspension (recovery)")
470	}
471	c := mustCourt(courtSlug)
472	cm := ensureMod(c)
473	if !cm.suspended {
474		panic("kourtv2: that court's set is not suspended")
475	}
476	cm.suspended = false
477	cm.suspendActByGlobal = heightNow()
478	emitGlobalAct(c.id, 0, "clear-suspension", who)
479}
480func PurgeClaim(cur realm, courtSlug string, claimID uint64, categoryCode string) {
481	if !cur.IsCurrent() {
482		panic(errStaleRealm)
483	}
484	who := cur.Previous().Address()
485	d := ensureGlobalDAO()
486	if !d.members.Has(who.String()) {
487		panic("kourtv2: only a global DAO member may purge")
488	}
489	mustCategoryCode(categoryCode)
490	c := mustCourt(courtSlug)
491	cm := ensureMod(c)
492	_ = mustClaim(c, claimID)
493	fire, code := approveAction(d.pending, "purge:"+c.id+":"+strconv.FormatUint(claimID, 10), who, categoryCode, d.purgeM)
494	if !fire {
495		return
496	}
497	clm := ensureClaimMod(c, cm, claimID)
498	clm.purged = true
499	clm.tombstone = code
500	if c.id == metaSlug {
501		poisonMetaParse(claimID)
502	}
503	clm.appendLog(who, "purge:"+code, "")
504	emitPurge(c.id, claimID, code, who)
505}
506func GlobalSuspendSet(cur realm, courtSlug string) {
507	if !cur.IsCurrent() {
508		panic(errStaleRealm)
509	}
510	who := cur.Previous().Address()
511	d := ensureGlobalDAO()
512	if !d.members.Has(who.String()) {
513		panic("kourtv2: only a global DAO member may suspend a moderator set")
514	}
515	c := mustCourt(courtSlug)
516	cm := ensureMod(c)
517	if cm.suspended {
518		panic("kourtv2: that set is already suspended")
519	}
520	fire, _ := approveAction(d.pending, "gsuspend:"+c.id, who, "", d.purgeM)
521	if !fire {
522		return
523	}
524	suspendSet(c, who)
525	cm.suspendActByGlobal = heightNow()
526	emitGlobalAct(c.id, 0, "global-suspend-set", who)
527}
528func ResetModSet(cur realm, courtSlug string) {
529	if !cur.IsCurrent() {
530		panic(errStaleRealm)
531	}
532	who := cur.Previous().Address()
533	d := ensureGlobalDAO()
534	if !d.members.Has(who.String()) {
535		panic("kourtv2: only a global DAO member may reset a moderator set")
536	}
537	c := mustCourt(courtSlug)
538	cm := ensureMod(c)
539	fire, _ := approveAction(d.pending, "resetmods:"+c.id, who, "", d.purgeM)
540	if !fire {
541		return
542	}
543	cm.members = bptree.NewBPTree32()
544	cm.n = 0
545	cm.m = 1
546	clearPendingOnMembershipChange(&cm.pending)
547	cm.suspended = false
548	cm.boardSanctionEpoch++
549	cm.setActHeight = heightNow()
550	emitGlobalAct(c.id, 0, "reset-mod-set", who)
551}
552func PurgeCourt(cur realm, courtSlug string, categoryCode string) {
553	if !cur.IsCurrent() {
554		panic(errStaleRealm)
555	}
556	who := cur.Previous().Address()
557	d := ensureGlobalDAO()
558	if !d.members.Has(who.String()) {
559		panic("kourtv2: only a global DAO member may purge")
560	}
561	mustCategoryCode(categoryCode)
562	c := mustCourt(courtSlug)
563	cm := ensureMod(c)
564	fire, code := approveAction(d.pending, "purgecourt:"+c.id, who, categoryCode, d.purgeM)
565	if !fire {
566		return
567	}
568	cm.courtPurged = true
569	cm.courtTombstone = code
570	emitPurge(c.id, 0, code, who)
571}
572func courtIsPurged(c *Court) bool {
573	return c.mod != nil && c.mod.courtPurged
574}
575func CourtPurged(courtSlug string) bool {
576	return courtIsPurged(mustCourt(courtSlug))
577}
578func setMetaBit(c *Court, claimID uint64, by address) {
579	cm := ensureMod(c)
580	clm := ensureClaimMod(c, cm, claimID)
581	clm.meta = true
582	clm.executedAt = heightNow()
583	clm.executedAtTime = nowTime()
584	clm.appendLog(by, "meta-hide", "")
585	emitModAct(c.id, claimID, "meta-hide", by)
586}
587func clearMetaBit(c *Court, claimID uint64, by address) {
588	cm := ensureMod(c)
589	clm := lookupClaimMod(cm, claimID)
590	if clm == nil || !clm.meta {
591		return
592	}
593	clm.meta = false
594	clm.appendLog(by, "meta-clear", "")
595	emitModAct(c.id, claimID, "meta-clear", by)
596}
597func clearCourtBitByMeta(c *Court, claimID uint64, by address) {
598	cm := ensureMod(c)
599	clm := lookupClaimMod(cm, claimID)
600	if clm == nil || !clm.court {
601		return
602	}
603	clm.court = false
604	clm.executedAt = heightNow()
605	clm.executedAtTime = nowTime()
606	clm.appendLog(by, "meta-unhide", "")
607	emitModAct(c.id, claimID, "meta-unhide", by)
608}
609func suspendSet(c *Court, by address) {
610	cm := ensureMod(c)
611	cm.suspended = true
612	cm.suspendedAt = heightNow()
613	cm.suspendedSetID = currentSetID(cm)
614	cm.suspendedM = cm.m
615	emitModAct(c.id, 0, "suspend", by)
616}
617func currentSetID(cm *courtMod) string {
618	var b strings.Builder
619	cm.members.Iterate("", "", func(k string, _ any) bool {
620		b.WriteString(k)
621		b.WriteByte(',')
622		return false
623	})
624	return b.String()
625}
626func unsuspendSet(c *Court, by address) {
627	cm := ensureMod(c)
628	cm.suspended = false
629	emitModAct(c.id, 0, "unsuspend", by)
630}
631func HiddenFromListing(courtSlug string, claimID uint64) bool {
632	c := mustCourt(courtSlug)
633	if c.mod == nil {
634		return false
635	}
636	clm := lookupClaimMod(c.mod, claimID)
637	if clm == nil {
638		return false
639	}
640	return clm.court || clm.meta || clm.global
641}
642func TextRedacted(courtSlug string, claimID uint64) bool {
643	c := mustCourt(courtSlug)
644	if c.mod == nil {
645		return false
646	}
647	clm := lookupClaimMod(c.mod, claimID)
648	if clm == nil {
649		return false
650	}
651	return clm.global || clm.purged
652}
653func ClaimPurged(courtSlug string, claimID uint64) bool {
654	c := mustCourt(courtSlug)
655	if c.mod == nil {
656		return false
657	}
658	clm := lookupClaimMod(c.mod, claimID)
659	return clm != nil && clm.purged
660}
661func PurgeModLogRow(cur realm, courtSlug string, claimID, rowID uint64, categoryCode string) {
662	if !cur.IsCurrent() {
663		panic(errStaleRealm)
664	}
665	who := cur.Previous().Address()
666	d := ensureGlobalDAO()
667	if !d.members.Has(who.String()) {
668		panic("kourtv2: only a global DAO member may purge")
669	}
670	mustCategoryCode(categoryCode)
671	c := mustCourt(courtSlug)
672	cm := ensureMod(c)
673	clm := lookupClaimMod(cm, claimID)
674	if clm == nil {
675		panic("kourtv2: that claim has no moderation log")
676	}
677	idx := -1
678	for i := range clm.log {
679		if clm.log[i].rowID == rowID {
680			idx = i
681			break
682		}
683	}
684	if idx < 0 {
685		panic("kourtv2: no such moderation-log row")
686	}
687	if clm.log[idx].purged {
688		return
689	}
690	key := "purgerow:" + c.id + ":" + strconv.FormatUint(claimID, 10) + ":" +
691		strconv.FormatUint(rowID, 10)
692	fire, code := approveAction(d.pending, key, who, categoryCode, d.purgeM)
693	if !fire {
694		return
695	}
696	clm.log[idx].purged = true
697	clm.log[idx].reason = ""
698	clm.appendLog(who, "purgerow:"+code, "")
699	emitPurge(c.id, claimID, code, who)
700}
701func PendingApproval(courtSlug, actionKey string) (approvals int, expiresAt, expiresAtHeight int64) {
702	var pending *bptree.BPTree
703	if courtSlug == "" {
704		if globalDAO == nil {
705			return 0, 0, 0
706		}
707		pending = globalDAO.pending
708	} else {
709		c := mustCourt(courtSlug)
710		if c.mod == nil {
711			return 0, 0, 0
712		}
713		pending = c.mod.pending
714	}
715	v := pending.Get(actionKey)
716	if v == nil {
717		return 0, 0, 0
718	}
719	a := v.(*approval)
720	if approvalStale(a) {
721		return 0, 0, 0
722	}
723	return a.approvers.Size(),
724		deadlineTime(a.openedAtTime, blocksToSecs(pendingTTLBlocks)),
725		a.openedAt + pendingTTLBlocks
726}
727func CourtSuspended(courtSlug string) bool {
728	c := mustCourt(courtSlug)
729	return c.mod != nil && c.mod.suspended
730}
731func IsCourtMod(courtSlug string, who address) bool {
732	c := mustCourt(courtSlug)
733	if c.mod == nil {
734		return who == c.admin
735	}
736	return c.mod.members.Has(who.String())
737}
738func ModThreshold(courtSlug string) (m, n int) {
739	c := mustCourt(courtSlug)
740	if c.mod == nil {
741		return 1, 1
742	}
743	return c.mod.m, c.mod.n
744}
745func GlobalModCount() int {
746	if globalDAO == nil {
747		return 0
748	}
749	return globalDAO.n
750}
751func ModLogLen(courtSlug string, claimID uint64) int {
752	c := mustCourt(courtSlug)
753	if c.mod == nil {
754		return 0
755	}
756	clm := lookupClaimMod(c.mod, claimID)
757	if clm == nil {
758		return 0
759	}
760	return len(clm.log)
761}
762func emitModAct(slug string, claimID uint64, code string, actor address) {
763	chain.Emit(modActEvent,
764		"court", slug,
765		"claim", strconv.FormatUint(claimID, 10),
766		"act", code,
767		"by", actor.String(),
768		"height", eventHeight(),
769	)
770}
771func emitGlobalAct(slug string, claimID uint64, code string, actor address) {
772	chain.Emit(globalActEvent,
773		"court", slug,
774		"claim", strconv.FormatUint(claimID, 10),
775		"act", code,
776		"by", actor.String(),
777		"height", eventHeight(),
778	)
779}
780func emitPurge(slug string, claimID uint64, categoryCode string, actor address) {
781	chain.Emit(purgeEvent,
782		"court", slug,
783		"claim", strconv.FormatUint(claimID, 10),
784		"code", categoryCode,
785		"by", actor.String(),
786		"height", eventHeight(),
787	)
788}