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}