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

prop_requests.gno

11.85 Kb · 327 lines
  1package impl
  2
  3import (
  4	"chain/runtime/unsafe"
  5	"strings"
  6
  7	"gno.land/p/aeddi/panictoerr/v0"
  8	"gno.land/p/moul/md/v0"
  9	"gno.land/p/nt/markdown/sanitize/v0"
 10	trs_pkg "gno.land/p/nt/treasury/v0"
 11	"gno.land/p/nt/ufmt/v0"
 12
 13	"gno.land/r/gov/dao"
 14	"gno.land/r/gov/dao/memberstore/v0"
 15	"gno.land/r/gov/dao/treasury/v0"
 16)
 17
 18func NewChangeLawRequest(cur realm, newLaw Law) dao.ProposalRequest {
 19	member, _ := memberstore.Get(0, cur).GetMember(unsafe.OriginCaller())
 20	if member == nil {
 21		panic("proposer is not a member")
 22	}
 23
 24	cb := func(cur realm) error {
 25		law = &newLaw
 26		return nil
 27	}
 28
 29	e := dao.NewSimpleExecutor(0, cur, cb, ufmt.Sprintf("A new Law is proposed:\n %v", newLaw))
 30
 31	return dao.NewProposalRequest("Change Law Proposal", "This proposal is looking to change the actual govDAO Law", e)
 32}
 33
 34func NewUpgradeDaoImplRequest(cur realm, newDao dao.DAO, realmPkg, reason string) dao.ProposalRequest {
 35	// Rejected here as well as in UpdateImpl so the mistake surfaces when the
 36	// proposal is written, not when it executes. An empty realmPkg would be
 37	// stored as an allowlist entry matching every user realm's empty
 38	// PkgPath(), and would render as a blank name in the grant sentence below.
 39	if strings.TrimSpace(realmPkg) == "" {
 40		panic("realmPkg must be the realm path being granted govDAO authority")
 41	}
 42	// Padding is rejected for the same reason UpdateImpl rejects it: the entry
 43	// is stored as given and matched whole, so a padded path grants nobody
 44	// anything. Caught here so the author sees it, not the voters.
 45	if realmPkg != strings.TrimSpace(realmPkg) {
 46		panic("realmPkg must not have leading or trailing spaces")
 47	}
 48
 49	member, _ := memberstore.Get(0, cur).GetMember(unsafe.OriginCaller())
 50	if member == nil {
 51		panic("proposer is not a member")
 52	}
 53
 54	cb := func(cur realm) error {
 55		// dao.UpdateImpl() must be cross-called from v0/impl but
 56		// what calls this cb function is r/gov/dao.
 57		// therefore we must cross back into v0/impl and then
 58		// cross call dao.UpdateRequest().
 59		dao.UpdateImpl(cross(cur), dao.NewUpdateRequest(newDao, []string{"gno.land/r/gov/dao/impl/v0", realmPkg}))
 60		return nil
 61	}
 62
 63	// State the grant: this executor rewrites AllowedDAOs, and previously
 64	// rendered nothing at all. realmPkg is caller-supplied and lands in a code
 65	// span, so it is wrapped with sanitize.InlineCode — which emits its own
 66	// fence, so do not add backticks around it. (md.EscapeText is wrong here:
 67	// CommonMark 6.1 does not process backslash escapes inside code spans.)
 68	// Nothing ties realmPkg to newDao, which is why the text says so.
 69	e := dao.NewSimpleExecutor(0, cur, cb, ufmt.Sprintf(
 70		"Replaces the govDAO implementation with the DAO carried by this proposal.\n\n"+
 71			"After execution, only `gno.land/r/gov/dao/impl/v0` and %s may replace the "+
 72			"implementation, mutate the member store, or move treasury funds.\n\n"+
 73			"This grant is stated by the proposer: the incoming implementation is passed "+
 74			"as a value and is not verified to belong to the realm named above.",
 75		sanitize.InlineCode(realmPkg)))
 76
 77	return dao.NewProposalRequest("Change DAO implementation", "This proposal is looking to change the actual govDAO implementation. Reason: "+reason, e)
 78}
 79
 80func NewAddMemberRequest(cur realm, addr address, tier string, portfolio string) dao.ProposalRequest {
 81	// Reject a non-bech32 address at proposal-build time (see AddMember): keeps
 82	// an unauthenticable / injection-bearing key out of the member store.
 83	if !addr.IsValid() {
 84		panic("invalid member address: " + addr.String())
 85	}
 86	_, ok := memberstore.GetTier(tier)
 87	if !ok {
 88		panic("provided tier does not exists")
 89	}
 90
 91	if tier != memberstore.T1 && tier != memberstore.T2 {
 92		panic("Only T1 and T2 members can be added by proposal. To add a T3 member use AddMember function directly.")
 93	}
 94
 95	if portfolio == "" {
 96		panic("A portfolio for the proposed member is required")
 97	}
 98
 99	member, _ := memberstore.Get(0, cur).GetMember(unsafe.OriginCaller())
100	if member == nil {
101		panic("proposer is not a member")
102	}
103
104	if member.InvitationPoints <= 0 {
105		panic("proposer does not have enough invitation points for inviting new people to the board")
106	}
107
108	cb := func(cur realm) error {
109		// Add the member first, spend the proposer's invitation point second.
110		//
111		// SetMember RETURNS an error when the address is already a member --
112		// easy to reach, since AddMember enrols T3 members directly. A returned
113		// executor error does not revert: ExecuteOrRejectProposal marks the
114		// proposal rejected and the transaction still commits. So with the old
115		// order the point was spent and the member was not added.
116		//
117		// RemoveInvitationPoint can still fail, despite the build-time check
118		// above: it panics at zero, and AddMember spends points from this same
119		// captured member, so a proposer can drain their own between creation
120		// and execution. That panics and reverts, which loses nothing.
121		if err := memberstore.Get(0, cur).SetMember(tier, addr, memberByTier(tier)); err != nil {
122			return err
123		}
124		member.RemoveInvitationPoint()
125
126		return nil
127	}
128
129	e := dao.NewSimpleExecutor(0, cur, cb, ufmt.Sprintf("A new member with address %v is proposed to be on tier %v. Provided Portfolio information:\n\n%v", addr, tier, portfolio))
130
131	name := tryResolveAddr(addr)
132	return dao.NewProposalRequestWithFilter(
133		ufmt.Sprintf("New %s Member Proposal", tier),
134		ufmt.Sprintf("This is a proposal to add `%s` to **%s**.\n#### `%s`'s Portfolio:\n\n%s\n", name, tier, name, portfolio),
135		e,
136		FilterByTier{Tier: tier},
137	)
138}
139
140func NewWithdrawMemberRequest(cur realm, addr address, reason string) dao.ProposalRequest {
141	member, tier := memberstore.Get(0, cur).GetMember(addr)
142	if member == nil {
143		panic("user we want to remove not found")
144	}
145
146	reason = strings.TrimSpace(reason)
147	if tier == memberstore.T1 && reason == "" {
148		panic("T1 user removals must contains a reason.")
149	}
150
151	cb := func(cur realm) error {
152		memberstore.Get(0, cur).RemoveMember(addr)
153		return nil
154	}
155
156	e := dao.NewSimpleExecutor(0, cur, cb, ufmt.Sprintf("Member with address %v will be withdrawn.\n\n REASON: %v.", addr, reason))
157
158	return dao.NewProposalRequest(
159		"Member Withdrawal Proposal",
160		ufmt.Sprintf("This is a proposal to remove %s from the GovDAO", tryResolveAddr(addr)),
161		e,
162	)
163}
164
165func NewPromoteMemberRequest(cur realm, addr address, fromTier string, toTier string) dao.ProposalRequest {
166	cb := func(cur realm) error {
167		// Fail on an unknown destination tier before touching the member.
168		//
169		// Everything that can be checked before a write is checked here, and
170		// RETURNS. A returned executor error rejects the proposal and closes it,
171		// and since nothing has been mutated yet that commits nothing.
172		//
173		// After RemoveMember the policy inverts: a return would commit a member
174		// who was removed and not re-added, so everything below panics instead,
175		// which reverts the whole transaction.
176		// A tier dropped from the global table. NewChangeTiersRequest replaces
177		// that table wholesale, so a proposal listing only T1 and T2 removes T3
178		// for good. memberByTier would not notice: it switches on the constant
179		// and ignores GetTier's ok, so the promotion would succeed and hand the
180		// member zero invitation points. Untested -- reaching it needs a passed
181		// tier-change proposal, which rewrites state every other test shares.
182		if _, ok := memberstore.GetTier(toTier); !ok {
183			return ufmt.Errorf("unknown destination tier: %s", toTier)
184		}
185
186		mbt := memberstore.Get(0, cur)
187
188		// SetMember consults the store's own index, not the global tier table
189		// above: DeleteAll empties the buckets and leaves the definitions.
190		if !mbt.Has(toTier) {
191			return ufmt.Errorf("destination tier is missing from the member store: %s", toTier)
192		}
193
194		prevTier := mbt.RemoveMember(addr)
195		if prevTier == "" {
196			panic("member not found, so cannot be promoted")
197		}
198
199		if prevTier != fromTier {
200			panic("previous tier changed from the one indicated in the proposal")
201		}
202
203		if err := mbt.SetMember(toTier, addr, memberByTier(toTier)); err != nil {
204			// Unreachable: both of SetMember's failures are ruled out above.
205			// Panicking is what makes it safe to be wrong about that.
206			panic("promotion failed after removal: " + err.Error())
207		}
208
209		return nil
210	}
211
212	e := dao.NewSimpleExecutor(0, cur, cb, ufmt.Sprintf("A new member with address %v will be promoted from tier %v to tier %v.", addr, fromTier, toTier))
213
214	return dao.NewProposalRequestWithFilter(
215		"Member Promotion Proposal",
216		ufmt.Sprintf("This is a proposal to promote %s from **%s** to **%s**.", tryResolveAddr(addr), fromTier, toTier),
217		e,
218		FilterByTier{Tier: toTier},
219	)
220}
221
222func NewTreasuryPaymentRequest(cur realm, payment trs_pkg.Payment, reason string) dao.ProposalRequest {
223	// A foreign impl renders a Payment line no Banker will process, so it would
224	// fail only at execution, after the vote. Rejected here so the proposer
225	// sees it, not the voters. See IsCanonicalPayment.
226	if !trs_pkg.IsCanonicalPayment(payment) {
227		panic("payment must be built by treasury.NewCoinsPayment or treasury.NewGRC20Payment")
228	}
229	if !treasury.HasBanker(payment.BankerID()) {
230		panic("banker not registered in treasury with ID: " + payment.BankerID())
231	}
232
233	reason = strings.TrimSpace(reason)
234	if reason == "" {
235		panic("treasury payment request requires a reason")
236	}
237
238	cb := func(cur realm) error {
239		return panictoerr.PanicToError(func() {
240			treasury.Send(cross(cur), payment)
241		})
242	}
243
244	// Both are caller-supplied and land in the same body as the Payment line a
245	// member votes on, which render.gno emits raw. InlineText folds newlines to
246	// spaces, so neither can start a line of its own and forge that label.
247	// Clamp before escaping, per clamp.gno.
248	safeReason := sanitize.InlineText(clampField(reason, maxRenderedReason))
249	safePayment := sanitize.InlineText(clampField(payment.String(), maxRenderedPayment))
250
251	e := dao.NewSimpleExecutor(0, cur,
252		cb,
253		ufmt.Sprintf(
254			"A payment will be sent by the GovDAO treasury.\n\nReason: %s\n\nPayment: %s.",
255			safeReason,
256			safePayment,
257		),
258	)
259
260	return dao.NewProposalRequest(
261		"Treasury Payment",
262		ufmt.Sprintf(
263			"This proposal is looking to send a payment using the treasury.\n\nReason: %s\n\nPayment: %s",
264			safeReason,
265			safePayment,
266		),
267		e,
268	)
269}
270
271// NewTreasuryGRC20TokensUpdate creates a proposal request to update the list of GRC20 tokens registry
272// keys used by the treasury. The new list, if voted and accepted, will overwrite the current one.
273func NewTreasuryGRC20TokensUpdate(cur realm, newTokenKeys []string) dao.ProposalRequest {
274	if len(newTokenKeys) == 0 {
275		panic("the list of new tokens is empty")
276	}
277
278	// Copied for the same reason NewCoinsPayment copies its coins: the bullet
279	// list below is rendered once at creation, but the executor closure
280	// re-reads the slice at execution, so the caller could otherwise install a
281	// token set the board never saw.
282	keys := make([]string, len(newTokenKeys))
283	copy(keys, newTokenKeys)
284
285	cb := func(cur realm) error {
286		return panictoerr.PanicToError(func() {
287			// NOTE:: Consider checking if the keys are already registered
288			// in the grc20reg before updating the treasury tokens keys.
289			treasury.SetTokenKeys(cross(cur), keys)
290		})
291	}
292
293	bulletList := md.BulletList(keys)
294
295	e := dao.NewSimpleExecutor(0, cur,
296		cb,
297		ufmt.Sprintf(
298			"The list of GRC20 tokens used by the treasury will be updated.\n\nNew Token Keys:\n%s.\n",
299			bulletList,
300		),
301	)
302
303	return dao.NewProposalRequest(
304		"Treasury GRC20 Tokens Update",
305		ufmt.Sprintf(
306			"This proposal is looking to update the list of GRC20 tokens used by the treasury.\n\nNew Token Keys:\n%s",
307			bulletList,
308		),
309		e,
310	)
311}
312
313func memberByTier(tier string) *memberstore.Member {
314	switch tier {
315	case memberstore.T1:
316		t, _ := memberstore.GetTier(memberstore.T1)
317		return memberstore.NewMember(t.InvitationPoints)
318	case memberstore.T2:
319		t, _ := memberstore.GetTier(memberstore.T2)
320		return memberstore.NewMember(t.InvitationPoints)
321	case memberstore.T3:
322		t, _ := memberstore.GetTier(memberstore.T3)
323		return memberstore.NewMember(t.InvitationPoints)
324	default:
325		panic("member not found by the specified tier")
326	}
327}