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

crews.gno

13.55 Kb · 372 lines
  1// Package crews is small-group coordination you can create in one
  2// transaction: three to fifteen people with a shared pot, a way to decide, and
  3// a way to leave with their share.
  4//
  5// It is the chain wiring for [gno.land/p/moul/x/social/crew], which holds the
  6// share math and the proposal state. This realm reads the caller, reads the
  7// coins attached to the call, asks the engine, and renders the result.
  8//
  9//	Create    pay in, and you are the crew's first member
 10//	Join      pay in at what a share is worth NOW, not at what it cost them
 11//	Fund      pay in and mint nothing, so every existing share is worth more
 12//	Propose   any member opens an advisory question
 13//	Vote      weighted by the shares you hold, changeable while it is open
 14//	Close     anyone, once the deadline has gone by
 15//	Ragequit  burn your shares, be credited your slice, leave
 16//	Withdraw  collect what you were credited
 17//
 18// # The shares are the token
 19//
 20// There is no second asset to issue. A share is the vote weight and the claim
 21// on the treasury at the same moment, minted by paying in and burned by
 22// leaving, so nothing about it is decorative and nobody has to be persuaded it
 23// is worth something. v0 keeps the shares as an internal ledger rather than one
 24// GRC20 per crew, because a GRC20 per crew means a realm per crew. A
 25// transferable share belongs in [gno.land/p/moul/x/social/coin], the sibling
 26// that refuses to exist until its mint rule, sink and buyer are declared; this
 27// realm does not import it.
 28//
 29// # The custody caveat, which is real
 30//
 31// ONE realm address holds EVERY crew's treasury, with per-crew accounting
 32// inside it. The chain sees one balance; which crew owns which part of it is a
 33// number in this realm's state. So a bug in the accounting is a bug across
 34// crews, not inside one: an arithmetic error that over-credits one crew's
 35// ragequit pays it out of another crew's money, and nothing at the bank layer
 36// would refuse that transfer.
 37//
 38// The fix is the instance-per-realm pattern (EFFECTIVE_GNO.md section 1.4): one
 39// realm per crew, each holding its own coins at its own address, with this
 40// package as the shared engine. That is v1. It is not v0 because deploying a
 41// realm per crew is a publish per crew, which is the opposite of "create it in
 42// one transaction", and the whole product claim here is the one transaction.
 43//
 44// # Payouts are pulled
 45//
 46// Nothing is ever pushed. A ragequit credits an internal ledger and the leaver
 47// calls [Withdraw] themselves, so one address that cannot be paid cannot wedge
 48// anybody else, and the credit is zeroed before the coins move.
 49package crews
 50
 51import (
 52	"strconv"
 53
 54	"chain"
 55	"chain/banker"
 56	"chain/runtime"
 57
 58	"gno.land/p/moul/kit/store/v0"
 59	"gno.land/p/moul/x/envelope/v0"
 60	"gno.land/p/moul/x/social/crew/v0"
 61	"gno.land/p/moul/xmath/v1"
 62)
 63
 64// realmPath is this realm's own path, the one its gnomod.toml module line
 65// declares. It is written out rather than read from the runtime, because every
 66// plain read this realm exports reports its CALLER and would build every link
 67// against the wrong realm.
 68const realmPath = "gno.land/r/moul/x/social/crews/v0"
 69
 70// denom is the only coin a crew holds. ugnot and nothing else: a treasury that
 71// can hold several denominations needs a per-denomination share price, which is
 72// a different product.
 73const denom = "ugnot"
 74
 75// IndexCrews is how many crews the index page lists.
 76const IndexCrews = 20
 77
 78// IndexProposals is how many of a crew's proposals its page lists.
 79//
 80// Finding 325-cost-at-caps-1: the page rendered every proposal a crew had ever
 81// had. Proposing is open to every member and nothing deletes one, so the cost
 82// every reader pays grew without a bound anybody controls.
 83const IndexProposals = 20
 84
 85// crews holds every crew, every proposal and the credit ledger. A redeploy
 86// wipes it, which is the trade gnomod.toml's private = true makes: see the
 87// comment there.
 88var crews = crew.New()
 89
 90// Create opens a crew and makes the caller its first member.
 91//
 92// Send ugnot with the call: the founder's shares are bought out of it at
 93// crew.InitialPricePerShare, and the whole amount, remainder included, becomes
 94// the treasury. Returns the new crew's id.
 95func Create(cur realm, name string) int64 {
 96	// Both checks have to be here and not behind a helper. cur.IsCurrent()
 97	// before cur.Previous() is the realm-token rule; IsUserCall before the
 98	// envelope read is the payment one, because the envelope reports what
 99	// the SIGNER attached to the transaction and not what reached this
100	// realm. Without it a realm the user called keeps the coins and calls
101	// in here as many times as it likes, every call reading the same send.
102	if !cur.IsCurrent() {
103		panic("spoofed realm: cur is not the live crossing frame")
104	}
105	if !cur.Previous().IsUserCall() {
106		panic("crews: paying in must be a direct user transaction")
107	}
108	who := cur.Previous().Address()
109	amount := envelope.RequireAtLeast(denom, crew.InitialPricePerShare)
110
111	id, err := crews.Create(name, who, amount, runtime.ChainHeight())
112	if err != nil {
113		panic(err.Error())
114	}
115	chain.Emit("Create",
116		"crew", id.String(),
117		"founder", who.String(),
118		"amount", strconv.FormatInt(amount, 10),
119	)
120	return int64(id)
121}
122
123// Join mints the caller shares at what a share is worth right now and adds what
124// they sent to the treasury. Returns the shares minted.
125//
126// The price is amount * totalShares / treasury, rounded down, so somebody
127// joining a crew that has tripled its money pays three times what the founders
128// did. Send at least the crew page's join price, treasury / totalShares rounded
129// up, or there is no whole share to mint.
130func Join(cur realm, crewID int64) int64 {
131	// The two guards every payable call needs; see Create.
132	if !cur.IsCurrent() {
133		panic("spoofed realm: cur is not the live crossing frame")
134	}
135	if !cur.Previous().IsUserCall() {
136		panic("crews: paying in must be a direct user transaction")
137	}
138	who := cur.Previous().Address()
139	id := store.ID(crewID)
140	c, ok := crews.Get(id)
141	if !ok {
142		panic(crew.ErrNoCrew.Error())
143	}
144	amount := envelope.RequireAtLeast(denom, joinPrice(c))
145
146	shares, err := crews.Join(id, who, amount)
147	if err != nil {
148		panic(err.Error())
149	}
150	chain.Emit("Join",
151		"crew", id.String(),
152		"member", who.String(),
153		"amount", strconv.FormatInt(amount, 10),
154		"shares", strconv.FormatInt(shares, 10),
155	)
156	return shares
157}
158
159// joinPrice is the least a joiner can send and still be minted a share: the
160// floor Join's envelope guard enforces, and the price a crew page quotes.
161//
162// The engine mints amount * totalShares / treasury, rounded down, so one whole
163// share needs treasury / totalShares rounded UP. crew.Crew.PricePerShare rounds
164// down, which is one ugnot short whenever the treasury is not an exact multiple
165// of the share count, the ordinary state after any Fund: a joiner paying the
166// quoted price passed the guard and then reverted on "buys no whole share".
167// That method lives in a published package and cannot change in place, so the
168// rule is here, next to the only guard that uses it. A crew nobody holds a
169// share in is back at the opening price, the same branch Join takes.
170func joinPrice(c *crew.Crew) int64 {
171	if c.TotalShares == 0 || c.Treasury == 0 {
172		return crew.InitialPricePerShare
173	}
174	return xmath.MulDivUp(1, c.Treasury, c.TotalShares)
175}
176
177// Fund adds what the caller sent to a crew's treasury and mints nothing, so
178// every existing share is worth more afterwards.
179//
180// It is how revenue, a grant or a member topping the pot up arrives. Anyone may
181// fund any crew, member or not: there is no way to refuse a transfer on this
182// chain anyway, and pretending otherwise would only move the donation to a bank
183// send the realm cannot account for.
184func Fund(cur realm, crewID int64) int64 {
185	// The two guards every payable call needs; see Create.
186	if !cur.IsCurrent() {
187		panic("spoofed realm: cur is not the live crossing frame")
188	}
189	if !cur.Previous().IsUserCall() {
190		panic("crews: paying in must be a direct user transaction")
191	}
192	who := cur.Previous().Address()
193	amount := envelope.RequireAtLeast(denom, 1)
194
195	if err := crews.Fund(store.ID(crewID), amount); err != nil {
196		panic(err.Error())
197	}
198	chain.Emit("Fund",
199		"crew", store.ID(crewID).String(),
200		"from", who.String(),
201		"amount", strconv.FormatInt(amount, 10),
202	)
203	return amount
204}
205
206// Propose opens an advisory question on a crew. Any member may propose, and it
207// stays open for crew.VoteBlocks blocks. Returns the proposal's id.
208//
209// A proposal EXECUTES NOTHING. Passing one records that the crew agreed by
210// share weight, and moves no coins, changes no membership and binds no code.
211// Executing a payout is the next step and it is not in v0.
212func Propose(cur realm, crewID int64, text string) int64 {
213	who := caller(cur)
214	pid, err := crews.Propose(store.ID(crewID), who, text, runtime.ChainHeight())
215	if err != nil {
216		panic(err.Error())
217	}
218	chain.Emit("Propose",
219		"crew", store.ID(crewID).String(),
220		"proposal", pid.String(),
221		"author", who.String(),
222	)
223	return int64(pid)
224}
225
226// Vote records the caller's ballot, weighted by the shares they hold at this
227// moment. One ballot per member, changeable while the proposal is open.
228func Vote(cur realm, proposalID int64, yes bool) {
229	who := caller(cur)
230	pid := store.ID(proposalID)
231	if err := crews.Vote(pid, who, yes, runtime.ChainHeight()); err != nil {
232		panic(err.Error())
233	}
234	chain.Emit("Vote",
235		"proposal", pid.String(),
236		"voter", who.String(),
237		"yes", strconv.FormatBool(yes),
238	)
239}
240
241// Close records a proposal as passed or failed once its deadline has gone by,
242// and returns which. Anyone may call it: closing is bookkeeping, not authority.
243func Close(cur realm, proposalID int64) bool {
244	pid := store.ID(proposalID)
245	passed, err := crews.Close(pid, runtime.ChainHeight())
246	if err != nil {
247		panic(err.Error())
248	}
249	chain.Emit("Close", "proposal", pid.String(), "passed", strconv.FormatBool(passed))
250	return passed
251}
252
253// Ragequit burns every share the caller holds, credits them their pro-rata
254// slice of the treasury and removes them from the crew. Returns the amount
255// credited, which [Withdraw] collects.
256//
257// Nobody has to agree. That is what makes a share mean something: the exit is
258// priced by the same number that votes, and a member outvoted on everything can
259// still leave with what they put in plus their part of what the crew built.
260func Ragequit(cur realm, crewID int64) int64 {
261	who := caller(cur)
262	id := store.ID(crewID)
263	shares, amount, err := crews.Ragequit(id, who)
264	if err != nil {
265		panic(err.Error())
266	}
267	chain.Emit("Ragequit",
268		"crew", id.String(),
269		"member", who.String(),
270		"shares", strconv.FormatInt(shares, 10),
271		"amount", strconv.FormatInt(amount, 10),
272	)
273	return amount
274}
275
276// Withdraw sends the caller everything they have been credited, and returns it.
277//
278// The credit is zeroed before the coins move, so a reentrant call finds nothing
279// left to take. Nothing in this realm ever pushes value: one address that
280// cannot be paid must not be able to wedge everybody else.
281func Withdraw(cur realm) int64 {
282	who := caller(cur)
283	amount, err := crews.Withdraw(who)
284	if err != nil {
285		panic(err.Error())
286	}
287
288	bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
289	bnk.SendCoins(cur.Address(), who, chain.NewCoins(chain.NewCoin(denom, amount)))
290
291	chain.Emit("Withdraw", "payee", who.String(), "amount", strconv.FormatInt(amount, 10))
292	return amount
293}
294
295// Get returns a crew's name, the height it was created at, how many members it
296// has, its total shares and its treasury in ugnot.
297//
298// Flat values rather than the crew itself: handing out a pointer to live state
299// is a mutation handle nothing checks, and a struct is not something a wallet
300// can decode anyway.
301func Get(crewID int64) (string, int64, int, int64, int64) {
302	c, ok := crews.Get(store.ID(crewID))
303	if !ok {
304		return "", 0, 0, 0, 0
305	}
306	return c.Name, c.CreatedAt, c.MemberCount(), c.TotalShares, c.Treasury
307}
308
309// Count is how many crews exist.
310func Count() int { return crews.Len() }
311
312// SharesOf is how many shares who holds in a crew.
313func SharesOf(crewID int64, who address) int64 {
314	c, _ := crews.Get(store.ID(crewID))
315	return c.SharesOf(who)
316}
317
318// MemberCount is how many people are in a crew.
319func MemberCount(crewID int64) int {
320	c, _ := crews.Get(store.ID(crewID))
321	return c.MemberCount()
322}
323
324// Treasury is what a crew holds, in ugnot. The coins themselves sit at this
325// realm's single address: see the package doc on custody.
326func Treasury(crewID int64) int64 {
327	c, _ := crews.Get(store.ID(crewID))
328	if c == nil {
329		return 0
330	}
331	return c.Treasury
332}
333
334// ValuePerShare is what one of a crew's shares is worth in ugnot, rounded down.
335func ValuePerShare(crewID int64) int64 {
336	c, _ := crews.Get(store.ID(crewID))
337	return c.ValuePerShare()
338}
339
340// ProposalOf returns a proposal's crew, author, text, deadline and whether it
341// has been closed and passed.
342func ProposalOf(proposalID int64) (int64, address, string, int64, bool, bool) {
343	p, ok := crews.Proposal(store.ID(proposalID))
344	if !ok {
345		return 0, "", "", 0, false, false
346	}
347	return int64(p.CrewID), p.Author, p.Text, p.Deadline, p.Closed, p.Passed
348}
349
350// Tally is a proposal's share-weighted yes and no.
351func Tally(proposalID int64) (int64, int64) {
352	p, ok := crews.Proposal(store.ID(proposalID))
353	if !ok {
354		return 0, 0
355	}
356	return p.Yes, p.No
357}
358
359// CreditOf is what an address can withdraw right now, in ugnot.
360func CreditOf(who address) int64 { return crews.CreditOf(who) }
361
362// TotalOwed is every outstanding credit, which is the part of this realm's
363// balance that belongs to people who have already left.
364func TotalOwed() int64 { return crews.TotalOwed() }
365
366// caller is the address that called us, checked the one way that is safe.
367func caller(cur realm) address {
368	if !cur.IsCurrent() {
369		panic("spoofed realm: cur is not the live crossing frame")
370	}
371	return cur.Previous().Address()
372}