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}