Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

apps.gno

16.45 Kb · 428 lines
  1package memba_bridge_v1
  2
  3import (
  4	"chain"
  5	"chain/banker"
  6	"crypto/sha256"
  7	"encoding/hex"
  8	"strconv"
  9	"strings"
 10
 11	"gno.land/p/g1a9g8nu3m0xgx58hh36fph49e2xkqm9axlxuf5z/daoauth"
 12	gov "gno.land/r/g1a9g8nu3m0xgx58hh36fph49e2xkqm9axlxuf5z/memba_gov"
 13	escrow "gno.land/r/g1a9g8nu3m0xgx58hh36fph49e2xkqm9axlxuf5z/escrow_v4"
 14	badges "gno.land/r/g1a9g8nu3m0xgx58hh36fph49e2xkqm9axlxuf5z/gnobuilders_badges_v2"
 15	appstore "gno.land/r/g1a9g8nu3m0xgx58hh36fph49e2xkqm9axlxuf5z/memba_appstore_v3"
 16	arcade "gno.land/r/g1a9g8nu3m0xgx58hh36fph49e2xkqm9axlxuf5z/memba_arcade_leaderboard_v1"
 17	channels "gno.land/r/g1a9g8nu3m0xgx58hh36fph49e2xkqm9axlxuf5z/memba_dao_channels_v2"
 18	feed "gno.land/r/g1a9g8nu3m0xgx58hh36fph49e2xkqm9axlxuf5z/memba_feed_v1"
 19	feedback "gno.land/r/g1a9g8nu3m0xgx58hh36fph49e2xkqm9axlxuf5z/memba_feedback_v2"
 20	market "gno.land/r/g1a9g8nu3m0xgx58hh36fph49e2xkqm9axlxuf5z/memba_market_config"
 21	quest "gno.land/r/g1a9g8nu3m0xgx58hh36fph49e2xkqm9axlxuf5z/memba_quest_attestation_v1"
 22	reviews "gno.land/r/g1a9g8nu3m0xgx58hh36fph49e2xkqm9axlxuf5z/memba_reviews_v2"
 23)
 24
 25// ── Roles: App Store curators, arcade attesters, badge admins, feed
 26// moderators. Operational work (curating, attesting, minting, moderating)
 27// is done by the holders the DAO appoints here.
 28
 29func Grant(cur realm, pid uint64, app string, who address) { role(cur, pid, app, who, true) }
 30
 31func Revoke(cur realm, pid uint64, app string, who address) { role(cur, pid, app, who, false) }
 32
 33func role(cur realm, pid uint64, app string, who address, grant bool) {
 34	approve(cur, pid, rolePlan(app, who, grant))
 35	switch {
 36	case app == appAppstore && grant:
 37		appstore.AddCurator(cross(cur), who)
 38	case app == appAppstore:
 39		appstore.RemoveCurator(cross(cur), who)
 40	case app == appArcade && grant:
 41		arcade.AddAttester(cross(cur), who)
 42	case app == appArcade:
 43		arcade.RemoveAttester(cross(cur), who)
 44	case app == appBadges && grant:
 45		badges.AddAdmin(cross(cur), who)
 46	case app == appBadges:
 47		badges.RemoveAdmin(cross(cur), who)
 48	case app == appFeed && grant:
 49		feed.AddModerator(cross(cur), who)
 50	default:
 51		feed.RemoveModerator(cross(cur), who)
 52	}
 53}
 54
 55func rolePlan(app string, who address, grant bool) plan {
 56	if grant {
 57		arg(who)
 58	} else if canonical(who) == self {
 59		// Revoking its own curator grant would end DAO curation until a hand-over round trip.
 60		panic("memba_bridge: the bridge keeps its own role")
 61	}
 62	// A stale grant held by another protected realm can be revoked.
 63	if hasRole(app, who) == grant {
 64		panic("memba_bridge: role already in that state")
 65	}
 66	op := "Revoke"
 67	if grant {
 68		op = "Grant"
 69	}
 70	args := daoauth.New().Addr(who)
 71	if app == appBadges {
 72		args.Int(int64(badges.GetAdminCount()))
 73	}
 74	return newPlan(app, op, args, app, gov.Critical)
 75}
 76
 77func hasRole(app string, who address) bool {
 78	switch app {
 79	case appAppstore:
 80		return appstore.IsCurator(string(who))
 81	case appArcade:
 82		return arcade.IsAttester(string(who))
 83	case appBadges:
 84		return badges.IsAdmin(who)
 85	case appFeed:
 86		return feed.IsModerator(who)
 87	}
 88	panic("memba_bridge: " + app + " has no role to grant")
 89}
 90
 91// ── App Store ───────────────────────────────────────────────────────────
 92
 93// Curate runs a curator action on one listing: approve, reject (with a
 94// reason), delist, restore or clearflags. Every approval binds the listing's
 95// status; approve and restore, which publish, also bind its content, and
 96// clearflags binds the flags it clears. Editing or flagging a listing thus
 97// cannot void a reject or delist vote, and cannot slip new content past one.
 98func Curate(cur realm, pid uint64, op, pkgPath, reason string) {
 99	approve(cur, pid, curatePlan(op, pkgPath, reason))
100	switch op {
101	case "approve":
102		appstore.ApproveApp(cross(cur), pkgPath)
103	case "reject":
104		appstore.RejectApp(cross(cur), pkgPath, reason)
105	case "delist":
106		appstore.DelistApp(cross(cur), pkgPath)
107	case "restore":
108		appstore.RestoreApp(cross(cur), pkgPath)
109	default:
110		appstore.ClearFlags(cross(cur), pkgPath)
111	}
112}
113
114func curatePlan(op, pkgPath, reason string) plan {
115	switch {
116	case op != "approve" && op != "reject" && op != "delist" && op != "restore" && op != "clearflags":
117		panic("memba_bridge: unknown curation " + op)
118	case (op == "reject") != (reason != ""):
119		panic("memba_bridge: a reason goes with reject only")
120	}
121	l := appstore.GetGovernanceListing(pkgPath, op == "clearflags")
122	if !l.Exists {
123		panic("memba_bridge: no listing " + pkgPath)
124	}
125	args := daoauth.New().Str(op).Str(pkgPath).Str(reason).Str(l.Status)
126	if op == "approve" || op == "restore" {
127		args.Str(l.ContentHash)
128	}
129	if op == "clearflags" {
130		keys := sha256.Sum256([]byte(l.ClearKeys))
131		args.Int(int64(l.Flags)).Str(hex.EncodeToString(keys[:]))
132	}
133	return newPlan(appAppstore, "Curate", args, appAppstore+"/l/"+pkgPath, gov.Routine)
134}
135
136func SetRegistrationFee(cur realm, pid uint64, fee int64) {
137	approve(cur, pid, registrationFeePlan(fee))
138	appstore.SetRegistrationFee(cross(cur), fee)
139}
140
141func registrationFeePlan(fee int64) plan {
142	if fee < 0 || fee > appstore.MaxRegistrationFee {
143		panic("memba_bridge: the registration fee is 0 to " + strconv.FormatInt(appstore.MaxRegistrationFee, 10) + " ugnot")
144	}
145	return newPlan(appAppstore, "SetRegistrationFee", daoauth.New().Int(fee).Int(appstore.GetRegistrationFee()), appAppstore, gov.Financial)
146}
147
148// SetTreasury points the fees of market_config (which escrow also pays
149// into) or of the App Store at addr.
150func SetTreasury(cur realm, pid uint64, app string, addr address) {
151	approve(cur, pid, treasuryPlan(app, addr))
152	if app == appMarket {
153		market.SetTreasury(cross(cur), addr)
154	} else {
155		appstore.SetTreasury(cross(cur), addr)
156	}
157}
158
159func treasuryPlan(app string, addr address) plan {
160	var current address
161	switch app {
162	case appMarket:
163		current = market.GetTreasury()
164	case appAppstore:
165		current = appstore.GetTreasury()
166	default:
167		panic("memba_bridge: " + app + " has no treasury")
168	}
169	return newPlan(app, "SetTreasury", daoauth.New().Addr(arg(addr)).Str(string(current)), app, gov.Critical)
170}
171
172// ── Market config ───────────────────────────────────────────────────────
173
174func SetFee(cur realm, pid uint64, lane string, bps int64) {
175	approve(cur, pid, feePlan(lane, bps))
176	market.SetFeeBPS(cross(cur), lane, bps)
177}
178
179func feePlan(lane string, bps int64) plan {
180	if lane == "" || bps < 0 || bps > market.MaxFeeBPS {
181		panic("memba_bridge: a fee names its lane and is 0 to " + strconv.FormatInt(market.MaxFeeBPS, 10) + " bps")
182	}
183	return newPlan(appMarket, "SetFee", daoauth.New().Str(lane).Int(bps).Int(int64(market.GetFeeBPS(lane))), appMarket, gov.Financial)
184}
185
186// ── Escrow ──────────────────────────────────────────────────────────────
187
188// ResolveDispute settles one disputed milestone: refund pays the client
189// the whole amount, otherwise the freelancer is paid less the fee. Either
190// way escrow pays out at most the milestone's amount, which is checked. A
191// milestone is disputed at most once (a resolution is final); binding the
192// height of that dispute means no approval can be voted before it exists.
193func ResolveDispute(cur realm, pid uint64, contractID string, milestone int, refund bool) {
194	p, amount := disputePlan(contractID, milestone, refund)
195	approve(cur, pid, p)
196	before := escrowBalance()
197	escrow.ResolveDispute(cross(cur), contractID, milestone, refund)
198	if paid := before - escrowBalance(); paid < 0 || paid > amount {
199		panic("memba_bridge: escrow paid out more than the milestone")
200	}
201}
202
203// disputePlan returns the approval and the milestone's amount.
204func disputePlan(contractID string, milestone int, refund bool) (plan, int64) {
205	c := escrow.GetGovernanceContract(contractID)
206	if !c.Exists || milestone < 0 || milestone >= c.Count {
207		panic("memba_bridge: no such contract milestone")
208	}
209	m := c.Milestones[milestone]
210	if m.Status != "disputed" {
211		panic("memba_bridge: milestone is " + m.Status + ", not disputed")
212	}
213	args := daoauth.New().Str(contractID).Int(int64(milestone)).Bool(refund).
214		Str(m.Status).Str(m.PreDisputeStatus).Int(m.Amount).Int(m.DisputedAt)
215	if !refund {
216		terms := escrow.GetGovernanceFeeTerms()
217		args.Int(terms.EffectiveBPS).Str(string(terms.EffectiveTreasury))
218	}
219	return newPlan(appEscrow, "ResolveDispute", args, appEscrow+"/c/"+contractID+"/m/"+strconv.Itoa(milestone), gov.Financial), m.Amount
220}
221
222func escrowBalance() int64 {
223	// One denom only: junk denoms sent to escrow cost nothing to read past.
224	return banker.NewReadonlyBanker().GetCoin(chain.PackageAddress("gno.land/r/g1a9g8nu3m0xgx58hh36fph49e2xkqm9axlxuf5z/"+appEscrow), "ugnot")
225}
226
227// ProposeFeeRecipient stages escrow's fallback fee recipient, used only when
228// market_config's treasury is unusable.
229func ProposeFeeRecipient(cur realm, pid uint64, addr address) {
230	approve(cur, pid, feeRecipientPlan(addr))
231	escrow.ProposeFeeRecipient(cross(cur), addr)
232}
233
234func feeRecipientPlan(addr address) plan {
235	return newPlan(appEscrow, "ProposeFeeRecipient", daoauth.New().Addr(arg(addr)).
236		Str(escrow.GetFeeRecipient()).Str(escrow.GetPendingFeeRecipient()), appEscrow, gov.Critical)
237}
238
239func CancelFeeRecipient(cur realm, pid uint64) {
240	approve(cur, pid, cancelFeeRecipientPlan())
241	escrow.CancelFeeRecipientProposal(cross(cur))
242}
243
244func cancelFeeRecipientPlan() plan {
245	pending := escrow.GetPendingFeeRecipient()
246	if pending == "" {
247		panic("memba_bridge: no fee recipient staged")
248	}
249	return newPlan(appEscrow, "CancelFeeRecipient", daoauth.New().Str(pending), appEscrow, gov.Financial)
250}
251
252// ── Reviews ─────────────────────────────────────────────────────────────
253
254func HideReview(cur realm, pid uint64, id uint64)  { moderate(cur, pid, "HideReview", id) }
255func HideComment(cur realm, pid uint64, id uint64) { moderate(cur, pid, "HideComment", id) }
256
257// Unhide shows an item again and dismisses its flags.
258func Unhide(cur realm, pid uint64, id uint64) { moderate(cur, pid, "Unhide", id) }
259
260// moderate binds the item's kind and visibility; Unhide, which shows the
261// item again, also binds its content, so an author editing it can neither
262// void a hide vote nor slip new text past an unhide vote.
263func moderate(cur realm, pid uint64, op string, id uint64) {
264	approve(cur, pid, moderationPlan(op, id))
265	switch op {
266	case "HideReview":
267		reviews.HideReview(cross(cur), id)
268	case "HideComment":
269		reviews.HideComment(cross(cur), id)
270	default:
271		reviews.Unhide(cross(cur), id)
272	}
273}
274
275func moderationPlan(op string, id uint64) plan {
276	s := reviews.GetModerationState(id)
277	switch {
278	case s.ID == 0:
279		// Ids are predictable: an approval for an item not posted yet would hide whatever takes the id.
280		panic("memba_bridge: no review or comment " + strconv.FormatUint(id, 10))
281	case op == "HideReview" && !s.Review:
282		panic("memba_bridge: item " + strconv.FormatUint(id, 10) + " is a comment, not a review")
283	case op == "HideComment" && s.Review:
284		panic("memba_bridge: item " + strconv.FormatUint(id, 10) + " is a review, not a comment")
285	case op != "Unhide" && (s.Hidden || s.Deleted):
286		panic("memba_bridge: already hidden or deleted")
287	case op == "Unhide" && !s.Hidden && !s.Flagged:
288		panic("memba_bridge: neither hidden nor flagged")
289	}
290	// The author and creation time pin the item itself.
291	args := daoauth.New().Uint(id).Bool(s.Review).Bool(s.Hidden).Bool(s.Deleted).Addr(s.Author).Int(s.CreatedAt)
292	if op == "Unhide" {
293		// The text is bound by its hash and edit height. Unhide also dismisses
294		// flags; the flagged bit is bound, not a count (reviews_v2 exposes none),
295		// so a flag added after the vote to an item already flagged is
296		// dismissed with the others.
297		args.Str(s.BodyHash).Int(s.EditedAt).Bool(s.Flagged)
298	}
299	return newPlan(appReviews, op, args, appReviews+"/i/"+strconv.FormatUint(id, 10), gov.Routine)
300}
301
302// ── Quests ──────────────────────────────────────────────────────────────
303
304// SetQuestSigner rotates the key every quest voucher is checked against.
305func SetQuestSigner(cur realm, pid uint64, pubKeyHex string) {
306	approve(cur, pid, signerPlan(pubKeyHex))
307	quest.SetSigner(cross(cur), pubKeyHex)
308}
309
310func signerPlan(pubKeyHex string) plan {
311	if len(pubKeyHex) != 64 || strings.Trim(pubKeyHex, "0123456789abcdef") != "" || pubKeyHex == quest.GetSigner() {
312		panic("memba_bridge: a new signer is 64 lowercase hex digits")
313	}
314	return newPlan(appQuest, "SetSigner", daoauth.New().Str(pubKeyHex).Str(quest.GetSigner()), appQuest, gov.Critical)
315}
316
317// ── Channels and feedback membership ────────────────────────────────────
318
319// AddMember adds a new member; it never rewrites an existing member's roles.
320func AddMember(cur realm, pid uint64, app string, addr address, roles string) {
321	member(cur, pid, app, "AddMember", addr, roles)
322}
323
324func RemoveMember(cur realm, pid uint64, app string, addr address) {
325	member(cur, pid, app, "RemoveMember", addr, "")
326}
327
328func SetRoles(cur realm, pid uint64, app string, addr address, roles string) {
329	member(cur, pid, app, "SetRoles", addr, roles)
330}
331
332func member(cur realm, pid uint64, app, op string, who address, roles string) {
333	approve(cur, pid, memberPlan(app, op, who, roles))
334	switch {
335	case app == appChannels && op == "AddMember":
336		channels.AddMember(cross(cur), who, roles)
337	case app == appChannels && op == "RemoveMember":
338		channels.RemoveMember(cross(cur), who)
339	case app == appChannels:
340		channels.UpdateMemberRoles(cross(cur), who, roles)
341	case op == "AddMember":
342		feedback.AddMember(cross(cur), who, roles)
343	case op == "RemoveMember":
344		feedback.RemoveMember(cross(cur), who)
345	default:
346		feedback.UpdateMemberRoles(cross(cur), who, roles)
347	}
348}
349
350func memberPlan(app, op string, who address, roles string) plan {
351	if op == "RemoveMember" {
352		canonical(who) // a stale membership of a protected realm can be removed
353	} else {
354		arg(who)
355	}
356	var revision uint64
357	var current string
358	switch app {
359	case appChannels:
360		revision, current = channels.GetMembershipRevision(), channels.GetMemberRoles(who)
361	case appFeedback:
362		revision, current = feedback.GetMembershipRevision(), feedback.GetMemberRoles(who)
363	default:
364		panic("memba_bridge: " + app + " has no members")
365	}
366	if (op == "AddMember") != (current == "") || (op != "RemoveMember" && !validRoles(roles)) {
367		panic("memba_bridge: add a non-member, change a member, roles an ordered subset of admin,dev,ops,member")
368	}
369	return newPlan(app, op, daoauth.New().Addr(who).Str(roles).Uint(revision).Str(current), app, gov.Critical)
370}
371
372// validRoles accepts a non-empty, ordered, duplicate-free subset of
373// admin,dev,ops,member.
374func validRoles(r string) bool {
375	names := []string{"admin", "dev", "ops", "member"}
376	next := 0
377	for _, x := range strings.Split(r, ",") {
378		for next < len(names) && names[next] != x {
379			next++
380		}
381		if next == len(names) {
382			return false
383		}
384		next++
385	}
386	return true
387}
388
389// CreateChannel adds a channel. Channels are permanent (at most 20).
390func CreateChannel(cur realm, pid uint64, app, name, description, ctype string) {
391	approve(cur, pid, channelPlan(app, name, description, ctype))
392	if app == appChannels {
393		channels.CreateChannel(cross(cur), name, description, ctype)
394	} else {
395		feedback.CreateChannel(cross(cur), name, description, ctype)
396	}
397}
398
399func channelPlan(app, name, description, ctype string) plan {
400	if ctype != "text" && ctype != "announcements" && ctype != "readonly" || description == "" || len(description) > 200 || !daoauth.ValidText(description) {
401		panic("memba_bridge: type text, announcements or readonly; description 1-200 printable ASCII characters")
402	}
403	var count int
404	var exists bool
405	switch app {
406	case appChannels:
407		count, exists, _, _, _, _ = channels.GetChannelState(name)
408	case appFeedback:
409		count, exists, _, _, _, _ = feedback.GetChannelState(name)
410	default:
411		panic("memba_bridge: " + app + " has no channels")
412	}
413	if exists {
414		panic("memba_bridge: channel exists")
415	}
416	if !channelName(name) || count >= 20 {
417		panic("memba_bridge: a channel name is 1-50 of a-z, 0-9 or -, and an app holds at most 20 channels")
418	}
419	return newPlan(app, "CreateChannel", daoauth.New().Str(name).Str(description).Str(ctype).Int(int64(count)), app, gov.Critical)
420}
421
422// channelName is the apps' own channel-name rule.
423func channelName(name string) bool {
424	if name == "" || len(name) > 50 {
425		return false
426	}
427	return strings.Trim(name, "abcdefghijklmnopqrstuvwxyz0123456789-") == ""
428}