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

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}