1// Package crew is small-group coordination as a product rather than as a2// framework: three to fifteen people with a shared pot, a way to decide, and a3// way to leave with their share.4//5// # Why not a DAO framework6//7// A framework asks you to pick a governance module, a voting strategy and a8// treasury adapter before anybody has put in a single ugnot. The targets here9// are real and already exist: a validator set, a working group, a hackathon10// team, a five-person company. They want the thing you can create in one11// transaction. [gno.land/p/moul/grants] and daokit are the framework layer, and12// this package is deliberately underneath them: one call to open a crew, one to13// join it, one to leave with what your shares are worth.14//15// # The model16//17// Crews every crew, plus the credit ledger every payout goes through18// Crew a name, a creation height, members with shares, a treasury, proposals19// Proposal advisory text with a deadline and a share-weighted tally20//21// Shares are the whole design. They are the vote weight and the claim on the22// treasury at the same time, minted by [Crews.Join] and burned by23// [Crews.Ragequit], so leaving is priced by the same number that decides. There24// is no separate token to issue, distribute or forget to make meaningful.25//26// # Rounding, which is the part that has to be right27//28// Every division here rounds DOWN, and both of them therefore round in the29// crew's favour:30//31// - joining mints amount * totalShares / treasury, so a late joiner buys32// exactly what they paid for at the CURRENT per-share value and never a33// share more. Rounding up would hand them a sliver of value the existing34// members built, which is the whole reason a flat price is wrong here.35// - ragequitting credits shares * treasury / totalShares, so a leaver takes36// no more than their slice and the remainder stays with the people who37// stayed.38//39// The dust that accrues from both is never stranded: the last member to40// ragequit holds every outstanding share, so their MulDiv is exact and the41// treasury empties to the last ugnot. [Crews.Ragequit] has a test for that.42//43// # What v0 deliberately does not do44//45// A proposal is advisory text. Passing one executes nothing, moves nothing and46// binds nothing; it records that the crew agreed by share weight at a point in47// time. Executing a payout is the obvious next step and the one that turns this48// 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 then51// ragequits leaves their weight behind in the tally, because unwinding it would52// mean re-weighing every ballot on every share change.53//54// # Errors, not aborts55//56// This is a p/, so it returns errors and declares no crossing function: the57// caller, the height and the amount all arrive as plain arguments and the realm58// that wires it to the chain decides what to abort on.59package crew6061import (62 "errors"63 "strings"6465 "gno.land/p/moul/kit/store/v0"66 "gno.land/p/moul/xmath/v1"67)6869const (70 // MaxMembers is the upper end of "three to fifteen people". It is a71 // product constraint and not a technical one: above it the share math72 // still works and the thing stops being a crew.73 MaxMembers = 157475 // InitialPricePerShare is what a share costs in ugnot before the crew76 // has a treasury to price against: at creation, and again if every77 // member has left. 1000 ugnot makes 1 GNOT worth 1000 shares, which78 // keeps the integer arithmetic far away from both zero and overflow.79 InitialPricePerShare = int64(1000)8081 // VoteBlocks is how long a proposal stays open, in blocks.82 VoteBlocks = int64(1000)8384 // MaxNameLen and MaxTextLen bound what one call can make the crew's85 // members pay a storage deposit on.86 MaxNameLen = 6087 MaxTextLen = 5008889 // ExcerptLen is how much of a proposal a listing shows.90 ExcerptLen = 6091)9293// maxInt64 is the largest int64, spelled out because the overflow guards94// compare against it directly.95const maxInt64 = int64(9223372036854775807)9697// 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)113114// Crew is one group: who is in it, what it holds, and what it is deciding.115type Crew struct {116 Name string117 CreatedAt int64 // block height118 Treasury int64 // ugnot, accounted here and held at the realm's address119120 // TotalShares is every share outstanding. It is the denominator of both121 // the join price and the ragequit payout, so it is maintained here122 // rather than summed over the members on demand.123 TotalShares int64124125 shares map[string]int64 // address -> shares held126 order []address // members in join order, so output never iterates a map127 proposals []store.ID128}129130// 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 0134 }135 return c.shares[who.String()]136}137138// IsMember reports whether who holds any share.139func (c *Crew) IsMember(who address) bool { return c.SharesOf(who) > 0 }140141// MemberCount is how many addresses hold shares.142func (c *Crew) MemberCount() int {143 if c == nil {144 return 0145 }146 return len(c.order)147}148149// Members returns the members in join order. The slice is a copy, so a caller150// rendering it cannot reorder the crew.151func (c *Crew) Members() []address {152 if c == nil {153 return nil154 }155 out := make([]address, len(c.order))156 copy(out, c.order)157 return out158}159160// Proposals returns this crew's proposal ids, oldest first, as a copy.161func (c *Crew) Proposals() []store.ID {162 if c == nil {163 return nil164 }165 out := make([]store.ID, len(c.proposals))166 copy(out, c.proposals)167 return out168}169170// 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] and173// [Crews.Ragequit] divide by the real totals, so a crew whose treasury is174// 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 0178 }179 return c.Treasury / c.TotalShares180}181182// PricePerShare is what the next share costs a joiner, rounded down, which is183// [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 InitialPricePerShare187 }188 return c.Treasury / c.TotalShares189}190191// ballot is one member's vote: which way, and what it weighed when cast.192type ballot struct {193 yes bool194 weight int64195}196197// Proposal is a question put to a crew. v0 proposals are advisory text and198// execute nothing: see the package doc.199type Proposal struct {200 CrewID store.ID201 Author address202 Text string203 OpenedAt int64204 Deadline int64 // OpenedAt + VoteBlocks, exclusive205206 // Yes and No are the running share-weighted tally, maintained on every207 // vote so reading it never walks the ballots.208 Yes int64209 No int64210211 Closed bool212 Passed bool213214 votes map[string]ballot215}216217// 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.Deadline220}221222// VoteOf reports how who voted and what it weighed, and whether they voted at223// all.224func (p *Proposal) VoteOf(who address) (yes bool, weight int64, voted bool) {225 if p == nil {226 return false, 0, false227 }228 b, ok := p.votes[who.String()]229 return b.yes, b.weight, ok230}231232// Voters is how many members have cast a ballot.233func (p *Proposal) Voters() int {234 if p == nil {235 return 0236 }237 return len(p.votes)238}239240// Crews holds every crew, every proposal, and the credit ledger each payout241// goes through.242type Crews struct {243 crews *store.Store244 props *store.Store245246 credit map[string]int64 // address -> ugnot owed247 owed int64 // the sum of the above, which the holder must reserve248}249250// 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}258259// Create opens a crew with founder as its only member, their shares bought out260// of amount at [InitialPricePerShare].261//262// The remainder below one whole share stays in the treasury rather than being263// 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, ErrBadName267 }268 if amount <= 0 {269 return 0, ErrBadAmount270 }271 shares := amount / InitialPricePerShare272 if shares < 1 {273 return 0, ErrNoShares274 }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), nil284}285286// Join mints who shares at the crew's CURRENT per-share value and adds amount287// to its treasury.288//289// amount * totalShares / treasury, rounded down. That is the whole anti-dilution290// rule: a crew that turned 10 GNOT into 20 sells the next share for what a share291// is worth now, not for what the founders paid, and the rounding remainder stays292// 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, ErrNoCrew297 }298 if amount <= 0 {299 return 0, ErrBadAmount300 }301 if c.IsMember(who) {302 return 0, ErrIsMember303 }304 if len(c.order) >= MaxMembers {305 return 0, ErrFull306 }307 if c.Treasury > maxInt64-amount {308 return 0, ErrWouldExceed309 }310311 var shares int64312 if c.TotalShares == 0 || c.Treasury == 0 {313 // Nobody holds a share, so there is no value to price against and314 // the crew is back at its opening price.315 shares = amount / InitialPricePerShare316 } else {317 shares = xmath.MulDiv(amount, c.TotalShares, c.Treasury)318 }319 if shares < 1 {320 return 0, ErrNoShares321 }322 if c.TotalShares > maxInt64-shares {323 return 0, ErrWouldExceed324 }325326 c.shares[who.String()] = shares327 c.order = append(c.order, who)328 c.TotalShares += shares329 c.Treasury += amount330 return shares, nil331}332333// 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 only336// ever moves with its share count prices every joiner identically forever, and337// the anti-dilution rule in [Crews.Join] would be decorative. Revenue, a grant338// and a member topping the pot up all arrive this way, and every existing share339// 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 ErrNoCrew344 }345 if amount <= 0 {346 return ErrBadAmount347 }348 if c.Treasury > maxInt64-amount {349 return ErrWouldExceed350 }351 c.Treasury += amount352 return nil353}354355// Propose opens an advisory question, open for [VoteBlocks] blocks. Any member356// 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, ErrNoCrew361 }362 if !c.IsMember(who) {363 return 0, ErrNotMember364 }365 if !ValidText(text) {366 return 0, ErrBadText367 }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, nil379}380381// 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 call384// replaces the first, tally and weight both, so changing your mind after buying385// 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 ErrNoProposal390 }391 if !p.Open(at) {392 return ErrVoteClosed393 }394 c, ok := cs.Get(p.CrewID)395 if !ok {396 return ErrNoCrew397 }398 weight := c.SharesOf(who)399 if weight <= 0 {400 return ErrNotMember401 }402403 key := who.String()404 if prev, voted := p.votes[key]; voted {405 if prev.yes {406 p.Yes -= prev.weight407 } else {408 p.No -= prev.weight409 }410 }411 p.votes[key] = ballot{yes: yes, weight: weight}412 if yes {413 p.Yes += weight414 } else {415 p.No += weight416 }417 return nil418}419420// Close records a proposal as passed or failed by share weight, once its421// deadline has gone by. Anyone may call it: closing is bookkeeping, not422// 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 the425// rest ignore it passes the proposal, which is exactly as advisory as the text426// 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, ErrNoProposal431 }432 if p.Closed {433 return p.Passed, ErrVoteClosed434 }435 if at < p.Deadline {436 return false, ErrVoteOpen437 }438 p.Closed = true439 p.Passed = p.Yes > p.No440 return p.Passed, nil441}442443// Ragequit burns every share who holds, credits them their pro-rata slice of444// the treasury and removes them from the crew.445//446// shares * treasury / totalShares, rounded down, so the remainder stays with447// the members who stayed. This is what makes a share mean something: the exit448// is priced by the same number that votes, and nobody has to agree to let you449// out.450//451// The payout is credited, never sent. The caller moves the coins after this452// 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, ErrNoCrew457 }458 shares = c.SharesOf(who)459 if shares <= 0 {460 return 0, 0, ErrNotMember461 }462463 amount = xmath.MulDiv(shares, c.Treasury, c.TotalShares)464 c.TotalShares -= shares465 c.Treasury -= amount466 delete(c.shares, who.String())467 c.order = dropAddress(c.order, who)468469 if amount > 0 {470 if err := cs.credits(who, amount); err != nil {471 return 0, 0, err472 }473 }474 return shares, amount, nil475}476477// 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 ErrWouldExceed482 }483 cs.credit[key] += amount484 cs.owed += amount485 return nil486}487488// 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 can491// move the coins afterwards and a reentrant call finds nothing: effects, then492// 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, ErrNothing498 }499 delete(cs.credit, key)500 cs.owed -= amount501 return amount, nil502}503504// CreditOf is what who can withdraw right now.505func (cs *Crews) CreditOf(who address) int64 { return cs.credit[who.String()] }506507// TotalOwed is the sum of every outstanding credit, which is what the holding508// realm must keep in reserve on top of every crew's treasury.509func (cs *Crews) TotalOwed() int64 { return cs.owed }510511// 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, false516 }517 return v.(*Crew), true518}519520// 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, false525 }526 return v.(*Proposal), true527}528529// Len is how many crews exist.530func (cs *Crews) Len() int { return cs.crews.Len() }531532// ProposalCount is how many proposals exist, across every crew.533func (cs *Crews) ProposalCount() int { return cs.props.Len() }534535// Listing is one row of [Crews.List]: the crew and the id a link needs.536type Listing struct {537 ID store.ID538 Crew *Crew539}540541// List returns up to limit crews, newest first. limit below 1 returns nothing.542func (cs *Crews) List(limit int) []Listing {543 var out []Listing544 for _, e := range cs.crews.PageReverse(1, limit) {545 out = append(out, Listing{ID: e.ID, Crew: e.Value.(*Crew)})546 }547 return out548}549550// ValidName reports whether name can be stored: non-empty after trimming,551// within [MaxNameLen], free of control characters including newlines, and552// free of the pipe character.553//554// The pipe is the one restriction that is not obvious, and it is here because555// a name is rendered as the TITLE of a link inside a table cell. md.Link556// escapes its title with the inline-text escaper, which deliberately leaves557// "|" alone because a pipe is markdown-inert outside a table, and wrapping the558// title in ui.Cell on top of that would double-escape and render the559// backslashes. Refusing the character at write time is the only place left560// 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 the563// render escapes it with ui.Cell, because free prose legitimately contains one564// and a name does not.565func ValidName(name string) bool {566 if len(name) > MaxNameLen || strings.TrimSpace(name) == "" {567 return false568 }569 for i := 0; i < len(name); i++ {570 c := name[i]571 if c < 0x20 || c == 0x7f || c == '|' {572 return false573 }574 }575 return true576}577578// ValidText reports whether a proposal body can be stored: non-empty after579// trimming, within [MaxTextLen], and free of control characters other than580// newline and tab.581//582// Control characters are refused rather than stripped because the text is shown583// back to its author, and silently rewriting what somebody wrote is worse than584// telling them it was refused. Everything else is allowed and escaped at render585// 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 false589 }590 for i := 0; i < len(text); i++ {591 c := text[i]592 if c < 0x20 && c != '\n' && c != '\t' || c == 0x7f {593 return false594 }595 }596 return true597}598599// dropAddress returns addrs without who, in a freshly allocated slice.600//601// It allocates rather than shortening in place: a slice is one persisted object602// and the storage deposit only comes back when that object is dropped, so603// 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 out612}613Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.