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}