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

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}