bubblerumble3.gno
33.66 Kb · 1049 lines
1// Package bubblerumble3 is the third Bubble Rumble realm. v2's money stays:
2// of every bid 55% goes to the pot, 30% is paid at once to the pool's earlier
3// bidders pro-rata to what they paid in, 10% to whoever invited the bidder,
4// 5% to the pool's creator. What v3 adds:
5//
6// - A last day. Every pool has a life (creator-set, 1h..30d) that starts at
7// its first bid. The pool closes when its clock runs out OR its life does,
8// whichever comes first. While the life runs out, the winner's share slides
9// toward nothing and that part of the pot is instead split among all the
10// pool's bidders pro-rata to what they paid in. A pool still contested on
11// its last day ends as a shared pot, so nobody is stuck in a loop and bots
12// gain nothing by outlasting people.
13// - Flag war. At the close, TeamShare of the pot goes to the OTHER bidders
14// who flew the winner's flag, pro-rata by paid-in; the winner never takes
15// it. A winner whose flag nobody else flew hands it to all the other
16// bidders instead, so a private flag buys nothing. A flag is locked per
17// pool at the first bid there. The winner's own part is what slides.
18// - Rebidding on yourself is allowed (it pays the others as any bid does).
19// - Earnings are credited, not pushed: dividends, team shares and the
20// close split accrue to each address and Withdraw sends them, so no bid or
21// close ever loops over a hundred transfers, and nothing can brick a pool.
22// - A bid's clock can be shortened by paying more, but never under MinClock,
23// so paying up is never a way to close a pool before anyone can answer.
24// - Shots. A bid may carry extra money as a shot at the bubble: a chance to
25// pop it at once. The chance is 0.8 × shot / prize, at most 1% a shot,
26// where the prize is the pot or ShotCap, whichever is smaller; a pot over
27// ShotCap pays a ShotCap slice instead of popping. The shot goes into the
28// pot, hit or miss, and every miss is counted on the bubble.
29//
30// The draw is the chain's block time, which on tm2 is the median of the
31// validators' vote timestamps for the block before: public one block
32// ahead, and nudgeable by a validator who is the median vote. Two things
33// follow. A shot is drawn only in the blocks ShotDelay to ShotDelay+
34// ShotWindow after it was paid for, by whoever touches the pool then, and
35// is void after that: a shooter cannot wait for a block whose time suits
36// them, and the two blocks of leeway are worth less than the bid a shot
37// must ride on. And the prize is capped at ShotCap, small enough that a
38// validator grinding a timestamp is stealing lunch money, with SetShots as
39// the switch if one does. Raise the cap when gno.land has real randomness.
40package bubblerumble3
41
42import (
43 "chain"
44 "chain/banker"
45 "chain/runtime"
46 "chain/runtime/unsafe"
47 "crypto/sha256"
48 "encoding/binary"
49 "math"
50 "math/bits"
51 "strconv"
52 "strings"
53 "time"
54)
55
56const (
57 MinWindow int64 = 10 // seconds
58 MaxWindow int64 = 30 * 24 * 3600 // 30 days
59 MinLife int64 = 3600 // an hour
60 MaxLife int64 = 30 * 24 * 3600 // 30 days
61 MinBasePrice int64 = 1_000 // ugnot, 0.001 GNOT
62 MaxBasePrice int64 = 1_000_000_000_000 // ugnot, 1,000,000 GNOT
63 MaxNameLen = 40
64 HistoryLen = 50 // bids kept per pool for the feed
65 RosterMax = 100 // distinct bidders a pool pays dividends, team shares and the close split to
66 MaxBoost int64 = 10 // the shortest clock a bid can buy is window/MaxBoost...
67 MinClock int64 = 300 // ...and never under five minutes: people must be able to answer
68
69 // where every bid goes, in percent
70 PotShare int64 = 55
71 DivShare int64 = 30 // to earlier bidders of the pool, pro-rata by weight
72 RefShare int64 = 10 // to the bidder's inviter
73 CreatorShare int64 = 5
74 // of the pot at the close, to the other bidders under the winner's flag
75 TeamShare int64 = 35
76
77 ShotCap int64 = 10_000_000 // ugnot: the most a shot can take, 10 GNOT
78 ShotMaxBps int64 = 100 // 1% a shot, whatever is paid
79 ShotFair int64 = 8000 // chance in bps = ShotFair × shot / prize (0.8 of fair odds)
80 ShotDelay int64 = 3 // blocks after the shot was paid for before it can be drawn...
81 ShotWindow int64 = 2 // ...and how many more blocks it may still be drawn in
82)
83
84// admin is the realm's deployer: the only address that may turn shots off.
85const admin = address("g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr")
86
87// pkgPath must match gnomod.toml: the realm's own address derives from it.
88const pkgPath = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble3"
89
90type Entry struct {
91 N int64
92 Bidder address
93 Flag string
94 Price int64 // what was paid for the bid itself; at least the price at the time
95 Shot int64 // extra paid for a chance to pop the bubble
96 Clock int64 // seconds this bid had to stand
97 Pot int64 // pot right after this bid
98 At time.Time
99}
100
101// Member is a distinct bidder of a pool: dividends, team shares and the close
102// split go to the roster. The flag is the one flown at the first bid here.
103type Member struct {
104 Addr address
105 Flag string
106 Paid int64 // gross paid into this pool (bids and shots)
107 Earned int64 // dividends received from this pool
108}
109
110// Shot is a shot at the bubble waiting to be drawn, or drawn.
111type Shot struct {
112 N int64 // 1-based within the pool
113 Bidder address
114 Paid int64 // ugnot paid for it
115 Prize int64 // what it plays for: the pot then, or ShotCap
116 Bps int64 // chance, in basis points
117 CommitH int64 // block height when paid
118 Resolved bool
119 Won bool
120 DrawH int64
121}
122
123type Pool struct {
124 ID int64
125 Name string
126 Creator address
127 Window int64 // seconds the last bid must stand to win
128 Life int64 // seconds from the first bid to the last day
129 BasePrice int64 // ugnot; the Nth bid costs BasePrice·√N
130 Seed int64
131 Pot int64
132 Bids int64
133 LastBidder address
134 LastBidAt time.Time
135 FirstBidAt time.Time
136 Clock int64 // seconds the current top bid has to stand
137 CreatedAt time.Time
138 Paid bool // the pot went out, or the seed back to the creator
139 Cancelled bool
140 Popped bool // a shot ended it
141 PoppedAt time.Time
142 DivPaid int64 // dividends paid out of this pool so far
143 RefPaid int64
144 CreatorPaid int64
145 ShotPaid int64 // slices paid to hits while the pool went on
146 Misses int64 // shots drawn and lost: the scars on the bubble
147 WinnerFlag string
148 TeamPaid int64 // what the winner's team received at the close
149 SharedPaid int64 // the pro-rata part of the close
150 WinnerPaid int64
151 History []Entry
152 Roster []*Member
153 Shots []*Shot
154}
155
156// Player is what the realm remembers about an address across pools.
157type Player struct {
158 Referrer address // set by the first bid, permanent
159 Recruits int64
160 Flag string
161 Bids int64
162 Paid int64
163 DivEarned int64
164 RefEarned int64
165 Won int64 // pots, team shares, close splits and shot slices received
166 Shots int64
167 Hits int64
168 Owed int64 // credited and not yet withdrawn
169 Withdrawn int64
170}
171
172var (
173 shotsOn = true
174 pools []*Pool
175 players = map[address]*Player{}
176 teamPaid = map[string]int64{} // flag -> gross bid volume under it
177 teamWon = map[string]int64{} // flag -> pots closed under it
178 teamSize = map[string]int64{} // flag -> players flying it
179 realmAddr = chain.PackageAddress(pkgPath)
180)
181
182// Price of the next bid in a pool with the given base price and n bids so far.
183func Price(base, n int64) int64 {
184 return int64(math.Ceil(float64(base) * math.Sqrt(float64(n+1))))
185}
186
187// LastDay is when the pool's life runs out; zero before the first bid.
188func (p *Pool) LastDay() time.Time {
189 if p.FirstBidAt.IsZero() {
190 return time.Time{}
191 }
192 return p.FirstBidAt.Add(time.Duration(p.Life) * time.Second)
193}
194
195// Deadline is when the pool closes if nothing changes: the clock or the last
196// day, whichever comes first; the pop, if a shot ended it.
197func (p *Pool) Deadline() time.Time {
198 if p.Popped {
199 return p.PoppedAt
200 }
201 d := p.LastBidAt.Add(time.Duration(p.Clock) * time.Second)
202 if last := p.LastDay(); !last.IsZero() && last.Before(d) {
203 return last
204 }
205 return d
206}
207
208func (p *Pool) Over() bool { return p.Bids > 0 && (p.Popped || !time.Now().Before(p.Deadline())) }
209func (p *Pool) NextPrice() int64 { return Price(p.BasePrice, p.Bids) }
210
211// LiveBps is how much of the pot still goes the winner's way, in basis points
212// of the pot: 10000 at the first bid, sliding to 0 on the last day. Frozen at
213// the close.
214func (p *Pool) LiveBps() int64 {
215 if p.FirstBidAt.IsZero() || p.Life <= 0 {
216 return 10000
217 }
218 at := time.Now()
219 if p.Over() {
220 at = p.Deadline()
221 }
222 elapsed := at.Unix() - p.FirstBidAt.Unix()
223 if elapsed <= 0 {
224 return 10000
225 }
226 if elapsed >= p.Life {
227 return 0
228 }
229 return 10000 * (p.Life - elapsed) / p.Life
230}
231
232// ClockFor is the clock a bid buys by paying sent against a price: the window
233// divided by the multiple paid, never below window/MaxBoost.
234func ClockFor(window, price, sent int64) int64 {
235 c := int64(float64(window) * float64(price) / float64(sent))
236 if c < window/MaxBoost {
237 c = window / MaxBoost
238 }
239 if c < MinClock {
240 c = MinClock
241 }
242 if c > window {
243 c = window
244 }
245 return c
246}
247
248// mulDiv is a×b/c without overflowing on the way: pots and paid-ins are both
249// in ugnot and their product passes int64 at a few thousand GNOT each.
250func mulDiv(a, b, c int64) int64 {
251 if a < 0 || b < 0 || c <= 0 {
252 panic("mulDiv: negative")
253 }
254 hi, lo := bits.Mul64(uint64(a), uint64(b))
255 if hi >= uint64(c) {
256 panic("mulDiv: overflow")
257 }
258 q, _ := bits.Div64(hi, lo, uint64(c))
259 return int64(q)
260}
261
262// ShotBps is the chance a shot buys against a prize, in basis points.
263func ShotBps(shot, prize int64) int64 {
264 if shot <= 0 || prize <= 0 {
265 return 0
266 }
267 bps := ShotFair * shot / prize
268 if bps > ShotMaxBps {
269 bps = ShotMaxBps
270 }
271 return bps
272}
273
274// CreatePool opens a pool. Any ugnot sent with the call seeds its pot. The
275// creator earns CreatorShare of every bid. life is the pool's life in seconds
276// from its first bid; 0 means ten windows (at least an hour, at most 30 days).
277func CreatePool(cur realm, name string, window int64, basePrice int64, life int64) int64 {
278 creator := userCaller(cur)
279 name = cleanName(name)
280 if window < MinWindow || window > MaxWindow {
281 panic("window must be between 10s and 30d")
282 }
283 if basePrice < MinBasePrice || basePrice > MaxBasePrice {
284 panic("base price must be between 0.001 and 1,000,000 GNOT")
285 }
286 if life == 0 { // ten windows of fighting, within the bounds
287 life = 10 * window
288 if life < MinLife {
289 life = MinLife
290 }
291 if life > MaxLife {
292 life = MaxLife
293 }
294 }
295 if life < MinLife || life > MaxLife {
296 panic("life must be between 1h and 30d")
297 }
298 if life < window {
299 panic("life must be at least the window")
300 }
301 seed := ugnotSent()
302 p := newPool(name, creator, window, basePrice, life, seed)
303 chain.Emit("PoolCreated", "id", itoa(p.ID), "creator", creator.String(), "seed", itoa(seed))
304 return p.ID
305}
306
307func newPool(name string, creator address, window, basePrice, life, seed int64) *Pool {
308 p := &Pool{
309 ID: int64(len(pools)) + 1, Name: name, Creator: creator, Window: window, Life: life,
310 BasePrice: basePrice, Seed: seed, Pot: seed, CreatedAt: time.Now(),
311 }
312 pools = append(pools, p)
313 return p
314}
315
316// Bid pays at least the current price and resets the clock. ref is the address
317// that invited the bidder ("" for none; only the first bid ever sets it), flag
318// a two-letter country to play under ("" keeps the last one; the flag is locked
319// per pool at the first bid there), shot how much of the money sent is a shot
320// at popping the bubble (0 for none): the rest must cover the price, and paying
321// more than the price shortens the clock.
322func Bid(cur realm, id int64, ref string, flag string, shot int64) {
323 bidder := userCaller(cur)
324 p := pool(id)
325 sent := ugnotSent()
326 if p.Cancelled {
327 panic("pool was cancelled")
328 }
329 // shots paid for earlier are drawn first; if one of them pops the bubble,
330 // this bid has nothing to bid on and its money goes back
331 p.drawShots(cur)
332 if p.Over() {
333 if sent > 0 {
334 send(cur, bidder, sent)
335 chain.Emit("Refund", "id", itoa(id), "to", bidder.String(), "ugnot", itoa(sent))
336 }
337 if p.Popped {
338 return
339 }
340 panic("pool is closed")
341 }
342 if shot < 0 || shot > sent {
343 panic("shot must be between 0 and the amount sent")
344 }
345 if shot > 0 && !shotsOn {
346 panic("shots are off")
347 }
348 price := p.NextPrice()
349 bid := sent - shot
350 if bid < price {
351 panic("send at least " + itoa(price) + "ugnot for the bid, got " + itoa(bid) + "ugnot")
352 }
353 if shot > 0 {
354 // checked against the most the pot can be after this bid, so a shot that
355 // passes here has a chance against the real pot too; nothing has changed yet
356 most := p.Pot + bid + shot
357 if most > ShotCap {
358 most = ShotCap
359 }
360 if ShotBps(shot, most) == 0 {
361 panic("that shot is too small for any chance at " + GNOT(most) + ": send at least " + itoa(most/ShotFair+1) + "ugnot as the shot")
362 }
363 }
364 pl := player(bidder)
365 if f := cleanFlag(flag); f != "" && f != pl.Flag {
366 if pl.Flag != "" {
367 teamSize[pl.Flag]--
368 }
369 pl.Flag = f
370 teamSize[f]++
371 }
372 if pl.Referrer == "" && ref != "" {
373 r := address(ref)
374 if r.IsValid() && r != bidder {
375 pl.Referrer = r
376 player(r).Recruits++
377 }
378 }
379
380 // where the bid goes
381 div, refCut, creatorCut := bid*DivShare/100, bid*RefShare/100, bid*CreatorShare/100
382 toPot := bid - div - refCut - creatorCut
383 if creatorCut > 0 {
384 send(cur, p.Creator, creatorCut)
385 p.CreatorPaid += creatorCut
386 }
387 if pl.Referrer != "" && refCut > 0 {
388 send(cur, pl.Referrer, refCut)
389 p.RefPaid += refCut
390 player(pl.Referrer).RefEarned += refCut
391 } else {
392 toPot += refCut
393 }
394 divPaid := div - p.payDividends(cur, bidder, div)
395 toPot += div - divPaid
396 // the shot goes into the pot whole, hit or miss: it is what it plays for
397 p.Pot += toPot + shot
398
399 m := p.member(bidder, pl.Flag)
400 if m.Flag == "" { // a flagless first bid does not lock "no flag": the first flag flown here does
401 m.Flag = pl.Flag
402 }
403 m.Paid += sent
404 pl.Bids++
405 pl.Paid += sent
406 if pl.Flag != "" {
407 teamPaid[pl.Flag] += sent
408 }
409 p.Bids++
410 if p.FirstBidAt.IsZero() {
411 p.FirstBidAt = time.Now()
412 }
413 p.LastBidder = bidder
414 p.LastBidAt = time.Now()
415 p.Clock = ClockFor(p.Window, price, bid)
416 p.History = append(p.History, Entry{N: p.Bids, Bidder: bidder, Flag: m.Flag, Price: bid, Shot: shot, Clock: p.Clock, Pot: p.Pot, At: p.LastBidAt})
417 if len(p.History) > HistoryLen {
418 p.History = p.History[len(p.History)-HistoryLen:]
419 }
420 chain.Emit("Bid", "id", itoa(id), "n", itoa(p.Bids), "bidder", bidder.String(), "paid", itoa(bid), "shot", itoa(shot), "pot", itoa(p.Pot), "div", itoa(divPaid))
421
422 if shot > 0 {
423 prize := p.Pot
424 if prize > ShotCap {
425 prize = ShotCap
426 }
427 bps := ShotBps(shot, prize)
428 t := &Shot{N: int64(len(p.Shots)) + 1, Bidder: bidder, Paid: shot, Prize: prize, Bps: bps, CommitH: runtime.ChainHeight()}
429 p.Shots = append(p.Shots, t)
430 pl.Shots++
431 chain.Emit("Shot", "id", itoa(id), "shot", itoa(t.N), "bidder", bidder.String(), "paid", itoa(shot), "prize", itoa(prize), "bps", itoa(bps), "drawAt", itoa(t.CommitH+ShotDelay))
432 }
433}
434
435// SetShots turns shots off (or on again); only the deployer may.
436func SetShots(cur realm, on bool) {
437 if userCaller(cur) != admin {
438 panic("only the deployer")
439 }
440 shotsOn = on
441 chain.Emit("Shots", "on", jbool(on))
442}
443
444// Draw draws the shots at a pool that are due. Anyone may call it; every bid
445// and claim on the pool does the same first.
446func Draw(cur realm, id int64) {
447 pool(id).drawShots(cur)
448}
449
450// drawShots resolves every shot that is due, oldest first. A shot is drawn in
451// the blocks ShotDelay to ShotDelay+ShotWindow after it was paid for, from the
452// block's time and height, the shot and its shooter, and the address that sent
453// the drawing transaction; a shot those blocks passed by undrawn is void, its
454// money stays in the pot, and it counts as a miss. A hit takes ShotCap from a
455// pot larger than that and the pool goes on; a pot at or under ShotCap pops:
456// the pool closes now with the shooter as its winner, settled at Claim.
457func (p *Pool) drawShots(cur realm) {
458 h := runtime.ChainHeight()
459 for _, t := range p.Shots {
460 if t.Resolved || t.CommitH+ShotDelay > h {
461 continue
462 }
463 t.Resolved, t.DrawH = true, h
464 if t.CommitH+ShotDelay+ShotWindow < h || p.Over() {
465 // too late, or the pool closed first: void
466 p.Misses++
467 chain.Emit("Drawn", "id", itoa(p.ID), "shot", itoa(t.N), "bidder", t.Bidder.String(), "hit", "false", "void", "true")
468 continue
469 }
470 t.Won = shotHits(seedFor(p.ID, t, h), t.Bps)
471 chain.Emit("Drawn", "id", itoa(p.ID), "shot", itoa(t.N), "bidder", t.Bidder.String(), "hit", jbool(t.Won), "bps", itoa(t.Bps))
472 if !t.Won {
473 p.Misses++
474 continue
475 }
476 player(t.Bidder).Hits++
477 if p.Pot > ShotCap {
478 credit(t.Bidder, ShotCap)
479 p.Pot -= ShotCap
480 p.ShotPaid += ShotCap
481 player(t.Bidder).Won += ShotCap
482 chain.Emit("Slice", "id", itoa(p.ID), "to", t.Bidder.String(), "ugnot", itoa(ShotCap))
483 continue
484 }
485 p.Popped, p.PoppedAt, p.LastBidder = true, time.Now(), t.Bidder
486 chain.Emit("Popped", "id", itoa(p.ID), "by", t.Bidder.String(), "pot", itoa(p.Pot))
487 }
488}
489
490func seedFor(poolID int64, t *Shot, h int64) uint64 {
491 var b []byte
492 b = append(b, pkgPath...)
493 b = binary.BigEndian.AppendUint64(b, uint64(poolID))
494 b = binary.BigEndian.AppendUint64(b, uint64(t.N))
495 b = append(b, t.Bidder.String()...)
496 b = binary.BigEndian.AppendUint64(b, uint64(t.CommitH))
497 b = binary.BigEndian.AppendUint64(b, uint64(h))
498 b = binary.BigEndian.AppendUint64(b, uint64(time.Now().UnixNano()))
499 b = append(b, unsafe.OriginCaller().String()...)
500 sum := sha256.Sum256(b)
501 return binary.BigEndian.Uint64(sum[:8])
502}
503
504// shotHits is the draw: a uniform number in [0, 10000) under the chance.
505func shotHits(seed uint64, bps int64) bool {
506 return int64(seed%10000) < bps
507}
508
509// payDividends credits div to the pool's earlier bidders, pro-rata to what
510// each has paid in, skipping the bidder; whatever cannot be placed (no earlier
511// bidders, rounding) is returned to go into the pot.
512func (p *Pool) payDividends(cur realm, bidder address, div int64) int64 {
513 var total int64
514 for _, m := range p.Roster {
515 if m.Addr != bidder {
516 total += m.Paid
517 }
518 }
519 if total == 0 || div == 0 {
520 return div
521 }
522 var paid int64
523 for _, m := range p.Roster {
524 if m.Addr == bidder {
525 continue
526 }
527 amt := mulDiv(div, m.Paid, total)
528 if amt > 0 {
529 credit(m.Addr, amt)
530 m.Earned += amt
531 player(m.Addr).DivEarned += amt
532 paid += amt
533 }
534 }
535 p.DivPaid += paid
536 return div - paid
537}
538
539// member finds or adds a bidder on the roster; a full roster drops its oldest.
540// A new member's flag is the one given; once set it is locked for this pool.
541func (p *Pool) member(a address, flag string) *Member {
542 for _, m := range p.Roster {
543 if m.Addr == a {
544 return m
545 }
546 }
547 if len(p.Roster) >= RosterMax {
548 p.Roster = p.Roster[1:]
549 }
550 m := &Member{Addr: a, Flag: flag}
551 p.Roster = append(p.Roster, m)
552 return m
553}
554
555// Claim settles a finished pool: everything is credited, and Withdraw sends
556// it. TeamShare of the pot goes to the OTHER roster members flying the
557// winner's flag, pro-rata by paid-in — or, when nobody else flew it, to all
558// the other roster members. Of the rest, the live part (LiveBps) is the
559// winner's and the shared part is split among the whole roster pro-rata by
560// paid-in. A pool with a single bidder credits them everything. Rounding goes
561// to the winner. Anyone may call it.
562func Claim(cur realm, id int64) {
563 p := pool(id)
564 if p.Cancelled {
565 panic("pool was cancelled")
566 }
567 p.drawShots(cur)
568 if !p.Over() {
569 panic("clock still running")
570 }
571 if p.Paid {
572 panic("already paid out")
573 }
574 p.Paid = true
575 winner := p.LastBidder
576 flag := ""
577 if m := p.find(winner); m != nil {
578 flag = m.Flag
579 }
580 p.WinnerFlag = flag
581 pot := p.Pot
582
583 // the team: the others under the winner's flag, else all the others
584 var team int64
585 for _, m := range p.Roster {
586 if m.Addr != winner && m.Flag == flag && flag != "" {
587 team += m.Paid
588 }
589 }
590 teamFlag := flag
591 if team == 0 {
592 teamFlag = "" // nobody else flew it: every other bidder is the team
593 for _, m := range p.Roster {
594 if m.Addr != winner {
595 team += m.Paid
596 }
597 }
598 }
599 toWinner := pot
600 if team > 0 {
601 cut := pot * TeamShare / 100
602 for _, m := range p.Roster {
603 if m.Addr == winner || (teamFlag != "" && m.Flag != teamFlag) {
604 continue
605 }
606 amt := mulDiv(cut, m.Paid, team)
607 if amt > 0 {
608 credit(m.Addr, amt)
609 player(m.Addr).Won += amt
610 toWinner -= amt
611 p.TeamPaid += amt
612 }
613 }
614 // the rest slides: the live part stays the winner's, the shared part is everyone's by paid-in
615 rest := pot - cut
616 shared := rest - mulDiv(rest, p.LiveBps(), 10000)
617 if shared > 0 {
618 var total int64
619 for _, m := range p.Roster {
620 total += m.Paid
621 }
622 for _, m := range p.Roster {
623 amt := mulDiv(shared, m.Paid, total)
624 if amt > 0 && m.Addr != winner {
625 credit(m.Addr, amt)
626 player(m.Addr).Won += amt
627 toWinner -= amt
628 p.SharedPaid += amt
629 }
630 }
631 }
632 }
633 if toWinner > 0 {
634 credit(winner, toWinner)
635 player(winner).Won += toWinner
636 }
637 p.WinnerPaid = toWinner
638 if flag != "" {
639 teamWon[flag] += p.Pot
640 }
641 chain.Emit("Paid", "id", itoa(id), "winner", winner.String(), "pot", itoa(p.Pot), "winnerPaid", itoa(toWinner), "team", itoa(p.TeamPaid), "shared", itoa(p.SharedPaid), "liveBps", itoa(p.LiveBps()))
642}
643
644// Cancel lets the creator take the seed back from a pool nobody has bid on.
645func Cancel(cur realm, id int64) {
646 caller := userCaller(cur)
647 p := pool(id)
648 if p.Creator != caller {
649 panic("only the creator can cancel")
650 }
651 if p.Bids > 0 {
652 panic("pool already has bids")
653 }
654 if p.Cancelled {
655 panic("already cancelled")
656 }
657 p.Cancelled = true
658 p.Paid = true
659 if p.Pot > 0 {
660 send(cur, caller, p.Pot)
661 }
662 chain.Emit("Cancelled", "id", itoa(id))
663}
664
665func (p *Pool) find(a address) *Member {
666 for _, m := range p.Roster {
667 if m.Addr == a {
668 return m
669 }
670 }
671 return nil
672}
673
674func player(a address) *Player {
675 pl := players[a]
676 if pl == nil {
677 pl = &Player{}
678 players[a] = pl
679 }
680 return pl
681}
682
683func send(cur realm, to address, ugnot int64) {
684 banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins(realmAddr, to, chain.Coins{{"ugnot", ugnot}})
685}
686
687// credit books ugnot to an address; Withdraw pays it out.
688func credit(to address, ugnot int64) {
689 player(to).Owed += ugnot
690}
691
692// Withdraw sends the caller everything credited to them: dividends, team
693// shares, close splits, slices and won pots.
694func Withdraw(cur realm) int64 {
695 who := userCaller(cur)
696 pl := player(who)
697 amt := pl.Owed
698 if amt <= 0 {
699 panic("nothing to withdraw")
700 }
701 pl.Owed = 0
702 pl.Withdrawn += amt
703 send(cur, who, amt)
704 chain.Emit("Withdrawn", "to", who.String(), "ugnot", itoa(amt))
705 return amt
706}
707
708// Owed is what an address could withdraw right now.
709func Owed(a address) int64 { return player(a).Owed }
710
711func pool(id int64) *Pool {
712 if id < 1 || id > int64(len(pools)) {
713 panic("no such pool")
714 }
715 return pools[id-1]
716}
717
718// userCaller is the signer of the transaction, refusing calls relayed through
719// another realm so nobody can bid on someone else's behalf, and so no realm can
720// pay for a shot and revert on a miss.
721func userCaller(cur realm) address {
722 prev := cur.Previous()
723 if !prev.IsUserCall() {
724 panic("must be a direct user call")
725 }
726 return prev.Address()
727}
728
729func ugnotSent() int64 {
730 sent := unsafe.OriginSend()
731 for _, c := range sent {
732 if c.Denom != "ugnot" {
733 panic("only ugnot is accepted")
734 }
735 }
736 return sent.AmountOf("ugnot")
737}
738
739func cleanName(s string) string {
740 s = strings.TrimSpace(s)
741 if s == "" || len(s) > MaxNameLen {
742 panic("name must be 1-40 characters")
743 }
744 for _, c := range s {
745 if c < 0x20 || c == 0x7f {
746 panic("name has control characters")
747 }
748 }
749 return s
750}
751
752// cleanFlag accepts a two-letter country code, upper-cased; anything else is "".
753func cleanFlag(s string) string {
754 s = strings.ToUpper(strings.TrimSpace(s))
755 if len(s) != 2 || s[0] < 'A' || s[0] > 'Z' || s[1] < 'A' || s[1] > 'Z' {
756 return ""
757 }
758 return s
759}
760
761func itoa(n int64) string { return strconv.FormatInt(n, 10) }
762
763func parseID(s string) int64 {
764 n, err := strconv.ParseInt(s, 10, 64)
765 if err != nil {
766 panic("bad pool id")
767 }
768 return n
769}
770
771// GNOT formats ugnot as GNOT with the trailing zeros trimmed.
772func GNOT(ugnot int64) string {
773 whole, frac := ugnot/1_000_000, ugnot%1_000_000
774 if frac == 0 {
775 return itoa(whole) + " GNOT"
776 }
777 f := strconv.FormatInt(1_000_000+frac, 10)[1:]
778 return itoa(whole) + "." + strings.TrimRight(f, "0") + " GNOT"
779}
780
781func short(a address) string {
782 s := a.String()
783 if len(s) < 12 {
784 return s
785 }
786 return s[:8] + "…" + s[len(s)-4:]
787}
788
789func dur(sec int64) string {
790 if sec <= 0 {
791 return "0s"
792 }
793 d, h, m, s := sec/86400, sec%86400/3600, sec%3600/60, sec%60
794 out := ""
795 if d > 0 {
796 out += itoa(d) + "d "
797 }
798 if d > 0 || h > 0 {
799 out += itoa(h) + "h "
800 }
801 if d > 0 || h > 0 || m > 0 {
802 out += itoa(m) + "m "
803 }
804 return out + itoa(s) + "s"
805}
806
807// --- rendering ---------------------------------------------------------------
808
809// Render serves gnoweb ("" and "<id>") and the page ("json", "json/<id>",
810// "me/<address>", "teams").
811func Render(path string) string {
812 path = strings.Trim(path, "/")
813 switch {
814 case path == "json":
815 return jsonAll()
816 case strings.HasPrefix(path, "json/"):
817 return pool(parseID(path[5:])).json(true)
818 case strings.HasPrefix(path, "me/"):
819 return jsonMe(address(path[3:]))
820 case path == "teams":
821 return jsonTeams()
822 case path == "":
823 return markdownAll()
824 default:
825 return pool(parseID(path)).markdown()
826 }
827}
828
829func unix(t time.Time) string {
830 if t.IsZero() {
831 return "0"
832 }
833 return itoa(t.Unix())
834}
835
836func jbool(b bool) string {
837 if b {
838 return "true"
839 }
840 return "false"
841}
842
843func (p *Pool) json(detail bool) string {
844 var b strings.Builder
845 b.WriteString("{\"id\":" + itoa(p.ID))
846 b.WriteString(",\"name\":" + strconv.Quote(p.Name))
847 b.WriteString(",\"creator\":\"" + p.Creator.String() + "\"")
848 b.WriteString(",\"window\":" + itoa(p.Window))
849 b.WriteString(",\"life\":" + itoa(p.Life))
850 b.WriteString(",\"basePrice\":" + itoa(p.BasePrice))
851 b.WriteString(",\"seed\":" + itoa(p.Seed))
852 b.WriteString(",\"pot\":" + itoa(p.Pot))
853 b.WriteString(",\"bids\":" + itoa(p.Bids))
854 b.WriteString(",\"nextPrice\":" + itoa(p.NextPrice()))
855 b.WriteString(",\"lastBidder\":\"" + p.LastBidder.String() + "\"")
856 b.WriteString(",\"lastBidAt\":" + unix(p.LastBidAt))
857 b.WriteString(",\"firstBidAt\":" + unix(p.FirstBidAt))
858 b.WriteString(",\"deadline\":" + unix(p.Deadline()))
859 b.WriteString(",\"lastDay\":" + unix(p.LastDay()))
860 b.WriteString(",\"liveBps\":" + itoa(p.LiveBps()))
861 b.WriteString(",\"clock\":" + itoa(p.Clock))
862 b.WriteString(",\"createdAt\":" + unix(p.CreatedAt))
863 b.WriteString(",\"over\":" + jbool(p.Over()))
864 b.WriteString(",\"popped\":" + jbool(p.Popped))
865 b.WriteString(",\"paid\":" + jbool(p.Paid))
866 b.WriteString(",\"cancelled\":" + jbool(p.Cancelled))
867 b.WriteString(",\"bidders\":" + itoa(int64(len(p.Roster))))
868 b.WriteString(",\"divPaid\":" + itoa(p.DivPaid))
869 b.WriteString(",\"refPaid\":" + itoa(p.RefPaid))
870 b.WriteString(",\"creatorPaid\":" + itoa(p.CreatorPaid))
871 b.WriteString(",\"shotPaid\":" + itoa(p.ShotPaid))
872 b.WriteString(",\"teamPaid\":" + itoa(p.TeamPaid))
873 b.WriteString(",\"sharedPaid\":" + itoa(p.SharedPaid))
874 b.WriteString(",\"winnerPaid\":" + itoa(p.WinnerPaid))
875 pending := int64(0)
876 for _, t := range p.Shots {
877 if !t.Resolved {
878 pending++
879 }
880 }
881 b.WriteString(",\"shots\":" + itoa(int64(len(p.Shots))) + ",\"misses\":" + itoa(p.Misses) + ",\"pending\":" + itoa(pending))
882 flag := p.WinnerFlag
883 if m := p.find(p.LastBidder); m != nil && flag == "" {
884 flag = m.Flag
885 }
886 b.WriteString(",\"flag\":\"" + flag + "\"")
887 // what each flag has paid into this pool
888 b.WriteString(",\"teams\":{")
889 seen := map[string]int64{}
890 order := []string{}
891 for _, m := range p.Roster {
892 if m.Flag == "" {
893 continue
894 }
895 if _, ok := seen[m.Flag]; !ok {
896 order = append(order, m.Flag)
897 }
898 seen[m.Flag] += m.Paid
899 }
900 for i, f := range order {
901 if i > 0 {
902 b.WriteString(",")
903 }
904 b.WriteString("\"" + f + "\":" + itoa(seen[f]))
905 }
906 b.WriteString("}")
907 if detail {
908 b.WriteString(",\"history\":[")
909 for i := len(p.History) - 1; i >= 0; i-- { // newest first
910 h := p.History[i]
911 if i < len(p.History)-1 {
912 b.WriteString(",")
913 }
914 b.WriteString("{\"n\":" + itoa(h.N) + ",\"bidder\":\"" + h.Bidder.String() + "\",\"flag\":\"" + h.Flag + "\",\"price\":" + itoa(h.Price) + ",\"shot\":" + itoa(h.Shot) + ",\"clock\":" + itoa(h.Clock) + ",\"pot\":" + itoa(h.Pot) + ",\"at\":" + unix(h.At) + "}")
915 }
916 b.WriteString("],\"roster\":[")
917 for i, m := range p.Roster {
918 if i > 0 {
919 b.WriteString(",")
920 }
921 b.WriteString("{\"addr\":\"" + m.Addr.String() + "\",\"flag\":\"" + m.Flag + "\",\"paid\":" + itoa(m.Paid) + ",\"earned\":" + itoa(m.Earned) + "}")
922 }
923 b.WriteString("],\"shotlog\":[")
924 for i := len(p.Shots) - 1; i >= 0; i-- { // newest first
925 t := p.Shots[i]
926 if i < len(p.Shots)-1 {
927 b.WriteString(",")
928 }
929 b.WriteString("{\"n\":" + itoa(t.N) + ",\"bidder\":\"" + t.Bidder.String() + "\",\"paid\":" + itoa(t.Paid) + ",\"prize\":" + itoa(t.Prize) + ",\"bps\":" + itoa(t.Bps) + ",\"drawAt\":" + itoa(t.CommitH+ShotDelay) + ",\"resolved\":" + jbool(t.Resolved) + ",\"hit\":" + jbool(t.Won) + "}")
930 }
931 b.WriteString("]")
932 }
933 b.WriteString("}")
934 return b.String()
935}
936
937func jsonAll() string {
938 var b strings.Builder
939 b.WriteString("{\"now\":" + itoa(time.Now().Unix()) + ",\"height\":" + itoa(runtime.ChainHeight()) + ",\"realm\":\"" + pkgPath + "\"")
940 b.WriteString(",\"split\":{\"pot\":" + itoa(PotShare) + ",\"div\":" + itoa(DivShare) + ",\"ref\":" + itoa(RefShare) + ",\"creator\":" + itoa(CreatorShare) + ",\"team\":" + itoa(TeamShare) + ",\"roster\":" + itoa(int64(RosterMax)) +
941 ",\"shotCap\":" + itoa(ShotCap) + ",\"shotMaxBps\":" + itoa(ShotMaxBps) + ",\"shotFair\":" + itoa(ShotFair) + ",\"shotDelay\":" + itoa(ShotDelay) + ",\"shotWindow\":" + itoa(ShotWindow) + ",\"minClock\":" + itoa(MinClock) + "}")
942 b.WriteString(",\"shotsOn\":" + jbool(shotsOn))
943 b.WriteString(",\"pools\":[")
944 for i, p := range pools {
945 if i > 0 {
946 b.WriteString(",")
947 }
948 b.WriteString(p.json(false))
949 }
950 b.WriteString("],\"teams\":" + jsonTeams() + "}")
951 return b.String()
952}
953
954// jsonTeams is the country leaderboard: paid in, won, and players per flag.
955func jsonTeams() string {
956 var b strings.Builder
957 b.WriteString("{")
958 i := 0
959 for f, paid := range teamPaid {
960 if i > 0 {
961 b.WriteString(",")
962 }
963 i++
964 b.WriteString("\"" + f + "\":{\"paid\":" + itoa(paid) + ",\"won\":" + itoa(teamWon[f]) + ",\"players\":" + itoa(teamSize[f]) + "}")
965 }
966 b.WriteString("}")
967 return b.String()
968}
969
970// jsonMe is what the page shows a connected wallet about itself.
971func jsonMe(a address) string {
972 pl := players[a]
973 if pl == nil {
974 pl = &Player{}
975 }
976 return "{\"addr\":\"" + a.String() + "\",\"referrer\":\"" + pl.Referrer.String() + "\",\"recruits\":" + itoa(pl.Recruits) +
977 ",\"flag\":\"" + pl.Flag + "\",\"bids\":" + itoa(pl.Bids) + ",\"paid\":" + itoa(pl.Paid) +
978 ",\"divEarned\":" + itoa(pl.DivEarned) + ",\"refEarned\":" + itoa(pl.RefEarned) + ",\"won\":" + itoa(pl.Won) +
979 ",\"shots\":" + itoa(pl.Shots) + ",\"hits\":" + itoa(pl.Hits) + ",\"owed\":" + itoa(pl.Owed) + ",\"withdrawn\":" + itoa(pl.Withdrawn) + "}"
980}
981
982func (p *Pool) status() string {
983 switch {
984 case p.Cancelled:
985 return "cancelled"
986 case p.Over():
987 s := "won by " + short(p.LastBidder)
988 if p.Popped {
989 s = "popped by " + short(p.LastBidder)
990 }
991 if !p.Paid {
992 s += " (unclaimed)"
993 }
994 return s
995 case p.Bids == 0:
996 return "no bids yet · " + dur(p.Window) + " window · next " + GNOT(p.NextPrice())
997 default:
998 left := p.Deadline().Unix() - time.Now().Unix()
999 s := dur(left) + " left"
1000 if p.Clock < p.Window {
1001 s += " (⚡ " + dur(p.Clock) + " clock)"
1002 }
1003 return s + " · " + short(p.LastBidder) + " on top · next " + GNOT(p.NextPrice()) + " · winner's share " + itoa(p.LiveBps()/100) + "%"
1004 }
1005}
1006
1007func markdownAll() string {
1008 var b strings.Builder
1009 b.WriteString("# Bubble Rumble v3\n\n")
1010 b.WriteString("Last bid standing takes the pot. Of every bid, 55% goes into the pot, 30% is paid at once to the pool's earlier bidders, 10% to whoever invited the bidder and 5% to the pool's creator. ")
1011 b.WriteString("Earnings are credited and withdrawn with Withdraw. At the close " + itoa(TeamShare) + "% of the pot goes to the other bidders who flew the winner's flag. Every pool has a last day: as it nears, the winner's own share slides toward a split of the pot among all its bidders. A bid may carry a shot at the bubble: a chance of at most " + itoa(ShotMaxBps/100) + "% to pop it, or to take a " + GNOT(ShotCap) + " slice of a bigger one; every miss is counted on the bubble.\n\n")
1012 b.WriteString("Play at [bubblerumble.net](https://bubblerumble.net).\n\n## Pools\n\n")
1013 if len(pools) == 0 {
1014 b.WriteString("_none yet_\n")
1015 }
1016 for _, p := range pools {
1017 b.WriteString("- [#" + itoa(p.ID) + " " + p.Name + "](/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble3:" + itoa(p.ID) + ") · " + GNOT(p.Pot) + " · " + p.status() + "\n")
1018 }
1019 return b.String()
1020}
1021
1022func (p *Pool) markdown() string {
1023 var b strings.Builder
1024 b.WriteString("# " + p.Name + "\n\n")
1025 b.WriteString("- pot: **" + GNOT(p.Pot) + "**\n- status: " + p.status() + "\n- window: " + dur(p.Window) + " · life: " + dur(p.Life) + "\n- base price: " + GNOT(p.BasePrice) + " · next bid: " + GNOT(p.NextPrice()) + "\n- bids: " + itoa(p.Bids) + " · creator: " + short(p.Creator) + "\n")
1026 if !p.LastDay().IsZero() {
1027 b.WriteString("- last day: " + p.LastDay().UTC().Format("2006-01-02 15:04 UTC") + " · winner's share now " + itoa(p.LiveBps()/100) + "%\n")
1028 }
1029 if len(p.Shots) > 0 {
1030 b.WriteString("- shots: " + itoa(int64(len(p.Shots))) + " · misses: " + itoa(p.Misses) + "\n")
1031 }
1032 b.WriteString("\n## Bids\n\n")
1033 if len(p.History) == 0 {
1034 b.WriteString("_none yet_\n")
1035 }
1036 for i := len(p.History) - 1; i >= 0; i-- {
1037 h := p.History[i]
1038 line := "- #" + itoa(h.N) + " " + short(h.Bidder)
1039 if h.Flag != "" {
1040 line += " (" + h.Flag + ")"
1041 }
1042 line += " paid " + GNOT(h.Price)
1043 if h.Shot > 0 {
1044 line += " + a " + GNOT(h.Shot) + " shot"
1045 }
1046 b.WriteString(line + " · pot " + GNOT(h.Pot) + "\n")
1047 }
1048 return b.String()
1049}