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

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}