Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

crew.gno

18.78 Kb · 612 lines
  1// Package crew is small-group coordination as a product rather than as a
  2// framework: three to fifteen people with a shared pot, a way to decide, and a
  3// way to leave with their share.
  4//
  5// # Why not a DAO framework
  6//
  7// A framework asks you to pick a governance module, a voting strategy and a
  8// treasury adapter before anybody has put in a single ugnot. The targets here
  9// are real and already exist: a validator set, a working group, a hackathon
 10// team, a five-person company. They want the thing you can create in one
 11// transaction. [gno.land/p/moul/grants] and daokit are the framework layer, and
 12// this package is deliberately underneath them: one call to open a crew, one to
 13// join it, one to leave with what your shares are worth.
 14//
 15// # The model
 16//
 17//	Crews     every crew, plus the credit ledger every payout goes through
 18//	Crew      a name, a creation height, members with shares, a treasury, proposals
 19//	Proposal  advisory text with a deadline and a share-weighted tally
 20//
 21// Shares are the whole design. They are the vote weight and the claim on the
 22// treasury at the same time, minted by [Crews.Join] and burned by
 23// [Crews.Ragequit], so leaving is priced by the same number that decides. There
 24// is no separate token to issue, distribute or forget to make meaningful.
 25//
 26// # Rounding, which is the part that has to be right
 27//
 28// Every division here rounds DOWN, and both of them therefore round in the
 29// crew's favour:
 30//
 31//   - joining mints amount * totalShares / treasury, so a late joiner buys
 32//     exactly what they paid for at the CURRENT per-share value and never a
 33//     share more. Rounding up would hand them a sliver of value the existing
 34//     members built, which is the whole reason a flat price is wrong here.
 35//   - ragequitting credits shares * treasury / totalShares, so a leaver takes
 36//     no more than their slice and the remainder stays with the people who
 37//     stayed.
 38//
 39// The dust that accrues from both is never stranded: the last member to
 40// ragequit holds every outstanding share, so their MulDiv is exact and the
 41// treasury empties to the last ugnot. [Crews.Ragequit] has a test for that.
 42//
 43// # What v0 deliberately does not do
 44//
 45// A proposal is advisory text. Passing one executes nothing, moves nothing and
 46// binds nothing; it records that the crew agreed by share weight at a point in
 47// time. Executing a payout is the obvious next step and the one that turns this
 48// into a treasury contract rather than a notice board.
 49//
 50// A vote is weighed at the moment it is cast. A member who votes and then
 51// ragequits leaves their weight behind in the tally, because unwinding it would
 52// mean re-weighing every ballot on every share change.
 53//
 54// # Errors, not aborts
 55//
 56// This is a p/, so it returns errors and declares no crossing function: the
 57// caller, the height and the amount all arrive as plain arguments and the realm
 58// that wires it to the chain decides what to abort on.
 59package crew
 60
 61import (
 62	"errors"
 63	"strings"
 64
 65	"gno.land/p/moul/kit/store/v0"
 66	"gno.land/p/moul/xmath/v1"
 67)
 68
 69const (
 70	// MaxMembers is the upper end of "three to fifteen people". It is a
 71	// product constraint and not a technical one: above it the share math
 72	// still works and the thing stops being a crew.
 73	MaxMembers = 15
 74
 75	// InitialPricePerShare is what a share costs in ugnot before the crew
 76	// has a treasury to price against: at creation, and again if every
 77	// member has left. 1000 ugnot makes 1 GNOT worth 1000 shares, which
 78	// keeps the integer arithmetic far away from both zero and overflow.
 79	InitialPricePerShare = int64(1000)
 80
 81	// VoteBlocks is how long a proposal stays open, in blocks.
 82	VoteBlocks = int64(1000)
 83
 84	// MaxNameLen and MaxTextLen bound what one call can make the crew's
 85	// members pay a storage deposit on.
 86	MaxNameLen = 60
 87	MaxTextLen = 500
 88
 89	// ExcerptLen is how much of a proposal a listing shows.
 90	ExcerptLen = 60
 91)
 92
 93// maxInt64 is the largest int64, spelled out because the overflow guards
 94// compare against it directly.
 95const maxInt64 = int64(9223372036854775807)
 96
 97// The errors a caller can get back.
 98var (
 99	ErrBadName     = errors.New("crew: name is empty, too long, or has control characters")
100	ErrBadText     = errors.New("crew: text is empty, too long, or has control characters")
101	ErrBadAmount   = errors.New("crew: amount must be positive")
102	ErrNoCrew      = errors.New("crew: no such crew")
103	ErrNoProposal  = errors.New("crew: no such proposal")
104	ErrNotMember   = errors.New("crew: not a member of this crew")
105	ErrIsMember    = errors.New("crew: already a member of this crew")
106	ErrFull        = errors.New("crew: the crew is full")
107	ErrNoShares    = errors.New("crew: the amount sent buys no whole share")
108	ErrVoteClosed  = errors.New("crew: the proposal is no longer open")
109	ErrVoteOpen    = errors.New("crew: the proposal is still open")
110	ErrNothing     = errors.New("crew: nothing to withdraw")
111	ErrWouldExceed = errors.New("crew: the amount would overflow the ledger")
112)
113
114// Crew is one group: who is in it, what it holds, and what it is deciding.
115type Crew struct {
116	Name      string
117	CreatedAt int64 // block height
118	Treasury  int64 // ugnot, accounted here and held at the realm's address
119
120	// TotalShares is every share outstanding. It is the denominator of both
121	// the join price and the ragequit payout, so it is maintained here
122	// rather than summed over the members on demand.
123	TotalShares int64
124
125	shares    map[string]int64 // address -> shares held
126	order     []address        // members in join order, so output never iterates a map
127	proposals []store.ID
128}
129
130// SharesOf is how many shares who holds, zero when they hold none.
131func (c *Crew) SharesOf(who address) int64 {
132	if c == nil {
133		return 0
134	}
135	return c.shares[who.String()]
136}
137
138// IsMember reports whether who holds any share.
139func (c *Crew) IsMember(who address) bool { return c.SharesOf(who) > 0 }
140
141// MemberCount is how many addresses hold shares.
142func (c *Crew) MemberCount() int {
143	if c == nil {
144		return 0
145	}
146	return len(c.order)
147}
148
149// Members returns the members in join order. The slice is a copy, so a caller
150// rendering it cannot reorder the crew.
151func (c *Crew) Members() []address {
152	if c == nil {
153		return nil
154	}
155	out := make([]address, len(c.order))
156	copy(out, c.order)
157	return out
158}
159
160// Proposals returns this crew's proposal ids, oldest first, as a copy.
161func (c *Crew) Proposals() []store.ID {
162	if c == nil {
163		return nil
164	}
165	out := make([]store.ID, len(c.proposals))
166	copy(out, c.proposals)
167	return out
168}
169
170// ValuePerShare is what one share is worth in ugnot right now, rounded down.
171//
172// It is a display figure and nothing computes against it: [Crews.Join] and
173// [Crews.Ragequit] divide by the real totals, so a crew whose treasury is
174// smaller than its share count still prices both correctly while this reads 0.
175func (c *Crew) ValuePerShare() int64 {
176	if c == nil || c.TotalShares == 0 {
177		return 0
178	}
179	return c.Treasury / c.TotalShares
180}
181
182// PricePerShare is what the next share costs a joiner, rounded down, which is
183// [Crew.ValuePerShare] except on a crew nobody holds a share in.
184func (c *Crew) PricePerShare() int64 {
185	if c == nil || c.TotalShares == 0 || c.Treasury == 0 {
186		return InitialPricePerShare
187	}
188	return c.Treasury / c.TotalShares
189}
190
191// ballot is one member's vote: which way, and what it weighed when cast.
192type ballot struct {
193	yes    bool
194	weight int64
195}
196
197// Proposal is a question put to a crew. v0 proposals are advisory text and
198// execute nothing: see the package doc.
199type Proposal struct {
200	CrewID   store.ID
201	Author   address
202	Text     string
203	OpenedAt int64
204	Deadline int64 // OpenedAt + VoteBlocks, exclusive
205
206	// Yes and No are the running share-weighted tally, maintained on every
207	// vote so reading it never walks the ballots.
208	Yes int64
209	No  int64
210
211	Closed bool
212	Passed bool
213
214	votes map[string]ballot
215}
216
217// Open reports whether the proposal still accepts votes at height now.
218func (p *Proposal) Open(now int64) bool {
219	return p != nil && !p.Closed && now < p.Deadline
220}
221
222// VoteOf reports how who voted and what it weighed, and whether they voted at
223// all.
224func (p *Proposal) VoteOf(who address) (yes bool, weight int64, voted bool) {
225	if p == nil {
226		return false, 0, false
227	}
228	b, ok := p.votes[who.String()]
229	return b.yes, b.weight, ok
230}
231
232// Voters is how many members have cast a ballot.
233func (p *Proposal) Voters() int {
234	if p == nil {
235		return 0
236	}
237	return len(p.votes)
238}
239
240// Crews holds every crew, every proposal, and the credit ledger each payout
241// goes through.
242type Crews struct {
243	crews *store.Store
244	props *store.Store
245
246	credit map[string]int64 // address -> ugnot owed
247	owed   int64            // the sum of the above, which the holder must reserve
248}
249
250// New returns an empty Crews.
251func New() *Crews {
252	return &Crews{
253		crews:  store.Named("crew"),
254		props:  store.Named("proposal"),
255		credit: map[string]int64{},
256	}
257}
258
259// Create opens a crew with founder as its only member, their shares bought out
260// of amount at [InitialPricePerShare].
261//
262// The remainder below one whole share stays in the treasury rather than being
263// refunded, which is the same direction every other division here rounds.
264func (cs *Crews) Create(name string, founder address, amount, at int64) (store.ID, error) {
265	if !ValidName(name) {
266		return 0, ErrBadName
267	}
268	if amount <= 0 {
269		return 0, ErrBadAmount
270	}
271	shares := amount / InitialPricePerShare
272	if shares < 1 {
273		return 0, ErrNoShares
274	}
275	c := &Crew{
276		Name:        name,
277		CreatedAt:   at,
278		Treasury:    amount,
279		TotalShares: shares,
280		shares:      map[string]int64{founder.String(): shares},
281		order:       []address{founder},
282	}
283	return cs.crews.Add(c), nil
284}
285
286// Join mints who shares at the crew's CURRENT per-share value and adds amount
287// to its treasury.
288//
289// amount * totalShares / treasury, rounded down. That is the whole anti-dilution
290// rule: a crew that turned 10 GNOT into 20 sells the next share for what a share
291// is worth now, not for what the founders paid, and the rounding remainder stays
292// with the crew rather than with the joiner.
293func (cs *Crews) Join(id store.ID, who address, amount int64) (int64, error) {
294	c, ok := cs.Get(id)
295	if !ok {
296		return 0, ErrNoCrew
297	}
298	if amount <= 0 {
299		return 0, ErrBadAmount
300	}
301	if c.IsMember(who) {
302		return 0, ErrIsMember
303	}
304	if len(c.order) >= MaxMembers {
305		return 0, ErrFull
306	}
307	if c.Treasury > maxInt64-amount {
308		return 0, ErrWouldExceed
309	}
310
311	var shares int64
312	if c.TotalShares == 0 || c.Treasury == 0 {
313		// Nobody holds a share, so there is no value to price against and
314		// the crew is back at its opening price.
315		shares = amount / InitialPricePerShare
316	} else {
317		shares = xmath.MulDiv(amount, c.TotalShares, c.Treasury)
318	}
319	if shares < 1 {
320		return 0, ErrNoShares
321	}
322	if c.TotalShares > maxInt64-shares {
323		return 0, ErrWouldExceed
324	}
325
326	c.shares[who.String()] = shares
327	c.order = append(c.order, who)
328	c.TotalShares += shares
329	c.Treasury += amount
330	return shares, nil
331}
332
333// Fund adds amount to a crew's treasury and mints nothing.
334//
335// It is what makes the join price mean anything: a crew whose treasury only
336// ever moves with its share count prices every joiner identically forever, and
337// the anti-dilution rule in [Crews.Join] would be decorative. Revenue, a grant
338// and a member topping the pot up all arrive this way, and every existing share
339// is worth more afterwards.
340func (cs *Crews) Fund(id store.ID, amount int64) error {
341	c, ok := cs.Get(id)
342	if !ok {
343		return ErrNoCrew
344	}
345	if amount <= 0 {
346		return ErrBadAmount
347	}
348	if c.Treasury > maxInt64-amount {
349		return ErrWouldExceed
350	}
351	c.Treasury += amount
352	return nil
353}
354
355// Propose opens an advisory question, open for [VoteBlocks] blocks. Any member
356// may propose.
357func (cs *Crews) Propose(id store.ID, who address, text string, at int64) (store.ID, error) {
358	c, ok := cs.Get(id)
359	if !ok {
360		return 0, ErrNoCrew
361	}
362	if !c.IsMember(who) {
363		return 0, ErrNotMember
364	}
365	if !ValidText(text) {
366		return 0, ErrBadText
367	}
368	p := &Proposal{
369		CrewID:   id,
370		Author:   who,
371		Text:     text,
372		OpenedAt: at,
373		Deadline: at + VoteBlocks,
374		votes:    map[string]ballot{},
375	}
376	pid := cs.props.Add(p)
377	c.proposals = append(c.proposals, pid)
378	return pid, nil
379}
380
381// Vote records who's ballot, weighted by the shares they hold right now.
382//
383// One ballot per member, changeable while the proposal is open: a second call
384// replaces the first, tally and weight both, so changing your mind after buying
385// more shares counts the shares you now hold.
386func (cs *Crews) Vote(pid store.ID, who address, yes bool, at int64) error {
387	p, ok := cs.Proposal(pid)
388	if !ok {
389		return ErrNoProposal
390	}
391	if !p.Open(at) {
392		return ErrVoteClosed
393	}
394	c, ok := cs.Get(p.CrewID)
395	if !ok {
396		return ErrNoCrew
397	}
398	weight := c.SharesOf(who)
399	if weight <= 0 {
400		return ErrNotMember
401	}
402
403	key := who.String()
404	if prev, voted := p.votes[key]; voted {
405		if prev.yes {
406			p.Yes -= prev.weight
407		} else {
408			p.No -= prev.weight
409		}
410	}
411	p.votes[key] = ballot{yes: yes, weight: weight}
412	if yes {
413		p.Yes += weight
414	} else {
415		p.No += weight
416	}
417	return nil
418}
419
420// Close records a proposal as passed or failed by share weight, once its
421// deadline has gone by. Anyone may call it: closing is bookkeeping, not
422// authority, and a proposal nobody closes is simply never recorded.
423//
424// A tie fails. There is no quorum in v0: a crew where one member votes and the
425// rest ignore it passes the proposal, which is exactly as advisory as the text
426// it carries.
427func (cs *Crews) Close(pid store.ID, at int64) (bool, error) {
428	p, ok := cs.Proposal(pid)
429	if !ok {
430		return false, ErrNoProposal
431	}
432	if p.Closed {
433		return p.Passed, ErrVoteClosed
434	}
435	if at < p.Deadline {
436		return false, ErrVoteOpen
437	}
438	p.Closed = true
439	p.Passed = p.Yes > p.No
440	return p.Passed, nil
441}
442
443// Ragequit burns every share who holds, credits them their pro-rata slice of
444// the treasury and removes them from the crew.
445//
446// shares * treasury / totalShares, rounded down, so the remainder stays with
447// the members who stayed. This is what makes a share mean something: the exit
448// is priced by the same number that votes, and nobody has to agree to let you
449// out.
450//
451// The payout is credited, never sent. The caller moves the coins after this
452// returns, which is the ordering the pull-payment pattern exists for.
453func (cs *Crews) Ragequit(id store.ID, who address) (shares, amount int64, err error) {
454	c, ok := cs.Get(id)
455	if !ok {
456		return 0, 0, ErrNoCrew
457	}
458	shares = c.SharesOf(who)
459	if shares <= 0 {
460		return 0, 0, ErrNotMember
461	}
462
463	amount = xmath.MulDiv(shares, c.Treasury, c.TotalShares)
464	c.TotalShares -= shares
465	c.Treasury -= amount
466	delete(c.shares, who.String())
467	c.order = dropAddress(c.order, who)
468
469	if amount > 0 {
470		if err := cs.credits(who, amount); err != nil {
471			return 0, 0, err
472		}
473	}
474	return shares, amount, nil
475}
476
477// credits adds to an address's withdrawable balance.
478func (cs *Crews) credits(who address, amount int64) error {
479	key := who.String()
480	if cs.credit[key] > maxInt64-amount || cs.owed > maxInt64-amount {
481		return ErrWouldExceed
482	}
483	cs.credit[key] += amount
484	cs.owed += amount
485	return nil
486}
487
488// Withdraw zeroes who's credit and returns what they were owed.
489//
490// The balance is gone from the ledger before this returns, so the caller can
491// move the coins afterwards and a reentrant call finds nothing: effects, then
492// interactions.
493func (cs *Crews) Withdraw(who address) (int64, error) {
494	key := who.String()
495	amount, ok := cs.credit[key]
496	if !ok || amount == 0 {
497		return 0, ErrNothing
498	}
499	delete(cs.credit, key)
500	cs.owed -= amount
501	return amount, nil
502}
503
504// CreditOf is what who can withdraw right now.
505func (cs *Crews) CreditOf(who address) int64 { return cs.credit[who.String()] }
506
507// TotalOwed is the sum of every outstanding credit, which is what the holding
508// realm must keep in reserve on top of every crew's treasury.
509func (cs *Crews) TotalOwed() int64 { return cs.owed }
510
511// Get returns a crew by id.
512func (cs *Crews) Get(id store.ID) (*Crew, bool) {
513	v, ok := cs.crews.Get(id)
514	if !ok {
515		return nil, false
516	}
517	return v.(*Crew), true
518}
519
520// Proposal returns a proposal by id.
521func (cs *Crews) Proposal(pid store.ID) (*Proposal, bool) {
522	v, ok := cs.props.Get(pid)
523	if !ok {
524		return nil, false
525	}
526	return v.(*Proposal), true
527}
528
529// Len is how many crews exist.
530func (cs *Crews) Len() int { return cs.crews.Len() }
531
532// ProposalCount is how many proposals exist, across every crew.
533func (cs *Crews) ProposalCount() int { return cs.props.Len() }
534
535// Listing is one row of [Crews.List]: the crew and the id a link needs.
536type Listing struct {
537	ID   store.ID
538	Crew *Crew
539}
540
541// List returns up to limit crews, newest first. limit below 1 returns nothing.
542func (cs *Crews) List(limit int) []Listing {
543	var out []Listing
544	for _, e := range cs.crews.PageReverse(1, limit) {
545		out = append(out, Listing{ID: e.ID, Crew: e.Value.(*Crew)})
546	}
547	return out
548}
549
550// ValidName reports whether name can be stored: non-empty after trimming,
551// within [MaxNameLen], free of control characters including newlines, and
552// free of the pipe character.
553//
554// The pipe is the one restriction that is not obvious, and it is here because
555// a name is rendered as the TITLE of a link inside a table cell. md.Link
556// escapes its title with the inline-text escaper, which deliberately leaves
557// "|" alone because a pipe is markdown-inert outside a table, and wrapping the
558// title in ui.Cell on top of that would double-escape and render the
559// backslashes. Refusing the character at write time is the only place left
560// where the fix is one rule rather than one exception per call site.
561//
562// A proposal's text has no such restriction: [ValidText] allows a pipe and the
563// render escapes it with ui.Cell, because free prose legitimately contains one
564// and a name does not.
565func ValidName(name string) bool {
566	if len(name) > MaxNameLen || strings.TrimSpace(name) == "" {
567		return false
568	}
569	for i := 0; i < len(name); i++ {
570		c := name[i]
571		if c < 0x20 || c == 0x7f || c == '|' {
572			return false
573		}
574	}
575	return true
576}
577
578// ValidText reports whether a proposal body can be stored: non-empty after
579// trimming, within [MaxTextLen], and free of control characters other than
580// newline and tab.
581//
582// Control characters are refused rather than stripped because the text is shown
583// back to its author, and silently rewriting what somebody wrote is worse than
584// telling them it was refused. Everything else is allowed and escaped at render
585// time: a validator and an escaper protect against different mistakes.
586func ValidText(text string) bool {
587	if len(text) > MaxTextLen || strings.TrimSpace(text) == "" {
588		return false
589	}
590	for i := 0; i < len(text); i++ {
591		c := text[i]
592		if c < 0x20 && c != '\n' && c != '\t' || c == 0x7f {
593			return false
594		}
595	}
596	return true
597}
598
599// dropAddress returns addrs without who, in a freshly allocated slice.
600//
601// It allocates rather than shortening in place: a slice is one persisted object
602// and the storage deposit only comes back when that object is dropped, so
603// append(s[:i], s[i+1:]...) would keep the peak allocation charged forever.
604func dropAddress(addrs []address, who address) []address {
605	out := make([]address, 0, len(addrs))
606	for _, a := range addrs {
607		if a != who {
608			out = append(out, a)
609		}
610	}
611	return out
612}