Small-group coordination as a product rather than as a framework: three to
fifteen people with a shared pot, a way to decide, and a way to leave with their
share. The pure engine behind
r/moul/x/social/crews, which is the chain wiring.
The targets are real and already exist: a validator set, a working group, a
hackathon team, a five-person company. p/moul/grants and daokit are the
framework layer; this is the thing you can create in one transaction.
The API
1cs:=crew.New() 2 3id,err:=cs.Create("validators",founder,amount,height)// founder gets amount/InitialPricePerShare shares 4shares,err:=cs.Join(id,who,amount)// minted at the CURRENT per-share value 5err:=cs.Fund(id,amount)// mints nothing, every share is worth more 6pid,err:=cs.Propose(id,who,"ship v1",height)// any member, open for VoteBlocks 7err:=cs.Vote(pid,who,true,height)// weighted by shares held right now 8passed,err:=cs.Close(pid,height)// anyone, after the deadline 9shares,amount,err:=cs.Ragequit(id,who)// burn the shares, be credited the slice10amount,err:=cs.Withdraw(who)// collect, zeroed before it returns
Reads: Get, Proposal, Len, ProposalCount, List, CreditOf,
TotalOwed, and on a *Crew: SharesOf, IsMember, MemberCount, Members,
Proposals, ValuePerShare, PricePerShare.
It returns errors and declares no crossing function. The caller, the height and
the amount all arrive as plain arguments, and the realm decides what to abort
on.
Shares are the token
A share is the vote weight and the claim on the treasury at the same moment,
minted by paying in and burned by leaving. There is no second asset to issue,
distribute, or fail to make meaningful, and the exit price is the same number
that votes: a member outvoted on everything can still leave with their part of
what the crew built, and nobody has to agree to let them.
The trap it avoids: which way the division rounds
Both divisions round down, so both round in the crew's favour.
joining
amount * totalShares / treasury, so a late joiner buys at what a share is worth now. Rounding up would hand them a sliver of value the existing members built.
ragequitting
shares * treasury / totalShares, so a leaver takes no more than their slice and the remainder stays with the people who stayed.
Both go through xmath.MulDiv, which computes through a 128-bit intermediate
and refuses rather than returning a wrong number: the naive a*b/c wraps to a
plausible-looking figure, which is how a payout split leaks money without
anything failing.
The dust that accrues is never stranded. The last member out holds every
outstanding share, so their division is exact and the treasury empties to the
last ugnot. TestEveryoneRagequittingEmptiesTheTreasury pins it on a treasury
of 3002 over three shares, which divides by nothing.
What v0 does not do
A proposal is advisory text. Passing one records that the crew agreed by
share weight and executes nothing: it moves no coins, changes no membership and
binds no code. Executing a payout is the next step, and it is what turns this
into a treasury contract rather than a notice board.
A vote keeps the weight it was cast with. A member who votes and then ragequits
leaves their weight behind in the tally, because unwinding it would mean
reweighing every ballot on every share change.
There is no quorum. A crew where one member votes and the rest ignore it passes
the proposal, which is exactly as advisory as the text it carries. A tie fails.
Two write-time rules worth knowing
ValidName refuses a pipe. A name is rendered as the title of a link inside a
table cell, md.Link escapes with the inline-text escaper, and that escaper
deliberately leaves | alone because a pipe is markdown-inert outside a table.
Wrapping the title in ui.Cell on top would double-escape and render the
backslashes, so the character is refused at write time instead.
ValidText allows a pipe, because free prose legitimately contains one, and
the render escapes it with ui.Cell. A validator and an escaper protect
against different mistakes.
Part of moul/gno-contracts — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage.
Dependency graph:
🧪 Highly experimental — potentially vibe-coded. Not audited; may break, change, or be removed at any time. Do not use with anything of value. Full disclaimer: DISCLAIMER.
Overview
Package crew is small-group coordination as a product rather than as a framework: three to fifteen people with a shared pot, a way to decide, and a way to leave with their share.
Why not a DAO framework
A framework asks you to pick a governance module, a voting strategy and a treasury adapter before anybody has put in a single ugnot. The targets here are real and already exist: a validator set, a working group, a hackathon team, a five-person company. They want the thing you can create in one transaction. gno.land/p/moul/grants and daokit are the framework layer, and this package is deliberately underneath them: one call to open a crew, one to join it, one to leave with what your shares are worth.
The model
Example
1Crews every crew, plus the credit ledger every payout goes through
2Crew a name, a creation height, members with shares, a treasury, proposals
3Proposal advisory text with a deadline and a share-weighted tally
Shares are the whole design. They are the vote weight and the claim on the treasury at the same time, minted by Crews.Join and burned by Crews.Ragequit, so leaving is priced by the same number that decides. There is no separate token to issue, distribute or forget to make meaningful.
Rounding, which is the part that has to be right
Every division here rounds DOWN, and both of them therefore round in the crew's favour:
joining mints amount * totalShares / treasury, so a late joiner buys exactly what they paid for at the CURRENT per-share value and never a share more. Rounding up would hand them a sliver of value the existing members built, which is the whole reason a flat price is wrong here.
ragequitting credits shares * treasury / totalShares, so a leaver takes no more than their slice and the remainder stays with the people who stayed.
The dust that accrues from both is never stranded: the last member to ragequit holds every outstanding share, so their MulDiv is exact and the treasury empties to the last ugnot. Crews.Ragequit has a test for that.
What v0 deliberately does not do
A proposal is advisory text. Passing one executes nothing, moves nothing and binds nothing; it records that the crew agreed by share weight at a point in time. Executing a payout is the obvious next step and the one that turns this into a treasury contract rather than a notice board.
A vote is weighed at the moment it is cast. A member who votes and then ragequits leaves their weight behind in the tally, because unwinding it would mean re-weighing every ballot on every share change.
Errors, not aborts
This is a p/, so it returns errors and declares no crossing function: the caller, the height and the amount all arrive as plain arguments and the realm that wires it to the chain decides what to abort on.
1const( 2// MaxMembers is the upper end of "three to fifteen people". It is a 3// product constraint and not a technical one: above it the share math 4// still works and the thing stops being a crew. 5MaxMembers=15 6 7// InitialPricePerShare is what a share costs in ugnot before the crew 8// has a treasury to price against: at creation, and again if every 9// member has left. 1000 ugnot makes 1 GNOT worth 1000 shares, which10// keeps the integer arithmetic far away from both zero and overflow.11InitialPricePerShare=int64(1000)1213// VoteBlocks is how long a proposal stays open, in blocks.14VoteBlocks=int64(1000)1516// MaxNameLen and MaxTextLen bound what one call can make the crew's17// members pay a storage deposit on.18MaxNameLen=6019MaxTextLen=5002021// ExcerptLen is how much of a proposal a listing shows.22ExcerptLen=6023)
1var( 2ErrBadName=errors.New("crew: name is empty, too long, or has control characters") 3ErrBadText=errors.New("crew: text is empty, too long, or has control characters") 4ErrBadAmount=errors.New("crew: amount must be positive") 5ErrNoCrew=errors.New("crew: no such crew") 6ErrNoProposal=errors.New("crew: no such proposal") 7ErrNotMember=errors.New("crew: not a member of this crew") 8ErrIsMember=errors.New("crew: already a member of this crew") 9ErrFull=errors.New("crew: the crew is full")10ErrNoShares=errors.New("crew: the amount sent buys no whole share")11ErrVoteClosed=errors.New("crew: the proposal is no longer open")12ErrVoteOpen=errors.New("crew: the proposal is still open")13ErrNothing=errors.New("crew: nothing to withdraw")14ErrWouldExceed=errors.New("crew: the amount would overflow the ledger")15)
ValidName reports whether name can be stored: non-empty after trimming, within MaxNameLen, free of control characters including newlines, and free of the pipe character.
The pipe is the one restriction that is not obvious, and it is here because a name is rendered as the TITLE of a link inside a table cell. md.Link escapes its title with the inline-text escaper, which deliberately leaves "|" alone because a pipe is markdown-inert outside a table, and wrapping the title in ui.Cell on top of that would double-escape and render the backslashes. Refusing the character at write time is the only place left where the fix is one rule rather than one exception per call site.
A proposal's text has no such restriction: ValidText allows a pipe and the render escapes it with ui.Cell, because free prose legitimately contains one and a name does not.
ValidText reports whether a proposal body can be stored: non-empty after trimming, within MaxTextLen, and free of control characters other than newline and tab.
Control characters are refused rather than stripped because the text is shown back to its author, and silently rewriting what somebody wrote is worse than telling them it was refused. Everything else is allowed and escaped at render time: a validator and an escaper protect against different mistakes.
1typeCrewstruct{ 2Namestring 3CreatedAtint64// block height 4Treasuryint64// ugnot, accounted here and held at the realm's address 5 6// TotalShares is every share outstanding. It is the denominator of both 7// the join price and the ragequit payout, so it is maintained here 8// rather than summed over the members on demand. 9TotalSharesint641011sharesmap[string]int64// address -> shares held12order[]address// members in join order, so output never iterates a map13proposals[]store.ID14}
ValuePerShare is what one share is worth in ugnot right now, rounded down.
It is a display figure and nothing computes against it: Crews.Join and Crews.Ragequit divide by the real totals, so a crew whose treasury is smaller than its share count still prices both correctly while this reads 0.
1typeCrewsstruct{2crews*store.Store3props*store.Store45creditmap[string]int64// address -> ugnot owed6owedint64// the sum of the above, which the holder must reserve7}
Close records a proposal as passed or failed by share weight, once its deadline has gone by. Anyone may call it: closing is bookkeeping, not authority, and a proposal nobody closes is simply never recorded.
A tie fails. There is no quorum in v0: a crew where one member votes and the rest ignore it passes the proposal, which is exactly as advisory as the text it carries.
Fund adds amount to a crew's treasury and mints nothing.
It is what makes the join price mean anything: a crew whose treasury only ever moves with its share count prices every joiner identically forever, and the anti-dilution rule in Crews.Join would be decorative. Revenue, a grant and a member topping the pot up all arrive this way, and every existing share is worth more afterwards.
Join mints who shares at the crew's CURRENT per-share value and adds amount to its treasury.
amount * totalShares / treasury, rounded down. That is the whole anti-dilution rule: a crew that turned 10 GNOT into 20 sells the next share for what a share is worth now, not for what the founders paid, and the rounding remainder stays with the crew rather than with the joiner.
Ragequit burns every share who holds, credits them their pro-rata slice of the treasury and removes them from the crew.
shares * treasury / totalShares, rounded down, so the remainder stays with the members who stayed. This is what makes a share mean something: the exit is priced by the same number that votes, and nobody has to agree to let you out.
The payout is credited, never sent. The caller moves the coins after this returns, which is the ordering the pull-payment pattern exists for.
Vote records who's ballot, weighted by the shares they hold right now.
One ballot per member, changeable while the proposal is open: a second call replaces the first, tally and weight both, so changing your mind after buying more shares counts the shares you now hold.
Withdraw zeroes who's credit and returns what they were owed.
The balance is gone from the ledger before this returns, so the caller can move the coins afterwards and a reentrant call finds nothing: effects, then interactions.
1typeProposalstruct{ 2CrewIDstore.ID 3Authoraddress 4Textstring 5OpenedAtint64 6Deadlineint64// OpenedAt + VoteBlocks, exclusive 7 8// Yes and No are the running share-weighted tally, maintained on every 9// vote so reading it never walks the ballots.10Yesint6411Noint641213Closedbool14Passedbool1516votesmap[string]ballot17}