1// Package bubblerumble4 is the fourth Bubble Rumble realm. v3's game stays whole:2// of every bid 55% goes to the pot, 30% is credited at once to the pool's earlier3// bidders pro-rata to what they paid in, 10% to whoever invited the bidder, 5% to4// the pool's creator; every pool has a last day, a flag war at the close, credited5// earnings and Withdraw, a clock that can be bought down but never under MinClock.6// What v4 changes is the shot: it can now win the pot.7//8// - A shot is still extra money on a bid, a chance to pop the bubble at once.9// The chance is 0.8 × shot / take, at most 1% a shot, where the take is what10// a pop would pay the shooter right now: the pot less the team's 35%, times the11// winner's live share (LiveBps). So the pot keeps a fifth of every shot's fair12// value however far the last day has slid, and a hit pops the bubble: the pool13// closes with the shooter as its winner and Claim pays it as any pop.14// - The draw's randomness is a commit-reveal by the keeper — the operator's own15// box, which also settles pools on time. The keeper publishes sha256 hashes of16// secrets ahead of time (Commit); a bid with a shot takes the next unused one;17// the shot is drawn when someone (the keeper, or anyone) reveals that secret18// (Reveal) in the blocks ShotDelay to ShotDelay+ShotWindow after the bid. The19// seed is the secret with the shot's own facts and the reveal block's height20// and time. Nobody without the secret can grind it: not the shooter, not a21// validator. The secret's holder knows the two candidate block times a block22// ahead and may pick which of the window's three blocks to reveal in — three23// rolls, at most 2.4× fair on a 1% shot — which is why the keeper key and its24// operator never bid or shoot.25// - A shot not revealed in its window, or on a pool that closed first, is void:26// it goes into the pot and counts as a miss, as in v3. Nothing is refunded, so27// withholding a reveal never pays whoever withholds it. What remains is that a28// keeper who leads a pot could withhold a stranger's hit and let the money fall29// into the pot it leads: refused shots are counted on the pool for all to see,30// and so are reveals that came in the window's later blocks (the honest keeper31// always reveals at the first), so a keeper choosing its block would show.32// - Shots are sold only while the keeper is alive: a shot needs a free commitment33// and a sign of life (Commit or Heartbeat) within KeeperFresh. Otherwise the bid34// stands and the shot's money goes straight back in the same transaction.35// - Shots are refused in the last quarter of a pool's life (which also keeps every36// reveal window clear of the last day) and under MinShot. RevealTip is taken37// from every shot and credited to the keeper whoever sends the reveal (the38// secret sits in the mempool, and a proposer could copy it), so the keeper39// pays for itself. The keeper's own shots are refused outright.40// - Reveals go in shot order, so among two pending shots nobody chooses which pops.41//42// Residuals, stated plainly: the secret holder's three rolls; the keeper-defender43// veto above; a proposer who reads the keeper's reveal in the mempool holds the44// secret too, and proposing the window's blocks could drop the reveal for a roll45// it does not like or leave it out altogether (a void), which Late and Voided46// show; keeper and validator together could grind. SetShots is the switch if any47// of it is seen. SetKeeper drops every unused commitment.48package bubblerumble44950import (51 "chain"52 "chain/banker"53 "chain/runtime"54 "chain/runtime/unsafe"55 "crypto/sha256"56 "encoding/binary"57 "encoding/hex"58 "math"59 "math/bits"60 "strconv"61 "strings"62 "time"63)6465const (66 MinWindow int64 = 10 // seconds67 MaxWindow int64 = 30 * 24 * 3600 // 30 days68 MinLife int64 = 3600 // an hour69 MaxLife int64 = 30 * 24 * 3600 // 30 days70 MinBasePrice int64 = 1_000 // ugnot, 0.001 GNOT71 MaxBasePrice int64 = 1_000_000_000_000 // ugnot, 1,000,000 GNOT72 MaxNameLen = 4073 HistoryLen = 50 // bids kept per pool for the feed74 RosterMax = 100 // distinct bidders a pool pays dividends, team shares and the close split to75 MaxBoost int64 = 10 // the shortest clock a bid can buy is window/MaxBoost...76 MinClock int64 = 300 // ...and never under five minutes: people must be able to answer7778 // where every bid goes, in percent79 PotShare int64 = 5580 DivShare int64 = 30 // to earlier bidders of the pool, pro-rata by weight81 RefShare int64 = 10 // to the bidder's inviter82 CreatorShare int64 = 583 // of the pot at the close, to the other bidders under the winner's flag84 TeamShare int64 = 358586 ShotMaxBps int64 = 100 // 1% a shot, whatever is paid87 ShotFair int64 = 8000 // chance in bps = ShotFair × shot / take (0.8 of fair odds)88 ShotDelay int64 = 3 // blocks after the shot was paid for before it can be revealed...89 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 holder90 MinShot int64 = 100_000 // ugnot: no shot under 0.1 GNOT, so dust cannot make reveals cost more than they tip91 RevealTip int64 = 20_000 // ugnot: off every shot, to whoever reveals it92 KeeperFresh int64 = 300 // seconds: shots are sold only this soon after the keeper's last sign of life (it heartbeats every two minutes)93 LiveFloor int64 = 2500 // bps: no shots once the winner's live share has slid under a quarter (which also keeps every reveal window clear of the last day)94 MaxCommits = 500 // unused commitments the queue holds at most95 CommitBatch = 100 // hashes one Commit may bring96 CommitCompact = 200 // used commitments kept before the queue is compacted97)9899// admin is the realm's deployer: the only address that may turn shots off.100const admin = address("g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr")101102// pkgPath must match gnomod.toml: the realm's own address derives from it.103const pkgPath = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble4"104105type Entry struct {106 N int64107 Bidder address108 Flag string109 Price int64 // what was paid for the bid itself; at least the price at the time110 Shot int64 // extra paid for a chance to pop the bubble111 Clock int64 // seconds this bid had to stand112 Pot int64 // pot right after this bid113 At time.Time114}115116// Member is a distinct bidder of a pool: dividends, team shares and the close117// split go to the roster. The flag is the one flown at the first bid here.118type Member struct {119 Addr address120 Flag string121 Paid int64 // gross paid into this pool (bids and shots)122 Earned int64 // dividends received from this pool123}124125// Shot is a shot at the bubble waiting to be revealed, or resolved.126type Shot struct {127 N int64 // 1-based within the pool128 Bidder address129 Paid int64 // ugnot paid for it (the tip comes out of this at the reveal)130 Take int64 // what a pop would have paid the shooter when the odds were set131 Bps int64 // chance, in basis points132 CommitH int64 // block height when paid; revealable at CommitH+ShotDelay and the ShotWindow blocks after133 Hash string // the commitment it took: sha256 of the secret that decides it, 32 raw bytes134 Resolved bool135 Won bool136 Void bool // not revealed in its window, or the pool closed first: a miss137 DrawH int64138 Revealer address139}140141type Pool struct {142 ID int64143 Name string144 Creator address145 Window int64 // seconds the last bid must stand to win146 Life int64 // seconds from the first bid to the last day147 BasePrice int64 // ugnot; the Nth bid costs BasePrice·√N148 Seed int64149 Pot int64150 Bids int64151 LastBidder address152 LastBidAt time.Time153 FirstBidAt time.Time154 Clock int64 // seconds the current top bid has to stand155 CreatedAt time.Time156 Paid bool // the pot went out, or the seed back to the creator157 Cancelled bool158 Popped bool // a shot ended it159 PoppedAt time.Time160 DivPaid int64 // dividends paid out of this pool so far161 RefPaid int64162 CreatorPaid int64163 ShotPaid int64 // slices paid to hits while the pool went on164 Misses int64 // shots resolved and lost, voids included: the scars on the bubble165 Voided int64 // of those, the shots nobody revealed in time166 Late int64 // shots revealed in their window but not at its first block: a keeper choosing its block would show here167 ShotEscrow int64 // shots paid for and not yet resolved: in the pot once they are168 WinnerFlag string169 TeamPaid int64 // what the winner's team received at the close170 SharedPaid int64 // the pro-rata part of the close171 WinnerPaid int64172 History []Entry173 Roster []*Member174 Shots []*Shot175 Settled int // shots before this index are all resolved (they resolve strictly in order): where settleShots starts176}177178// Player is what the realm remembers about an address across pools.179type Player struct {180 Referrer address // set by the first bid, permanent181 Recruits int64182 Flag string183 Bids int64184 Paid int64185 DivEarned int64186 RefEarned int64187 Won int64 // pots, team shares, close splits and shot slices received188 Shots int64189 Hits int64190 Owed int64 // credited and not yet withdrawn191 Withdrawn int64192}193194var (195 shotsOn = true196 keeper address // who commits and heartbeats (SetKeeper); the admin may too197 keeperSeen time.Time // the keeper's last Commit or Heartbeat198 commits []string // the hashes of the keeper's secrets (32 raw bytes each), in order; the first head are used199 head int200 voided int64 // shots voided over all pools201 late int64 // shots revealed after the first block of their window, over all pools202 pools []*Pool203 players = map[address]*Player{}204 teamPaid = map[string]int64{} // flag -> gross bid volume under it205 teamWon = map[string]int64{} // flag -> pots closed under it206 teamSize = map[string]int64{} // flag -> players flying it207 realmAddr = chain.PackageAddress(pkgPath)208)209210// Price of the next bid in a pool with the given base price and n bids so far.211func Price(base, n int64) int64 {212 return int64(math.Ceil(float64(base) * math.Sqrt(float64(n+1))))213}214215// LastDay is when the pool's life runs out; zero before the first bid.216func (p *Pool) LastDay() time.Time {217 if p.FirstBidAt.IsZero() {218 return time.Time{}219 }220 return p.FirstBidAt.Add(time.Duration(p.Life) * time.Second)221}222223// Deadline is when the pool closes if nothing changes: the clock or the last224// day, whichever comes first; the pop, if a shot ended it.225func (p *Pool) Deadline() time.Time {226 if p.Popped {227 return p.PoppedAt228 }229 d := p.LastBidAt.Add(time.Duration(p.Clock) * time.Second)230 if last := p.LastDay(); !last.IsZero() && last.Before(d) {231 return last232 }233 return d234}235236func (p *Pool) Over() bool { return p.Bids > 0 && (p.Popped || !time.Now().Before(p.Deadline())) }237func (p *Pool) NextPrice() int64 { return Price(p.BasePrice, p.Bids) }238239// LiveBps is how much of the pot still goes the winner's way, in basis points240// of the pot: 10000 at the first bid, sliding to 0 on the last day. Frozen at241// the close.242func (p *Pool) LiveBps() int64 {243 if p.FirstBidAt.IsZero() || p.Life <= 0 {244 return 10000245 }246 at := time.Now()247 if p.Over() {248 at = p.Deadline()249 }250 elapsed := at.Unix() - p.FirstBidAt.Unix()251 if elapsed <= 0 {252 return 10000253 }254 if elapsed >= p.Life {255 return 0256 }257 return 10000 * (p.Life - elapsed) / p.Life258}259260// ClockFor is the clock a bid buys by paying sent against a price: the window261// divided by the multiple paid, never below window/MaxBoost.262func ClockFor(window, price, sent int64) int64 {263 c := int64(float64(window) * float64(price) / float64(sent))264 if c < window/MaxBoost {265 c = window / MaxBoost266 }267 if c < MinClock {268 c = MinClock269 }270 if c > window {271 c = window272 }273 return c274}275276// mulDiv is a×b/c without overflowing on the way: pots and paid-ins are both277// in ugnot and their product passes int64 at a few thousand GNOT each.278func mulDiv(a, b, c int64) int64 {279 if a < 0 || b < 0 || c <= 0 {280 panic("mulDiv: negative")281 }282 hi, lo := bits.Mul64(uint64(a), uint64(b))283 if hi >= uint64(c) {284 panic("mulDiv: overflow")285 }286 q, _ := bits.Div64(hi, lo, uint64(c))287 return int64(q)288}289290// ShotBps is the chance a shot buys against what a pop would pay, in basis points.291func ShotBps(shot, take int64) int64 {292 if shot <= 0 || take <= 0 {293 return 0294 }295 bps := ShotFair * shot / take296 if bps > ShotMaxBps {297 bps = ShotMaxBps298 }299 return bps300}301302// Take is the most a pop could pay this shooter if the pool closed now on a pot303// of this size: Claim's own arithmetic, run dry, with the team's share counted304// as the shooter's too — one bid from a friend (or a second address) under a305// flag nobody else flies collects it, so the odds must assume it is collected.306// What is not the shooter's is the slide: the part of the rest that the last307// day has moved to the whole roster by paid-in.308func (p *Pool) Take(shooter address, pot int64) int64 {309 toWinner, cut := p.payout(shooter, pot, false)310 return toWinner + cut311}312313// CreatePool opens a pool. Any ugnot sent with the call seeds its pot. The314// creator earns CreatorShare of every bid. life is the pool's life in seconds315// from its first bid; 0 means ten windows (at least an hour, at most 30 days).316func CreatePool(cur realm, name string, window int64, basePrice int64, life int64) int64 {317 creator := userCaller(cur)318 name = cleanName(name)319 if window < MinWindow || window > MaxWindow {320 panic("window must be between 10s and 30d")321 }322 if basePrice < MinBasePrice || basePrice > MaxBasePrice {323 panic("base price must be between 0.001 and 1,000,000 GNOT")324 }325 if life == 0 { // ten windows of fighting, within the bounds326 life = 10 * window327 if life < MinLife {328 life = MinLife329 }330 if life > MaxLife {331 life = MaxLife332 }333 }334 if life < MinLife || life > MaxLife {335 panic("life must be between 1h and 30d")336 }337 if life < window {338 panic("life must be at least the window")339 }340 seed := ugnotSent()341 p := newPool(name, creator, window, basePrice, life, seed)342 chain.Emit("PoolCreated", "id", itoa(p.ID), "creator", creator.String(), "seed", itoa(seed))343 return p.ID344}345346func newPool(name string, creator address, window, basePrice, life, seed int64) *Pool {347 p := &Pool{348 ID: int64(len(pools)) + 1, Name: name, Creator: creator, Window: window, Life: life,349 BasePrice: basePrice, Seed: seed, Pot: seed, CreatedAt: time.Now(),350 }351 pools = append(pools, p)352 return p353}354355// Bid pays at least the current price and resets the clock. ref is the address356// that invited the bidder ("" for none; only the first bid ever sets it), flag357// a two-letter country to play under ("" keeps the last one; the flag is locked358// per pool at the first bid there), shot how much of the money sent is a shot359// at popping the bubble (0 for none): the rest must cover the price, and paying360// more than the price shortens the clock.361func Bid(cur realm, id int64, ref string, flag string, shot int64) {362 bidder := userCaller(cur)363 p := pool(id)364 sent := ugnotSent()365 if p.Cancelled {366 panic("pool was cancelled")367 }368 // a pool that closed (or popped) since the bidder looked: the money goes back369 if p.Over() {370 if sent > 0 {371 send(cur, bidder, sent)372 chain.Emit("Refund", "id", itoa(id), "to", bidder.String(), "ugnot", itoa(sent))373 }374 if p.Popped {375 return376 }377 panic("pool is closed")378 }379 if shot < 0 || shot > sent {380 panic("shot must be between 0 and the amount sent")381 }382 price := p.NextPrice()383 bid := sent - shot384 if bid < price {385 panic("send at least " + itoa(price) + "ugnot for the bid, got " + itoa(bid) + "ugnot")386 }387 // shots of earlier bids whose window has passed are settled first388 p.settleShots()389 pl := player(bidder)390 if f := cleanFlag(flag); f != "" && f != pl.Flag {391 if pl.Flag != "" {392 teamSize[pl.Flag]--393 }394 pl.Flag = f395 teamSize[f]++396 }397 if pl.Referrer == "" && ref != "" {398 r := address(ref)399 if r.IsValid() && r != bidder {400 pl.Referrer = r401 player(r).Recruits++402 }403 }404405 // where the bid goes406 div, refCut, creatorCut := bid*DivShare/100, bid*RefShare/100, bid*CreatorShare/100407 toPot := bid - div - refCut - creatorCut408 if creatorCut > 0 {409 send(cur, p.Creator, creatorCut)410 p.CreatorPaid += creatorCut411 }412 if pl.Referrer != "" && refCut > 0 {413 send(cur, pl.Referrer, refCut)414 p.RefPaid += refCut415 player(pl.Referrer).RefEarned += refCut416 } else {417 toPot += refCut418 }419 divPaid := div - p.payDividends(cur, bidder, div)420 toPot += div - divPaid421 p.Pot += toPot422423 m := p.member(bidder, pl.Flag)424 if m.Flag == "" { // a flagless first bid does not lock "no flag": the first flag flown here does425 m.Flag = pl.Flag426 }427 // the shot, if there can be one: else its money goes straight back and the bid stands428 why := ""429 if shot > 0 {430 why = p.shotRefusal(bidder, shot)431 }432 m.Paid += sent433 pl.Bids++434 pl.Paid += sent435 if pl.Flag != "" {436 teamPaid[pl.Flag] += sent437 }438 // the odds, on the pool as it stands with this bid booked (the shooter's own paid-in weighs in the slide):439 // priced here once, and a shot that buys no chance at all is refused like the rest440 var take, bps int64441 if shot > 0 && why == "" {442 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 out443 bps = ShotBps(shot-RevealTip, take)444 if bps == 0 {445 why = "too small for any chance at this pot"446 }447 }448 if why != "" {449 m.Paid -= shot450 pl.Paid -= shot451 if pl.Flag != "" {452 teamPaid[pl.Flag] -= shot453 }454 send(cur, bidder, shot)455 chain.Emit("ShotReturned", "id", itoa(id), "bidder", bidder.String(), "ugnot", itoa(shot), "why", why)456 sent -= shot457 shot = 0458 }459 p.Bids++460 if p.FirstBidAt.IsZero() {461 p.FirstBidAt = time.Now()462 }463 p.LastBidder = bidder464 p.LastBidAt = time.Now()465 p.Clock = ClockFor(p.Window, price, bid)466 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})467 if len(p.History) > HistoryLen {468 p.History = p.History[len(p.History)-HistoryLen:]469 }470 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))471472 if shot > 0 {473 t := &Shot{N: int64(len(p.Shots)) + 1, Bidder: bidder, Paid: shot, Take: take, Bps: bps, CommitH: runtime.ChainHeight(), Hash: commits[head]}474 head++475 p.Shots = append(p.Shots, t)476 p.ShotEscrow += shot477 pl.Shots++478 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))479 }480}481482// shotRefusal is why a shot cannot be sold on this pool right now ("" when it483// can): the checks a bid runs before taking one, in the order the page shows.484// The odds themselves are checked by the caller, on the booked state.485func (p *Pool) shotRefusal(shooter address, shot int64) string {486 switch {487 case !shotsOn:488 return "shots are off"489 case keeper != "" && shooter == keeper:490 return "the keeper never shoots"491 case shot < MinShot:492 return "under the smallest shot, " + GNOT(MinShot)493 case head >= len(commits):494 return "no commitment free: the keeper is away"495 case keeperSeen.IsZero() || time.Now().Unix()-keeperSeen.Unix() > KeeperFresh:496 return "the keeper has not been seen for " + dur(KeeperFresh)497 case p.LiveBps() < LiveFloor:498 return "the winner's share has slid under a quarter"499 }500 return ""501}502503// SetShots turns shots off (or on again); only the deployer may.504func SetShots(cur realm, on bool) {505 if userCaller(cur) != admin {506 panic("only the deployer")507 }508 shotsOn = on509 chain.Emit("Shots", "on", jbool(on))510}511512// SetKeeper names the address that commits and heartbeats; only the deployer513// may. Every unused commitment is dropped with it: a rotated or compromised514// keeper's secrets never decide a shot.515func SetKeeper(cur realm, addr address) {516 if userCaller(cur) != admin {517 panic("only the deployer")518 }519 if !addr.IsValid() {520 panic("bad address")521 }522 keeper = addr523 commits = commits[:head]524 chain.Emit("Keeper", "addr", addr.String())525}526527func keeperCaller(cur realm) address {528 who := userCaller(cur)529 if who == "" || who != keeper {530 panic("only the keeper")531 }532 return who533}534535func keeperOrAdmin(cur realm) address {536 who := userCaller(cur)537 if who != admin && (who == "" || who != keeper) {538 panic("only the keeper or the deployer")539 }540 return who541}542543// Heartbeat is the keeper's sign of life: shots are sold only within544// KeeperFresh of the last one (a Commit counts too).545func Heartbeat(cur realm) {546 keeperCaller(cur)547 keeperSeen = time.Now()548}549550// Commit queues the hashes of secrets the keeper will reveal later, in order,551// as a comma-separated list of 64 hex characters each; only the keeper, whose552// box holds the secrets. A hash still in the queue (used or not) is refused: a553// repeated secret would be public after its first reveal (a safety net against554// the keeper repeating itself; compaction forgets the oldest). The queue holds555// at most MaxCommits unused; the used ones in front of it are compacted away556// past CommitCompact.557func Commit(cur realm, hashes string) {558 keeperCaller(cur)559 keeperSeen = time.Now()560 parts := strings.Split(hashes, ",")561 if len(parts) == 0 || len(parts) > CommitBatch {562 panic("1 to " + strconv.Itoa(CommitBatch) + " hashes")563 }564 if len(commits)-head+len(parts) > MaxCommits {565 panic("the queue holds " + strconv.Itoa(MaxCommits) + " unused commitments at most")566 }567 if head > CommitCompact {568 commits = append([]string(nil), commits[head:]...)569 head = 0570 }571 for _, part := range parts {572 b, err := hex.DecodeString(strings.TrimSpace(part))573 if err != nil || len(b) != 32 {574 panic("a hash is 64 hex characters")575 }576 h := string(b)577 for _, c := range commits { // a safety net against the keeper repeating itself, over what the queue still holds578 if c == h {579 panic("hash already committed: " + part)580 }581 }582 commits = append(commits, h)583 }584 chain.Emit("Commit", "n", itoa(int64(len(parts))), "free", itoa(int64(len(commits)-head)))585}586587// Retire drops the first n unused commitments: a keeper that lost the secrets588// behind them (the box rebuilt, the file gone) clears them so shots can go on.589// The deployer may too. It is not a sign of life.590func Retire(cur realm, n int64) {591 keeperOrAdmin(cur)592 if n <= 0 {593 panic("retire at least one")594 }595 if int(n) > len(commits)-head {596 n = int64(len(commits) - head)597 }598 commits = append(commits[:head], commits[head+int(n):]...)599 chain.Emit("Retire", "n", itoa(n), "free", itoa(int64(len(commits)-head)))600}601602// Reveal draws shots with the secrets behind their commitments: a comma-separated603// list of pool:shot:secretHex, any pools, in one transaction — the keeper sends604// every shot that is due in one call, so a run of shots on one block never605// outpaces it. Anyone may call it; the secret is the proof, and the caller is606// tipped RevealTip out of each shot revealed. Shots go in order: an earlier one607// still pending must come first (in the list, or before). An entry not yet due608// is skipped, one with a wrong secret or already resolved is skipped with an609// event, so a stale entry never voids the rest; a malformed one (no such pool610// or shot, a cancelled pool, not pool:shot:secret) aborts the whole call, which611// only the keeper's own bug could send. The reveal must land in the612// blocks CommitH+ShotDelay through CommitH+ShotDelay+ShotWindow; later, or on a613// pool that has closed, the shot is already void (settleShots runs first): into614// the pot as a miss. A hit pops the bubble: the pool closes now with the shooter615// as its winner, settled at Claim.616func Reveal(cur realm, reveals string) {617 who := userCaller(cur)618 h := runtime.ChainHeight()619 done := 0620 for _, entry := range strings.Split(reveals, ",") {621 f := strings.Split(strings.TrimSpace(entry), ":")622 if len(f) != 3 {623 panic("each entry is pool:shot:secret")624 }625 id, n := parseID(f[0]), parseNum(f[1])626 p := pool(id)627 if p.Cancelled {628 panic("pool was cancelled")629 }630 p.settleShots()631 if n < 1 || int(n) > len(p.Shots) {632 panic("no such shot")633 }634 t := p.Shots[n-1]635 if t.Resolved || (n > 1 && !p.Shots[n-2].Resolved) {636 chain.Emit("RevealSkipped", "id", itoa(id), "shot", itoa(n), "why", jif(t.Resolved, "resolved", "earlier shot pending"))637 continue638 }639 if h < t.CommitH+ShotDelay {640 continue // not due yet: the keeper sends a block early to be sure of the window641 }642 secret, err := hex.DecodeString(strings.TrimSpace(f[2]))643 sum := sha256.Sum256(secret)644 if err != nil || string(sum[:]) != t.Hash {645 chain.Emit("RevealSkipped", "id", itoa(id), "shot", itoa(n), "why", "wrong secret")646 continue647 }648 // (a shot past its window, or on a pool that closed, was voided by settleShots above and skipped as resolved)649 done++650 t.Resolved, t.DrawH, t.Revealer = true, h, who651 p.ShotEscrow -= t.Paid652 // the tip is the keeper's whoever sent the reveal: the secret sits in the mempool, and a proposer could653 // copy it into a reveal of its own; the seed does not care who reveals, so nothing else changes654 tip := RevealTip655 if tip > t.Paid {656 tip = t.Paid657 }658 if keeper != "" {659 credit(keeper, tip)660 } else {661 credit(who, tip)662 }663 p.Pot += t.Paid - tip664 if h > t.CommitH+ShotDelay { // revealed in the window but not at its first block: the honest keeper's tell, counted665 p.Late++666 late++667 }668 t.Won = shotHits(seedFor(id, t, secret, h), t.Bps)669 chain.Emit("Revealed", "id", itoa(id), "shot", itoa(n), "bidder", t.Bidder.String(), "hit", jbool(t.Won), "bps", itoa(t.Bps), "by", who.String())670 if !t.Won {671 p.Misses++672 continue673 }674 player(t.Bidder).Hits++675 p.Popped, p.PoppedAt, p.LastBidder = true, time.Now(), t.Bidder676 chain.Emit("Popped", "id", itoa(id), "by", t.Bidder.String(), "pot", itoa(p.Pot))677 }678 if done == 0 {679 panic("nothing revealed")680 }681}682683// Settle voids the shots at a pool that nobody revealed in time, or that its684// close overtook. Anyone may call it; every bid, reveal and claim on the pool685// does the same first.686func Settle(cur realm, id int64) {687 pool(id).settleShots()688}689690// settleShots voids, in order, every pending shot whose window has passed or691// whose pool is over: the shot's money goes into the pot and counts as a miss;692// nobody is tipped.693func (p *Pool) settleShots() {694 h := runtime.ChainHeight()695 over := p.Over()696 for p.Settled < len(p.Shots) && p.Shots[p.Settled].Resolved {697 p.Settled++698 }699 for _, t := range p.Shots[p.Settled:] {700 if t.Resolved || (h <= t.CommitH+ShotDelay+ShotWindow && !over) {701 continue702 }703 t.Resolved, t.Void, t.DrawH = true, true, h704 p.ShotEscrow -= t.Paid705 p.Pot += t.Paid706 p.Misses++707 p.Voided++708 voided++709 chain.Emit("Voided", "id", itoa(p.ID), "shot", itoa(t.N), "bidder", t.Bidder.String(), "why", jif(over, "closed", "unrevealed"))710 }711}712713func jif(c bool, a, b string) string {714 if c {715 return a716 }717 return b718}719720// seedFor is the draw's seed: the secret with the shot's own facts and the721// reveal block's height and time. Everything but the secret is public, and the722// secret is the keeper's until the reveal; the block's time is the validators'.723func seedFor(poolID int64, t *Shot, secret []byte, h int64) uint64 {724 var b []byte725 b = append(b, pkgPath...)726 b = binary.BigEndian.AppendUint64(b, uint64(poolID))727 b = binary.BigEndian.AppendUint64(b, uint64(t.N))728 b = append(b, t.Bidder.String()...)729 b = binary.BigEndian.AppendUint64(b, uint64(t.CommitH))730 b = append(b, secret...)731 b = binary.BigEndian.AppendUint64(b, uint64(h))732 b = binary.BigEndian.AppendUint64(b, uint64(time.Now().UnixNano()))733 sum := sha256.Sum256(b)734 return binary.BigEndian.Uint64(sum[:8])735}736737// shotHits is the draw: a uniform number in [0, 10000) under the chance.738func shotHits(seed uint64, bps int64) bool {739 return int64(seed%10000) < bps740}741742// payDividends credits div to the pool's earlier bidders, pro-rata to what743// each has paid in, skipping the bidder; whatever cannot be placed (no earlier744// bidders, rounding) is returned to go into the pot.745func (p *Pool) payDividends(cur realm, bidder address, div int64) int64 {746 var total int64747 for _, m := range p.Roster {748 if m.Addr != bidder {749 total += m.Paid750 }751 }752 if total == 0 || div == 0 {753 return div754 }755 var paid int64756 for _, m := range p.Roster {757 if m.Addr == bidder {758 continue759 }760 amt := mulDiv(div, m.Paid, total)761 if amt > 0 {762 credit(m.Addr, amt)763 m.Earned += amt764 player(m.Addr).DivEarned += amt765 paid += amt766 }767 }768 p.DivPaid += paid769 return div - paid770}771772// member finds or adds a bidder on the roster; a full roster drops its oldest.773// A new member's flag is the one given; once set it is locked for this pool.774func (p *Pool) member(a address, flag string) *Member {775 for _, m := range p.Roster {776 if m.Addr == a {777 return m778 }779 }780 if len(p.Roster) >= RosterMax {781 p.Roster = p.Roster[1:]782 }783 m := &Member{Addr: a, Flag: flag}784 p.Roster = append(p.Roster, m)785 return m786}787788// Claim settles a finished pool: everything is credited, and Withdraw sends789// it. TeamShare of the pot goes to the OTHER roster members flying the790// winner's flag, pro-rata by paid-in — or, when nobody else flew it, to all791// the other roster members. Of the rest, the live part (LiveBps) is the792// winner's and the shared part is split among the whole roster pro-rata by793// paid-in. A pool with a single bidder credits them everything. Rounding goes794// to the winner. Anyone may call it.795func Claim(cur realm, id int64) {796 p := pool(id)797 if p.Cancelled {798 panic("pool was cancelled")799 }800 p.settleShots()801 if !p.Over() {802 panic("clock still running")803 }804 if p.Paid {805 panic("already paid out")806 }807 p.Paid = true808 winner := p.LastBidder809 flag := ""810 if m := p.find(winner); m != nil {811 flag = m.Flag812 }813 p.WinnerFlag = flag814 toWinner, _ := p.payout(winner, p.Pot, true)815 if toWinner > 0 {816 credit(winner, toWinner)817 player(winner).Won += toWinner818 }819 p.WinnerPaid = toWinner820 if flag != "" {821 teamWon[flag] += p.Pot822 }823 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()))824}825826// payout is the close's arithmetic for a winner and a pot: TeamShare to the827// other roster members under the winner's flag (or, when nobody else flew it,828// to all the others), then the shared part of the rest — what the live share829// has slid away from — to the whole roster pro-rata by paid-in; the winner830// keeps the rest and the rounding. With pay it credits and books the shares;831// dry, it only says what the winner would get and what the team would (Take832// prices shots with both).833func (p *Pool) payout(winner address, pot int64, pay bool) (toWinner, teamCut int64) {834 flag := ""835 if m := p.find(winner); m != nil {836 flag = m.Flag837 }838 var team int64839 for _, m := range p.Roster {840 if m.Addr != winner && m.Flag == flag && flag != "" {841 team += m.Paid842 }843 }844 teamFlag := flag845 if team == 0 {846 teamFlag = "" // nobody else flew it: every other bidder is the team847 for _, m := range p.Roster {848 if m.Addr != winner {849 team += m.Paid850 }851 }852 }853 toWinner = pot854 if team == 0 {855 return toWinner, 0 // alone on the roster: everything856 }857 cut := pot * TeamShare / 100858 for _, m := range p.Roster {859 if m.Addr == winner || (teamFlag != "" && m.Flag != teamFlag) {860 continue861 }862 amt := mulDiv(cut, m.Paid, team)863 if amt > 0 {864 toWinner -= amt865 teamCut += amt866 if pay {867 credit(m.Addr, amt)868 player(m.Addr).Won += amt869 p.TeamPaid += amt870 }871 }872 }873 // the rest slides: the live part stays the winner's, the shared part is everyone's by paid-in874 rest := pot - cut875 shared := rest - mulDiv(rest, p.LiveBps(), 10000)876 if shared > 0 {877 var total int64878 for _, m := range p.Roster {879 total += m.Paid880 }881 for _, m := range p.Roster {882 amt := mulDiv(shared, m.Paid, total)883 if amt > 0 && m.Addr != winner {884 toWinner -= amt885 if pay {886 credit(m.Addr, amt)887 player(m.Addr).Won += amt888 p.SharedPaid += amt889 }890 }891 }892 }893 return toWinner, teamCut894}895896// Cancel lets the creator take the seed back from a pool nobody has bid on.897func Cancel(cur realm, id int64) {898 caller := userCaller(cur)899 p := pool(id)900 if p.Creator != caller {901 panic("only the creator can cancel")902 }903 if p.Bids > 0 {904 panic("pool already has bids")905 }906 if p.Cancelled {907 panic("already cancelled")908 }909 p.Cancelled = true910 p.Paid = true911 if p.Pot > 0 {912 send(cur, caller, p.Pot)913 }914 chain.Emit("Cancelled", "id", itoa(id))915}916917func (p *Pool) find(a address) *Member {918 for _, m := range p.Roster {919 if m.Addr == a {920 return m921 }922 }923 return nil924}925926func player(a address) *Player {927 pl := players[a]928 if pl == nil {929 pl = &Player{}930 players[a] = pl931 }932 return pl933}934935func send(cur realm, to address, ugnot int64) {936 banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins(realmAddr, to, chain.Coins{{"ugnot", ugnot}})937}938939// credit books ugnot to an address; Withdraw pays it out.940func credit(to address, ugnot int64) {941 player(to).Owed += ugnot942}943944// Withdraw sends the caller everything credited to them: dividends, team945// shares, close splits, slices and won pots.946func Withdraw(cur realm) int64 {947 who := userCaller(cur)948 pl := player(who)949 amt := pl.Owed950 if amt <= 0 {951 panic("nothing to withdraw")952 }953 pl.Owed = 0954 pl.Withdrawn += amt955 send(cur, who, amt)956 chain.Emit("Withdrawn", "to", who.String(), "ugnot", itoa(amt))957 return amt958}959960// Owed is what an address could withdraw right now.961func Owed(a address) int64 { return player(a).Owed }962963func pool(id int64) *Pool {964 if id < 1 || id > int64(len(pools)) {965 panic("no such pool")966 }967 return pools[id-1]968}969970// userCaller is the signer of the transaction, refusing calls relayed through971// another realm so nobody can bid on someone else's behalf, and so no realm can972// pay for a shot and revert on a miss.973func userCaller(cur realm) address {974 prev := cur.Previous()975 if !prev.IsUserCall() {976 panic("must be a direct user call")977 }978 return prev.Address()979}980981func ugnotSent() int64 {982 sent := unsafe.OriginSend()983 for _, c := range sent {984 if c.Denom != "ugnot" {985 panic("only ugnot is accepted")986 }987 }988 return sent.AmountOf("ugnot")989}990991func cleanName(s string) string {992 s = strings.TrimSpace(s)993 if s == "" || len(s) > MaxNameLen {994 panic("name must be 1-40 characters")995 }996 for _, c := range s {997 if c < 0x20 || c == 0x7f {998 panic("name has control characters")999 }1000 }1001 return s1002}10031004// cleanFlag accepts a two-letter country code, upper-cased; anything else is "".1005func cleanFlag(s string) string {1006 s = strings.ToUpper(strings.TrimSpace(s))1007 if len(s) != 2 || s[0] < 'A' || s[0] > 'Z' || s[1] < 'A' || s[1] > 'Z' {1008 return ""1009 }1010 return s1011}10121013func itoa(n int64) string { return strconv.FormatInt(n, 10) }10141015func parseID(s string) int64 {1016 n, err := strconv.ParseInt(s, 10, 64)1017 if err != nil {1018 panic("bad pool id")1019 }1020 return n1021}10221023func parseNum(s string) int64 {1024 n, err := strconv.ParseInt(s, 10, 64)1025 if err != nil {1026 panic("bad number")1027 }1028 return n1029}10301031// GNOT formats ugnot as GNOT with the trailing zeros trimmed.1032func GNOT(ugnot int64) string {1033 whole, frac := ugnot/1_000_000, ugnot%1_000_0001034 if frac == 0 {1035 return itoa(whole) + " GNOT"1036 }1037 f := strconv.FormatInt(1_000_000+frac, 10)[1:]1038 return itoa(whole) + "." + strings.TrimRight(f, "0") + " GNOT"1039}10401041func short(a address) string {1042 s := a.String()1043 if len(s) < 12 {1044 return s1045 }1046 return s[:8] + "…" + s[len(s)-4:]1047}10481049func dur(sec int64) string {1050 if sec <= 0 {1051 return "0s"1052 }1053 d, h, m, s := sec/86400, sec%86400/3600, sec%3600/60, sec%601054 out := ""1055 if d > 0 {1056 out += itoa(d) + "d "1057 }1058 if d > 0 || h > 0 {1059 out += itoa(h) + "h "1060 }1061 if d > 0 || h > 0 || m > 0 {1062 out += itoa(m) + "m "1063 }1064 return out + itoa(s) + "s"1065}10661067// --- rendering ---------------------------------------------------------------10681069// Render serves gnoweb ("" and "<id>") and the page ("json", "json/<id>",1070// "me/<address>", "teams").1071func Render(path string) string {1072 path = strings.Trim(path, "/")1073 switch {1074 case path == "json":1075 return jsonAll()1076 case strings.HasPrefix(path, "json/"):1077 return pool(parseID(path[5:])).json(true)1078 case strings.HasPrefix(path, "me/"):1079 return jsonMe(address(path[3:]))1080 case path == "teams":1081 return jsonTeams()1082 case path == "commits":1083 return jsonCommits()1084 case path == "":1085 return markdownAll()1086 default:1087 return pool(parseID(path)).markdown()1088 }1089}10901091func unix(t time.Time) string {1092 if t.IsZero() {1093 return "0"1094 }1095 return itoa(t.Unix())1096}10971098func jbool(b bool) string {1099 if b {1100 return "true"1101 }1102 return "false"1103}11041105func (p *Pool) json(detail bool) string {1106 var b strings.Builder1107 b.WriteString("{\"id\":" + itoa(p.ID))1108 b.WriteString(",\"name\":" + strconv.Quote(p.Name))1109 b.WriteString(",\"creator\":\"" + p.Creator.String() + "\"")1110 b.WriteString(",\"window\":" + itoa(p.Window))1111 b.WriteString(",\"life\":" + itoa(p.Life))1112 b.WriteString(",\"basePrice\":" + itoa(p.BasePrice))1113 b.WriteString(",\"seed\":" + itoa(p.Seed))1114 b.WriteString(",\"pot\":" + itoa(p.Pot))1115 b.WriteString(",\"bids\":" + itoa(p.Bids))1116 b.WriteString(",\"nextPrice\":" + itoa(p.NextPrice()))1117 b.WriteString(",\"lastBidder\":\"" + p.LastBidder.String() + "\"")1118 b.WriteString(",\"lastBidAt\":" + unix(p.LastBidAt))1119 b.WriteString(",\"firstBidAt\":" + unix(p.FirstBidAt))1120 b.WriteString(",\"deadline\":" + unix(p.Deadline()))1121 b.WriteString(",\"lastDay\":" + unix(p.LastDay()))1122 b.WriteString(",\"liveBps\":" + itoa(p.LiveBps()))1123 b.WriteString(",\"clock\":" + itoa(p.Clock))1124 b.WriteString(",\"createdAt\":" + unix(p.CreatedAt))1125 b.WriteString(",\"over\":" + jbool(p.Over()))1126 b.WriteString(",\"popped\":" + jbool(p.Popped))1127 b.WriteString(",\"paid\":" + jbool(p.Paid))1128 b.WriteString(",\"cancelled\":" + jbool(p.Cancelled))1129 b.WriteString(",\"bidders\":" + itoa(int64(len(p.Roster))))1130 b.WriteString(",\"divPaid\":" + itoa(p.DivPaid))1131 b.WriteString(",\"refPaid\":" + itoa(p.RefPaid))1132 b.WriteString(",\"creatorPaid\":" + itoa(p.CreatorPaid))1133 b.WriteString(",\"shotPaid\":" + itoa(p.ShotPaid))1134 b.WriteString(",\"teamPaid\":" + itoa(p.TeamPaid))1135 b.WriteString(",\"sharedPaid\":" + itoa(p.SharedPaid))1136 b.WriteString(",\"winnerPaid\":" + itoa(p.WinnerPaid))1137 pending := int64(0)1138 for _, t := range p.Shots {1139 if !t.Resolved {1140 pending++1141 }1142 }1143 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))1144 flag := p.WinnerFlag1145 if m := p.find(p.LastBidder); m != nil && flag == "" {1146 flag = m.Flag1147 }1148 b.WriteString(",\"flag\":\"" + flag + "\"")1149 // what each flag has paid into this pool1150 b.WriteString(",\"teams\":{")1151 seen := map[string]int64{}1152 order := []string{}1153 for _, m := range p.Roster {1154 if m.Flag == "" {1155 continue1156 }1157 if _, ok := seen[m.Flag]; !ok {1158 order = append(order, m.Flag)1159 }1160 seen[m.Flag] += m.Paid1161 }1162 for i, f := range order {1163 if i > 0 {1164 b.WriteString(",")1165 }1166 b.WriteString("\"" + f + "\":" + itoa(seen[f]))1167 }1168 b.WriteString("}")1169 if detail {1170 b.WriteString(",\"history\":[")1171 for i := len(p.History) - 1; i >= 0; i-- { // newest first1172 h := p.History[i]1173 if i < len(p.History)-1 {1174 b.WriteString(",")1175 }1176 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) + "}")1177 }1178 b.WriteString("],\"roster\":[")1179 for i, m := range p.Roster {1180 if i > 0 {1181 b.WriteString(",")1182 }1183 b.WriteString("{\"addr\":\"" + m.Addr.String() + "\",\"flag\":\"" + m.Flag + "\",\"paid\":" + itoa(m.Paid) + ",\"earned\":" + itoa(m.Earned) + "}")1184 }1185 b.WriteString("],\"shotlog\":[")1186 for i := len(p.Shots) - 1; i >= 0; i-- { // newest first1187 t := p.Shots[i]1188 if i < len(p.Shots)-1 {1189 b.WriteString(",")1190 }1191 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) + "}")1192 }1193 b.WriteString("]")1194 }1195 b.WriteString("}")1196 return b.String()1197}11981199// jsonCommits lists the unused commitments in order, hex, so a keeper that1200// starts can tell which of them it holds the secrets for and Retire the rest.1201func jsonCommits() string {1202 var b strings.Builder1203 b.WriteString("{\"height\":" + itoa(runtime.ChainHeight()) + ",\"commits\":[")1204 for i := head; i < len(commits); i++ {1205 if i > head {1206 b.WriteString(",")1207 }1208 b.WriteString("\"" + hex.EncodeToString([]byte(commits[i])) + "\"")1209 }1210 b.WriteString("]}")1211 return b.String()1212}12131214// commitHead is the next commitment a shot would take, hex, "" when none is free.1215func commitHead() string {1216 if head >= len(commits) {1217 return ""1218 }1219 return hex.EncodeToString([]byte(commits[head]))1220}12211222func jsonAll() string {1223 var b strings.Builder1224 b.WriteString("{\"now\":" + itoa(time.Now().Unix()) + ",\"height\":" + itoa(runtime.ChainHeight()) + ",\"realm\":\"" + pkgPath + "\"")1225 b.WriteString(",\"split\":{\"pot\":" + itoa(PotShare) + ",\"div\":" + itoa(DivShare) + ",\"ref\":" + itoa(RefShare) + ",\"creator\":" + itoa(CreatorShare) + ",\"team\":" + itoa(TeamShare) + ",\"roster\":" + itoa(int64(RosterMax)) +1226 ",\"shotMaxBps\":" + itoa(ShotMaxBps) + ",\"shotFair\":" + itoa(ShotFair) + ",\"shotDelay\":" + itoa(ShotDelay) + ",\"shotWindow\":" + itoa(ShotWindow) + ",\"minClock\":" + itoa(MinClock) +1227 ",\"minShot\":" + itoa(MinShot) + ",\"revealTip\":" + itoa(RevealTip) + ",\"keeperFresh\":" + itoa(KeeperFresh) + ",\"liveFloor\":" + itoa(LiveFloor) + "}")1228 b.WriteString(",\"shotsOn\":" + jbool(shotsOn))1229 b.WriteString(",\"keeper\":\"" + keeper.String() + "\",\"keeperSeen\":" + unix(keeperSeen) + ",\"commitsFree\":" + itoa(int64(len(commits)-head)) + ",\"commitHead\":\"" + commitHead() + "\",\"voided\":" + itoa(voided) + ",\"late\":" + itoa(late))1230 b.WriteString(",\"pools\":[")1231 for i, p := range pools {1232 if i > 0 {1233 b.WriteString(",")1234 }1235 b.WriteString(p.json(false))1236 }1237 b.WriteString("],\"teams\":" + jsonTeams() + "}")1238 return b.String()1239}12401241// jsonTeams is the country leaderboard: paid in, won, and players per flag.1242func jsonTeams() string {1243 var b strings.Builder1244 b.WriteString("{")1245 i := 01246 for f, paid := range teamPaid {1247 if i > 0 {1248 b.WriteString(",")1249 }1250 i++1251 b.WriteString("\"" + f + "\":{\"paid\":" + itoa(paid) + ",\"won\":" + itoa(teamWon[f]) + ",\"players\":" + itoa(teamSize[f]) + "}")1252 }1253 b.WriteString("}")1254 return b.String()1255}12561257// jsonMe is what the page shows a connected wallet about itself.1258func jsonMe(a address) string {1259 pl := players[a]1260 if pl == nil {1261 pl = &Player{}1262 }1263 return "{\"addr\":\"" + a.String() + "\",\"referrer\":\"" + pl.Referrer.String() + "\",\"recruits\":" + itoa(pl.Recruits) +1264 ",\"flag\":\"" + pl.Flag + "\",\"bids\":" + itoa(pl.Bids) + ",\"paid\":" + itoa(pl.Paid) +1265 ",\"divEarned\":" + itoa(pl.DivEarned) + ",\"refEarned\":" + itoa(pl.RefEarned) + ",\"won\":" + itoa(pl.Won) +1266 ",\"shots\":" + itoa(pl.Shots) + ",\"hits\":" + itoa(pl.Hits) + ",\"owed\":" + itoa(pl.Owed) + ",\"withdrawn\":" + itoa(pl.Withdrawn) + "}"1267}12681269func (p *Pool) status() string {1270 switch {1271 case p.Cancelled:1272 return "cancelled"1273 case p.Over():1274 s := "won by " + short(p.LastBidder)1275 if p.Popped {1276 s = "popped by " + short(p.LastBidder)1277 }1278 if !p.Paid {1279 s += " (unclaimed)"1280 }1281 return s1282 case p.Bids == 0:1283 return "no bids yet · " + dur(p.Window) + " window · next " + GNOT(p.NextPrice())1284 default:1285 left := p.Deadline().Unix() - time.Now().Unix()1286 s := dur(left) + " left"1287 if p.Clock < p.Window {1288 s += " (⚡ " + dur(p.Clock) + " clock)"1289 }1290 return s + " · " + short(p.LastBidder) + " on top · next " + GNOT(p.NextPrice()) + " · winner's share " + itoa(p.LiveBps()/100) + "%"1291 }1292}12931294func markdownAll() string {1295 var b strings.Builder1296 b.WriteString("# Bubble Rumble v4\n\n")1297 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. ")1298 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 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")1299 b.WriteString("Play at [bubblerumble.net](https://bubblerumble.net).\n\n## Pools\n\n")1300 if len(pools) == 0 {1301 b.WriteString("_none yet_\n")1302 }1303 for _, p := range pools {1304 b.WriteString("- [#" + itoa(p.ID) + " " + p.Name + "](/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble4:" + itoa(p.ID) + ") · " + GNOT(p.Pot) + " · " + p.status() + "\n")1305 }1306 return b.String()1307}13081309func (p *Pool) markdown() string {1310 var b strings.Builder1311 b.WriteString("# " + p.Name + "\n\n")1312 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")1313 if !p.LastDay().IsZero() {1314 b.WriteString("- last day: " + p.LastDay().UTC().Format("2006-01-02 15:04 UTC") + " · winner's share now " + itoa(p.LiveBps()/100) + "%\n")1315 }1316 if len(p.Shots) > 0 {1317 b.WriteString("- shots: " + itoa(int64(len(p.Shots))) + " · misses: " + itoa(p.Misses) + " · unrevealed: " + itoa(p.Voided) + "\n")1318 }1319 b.WriteString("\n## Bids\n\n")1320 if len(p.History) == 0 {1321 b.WriteString("_none yet_\n")1322 }1323 for i := len(p.History) - 1; i >= 0; i-- {1324 h := p.History[i]1325 line := "- #" + itoa(h.N) + " " + short(h.Bidder)1326 if h.Flag != "" {1327 line += " (" + h.Flag + ")"1328 }1329 line += " paid " + GNOT(h.Price)1330 if h.Shot > 0 {1331 line += " + a " + GNOT(h.Shot) + " shot"1332 }1333 b.WriteString(line + " · pot " + GNOT(h.Pot) + "\n")1334 }1335 return b.String()1336}1337Bid(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id int64, ref string, flag string, shot int64)
Cancel(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id int64)
Claim(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id int64)
ClockFor(window int64, price int64, sent int64) int64
Commit(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, hashes string)
CreatePool(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, name string, window int64, basePrice int64, life int64) int64
GNOT(ugnot int64) string
Heartbeat(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
Owed(a string) int64
Price(base int64, n int64) int64
Render(path string) string
Retire(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, n int64)
Reveal(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, reveals string)
SetKeeper(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, addr string)
SetShots(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, on bool)
Settle(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, id int64)
ShotBps(shot int64, take int64) int64
Withdraw(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}) int64
Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.
vm/qrender output, sanitized (docs/render-security.md) and displayed in an empty-sandbox iframe — scripts, forms and popups cannot run. Links stay inert in-preview; right-click to open.