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}