basedao.gno
14.73 Kb · 392 lines
1package basedao
2
3import (
4 "chain"
5 "chain/runtime"
6 "chain/runtime/unsafe"
7 "errors"
8
9 "gno.land/p/nt/mux/v0"
10 "gno.land/p/samcrew/daocond"
11 "gno.land/p/samcrew/daokit"
12)
13
14type daoPublic struct {
15 impl *DAOPrivate
16}
17
18type SetImplemRaw func(newDAO daokit.DAO)
19
20type RenderFn func(path string, dao *DAOPrivate) string
21
22func (d *daoPublic) Extension(path string, rlm realm) daokit.Extension {
23 ext, ok := d.impl.Core.Extensions.Get(path)
24 if !ok {
25 return nil
26 }
27 // Only private extensions are gated; a public one is readable by anyone and
28 // ignores the realm entirely.
29 //
30 // This used to ask unsafe.CurrentRealm(), which is a stack walk and answers
31 // a different question: "is a DAO frame the innermost live one", not "is my
32 // caller the DAO". It was wrong in both directions. Too lax, because
33 // anything the DAO invoked WITHOUT crossing inherited the DAO's realm —
34 // including a proposer-authored ExecuteLambda callback, which could read a
35 // private extension. And too strict, because a DAO-realm helper that is not
36 // itself a crossing function had no frame of its own for the walk to find.
37 //
38 // Requiring the DAO's own live realm answers the intended question directly.
39 // It also closes the lambda case by construction rather than by check: a
40 // callback that receives no realm cannot produce one, and realm values
41 // cannot be persisted or forged.
42 if ext.Info().Private {
43 assertRealmIsOwn(d.impl, rlm)
44 }
45 return ext
46}
47
48// Not gated, deliberately. It exposes ExtensionInfo — paths and versions — and
49// not extension values, so it grants no capability; and realm state is
50// world-readable over ABCI anyway, so gating it would suggest a confidentiality
51// this cannot provide. Getting the VALUE of a private extension goes through
52// Extension, which is gated.
53func (d *daoPublic) ExtensionsList() daokit.ExtensionsList {
54 return d.impl.Core.Extensions.List()
55}
56
57func (d *daoPublic) Propose(req daokit.ProposalRequest, rlm realm) uint64 {
58 return d.impl.Propose(req, rlm)
59}
60
61func (d *daoPublic) Execute(id uint64, rlm realm) {
62 d.impl.Execute(id, rlm)
63}
64
65func (d *daoPublic) Vote(id uint64, vote daocond.Vote, rlm realm) {
66 d.impl.Vote(id, vote, rlm)
67}
68
69func (d *daoPublic) Render(path string) string {
70 return d.impl.Render(path)
71}
72
73// DAO is meant for internal realm usage and should not be exposed.
74type DAOPrivate struct {
75 Core *daokit.Core
76 Members *MembersStore
77 RenderRouter *mux.Router
78 GetProfileString ProfileStringGetter
79 Realm runtime.Realm
80 RenderFn func(path string, dao *DAOPrivate) string
81 CallerID CallerIDFn
82
83 InitialConfig *Config // mostly there in case we need this data during upgrade
84}
85
86// CallerIDFn resolves the identity of the DAO's immediate caller.
87//
88// It receives the DAO realm's own threaded realm, so the implementation can use
89// rlm.Previous(), which is statically scoped to that crossing function.
90//
91// On every path the entry gate admits, a stack walk would name the same realm:
92// the gate has already established that the DAO's own frame is the live one, so
93// unsafe.PreviousRealm() and rlm.Previous() agree. The walk goes wrong only in
94// the shape the gate now rejects — a caller invoking these methods through the
95// DAO handle, with no DAO realm frame on the stack at all, where the walk names
96// the signing account instead. Deriving from the threaded realm is preferred
97// because it does not depend on the gate having run first.
98//
99// The realm is deliberately the SECOND parameter: a function whose first
100// parameter is a realm is read as crossing, and /p/ packages may not declare
101// crossing functions.
102type CallerIDFn func(dao *DAOPrivate, rlm realm) string
103
104type Config struct {
105 // Basic DAO information
106 Name string
107 Description string
108 ImageURI string
109
110 // Storage handler
111 Members *MembersStore
112
113 // Feature toggles
114 NoDefaultHandlers bool // Skips registration of default management actions (add/remove members, etc.)
115 NoDefaultRendering bool // Skips setup of default web UI rendering routes
116 NoCreationEvent bool // Skips emitting the DAO creation event
117
118 // Governance configuration
119 InitialCondition daocond.Condition // Default condition for all built-in actions, defaults to 60% member majority
120
121 // Profile integration (optional)
122 SetProfileString ProfileStringSetter // Function to update profile fields (DisplayName, Bio, Avatar)
123 GetProfileString ProfileStringGetter // Function to retrieve profile fields for members
124
125 // Advanced customization hooks
126 SetImplemFn SetImplemRaw // Function called when DAO implementation changes via governance
127 MigrationParamsFn MigrationParamsFn // Function providing parameters for DAO upgrades
128 // Condition for replacing the DAO's implementation. A passing migration
129 // hands proposer-authored code the DAO's live realm and its full private
130 // state, so it is a takeover in the general case — it should not be as easy
131 // as adding a member.
132 //
133 // If you leave InitialCondition to its default, this defaults to an 80%
134 // member majority — the same dimension, raised.
135 //
136 // If you SET InitialCondition, this defaults to it unchanged, and you should
137 // set this too. A Condition is opaque, so nothing here can strengthen yours:
138 // conjoining a members threshold onto role-based governance can demand a
139 // majority such a DAO never assembles, and migration is the one rule that
140 // cannot be repaired afterwards — changing it requires a migration. Whatever
141 // you choose, make it at least as hard as InitialCondition along every
142 // dimension; a condition that merely replaces yours can be WEAKER where it
143 // matters.
144 MigrationCondition daocond.Condition
145 RenderFn RenderFn // Rendering function for Gnoweb
146 CallerID CallerIDFn // Resolves the immediate caller from the DAO's threaded realm; defaults to defaultCallerID
147
148 // Internal configuration
149 PrivateVarName string // Name of the private DAO variable for member querying extensions
150}
151
152type ProfileStringSetter func(cur realm, field string, value string) bool
153type ProfileStringGetter func(addr address, field string, def string) string
154
155const EventBaseDAOCreated = "BaseDAOCreated"
156
157func New(conf *Config, rlm realm) (daokit.DAO, *DAOPrivate) {
158 // XXX: emit events from memberstore
159
160 members := conf.Members
161 if members == nil {
162 members = NewMembersStore(nil, nil)
163 }
164
165 if conf.GetProfileString == nil {
166 panic(errors.New("GetProfileString is required"))
167 }
168
169 if conf.CallerID == nil {
170 conf.CallerID = defaultCallerID
171 }
172
173 core := daokit.NewCore()
174 dao := &DAOPrivate{
175 Core: core,
176 Members: members,
177 GetProfileString: conf.GetProfileString,
178 Realm: unsafe.CurrentRealm(),
179 RenderFn: conf.RenderFn,
180 CallerID: conf.CallerID,
181 InitialConfig: conf,
182 }
183
184 pubdao := &daoPublic{impl: dao}
185
186 dao.Core.Extensions.Set(&membersViewExtension{
187 getStore: func() *MembersStore { return dao.Members },
188 queryPath: dao.Realm.PkgPath() + "." + conf.PrivateVarName + ".Members",
189 })
190
191 dao.initRenderingRouter()
192
193 if !conf.NoDefaultRendering {
194 dao.InitDefaultRendering()
195 }
196
197 if conf.SetProfileString != nil {
198 conf.SetProfileString(cross(rlm), "DisplayName", conf.Name)
199 conf.SetProfileString(cross(rlm), "Bio", conf.Description)
200 conf.SetProfileString(cross(rlm), "Avatar", conf.ImageURI)
201 }
202
203 if !conf.NoDefaultHandlers {
204 // Whether the DAO chose its own governance decides how far the migration
205 // default may go, below. Captured before the default is filled in.
206 governanceIsCallerChosen := conf.InitialCondition != nil
207 if conf.InitialCondition == nil {
208 conf.InitialCondition = daocond.MembersThreshold(0.6, members.IsMember, members.MembersCount)
209 }
210
211 if conf.SetProfileString != nil {
212 dao.Core.Resources.Set(&daokit.Resource{
213 Handler: NewEditProfileHandler(conf.SetProfileString, []string{"DisplayName", "Bio", "Avatar"}),
214 Condition: conf.InitialCondition,
215 DisplayName: "Edit Profile",
216 Description: "This proposal allows you to edit this DAO profile.",
217 })
218 }
219
220 if conf.SetImplemFn != nil {
221 setImplemFn := func(dao daokit.DAO) {
222 conf.SetImplemFn(dao)
223 }
224 setImplemFn(pubdao)
225
226 if conf.MigrationParamsFn == nil {
227 conf.MigrationParamsFn = func() []any { return nil }
228 }
229
230 // Held locally rather than written back into conf: New must not
231 // mutate its caller's Config, and this closure captures THIS DAO's
232 // member store — reusing one Config for a second DAO would otherwise
233 // give it a migration condition evaluating the first one's members.
234 migrationCondition := conf.MigrationCondition
235 if migrationCondition == nil {
236 if governanceIsCallerChosen {
237 // The DAO designed its own governance, and a Condition is
238 // opaque, so there is no way to strengthen it from here. A
239 // members threshold is not a strengthening: conjoined with
240 // role-based governance it can demand a member majority that
241 // role-governed DAOs never assemble, and migration is the one
242 // rule that cannot be repaired afterwards — changing it needs
243 // a migration. Substituting one is worse still: against
244 // And(MembersThreshold(0.4), RoleCount(1,"officer")) a ballot
245 // that fails an ordinary action passed a bare 0.8 migration,
246 // making a takeover CHEAPER than adding a member.
247 //
248 // So: match the DAO's own bar, and say plainly that a
249 // takeover deserves a higher one. Set MigrationCondition.
250 migrationCondition = conf.InitialCondition
251 } else {
252 // Default governance, so a members threshold is the same
253 // dimension the DAO is already judged on and raising it is a
254 // genuine strengthening: 80% against the default 60%.
255 migrationCondition = daocond.MembersThreshold(0.8, members.IsMember, members.MembersCount)
256 }
257 }
258
259 dao.Core.Resources.Set(&daokit.Resource{
260 Handler: NewChangeDAOImplementationHandler(dao, setImplemFn, conf.MigrationParamsFn),
261 Condition: migrationCondition,
262 DisplayName: "Change DAO Implementation",
263 Description: "Replaces the DAO implementation. The migration receives the DAO's live realm and private state.",
264 })
265 }
266
267 defaultResources := []daokit.Resource{
268 {
269 Handler: NewAddMemberHandler(dao),
270 Condition: conf.InitialCondition,
271 DisplayName: "Add Member",
272 Description: "This proposal allows you to add a new member to the DAO.",
273 },
274 {
275 Handler: NewRemoveMemberHandler(dao),
276 Condition: conf.InitialCondition,
277 DisplayName: "Remove Member",
278 Description: "This proposal allows you to remove a member from the DAO.",
279 },
280 {
281 Handler: NewAssignRoleHandler(dao),
282 Condition: conf.InitialCondition,
283 DisplayName: "Assign Role",
284 Description: "This proposal allows you to assign a role to a member.",
285 },
286 {
287 Handler: NewUnassignRoleHandler(dao),
288 Condition: conf.InitialCondition,
289 DisplayName: "Unassign Role",
290 Description: "This proposal allows you to unassign a role from a member.",
291 },
292 }
293 // register management handlers
294 for _, resource := range defaultResources {
295 dao.Core.Resources.Set(&resource)
296 }
297
298 }
299
300 if !conf.NoCreationEvent {
301 chain.Emit(EventBaseDAOCreated)
302 }
303
304 return pubdao, dao
305}
306
307func (d *DAOPrivate) Vote(proposalID uint64, vote daocond.Vote, rlm realm) {
308 if len(vote) > 16 {
309 panic("invalid vote")
310 }
311
312 assertRealmIsOwn(d, rlm)
313 voterID := d.assertCallerIsMember(d.CallerID(d, rlm))
314 d.Core.Vote(voterID, proposalID, vote)
315}
316
317func (d *DAOPrivate) Execute(proposalID uint64, rlm realm) {
318 assertRealmIsOwn(d, rlm)
319 _ = d.assertCallerIsMember(d.CallerID(d, rlm))
320 d.Core.Execute(proposalID, rlm)
321}
322
323func (d *DAOPrivate) Propose(req daokit.ProposalRequest, rlm realm) uint64 {
324 assertRealmIsOwn(d, rlm)
325 proposerID := d.assertCallerIsMember(d.CallerID(d, rlm))
326 return d.Core.Propose(proposerID, req)
327}
328
329// Resolves the DAO's immediate caller from the DAO's own threaded realm.
330// rlm.Previous() is statically scoped to the DAO realm's crossing function, so
331// unlike a stack walk it cannot be shifted by how deep the call chain is.
332//
333// At the root of a call chain — a package init, or an entry point an account
334// reached directly — the previous realm is the origin realm: the signing
335// account, with an empty pkgpath. IsUser() holds there, so this resolves to
336// that account's address, which is how an ordinary member is authenticated.
337//
338// It yields "" only where the machine carries no signing account at all. That
339// is not an identity, and assertCallerIsMember refuses it.
340func defaultCallerID(dao *DAOPrivate, rlm realm) string {
341 p := rlm.Previous()
342 if p.IsUser() {
343 return p.Address().String()
344 }
345 return p.PkgPath()
346}
347
348// The realm threaded into an entry point must be the DAO realm's own live one.
349//
350// Execute forwards this realm to the action handlers, which cross out under it,
351// so whoever supplies it decides the identity the DAO acts as. Left unchecked, a
352// third realm holding the DAO value could pass its own realm and have the DAO
353// write to that realm's profile — or, through MigrateFn, reach anywhere the
354// caller controls. Realm values are unforgeable and cannot be persisted across
355// transactions, so a caller can only ever pass its own; requiring it to equal
356// the DAO's own realm therefore closes the donation path outright.
357//
358// The pkgpath alone is not enough. Every realm the DAO crosses out into during
359// an action receives the DAO's own realm value as its cur.Previous(), and can
360// hand that value straight back within the same transaction — same pkgpath,
361// but belonging to a frame that is no longer executing. IsCurrent() is what
362// separates the two: it holds only for the realm minted for the crossing
363// function currently on the stack, by frame identity rather than by name. It
364// survives the /p/ hop into this package, and it is true during a realm's own
365// init, so neither the ordinary path nor construction is affected.
366//
367// Given both, rlm.Previous() is by construction the DAO's immediate caller.
368//
369// This takes the DAO as its first parameter rather than being a method on it:
370// the VM reads any function whose FIRST parameter is a realm as crossing, and
371// /p/ packages may not declare crossing functions. Receivers do not count, so a
372// method would hit the same rule.
373func assertRealmIsOwn(d *DAOPrivate, rlm realm) {
374 if rlm.PkgPath() != d.Realm.PkgPath() || !rlm.IsCurrent() {
375 panic("realm mismatch: the DAO may only act as " + d.Realm.PkgPath())
376 }
377}
378
379func (d *DAOPrivate) assertCallerIsMember(id string) string {
380 // An empty id is never a real caller, and the store can hold one: AddMember
381 // does not reject it, and Members is an exported avl.Tree that a same-realm
382 // write or a MigrateFn can seed directly. Reject it here, where every entry
383 // point passes, so one such entry cannot authenticate every unattributable
384 // call.
385 if id == "" {
386 panic(errors.New("caller id is empty"))
387 }
388 if !d.Members.IsMember(id) {
389 panic(errors.New("caller is not a member"))
390 }
391 return id
392}