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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

crew.gno

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