1// Package bubblerumble is a last-bidder-wins game in GNOT.2//3// A pool has a pot and a clock. Every bid goes into the pot and resets the4// clock; the Nth bid costs BasePrice·√N. When the clock runs out, the last5// bidder takes the whole pot. Anyone can open a pool, and the coins sent with6// CreatePool seed its pot.7//8// Paying more than the price buys a shorter clock: k× the price gives that bid9// a clock of window/k, never below a tenth of the window. The whole amount goes10// into the pot, and the next bid runs the full window again.11package bubblerumble1213import (14 "chain"15 "chain/banker"16 "chain/runtime/unsafe"17 "math"18 "strconv"19 "strings"20 "time"21)2223const (24 MinWindow int64 = 10 // seconds25 MaxWindow int64 = 30 * 24 * 3600 // 30 days26 MinBasePrice int64 = 1_000 // ugnot, 0.001 GNOT27 MaxBasePrice int64 = 1_000_000_000_000 // ugnot, 1,000,000 GNOT28 MaxNameLen = 4029 HistoryLen = 50 // bids kept per pool for the feed30 MaxBoost int64 = 10 // the shortest clock a bid can buy is window/MaxBoost31)3233type Entry struct {34 N int6435 Bidder address36 Price int64 // what was paid; at least the price at the time37 Clock int64 // seconds this bid had to stand38 Pot int64 // pot right after this bid39 At time.Time40}4142type Pool struct {43 ID int6444 Name string45 Creator address46 Window int64 // seconds the last bid must stand to win47 BasePrice int64 // ugnot; the Nth bid costs BasePrice·√N48 Seed int6449 Pot int6450 Bids int6451 LastBidder address52 LastBidAt time.Time53 Clock int64 // seconds the current top bid has to stand: Window, or less if it paid for it54 CreatedAt time.Time55 Paid bool // the pot went to the winner, or the seed back to the creator56 Cancelled bool57 History []Entry58}5960// pkgPath must match gnomod.toml: the realm's own address derives from it.61const pkgPath = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble"6263var (64 pools []*Pool65 realmAddr = chain.PackageAddress(pkgPath)66)6768// Price of the next bid in a pool with the given base price and n bids so far.69// Sublinear in n: bid 1 costs base, bid 4 costs 2·base, bid 100 costs 10·base.70func Price(base, n int64) int64 {71 return int64(math.Ceil(float64(base) * math.Sqrt(float64(n+1))))72}7374// Deadline is when the pool closes if nobody else bids. Meaningless before the first bid.75func (p *Pool) Deadline() time.Time {76 return p.LastBidAt.Add(time.Duration(p.Clock) * time.Second)77}7879// ClockFor is the clock a bid buys by paying sent against a price: the window80// divided by the multiple paid, never below window/MaxBoost.81func ClockFor(window, price, sent int64) int64 {82 c := int64(float64(window) * float64(price) / float64(sent))83 if c < window/MaxBoost {84 c = window / MaxBoost85 }86 if c < 1 {87 c = 188 }89 return c90}9192// Over reports whether the clock has run out, which is the moment a pool has a winner.93func (p *Pool) Over() bool {94 return p.Bids > 0 && !time.Now().Before(p.Deadline())95}9697// NextPrice is what the next bid costs.98func (p *Pool) NextPrice() int64 {99 return Price(p.BasePrice, p.Bids)100}101102// CreatePool opens a pool. Any ugnot sent with the call seeds its pot.103// Returns the new pool's id.104func CreatePool(cur realm, name string, window int64, basePrice int64) int64 {105 creator := userCaller(cur)106 name = cleanName(name)107 if window < MinWindow || window > MaxWindow {108 panic("window must be between 10s and 30d")109 }110 if basePrice < MinBasePrice || basePrice > MaxBasePrice {111 panic("base price must be between 0.001 and 1,000,000 GNOT")112 }113 seed := ugnotSent()114 p := &Pool{115 ID: int64(len(pools)) + 1, Name: name, Creator: creator, Window: window,116 BasePrice: basePrice, Seed: seed, Pot: seed, CreatedAt: time.Now(),117 }118 pools = append(pools, p)119 chain.Emit("PoolCreated", "id", itoa(p.ID), "creator", creator.String(), "seed", itoa(seed))120 return p.ID121}122123// Bid pays at least the current price into a pool's pot and resets its clock;124// paying k× the price makes this bid's clock window/k (see ClockFor).125func Bid(cur realm, id int64) {126 bidder := userCaller(cur)127 p := pool(id)128 if p.Cancelled {129 panic("pool was cancelled")130 }131 if p.Over() {132 panic("pool is closed")133 }134 if p.LastBidder == bidder {135 panic("you are already the last bidder")136 }137 price := p.NextPrice()138 sent := ugnotSent()139 if sent < price {140 panic("send at least " + itoa(price) + "ugnot, got " + itoa(sent) + "ugnot")141 }142 p.Pot += sent143 p.Bids++144 p.LastBidder = bidder145 p.LastBidAt = time.Now()146 p.Clock = ClockFor(p.Window, price, sent)147 p.History = append(p.History, Entry{N: p.Bids, Bidder: bidder, Price: sent, Clock: p.Clock, Pot: p.Pot, At: p.LastBidAt})148 if len(p.History) > HistoryLen {149 p.History = p.History[len(p.History)-HistoryLen:]150 }151 chain.Emit("Bid", "id", itoa(id), "n", itoa(p.Bids), "bidder", bidder.String(), "paid", itoa(sent), "clock", itoa(p.Clock), "pot", itoa(p.Pot))152}153154// Claim sends a finished pool's pot to its winner. Anyone may call it.155func Claim(cur realm, id int64) {156 p := pool(id)157 if p.Cancelled {158 panic("pool was cancelled")159 }160 if !p.Over() {161 panic("clock still running")162 }163 if p.Paid {164 panic("already paid out")165 }166 p.Paid = true167 send(cur, p.LastBidder, p.Pot)168 chain.Emit("Paid", "id", itoa(id), "winner", p.LastBidder.String(), "pot", itoa(p.Pot))169}170171// Cancel lets the creator take the seed back from a pool nobody has bid on.172func Cancel(cur realm, id int64) {173 caller := userCaller(cur)174 p := pool(id)175 if p.Creator != caller {176 panic("only the creator can cancel")177 }178 if p.Bids > 0 {179 panic("pool already has bids")180 }181 if p.Cancelled {182 panic("already cancelled")183 }184 p.Cancelled = true185 p.Paid = true186 if p.Pot > 0 {187 send(cur, caller, p.Pot)188 }189 chain.Emit("Cancelled", "id", itoa(id))190}191192func send(cur realm, to address, ugnot int64) {193 banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins(realmAddr, to, chain.Coins{{"ugnot", ugnot}})194}195196func pool(id int64) *Pool {197 if id < 1 || id > int64(len(pools)) {198 panic("no such pool")199 }200 return pools[id-1]201}202203// userCaller is the signer of the transaction, refusing calls relayed through204// another realm so nobody can bid on someone else's behalf.205func userCaller(cur realm) address {206 prev := cur.Previous()207 if !prev.IsUserCall() {208 panic("must be a direct user call")209 }210 return prev.Address()211}212213func ugnotSent() int64 {214 sent := unsafe.OriginSend()215 for _, c := range sent {216 if c.Denom != "ugnot" {217 panic("only ugnot is accepted")218 }219 }220 return sent.AmountOf("ugnot")221}222223func cleanName(s string) string {224 s = strings.TrimSpace(s)225 if s == "" || len(s) > MaxNameLen {226 panic("name must be 1-40 characters")227 }228 for _, c := range s {229 if c < 0x20 || c == 0x7f {230 panic("name has control characters")231 }232 }233 return s234}235236func itoa(n int64) string { return strconv.FormatInt(n, 10) }237238func parseID(s string) int64 {239 n, err := strconv.ParseInt(s, 10, 64)240 if err != nil {241 panic("bad pool id")242 }243 return n244}245246// GNOT formats ugnot as GNOT with the trailing zeros trimmed.247func GNOT(ugnot int64) string {248 whole, frac := ugnot/1_000_000, ugnot%1_000_000249 if frac == 0 {250 return itoa(whole) + " GNOT"251 }252 f := strconv.FormatInt(1_000_000+frac, 10)[1:] // six digits, zero padded253 return itoa(whole) + "." + strings.TrimRight(f, "0") + " GNOT"254}255256func short(a address) string {257 s := a.String()258 if len(s) < 12 {259 return s260 }261 return s[:8] + "…" + s[len(s)-4:]262}263264func dur(sec int64) string {265 if sec <= 0 {266 return "0s"267 }268 d, h, m, s := sec/86400, sec%86400/3600, sec%3600/60, sec%60269 out := ""270 if d > 0 {271 out += itoa(d) + "d "272 }273 if d > 0 || h > 0 {274 out += itoa(h) + "h "275 }276 if d > 0 || h > 0 || m > 0 {277 out += itoa(m) + "m "278 }279 return out + itoa(s) + "s"280}281282// --- rendering ---------------------------------------------------------------283284// Render serves gnoweb ("" and "<id>") and the web page ("json" and "json/<id>").285func Render(path string) string {286 path = strings.Trim(path, "/")287 switch {288 case path == "json":289 return jsonAll()290 case strings.HasPrefix(path, "json/"):291 return pool(parseID(path[5:])).json(true)292 case path == "":293 return markdownAll()294 default:295 return pool(parseID(path)).markdown()296 }297}298299func unix(t time.Time) string {300 if t.IsZero() {301 return "0"302 }303 return itoa(t.Unix())304}305306func jbool(b bool) string {307 if b {308 return "true"309 }310 return "false"311}312313func (p *Pool) json(withHistory bool) string {314 var b strings.Builder315 b.WriteString("{\"id\":" + itoa(p.ID))316 b.WriteString(",\"name\":" + strconv.Quote(p.Name))317 b.WriteString(",\"creator\":\"" + p.Creator.String() + "\"")318 b.WriteString(",\"window\":" + itoa(p.Window))319 b.WriteString(",\"basePrice\":" + itoa(p.BasePrice))320 b.WriteString(",\"seed\":" + itoa(p.Seed))321 b.WriteString(",\"pot\":" + itoa(p.Pot))322 b.WriteString(",\"bids\":" + itoa(p.Bids))323 b.WriteString(",\"nextPrice\":" + itoa(p.NextPrice()))324 b.WriteString(",\"lastBidder\":\"" + p.LastBidder.String() + "\"")325 b.WriteString(",\"lastBidAt\":" + unix(p.LastBidAt))326 b.WriteString(",\"clock\":" + itoa(p.Clock))327 b.WriteString(",\"createdAt\":" + unix(p.CreatedAt))328 b.WriteString(",\"over\":" + jbool(p.Over()))329 b.WriteString(",\"paid\":" + jbool(p.Paid))330 b.WriteString(",\"cancelled\":" + jbool(p.Cancelled))331 if withHistory {332 b.WriteString(",\"history\":[")333 for i := len(p.History) - 1; i >= 0; i-- { // newest first334 h := p.History[i]335 if i < len(p.History)-1 {336 b.WriteString(",")337 }338 b.WriteString("{\"n\":" + itoa(h.N) + ",\"bidder\":\"" + h.Bidder.String() + "\",\"price\":" + itoa(h.Price) + ",\"clock\":" + itoa(h.Clock) + ",\"pot\":" + itoa(h.Pot) + ",\"at\":" + unix(h.At) + "}")339 }340 b.WriteString("]")341 }342 b.WriteString("}")343 return b.String()344}345346func jsonAll() string {347 var b strings.Builder348 b.WriteString("{\"now\":" + itoa(time.Now().Unix()) + ",\"realm\":\"" + pkgPath + "\",\"pools\":[")349 for i, p := range pools {350 if i > 0 {351 b.WriteString(",")352 }353 b.WriteString(p.json(false))354 }355 b.WriteString("]}")356 return b.String()357}358359func (p *Pool) status() string {360 switch {361 case p.Cancelled:362 return "cancelled"363 case p.Over():364 s := "won by " + short(p.LastBidder)365 if !p.Paid {366 s += " (unclaimed)"367 }368 return s369 case p.Bids == 0:370 return "no bids yet · " + dur(p.Window) + " window · next " + GNOT(p.NextPrice())371 default:372 left := p.Deadline().Unix() - time.Now().Unix()373 s := dur(left) + " left"374 if p.Clock < p.Window {375 s += " of a " + dur(p.Clock) + " clock"376 }377 return s + " · " + short(p.LastBidder) + " on top · next " + GNOT(p.NextPrice())378 }379}380381func markdownAll() string {382 var b strings.Builder383 b.WriteString("# Bubble Rumble\n\n")384 b.WriteString("Every bid goes into a pool's pot and resets its clock. The Nth bid costs base·√N. ")385 b.WriteString("When the clock runs out, the last bidder takes the whole pot. Play at [bubblerumble.net](https://bubblerumble.net).\n\n")386 if len(pools) == 0 {387 b.WriteString("_No pools yet._\n")388 return b.String()389 }390 b.WriteString("## Pools\n\n")391 for i := len(pools) - 1; i >= 0; i-- {392 p := pools[i]393 b.WriteString("- [" + p.Name + "](?" + itoa(p.ID) + ") — **" + GNOT(p.Pot) + "** · " + itoa(p.Bids) + " bids · " + p.status() + "\n")394 }395 return b.String()396}397398func (p *Pool) markdown() string {399 var b strings.Builder400 b.WriteString("# " + p.Name + "\n\n")401 b.WriteString("**" + GNOT(p.Pot) + "** in the pot · " + p.status() + "\n\n")402 b.WriteString("Opened by " + short(p.Creator) + " · " + dur(p.Window) + " window · base " + GNOT(p.BasePrice) + " · seed " + GNOT(p.Seed) + "\n\n")403 if len(p.History) > 0 {404 b.WriteString("## Bids\n\n")405 for i := len(p.History) - 1; i >= 0; i-- {406 h := p.History[i]407 b.WriteString("- #" + itoa(h.N) + " " + short(h.Bidder) + " paid " + GNOT(h.Price) + " → pot " + GNOT(h.Pot) + "\n")408 }409 }410 return b.String()411}412Bid(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}, id int64)
Cancel(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}, id int64)
Claim(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}, id int64)
ClockFor(window int64, price int64, sent int64) int64
CreatePool(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}, name string, window int64, basePrice int64) int64
GNOT(ugnot int64) string
Price(base int64, n int64) int64
Render(path string) string
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.