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

bridge.gno

17.42 Kb · 572 lines
  1// Package memba_bridge_v1 governs the ten Memba application realms published
  2// before memba_gov. It becomes each app's admin through the app's own
  3// two-step handover, so the apps' code does not change, and turns memba_gov
  4// approvals into the app's owner-only calls.
  5//
  6// Every governed entrypoint is a direct call by a seated member. It reads the
  7// app's live pre-state, encodes it with the call's arguments and the app's
  8// tenure (how many times the bridge has accepted it), and consumes exactly
  9// that approval from memba_gov before calling the app: an approval voted on
 10// another state, app, action, argument or tenure never matches. Action ids
 11// are "<app>.<Op>"; the invalidation scope is the app, or an object of it
 12// where actions are independent.
 13//
 14// Accept, ExpirePause and the reads need no vote. EmergencyPause needs a
 15// seated member, no vote, and expires by itself. Approval tells a proposer
 16// exactly what an entrypoint call would consume, from the same code.
 17package memba_bridge_v1
 18
 19import (
 20	"chain"
 21	"strconv"
 22	"time"
 23
 24	"gno.land/p/samcrew/daoauth"
 25	gov "gno.land/r/samcrew/memba_gov"
 26	escrow "gno.land/r/samcrew/escrow_v4"
 27	badges "gno.land/r/samcrew/gnobuilders_badges_v2"
 28	appstore "gno.land/r/samcrew/memba_appstore_v3"
 29	arcade "gno.land/r/samcrew/memba_arcade_leaderboard_v1"
 30	channels "gno.land/r/samcrew/memba_dao_channels_v2"
 31	feed "gno.land/r/samcrew/memba_feed_v1"
 32	feedback "gno.land/r/samcrew/memba_feedback_v2"
 33	market "gno.land/r/samcrew/memba_market_config"
 34	quest "gno.land/r/samcrew/memba_quest_attestation_v1"
 35	reviews "gno.land/r/samcrew/memba_reviews_v2"
 36)
 37
 38const (
 39	appMarket   = "memba_market_config"
 40	appEscrow   = "escrow_v4"
 41	appAppstore = "memba_appstore_v3"
 42	appReviews  = "memba_reviews_v2"
 43	appQuest    = "memba_quest_attestation_v1"
 44	appArcade   = "memba_arcade_leaderboard_v1"
 45	appBadges   = "gnobuilders_badges_v2"
 46	appFeed     = "memba_feed_v1"
 47	appChannels = "memba_dao_channels_v2"
 48	appFeedback = "memba_feedback_v2"
 49)
 50
 51var (
 52	self = chain.PackageAddress("gno.land/r/samcrew/memba_bridge_v1")
 53	apps = []string{appMarket, appEscrow, appAppstore, appReviews, appQuest, appArcade, appBadges, appFeed, appChannels, appFeedback}
 54	// Addresses no governed argument may name: fees sent there are locked
 55	// for good, and a role given to one of them is unusable.
 56	protected   = map[address]bool{}
 57	tenure      = map[string]uint64{}    // app -> times the bridge accepted it
 58	pausedUntil = map[string]time.Time{} // app -> end of the bridge's pause
 59	episodes    = map[string]uint64{}    // app -> pauses the bridge started
 60	lastPause   = map[string]time.Time{} // member id -> last emergency pause
 61)
 62
 63func init() {
 64	for _, path := range []string{"memba_bridge_v1", "memba_gov", "memba_dao", "escrow_v3"} {
 65		protected[chain.PackageAddress("gno.land/r/samcrew/"+path)] = true
 66	}
 67	for _, app := range apps {
 68		protected[chain.PackageAddress("gno.land/r/samcrew/"+app)] = true
 69	}
 70}
 71
 72// ── Authorisation ───────────────────────────────────────────────────────
 73
 74// direct returns the account that called the bridge directly.
 75func direct(cur realm) address {
 76	if !cur.IsCurrent() || !cur.Previous().IsUserCall() {
 77		panic("memba_bridge: call directly from your account")
 78	}
 79	return cur.Previous().Address()
 80}
 81
 82// A plan is the approval one governed call needs: action app.op, args
 83// (completed by the app's tenure, which voids approvals voted while the app
 84// was away), the invalidation scope and the minimum class.
 85type plan struct {
 86	app, op string
 87	args    *daoauth.Args
 88	scope   string
 89	class   int
 90}
 91
 92// newPlan refuses an approval for an app the bridge does not govern now: no
 93// call could consume it.
 94func newPlan(app, op string, args *daoauth.Args, scope string, class int) plan {
 95	if !governs(app) {
 96		panic("memba_bridge: the bridge does not govern " + app)
 97	}
 98	return plan{app, op, args, scope, class}
 99}
100
101func (p plan) voted() string { return p.args.Uint(tenure[p.app]).String() }
102
103// approve consumes proposal pid for p; memba_gov checks the executor, the
104// bridge's direct caller, is a seated member.
105func approve(cur realm, pid uint64, p plan) {
106	executor := direct(cur)
107	gov.Consume(cross(cur), pid, p.app+"."+p.op, p.voted(), p.scope, p.class, executor)
108}
109
110// Approval returns, as JSON, the proposal a governed entrypoint call would
111// consume if made now: target this realm, with the action, args, scope and
112// minimum class given. call is the entrypoint's name and its arguments after
113// pid, daoauth-encoded in order (strings s:, addresses a:, integers i: or
114// u:, booleans b:). It refuses a call its entrypoint would refuse before
115// voting, and holds only while the state it binds is unchanged.
116func Approval(call string) string {
117	c := parseCall(call)
118	var p plan
119	switch name := c.str(); name {
120	case "TransferAdmin":
121		p = transferPlan(c.str(), c.addr())
122	case "CancelTransfer":
123		p = cancelPlan(c.str())
124	case "SetPause":
125		p = pausePlan(c.str(), c.i64())
126	case "Grant", "Revoke":
127		p = rolePlan(c.str(), c.addr(), name == "Grant")
128	case "Curate":
129		p = curatePlan(c.str(), c.str(), c.str())
130	case "SetRegistrationFee":
131		p = registrationFeePlan(c.i64())
132	case "SetTreasury":
133		p = treasuryPlan(c.str(), c.addr())
134	case "SetFee":
135		p = feePlan(c.str(), c.i64())
136	case "ResolveDispute":
137		p, _ = disputePlan(c.str(), int(c.i64()), c.flag())
138	case "ProposeFeeRecipient":
139		p = feeRecipientPlan(c.addr())
140	case "CancelFeeRecipient":
141		p = cancelFeeRecipientPlan()
142	case "HideReview", "HideComment", "Unhide":
143		p = moderationPlan(name, c.u64())
144	case "SetQuestSigner":
145		p = signerPlan(c.str())
146	case "AddMember", "SetRoles":
147		p = memberPlan(c.str(), name, c.addr(), c.str())
148	case "RemoveMember":
149		p = memberPlan(c.str(), name, c.addr(), "")
150	case "CreateChannel":
151		p = channelPlan(c.str(), c.str(), c.str(), c.str())
152	default:
153		panic("memba_bridge: no governed entrypoint " + name)
154	}
155	c.done()
156	return `{"target":"gno.land/r/samcrew/memba_bridge_v1","action":` + quote(p.app+"."+p.op) +
157		`,"args":` + quote(p.voted()) + `,"scope":` + quote(p.scope) + `,"class":` + strconv.Itoa(p.class) + `}`
158}
159
160// call reads an Approval call's fields in order, each with its tag.
161type call struct{ f []daoauth.Field }
162
163func parseCall(s string) *call {
164	f, err := daoauth.Parse(s)
165	if err != nil {
166		panic(err.Error())
167	}
168	return &call{f}
169}
170
171func (c *call) next(tag byte) string {
172	if len(c.f) == 0 || c.f[0].Tag != tag {
173		panic("memba_bridge: call argument " + string(tag) + ": expected")
174	}
175	v := c.f[0].Value
176	c.f = c.f[1:]
177	return v
178}
179
180// daoauth.Parse has checked each value is canonical for its tag.
181func (c *call) str() string   { return c.next('s') }
182func (c *call) addr() address { return address(c.next('a')) }
183func (c *call) i64() int64 {
184	n, _ := strconv.ParseInt(c.next('i'), 10, 64)
185	return n
186}
187
188func (c *call) u64() uint64 {
189	n, _ := strconv.ParseUint(c.next('u'), 10, 64)
190	return n
191}
192
193func (c *call) flag() bool { return c.next('b') == "1" }
194
195func (c *call) done() {
196	if len(c.f) != 0 {
197		panic("memba_bridge: too many call arguments")
198	}
199}
200
201// quote returns s, printable ASCII, as a JSON string with < > & escaped.
202func quote(s string) string {
203	b := make([]byte, 0, 6*len(s)+2)
204	b = append(b, '"')
205	for i := 0; i < len(s); i++ {
206		switch c := s[i]; c {
207		case '"', '\\':
208			b = append(b, '\\', c)
209		case '<', '>', '&':
210			b = append(b, `\u00`...)
211			b = append(b, "0123456789abcdef"[c>>4], "0123456789abcdef"[c&0xf])
212		default:
213			b = append(b, c)
214		}
215	}
216	return string(append(b, '"'))
217}
218
219func checkApp(app string) {
220	for _, a := range apps {
221		if a == app {
222			return
223		}
224	}
225	panic("memba_bridge: unknown app " + app)
226}
227
228// arg refuses a non-canonical or protected address as a recipient of a role,
229// membership, admin or fees.
230func arg(a address) address {
231	if protected[a] {
232		panic("memba_bridge: address must not be a governance or app realm")
233	}
234	return canonical(a)
235}
236
237// canonical refuses an address the chain would never give a caller.
238func canonical(a address) address {
239	if !daoauth.ValidAddress(a) {
240		panic("memba_bridge: address must be lowercase bech32")
241	}
242	return a
243}
244
245// ── Admin handover (every app) ──────────────────────────────────────────
246
247// Accept takes the admin role the 2-of-3 nominated the bridge for. It needs
248// no vote: the nomination is the decision. A paused app is refused: only a
249// pause the bridge started can be ended by its rules.
250func Accept(cur realm, app string) {
251	checkApp(app)
252	if hasPause(app) && isPaused(app) {
253		panic("memba_bridge: unpause " + app + " before handing it over")
254	}
255	switch app {
256	case appMarket:
257		market.AcceptAdmin(cross(cur))
258	case appEscrow:
259		escrow.AcceptOwnership(cross(cur))
260	case appAppstore:
261		appstore.AcceptOwnership(cross(cur))
262	case appReviews:
263		reviews.AcceptOwnership(cross(cur))
264	case appQuest:
265		quest.AcceptOwnership(cross(cur))
266	case appArcade:
267		arcade.AcceptOwnership(cross(cur))
268	case appBadges:
269		badges.AcceptOwnership(cross(cur))
270	case appFeed:
271		feed.AcceptOwnership(cross(cur))
272	case appChannels:
273		channels.AcceptOwnership(cross(cur))
274	case appFeedback:
275		feedback.AcceptOwnership(cross(cur))
276	}
277	tenure[app]++
278	delete(pausedUntil, app) // a record from an earlier tenure never applies
279}
280
281// TransferAdmin stages to as the app's next admin: the 2-of-3, a successor
282// bridge, or anyone the DAO names. The recipient accepts by its own call.
283func TransferAdmin(cur realm, pid uint64, app string, to address) {
284	approve(cur, pid, transferPlan(app, to))
285	switch app {
286	case appMarket:
287		market.TransferAdmin(cross(cur), to)
288	case appEscrow:
289		escrow.TransferOwnership(cross(cur), to)
290	case appAppstore:
291		appstore.TransferOwnership(cross(cur), to)
292	case appReviews:
293		reviews.TransferOwnership(cross(cur), to)
294	case appQuest:
295		quest.TransferOwnership(cross(cur), to)
296	case appArcade:
297		arcade.TransferOwnership(cross(cur), to)
298	case appBadges:
299		badges.TransferOwnership(cross(cur), to)
300	case appFeed:
301		feed.TransferOwnership(cross(cur), to)
302	case appChannels:
303		channels.ProposeOwner(cross(cur), to)
304	case appFeedback:
305		feedback.TransferOwnership(cross(cur), to)
306	}
307}
308
309func transferPlan(app string, to address) plan {
310	checkApp(app)
311	return newPlan(app, "TransferAdmin", daoauth.New().Addr(arg(to)).Str(pendingAdmin(app)), app, gov.Critical)
312}
313
314// CancelTransfer withdraws a staged admin before the recipient accepts.
315func CancelTransfer(cur realm, pid uint64, app string) {
316	approve(cur, pid, cancelPlan(app))
317	switch app {
318	case appMarket:
319		market.CancelAdminTransfer(cross(cur))
320	case appEscrow:
321		escrow.CancelOwnershipTransfer(cross(cur))
322	case appAppstore:
323		appstore.CancelOwnershipTransfer(cross(cur))
324	case appReviews:
325		reviews.CancelOwnershipTransfer(cross(cur))
326	case appQuest:
327		quest.CancelOwnershipTransfer(cross(cur))
328	case appArcade:
329		arcade.CancelOwnershipTransfer(cross(cur))
330	case appBadges:
331		badges.CancelOwnershipTransfer(cross(cur))
332	case appFeed:
333		feed.CancelOwnershipTransfer(cross(cur))
334	case appChannels:
335		channels.CancelPendingOwner(cross(cur))
336	case appFeedback:
337		feedback.CancelOwnershipTransfer(cross(cur))
338	}
339}
340
341func cancelPlan(app string) plan {
342	checkApp(app)
343	pending := pendingAdmin(app)
344	if pending == "" {
345		panic("memba_bridge: nothing staged to cancel for " + app)
346	}
347	return newPlan(app, "CancelTransfer", daoauth.New().Str(pending), app, gov.Financial)
348}
349
350// governs reports whether the bridge is app's admin now.
351func governs(app string) bool {
352	var admin string
353	switch app {
354	case appMarket:
355		admin = string(market.GetAdmin())
356	case appEscrow:
357		admin = escrow.GetAdmin()
358	case appAppstore:
359		admin = string(appstore.GetOwner())
360	case appReviews:
361		admin = reviews.GetModerator()
362	case appQuest:
363		admin = quest.GetOwner()
364	case appArcade:
365		admin = string(arcade.GetOwner())
366	case appBadges:
367		admin = string(badges.GetOwner())
368	case appFeed:
369		admin = string(feed.GetOwner())
370	case appChannels:
371		admin = string(channels.GetOwner())
372	case appFeedback:
373		admin = string(feedback.GetOwner())
374	default:
375		return false
376	}
377	return admin == string(self)
378}
379
380func pendingAdmin(app string) string {
381	switch app {
382	case appMarket:
383		return string(market.GetPendingAdmin())
384	case appEscrow:
385		return escrow.GetPendingAdmin()
386	case appAppstore:
387		return string(appstore.GetPendingOwner())
388	case appReviews:
389		return reviews.GetPendingModerator()
390	case appQuest:
391		return quest.GetPendingOwner()
392	case appArcade:
393		return string(arcade.GetPendingOwner())
394	case appBadges:
395		return string(badges.GetPendingOwner())
396	case appFeed:
397		return string(feed.GetPendingOwner())
398	case appChannels:
399		return string(channels.GetPendingOwner())
400	}
401	return string(feedback.GetPendingOwner())
402}
403
404// ── Pause (escrow, App Store, arcade, badges, feed, channels, feedback) ──
405
406// EmergencyPause pauses app for 7 days, without a vote. Each member may use
407// it once per 30 days, so one key cannot keep an app closed.
408func EmergencyPause(cur realm, app string) {
409	id := gov.PersonOf(direct(cur))
410	if id == "" {
411		panic("memba_bridge: not a seated member")
412	}
413	if !governs(app) {
414		panic("memba_bridge: the bridge does not govern " + app)
415	}
416	// A paused app is refused first, and costs no allowance: an emergency
417	// pause must never shorten a longer pause members voted.
418	if isPaused(app) {
419		panic("memba_bridge: already paused")
420	}
421	now := time.Now()
422	if last, ok := lastPause[id]; ok && now.Before(last.Add(PauseAllowance)) {
423		panic("memba_bridge: one emergency pause per member per 30 days")
424	}
425	setPaused(cur, app, true)
426	pausedUntil[app] = now.Add(EmergencyPauseFor)
427	episodes[app]++
428	lastPause[id] = now
429}
430
431// SetPause ends the bridge's pause of app now (until = 0) or keeps app
432// paused until the unix time until, at most 30 days ahead. The approval names
433// the pause episode, so it never ends a later pause.
434func SetPause(cur realm, pid uint64, app string, until int64) {
435	approve(cur, pid, pausePlan(app, until))
436	if until == 0 {
437		if isPaused(app) {
438			setPaused(cur, app, false)
439		}
440		delete(pausedUntil, app)
441		return
442	}
443	if !isPaused(app) {
444		setPaused(cur, app, true)
445		episodes[app]++
446	}
447	pausedUntil[app] = time.Unix(until, 0)
448}
449
450func pausePlan(app string, until int64) plan {
451	now := time.Now()
452	if end := time.Unix(until, 0); until != 0 && (!end.After(now) || end.After(now.Add(MaxPauseAhead))) {
453		panic("memba_bridge: pause end must be within the next 30 days")
454	}
455	// Governance first: an app the bridge does not govern says so before its pause is read.
456	p := newPlan(app, "SetPause", daoauth.New(), app, gov.Financial)
457	p.args.Int(until).Bool(isPaused(app)).Uint(episodes[app])
458	return p
459}
460
461// ExpirePause lets anyone end the bridge's pause of app once it is over. A
462// SetPause that keeps app paused longer must execute before then. Once the
463// bridge no longer governs app, its record no longer applies: anyone drops
464// it, and the app, now its new admin's, is left as it is.
465func ExpirePause(cur realm, app string) {
466	end, ok := pausedUntil[app]
467	if ok && !governs(app) {
468		delete(pausedUntil, app)
469		return
470	}
471	if !ok || time.Now().Before(end) {
472		panic("memba_bridge: no expired pause for " + app)
473	}
474	delete(pausedUntil, app)
475	if isPaused(app) {
476		setPaused(cur, app, false)
477	}
478}
479
480func hasPause(app string) bool {
481	return app != appMarket && app != appReviews && app != appQuest
482}
483
484func isPaused(app string) bool {
485	switch app {
486	case appEscrow:
487		return escrow.IsPaused()
488	case appAppstore:
489		return appstore.IsPaused()
490	case appArcade:
491		return arcade.IsPaused()
492	case appBadges:
493		return badges.IsPaused()
494	case appFeed:
495		return feed.IsPaused()
496	case appChannels:
497		return channels.IsPaused()
498	case appFeedback:
499		return feedback.IsPaused()
500	}
501	panic("memba_bridge: " + app + " has no pause")
502}
503
504func setPaused(cur realm, app string, on bool) {
505	switch {
506	case app == appEscrow && on:
507		escrow.Pause(cross(cur))
508	case app == appEscrow:
509		escrow.Unpause(cross(cur))
510	case app == appAppstore:
511		appstore.Pause(cross(cur), on)
512	case app == appArcade:
513		arcade.Pause(cross(cur), on)
514	case app == appBadges && on:
515		badges.Pause(cross(cur))
516	case app == appBadges:
517		badges.Unpause(cross(cur))
518	case app == appFeed && on:
519		feed.PauseRealm(cross(cur))
520	case app == appFeed:
521		feed.UnpauseRealm(cross(cur))
522	case app == appChannels && on:
523		channels.PauseRealm(cross(cur))
524	case app == appChannels:
525		channels.UnpauseRealm(cross(cur))
526	case app == appFeedback && on:
527		feedback.PauseRealm(cross(cur))
528	case app == appFeedback:
529		feedback.UnpauseRealm(cross(cur))
530	}
531}
532
533func unixOf(t time.Time) int64 {
534	if t.IsZero() {
535		return 0
536	}
537	return t.Unix()
538}
539
540// ── Reads ───────────────────────────────────────────────────────────────
541
542// PausedUntil returns the unix end of the bridge's pause of app, or 0 (also
543// once the bridge no longer governs app).
544func PausedUntil(app string) int64 {
545	if !governs(app) {
546		return 0
547	}
548	return unixOf(pausedUntil[app])
549}
550
551// PauseEpisode returns how many pauses of app the bridge has started; a
552// SetPause approval binds it.
553func PauseEpisode(app string) uint64 { return episodes[app] }
554
555// Tenure returns how many times the bridge has accepted app; every approval
556// for app binds it as its last argument.
557func Tenure(app string) uint64 { return tenure[app] }
558
559func Render(_ string) string {
560	out := "# Memba bridge v1\n\nGoverns the ten Memba apps for memba_gov. Bridge pauses:\n\n"
561	n := 0
562	for _, app := range apps {
563		if end, ok := pausedUntil[app]; ok && governs(app) {
564			out += "- " + app + " until unix " + strconv.FormatInt(end.Unix(), 10) + "\n"
565			n++
566		}
567	}
568	if n == 0 {
569		out += "None.\n"
570	}
571	return out
572}