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}