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

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}