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.

bubblerumble6.gno

46.06 Kb · 1292 lines
   1// Package bubblerumble6 is the sixth Bubble Rumble realm: v5's game without the
   2// last day (jae, 2026-10-01: "pools shouldn't expire… at all") — a pool lives
   3// as long as it is booped, as in v3 and v4, and the last booper takes the pot's
   4// rest whole. v5's one new slice stays. Of every bid 50% goes to the pot, 30% is credited at once to the
   5// pool's earlier bidders pro-rata to what they paid in, 10% to whoever invited the
   6// bidder, 5% to the pool's creator — and 5% to the BUBBLE treasury: the token
   7// realm's own sub-realm (bubbletreasury), which nothing moves but a decided vote
   8// of the BUBBLE holders. The five percent comes out of the pot's share alone; the
   9// dividends, the inviter's and the creator's slices are v4's. Seeds (a creator's
  10// own money into a new pool) and shots pay no royalty: a seed is a gift to the
  11// players, and a shot's losing side already feeds the pot. Every pool has a flag
  12// war at the close, credited earnings and Withdraw, a clock that can
  13// be bought down but never under MinClock, and the shot that can win the pot:
  14//
  15//   - A shot is still extra money on a bid, a chance to pop the bubble at once.
  16//     The chance is 0.8 × shot / take, at most 1% a shot, where the take is what
  17//     a pop would pay the shooter right now: the whole pot, the team's 35% counted. So the
  18//     pot keeps a fifth of every shot's fair value, and a hit pops the bubble: the pool
  19//     closes with the shooter as its winner and Claim pays it as any pop.
  20//   - The draw's randomness is a commit-reveal by the keeper — the operator's own
  21//     box, which also settles pools on time. The keeper publishes sha256 hashes of
  22//     secrets ahead of time (Commit); a bid with a shot takes the next unused one;
  23//     the shot is drawn when someone (the keeper, or anyone) reveals that secret
  24//     (Reveal) in the blocks ShotDelay to ShotDelay+ShotWindow after the bid. The
  25//     seed is the secret with the shot's own facts and the reveal block's height
  26//     and time. Nobody without the secret can grind it: not the shooter, not a
  27//     validator. The secret's holder knows the two candidate block times a block
  28//     ahead and may pick which of the window's three blocks to reveal in — three
  29//     rolls, at most 2.4× fair on a 1% shot — which is why the keeper key never
  30//     bids or shoots, and its operator never shoots. (The site's own keep-alive boops come from another key and
  31//     pay the royalty like any boop; for the operator, who votes the treasury,
  32//     that slice is a wash — said here so nobody has to work it out.)
  33//   - A shot not revealed in its window, or on a pool that closed first, is void:
  34//     it goes into the pot and counts as a miss, as in v3. Nothing is refunded, so
  35//     withholding a reveal never pays whoever withholds it. What remains is that a
  36//     keeper who leads a pot could withhold a stranger's hit and let the money fall
  37//     into the pot it leads: refused shots are counted on the pool for all to see,
  38//     and so are reveals that came in the window's later blocks (the honest keeper
  39//     always reveals at the first), so a keeper choosing its block would show.
  40//   - Shots are sold only while the keeper is alive: a shot needs a free commitment
  41//     and a sign of life (Commit or Heartbeat) within KeeperFresh. Otherwise the bid
  42//     stands and the shot's money goes straight back in the same transaction.
  43//   - Shots are refused under MinShot. RevealTip is taken
  44//     from every shot and credited to the keeper whoever sends the reveal (the
  45//     secret sits in the mempool, and a proposer could copy it), so the keeper
  46//     pays for itself. The keeper's own shots are refused outright.
  47//   - Reveals go in shot order, so among two pending shots nobody chooses which pops.
  48//
  49// Residuals, stated plainly: the secret holder's three rolls; the keeper-defender
  50// veto above; a proposer who reads the keeper's reveal in the mempool holds the
  51// secret too, and proposing the window's blocks could drop the reveal for a roll
  52// it does not like or leave it out altogether (a void), which Late and Voided
  53// show; keeper and validator together could grind. SetShots is the switch if any
  54// of it is seen. SetKeeper drops every unused commitment.
  55package bubblerumble6
  56
  57import (
  58	"chain"
  59	"chain/banker"
  60	"chain/runtime"
  61	"chain/runtime/unsafe"
  62	"crypto/sha256"
  63	"encoding/binary"
  64	"encoding/hex"
  65	"math"
  66	"math/bits"
  67	"strconv"
  68	"strings"
  69	"time"
  70)
  71
  72const (
  73	MinWindow    int64 = 10                // seconds
  74	MaxWindow    int64 = 30 * 24 * 3600    // 30 days
  75	MinBasePrice int64 = 1_000             // ugnot, 0.001 GNOT
  76	MaxBasePrice int64 = 1_000_000_000_000 // ugnot, 1,000,000 GNOT
  77	MaxNameLen         = 40
  78	HistoryLen         = 50  // bids kept per pool for the feed
  79	RosterMax          = 100 // distinct bidders a pool pays dividends, team shares and the close split to
  80	MaxBoost     int64 = 10  // the shortest clock a bid can buy is window/MaxBoost...
  81	MinClock     int64 = 300 // ...and never under five minutes: people must be able to answer
  82
  83	// where every bid goes, in percent
  84	PotShare      int64 = 50
  85	DivShare      int64 = 30 // to earlier bidders of the pool, pro-rata by weight
  86	RefShare      int64 = 10 // to the bidder's inviter
  87	CreatorShare  int64 = 5
  88	TreasuryShare int64 = 5 // to the BUBBLE treasury: the token realm's sub-realm, spent only by the holders' vote
  89	// of the pot at the close, to the other bidders under the winner's flag
  90	TeamShare int64 = 35
  91
  92	ShotMaxBps    int64 = 100     // 1% a shot, whatever is paid
  93	ShotFair      int64 = 8000    // chance in bps = ShotFair × shot / take (0.8 of fair odds)
  94	ShotDelay     int64 = 3       // blocks after the shot was paid for before it can be revealed...
  95	ShotWindow    int64 = 2       // ...and how many more blocks it may still be revealed in: three blocks, as v3 drew; three rolls at most for the secret's holder
  96	MinShot       int64 = 100_000 // ugnot: no shot under 0.1 GNOT, so dust cannot make reveals cost more than they tip
  97	RevealTip     int64 = 20_000  // ugnot: off every shot, to whoever reveals it
  98	KeeperFresh   int64 = 300     // seconds: shots are sold only this soon after the keeper's last sign of life (it heartbeats every two minutes)
  99	MaxCommits          = 500     // unused commitments the queue holds at most
 100	CommitBatch         = 100     // hashes one Commit may bring
 101	CommitCompact       = 200     // used commitments kept before the queue is compacted
 102)
 103
 104// admin is the site's cold key (jae's rule of 2026-09-27: every realm hands its
 105// ownership to it; the deployer, the hotkey, keeps nothing): the only address that
 106// may turn shots off or name another keeper.
 107const admin = address("g1ecsuj0q572jr0dhu29q9njtnmw03hyu7tyyvv6")
 108
 109// pkgPath must match gnomod.toml: the realm's own address derives from it.
 110const pkgPath = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble6"
 111
 112// The BUBBLE treasury is the token realm's sub-realm for its `treasury:spend` kind (the realm bubbletreasury
 113// publishes the kind and says the same address): a distinct address the token's governor alone can spend from,
 114// after a vote of the holders. Derived here from the two paths, as the chain derives it.
 115const (
 116	bubblePath      = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubble"
 117	treasurySubpath = "treasury-spend"
 118)
 119
 120type Entry struct {
 121	N      int64
 122	Bidder address
 123	Flag   string
 124	Price  int64 // what was paid for the bid itself; at least the price at the time
 125	Shot   int64 // extra paid for a chance to pop the bubble
 126	Clock  int64 // seconds this bid had to stand
 127	Pot    int64 // pot right after this bid
 128	At     time.Time
 129}
 130
 131// Member is a distinct bidder of a pool: dividends, team shares and the close
 132// split go to the roster. The flag is the one flown at the first bid here.
 133type Member struct {
 134	Addr   address
 135	Flag   string
 136	Paid   int64 // gross paid into this pool (bids and shots)
 137	Earned int64 // dividends received from this pool
 138}
 139
 140// Shot is a shot at the bubble waiting to be revealed, or resolved.
 141type Shot struct {
 142	N        int64 // 1-based within the pool
 143	Bidder   address
 144	Paid     int64  // ugnot paid for it (the tip comes out of this at the reveal)
 145	Take     int64  // what a pop would have paid the shooter when the odds were set
 146	Bps      int64  // chance, in basis points
 147	CommitH  int64  // block height when paid; revealable at CommitH+ShotDelay and the ShotWindow blocks after
 148	Hash     string // the commitment it took: sha256 of the secret that decides it, 32 raw bytes
 149	Resolved bool
 150	Won      bool
 151	Void     bool // not revealed in its window, or the pool closed first: a miss
 152	DrawH    int64
 153	Revealer address
 154}
 155
 156type Pool struct {
 157	ID           int64
 158	Name         string
 159	Creator      address
 160	Window       int64 // seconds the last bid must stand to win
 161	BasePrice    int64 // ugnot; the Nth bid costs BasePrice·√N
 162	Seed         int64
 163	Pot          int64
 164	Bids         int64
 165	LastBidder   address
 166	LastBidAt    time.Time
 167	FirstBidAt   time.Time
 168	Clock        int64 // seconds the current top bid has to stand
 169	CreatedAt    time.Time
 170	Paid         bool // the pot went out, or the seed back to the creator
 171	Cancelled    bool
 172	Popped       bool // a shot ended it
 173	PoppedAt     time.Time
 174	DivPaid      int64 // dividends paid out of this pool so far
 175	RefPaid      int64
 176	CreatorPaid  int64
 177	TreasuryPaid int64 // the BUBBLE treasury's 5% of this pool's bids
 178	ShotPaid     int64 // slices paid to hits while the pool went on
 179	Misses       int64 // shots resolved and lost, voids included: the scars on the bubble
 180	Voided       int64 // of those, the shots nobody revealed in time
 181	Late         int64 // shots revealed in their window but not at its first block: a keeper choosing its block would show here
 182	ShotEscrow   int64 // shots paid for and not yet resolved: in the pot once they are
 183	WinnerFlag   string
 184	TeamPaid     int64 // what the winner's team received at the close
 185	SharedPaid   int64 // the pro-rata part of the close
 186	WinnerPaid   int64
 187	History      []Entry
 188	Roster       []*Member
 189	Shots        []*Shot
 190	Settled      int // shots before this index are all resolved (they resolve strictly in order): where settleShots starts
 191}
 192
 193// Player is what the realm remembers about an address across pools.
 194type Player struct {
 195	Referrer  address // set by the first bid, permanent
 196	Recruits  int64
 197	Flag      string
 198	Bids      int64
 199	Paid      int64
 200	DivEarned int64
 201	RefEarned int64
 202	Won       int64 // pots, team shares, close splits and shot slices received
 203	Shots     int64
 204	Hits      int64
 205	Owed      int64 // credited and not yet withdrawn
 206	Withdrawn int64
 207}
 208
 209var (
 210	shotsOn      = true
 211	keeper       address = address("g18dd5mstjqlcg9ngqpg26azeya6wqg7upd9t8xa") // who commits and heartbeats: the box's own key from the first block, so no admin call follows the deploy (SetKeeper may change it); the admin may too
 212	keeperSeen   time.Time // the keeper's last Commit or Heartbeat
 213	commits      []string  // the hashes of the keeper's secrets (32 raw bytes each), in order; the first head are used
 214	head         int
 215	voided       int64 // shots voided over all pools
 216	late         int64 // shots revealed after the first block of their window, over all pools
 217	pools        []*Pool
 218	players      = map[address]*Player{}
 219	teamPaid     = map[string]int64{} // flag -> gross bid volume under it
 220	teamWon      = map[string]int64{} // flag -> pots closed under it
 221	teamSize     = map[string]int64{} // flag -> players flying it
 222	realmAddr    = chain.PackageAddress(pkgPath)
 223	treasuryAddr = chain.DerivePkgSubAddr(bubblePath, treasurySubpath)
 224	treasuryPaid int64 // the royalty over all pools
 225)
 226
 227// TreasuryAddr is where 5% of every bid goes: the BUBBLE treasury.
 228func TreasuryAddr() address { return treasuryAddr }
 229
 230// TreasuryPaid is what every pool of this realm has sent there.
 231func TreasuryPaid() int64 { return treasuryPaid }
 232
 233// Price of the next bid in a pool with the given base price and n bids so far.
 234func Price(base, n int64) int64 {
 235	return int64(math.Ceil(float64(base) * math.Sqrt(float64(n+1))))
 236}
 237
 238// Deadline is when the pool closes if nothing changes: the clock running out
 239// behind the last bid; the pop, if a shot ended it. v6 (jae, 2026-10-01 7539:
 240// "pools shouldn't expire… at all"): no last day — a pool lives as long as it
 241// is booped, as v3 and v4 did.
 242func (p *Pool) Deadline() time.Time {
 243	if p.Popped {
 244		return p.PoppedAt
 245	}
 246	return p.LastBidAt.Add(time.Duration(p.Clock) * time.Second)
 247}
 248
 249func (p *Pool) Over() bool       { return p.Bids > 0 && (p.Popped || !time.Now().Before(p.Deadline())) }
 250func (p *Pool) NextPrice() int64 { return Price(p.BasePrice, p.Bids) }
 251
 252
 253// ClockFor is the clock a bid buys by paying sent against a price: the window
 254// divided by the multiple paid, never below window/MaxBoost.
 255func ClockFor(window, price, sent int64) int64 {
 256	c := int64(float64(window) * float64(price) / float64(sent))
 257	if c < window/MaxBoost {
 258		c = window / MaxBoost
 259	}
 260	if c < MinClock {
 261		c = MinClock
 262	}
 263	if c > window {
 264		c = window
 265	}
 266	return c
 267}
 268
 269// mulDiv is a×b/c without overflowing on the way: pots and paid-ins are both
 270// in ugnot and their product passes int64 at a few thousand GNOT each.
 271func mulDiv(a, b, c int64) int64 {
 272	if a < 0 || b < 0 || c <= 0 {
 273		panic("mulDiv: negative")
 274	}
 275	hi, lo := bits.Mul64(uint64(a), uint64(b))
 276	if hi >= uint64(c) {
 277		panic("mulDiv: overflow")
 278	}
 279	q, _ := bits.Div64(hi, lo, uint64(c))
 280	return int64(q)
 281}
 282
 283// ShotBps is the chance a shot buys against what a pop would pay, in basis points.
 284func ShotBps(shot, take int64) int64 {
 285	if shot <= 0 || take <= 0 {
 286		return 0
 287	}
 288	bps := ShotFair * shot / take
 289	if bps > ShotMaxBps {
 290		bps = ShotMaxBps
 291	}
 292	return bps
 293}
 294
 295// Take is the most a pop could pay this shooter if the pool closed now on a pot
 296// of this size: Claim's own arithmetic, run dry, with the team's share counted
 297// as the shooter's too — one bid from a friend (or a second address) under a
 298// flag nobody else flies collects it, so the odds must assume it is collected.
 299// v6 has no last day, so nothing of the rest ever slides away from the shooter.
 300func (p *Pool) Take(shooter address, pot int64) int64 {
 301	toWinner, cut := p.payout(shooter, pot, false)
 302	return toWinner + cut
 303}
 304
 305// CreatePool opens a pool. Any ugnot sent with the call seeds its pot. The
 306// creator earns CreatorShare of every bid. There is no life and no last day:
 307// the pool runs while it is booped (v6).
 308func CreatePool(cur realm, name string, window int64, basePrice int64) int64 {
 309	creator := userCaller(cur)
 310	name = cleanName(name)
 311	if window < MinWindow || window > MaxWindow {
 312		panic("window must be between 10s and 30d")
 313	}
 314	if basePrice < MinBasePrice || basePrice > MaxBasePrice {
 315		panic("base price must be between 0.001 and 1,000,000 GNOT")
 316	}
 317	seed := ugnotSent()
 318	p := newPool(name, creator, window, basePrice, seed)
 319	chain.Emit("PoolCreated", "id", itoa(p.ID), "creator", creator.String(), "seed", itoa(seed))
 320	return p.ID
 321}
 322
 323func newPool(name string, creator address, window, basePrice, seed int64) *Pool {
 324	p := &Pool{
 325		ID: int64(len(pools)) + 1, Name: name, Creator: creator, Window: window,
 326		BasePrice: basePrice, Seed: seed, Pot: seed, CreatedAt: time.Now(),
 327	}
 328	pools = append(pools, p)
 329	return p
 330}
 331
 332// Bid pays at least the current price and resets the clock. ref is the address
 333// that invited the bidder ("" for none; only the first bid ever sets it), flag
 334// a two-letter country to play under ("" keeps the last one; the flag is locked
 335// per pool at the first bid there), shot how much of the money sent is a shot
 336// at popping the bubble (0 for none): the rest must cover the price, and paying
 337// more than the price shortens the clock.
 338func Bid(cur realm, id int64, ref string, flag string, shot int64) {
 339	bidder := userCaller(cur)
 340	p := pool(id)
 341	sent := ugnotSent()
 342	if p.Cancelled {
 343		panic("pool was cancelled")
 344	}
 345	// a pool that closed (or popped) since the bidder looked: the money goes back
 346	if p.Over() {
 347		if sent > 0 {
 348			send(cur, bidder, sent)
 349			chain.Emit("Refund", "id", itoa(id), "to", bidder.String(), "ugnot", itoa(sent))
 350		}
 351		if p.Popped {
 352			return
 353		}
 354		panic("pool is closed")
 355	}
 356	if shot < 0 || shot > sent {
 357		panic("shot must be between 0 and the amount sent")
 358	}
 359	price := p.NextPrice()
 360	bid := sent - shot
 361	if bid < price {
 362		panic("send at least " + itoa(price) + "ugnot for the bid, got " + itoa(bid) + "ugnot")
 363	}
 364	// shots of earlier bids whose window has passed are settled first
 365	p.settleShots()
 366	pl := player(bidder)
 367	if f := cleanFlag(flag); f != "" && f != pl.Flag {
 368		if pl.Flag != "" {
 369			teamSize[pl.Flag]--
 370		}
 371		pl.Flag = f
 372		teamSize[f]++
 373	}
 374	if pl.Referrer == "" && ref != "" {
 375		r := address(ref)
 376		if r.IsValid() && r != bidder {
 377			pl.Referrer = r
 378			player(r).Recruits++
 379		}
 380	}
 381
 382	// where the bid goes
 383	div, refCut, creatorCut, royalty := bid*DivShare/100, bid*RefShare/100, bid*CreatorShare/100, bid*TreasuryShare/100
 384	toPot := bid - div - refCut - creatorCut - royalty // the dust of the four divisions stays in the pot
 385	if creatorCut > 0 {
 386		send(cur, p.Creator, creatorCut)
 387		p.CreatorPaid += creatorCut
 388	}
 389	if royalty > 0 { // the BUBBLE treasury's share, sent now like the creator's and the inviter's
 390		send(cur, treasuryAddr, royalty)
 391		p.TreasuryPaid += royalty
 392		treasuryPaid += royalty
 393	}
 394	if pl.Referrer != "" && refCut > 0 {
 395		send(cur, pl.Referrer, refCut)
 396		p.RefPaid += refCut
 397		player(pl.Referrer).RefEarned += refCut
 398	} else {
 399		toPot += refCut
 400	}
 401	divPaid := div - p.payDividends(cur, bidder, div)
 402	toPot += div - divPaid
 403	p.Pot += toPot
 404
 405	m := p.member(bidder, pl.Flag)
 406	if m.Flag == "" { // a flagless first bid does not lock "no flag": the first flag flown here does
 407		m.Flag = pl.Flag
 408	}
 409	// the shot, if there can be one: else its money goes straight back and the bid stands
 410	why := ""
 411	if shot > 0 {
 412		why = p.shotRefusal(bidder, shot)
 413	}
 414	m.Paid += sent
 415	pl.Bids++
 416	pl.Paid += sent
 417	if pl.Flag != "" {
 418		teamPaid[pl.Flag] += sent
 419	}
 420	// the odds, on the pool as it stands with this bid booked:
 421	// priced here once, and a shot that buys no chance at all is refused like the rest
 422	var take, bps int64
 423	if shot > 0 && why == "" {
 424		take = p.Take(bidder, p.Pot+shot-RevealTip) // the pot a pop would pay from: this bid's share in, the shot itself in, the tip out
 425		bps = ShotBps(shot-RevealTip, take)
 426		if bps == 0 {
 427			why = "too small for any chance at this pot"
 428		}
 429	}
 430	if why != "" {
 431		m.Paid -= shot
 432		pl.Paid -= shot
 433		if pl.Flag != "" {
 434			teamPaid[pl.Flag] -= shot
 435		}
 436		send(cur, bidder, shot)
 437		chain.Emit("ShotReturned", "id", itoa(id), "bidder", bidder.String(), "ugnot", itoa(shot), "why", why)
 438		sent -= shot
 439		shot = 0
 440	}
 441	p.Bids++
 442	if p.FirstBidAt.IsZero() {
 443		p.FirstBidAt = time.Now()
 444	}
 445	p.LastBidder = bidder
 446	p.LastBidAt = time.Now()
 447	p.Clock = ClockFor(p.Window, price, bid)
 448	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})
 449	if len(p.History) > HistoryLen {
 450		p.History = p.History[len(p.History)-HistoryLen:]
 451	}
 452	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), "treasury", itoa(royalty))
 453
 454	if shot > 0 {
 455		t := &Shot{N: int64(len(p.Shots)) + 1, Bidder: bidder, Paid: shot, Take: take, Bps: bps, CommitH: runtime.ChainHeight(), Hash: commits[head]}
 456		head++
 457		p.Shots = append(p.Shots, t)
 458		p.ShotEscrow += shot
 459		pl.Shots++
 460		chain.Emit("Shot", "id", itoa(id), "shot", itoa(t.N), "bidder", bidder.String(), "paid", itoa(shot), "take", itoa(take), "bps", itoa(bps), "commit", hex.EncodeToString([]byte(t.Hash)), "revealAt", itoa(t.CommitH+ShotDelay))
 461	}
 462}
 463
 464// shotRefusal is why a shot cannot be sold on this pool right now ("" when it
 465// can): the checks a bid runs before taking one, in the order the page shows.
 466// The odds themselves are checked by the caller, on the booked state.
 467func (p *Pool) shotRefusal(shooter address, shot int64) string {
 468	switch {
 469	case !shotsOn:
 470		return "shots are off"
 471	case keeper != "" && shooter == keeper:
 472		return "the keeper never shoots"
 473	case shot < MinShot:
 474		return "under the smallest shot, " + GNOT(MinShot)
 475	case head >= len(commits):
 476		return "no commitment free: the keeper is away"
 477	case keeperSeen.IsZero() || time.Now().Unix()-keeperSeen.Unix() > KeeperFresh:
 478		return "the keeper has not been seen for " + dur(KeeperFresh)
 479	}
 480	return ""
 481}
 482
 483// SetShots turns shots off (or on again); only the admin (the cold key) may.
 484func SetShots(cur realm, on bool) {
 485	if userCaller(cur) != admin {
 486		panic("only the admin")
 487	}
 488	shotsOn = on
 489	chain.Emit("Shots", "on", jbool(on))
 490}
 491
 492// SetKeeper names the address that commits and heartbeats; only the admin
 493// may. Every unused commitment is dropped with it: a rotated or compromised
 494// keeper's secrets never decide a shot.
 495func SetKeeper(cur realm, addr address) {
 496	if userCaller(cur) != admin {
 497		panic("only the admin")
 498	}
 499	if !addr.IsValid() {
 500		panic("bad address")
 501	}
 502	keeper = addr
 503	commits = commits[:head]
 504	chain.Emit("Keeper", "addr", addr.String())
 505}
 506
 507func keeperCaller(cur realm) address {
 508	who := userCaller(cur)
 509	if who == "" || who != keeper {
 510		panic("only the keeper")
 511	}
 512	return who
 513}
 514
 515func keeperOrAdmin(cur realm) address {
 516	who := userCaller(cur)
 517	if who != admin && (who == "" || who != keeper) {
 518		panic("only the keeper or the admin")
 519	}
 520	return who
 521}
 522
 523// Heartbeat is the keeper's sign of life: shots are sold only within
 524// KeeperFresh of the last one (a Commit counts too).
 525func Heartbeat(cur realm) {
 526	keeperCaller(cur)
 527	keeperSeen = time.Now()
 528}
 529
 530// Commit queues the hashes of secrets the keeper will reveal later, in order,
 531// as a comma-separated list of 64 hex characters each; only the keeper, whose
 532// box holds the secrets. A hash still in the queue (used or not) is refused: a
 533// repeated secret would be public after its first reveal (a safety net against
 534// the keeper repeating itself; compaction forgets the oldest). The queue holds
 535// at most MaxCommits unused; the used ones in front of it are compacted away
 536// past CommitCompact.
 537func Commit(cur realm, hashes string) {
 538	keeperCaller(cur)
 539	keeperSeen = time.Now()
 540	parts := strings.Split(hashes, ",")
 541	if len(parts) == 0 || len(parts) > CommitBatch {
 542		panic("1 to " + strconv.Itoa(CommitBatch) + " hashes")
 543	}
 544	if len(commits)-head+len(parts) > MaxCommits {
 545		panic("the queue holds " + strconv.Itoa(MaxCommits) + " unused commitments at most")
 546	}
 547	if head > CommitCompact {
 548		commits = append([]string(nil), commits[head:]...)
 549		head = 0
 550	}
 551	for _, part := range parts {
 552		b, err := hex.DecodeString(strings.TrimSpace(part))
 553		if err != nil || len(b) != 32 {
 554			panic("a hash is 64 hex characters")
 555		}
 556		h := string(b)
 557		for _, c := range commits { // a safety net against the keeper repeating itself, over what the queue still holds
 558			if c == h {
 559				panic("hash already committed: " + part)
 560			}
 561		}
 562		commits = append(commits, h)
 563	}
 564	chain.Emit("Commit", "n", itoa(int64(len(parts))), "free", itoa(int64(len(commits)-head)))
 565}
 566
 567// Retire drops the first n unused commitments: a keeper that lost the secrets
 568// behind them (the box rebuilt, the file gone) clears them so shots can go on.
 569// The admin may too. It is not a sign of life.
 570func Retire(cur realm, n int64) {
 571	keeperOrAdmin(cur)
 572	if n <= 0 {
 573		panic("retire at least one")
 574	}
 575	if int(n) > len(commits)-head {
 576		n = int64(len(commits) - head)
 577	}
 578	commits = append(commits[:head], commits[head+int(n):]...)
 579	chain.Emit("Retire", "n", itoa(n), "free", itoa(int64(len(commits)-head)))
 580}
 581
 582// Reveal draws shots with the secrets behind their commitments: a comma-separated
 583// list of pool:shot:secretHex, any pools, in one transaction — the keeper sends
 584// every shot that is due in one call, so a run of shots on one block never
 585// outpaces it. Anyone may call it; the secret is the proof, and the caller is
 586// tipped RevealTip out of each shot revealed. Shots go in order: an earlier one
 587// still pending must come first (in the list, or before). An entry not yet due
 588// is skipped, one with a wrong secret or already resolved is skipped with an
 589// event, so a stale entry never voids the rest; a malformed one (no such pool
 590// or shot, a cancelled pool, not pool:shot:secret) aborts the whole call, which
 591// only the keeper's own bug could send. The reveal must land in the
 592// blocks CommitH+ShotDelay through CommitH+ShotDelay+ShotWindow; later, or on a
 593// pool that has closed, the shot is already void (settleShots runs first): into
 594// the pot as a miss. A hit pops the bubble: the pool closes now with the shooter
 595// as its winner, settled at Claim.
 596func Reveal(cur realm, reveals string) {
 597	who := userCaller(cur)
 598	h := runtime.ChainHeight()
 599	done := 0
 600	for _, entry := range strings.Split(reveals, ",") {
 601		f := strings.Split(strings.TrimSpace(entry), ":")
 602		if len(f) != 3 {
 603			panic("each entry is pool:shot:secret")
 604		}
 605		id, n := parseID(f[0]), parseNum(f[1])
 606		p := pool(id)
 607		if p.Cancelled {
 608			panic("pool was cancelled")
 609		}
 610		p.settleShots()
 611		if n < 1 || int(n) > len(p.Shots) {
 612			panic("no such shot")
 613		}
 614		t := p.Shots[n-1]
 615		if t.Resolved || (n > 1 && !p.Shots[n-2].Resolved) {
 616			chain.Emit("RevealSkipped", "id", itoa(id), "shot", itoa(n), "why", jif(t.Resolved, "resolved", "earlier shot pending"))
 617			continue
 618		}
 619		if h < t.CommitH+ShotDelay {
 620			continue // not due yet: the keeper sends a block early to be sure of the window
 621		}
 622		secret, err := hex.DecodeString(strings.TrimSpace(f[2]))
 623		sum := sha256.Sum256(secret)
 624		if err != nil || string(sum[:]) != t.Hash {
 625			chain.Emit("RevealSkipped", "id", itoa(id), "shot", itoa(n), "why", "wrong secret")
 626			continue
 627		}
 628		// (a shot past its window, or on a pool that closed, was voided by settleShots above and skipped as resolved)
 629		done++
 630		t.Resolved, t.DrawH, t.Revealer = true, h, who
 631		p.ShotEscrow -= t.Paid
 632		// the tip is the keeper's whoever sent the reveal: the secret sits in the mempool, and a proposer could
 633		// copy it into a reveal of its own; the seed does not care who reveals, so nothing else changes
 634		tip := RevealTip
 635		if tip > t.Paid {
 636			tip = t.Paid
 637		}
 638		if keeper != "" {
 639			credit(keeper, tip)
 640		} else {
 641			credit(who, tip)
 642		}
 643		p.Pot += t.Paid - tip
 644		if h > t.CommitH+ShotDelay { // revealed in the window but not at its first block: the honest keeper's tell, counted
 645			p.Late++
 646			late++
 647		}
 648		t.Won = shotHits(seedFor(id, t, secret, h), t.Bps)
 649		chain.Emit("Revealed", "id", itoa(id), "shot", itoa(n), "bidder", t.Bidder.String(), "hit", jbool(t.Won), "bps", itoa(t.Bps), "by", who.String())
 650		if !t.Won {
 651			p.Misses++
 652			continue
 653		}
 654		player(t.Bidder).Hits++
 655		p.Popped, p.PoppedAt, p.LastBidder = true, time.Now(), t.Bidder
 656		chain.Emit("Popped", "id", itoa(id), "by", t.Bidder.String(), "pot", itoa(p.Pot))
 657	}
 658	if done == 0 {
 659		panic("nothing revealed")
 660	}
 661}
 662
 663// Settle voids the shots at a pool that nobody revealed in time, or that its
 664// close overtook. Anyone may call it; every bid, reveal and claim on the pool
 665// does the same first.
 666func Settle(cur realm, id int64) {
 667	pool(id).settleShots()
 668}
 669
 670// settleShots voids, in order, every pending shot whose window has passed or
 671// whose pool is over: the shot's money goes into the pot and counts as a miss;
 672// nobody is tipped.
 673func (p *Pool) settleShots() {
 674	h := runtime.ChainHeight()
 675	over := p.Over()
 676	for p.Settled < len(p.Shots) && p.Shots[p.Settled].Resolved {
 677		p.Settled++
 678	}
 679	for _, t := range p.Shots[p.Settled:] {
 680		if t.Resolved || (h <= t.CommitH+ShotDelay+ShotWindow && !over) {
 681			continue
 682		}
 683		t.Resolved, t.Void, t.DrawH = true, true, h
 684		p.ShotEscrow -= t.Paid
 685		p.Pot += t.Paid
 686		p.Misses++
 687		p.Voided++
 688		voided++
 689		chain.Emit("Voided", "id", itoa(p.ID), "shot", itoa(t.N), "bidder", t.Bidder.String(), "why", jif(over, "closed", "unrevealed"))
 690	}
 691}
 692
 693func jif(c bool, a, b string) string {
 694	if c {
 695		return a
 696	}
 697	return b
 698}
 699
 700// seedFor is the draw's seed: the secret with the shot's own facts and the
 701// reveal block's height and time. Everything but the secret is public, and the
 702// secret is the keeper's until the reveal; the block's time is the validators'.
 703func seedFor(poolID int64, t *Shot, secret []byte, h int64) uint64 {
 704	var b []byte
 705	b = append(b, pkgPath...)
 706	b = binary.BigEndian.AppendUint64(b, uint64(poolID))
 707	b = binary.BigEndian.AppendUint64(b, uint64(t.N))
 708	b = append(b, t.Bidder.String()...)
 709	b = binary.BigEndian.AppendUint64(b, uint64(t.CommitH))
 710	b = append(b, secret...)
 711	b = binary.BigEndian.AppendUint64(b, uint64(h))
 712	b = binary.BigEndian.AppendUint64(b, uint64(time.Now().UnixNano()))
 713	sum := sha256.Sum256(b)
 714	return binary.BigEndian.Uint64(sum[:8])
 715}
 716
 717// shotHits is the draw: a uniform number in [0, 10000) under the chance.
 718func shotHits(seed uint64, bps int64) bool {
 719	return int64(seed%10000) < bps
 720}
 721
 722// payDividends credits div to the pool's earlier bidders, pro-rata to what
 723// each has paid in, skipping the bidder; whatever cannot be placed (no earlier
 724// bidders, rounding) is returned to go into the pot.
 725func (p *Pool) payDividends(cur realm, bidder address, div int64) int64 {
 726	var total int64
 727	for _, m := range p.Roster {
 728		if m.Addr != bidder {
 729			total += m.Paid
 730		}
 731	}
 732	if total == 0 || div == 0 {
 733		return div
 734	}
 735	var paid int64
 736	for _, m := range p.Roster {
 737		if m.Addr == bidder {
 738			continue
 739		}
 740		amt := mulDiv(div, m.Paid, total)
 741		if amt > 0 {
 742			credit(m.Addr, amt)
 743			m.Earned += amt
 744			player(m.Addr).DivEarned += amt
 745			paid += amt
 746		}
 747	}
 748	p.DivPaid += paid
 749	return div - paid
 750}
 751
 752// member finds or adds a bidder on the roster; a full roster drops its oldest.
 753// A new member's flag is the one given; once set it is locked for this pool.
 754func (p *Pool) member(a address, flag string) *Member {
 755	for _, m := range p.Roster {
 756		if m.Addr == a {
 757			return m
 758		}
 759	}
 760	if len(p.Roster) >= RosterMax {
 761		p.Roster = p.Roster[1:]
 762	}
 763	m := &Member{Addr: a, Flag: flag}
 764	p.Roster = append(p.Roster, m)
 765	return m
 766}
 767
 768// Claim settles a finished pool: everything is credited, and Withdraw sends
 769// it. TeamShare of the pot goes to the OTHER roster members flying the
 770// winner's flag, pro-rata by paid-in — or, when nobody else flew it, to all
 771// the other roster members. The rest is the winner's (v6: no last day, no
 772// slide). A pool with a single bidder credits them everything. Rounding goes
 773// to the winner. Anyone may call it.
 774func Claim(cur realm, id int64) {
 775	p := pool(id)
 776	if p.Cancelled {
 777		panic("pool was cancelled")
 778	}
 779	p.settleShots()
 780	if !p.Over() {
 781		panic("clock still running")
 782	}
 783	if p.Paid {
 784		panic("already paid out")
 785	}
 786	p.Paid = true
 787	winner := p.LastBidder
 788	flag := ""
 789	if m := p.find(winner); m != nil {
 790		flag = m.Flag
 791	}
 792	p.WinnerFlag = flag
 793	toWinner, _ := p.payout(winner, p.Pot, true)
 794	if toWinner > 0 {
 795		credit(winner, toWinner)
 796		player(winner).Won += toWinner
 797	}
 798	p.WinnerPaid = toWinner
 799	if flag != "" {
 800		teamWon[flag] += p.Pot
 801	}
 802	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", "10000")
 803}
 804
 805// payout is the close's arithmetic for a winner and a pot: TeamShare to the
 806// other roster members under the winner's flag (or, when nobody else flew it,
 807// to all the others); the winner keeps the rest and the rounding (v6 shares
 808// nothing else out: no last day, no slide). With pay it credits and books the shares;
 809// dry, it only says what the winner would get and what the team would (Take
 810// prices shots with both).
 811func (p *Pool) payout(winner address, pot int64, pay bool) (toWinner, teamCut int64) {
 812	flag := ""
 813	if m := p.find(winner); m != nil {
 814		flag = m.Flag
 815	}
 816	var team int64
 817	for _, m := range p.Roster {
 818		if m.Addr != winner && m.Flag == flag && flag != "" {
 819			team += m.Paid
 820		}
 821	}
 822	teamFlag := flag
 823	if team == 0 {
 824		teamFlag = "" // nobody else flew it: every other bidder is the team
 825		for _, m := range p.Roster {
 826			if m.Addr != winner {
 827				team += m.Paid
 828			}
 829		}
 830	}
 831	toWinner = pot
 832	if team == 0 {
 833		return toWinner, 0 // alone on the roster: everything
 834	}
 835	cut := pot * TeamShare / 100
 836	for _, m := range p.Roster {
 837		if m.Addr == winner || (teamFlag != "" && m.Flag != teamFlag) {
 838			continue
 839		}
 840		amt := mulDiv(cut, m.Paid, team)
 841		if amt > 0 {
 842			toWinner -= amt
 843			teamCut += amt
 844			if pay {
 845				credit(m.Addr, amt)
 846				player(m.Addr).Won += amt
 847				p.TeamPaid += amt
 848			}
 849		}
 850	}
 851	// the rest is the winner's: v6 has no last day and no slide, so nothing of it is shared out (v5 slid it to the roster over the life)
 852	return toWinner, teamCut
 853}
 854
 855// Cancel lets the creator take the seed back from a pool nobody has bid on.
 856func Cancel(cur realm, id int64) {
 857	caller := userCaller(cur)
 858	p := pool(id)
 859	if p.Creator != caller {
 860		panic("only the creator can cancel")
 861	}
 862	if p.Bids > 0 {
 863		panic("pool already has bids")
 864	}
 865	if p.Cancelled {
 866		panic("already cancelled")
 867	}
 868	p.Cancelled = true
 869	p.Paid = true
 870	if p.Pot > 0 {
 871		send(cur, caller, p.Pot)
 872	}
 873	chain.Emit("Cancelled", "id", itoa(id))
 874}
 875
 876func (p *Pool) find(a address) *Member {
 877	for _, m := range p.Roster {
 878		if m.Addr == a {
 879			return m
 880		}
 881	}
 882	return nil
 883}
 884
 885func player(a address) *Player {
 886	pl := players[a]
 887	if pl == nil {
 888		pl = &Player{}
 889		players[a] = pl
 890	}
 891	return pl
 892}
 893
 894func send(cur realm, to address, ugnot int64) {
 895	banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins(realmAddr, to, chain.Coins{{"ugnot", ugnot}})
 896}
 897
 898// credit books ugnot to an address; Withdraw pays it out.
 899func credit(to address, ugnot int64) {
 900	player(to).Owed += ugnot
 901}
 902
 903// Withdraw sends the caller everything credited to them: dividends, team
 904// shares, close splits, slices and won pots.
 905func Withdraw(cur realm) int64 {
 906	who := userCaller(cur)
 907	pl := player(who)
 908	amt := pl.Owed
 909	if amt <= 0 {
 910		panic("nothing to withdraw")
 911	}
 912	pl.Owed = 0
 913	pl.Withdrawn += amt
 914	send(cur, who, amt)
 915	chain.Emit("Withdrawn", "to", who.String(), "ugnot", itoa(amt))
 916	return amt
 917}
 918
 919// Owed is what an address could withdraw right now.
 920func Owed(a address) int64 { return player(a).Owed }
 921
 922func pool(id int64) *Pool {
 923	if id < 1 || id > int64(len(pools)) {
 924		panic("no such pool")
 925	}
 926	return pools[id-1]
 927}
 928
 929// userCaller is the signer of the transaction, refusing calls relayed through
 930// another realm so nobody can bid on someone else's behalf, and so no realm can
 931// pay for a shot and revert on a miss.
 932func userCaller(cur realm) address {
 933	prev := cur.Previous()
 934	if !prev.IsUserCall() {
 935		panic("must be a direct user call")
 936	}
 937	return prev.Address()
 938}
 939
 940func ugnotSent() int64 {
 941	sent := unsafe.OriginSend()
 942	for _, c := range sent {
 943		if c.Denom != "ugnot" {
 944			panic("only ugnot is accepted")
 945		}
 946	}
 947	return sent.AmountOf("ugnot")
 948}
 949
 950func cleanName(s string) string {
 951	s = strings.TrimSpace(s)
 952	if s == "" || len(s) > MaxNameLen {
 953		panic("name must be 1-40 characters")
 954	}
 955	for _, c := range s {
 956		if c < 0x20 || c == 0x7f {
 957			panic("name has control characters")
 958		}
 959	}
 960	return s
 961}
 962
 963// cleanFlag accepts a two-letter country code, upper-cased; anything else is "".
 964func cleanFlag(s string) string {
 965	s = strings.ToUpper(strings.TrimSpace(s))
 966	if len(s) != 2 || s[0] < 'A' || s[0] > 'Z' || s[1] < 'A' || s[1] > 'Z' {
 967		return ""
 968	}
 969	return s
 970}
 971
 972func itoa(n int64) string { return strconv.FormatInt(n, 10) }
 973
 974func parseID(s string) int64 {
 975	n, err := strconv.ParseInt(s, 10, 64)
 976	if err != nil {
 977		panic("bad pool id")
 978	}
 979	return n
 980}
 981
 982func parseNum(s string) int64 {
 983	n, err := strconv.ParseInt(s, 10, 64)
 984	if err != nil {
 985		panic("bad number")
 986	}
 987	return n
 988}
 989
 990// GNOT formats ugnot as GNOT with the trailing zeros trimmed.
 991func GNOT(ugnot int64) string {
 992	whole, frac := ugnot/1_000_000, ugnot%1_000_000
 993	if frac == 0 {
 994		return itoa(whole) + " GNOT"
 995	}
 996	f := strconv.FormatInt(1_000_000+frac, 10)[1:]
 997	return itoa(whole) + "." + strings.TrimRight(f, "0") + " GNOT"
 998}
 999
1000func short(a address) string {
1001	s := a.String()
1002	if len(s) < 12 {
1003		return s
1004	}
1005	return s[:8] + "…" + s[len(s)-4:]
1006}
1007
1008func dur(sec int64) string {
1009	if sec <= 0 {
1010		return "0s"
1011	}
1012	d, h, m, s := sec/86400, sec%86400/3600, sec%3600/60, sec%60
1013	out := ""
1014	if d > 0 {
1015		out += itoa(d) + "d "
1016	}
1017	if d > 0 || h > 0 {
1018		out += itoa(h) + "h "
1019	}
1020	if d > 0 || h > 0 || m > 0 {
1021		out += itoa(m) + "m "
1022	}
1023	return out + itoa(s) + "s"
1024}
1025
1026// --- rendering ---------------------------------------------------------------
1027
1028// Render serves gnoweb ("" and "<id>") and the page ("json", "json/<id>",
1029// "me/<address>", "teams").
1030func Render(path string) string {
1031	path = strings.Trim(path, "/")
1032	switch {
1033	case path == "json":
1034		return jsonAll()
1035	case strings.HasPrefix(path, "json/"):
1036		return pool(parseID(path[5:])).json(true)
1037	case strings.HasPrefix(path, "me/"):
1038		return jsonMe(address(path[3:]))
1039	case path == "teams":
1040		return jsonTeams()
1041	case path == "commits":
1042		return jsonCommits()
1043	case path == "":
1044		return markdownAll()
1045	default:
1046		return pool(parseID(path)).markdown()
1047	}
1048}
1049
1050func unix(t time.Time) string {
1051	if t.IsZero() {
1052		return "0"
1053	}
1054	return itoa(t.Unix())
1055}
1056
1057func jbool(b bool) string {
1058	if b {
1059		return "true"
1060	}
1061	return "false"
1062}
1063
1064func (p *Pool) json(detail bool) string {
1065	var b strings.Builder
1066	b.WriteString("{\"id\":" + itoa(p.ID))
1067	b.WriteString(",\"name\":" + strconv.Quote(p.Name))
1068	b.WriteString(",\"creator\":\"" + p.Creator.String() + "\"")
1069	b.WriteString(",\"window\":" + itoa(p.Window))
1070	b.WriteString(",\"basePrice\":" + itoa(p.BasePrice))
1071	b.WriteString(",\"seed\":" + itoa(p.Seed))
1072	b.WriteString(",\"pot\":" + itoa(p.Pot))
1073	b.WriteString(",\"bids\":" + itoa(p.Bids))
1074	b.WriteString(",\"nextPrice\":" + itoa(p.NextPrice()))
1075	b.WriteString(",\"lastBidder\":\"" + p.LastBidder.String() + "\"")
1076	b.WriteString(",\"lastBidAt\":" + unix(p.LastBidAt))
1077	b.WriteString(",\"firstBidAt\":" + unix(p.FirstBidAt))
1078	b.WriteString(",\"deadline\":" + unix(p.Deadline()))
1079	b.WriteString(",\"lastDay\":0,\"liveBps\":10000") // v6: no last day, the winner's share never slides (kept for the page's reader)
1080	b.WriteString(",\"clock\":" + itoa(p.Clock))
1081	b.WriteString(",\"createdAt\":" + unix(p.CreatedAt))
1082	b.WriteString(",\"over\":" + jbool(p.Over()))
1083	b.WriteString(",\"popped\":" + jbool(p.Popped))
1084	b.WriteString(",\"paid\":" + jbool(p.Paid))
1085	b.WriteString(",\"cancelled\":" + jbool(p.Cancelled))
1086	b.WriteString(",\"bidders\":" + itoa(int64(len(p.Roster))))
1087	b.WriteString(",\"divPaid\":" + itoa(p.DivPaid))
1088	b.WriteString(",\"refPaid\":" + itoa(p.RefPaid))
1089	b.WriteString(",\"creatorPaid\":" + itoa(p.CreatorPaid))
1090	b.WriteString(",\"treasuryPaid\":" + itoa(p.TreasuryPaid))
1091	b.WriteString(",\"shotPaid\":" + itoa(p.ShotPaid))
1092	b.WriteString(",\"teamPaid\":" + itoa(p.TeamPaid))
1093	b.WriteString(",\"sharedPaid\":" + itoa(p.SharedPaid))
1094	b.WriteString(",\"winnerPaid\":" + itoa(p.WinnerPaid))
1095	pending := int64(0)
1096	for _, t := range p.Shots {
1097		if !t.Resolved {
1098			pending++
1099		}
1100	}
1101	b.WriteString(",\"shots\":" + itoa(int64(len(p.Shots))) + ",\"misses\":" + itoa(p.Misses) + ",\"voided\":" + itoa(p.Voided) + ",\"late\":" + itoa(p.Late) + ",\"pending\":" + itoa(pending) + ",\"escrow\":" + itoa(p.ShotEscrow))
1102	flag := p.WinnerFlag
1103	if m := p.find(p.LastBidder); m != nil && flag == "" {
1104		flag = m.Flag
1105	}
1106	b.WriteString(",\"flag\":\"" + flag + "\"")
1107	// what each flag has paid into this pool
1108	b.WriteString(",\"teams\":{")
1109	seen := map[string]int64{}
1110	order := []string{}
1111	for _, m := range p.Roster {
1112		if m.Flag == "" {
1113			continue
1114		}
1115		if _, ok := seen[m.Flag]; !ok {
1116			order = append(order, m.Flag)
1117		}
1118		seen[m.Flag] += m.Paid
1119	}
1120	for i, f := range order {
1121		if i > 0 {
1122			b.WriteString(",")
1123		}
1124		b.WriteString("\"" + f + "\":" + itoa(seen[f]))
1125	}
1126	b.WriteString("}")
1127	if detail {
1128		b.WriteString(",\"history\":[")
1129		for i := len(p.History) - 1; i >= 0; i-- { // newest first
1130			h := p.History[i]
1131			if i < len(p.History)-1 {
1132				b.WriteString(",")
1133			}
1134			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) + "}")
1135		}
1136		b.WriteString("],\"roster\":[")
1137		for i, m := range p.Roster {
1138			if i > 0 {
1139				b.WriteString(",")
1140			}
1141			b.WriteString("{\"addr\":\"" + m.Addr.String() + "\",\"flag\":\"" + m.Flag + "\",\"paid\":" + itoa(m.Paid) + ",\"earned\":" + itoa(m.Earned) + "}")
1142		}
1143		b.WriteString("],\"shotlog\":[")
1144		for i := len(p.Shots) - 1; i >= 0; i-- { // newest first
1145			t := p.Shots[i]
1146			if i < len(p.Shots)-1 {
1147				b.WriteString(",")
1148			}
1149			b.WriteString("{\"n\":" + itoa(t.N) + ",\"bidder\":\"" + t.Bidder.String() + "\",\"paid\":" + itoa(t.Paid) + ",\"take\":" + itoa(t.Take) + ",\"bps\":" + itoa(t.Bps) + ",\"commitH\":" + itoa(t.CommitH) + ",\"commit\":\"" + hex.EncodeToString([]byte(t.Hash)) + "\",\"revealAt\":" + itoa(t.CommitH+ShotDelay) + ",\"revealBy\":" + itoa(t.CommitH+ShotDelay+ShotWindow) + ",\"resolved\":" + jbool(t.Resolved) + ",\"hit\":" + jbool(t.Won) + ",\"void\":" + jbool(t.Void) + ",\"drawH\":" + itoa(t.DrawH) + "}")
1150		}
1151		b.WriteString("]")
1152	}
1153	b.WriteString("}")
1154	return b.String()
1155}
1156
1157// jsonCommits lists the unused commitments in order, hex, so a keeper that
1158// starts can tell which of them it holds the secrets for and Retire the rest.
1159func jsonCommits() string {
1160	var b strings.Builder
1161	b.WriteString("{\"height\":" + itoa(runtime.ChainHeight()) + ",\"commits\":[")
1162	for i := head; i < len(commits); i++ {
1163		if i > head {
1164			b.WriteString(",")
1165		}
1166		b.WriteString("\"" + hex.EncodeToString([]byte(commits[i])) + "\"")
1167	}
1168	b.WriteString("]}")
1169	return b.String()
1170}
1171
1172// commitHead is the next commitment a shot would take, hex, "" when none is free.
1173func commitHead() string {
1174	if head >= len(commits) {
1175		return ""
1176	}
1177	return hex.EncodeToString([]byte(commits[head]))
1178}
1179
1180func jsonAll() string {
1181	var b strings.Builder
1182	b.WriteString("{\"now\":" + itoa(time.Now().Unix()) + ",\"height\":" + itoa(runtime.ChainHeight()) + ",\"realm\":\"" + pkgPath + "\"")
1183	b.WriteString(",\"split\":{\"pot\":" + itoa(PotShare) + ",\"div\":" + itoa(DivShare) + ",\"ref\":" + itoa(RefShare) + ",\"creator\":" + itoa(CreatorShare) + ",\"treasury\":" + itoa(TreasuryShare) + ",\"team\":" + itoa(TeamShare) + ",\"roster\":" + itoa(int64(RosterMax)) +
1184		",\"shotMaxBps\":" + itoa(ShotMaxBps) + ",\"shotFair\":" + itoa(ShotFair) + ",\"shotDelay\":" + itoa(ShotDelay) + ",\"shotWindow\":" + itoa(ShotWindow) + ",\"minClock\":" + itoa(MinClock) +
1185		",\"minShot\":" + itoa(MinShot) + ",\"revealTip\":" + itoa(RevealTip) + ",\"keeperFresh\":" + itoa(KeeperFresh) + "}")
1186	b.WriteString(",\"shotsOn\":" + jbool(shotsOn))
1187	b.WriteString(",\"treasury\":\"" + treasuryAddr.String() + "\",\"treasuryPaid\":" + itoa(treasuryPaid))
1188	b.WriteString(",\"keeper\":\"" + keeper.String() + "\",\"keeperSeen\":" + unix(keeperSeen) + ",\"commitsFree\":" + itoa(int64(len(commits)-head)) + ",\"commitHead\":\"" + commitHead() + "\",\"voided\":" + itoa(voided) + ",\"late\":" + itoa(late))
1189	b.WriteString(",\"pools\":[")
1190	for i, p := range pools {
1191		if i > 0 {
1192			b.WriteString(",")
1193		}
1194		b.WriteString(p.json(false))
1195	}
1196	b.WriteString("],\"teams\":" + jsonTeams() + "}")
1197	return b.String()
1198}
1199
1200// jsonTeams is the country leaderboard: paid in, won, and players per flag.
1201func jsonTeams() string {
1202	var b strings.Builder
1203	b.WriteString("{")
1204	i := 0
1205	for f, paid := range teamPaid {
1206		if i > 0 {
1207			b.WriteString(",")
1208		}
1209		i++
1210		b.WriteString("\"" + f + "\":{\"paid\":" + itoa(paid) + ",\"won\":" + itoa(teamWon[f]) + ",\"players\":" + itoa(teamSize[f]) + "}")
1211	}
1212	b.WriteString("}")
1213	return b.String()
1214}
1215
1216// jsonMe is what the page shows a connected wallet about itself.
1217func jsonMe(a address) string {
1218	pl := players[a]
1219	if pl == nil {
1220		pl = &Player{}
1221	}
1222	return "{\"addr\":\"" + a.String() + "\",\"referrer\":\"" + pl.Referrer.String() + "\",\"recruits\":" + itoa(pl.Recruits) +
1223		",\"flag\":\"" + pl.Flag + "\",\"bids\":" + itoa(pl.Bids) + ",\"paid\":" + itoa(pl.Paid) +
1224		",\"divEarned\":" + itoa(pl.DivEarned) + ",\"refEarned\":" + itoa(pl.RefEarned) + ",\"won\":" + itoa(pl.Won) +
1225		",\"shots\":" + itoa(pl.Shots) + ",\"hits\":" + itoa(pl.Hits) + ",\"owed\":" + itoa(pl.Owed) + ",\"withdrawn\":" + itoa(pl.Withdrawn) + "}"
1226}
1227
1228func (p *Pool) status() string {
1229	switch {
1230	case p.Cancelled:
1231		return "cancelled"
1232	case p.Over():
1233		s := "won by " + short(p.LastBidder)
1234		if p.Popped {
1235			s = "popped by " + short(p.LastBidder)
1236		}
1237		if !p.Paid {
1238			s += " (unclaimed)"
1239		}
1240		return s
1241	case p.Bids == 0:
1242		return "no bids yet · " + dur(p.Window) + " window · next " + GNOT(p.NextPrice())
1243	default:
1244		left := p.Deadline().Unix() - time.Now().Unix()
1245		s := dur(left) + " left"
1246		if p.Clock < p.Window {
1247			s += " (⚡ " + dur(p.Clock) + " clock)"
1248		}
1249		return s + " · " + short(p.LastBidder) + " on top · next " + GNOT(p.NextPrice())
1250	}
1251}
1252
1253func markdownAll() string {
1254	var b strings.Builder
1255	b.WriteString("# Bubble Rumble v6\n\n")
1256	b.WriteString("Last bid standing takes the pot. Of every bid, 50% goes into the pot, 30% is paid at once to the pool's earlier bidders, 10% to whoever invited the bidder, 5% to the pool's creator and 5% to the BUBBLE treasury (spent only by a vote of the BUBBLE holders). ")
1257	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. Pools never expire: a pool closes when its clock runs out after the last bid, or when a shot pops it. A bid may carry a shot at the bubble: a chance of at most " + itoa(ShotMaxBps/100) + "% to pop it and take the pot, drawn by the keeper's commit-reveal a few blocks later (the keeper never bids; a shot it fails to reveal in time is a miss); every miss is counted on the bubble.\n\n")
1258	b.WriteString("Play at [bubblerumble.net](https://bubblerumble.net).\n\n## Pools\n\n")
1259	if len(pools) == 0 {
1260		b.WriteString("_none yet_\n")
1261	}
1262	for _, p := range pools {
1263		b.WriteString("- [#" + itoa(p.ID) + " " + p.Name + "](/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble6:" + itoa(p.ID) + ") · " + GNOT(p.Pot) + " · " + p.status() + "\n")
1264	}
1265	return b.String()
1266}
1267
1268func (p *Pool) markdown() string {
1269	var b strings.Builder
1270	b.WriteString("# " + p.Name + "\n\n")
1271	b.WriteString("- pot: **" + GNOT(p.Pot) + "**\n- status: " + p.status() + "\n- window: " + dur(p.Window) + "\n- base price: " + GNOT(p.BasePrice) + " · next bid: " + GNOT(p.NextPrice()) + "\n- bids: " + itoa(p.Bids) + " · creator: " + short(p.Creator) + "\n")
1272	if len(p.Shots) > 0 {
1273		b.WriteString("- shots: " + itoa(int64(len(p.Shots))) + " · misses: " + itoa(p.Misses) + " · unrevealed: " + itoa(p.Voided) + "\n")
1274	}
1275	b.WriteString("\n## Bids\n\n")
1276	if len(p.History) == 0 {
1277		b.WriteString("_none yet_\n")
1278	}
1279	for i := len(p.History) - 1; i >= 0; i-- {
1280		h := p.History[i]
1281		line := "- #" + itoa(h.N) + " " + short(h.Bidder)
1282		if h.Flag != "" {
1283			line += " (" + h.Flag + ")"
1284		}
1285		line += " paid " + GNOT(h.Price)
1286		if h.Shot > 0 {
1287			line += " + a " + GNOT(h.Shot) + " shot"
1288		}
1289		b.WriteString(line + " · pot " + GNOT(h.Pot) + "\n")
1290	}
1291	return b.String()
1292}