Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}