PathrockNetwork Gno Explorer
HomeBlocksTransactionsTokensRealmsPackagesValidatorsAnalytics

PathrockNetwork Gno Explorer — an independent explorer for Gno.land Mainnet (gnoland-1), operated by PathrockNetwork. Not an official Gno.land service.

gnowebarchive RPC

gno.land/p/moul/x/social/crew/v0

Package
Open in gnoweb ↗

Overview

Kind
Pure package
Name
v0
Namespace
moul / x / social / crew
Files
4 (README)(gnomod.toml)
Exported functions
n/a — not supported for pure packages by the node (vm/qfuncs)
Module
gno.land/p/moul/x/social/crew/v0
gno
0.9

Files (4)

  • README.mdmarkdown
  • gnomod.tomltoml
  • crew.gnogno
  • crew_test.gnogno
crew.gnogno
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}613

Functions

not supported for pure packages by the node (vm/qfuncs)

Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.