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

crews.gno

12.24 Kb · 345 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)
 62
 63// realmPath is this realm's own path, the one its gnomod.toml module line
 64// declares. It is written out rather than read from the runtime, because every
 65// plain read this realm exports reports its CALLER and would build every link
 66// against the wrong realm.
 67const realmPath = "gno.land/r/moul/x/social/crews/v0"
 68
 69// denom is the only coin a crew holds. ugnot and nothing else: a treasury that
 70// can hold several denominations needs a per-denomination share price, which is
 71// a different product.
 72const denom = "ugnot"
 73
 74// IndexCrews is how many crews the index page lists.
 75const IndexCrews = 20
 76
 77// crews holds every crew, every proposal and the credit ledger. A redeploy
 78// wipes it, which is the trade gnomod.toml's private = true makes: see the
 79// comment there.
 80var crews = crew.New()
 81
 82// Create opens a crew and makes the caller its first member.
 83//
 84// Send ugnot with the call: the founder's shares are bought out of it at
 85// crew.InitialPricePerShare, and the whole amount, remainder included, becomes
 86// the treasury. Returns the new crew's id.
 87func Create(cur realm, name string) int64 {
 88	// Both checks have to be here and not behind a helper. cur.IsCurrent()
 89	// before cur.Previous() is the realm-token rule; IsUserCall before the
 90	// envelope read is the payment one, because the envelope reports what
 91	// the SIGNER attached to the transaction and not what reached this
 92	// realm. Without it a realm the user called keeps the coins and calls
 93	// in here as many times as it likes, every call reading the same send.
 94	if !cur.IsCurrent() {
 95		panic("spoofed realm: cur is not the live crossing frame")
 96	}
 97	if !cur.Previous().IsUserCall() {
 98		panic("crews: paying in must be a direct user transaction")
 99	}
100	who := cur.Previous().Address()
101	amount := envelope.RequireAtLeast(denom, crew.InitialPricePerShare)
102
103	id, err := crews.Create(name, who, amount, runtime.ChainHeight())
104	if err != nil {
105		panic(err.Error())
106	}
107	chain.Emit("Create",
108		"crew", id.String(),
109		"founder", who.String(),
110		"amount", strconv.FormatInt(amount, 10),
111	)
112	return int64(id)
113}
114
115// Join mints the caller shares at what a share is worth right now and adds what
116// they sent to the treasury. Returns the shares minted.
117//
118// The price is amount * totalShares / treasury, rounded down, so somebody
119// joining a crew that has tripled its money pays three times what the founders
120// did. Send at least ValuePerShare ugnot, or there is no whole share to mint.
121func Join(cur realm, crewID int64) int64 {
122	// The two guards every payable call needs; see Create.
123	if !cur.IsCurrent() {
124		panic("spoofed realm: cur is not the live crossing frame")
125	}
126	if !cur.Previous().IsUserCall() {
127		panic("crews: paying in must be a direct user transaction")
128	}
129	who := cur.Previous().Address()
130	id := store.ID(crewID)
131	c, ok := crews.Get(id)
132	if !ok {
133		panic(crew.ErrNoCrew.Error())
134	}
135	amount := envelope.RequireAtLeast(denom, c.PricePerShare())
136
137	shares, err := crews.Join(id, who, amount)
138	if err != nil {
139		panic(err.Error())
140	}
141	chain.Emit("Join",
142		"crew", id.String(),
143		"member", who.String(),
144		"amount", strconv.FormatInt(amount, 10),
145		"shares", strconv.FormatInt(shares, 10),
146	)
147	return shares
148}
149
150// Fund adds what the caller sent to a crew's treasury and mints nothing, so
151// every existing share is worth more afterwards.
152//
153// It is how revenue, a grant or a member topping the pot up arrives. Anyone may
154// fund any crew, member or not: there is no way to refuse a transfer on this
155// chain anyway, and pretending otherwise would only move the donation to a bank
156// send the realm cannot account for.
157func Fund(cur realm, crewID int64) int64 {
158	// The two guards every payable call needs; see Create.
159	if !cur.IsCurrent() {
160		panic("spoofed realm: cur is not the live crossing frame")
161	}
162	if !cur.Previous().IsUserCall() {
163		panic("crews: paying in must be a direct user transaction")
164	}
165	who := cur.Previous().Address()
166	amount := envelope.RequireAtLeast(denom, 1)
167
168	if err := crews.Fund(store.ID(crewID), amount); err != nil {
169		panic(err.Error())
170	}
171	chain.Emit("Fund",
172		"crew", store.ID(crewID).String(),
173		"from", who.String(),
174		"amount", strconv.FormatInt(amount, 10),
175	)
176	return amount
177}
178
179// Propose opens an advisory question on a crew. Any member may propose, and it
180// stays open for crew.VoteBlocks blocks. Returns the proposal's id.
181//
182// A proposal EXECUTES NOTHING. Passing one records that the crew agreed by
183// share weight, and moves no coins, changes no membership and binds no code.
184// Executing a payout is the next step and it is not in v0.
185func Propose(cur realm, crewID int64, text string) int64 {
186	who := caller(cur)
187	pid, err := crews.Propose(store.ID(crewID), who, text, runtime.ChainHeight())
188	if err != nil {
189		panic(err.Error())
190	}
191	chain.Emit("Propose",
192		"crew", store.ID(crewID).String(),
193		"proposal", pid.String(),
194		"author", who.String(),
195	)
196	return int64(pid)
197}
198
199// Vote records the caller's ballot, weighted by the shares they hold at this
200// moment. One ballot per member, changeable while the proposal is open.
201func Vote(cur realm, proposalID int64, yes bool) {
202	who := caller(cur)
203	pid := store.ID(proposalID)
204	if err := crews.Vote(pid, who, yes, runtime.ChainHeight()); err != nil {
205		panic(err.Error())
206	}
207	chain.Emit("Vote",
208		"proposal", pid.String(),
209		"voter", who.String(),
210		"yes", strconv.FormatBool(yes),
211	)
212}
213
214// Close records a proposal as passed or failed once its deadline has gone by,
215// and returns which. Anyone may call it: closing is bookkeeping, not authority.
216func Close(cur realm, proposalID int64) bool {
217	pid := store.ID(proposalID)
218	passed, err := crews.Close(pid, runtime.ChainHeight())
219	if err != nil {
220		panic(err.Error())
221	}
222	chain.Emit("Close", "proposal", pid.String(), "passed", strconv.FormatBool(passed))
223	return passed
224}
225
226// Ragequit burns every share the caller holds, credits them their pro-rata
227// slice of the treasury and removes them from the crew. Returns the amount
228// credited, which [Withdraw] collects.
229//
230// Nobody has to agree. That is what makes a share mean something: the exit is
231// priced by the same number that votes, and a member outvoted on everything can
232// still leave with what they put in plus their part of what the crew built.
233func Ragequit(cur realm, crewID int64) int64 {
234	who := caller(cur)
235	id := store.ID(crewID)
236	shares, amount, err := crews.Ragequit(id, who)
237	if err != nil {
238		panic(err.Error())
239	}
240	chain.Emit("Ragequit",
241		"crew", id.String(),
242		"member", who.String(),
243		"shares", strconv.FormatInt(shares, 10),
244		"amount", strconv.FormatInt(amount, 10),
245	)
246	return amount
247}
248
249// Withdraw sends the caller everything they have been credited, and returns it.
250//
251// The credit is zeroed before the coins move, so a reentrant call finds nothing
252// left to take. Nothing in this realm ever pushes value: one address that
253// cannot be paid must not be able to wedge everybody else.
254func Withdraw(cur realm) int64 {
255	who := caller(cur)
256	amount, err := crews.Withdraw(who)
257	if err != nil {
258		panic(err.Error())
259	}
260
261	bnk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
262	bnk.SendCoins(cur.Address(), who, chain.NewCoins(chain.NewCoin(denom, amount)))
263
264	chain.Emit("Withdraw", "payee", who.String(), "amount", strconv.FormatInt(amount, 10))
265	return amount
266}
267
268// Get returns a crew's name, the height it was created at, how many members it
269// has, its total shares and its treasury in ugnot.
270//
271// Flat values rather than the crew itself: handing out a pointer to live state
272// is a mutation handle nothing checks, and a struct is not something a wallet
273// can decode anyway.
274func Get(crewID int64) (string, int64, int, int64, int64) {
275	c, ok := crews.Get(store.ID(crewID))
276	if !ok {
277		return "", 0, 0, 0, 0
278	}
279	return c.Name, c.CreatedAt, c.MemberCount(), c.TotalShares, c.Treasury
280}
281
282// Count is how many crews exist.
283func Count() int { return crews.Len() }
284
285// SharesOf is how many shares who holds in a crew.
286func SharesOf(crewID int64, who address) int64 {
287	c, _ := crews.Get(store.ID(crewID))
288	return c.SharesOf(who)
289}
290
291// MemberCount is how many people are in a crew.
292func MemberCount(crewID int64) int {
293	c, _ := crews.Get(store.ID(crewID))
294	return c.MemberCount()
295}
296
297// Treasury is what a crew holds, in ugnot. The coins themselves sit at this
298// realm's single address: see the package doc on custody.
299func Treasury(crewID int64) int64 {
300	c, _ := crews.Get(store.ID(crewID))
301	if c == nil {
302		return 0
303	}
304	return c.Treasury
305}
306
307// ValuePerShare is what one of a crew's shares is worth in ugnot, rounded down.
308func ValuePerShare(crewID int64) int64 {
309	c, _ := crews.Get(store.ID(crewID))
310	return c.ValuePerShare()
311}
312
313// ProposalOf returns a proposal's crew, author, text, deadline and whether it
314// has been closed and passed.
315func ProposalOf(proposalID int64) (int64, address, string, int64, bool, bool) {
316	p, ok := crews.Proposal(store.ID(proposalID))
317	if !ok {
318		return 0, "", "", 0, false, false
319	}
320	return int64(p.CrewID), p.Author, p.Text, p.Deadline, p.Closed, p.Passed
321}
322
323// Tally is a proposal's share-weighted yes and no.
324func Tally(proposalID int64) (int64, int64) {
325	p, ok := crews.Proposal(store.ID(proposalID))
326	if !ok {
327		return 0, 0
328	}
329	return p.Yes, p.No
330}
331
332// CreditOf is what an address can withdraw right now, in ugnot.
333func CreditOf(who address) int64 { return crews.CreditOf(who) }
334
335// TotalOwed is every outstanding credit, which is the part of this realm's
336// balance that belongs to people who have already left.
337func TotalOwed() int64 { return crews.TotalOwed() }
338
339// caller is the address that called us, checked the one way that is safe.
340func caller(cur realm) address {
341	if !cur.IsCurrent() {
342		panic("spoofed realm: cur is not the live crossing frame")
343	}
344	return cur.Previous().Address()
345}