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}