Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

v0 source pure

Package crew is small-group coordination as a product rather than as a framework: three to fifteen people with a shar...

Readme View source

crew

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 slice
10amount, 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:

gno.land/p/moul/x/social/crew/v0 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.

Constants 1

const MaxMembers, InitialPricePerShare, VoteBlocks, MaxNameLen, MaxTextLen, ExcerptLen

 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.
 5	MaxMembers = 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, which
10	// keeps the integer arithmetic far away from both zero and overflow.
11	InitialPricePerShare = int64(1000)
12
13	// VoteBlocks is how long a proposal stays open, in blocks.
14	VoteBlocks = int64(1000)
15
16	// MaxNameLen and MaxTextLen bound what one call can make the crew's
17	// members pay a storage deposit on.
18	MaxNameLen = 60
19	MaxTextLen = 500
20
21	// ExcerptLen is how much of a proposal a listing shows.
22	ExcerptLen = 60
23)
source

Variables 1

var ErrBadName, ErrBadText, ErrBadAmount, ErrNoCrew, ErrNoProposal, ErrNotMember, ErrIsMember, ErrFull, ErrNoShares, ErrVoteClosed, ErrVoteOpen, ErrNothing, ErrWouldExceed

 1var (
 2	ErrBadName     = errors.New("crew: name is empty, too long, or has control characters")
 3	ErrBadText     = errors.New("crew: text is empty, too long, or has control characters")
 4	ErrBadAmount   = errors.New("crew: amount must be positive")
 5	ErrNoCrew      = errors.New("crew: no such crew")
 6	ErrNoProposal  = errors.New("crew: no such proposal")
 7	ErrNotMember   = errors.New("crew: not a member of this crew")
 8	ErrIsMember    = errors.New("crew: already a member of this crew")
 9	ErrFull        = errors.New("crew: the crew is full")
10	ErrNoShares    = errors.New("crew: the amount sent buys no whole share")
11	ErrVoteClosed  = errors.New("crew: the proposal is no longer open")
12	ErrVoteOpen    = errors.New("crew: the proposal is still open")
13	ErrNothing     = errors.New("crew: nothing to withdraw")
14	ErrWouldExceed = errors.New("crew: the amount would overflow the ledger")
15)
source

The errors a caller can get back.

Functions 3

func ValidName

1func ValidName(name string) bool
source

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.

func ValidText

1func ValidText(text string) bool
source

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.

func New

1func New() *Crews
source

New returns an empty Crews.

Types 4

type Crew

struct
 1type Crew struct {
 2	Name      string
 3	CreatedAt int64 // block height
 4	Treasury  int64 // 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.
 9	TotalShares int64
10
11	shares    map[string]int64 // address -> shares held
12	order     []address        // members in join order, so output never iterates a map
13	proposals []store.ID
14}
source

Crew is one group: who is in it, what it holds, and what it is deciding.

Methods on Crew

func IsMember

method on Crew
1func (c *Crew) IsMember(who address) bool
source

IsMember reports whether who holds any share.

func MemberCount

method on Crew
1func (c *Crew) MemberCount() int
source

MemberCount is how many addresses hold shares.

func Members

method on Crew
1func (c *Crew) Members() []address
source

Members returns the members in join order. The slice is a copy, so a caller rendering it cannot reorder the crew.

func PricePerShare

method on Crew
1func (c *Crew) PricePerShare() int64
source

PricePerShare is what the next share costs a joiner, rounded down, which is Crew.ValuePerShare except on a crew nobody holds a share in.

func Proposals

method on Crew
1func (c *Crew) Proposals() []store.ID
source

Proposals returns this crew's proposal ids, oldest first, as a copy.

func SharesOf

method on Crew
1func (c *Crew) SharesOf(who address) int64
source

SharesOf is how many shares who holds, zero when they hold none.

func ValuePerShare

method on Crew
1func (c *Crew) ValuePerShare() int64
source

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.

type Crews

struct
1type Crews struct {
2	crews *store.Store
3	props *store.Store
4
5	credit map[string]int64 // address -> ugnot owed
6	owed   int64            // the sum of the above, which the holder must reserve
7}
source

Crews holds every crew, every proposal, and the credit ledger each payout goes through.

Methods on Crews

func Close

method on Crews
1func (cs *Crews) Close(pid store.ID, at int64) (bool, error)
source

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.

func Create

method on Crews
1func (cs *Crews) Create(name string, founder address, amount, at int64) (store.ID, error)
source

Create opens a crew with founder as its only member, their shares bought out of amount at InitialPricePerShare.

The remainder below one whole share stays in the treasury rather than being refunded, which is the same direction every other division here rounds.

func CreditOf

method on Crews
1func (cs *Crews) CreditOf(who address) int64
source

CreditOf is what who can withdraw right now.

func Fund

method on Crews
1func (cs *Crews) Fund(id store.ID, amount int64) error
source

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.

func Get

method on Crews
1func (cs *Crews) Get(id store.ID) (*Crew, bool)
source

Get returns a crew by id.

func Join

method on Crews
1func (cs *Crews) Join(id store.ID, who address, amount int64) (int64, error)
source

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.

func Len

method on Crews
1func (cs *Crews) Len() int
source

Len is how many crews exist.

func List

method on Crews
1func (cs *Crews) List(limit int) []Listing
source

List returns up to limit crews, newest first. limit below 1 returns nothing.

func Proposal

method on Crews
1func (cs *Crews) Proposal(pid store.ID) (*Proposal, bool)
source

Proposal returns a proposal by id.

func ProposalCount

method on Crews
1func (cs *Crews) ProposalCount() int
source

ProposalCount is how many proposals exist, across every crew.

func Propose

method on Crews
1func (cs *Crews) Propose(id store.ID, who address, text string, at int64) (store.ID, error)
source

Propose opens an advisory question, open for VoteBlocks blocks. Any member may propose.

func Ragequit

method on Crews
1func (cs *Crews) Ragequit(id store.ID, who address) (shares, amount int64, err error)
source

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.

func TotalOwed

method on Crews
1func (cs *Crews) TotalOwed() int64
source

TotalOwed is the sum of every outstanding credit, which is what the holding realm must keep in reserve on top of every crew's treasury.

func Vote

method on Crews
1func (cs *Crews) Vote(pid store.ID, who address, yes bool, at int64) error
source

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.

func Withdraw

method on Crews
1func (cs *Crews) Withdraw(who address) (int64, error)
source

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.

type Listing

struct
1type Listing struct {
2	ID   store.ID
3	Crew *Crew
4}
source

Listing is one row of Crews.List: the crew and the id a link needs.

type Proposal

struct
 1type Proposal struct {
 2	CrewID   store.ID
 3	Author   address
 4	Text     string
 5	OpenedAt int64
 6	Deadline int64 // 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.
10	Yes int64
11	No  int64
12
13	Closed bool
14	Passed bool
15
16	votes map[string]ballot
17}
source

Proposal is a question put to a crew. v0 proposals are advisory text and execute nothing: see the package doc.

Methods on Proposal

func Open

method on Proposal
1func (p *Proposal) Open(now int64) bool
source

Open reports whether the proposal still accepts votes at height now.

func VoteOf

method on Proposal
1func (p *Proposal) VoteOf(who address) (yes bool, weight int64, voted bool)
source

VoteOf reports how who voted and what it weighed, and whether they voted at all.

func Voters

method on Proposal
1func (p *Proposal) Voters() int
source

Voters is how many members have cast a ballot.

Imports 4

Source Files 4