treasury.gno
15.46 Kb · 302 lines
1// The BUBBLE treasury: where the pools' royalty lands, and the one power that moves it.
2//
3// Bubble Rumble's pools (v5 on) send 5 % of every boop to an address that belongs to the BUBBLE token realm and to
4// nobody else: the token's SUB-realm for the kind published here, `treasury:spend`. A sub-realm is a distinct
5// package path (`…/bubble#treasury-spend`) with its own address; it can hold and send coins and can never issue
6// tokens. The token's governor hands exactly that sub-realm to this kind's Do when the holders have decided a
7// spend, and to nothing else — so the only way a coin leaves the treasury is a proposal under `treasury:spend`,
8// adopted by the holders (govern:adopt) and passed on the token's own money terms: a fifth of the supply turning
9// out, two thirds yes, a week to vote, two days' delay.
10//
11// This realm holds no coins and no power of its own. It publishes the kind (Offer, at its deploy), says where the
12// treasury is (Address), how much it holds (Balance) and renders the terms. The kind's Do runs inside the token
13// realm and crosses back here with the sub-realm's own token to note each spend (noteSpend: the pace lives here);
14// nothing else writes here, and what was spent is also on the chain (the TreasurySpend event).
15package bubbletreasury
16
17import (
18 "chain"
19 "chain/banker"
20 "chain/runtime"
21 "strconv"
22 "strings"
23 "time"
24
25 governor "gno.land/p/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/governor/v0"
26 "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubble"
27)
28
29const (
30 bubblePath = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubble"
31 ownPath = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubbletreasury"
32
33 // KindName is what the holders adopt and propose under. The governor derives the sub-realm's path from it:
34 // lower-cased, every character outside [a-z0-9] turned into '-' — so `treasury:spend` lives at `treasury-spend`.
35 KindName = "treasury:spend"
36 subpath = "treasury-spend"
37
38 one = int64(1_000_000) // ugnot in one GNOT
39
40 // MaxSpend is the most one proposal may move, in whole GNOT — and never more than a QUARTER of what the treasury
41 // holds (MaxShare), or SmallTreasury whole GNOT if that is more (so a small treasury can be spent whole, and a
42 // treasury just over it has no cliff). The cap bounds one question; the PACE is the window: a window opens at
43 // the first spend after Window seconds of none, and the spends in the Window seconds from that first spend
44 // together may move at most that same quarter of what the treasury held then (spentAt/windowBase below); the
45 // next spend after those seconds opens the next window. So "a quarter per two-day window" is true in code: a
46 // majority that wants it all has to say so in public, and wait, several times over.
47 MaxSpend = int64(100_000)
48 MaxShare = int64(4) // a proposal, and a window, may move at most balance/MaxShare…
49 SmallTreasury = int64(40) // …or this many whole GNOT, whichever is more (up to the balance)
50 Window = int64(2 * 24 * 60 * 60) // seconds: the pace — one quarter per window
51)
52
53// Offer, at deploy: the kind is published in the same transaction that stores this realm, so there is no window
54// in which somebody else can bind the name first (r/bubble's Offer is open to anyone and a name, once taken, is
55// taken for good). If the name IS taken by then, the deploy fails as a whole and this realm is redeployed under a
56// name that maps to the same sub-path (see Recovery in the plan) — nothing has been paid to the address yet.
57func init(cur realm) {
58 bubble.Offer(cross(cur), spendKind{}, governor.BootstrapRules())
59 offered = true
60}
61
62// poolRealms are the game's realms: a spend to one of their addresses would strand the coins (a realm's balance is
63// not a bubble's pot), and "seed a bubble" is exactly the spend the holders will think of first.
64var poolRealms = []string{
65 "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble",
66 "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble2",
67 "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble3",
68 "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble4",
69 "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble5",
70}
71
72var (
73 // treasury is the address the pools pay: the token realm's sub-realm for the kind. Pure arithmetic on the two
74 // paths (chain.DerivePkgSubAddr), the same the VM does when the token realm runs the kind.
75 treasury = chain.DerivePkgSubAddr(bubblePath, subpath)
76 offered bool
77 // spentAt is the height of the last spend: ONE spend per block. The governor's own govern:batch runs every
78 // member of a decided batch in one transaction, and each member's Check sees the balance before any of them
79 // ran — so a ladder of quarter-sized lines (25, 18, 14, 10 … of 100) would pass every cap and drain the
80 // treasury in one vote. A second spend in the block a spend already landed in aborts instead (Do panics: the
81 // transaction unwinds, nothing moves, the proposal stays decided and runs in a later block); a batch of two
82 // spends therefore never runs at all. So "a quarter at a time" means a quarter per vote, as it should.
83 spentAt int64
84 spends int64 // spends executed, ever
85 spent int64 // ugnot moved out, ever
86 // the window: opened by the first spend after Window seconds of none; what it may move is the cap for what the
87 // treasury held then (windowBase), and windowSpent is what it has moved so far
88 windowAt int64 // block time the window opened
89 windowBase int64 // ugnot held when it opened, before its first spend
90 windowSpent int64 // ugnot moved in it
91)
92
93// WindowLeft is what may still move before the window closes, in ugnot — the whole cap for a window not open.
94func WindowLeft() int64 {
95 now := time.Now().Unix()
96 if windowAt == 0 || now-windowAt >= Window {
97 return maxFor(Balance()) * one
98 }
99 left := maxFor(windowBase)*one - windowSpent
100 if left < 0 {
101 return 0
102 }
103 return left
104}
105
106// SpentAt is the height of the last spend; Spends and Spent, the count and the sum — the realm's own log.
107func SpentAt() int64 { return spentAt }
108func Spends() int64 { return spends }
109func Spent() int64 { return spent }
110
111// noteSpend is the kind's way to write here: Do runs inside the token realm, which cannot touch this realm's
112// variables, so it crosses back with the sub-realm token it was handed — and only that token is let in
113// (its address is the treasury). Called BEFORE the coins move; a second call in one block panics so the
114// transaction, and with it any batch, unwinds with nothing moved.
115func noteSpend(cur realm, amt int64) {
116 if cur.Previous().Address() != treasury {
117 panic("treasury: only the treasury's own kind may note a spend")
118 }
119 h := runtime.ChainHeight()
120 if h == spentAt {
121 panic("treasury: one spend per block — this block already has one; run it again in the next")
122 }
123 now := time.Now().Unix()
124 if windowAt == 0 || now-windowAt >= Window {
125 windowAt, windowBase, windowSpent = now, Balance(), 0 // Balance() is read before this spend's send
126 }
127 if windowSpent+amt > maxFor(windowBase)*one {
128 panic("treasury: this window (" + strconv.FormatInt(Window/86400, 10) + " days) has moved its quarter already; run it again when the window has passed")
129 }
130 windowSpent += amt
131 spentAt, spends, spent = h, spends+1, spent+amt
132}
133
134// Address is where the royalty goes. Every pool version from v5 pays here; anyone may send here.
135func Address() address { return treasury }
136
137// Balance is what the treasury holds right now, in ugnot.
138func Balance() int64 { return banker.NewReadonlyBanker().GetCoins(treasury).AmountOf("ugnot") }
139
140// Offered is whether the kind has been published to the token's governor.
141func Offered() bool { return offered }
142
143// Offer is the same publication for a realm loaded without its init having run it (a test harness): it does
144// nothing once the token's governor knows the name. The holders still have to adopt the kind by vote before a spend
145// can be proposed.
146func Offer(cur realm) {
147 if _, known := bubble.KindRules(KindName); known {
148 offered = true
149 return
150 }
151 bubble.Offer(cross(cur), spendKind{}, governor.BootstrapRules())
152 offered = true
153}
154
155// MaxNow is the most one proposal may move right now, in whole GNOT: the cap for a balance.
156func MaxNow() int64 { return maxFor(Balance()) }
157
158func maxFor(balance int64) int64 {
159 whole := balance / one
160 m := whole / MaxShare
161 if m < SmallTreasury {
162 m = minInt(whole, SmallTreasury) // a small treasury may go whole; just over it, the same forty — no cliff
163 }
164 return minInt(m, MaxSpend)
165}
166
167func minInt(a, b int64) int64 {
168 if a < b {
169 return a
170 }
171 return b
172}
173
174// Preview is what a proposal with this payload would say, and whether it could open now.
175func Preview(payload string) string {
176 k := spendKind{}
177 s := k.Describe(payload)
178 if err := k.Check(payload); err != nil {
179 return s + " — refused: " + err.Error()
180 }
181 return s
182}
183
184// ---- the kind
185
186type spendKind struct{}
187
188func (spendKind) Name() string { return KindName }
189
190// Describe reads the payload alone: two voters at different heights must see the same question.
191func (spendKind) Describe(p string) string {
192 to, n, err := parse(p)
193 if err != nil {
194 return "spend from the BUBBLE treasury: " + p
195 }
196 return "send " + strconv.FormatInt(n, 10) + " GNOT from the BUBBLE treasury to " + to.String()
197}
198
199// Check runs when the proposal opens and again right before Do: the amount must be within the cap both times.
200func (spendKind) Check(p string) error {
201 _, n, err := parse(p)
202 if err != nil {
203 return err
204 }
205 return within(n)
206}
207
208// within is the cap for the balance as it stands: the whole of a small treasury, a quarter of a large one.
209func within(n int64) error {
210 have := Balance()
211 if n*one > have {
212 return errf("the treasury holds " + gnot(have) + " GNOT, not " + strconv.FormatInt(n, 10))
213 }
214 if m := maxFor(have); n > m {
215 return errf("one proposal may move at most " + strconv.FormatInt(m, 10) + " GNOT of the " + gnot(have) + " held (a quarter, or " + strconv.FormatInt(SmallTreasury, 10) + " whole GNOT if that is more)")
216 }
217 return nil
218}
219
220// Do moves the coins. rlm is the token realm's sub-realm for this kind, handed over by its governor for this one
221// decided proposal: its address is the treasury, and RealmSend from it is the only send there is. An error ends
222// the proposal as failed with nothing moved; nothing here can half-finish.
223func (spendKind) Do(_ int, rlm realm, p string) error {
224 to, n, err := parse(p)
225 if err != nil {
226 return err
227 }
228 if rlm.Address() != treasury {
229 return errf("that is not the treasury's realm") // a governor other than the token's ran this kind
230 }
231 if err := within(n); err != nil {
232 return err
233 }
234 amt, have := n*one, Balance()
235 noteSpend(cross(rlm), amt) // one spend per block: a batch of spends aborts here (see spentAt)
236 banker.NewBanker(banker.BankerTypeRealmSend, rlm).SendCoins(treasury, to, chain.Coins{{"ugnot", amt}})
237 chain.Emit("TreasurySpend", "to", to.String(), "ugnot", strconv.FormatInt(amt, 10), "left", strconv.FormatInt(have-amt, 10))
238 return nil
239}
240
241// parse reads "g1… N": an address and whole GNOT, 1 ≤ N ≤ MaxSpend.
242func parse(p string) (address, int64, error) {
243 parts := strings.Fields(p)
244 if len(parts) != 2 {
245 return "", 0, errf("expected \"g1… N\": an address and whole GNOT")
246 }
247 to := address(parts[0])
248 if !to.IsValid() {
249 return "", 0, errf("not an address")
250 }
251 if to == treasury || to == chain.PackageAddress(bubblePath) || to == chain.PackageAddress(ownPath) {
252 return "", 0, errf("that is a realm of the token's own: coins sent there could never come back")
253 }
254 for _, r := range poolRealms { // a pool realm's address takes coins into no pool: they would sit there for good
255 if to == chain.PackageAddress(r) {
256 return "", 0, errf("that is a pool realm's own address: coins sent there join no bubble and never come back (seed a bubble with CreatePool, from a wallet)")
257 }
258 }
259 n, err := strconv.ParseInt(parts[1], 10, 64)
260 if err != nil || n <= 0 || n > MaxSpend {
261 return "", 0, errf("expected whole GNOT, between 1 and " + strconv.FormatInt(MaxSpend, 10) + " in one proposal")
262 }
263 return to, n, nil
264}
265
266type treasuryErr struct{ msg string }
267
268func (e *treasuryErr) Error() string { return "treasury: " + e.msg }
269func errf(s string) error { return &treasuryErr{s} }
270
271// gnot prints ugnot as GNOT with up to six decimals, trailing zeros dropped.
272func gnot(u int64) string {
273 whole, frac := u/one, u%one
274 if frac == 0 {
275 return strconv.FormatInt(whole, 10)
276 }
277 f := strconv.FormatInt(frac, 10)
278 for len(f) < 6 {
279 f = "0" + f
280 }
281 return strconv.FormatInt(whole, 10) + "." + strings.TrimRight(f, "0")
282}
283
284// Render: the treasury for a reader — where, how much, and how it moves.
285func Render(path string) string {
286 if path == "json" {
287 return `{"address":"` + treasury.String() + `","ugnot":` + strconv.FormatInt(Balance(), 10) + `,"kind":"` + KindName + `","offered":` + strconv.FormatBool(offered) + `,"maxSpend":` + strconv.FormatInt(MaxSpend, 10) + `,"maxNow":` + strconv.FormatInt(MaxNow(), 10) + `,"windowLeft":` + strconv.FormatInt(WindowLeft(), 10) + `,"spends":` + strconv.FormatInt(spends, 10) + `,"spent":` + strconv.FormatInt(spent, 10) + `}`
288 }
289 var b strings.Builder
290 b.WriteString("# The BUBBLE treasury\n\n")
291 b.WriteString("**" + gnot(Balance()) + " GNOT** at `" + treasury.String() + "` — the token realm's own sub-realm `" + subpath + "`.\n\n")
292 b.WriteString("Every Bubble Rumble pool from v5 on sends **5% of every boop's bid** here (not of a bubble's seed, not of shots). Nothing leaves except by a decided vote of the BUBBLE holders under `" + KindName + "`")
293 if offered {
294 b.WriteString(" (published to the token's governor; the holders adopt it with `govern:adopt`, then anyone may propose `\"g1… N\"` — an address and whole GNOT)")
295 } else {
296 b.WriteString(" (not yet published: call `Offer` once)")
297 }
298 b.WriteString(". A proposal may move at most a quarter of what the treasury holds, or " + strconv.FormatInt(SmallTreasury, 10) + " GNOT if that is more, never over " + strconv.FormatInt(MaxSpend, 10) + " — right now " + strconv.FormatInt(MaxNow(), 10) + " GNOT; and the spends in a window — the " + strconv.FormatInt(Window/86400, 10) + " days from its first spend — together may move that same quarter (or " + strconv.FormatInt(SmallTreasury, 10) + ") of what it held then (" + gnot(WindowLeft()) + " GNOT left in the window now); the next spend after those days opens the next window; one spend a block, so a batch of spends cannot run. A quarter per two-day window, whatever is proposed. Only the holders' votes move money, on the token's money terms: a fifth of the supply turning out (this kind takes the plain fifth; the token's own settings raise their bar with past turnout), two thirds yes, a week to vote (or sooner, once the outcome cannot change), two days' delay, a fortnight to run it. Votes count what a wallet held at the last sealed epoch before the question opened. Today the founding share (10,000,000 of the BUBBLE in issue) carries every vote; that thins as referrals mint.\n\n")
299 b.WriteString("BUBBLE carries a vote, not a claim: nothing here promises holders a payment from the treasury, and the holders' custom is prizes, work on the game and the token's market. Spends so far: " + strconv.FormatInt(spends, 10) + ", " + gnot(spent) + " GNOT.\n\n")
300 b.WriteString("Replacing this kind: a kind under another name that the governor maps to the same sub-path `" + subpath + "` (for example `treasury/spend` or `treasury.spend`) spends from the same address once adopted; a name that maps elsewhere is a new, empty treasury. Anyone may offer a look-alike name: before adopting one, read who offered it (the KindOffered event) and its code.\n")
301 return b.String()
302}