1// Package bubblerumble5 is the fifth Bubble Rumble realm: v4's game whole, with2// one new slice. Of every bid 50% goes to the pot, 30% is credited at once to the3// pool's earlier bidders pro-rata to what they paid in, 10% to whoever invited the4// bidder, 5% to the pool's creator — and 5% to the BUBBLE treasury: the token5// realm's own sub-realm (bubbletreasury), which nothing moves but a decided vote6// of the BUBBLE holders. The five percent comes out of the pot's share alone; the7// dividends, the inviter's and the creator's slices are v4's. Seeds (a creator's8// own money into a new pool) and shots pay no royalty: a seed is a gift to the9// players, and a shot's losing side already feeds the pot. Every pool has a last10// day, a flag war at the close, credited earnings and Withdraw, a clock that can11// be bought down but never under MinClock, and the shot that can win the pot:12//13// - A shot is still extra money on a bid, a chance to pop the bubble at once.14// The chance is 0.8 × shot / take, at most 1% a shot, where the take is what15// a pop would pay the shooter right now: the pot less the team's 35%, times the16// winner's live share (LiveBps). So the pot keeps a fifth of every shot's fair17// value however far the last day has slid, and a hit pops the bubble: the pool18// closes with the shooter as its winner and Claim pays it as any pop.19// - The draw's randomness is a commit-reveal by the keeper — the operator's own20// box, which also settles pools on time. The keeper publishes sha256 hashes of21// secrets ahead of time (Commit); a bid with a shot takes the next unused one;22// the shot is drawn when someone (the keeper, or anyone) reveals that secret23// (Reveal) in the blocks ShotDelay to ShotDelay+ShotWindow after the bid. The24// seed is the secret with the shot's own facts and the reveal block's height25// and time. Nobody without the secret can grind it: not the shooter, not a26// validator. The secret's holder knows the two candidate block times a block27// ahead and may pick which of the window's three blocks to reveal in — three28// rolls, at most 2.4× fair on a 1% shot — which is why the keeper key never29// bids or shoots, and its operator never shoots. (The site's own keep-alive boops come from another key and30// pay the royalty like any boop; for the operator, who votes the treasury,31// that slice is a wash — said here so nobody has to work it out.)32// - A shot not revealed in its window, or on a pool that closed first, is void:33// it goes into the pot and counts as a miss, as in v3. Nothing is refunded, so34// withholding a reveal never pays whoever withholds it. What remains is that a35// keeper who leads a pot could withhold a stranger's hit and let the money fall36// into the pot it leads: refused shots are counted on the pool for all to see,37// and so are reveals that came in the window's later blocks (the honest keeper38// always reveals at the first), so a keeper choosing its block would show.39// - Shots are sold only while the keeper is alive: a shot needs a free commitment40// and a sign of life (Commit or Heartbeat) within KeeperFresh. Otherwise the bid41// stands and the shot's money goes straight back in the same transaction.42// - Shots are refused in the last quarter of a pool's life (which also keeps every43// reveal window clear of the last day) and under MinShot. RevealTip is taken44// from every shot and credited to the keeper whoever sends the reveal (the45// secret sits in the mempool, and a proposer could copy it), so the keeper46// 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-defender50// veto above; a proposer who reads the keeper's reveal in the mempool holds the51// secret too, and proposing the window's blocks could drop the reveal for a roll52// it does not like or leave it out altogether (a void), which Late and Voided53// show; keeper and validator together could grind. SetShots is the switch if any54// of it is seen. SetKeeper drops every unused commitment.55package bubblerumble55657import (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)7172const (73 MinWindow int64 = 10 // seconds74 MaxWindow int64 = 30 * 24 * 3600 // 30 days75 MinLife int64 = 3600 // an hour76 MaxLife int64 = 30 * 24 * 3600 // 30 days77 MinBasePrice int64 = 1_000 // ugnot, 0.001 GNOT78 MaxBasePrice int64 = 1_000_000_000_000 // ugnot, 1,000,000 GNOT79 MaxNameLen = 4080 HistoryLen = 50 // bids kept per pool for the feed81 RosterMax = 100 // distinct bidders a pool pays dividends, team shares and the close split to82 MaxBoost int64 = 10 // the shortest clock a bid can buy is window/MaxBoost...83 MinClock int64 = 300 // ...and never under five minutes: people must be able to answer8485 // where every bid goes, in percent86 PotShare int64 = 5087 DivShare int64 = 30 // to earlier bidders of the pool, pro-rata by weight88 RefShare int64 = 10 // to the bidder's inviter89 CreatorShare int64 = 590 TreasuryShare int64 = 5 // to the BUBBLE treasury: the token realm's sub-realm, spent only by the holders' vote91 // of the pot at the close, to the other bidders under the winner's flag92 TeamShare int64 = 359394 ShotMaxBps int64 = 100 // 1% a shot, whatever is paid95 ShotFair int64 = 8000 // chance in bps = ShotFair × shot / take (0.8 of fair odds)96 ShotDelay int64 = 3 // blocks after the shot was paid for before it can be revealed...97 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 holder98 MinShot int64 = 100_000 // ugnot: no shot under 0.1 GNOT, so dust cannot make reveals cost more than they tip99 RevealTip int64 = 20_000 // ugnot: off every shot, to whoever reveals it100 KeeperFresh int64 = 300 // seconds: shots are sold only this soon after the keeper's last sign of life (it heartbeats every two minutes)101 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)102 MaxCommits = 500 // unused commitments the queue holds at most103 CommitBatch = 100 // hashes one Commit may bring104 CommitCompact = 200 // used commitments kept before the queue is compacted105)106107// admin is the realm's deployer: the only address that may turn shots off.108const admin = address("g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr")109110// pkgPath must match gnomod.toml: the realm's own address derives from it.111const pkgPath = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble5"112113// The BUBBLE treasury is the token realm's sub-realm for its `treasury:spend` kind (the realm bubbletreasury114// publishes the kind and says the same address): a distinct address the token's governor alone can spend from,115// after a vote of the holders. Derived here from the two paths, as the chain derives it.116const (117 bubblePath = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubble"118 treasurySubpath = "treasury-spend"119)120121type Entry struct {122 N int64123 Bidder address124 Flag string125 Price int64 // what was paid for the bid itself; at least the price at the time126 Shot int64 // extra paid for a chance to pop the bubble127 Clock int64 // seconds this bid had to stand128 Pot int64 // pot right after this bid129 At time.Time130}131132// Member is a distinct bidder of a pool: dividends, team shares and the close133// split go to the roster. The flag is the one flown at the first bid here.134type Member struct {135 Addr address136 Flag string137 Paid int64 // gross paid into this pool (bids and shots)138 Earned int64 // dividends received from this pool139}140141// Shot is a shot at the bubble waiting to be revealed, or resolved.142type Shot struct {143 N int64 // 1-based within the pool144 Bidder address145 Paid int64 // ugnot paid for it (the tip comes out of this at the reveal)146 Take int64 // what a pop would have paid the shooter when the odds were set147 Bps int64 // chance, in basis points148 CommitH int64 // block height when paid; revealable at CommitH+ShotDelay and the ShotWindow blocks after149 Hash string // the commitment it took: sha256 of the secret that decides it, 32 raw bytes150 Resolved bool151 Won bool152 Void bool // not revealed in its window, or the pool closed first: a miss153 DrawH int64154 Revealer address155}156157type Pool struct {158 ID int64159 Name string160 Creator address161 Window int64 // seconds the last bid must stand to win162 Life int64 // seconds from the first bid to the last day163 BasePrice int64 // ugnot; the Nth bid costs BasePrice·√N164 Seed int64165 Pot int64166 Bids int64167 LastBidder address168 LastBidAt time.Time169 FirstBidAt time.Time170 Clock int64 // seconds the current top bid has to stand171 CreatedAt time.Time172 Paid bool // the pot went out, or the seed back to the creator173 Cancelled bool174 Popped bool // a shot ended it175 PoppedAt time.Time176 DivPaid int64 // dividends paid out of this pool so far177 RefPaid int64178 CreatorPaid int64179 TreasuryPaid int64 // the BUBBLE treasury's 5% of this pool's bids180 ShotPaid int64 // slices paid to hits while the pool went on181 Misses int64 // shots resolved and lost, voids included: the scars on the bubble182 Voided int64 // of those, the shots nobody revealed in time183 Late int64 // shots revealed in their window but not at its first block: a keeper choosing its block would show here184 ShotEscrow int64 // shots paid for and not yet resolved: in the pot once they are185 WinnerFlag string186 TeamPaid int64 // what the winner's team received at the close187 SharedPaid int64 // the pro-rata part of the close188 WinnerPaid int64189 History []Entry190 Roster []*Member191 Shots []*Shot192 Settled int // shots before this index are all resolved (they resolve strictly in order): where settleShots starts193}194195// Player is what the realm remembers about an address across pools.196type Player struct {197 Referrer address // set by the first bid, permanent198 Recruits int64199 Flag string200 Bids int64201 Paid int64202 DivEarned int64203 RefEarned int64204 Won int64 // pots, team shares, close splits and shot slices received205 Shots int64206 Hits int64207 Owed int64 // credited and not yet withdrawn208 Withdrawn int64209}210211var (212 shotsOn = true213 keeper address // who commits and heartbeats (SetKeeper); the admin may too214 keeperSeen time.Time // the keeper's last Commit or Heartbeat215 commits []string // the hashes of the keeper's secrets (32 raw bytes each), in order; the first head are used216 head int217 voided int64 // shots voided over all pools218 late int64 // shots revealed after the first block of their window, over all pools219 pools []*Pool220 players = map[address]*Player{}221 teamPaid = map[string]int64{} // flag -> gross bid volume under it222 teamWon = map[string]int64{} // flag -> pots closed under it223 teamSize = map[string]int64{} // flag -> players flying it224 realmAddr = chain.PackageAddress(pkgPath)225 treasuryAddr = chain.DerivePkgSubAddr(bubblePath, treasurySubpath)226 treasuryPaid int64 // the royalty over all pools227)228229// TreasuryAddr is where 5% of every bid goes: the BUBBLE treasury.230func TreasuryAddr() address { return treasuryAddr }231232// TreasuryPaid is what every pool of this realm has sent there.233func TreasuryPaid() int64 { return treasuryPaid }234235// Price of the next bid in a pool with the given base price and n bids so far.236func Price(base, n int64) int64 {237 return int64(math.Ceil(float64(base) * math.Sqrt(float64(n+1))))238}239240// LastDay is when the pool's life runs out; zero before the first bid.241func (p *Pool) LastDay() time.Time {242 if p.FirstBidAt.IsZero() {243 return time.Time{}244 }245 return p.FirstBidAt.Add(time.Duration(p.Life) * time.Second)246}247248// Deadline is when the pool closes if nothing changes: the clock or the last249// day, whichever comes first; the pop, if a shot ended it.250func (p *Pool) Deadline() time.Time {251 if p.Popped {252 return p.PoppedAt253 }254 d := p.LastBidAt.Add(time.Duration(p.Clock) * time.Second)255 if last := p.LastDay(); !last.IsZero() && last.Before(d) {256 return last257 }258 return d259}260261func (p *Pool) Over() bool { return p.Bids > 0 && (p.Popped || !time.Now().Before(p.Deadline())) }262func (p *Pool) NextPrice() int64 { return Price(p.BasePrice, p.Bids) }263264// LiveBps is how much of the pot still goes the winner's way, in basis points265// of the pot: 10000 at the first bid, sliding to 0 on the last day. Frozen at266// the close.267func (p *Pool) LiveBps() int64 {268 if p.FirstBidAt.IsZero() || p.Life <= 0 {269 return 10000270 }271 at := time.Now()272 if p.Over() {273 at = p.Deadline()274 }275 elapsed := at.Unix() - p.FirstBidAt.Unix()276 if elapsed <= 0 {277 return 10000278 }279 if elapsed >= p.Life {280 return 0281 }282 return 10000 * (p.Life - elapsed) / p.Life283}284285// ClockFor is the clock a bid buys by paying sent against a price: the window286// divided by the multiple paid, never below window/MaxBoost.287func ClockFor(window, price, sent int64) int64 {288 c := int64(float64(window) * float64(price) / float64(sent))289 if c < window/MaxBoost {290 c = window / MaxBoost291 }292 if c < MinClock {293 c = MinClock294 }295 if c > window {296 c = window297 }298 return c299}300301// mulDiv is a×b/c without overflowing on the way: pots and paid-ins are both302// in ugnot and their product passes int64 at a few thousand GNOT each.303func mulDiv(a, b, c int64) int64 {304 if a < 0 || b < 0 || c <= 0 {305 panic("mulDiv: negative")306 }307 hi, lo := bits.Mul64(uint64(a), uint64(b))308 if hi >= uint64(c) {309 panic("mulDiv: overflow")310 }311 q, _ := bits.Div64(hi, lo, uint64(c))312 return int64(q)313}314315// ShotBps is the chance a shot buys against what a pop would pay, in basis points.316func ShotBps(shot, take int64) int64 {317 if shot <= 0 || take <= 0 {318 return 0319 }320 bps := ShotFair * shot / take321 if bps > ShotMaxBps {322 bps = ShotMaxBps323 }324 return bps325}326327// Take is the most a pop could pay this shooter if the pool closed now on a pot328// of this size: Claim's own arithmetic, run dry, with the team's share counted329// as the shooter's too — one bid from a friend (or a second address) under a330// flag nobody else flies collects it, so the odds must assume it is collected.331// What is not the shooter's is the slide: the part of the rest that the last332// day has moved to the whole roster by paid-in.333func (p *Pool) Take(shooter address, pot int64) int64 {334 toWinner, cut := p.payout(shooter, pot, false)335 return toWinner + cut336}337338// CreatePool opens a pool. Any ugnot sent with the call seeds its pot. The339// creator earns CreatorShare of every bid. life is the pool's life in seconds340// from its first bid; 0 means ten windows (at least an hour, at most 30 days).341func CreatePool(cur realm, name string, window int64, basePrice int64, life int64) int64 {342 creator := userCaller(cur)343 name = cleanName(name)344 if window < MinWindow || window > MaxWindow {345 panic("window must be between 10s and 30d")346 }347 if basePrice < MinBasePrice || basePrice > MaxBasePrice {348 panic("base price must be between 0.001 and 1,000,000 GNOT")349 }350 if life == 0 { // ten windows of fighting, within the bounds351 life = 10 * window352 if life < MinLife {353 life = MinLife354 }355 if life > MaxLife {356 life = MaxLife357 }358 }359 if life < MinLife || life > MaxLife {360 panic("life must be between 1h and 30d")361 }362 if life < window {363 panic("life must be at least the window")364 }365 seed := ugnotSent()366 p := newPool(name, creator, window, basePrice, life, seed)367 chain.Emit("PoolCreated", "id", itoa(p.ID), "creator", creator.String(), "seed", itoa(seed))368 return p.ID369}370371func newPool(name string, creator address, window, basePrice, life, seed int64) *Pool {372 p := &Pool{373 ID: int64(len(pools)) + 1, Name: name, Creator: creator, Window: window, Life: life,374 BasePrice: basePrice, Seed: seed, Pot: seed, CreatedAt: time.Now(),375 }376 pools = append(pools, p)377 return p378}379380// Bid pays at least the current price and resets the clock. ref is the address381// that invited the bidder ("" for none; only the first bid ever sets it), flag382// a two-letter country to play under ("" keeps the last one; the flag is locked383// per pool at the first bid there), shot how much of the money sent is a shot384// at popping the bubble (0 for none): the rest must cover the price, and paying385// more than the price shortens the clock.386func Bid(cur realm, id int64, ref string, flag string, shot int64) {387 bidder := userCaller(cur)388 p := pool(id)389 sent := ugnotSent()390 if p.Cancelled {391 panic("pool was cancelled")392 }393 // a pool that closed (or popped) since the bidder looked: the money goes back394 if p.Over() {395 if sent > 0 {396 send(cur, bidder, sent)397 chain.Emit("Refund", "id", itoa(id), "to", bidder.String(), "ugnot", itoa(sent))398 }399 if p.Popped {400 return401 }402 panic("pool is closed")403 }404 if shot < 0 || shot > sent {405 panic("shot must be between 0 and the amount sent")406 }407 price := p.NextPrice()408 bid := sent - shot409 if bid < price {410 panic("send at least " + itoa(price) + "ugnot for the bid, got " + itoa(bid) + "ugnot")411 }412 // shots of earlier bids whose window has passed are settled first413 p.settleShots()414 pl := player(bidder)415 if f := cleanFlag(flag); f != "" && f != pl.Flag {416 if pl.Flag != "" {417 teamSize[pl.Flag]--418 }419 pl.Flag = f420 teamSize[f]++421 }422 if pl.Referrer == "" && ref != "" {423 r := address(ref)424 if r.IsValid() && r != bidder {425 pl.Referrer = r426 player(r).Recruits++427 }428 }429430 // where the bid goes431 div, refCut, creatorCut, royalty := bid*DivShare/100, bid*RefShare/100, bid*CreatorShare/100, bid*TreasuryShare/100432 toPot := bid - div - refCut - creatorCut - royalty // the dust of the four divisions stays in the pot433 if creatorCut > 0 {434 send(cur, p.Creator, creatorCut)435 p.CreatorPaid += creatorCut436 }437 if royalty > 0 { // the BUBBLE treasury's share, sent now like the creator's and the inviter's438 send(cur, treasuryAddr, royalty)439 p.TreasuryPaid += royalty440 treasuryPaid += royalty441 }442 if pl.Referrer != "" && refCut > 0 {443 send(cur, pl.Referrer, refCut)444 p.RefPaid += refCut445 player(pl.Referrer).RefEarned += refCut446 } else {447 toPot += refCut448 }449 divPaid := div - p.payDividends(cur, bidder, div)450 toPot += div - divPaid451 p.Pot += toPot452453 m := p.member(bidder, pl.Flag)454 if m.Flag == "" { // a flagless first bid does not lock "no flag": the first flag flown here does455 m.Flag = pl.Flag456 }457 // the shot, if there can be one: else its money goes straight back and the bid stands458 why := ""459 if shot > 0 {460 why = p.shotRefusal(bidder, shot)461 }462 m.Paid += sent463 pl.Bids++464 pl.Paid += sent465 if pl.Flag != "" {466 teamPaid[pl.Flag] += sent467 }468 // the odds, on the pool as it stands with this bid booked (the shooter's own paid-in weighs in the slide):469 // priced here once, and a shot that buys no chance at all is refused like the rest470 var take, bps int64471 if shot > 0 && why == "" {472 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 out473 bps = ShotBps(shot-RevealTip, take)474 if bps == 0 {475 why = "too small for any chance at this pot"476 }477 }478 if why != "" {479 m.Paid -= shot480 pl.Paid -= shot481 if pl.Flag != "" {482 teamPaid[pl.Flag] -= shot483 }484 send(cur, bidder, shot)485 chain.Emit("ShotReturned", "id", itoa(id), "bidder", bidder.String(), "ugnot", itoa(shot), "why", why)486 sent -= shot487 shot = 0488 }489 p.Bids++490 if p.FirstBidAt.IsZero() {491 p.FirstBidAt = time.Now()492 }493 p.LastBidder = bidder494 p.LastBidAt = time.Now()495 p.Clock = ClockFor(p.Window, price, bid)496 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})497 if len(p.History) > HistoryLen {498 p.History = p.History[len(p.History)-HistoryLen:]499 }500 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))501502 if shot > 0 {503 t := &Shot{N: int64(len(p.Shots)) + 1, Bidder: bidder, Paid: shot, Take: take, Bps: bps, CommitH: runtime.ChainHeight(), Hash: commits[head]}504 head++505 p.Shots = append(p.Shots, t)506 p.ShotEscrow += shot507 pl.Shots++508 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))509 }510}511512// shotRefusal is why a shot cannot be sold on this pool right now ("" when it513// can): the checks a bid runs before taking one, in the order the page shows.514// The odds themselves are checked by the caller, on the booked state.515func (p *Pool) shotRefusal(shooter address, shot int64) string {516 switch {517 case !shotsOn:518 return "shots are off"519 case keeper != "" && shooter == keeper:520 return "the keeper never shoots"521 case shot < MinShot:522 return "under the smallest shot, " + GNOT(MinShot)523 case head >= len(commits):524 return "no commitment free: the keeper is away"525 case keeperSeen.IsZero() || time.Now().Unix()-keeperSeen.Unix() > KeeperFresh:526 return "the keeper has not been seen for " + dur(KeeperFresh)527 case p.LiveBps() < LiveFloor:528 return "the winner's share has slid under a quarter"529 }530 return ""531}532533// SetShots turns shots off (or on again); only the deployer may.534func SetShots(cur realm, on bool) {535 if userCaller(cur) != admin {536 panic("only the deployer")537 }538 shotsOn = on539 chain.Emit("Shots", "on", jbool(on))540}541542// SetKeeper names the address that commits and heartbeats; only the deployer543// may. Every unused commitment is dropped with it: a rotated or compromised544// keeper's secrets never decide a shot.545func SetKeeper(cur realm, addr address) {546 if userCaller(cur) != admin {547 panic("only the deployer")548 }549 if !addr.IsValid() {550 panic("bad address")551 }552 keeper = addr553 commits = commits[:head]554 chain.Emit("Keeper", "addr", addr.String())555}556557func keeperCaller(cur realm) address {558 who := userCaller(cur)559 if who == "" || who != keeper {560 panic("only the keeper")561 }562 return who563}564565func keeperOrAdmin(cur realm) address {566 who := userCaller(cur)567 if who != admin && (who == "" || who != keeper) {568 panic("only the keeper or the deployer")569 }570 return who571}572573// Heartbeat is the keeper's sign of life: shots are sold only within574// KeeperFresh of the last one (a Commit counts too).575func Heartbeat(cur realm) {576 keeperCaller(cur)577 keeperSeen = time.Now()578}579580// Commit queues the hashes of secrets the keeper will reveal later, in order,581// as a comma-separated list of 64 hex characters each; only the keeper, whose582// box holds the secrets. A hash still in the queue (used or not) is refused: a583// repeated secret would be public after its first reveal (a safety net against584// the keeper repeating itself; compaction forgets the oldest). The queue holds585// at most MaxCommits unused; the used ones in front of it are compacted away586// past CommitCompact.587func Commit(cur realm, hashes string) {588 keeperCaller(cur)589 keeperSeen = time.Now()590 parts := strings.Split(hashes, ",")591 if len(parts) == 0 || len(parts) > CommitBatch {592 panic("1 to " + strconv.Itoa(CommitBatch) + " hashes")593 }594 if len(commits)-head+len(parts) > MaxCommits {595 panic("the queue holds " + strconv.Itoa(MaxCommits) + " unused commitments at most")596 }597 if head > CommitCompact {598 commits = append([]string(nil), commits[head:]...)599 head = 0600 }601 for _, part := range parts {602 b, err := hex.DecodeString(strings.TrimSpace(part))603 if err != nil || len(b) != 32 {604 panic("a hash is 64 hex characters")605 }606 h := string(b)607 for _, c := range commits { // a safety net against the keeper repeating itself, over what the queue still holds608 if c == h {609 panic("hash already committed: " + part)610 }611 }612 commits = append(commits, h)613 }614 chain.Emit("Commit", "n", itoa(int64(len(parts))), "free", itoa(int64(len(commits)-head)))615}616617// Retire drops the first n unused commitments: a keeper that lost the secrets618// behind them (the box rebuilt, the file gone) clears them so shots can go on.619// The deployer may too. It is not a sign of life.620func Retire(cur realm, n int64) {621 keeperOrAdmin(cur)622 if n <= 0 {623 panic("retire at least one")624 }625 if int(n) > len(commits)-head {626 n = int64(len(commits) - head)627 }628 commits = append(commits[:head], commits[head+int(n):]...)629 chain.Emit("Retire", "n", itoa(n), "free", itoa(int64(len(commits)-head)))630}631632// Reveal draws shots with the secrets behind their commitments: a comma-separated633// list of pool:shot:secretHex, any pools, in one transaction — the keeper sends634// every shot that is due in one call, so a run of shots on one block never635// outpaces it. Anyone may call it; the secret is the proof, and the caller is636// tipped RevealTip out of each shot revealed. Shots go in order: an earlier one637// still pending must come first (in the list, or before). An entry not yet due638// is skipped, one with a wrong secret or already resolved is skipped with an639// event, so a stale entry never voids the rest; a malformed one (no such pool640// or shot, a cancelled pool, not pool:shot:secret) aborts the whole call, which641// only the keeper's own bug could send. The reveal must land in the642// blocks CommitH+ShotDelay through CommitH+ShotDelay+ShotWindow; later, or on a643// pool that has closed, the shot is already void (settleShots runs first): into644// the pot as a miss. A hit pops the bubble: the pool closes now with the shooter645// as its winner, settled at Claim.646func Reveal(cur realm, reveals string) {647 who := userCaller(cur)648 h := runtime.ChainHeight()649 done := 0650 for _, entry := range strings.Split(reveals, ",") {651 f := strings.Split(strings.TrimSpace(entry), ":")652 if len(f) != 3 {653 panic("each entry is pool:shot:secret")654 }655 id, n := parseID(f[0]), parseNum(f[1])656 p := pool(id)657 if p.Cancelled {658 panic("pool was cancelled")659 }660 p.settleShots()661 if n < 1 || int(n) > len(p.Shots) {662 panic("no such shot")663 }664 t := p.Shots[n-1]665 if t.Resolved || (n > 1 && !p.Shots[n-2].Resolved) {666 chain.Emit("RevealSkipped", "id", itoa(id), "shot", itoa(n), "why", jif(t.Resolved, "resolved", "earlier shot pending"))667 continue668 }669 if h < t.CommitH+ShotDelay {670 continue // not due yet: the keeper sends a block early to be sure of the window671 }672 secret, err := hex.DecodeString(strings.TrimSpace(f[2]))673 sum := sha256.Sum256(secret)674 if err != nil || string(sum[:]) != t.Hash {675 chain.Emit("RevealSkipped", "id", itoa(id), "shot", itoa(n), "why", "wrong secret")676 continue677 }678 // (a shot past its window, or on a pool that closed, was voided by settleShots above and skipped as resolved)679 done++680 t.Resolved, t.DrawH, t.Revealer = true, h, who681 p.ShotEscrow -= t.Paid682 // the tip is the keeper's whoever sent the reveal: the secret sits in the mempool, and a proposer could683 // copy it into a reveal of its own; the seed does not care who reveals, so nothing else changes684 tip := RevealTip685 if tip > t.Paid {686 tip = t.Paid687 }688 if keeper != "" {689 credit(keeper, tip)690 } else {691 credit(who, tip)692 }693 p.Pot += t.Paid - tip694 if h > t.CommitH+ShotDelay { // revealed in the window but not at its first block: the honest keeper's tell, counted695 p.Late++696 late++697 }698 t.Won = shotHits(seedFor(id, t, secret, h), t.Bps)699 chain.Emit("Revealed", "id", itoa(id), "shot", itoa(n), "bidder", t.Bidder.String(), "hit", jbool(t.Won), "bps", itoa(t.Bps), "by", who.String())700 if !t.Won {701 p.Misses++702 continue703 }704 player(t.Bidder).Hits++705 p.Popped, p.PoppedAt, p.LastBidder = true, time.Now(), t.Bidder706 chain.Emit("Popped", "id", itoa(id), "by", t.Bidder.String(), "pot", itoa(p.Pot))707 }708 if done == 0 {709 panic("nothing revealed")710 }711}712713// Settle voids the shots at a pool that nobody revealed in time, or that its714// close overtook. Anyone may call it; every bid, reveal and claim on the pool715// does the same first.716func Settle(cur realm, id int64) {717 pool(id).settleShots()718}719720// settleShots voids, in order, every pending shot whose window has passed or721// whose pool is over: the shot's money goes into the pot and counts as a miss;722// nobody is tipped.723func (p *Pool) settleShots() {724 h := runtime.ChainHeight()725 over := p.Over()726 for p.Settled < len(p.Shots) && p.Shots[p.Settled].Resolved {727 p.Settled++728 }729 for _, t := range p.Shots[p.Settled:] {730 if t.Resolved || (h <= t.CommitH+ShotDelay+ShotWindow && !over) {731 continue732 }733 t.Resolved, t.Void, t.DrawH = true, true, h734 p.ShotEscrow -= t.Paid735 p.Pot += t.Paid736 p.Misses++737 p.Voided++738 voided++739 chain.Emit("Voided", "id", itoa(p.ID), "shot", itoa(t.N), "bidder", t.Bidder.String(), "why", jif(over, "closed", "unrevealed"))740 }741}742743func jif(c bool, a, b string) string {744 if c {745 return a746 }747 return b748}749750// seedFor is the draw's seed: the secret with the shot's own facts and the751// reveal block's height and time. Everything but the secret is public, and the752// secret is the keeper's until the reveal; the block's time is the validators'.753func seedFor(poolID int64, t *Shot, secret []byte, h int64) uint64 {754 var b []byte755 b = append(b, pkgPath...)756 b = binary.BigEndian.AppendUint64(b, uint64(poolID))757 b = binary.BigEndian.AppendUint64(b, uint64(t.N))758 b = append(b, t.Bidder.String()...)759 b = binary.BigEndian.AppendUint64(b, uint64(t.CommitH))760 b = append(b, secret...)761 b = binary.BigEndian.AppendUint64(b, uint64(h))762 b = binary.BigEndian.AppendUint64(b, uint64(time.Now().UnixNano()))763 sum := sha256.Sum256(b)764 return binary.BigEndian.Uint64(sum[:8])765}766767// shotHits is the draw: a uniform number in [0, 10000) under the chance.768func shotHits(seed uint64, bps int64) bool {769 return int64(seed%10000) < bps770}771772// payDividends credits div to the pool's earlier bidders, pro-rata to what773// each has paid in, skipping the bidder; whatever cannot be placed (no earlier774// bidders, rounding) is returned to go into the pot.775func (p *Pool) payDividends(cur realm, bidder address, div int64) int64 {776 var total int64777 for _, m := range p.Roster {778 if m.Addr != bidder {779 total += m.Paid780 }781 }782 if total == 0 || div == 0 {783 return div784 }785 var paid int64786 for _, m := range p.Roster {787 if m.Addr == bidder {788 continue789 }790 amt := mulDiv(div, m.Paid, total)791 if amt > 0 {792 credit(m.Addr, amt)793 m.Earned += amt794 player(m.Addr).DivEarned += amt795 paid += amt796 }797 }798 p.DivPaid += paid799 return div - paid800}801802// member finds or adds a bidder on the roster; a full roster drops its oldest.803// A new member's flag is the one given; once set it is locked for this pool.804func (p *Pool) member(a address, flag string) *Member {805 for _, m := range p.Roster {806 if m.Addr == a {807 return m808 }809 }810 if len(p.Roster) >= RosterMax {811 p.Roster = p.Roster[1:]812 }813 m := &Member{Addr: a, Flag: flag}814 p.Roster = append(p.Roster, m)815 return m816}817818// Claim settles a finished pool: everything is credited, and Withdraw sends819// it. TeamShare of the pot goes to the OTHER roster members flying the820// winner's flag, pro-rata by paid-in — or, when nobody else flew it, to all821// the other roster members. Of the rest, the live part (LiveBps) is the822// winner's and the shared part is split among the whole roster pro-rata by823// paid-in. A pool with a single bidder credits them everything. Rounding goes824// to the winner. Anyone may call it.825func Claim(cur realm, id int64) {826 p := pool(id)827 if p.Cancelled {828 panic("pool was cancelled")829 }830 p.settleShots()831 if !p.Over() {832 panic("clock still running")833 }834 if p.Paid {835 panic("already paid out")836 }837 p.Paid = true838 winner := p.LastBidder839 flag := ""840 if m := p.find(winner); m != nil {841 flag = m.Flag842 }843 p.WinnerFlag = flag844 toWinner, _ := p.payout(winner, p.Pot, true)845 if toWinner > 0 {846 credit(winner, toWinner)847 player(winner).Won += toWinner848 }849 p.WinnerPaid = toWinner850 if flag != "" {851 teamWon[flag] += p.Pot852 }853 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()))854}855856// payout is the close's arithmetic for a winner and a pot: TeamShare to the857// other roster members under the winner's flag (or, when nobody else flew it,858// to all the others), then the shared part of the rest — what the live share859// has slid away from — to the whole roster pro-rata by paid-in; the winner860// keeps the rest and the rounding. With pay it credits and books the shares;861// dry, it only says what the winner would get and what the team would (Take862// prices shots with both).863func (p *Pool) payout(winner address, pot int64, pay bool) (toWinner, teamCut int64) {864 flag := ""865 if m := p.find(winner); m != nil {866 flag = m.Flag867 }868 var team int64869 for _, m := range p.Roster {870 if m.Addr != winner && m.Flag == flag && flag != "" {871 team += m.Paid872 }873 }874 teamFlag := flag875 if team == 0 {876 teamFlag = "" // nobody else flew it: every other bidder is the team877 for _, m := range p.Roster {878 if m.Addr != winner {879 team += m.Paid880 }881 }882 }883 toWinner = pot884 if team == 0 {885 return toWinner, 0 // alone on the roster: everything886 }887 cut := pot * TeamShare / 100888 for _, m := range p.Roster {889 if m.Addr == winner || (teamFlag != "" && m.Flag != teamFlag) {890 continue891 }892 amt := mulDiv(cut, m.Paid, team)893 if amt > 0 {894 toWinner -= amt895 teamCut += amt896 if pay {897 credit(m.Addr, amt)898 player(m.Addr).Won += amt899 p.TeamPaid += amt900 }901 }902 }903 // the rest slides: the live part stays the winner's, the shared part is everyone's by paid-in904 rest := pot - cut905 shared := rest - mulDiv(rest, p.LiveBps(), 10000)906 if shared > 0 {907 var total int64908 for _, m := range p.Roster {909 total += m.Paid910 }911 for _, m := range p.Roster {912 amt := mulDiv(shared, m.Paid, total)913 if amt > 0 && m.Addr != winner {914 toWinner -= amt915 if pay {916 credit(m.Addr, amt)917 player(m.Addr).Won += amt918 p.SharedPaid += amt919 }920 }921 }922 }923 return toWinner, teamCut924}925926// Cancel lets the creator take the seed back from a pool nobody has bid on.927func Cancel(cur realm, id int64) {928 caller := userCaller(cur)929 p := pool(id)930 if p.Creator != caller {931 panic("only the creator can cancel")932 }933 if p.Bids > 0 {934 panic("pool already has bids")935 }936 if p.Cancelled {937 panic("already cancelled")938 }939 p.Cancelled = true940 p.Paid = true941 if p.Pot > 0 {942 send(cur, caller, p.Pot)943 }944 chain.Emit("Cancelled", "id", itoa(id))945}946947func (p *Pool) find(a address) *Member {948 for _, m := range p.Roster {949 if m.Addr == a {950 return m951 }952 }953 return nil954}955956func player(a address) *Player {957 pl := players[a]958 if pl == nil {959 pl = &Player{}960 players[a] = pl961 }962 return pl963}964965func send(cur realm, to address, ugnot int64) {966 banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins(realmAddr, to, chain.Coins{{"ugnot", ugnot}})967}968969// credit books ugnot to an address; Withdraw pays it out.970func credit(to address, ugnot int64) {971 player(to).Owed += ugnot972}973974// Withdraw sends the caller everything credited to them: dividends, team975// shares, close splits, slices and won pots.976func Withdraw(cur realm) int64 {977 who := userCaller(cur)978 pl := player(who)979 amt := pl.Owed980 if amt <= 0 {981 panic("nothing to withdraw")982 }983 pl.Owed = 0984 pl.Withdrawn += amt985 send(cur, who, amt)986 chain.Emit("Withdrawn", "to", who.String(), "ugnot", itoa(amt))987 return amt988}989990// Owed is what an address could withdraw right now.991func Owed(a address) int64 { return player(a).Owed }992993func pool(id int64) *Pool {994 if id < 1 || id > int64(len(pools)) {995 panic("no such pool")996 }997 return pools[id-1]998}9991000// userCaller is the signer of the transaction, refusing calls relayed through1001// another realm so nobody can bid on someone else's behalf, and so no realm can1002// pay for a shot and revert on a miss.1003func userCaller(cur realm) address {1004 prev := cur.Previous()1005 if !prev.IsUserCall() {1006 panic("must be a direct user call")1007 }1008 return prev.Address()1009}10101011func ugnotSent() int64 {1012 sent := unsafe.OriginSend()1013 for _, c := range sent {1014 if c.Denom != "ugnot" {1015 panic("only ugnot is accepted")1016 }1017 }1018 return sent.AmountOf("ugnot")1019}10201021func cleanName(s string) string {1022 s = strings.TrimSpace(s)1023 if s == "" || len(s) > MaxNameLen {1024 panic("name must be 1-40 characters")1025 }1026 for _, c := range s {1027 if c < 0x20 || c == 0x7f {1028 panic("name has control characters")1029 }1030 }1031 return s1032}10331034// cleanFlag accepts a two-letter country code, upper-cased; anything else is "".1035func cleanFlag(s string) string {1036 s = strings.ToUpper(strings.TrimSpace(s))1037 if len(s) != 2 || s[0] < 'A' || s[0] > 'Z' || s[1] < 'A' || s[1] > 'Z' {1038 return ""1039 }1040 return s1041}10421043func itoa(n int64) string { return strconv.FormatInt(n, 10) }10441045func parseID(s string) int64 {1046 n, err := strconv.ParseInt(s, 10, 64)1047 if err != nil {1048 panic("bad pool id")1049 }1050 return n1051}10521053func parseNum(s string) int64 {1054 n, err := strconv.ParseInt(s, 10, 64)1055 if err != nil {1056 panic("bad number")1057 }1058 return n1059}10601061// GNOT formats ugnot as GNOT with the trailing zeros trimmed.1062func GNOT(ugnot int64) string {1063 whole, frac := ugnot/1_000_000, ugnot%1_000_0001064 if frac == 0 {1065 return itoa(whole) + " GNOT"1066 }1067 f := strconv.FormatInt(1_000_000+frac, 10)[1:]1068 return itoa(whole) + "." + strings.TrimRight(f, "0") + " GNOT"1069}10701071func short(a address) string {1072 s := a.String()1073 if len(s) < 12 {1074 return s1075 }1076 return s[:8] + "…" + s[len(s)-4:]1077}10781079func dur(sec int64) string {1080 if sec <= 0 {1081 return "0s"1082 }1083 d, h, m, s := sec/86400, sec%86400/3600, sec%3600/60, sec%601084 out := ""1085 if d > 0 {1086 out += itoa(d) + "d "1087 }1088 if d > 0 || h > 0 {1089 out += itoa(h) + "h "1090 }1091 if d > 0 || h > 0 || m > 0 {1092 out += itoa(m) + "m "1093 }1094 return out + itoa(s) + "s"1095}10961097// --- rendering ---------------------------------------------------------------10981099// Render serves gnoweb ("" and "<id>") and the page ("json", "json/<id>",1100// "me/<address>", "teams").1101func Render(path string) string {1102 path = strings.Trim(path, "/")1103 switch {1104 case path == "json":1105 return jsonAll()1106 case strings.HasPrefix(path, "json/"):1107 return pool(parseID(path[5:])).json(true)1108 case strings.HasPrefix(path, "me/"):1109 return jsonMe(address(path[3:]))1110 case path == "teams":1111 return jsonTeams()1112 case path == "commits":1113 return jsonCommits()1114 case path == "":1115 return markdownAll()1116 default:1117 return pool(parseID(path)).markdown()1118 }1119}11201121func unix(t time.Time) string {1122 if t.IsZero() {1123 return "0"1124 }1125 return itoa(t.Unix())1126}11271128func jbool(b bool) string {1129 if b {1130 return "true"1131 }1132 return "false"1133}11341135func (p *Pool) json(detail bool) string {1136 var b strings.Builder1137 b.WriteString("{\"id\":" + itoa(p.ID))1138 b.WriteString(",\"name\":" + strconv.Quote(p.Name))1139 b.WriteString(",\"creator\":\"" + p.Creator.String() + "\"")1140 b.WriteString(",\"window\":" + itoa(p.Window))1141 b.WriteString(",\"life\":" + itoa(p.Life))1142 b.WriteString(",\"basePrice\":" + itoa(p.BasePrice))1143 b.WriteString(",\"seed\":" + itoa(p.Seed))1144 b.WriteString(",\"pot\":" + itoa(p.Pot))1145 b.WriteString(",\"bids\":" + itoa(p.Bids))1146 b.WriteString(",\"nextPrice\":" + itoa(p.NextPrice()))1147 b.WriteString(",\"lastBidder\":\"" + p.LastBidder.String() + "\"")1148 b.WriteString(",\"lastBidAt\":" + unix(p.LastBidAt))1149 b.WriteString(",\"firstBidAt\":" + unix(p.FirstBidAt))1150 b.WriteString(",\"deadline\":" + unix(p.Deadline()))1151 b.WriteString(",\"lastDay\":" + unix(p.LastDay()))1152 b.WriteString(",\"liveBps\":" + itoa(p.LiveBps()))1153 b.WriteString(",\"clock\":" + itoa(p.Clock))1154 b.WriteString(",\"createdAt\":" + unix(p.CreatedAt))1155 b.WriteString(",\"over\":" + jbool(p.Over()))1156 b.WriteString(",\"popped\":" + jbool(p.Popped))1157 b.WriteString(",\"paid\":" + jbool(p.Paid))1158 b.WriteString(",\"cancelled\":" + jbool(p.Cancelled))1159 b.WriteString(",\"bidders\":" + itoa(int64(len(p.Roster))))1160 b.WriteString(",\"divPaid\":" + itoa(p.DivPaid))1161 b.WriteString(",\"refPaid\":" + itoa(p.RefPaid))1162 b.WriteString(",\"creatorPaid\":" + itoa(p.CreatorPaid))1163 b.WriteString(",\"treasuryPaid\":" + itoa(p.TreasuryPaid))1164 b.WriteString(",\"shotPaid\":" + itoa(p.ShotPaid))1165 b.WriteString(",\"teamPaid\":" + itoa(p.TeamPaid))1166 b.WriteString(",\"sharedPaid\":" + itoa(p.SharedPaid))1167 b.WriteString(",\"winnerPaid\":" + itoa(p.WinnerPaid))1168 pending := int64(0)1169 for _, t := range p.Shots {1170 if !t.Resolved {1171 pending++1172 }1173 }1174 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))1175 flag := p.WinnerFlag1176 if m := p.find(p.LastBidder); m != nil && flag == "" {1177 flag = m.Flag1178 }1179 b.WriteString(",\"flag\":\"" + flag + "\"")1180 // what each flag has paid into this pool1181 b.WriteString(",\"teams\":{")1182 seen := map[string]int64{}1183 order := []string{}1184 for _, m := range p.Roster {1185 if m.Flag == "" {1186 continue1187 }1188 if _, ok := seen[m.Flag]; !ok {1189 order = append(order, m.Flag)1190 }1191 seen[m.Flag] += m.Paid1192 }1193 for i, f := range order {1194 if i > 0 {1195 b.WriteString(",")1196 }1197 b.WriteString("\"" + f + "\":" + itoa(seen[f]))1198 }1199 b.WriteString("}")1200 if detail {1201 b.WriteString(",\"history\":[")1202 for i := len(p.History) - 1; i >= 0; i-- { // newest first1203 h := p.History[i]1204 if i < len(p.History)-1 {1205 b.WriteString(",")1206 }1207 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) + "}")1208 }1209 b.WriteString("],\"roster\":[")1210 for i, m := range p.Roster {1211 if i > 0 {1212 b.WriteString(",")1213 }1214 b.WriteString("{\"addr\":\"" + m.Addr.String() + "\",\"flag\":\"" + m.Flag + "\",\"paid\":" + itoa(m.Paid) + ",\"earned\":" + itoa(m.Earned) + "}")1215 }1216 b.WriteString("],\"shotlog\":[")1217 for i := len(p.Shots) - 1; i >= 0; i-- { // newest first1218 t := p.Shots[i]1219 if i < len(p.Shots)-1 {1220 b.WriteString(",")1221 }1222 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) + "}")1223 }1224 b.WriteString("]")1225 }1226 b.WriteString("}")1227 return b.String()1228}12291230// jsonCommits lists the unused commitments in order, hex, so a keeper that1231// starts can tell which of them it holds the secrets for and Retire the rest.1232func jsonCommits() string {1233 var b strings.Builder1234 b.WriteString("{\"height\":" + itoa(runtime.ChainHeight()) + ",\"commits\":[")1235 for i := head; i < len(commits); i++ {1236 if i > head {1237 b.WriteString(",")1238 }1239 b.WriteString("\"" + hex.EncodeToString([]byte(commits[i])) + "\"")1240 }1241 b.WriteString("]}")1242 return b.String()1243}12441245// commitHead is the next commitment a shot would take, hex, "" when none is free.1246func commitHead() string {1247 if head >= len(commits) {1248 return ""1249 }1250 return hex.EncodeToString([]byte(commits[head]))1251}12521253func jsonAll() string {1254 var b strings.Builder1255 b.WriteString("{\"now\":" + itoa(time.Now().Unix()) + ",\"height\":" + itoa(runtime.ChainHeight()) + ",\"realm\":\"" + pkgPath + "\"")1256 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)) +1257 ",\"shotMaxBps\":" + itoa(ShotMaxBps) + ",\"shotFair\":" + itoa(ShotFair) + ",\"shotDelay\":" + itoa(ShotDelay) + ",\"shotWindow\":" + itoa(ShotWindow) + ",\"minClock\":" + itoa(MinClock) +1258 ",\"minShot\":" + itoa(MinShot) + ",\"revealTip\":" + itoa(RevealTip) + ",\"keeperFresh\":" + itoa(KeeperFresh) + ",\"liveFloor\":" + itoa(LiveFloor) + "}")1259 b.WriteString(",\"shotsOn\":" + jbool(shotsOn))1260 b.WriteString(",\"treasury\":\"" + treasuryAddr.String() + "\",\"treasuryPaid\":" + itoa(treasuryPaid))1261 b.WriteString(",\"keeper\":\"" + keeper.String() + "\",\"keeperSeen\":" + unix(keeperSeen) + ",\"commitsFree\":" + itoa(int64(len(commits)-head)) + ",\"commitHead\":\"" + commitHead() + "\",\"voided\":" + itoa(voided) + ",\"late\":" + itoa(late))1262 b.WriteString(",\"pools\":[")1263 for i, p := range pools {1264 if i > 0 {1265 b.WriteString(",")1266 }1267 b.WriteString(p.json(false))1268 }1269 b.WriteString("],\"teams\":" + jsonTeams() + "}")1270 return b.String()1271}12721273// jsonTeams is the country leaderboard: paid in, won, and players per flag.1274func jsonTeams() string {1275 var b strings.Builder1276 b.WriteString("{")1277 i := 01278 for f, paid := range teamPaid {1279 if i > 0 {1280 b.WriteString(",")1281 }1282 i++1283 b.WriteString("\"" + f + "\":{\"paid\":" + itoa(paid) + ",\"won\":" + itoa(teamWon[f]) + ",\"players\":" + itoa(teamSize[f]) + "}")1284 }1285 b.WriteString("}")1286 return b.String()1287}12881289// jsonMe is what the page shows a connected wallet about itself.1290func jsonMe(a address) string {1291 pl := players[a]1292 if pl == nil {1293 pl = &Player{}1294 }1295 return "{\"addr\":\"" + a.String() + "\",\"referrer\":\"" + pl.Referrer.String() + "\",\"recruits\":" + itoa(pl.Recruits) +1296 ",\"flag\":\"" + pl.Flag + "\",\"bids\":" + itoa(pl.Bids) + ",\"paid\":" + itoa(pl.Paid) +1297 ",\"divEarned\":" + itoa(pl.DivEarned) + ",\"refEarned\":" + itoa(pl.RefEarned) + ",\"won\":" + itoa(pl.Won) +1298 ",\"shots\":" + itoa(pl.Shots) + ",\"hits\":" + itoa(pl.Hits) + ",\"owed\":" + itoa(pl.Owed) + ",\"withdrawn\":" + itoa(pl.Withdrawn) + "}"1299}13001301func (p *Pool) status() string {1302 switch {1303 case p.Cancelled:1304 return "cancelled"1305 case p.Over():1306 s := "won by " + short(p.LastBidder)1307 if p.Popped {1308 s = "popped by " + short(p.LastBidder)1309 }1310 if !p.Paid {1311 s += " (unclaimed)"1312 }1313 return s1314 case p.Bids == 0:1315 return "no bids yet · " + dur(p.Window) + " window · next " + GNOT(p.NextPrice())1316 default:1317 left := p.Deadline().Unix() - time.Now().Unix()1318 s := dur(left) + " left"1319 if p.Clock < p.Window {1320 s += " (⚡ " + dur(p.Clock) + " clock)"1321 }1322 return s + " · " + short(p.LastBidder) + " on top · next " + GNOT(p.NextPrice()) + " · winner's share " + itoa(p.LiveBps()/100) + "%"1323 }1324}13251326func markdownAll() string {1327 var b strings.Builder1328 b.WriteString("# Bubble Rumble v5\n\n")1329 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). ")1330 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")1331 b.WriteString("Play at [bubblerumble.net](https://bubblerumble.net).\n\n## Pools\n\n")1332 if len(pools) == 0 {1333 b.WriteString("_none yet_\n")1334 }1335 for _, p := range pools {1336 b.WriteString("- [#" + itoa(p.ID) + " " + p.Name + "](/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble5:" + itoa(p.ID) + ") · " + GNOT(p.Pot) + " · " + p.status() + "\n")1337 }1338 return b.String()1339}13401341func (p *Pool) markdown() string {1342 var b strings.Builder1343 b.WriteString("# " + p.Name + "\n\n")1344 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")1345 if !p.LastDay().IsZero() {1346 b.WriteString("- last day: " + p.LastDay().UTC().Format("2006-01-02 15:04 UTC") + " · winner's share now " + itoa(p.LiveBps()/100) + "%\n")1347 }1348 if len(p.Shots) > 0 {1349 b.WriteString("- shots: " + itoa(int64(len(p.Shots))) + " · misses: " + itoa(p.Misses) + " · unrevealed: " + itoa(p.Voided) + "\n")1350 }1351 b.WriteString("\n## Bids\n\n")1352 if len(p.History) == 0 {1353 b.WriteString("_none yet_\n")1354 }1355 for i := len(p.History) - 1; i >= 0; i-- {1356 h := p.History[i]1357 line := "- #" + itoa(h.N) + " " + short(h.Bidder)1358 if h.Flag != "" {1359 line += " (" + h.Flag + ")"1360 }1361 line += " paid " + GNOT(h.Price)1362 if h.Shot > 0 {1363 line += " + a " + GNOT(h.Shot) + " shot"1364 }1365 b.WriteString(line + " · pot " + GNOT(h.Pot) + "\n")1366 }1367 return b.String()1368}1369Bid(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
TreasuryAddr() string
TreasuryPaid() 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.