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 to4// nobody else: the token's SUB-realm for the kind published here, `treasury:spend`. A sub-realm is a distinct5// package path (`…/bubble#treasury-spend`) with its own address; it can hold and send coins and can never issue6// tokens. The token's governor hands exactly that sub-realm to this kind's Do when the holders have decided a7// 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 turning9// 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 the12// treasury is (Address), how much it holds (Balance) and renders the terms. The kind's Do runs inside the token13// 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 bubbletreasury1617import (18 "chain"19 "chain/banker"20 "chain/runtime"21 "strconv"22 "strings"23 "time"2425 governor "gno.land/p/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/governor/v0"26 "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubble"27)2829const (30 bubblePath = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubble"31 ownPath = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubbletreasury"3233 // 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"3738 one = int64(1_000_000) // ugnot in one GNOT3940 // MaxSpend is the most one proposal may move, in whole GNOT — and never more than a QUARTER of what the treasury41 // holds (MaxShare), or SmallTreasury whole GNOT if that is more (so a small treasury can be spent whole, and a42 // treasury just over it has no cliff). The cap bounds one question; the PACE is the window: a window opens at43 // the first spend after Window seconds of none, and the spends in the Window seconds from that first spend44 // together may move at most that same quarter of what the treasury held then (spentAt/windowBase below); the45 // next spend after those seconds opens the next window. So "a quarter per two-day window" is true in code: a46 // 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 window51)5253// Offer, at deploy: the kind is published in the same transaction that stores this realm, so there is no window54// in which somebody else can bind the name first (r/bubble's Offer is open to anyone and a name, once taken, is55// taken for good). If the name IS taken by then, the deploy fails as a whole and this realm is redeployed under a56// 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 = true60}6162// poolRealms are the game's realms: a spend to one of their addresses would strand the coins (a realm's balance is63// 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}7172var (73 // treasury is the address the pools pay: the token realm's sub-realm for the kind. Pure arithmetic on the two74 // paths (chain.DerivePkgSubAddr), the same the VM does when the token realm runs the kind.75 treasury = chain.DerivePkgSubAddr(bubblePath, subpath)76 offered bool77 // spentAt is the height of the last spend: ONE spend per block. The governor's own govern:batch runs every78 // member of a decided batch in one transaction, and each member's Check sees the balance before any of them79 // ran — so a ladder of quarter-sized lines (25, 18, 14, 10 … of 100) would pass every cap and drain the80 // treasury in one vote. A second spend in the block a spend already landed in aborts instead (Do panics: the81 // transaction unwinds, nothing moves, the proposal stays decided and runs in a later block); a batch of two82 // spends therefore never runs at all. So "a quarter at a time" means a quarter per vote, as it should.83 spentAt int6484 spends int64 // spends executed, ever85 spent int64 // ugnot moved out, ever86 // the window: opened by the first spend after Window seconds of none; what it may move is the cap for what the87 // treasury held then (windowBase), and windowSpent is what it has moved so far88 windowAt int64 // block time the window opened89 windowBase int64 // ugnot held when it opened, before its first spend90 windowSpent int64 // ugnot moved in it91)9293// 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()) * one98 }99 left := maxFor(windowBase)*one - windowSpent100 if left < 0 {101 return 0102 }103 return left104}105106// 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 }110111// noteSpend is the kind's way to write here: Do runs inside the token realm, which cannot touch this realm's112// variables, so it crosses back with the sub-realm token it was handed — and only that token is let in113// (its address is the treasury). Called BEFORE the coins move; a second call in one block panics so the114// 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 send126 }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 += amt131 spentAt, spends, spent = h, spends+1, spent+amt132}133134// Address is where the royalty goes. Every pool version from v5 pays here; anyone may send here.135func Address() address { return treasury }136137// Balance is what the treasury holds right now, in ugnot.138func Balance() int64 { return banker.NewReadonlyBanker().GetCoins(treasury).AmountOf("ugnot") }139140// Offered is whether the kind has been published to the token's governor.141func Offered() bool { return offered }142143// Offer is the same publication for a realm loaded without its init having run it (a test harness): it does144// nothing once the token's governor knows the name. The holders still have to adopt the kind by vote before a spend145// can be proposed.146func Offer(cur realm) {147 if _, known := bubble.KindRules(KindName); known {148 offered = true149 return150 }151 bubble.Offer(cross(cur), spendKind{}, governor.BootstrapRules())152 offered = true153}154155// MaxNow is the most one proposal may move right now, in whole GNOT: the cap for a balance.156func MaxNow() int64 { return maxFor(Balance()) }157158func maxFor(balance int64) int64 {159 whole := balance / one160 m := whole / MaxShare161 if m < SmallTreasury {162 m = minInt(whole, SmallTreasury) // a small treasury may go whole; just over it, the same forty — no cliff163 }164 return minInt(m, MaxSpend)165}166167func minInt(a, b int64) int64 {168 if a < b {169 return a170 }171 return b172}173174// 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 s182}183184// ---- the kind185186type spendKind struct{}187188func (spendKind) Name() string { return KindName }189190// 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: " + p195 }196 return "send " + strconv.FormatInt(n, 10) + " GNOT from the BUBBLE treasury to " + to.String()197}198199// 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 err204 }205 return within(n)206}207208// 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 nil218}219220// Do moves the coins. rlm is the token realm's sub-realm for this kind, handed over by its governor for this one221// decided proposal: its address is the treasury, and RealmSend from it is the only send there is. An error ends222// 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 err227 }228 if rlm.Address() != treasury {229 return errf("that is not the treasury's realm") // a governor other than the token's ran this kind230 }231 if err := within(n); err != nil {232 return err233 }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 nil239}240241// 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 good255 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, nil264}265266type treasuryErr struct{ msg string }267268func (e *treasuryErr) Error() string { return "treasury: " + e.msg }269func errf(s string) error { return &treasuryErr{s} }270271// gnot prints ugnot as GNOT with up to six decimals, trailing zeros dropped.272func gnot(u int64) string {273 whole, frac := u/one, u%one274 if frac == 0 {275 return strconv.FormatInt(whole, 10)276 }277 f := strconv.FormatInt(frac, 10)278 for len(f) < 6 {279 f = "0" + f280 }281 return strconv.FormatInt(whole, 10) + "." + strings.TrimRight(f, "0")282}283284// 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.Builder290 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}303Address() string
Balance() int64
MaxNow() int64
Offer(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
Offered() bool
Preview(payload string) string
Render(path string) string
Spends() int64
Spent() int64
SpentAt() int64
WindowLeft() int64
Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.
vm/qrender output, sanitized (docs/render-security.md) and displayed in an empty-sandbox iframe — scripts, forms and popups cannot run. Links stay inert in-preview; right-click to open.