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}