public.gno
10.77 Kb · 335 lines
1package commondao
2
3import (
4 // unsafe is used only for the CurrentRealm call in assertRunningPath when
5 // checking the deployment path. Caller authentication uses cur.Previous().
6 "chain/runtime/unsafe"
7 "strings"
8
9 "gno.land/p/nt/commondao/v0"
10)
11
12// assertCurrent guards every public crossing entry. Authentication reads
13// cur.Previous() and Execute mints cur.Sub(...) for the executor; both
14// require cur to be the live top-of-frame realm (AGENTS.md / interrealm-v2).
15// Every entry is only reachable via a cross today, so this is
16// defense-in-depth against a future non-crossing caller.
17func assertCurrent(cur realm) {
18 if !cur.IsCurrent() {
19 panic("commondao: cur realm is not current")
20 }
21}
22
23// assertRunningPath fails closed if this realm is deployed at a package
24// path other than pkgPath. Treasury addresses derive from the pkgPath
25// const (daoAddress), while Execute mints the DAO sub under the running
26// realm's own path (cur.Sub). Those agree only when the running path is
27// pkgPath; a copy deployed elsewhere would read balances at one address
28// and send from another, turning a clean StatusFailed into a mismatch. We
29// reject that at genesis rather than silently diverge.
30func assertRunningPath() {
31 if got := unsafe.CurrentRealm().PkgPath(); got != pkgPath {
32 panic("commondao: realm deployed at " + got + ", expected " + pkgPath)
33 }
34}
35
36// assertCallerIsUser rejects a code realm creating a DAO on someone's
37// behalf. New keys the invite and the creators set on the caller, and
38// invites are granted to people, so the caller must be the user itself:
39// either a direct MsgCall (IsUserCall) or that user's own MsgRun frame
40// (IsUserRun, whose address is the signer's own). Both are IsUser; a
41// published realm is neither.
42//
43// Relaying is what this excludes. If a realm could redeem an invite, one
44// invited realm would become a DAO factory for un-invited callers — the
45// vector pr6012_commondao_ownership_rescope.md closed by keying on the
46// transaction origin. Keying on the caller closes it too, but only while
47// the caller cannot be a realm.
48//
49// It bounds who may *create* a DAO, not who may sit on one: the members
50// argument may still name realm addresses, and sub-DAO councils are set
51// by proposal (see CreateSubDAOProposal). Handing a DAO to a realm is a
52// council decision, not a creation-time one.
53func assertCallerIsUser(cur realm) {
54 if !cur.Previous().IsUser() {
55 panic("only a user can create a DAO")
56 }
57}
58
59// Invite invites a user to the realm.
60// A user invitation is required to start creating new DAOs.
61func Invite(cur realm, invitee address) {
62 assertCurrent(cur)
63
64 if !invitee.IsValid() {
65 panic("invalid address")
66 }
67
68 dao := mustGetDAO(CommonDAOID)
69 caller := cur.Previous().Address()
70 if !dao.Council().Has(caller) {
71 panic("unauthorized")
72 }
73
74 invites.Set(invitee.String(), caller.String())
75}
76
77// IsInvited checks if an address has an invitation to the realm.
78func IsInvited(addr address) bool {
79 return isInvited(addr)
80}
81
82// New creates a new CommonDAO and returns its ID.
83// The caller must be a user, not a realm (see assertCallerIsUser), and must
84// hold an invite. The invite is consumed when that caller creates a DAO for
85// the first time, after which the caller may create further DAOs freely.
86// The caller becomes a council member along with any additional member
87// addresses listed on separate lines; those may be realm addresses, so a
88// DAO can be handed to a realm by seating it here and resigning. DAOs with
89// a parent are created through proposals (see CreateSubDAOProposal).
90func New(cur realm, name, purpose, description, members string) uint64 {
91 assertCurrent(cur)
92 assertCallerIsUser(cur)
93 caller := cur.Previous().Address()
94
95 name = strings.TrimSpace(name)
96 assertDAONameIsValid(name)
97
98 purpose = strings.TrimSpace(purpose)
99 assertDAOPurposeIsValid(purpose)
100
101 description = strings.TrimSpace(description)
102 assertDAODescriptionIsValid(description)
103
104 // The invite is consumed once. The direct user caller is then recorded and
105 // can create further DAOs without another invite.
106 if !isCreator(caller) {
107 assertIsInvited(caller)
108 invites.Remove(caller.String())
109 creators.Set(caller.String(), struct{}{})
110 }
111
112 dao := createDAO(name, purpose, description, parseInitialMembers(caller, members)...)
113 return dao.ID()
114}
115
116// parseInitialMembers returns the caller plus the parsed additional
117// members, deduplicated.
118func parseInitialMembers(caller address, members string) []address {
119 addrs := parseAddresses(members)
120 if containsAddress(addrs, caller) {
121 return addrs
122 }
123 return append(addrs, caller)
124}
125
126// SetListed adds or removes a DAO from the realm's public home index.
127// Listing is cosmetic — it affects only how this realm presents the DAO
128// in its own UI — so any single council member of the DAO may toggle it,
129// like Resign. It defaults to off.
130func SetListed(cur realm, daoID uint64, listed bool) {
131 assertCurrent(cur)
132
133 dao := mustGetDAO(daoID)
134 if dao.IsDeleted() {
135 panic(commondao.ErrDAOIsDeleted)
136 }
137
138 assertCallerIsCouncilMember(cur.Previous().Address(), dao)
139 setListed(daoID, listed)
140}
141
142// IsListed reports whether a DAO appears in the realm's public home index.
143func IsListed(daoID uint64) bool {
144 return isListed(daoID)
145}
146
147// GetView returns a read only view of a common DAO searched by ID.
148func GetView(daoID uint64) commondao.ReadonlyCommonDAO {
149 return mustGetDAO(daoID).Readonly()
150}
151
152// GetBylawsDoc returns the text of a DAO's bylaws/mandates document, or
153// an empty string when the document does not exist (a stored document is
154// never empty).
155func GetBylawsDoc(daoID uint64, path string) string {
156 mustGetDAO(daoID)
157
158 if set := bylawsView(daoID); set != nil {
159 text, _ := set.Get(path)
160 return text
161 }
162 return ""
163}
164
165// ListBylawsDocs returns the sorted paths of a DAO's bylaws/mandates
166// documents under a prefix (empty prefix lists all).
167func ListBylawsDocs(daoID uint64, prefix string) []string {
168 mustGetDAO(daoID)
169
170 if set := bylawsView(daoID); set != nil {
171 return set.List(prefix)
172 }
173 return nil
174}
175
176// Vote submits a vote for a DAO proposal.
177// Voting is allowed to the members of the proposal's electorate: the
178// council snapshot taken when the proposal was created.
179func Vote(cur realm, daoID, proposalID uint64, vote commondao.VoteChoice, reason string) {
180 assertCurrent(cur)
181
182 dao := mustGetDAO(daoID)
183 caller := cur.Previous().Address()
184 err := dao.Vote(caller, proposalID, vote, reason)
185 if err != nil {
186 panic(err)
187 }
188}
189
190// Funded is the optional contract a proposal definition implements when its
191// executor moves funds from a DAO other than the proposal's host: it names,
192// by ID, the DAO whose sub-identity address funds the executor. Execute
193// resolves that DAO, mints its terminal RealmSend-only sub and passes it to
194// the ExecFunc; a definition that does not implement Funded receives the
195// host DAO's own sub by default. The returned ID must identify the DAO the
196// definition validates its fund movement against (e.g. the DAO being spent,
197// swept or dissolved).
198//
199// It lives realm-side, not in /p/: minting a DAO sub needs the host realm's
200// cur (cur.Sub), so only the host — never the package — can honor it. /p/
201// dispatches CapExempt/Executable/Validable itself, but Execute (the host)
202// is the sole consumer of Funded, so the package has no reason to know it.
203type Funded interface {
204 // FundingDAOID returns the ID of the DAO whose sub-address funds the
205 // executor.
206 FundingDAOID() uint64
207}
208
209// Execute executes a DAO proposal.
210//
211// Executing a proposal that passed early (decided by the default Council
212// rules before its voting deadline) requires the caller to be a council
213// member. Once the voting deadline has passed execution is permissionless:
214// the tally is deterministic, so anyone can finalize the proposal.
215func Execute(cur realm, daoID, proposalID uint64) {
216 assertCurrent(cur)
217
218 // Re-entrancy latch: a proposal executor must not trigger another
219 // Execute. Without this, an executor could finalize a second proposal
220 // mid-run — e.g. dissolve its own DAO and leave this frame finalizing on
221 // a deleted DAO. The latch is global (one executor per tx); it does not
222 // block Vote/Create*, so an executor may still act as its DAO elsewhere.
223 enterExecute()
224 defer leaveExecute()
225
226 dao := mustGetDAO(daoID)
227 p := dao.GetProposal(proposalID)
228 if p == nil {
229 panic(commondao.ErrProposalNotFound)
230 }
231
232 // Before the deadline, only a council member may execute an
233 // early-passed proposal. Once the deadline passes the tally is
234 // deterministic, so finalization is permissionless.
235 if !p.HasVotingDeadlinePassed() {
236 assertCallerIsCouncilMember(cur.Previous().Address(), dao)
237 }
238
239 // Mint the sub-identity that funds the executor and pass it in. The
240 // operative DAO is the host by default; a fund-moving definition
241 // (Funded) may name a different DAO by ID (e.g. clawback sweeps the
242 // target, sub-DAO dissolution sweeps the dissolved descendant). Non-fund
243 // executors ignore the sub. The sub is terminal and RealmSend-only, so
244 // the executor can move value only from this one DAO address.
245 op := daoID
246 if f, ok := p.Definition().(Funded); ok {
247 op = f.FundingDAOID()
248 }
249 sub := cur.Sub(subpathOf(op))
250
251 err := dao.Execute(proposalID, sub)
252 if err != nil {
253 panic(err)
254 }
255}
256
257// Withdraw withdraws an active DAO proposal that has no votes.
258// Only the proposal creator can withdraw it.
259func Withdraw(cur realm, daoID, proposalID uint64) {
260 assertCurrent(cur)
261
262 dao := mustGetDAO(daoID)
263 p := dao.GetProposal(proposalID)
264 if p == nil {
265 panic(commondao.ErrProposalNotFound)
266 }
267
268 if p.Creator() != cur.Previous().Address() {
269 panic("only the proposal creator can withdraw it")
270 }
271
272 if err := dao.Withdraw(proposalID); err != nil {
273 panic(err)
274 }
275}
276
277// Resign removes the caller from a DAO council.
278// The last remaining council member cannot resign.
279func Resign(cur realm, daoID uint64) {
280 assertCurrent(cur)
281
282 dao := mustGetDAO(daoID)
283 if dao.IsDeleted() {
284 panic(commondao.ErrDAOIsDeleted)
285 }
286
287 caller := cur.Previous().Address()
288 assertCallerIsCouncilMember(caller, dao)
289
290 if err := dao.UpdateCouncil(nil, []address{caller}); err != nil {
291 panic(err)
292 }
293}
294
295func isInvited(addr address) bool {
296 return invites.Has(addr.String())
297}
298
299func assertIsInvited(addr address) {
300 if !isInvited(addr) {
301 panic("unauthorized")
302 }
303}
304
305func assertDAONameIsValid(name string) {
306 if name == "" {
307 panic("DAO name is empty")
308 }
309
310 if len(name) > 60 {
311 panic("DAO name is too long, max length is 60 characters")
312 }
313}
314
315func assertDAOPurposeIsValid(purpose string) {
316 if purpose == "" {
317 panic("DAO purpose is empty")
318 }
319
320 if len(purpose) > 250 {
321 panic("DAO purpose is too long, max length is 250 characters")
322 }
323}
324
325func assertDAODescriptionIsValid(description string) {
326 if len(description) > 250 {
327 panic("DAO description is too long, max length is 250 characters")
328 }
329}
330
331func assertCallerIsCouncilMember(caller address, dao *commondao.CommonDAO) {
332 if !dao.Council().Has(caller) {
333 panic("caller is not a council member")
334 }
335}