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

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}