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

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}