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.

bubblerumble.gno

11.56 Kb · 411 lines
  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 the
  4// clock; the Nth bid costs BasePrice·√N. When the clock runs out, the last
  5// bidder takes the whole pot. Anyone can open a pool, and the coins sent with
  6// CreatePool seed its pot.
  7//
  8// Paying more than the price buys a shorter clock: k× the price gives that bid
  9// a clock of window/k, never below a tenth of the window. The whole amount goes
 10// into the pot, and the next bid runs the full window again.
 11package bubblerumble
 12
 13import (
 14	"chain"
 15	"chain/banker"
 16	"chain/runtime/unsafe"
 17	"math"
 18	"strconv"
 19	"strings"
 20	"time"
 21)
 22
 23const (
 24	MinWindow    int64 = 10                // seconds
 25	MaxWindow    int64 = 30 * 24 * 3600    // 30 days
 26	MinBasePrice int64 = 1_000             // ugnot, 0.001 GNOT
 27	MaxBasePrice int64 = 1_000_000_000_000 // ugnot, 1,000,000 GNOT
 28	MaxNameLen         = 40
 29	HistoryLen         = 50 // bids kept per pool for the feed
 30	MaxBoost     int64 = 10 // the shortest clock a bid can buy is window/MaxBoost
 31)
 32
 33type Entry struct {
 34	N      int64
 35	Bidder address
 36	Price  int64 // what was paid; at least the price at the time
 37	Clock  int64 // seconds this bid had to stand
 38	Pot    int64 // pot right after this bid
 39	At     time.Time
 40}
 41
 42type Pool struct {
 43	ID         int64
 44	Name       string
 45	Creator    address
 46	Window     int64 // seconds the last bid must stand to win
 47	BasePrice  int64 // ugnot; the Nth bid costs BasePrice·√N
 48	Seed       int64
 49	Pot        int64
 50	Bids       int64
 51	LastBidder address
 52	LastBidAt  time.Time
 53	Clock      int64 // seconds the current top bid has to stand: Window, or less if it paid for it
 54	CreatedAt  time.Time
 55	Paid       bool // the pot went to the winner, or the seed back to the creator
 56	Cancelled  bool
 57	History    []Entry
 58}
 59
 60// pkgPath must match gnomod.toml: the realm's own address derives from it.
 61const pkgPath = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble"
 62
 63var (
 64	pools     []*Pool
 65	realmAddr = chain.PackageAddress(pkgPath)
 66)
 67
 68// 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}
 73
 74// 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}
 78
 79// ClockFor is the clock a bid buys by paying sent against a price: the window
 80// 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 / MaxBoost
 85	}
 86	if c < 1 {
 87		c = 1
 88	}
 89	return c
 90}
 91
 92// 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}
 96
 97// NextPrice is what the next bid costs.
 98func (p *Pool) NextPrice() int64 {
 99	return Price(p.BasePrice, p.Bids)
100}
101
102// 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.ID
121}
122
123// 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 += sent
143	p.Bids++
144	p.LastBidder = bidder
145	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}
153
154// 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 = true
167	send(cur, p.LastBidder, p.Pot)
168	chain.Emit("Paid", "id", itoa(id), "winner", p.LastBidder.String(), "pot", itoa(p.Pot))
169}
170
171// 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 = true
185	p.Paid = true
186	if p.Pot > 0 {
187		send(cur, caller, p.Pot)
188	}
189	chain.Emit("Cancelled", "id", itoa(id))
190}
191
192func send(cur realm, to address, ugnot int64) {
193	banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins(realmAddr, to, chain.Coins{{"ugnot", ugnot}})
194}
195
196func pool(id int64) *Pool {
197	if id < 1 || id > int64(len(pools)) {
198		panic("no such pool")
199	}
200	return pools[id-1]
201}
202
203// userCaller is the signer of the transaction, refusing calls relayed through
204// 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}
212
213func 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}
222
223func 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 s
234}
235
236func itoa(n int64) string { return strconv.FormatInt(n, 10) }
237
238func parseID(s string) int64 {
239	n, err := strconv.ParseInt(s, 10, 64)
240	if err != nil {
241		panic("bad pool id")
242	}
243	return n
244}
245
246// 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_000
249	if frac == 0 {
250		return itoa(whole) + " GNOT"
251	}
252	f := strconv.FormatInt(1_000_000+frac, 10)[1:] // six digits, zero padded
253	return itoa(whole) + "." + strings.TrimRight(f, "0") + " GNOT"
254}
255
256func short(a address) string {
257	s := a.String()
258	if len(s) < 12 {
259		return s
260	}
261	return s[:8] + "…" + s[len(s)-4:]
262}
263
264func 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%60
269	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}
281
282// --- rendering ---------------------------------------------------------------
283
284// 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}
298
299func unix(t time.Time) string {
300	if t.IsZero() {
301		return "0"
302	}
303	return itoa(t.Unix())
304}
305
306func jbool(b bool) string {
307	if b {
308		return "true"
309	}
310	return "false"
311}
312
313func (p *Pool) json(withHistory bool) string {
314	var b strings.Builder
315	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 first
334			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}
345
346func jsonAll() string {
347	var b strings.Builder
348	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}
358
359func (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 s
369	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}
380
381func markdownAll() string {
382	var b strings.Builder
383	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}
397
398func (p *Pool) markdown() string {
399	var b strings.Builder
400	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}