Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}