1// Package bubblerumble3 is the third Bubble Rumble realm. v2's money stays:2// of every bid 55% goes to the pot, 30% is paid at once to the pool's earlier3// bidders pro-rata to what they paid in, 10% to whoever invited the bidder,4// 5% to the pool's creator. What v3 adds:5//6// - A last day. Every pool has a life (creator-set, 1h..30d) that starts at7// its first bid. The pool closes when its clock runs out OR its life does,8// whichever comes first. While the life runs out, the winner's share slides9// toward nothing and that part of the pot is instead split among all the10// pool's bidders pro-rata to what they paid in. A pool still contested on11// its last day ends as a shared pot, so nobody is stuck in a loop and bots12// gain nothing by outlasting people.13// - Flag war. At the close, TeamShare of the pot goes to the OTHER bidders14// who flew the winner's flag, pro-rata by paid-in; the winner never takes15// it. A winner whose flag nobody else flew hands it to all the other16// bidders instead, so a private flag buys nothing. A flag is locked per17// pool at the first bid there. The winner's own part is what slides.18// - Rebidding on yourself is allowed (it pays the others as any bid does).19// - Earnings are credited, not pushed: dividends, team shares and the20// close split accrue to each address and Withdraw sends them, so no bid or21// close ever loops over a hundred transfers, and nothing can brick a pool.22// - A bid's clock can be shortened by paying more, but never under MinClock,23// so paying up is never a way to close a pool before anyone can answer.24// - Shots. A bid may carry extra money as a shot at the bubble: a chance to25// pop it at once. The chance is 0.8 × shot / prize, at most 1% a shot,26// where the prize is the pot or ShotCap, whichever is smaller; a pot over27// ShotCap pays a ShotCap slice instead of popping. The shot goes into the28// pot, hit or miss, and every miss is counted on the bubble.29//30// The draw is the chain's block time, which on tm2 is the median of the31// validators' vote timestamps for the block before: public one block32// ahead, and nudgeable by a validator who is the median vote. Two things33// follow. A shot is drawn only in the blocks ShotDelay to ShotDelay+34// ShotWindow after it was paid for, by whoever touches the pool then, and35// is void after that: a shooter cannot wait for a block whose time suits36// them, and the two blocks of leeway are worth less than the bid a shot37// must ride on. And the prize is capped at ShotCap, small enough that a38// validator grinding a timestamp is stealing lunch money, with SetShots as39// the switch if one does. Raise the cap when gno.land has real randomness.40package bubblerumble34142import (43 "chain"44 "chain/banker"45 "chain/runtime"46 "chain/runtime/unsafe"47 "crypto/sha256"48 "encoding/binary"49 "math"50 "math/bits"51 "strconv"52 "strings"53 "time"54)5556const (57 MinWindow int64 = 10 // seconds58 MaxWindow int64 = 30 * 24 * 3600 // 30 days59 MinLife int64 = 3600 // an hour60 MaxLife int64 = 30 * 24 * 3600 // 30 days61 MinBasePrice int64 = 1_000 // ugnot, 0.001 GNOT62 MaxBasePrice int64 = 1_000_000_000_000 // ugnot, 1,000,000 GNOT63 MaxNameLen = 4064 HistoryLen = 50 // bids kept per pool for the feed65 RosterMax = 100 // distinct bidders a pool pays dividends, team shares and the close split to66 MaxBoost int64 = 10 // the shortest clock a bid can buy is window/MaxBoost...67 MinClock int64 = 300 // ...and never under five minutes: people must be able to answer6869 // where every bid goes, in percent70 PotShare int64 = 5571 DivShare int64 = 30 // to earlier bidders of the pool, pro-rata by weight72 RefShare int64 = 10 // to the bidder's inviter73 CreatorShare int64 = 574 // of the pot at the close, to the other bidders under the winner's flag75 TeamShare int64 = 357677 ShotCap int64 = 10_000_000 // ugnot: the most a shot can take, 10 GNOT78 ShotMaxBps int64 = 100 // 1% a shot, whatever is paid79 ShotFair int64 = 8000 // chance in bps = ShotFair × shot / prize (0.8 of fair odds)80 ShotDelay int64 = 3 // blocks after the shot was paid for before it can be drawn...81 ShotWindow int64 = 2 // ...and how many more blocks it may still be drawn in82)8384// admin is the realm's deployer: the only address that may turn shots off.85const admin = address("g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr")8687// pkgPath must match gnomod.toml: the realm's own address derives from it.88const pkgPath = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble3"8990type Entry struct {91 N int6492 Bidder address93 Flag string94 Price int64 // what was paid for the bid itself; at least the price at the time95 Shot int64 // extra paid for a chance to pop the bubble96 Clock int64 // seconds this bid had to stand97 Pot int64 // pot right after this bid98 At time.Time99}100101// Member is a distinct bidder of a pool: dividends, team shares and the close102// split go to the roster. The flag is the one flown at the first bid here.103type Member struct {104 Addr address105 Flag string106 Paid int64 // gross paid into this pool (bids and shots)107 Earned int64 // dividends received from this pool108}109110// Shot is a shot at the bubble waiting to be drawn, or drawn.111type Shot struct {112 N int64 // 1-based within the pool113 Bidder address114 Paid int64 // ugnot paid for it115 Prize int64 // what it plays for: the pot then, or ShotCap116 Bps int64 // chance, in basis points117 CommitH int64 // block height when paid118 Resolved bool119 Won bool120 DrawH int64121}122123type Pool struct {124 ID int64125 Name string126 Creator address127 Window int64 // seconds the last bid must stand to win128 Life int64 // seconds from the first bid to the last day129 BasePrice int64 // ugnot; the Nth bid costs BasePrice·√N130 Seed int64131 Pot int64132 Bids int64133 LastBidder address134 LastBidAt time.Time135 FirstBidAt time.Time136 Clock int64 // seconds the current top bid has to stand137 CreatedAt time.Time138 Paid bool // the pot went out, or the seed back to the creator139 Cancelled bool140 Popped bool // a shot ended it141 PoppedAt time.Time142 DivPaid int64 // dividends paid out of this pool so far143 RefPaid int64144 CreatorPaid int64145 ShotPaid int64 // slices paid to hits while the pool went on146 Misses int64 // shots drawn and lost: the scars on the bubble147 WinnerFlag string148 TeamPaid int64 // what the winner's team received at the close149 SharedPaid int64 // the pro-rata part of the close150 WinnerPaid int64151 History []Entry152 Roster []*Member153 Shots []*Shot154}155156// Player is what the realm remembers about an address across pools.157type Player struct {158 Referrer address // set by the first bid, permanent159 Recruits int64160 Flag string161 Bids int64162 Paid int64163 DivEarned int64164 RefEarned int64165 Won int64 // pots, team shares, close splits and shot slices received166 Shots int64167 Hits int64168 Owed int64 // credited and not yet withdrawn169 Withdrawn int64170}171172var (173 shotsOn = true174 pools []*Pool175 players = map[address]*Player{}176 teamPaid = map[string]int64{} // flag -> gross bid volume under it177 teamWon = map[string]int64{} // flag -> pots closed under it178 teamSize = map[string]int64{} // flag -> players flying it179 realmAddr = chain.PackageAddress(pkgPath)180)181182// Price of the next bid in a pool with the given base price and n bids so far.183func Price(base, n int64) int64 {184 return int64(math.Ceil(float64(base) * math.Sqrt(float64(n+1))))185}186187// LastDay is when the pool's life runs out; zero before the first bid.188func (p *Pool) LastDay() time.Time {189 if p.FirstBidAt.IsZero() {190 return time.Time{}191 }192 return p.FirstBidAt.Add(time.Duration(p.Life) * time.Second)193}194195// Deadline is when the pool closes if nothing changes: the clock or the last196// day, whichever comes first; the pop, if a shot ended it.197func (p *Pool) Deadline() time.Time {198 if p.Popped {199 return p.PoppedAt200 }201 d := p.LastBidAt.Add(time.Duration(p.Clock) * time.Second)202 if last := p.LastDay(); !last.IsZero() && last.Before(d) {203 return last204 }205 return d206}207208func (p *Pool) Over() bool { return p.Bids > 0 && (p.Popped || !time.Now().Before(p.Deadline())) }209func (p *Pool) NextPrice() int64 { return Price(p.BasePrice, p.Bids) }210211// LiveBps is how much of the pot still goes the winner's way, in basis points212// of the pot: 10000 at the first bid, sliding to 0 on the last day. Frozen at213// the close.214func (p *Pool) LiveBps() int64 {215 if p.FirstBidAt.IsZero() || p.Life <= 0 {216 return 10000217 }218 at := time.Now()219 if p.Over() {220 at = p.Deadline()221 }222 elapsed := at.Unix() - p.FirstBidAt.Unix()223 if elapsed <= 0 {224 return 10000225 }226 if elapsed >= p.Life {227 return 0228 }229 return 10000 * (p.Life - elapsed) / p.Life230}231232// ClockFor is the clock a bid buys by paying sent against a price: the window233// divided by the multiple paid, never below window/MaxBoost.234func ClockFor(window, price, sent int64) int64 {235 c := int64(float64(window) * float64(price) / float64(sent))236 if c < window/MaxBoost {237 c = window / MaxBoost238 }239 if c < MinClock {240 c = MinClock241 }242 if c > window {243 c = window244 }245 return c246}247248// mulDiv is a×b/c without overflowing on the way: pots and paid-ins are both249// in ugnot and their product passes int64 at a few thousand GNOT each.250func mulDiv(a, b, c int64) int64 {251 if a < 0 || b < 0 || c <= 0 {252 panic("mulDiv: negative")253 }254 hi, lo := bits.Mul64(uint64(a), uint64(b))255 if hi >= uint64(c) {256 panic("mulDiv: overflow")257 }258 q, _ := bits.Div64(hi, lo, uint64(c))259 return int64(q)260}261262// ShotBps is the chance a shot buys against a prize, in basis points.263func ShotBps(shot, prize int64) int64 {264 if shot <= 0 || prize <= 0 {265 return 0266 }267 bps := ShotFair * shot / prize268 if bps > ShotMaxBps {269 bps = ShotMaxBps270 }271 return bps272}273274// CreatePool opens a pool. Any ugnot sent with the call seeds its pot. The275// creator earns CreatorShare of every bid. life is the pool's life in seconds276// from its first bid; 0 means ten windows (at least an hour, at most 30 days).277func CreatePool(cur realm, name string, window int64, basePrice int64, life int64) int64 {278 creator := userCaller(cur)279 name = cleanName(name)280 if window < MinWindow || window > MaxWindow {281 panic("window must be between 10s and 30d")282 }283 if basePrice < MinBasePrice || basePrice > MaxBasePrice {284 panic("base price must be between 0.001 and 1,000,000 GNOT")285 }286 if life == 0 { // ten windows of fighting, within the bounds287 life = 10 * window288 if life < MinLife {289 life = MinLife290 }291 if life > MaxLife {292 life = MaxLife293 }294 }295 if life < MinLife || life > MaxLife {296 panic("life must be between 1h and 30d")297 }298 if life < window {299 panic("life must be at least the window")300 }301 seed := ugnotSent()302 p := newPool(name, creator, window, basePrice, life, seed)303 chain.Emit("PoolCreated", "id", itoa(p.ID), "creator", creator.String(), "seed", itoa(seed))304 return p.ID305}306307func newPool(name string, creator address, window, basePrice, life, seed int64) *Pool {308 p := &Pool{309 ID: int64(len(pools)) + 1, Name: name, Creator: creator, Window: window, Life: life,310 BasePrice: basePrice, Seed: seed, Pot: seed, CreatedAt: time.Now(),311 }312 pools = append(pools, p)313 return p314}315316// Bid pays at least the current price and resets the clock. ref is the address317// that invited the bidder ("" for none; only the first bid ever sets it), flag318// a two-letter country to play under ("" keeps the last one; the flag is locked319// per pool at the first bid there), shot how much of the money sent is a shot320// at popping the bubble (0 for none): the rest must cover the price, and paying321// more than the price shortens the clock.322func Bid(cur realm, id int64, ref string, flag string, shot int64) {323 bidder := userCaller(cur)324 p := pool(id)325 sent := ugnotSent()326 if p.Cancelled {327 panic("pool was cancelled")328 }329 // shots paid for earlier are drawn first; if one of them pops the bubble,330 // this bid has nothing to bid on and its money goes back331 p.drawShots(cur)332 if p.Over() {333 if sent > 0 {334 send(cur, bidder, sent)335 chain.Emit("Refund", "id", itoa(id), "to", bidder.String(), "ugnot", itoa(sent))336 }337 if p.Popped {338 return339 }340 panic("pool is closed")341 }342 if shot < 0 || shot > sent {343 panic("shot must be between 0 and the amount sent")344 }345 if shot > 0 && !shotsOn {346 panic("shots are off")347 }348 price := p.NextPrice()349 bid := sent - shot350 if bid < price {351 panic("send at least " + itoa(price) + "ugnot for the bid, got " + itoa(bid) + "ugnot")352 }353 if shot > 0 {354 // checked against the most the pot can be after this bid, so a shot that355 // passes here has a chance against the real pot too; nothing has changed yet356 most := p.Pot + bid + shot357 if most > ShotCap {358 most = ShotCap359 }360 if ShotBps(shot, most) == 0 {361 panic("that shot is too small for any chance at " + GNOT(most) + ": send at least " + itoa(most/ShotFair+1) + "ugnot as the shot")362 }363 }364 pl := player(bidder)365 if f := cleanFlag(flag); f != "" && f != pl.Flag {366 if pl.Flag != "" {367 teamSize[pl.Flag]--368 }369 pl.Flag = f370 teamSize[f]++371 }372 if pl.Referrer == "" && ref != "" {373 r := address(ref)374 if r.IsValid() && r != bidder {375 pl.Referrer = r376 player(r).Recruits++377 }378 }379380 // where the bid goes381 div, refCut, creatorCut := bid*DivShare/100, bid*RefShare/100, bid*CreatorShare/100382 toPot := bid - div - refCut - creatorCut383 if creatorCut > 0 {384 send(cur, p.Creator, creatorCut)385 p.CreatorPaid += creatorCut386 }387 if pl.Referrer != "" && refCut > 0 {388 send(cur, pl.Referrer, refCut)389 p.RefPaid += refCut390 player(pl.Referrer).RefEarned += refCut391 } else {392 toPot += refCut393 }394 divPaid := div - p.payDividends(cur, bidder, div)395 toPot += div - divPaid396 // the shot goes into the pot whole, hit or miss: it is what it plays for397 p.Pot += toPot + shot398399 m := p.member(bidder, pl.Flag)400 if m.Flag == "" { // a flagless first bid does not lock "no flag": the first flag flown here does401 m.Flag = pl.Flag402 }403 m.Paid += sent404 pl.Bids++405 pl.Paid += sent406 if pl.Flag != "" {407 teamPaid[pl.Flag] += sent408 }409 p.Bids++410 if p.FirstBidAt.IsZero() {411 p.FirstBidAt = time.Now()412 }413 p.LastBidder = bidder414 p.LastBidAt = time.Now()415 p.Clock = ClockFor(p.Window, price, bid)416 p.History = append(p.History, Entry{N: p.Bids, Bidder: bidder, Flag: m.Flag, Price: bid, Shot: shot, Clock: p.Clock, Pot: p.Pot, At: p.LastBidAt})417 if len(p.History) > HistoryLen {418 p.History = p.History[len(p.History)-HistoryLen:]419 }420 chain.Emit("Bid", "id", itoa(id), "n", itoa(p.Bids), "bidder", bidder.String(), "paid", itoa(bid), "shot", itoa(shot), "pot", itoa(p.Pot), "div", itoa(divPaid))421422 if shot > 0 {423 prize := p.Pot424 if prize > ShotCap {425 prize = ShotCap426 }427 bps := ShotBps(shot, prize)428 t := &Shot{N: int64(len(p.Shots)) + 1, Bidder: bidder, Paid: shot, Prize: prize, Bps: bps, CommitH: runtime.ChainHeight()}429 p.Shots = append(p.Shots, t)430 pl.Shots++431 chain.Emit("Shot", "id", itoa(id), "shot", itoa(t.N), "bidder", bidder.String(), "paid", itoa(shot), "prize", itoa(prize), "bps", itoa(bps), "drawAt", itoa(t.CommitH+ShotDelay))432 }433}434435// SetShots turns shots off (or on again); only the deployer may.436func SetShots(cur realm, on bool) {437 if userCaller(cur) != admin {438 panic("only the deployer")439 }440 shotsOn = on441 chain.Emit("Shots", "on", jbool(on))442}443444// Draw draws the shots at a pool that are due. Anyone may call it; every bid445// and claim on the pool does the same first.446func Draw(cur realm, id int64) {447 pool(id).drawShots(cur)448}449450// drawShots resolves every shot that is due, oldest first. A shot is drawn in451// the blocks ShotDelay to ShotDelay+ShotWindow after it was paid for, from the452// block's time and height, the shot and its shooter, and the address that sent453// the drawing transaction; a shot those blocks passed by undrawn is void, its454// money stays in the pot, and it counts as a miss. A hit takes ShotCap from a455// pot larger than that and the pool goes on; a pot at or under ShotCap pops:456// the pool closes now with the shooter as its winner, settled at Claim.457func (p *Pool) drawShots(cur realm) {458 h := runtime.ChainHeight()459 for _, t := range p.Shots {460 if t.Resolved || t.CommitH+ShotDelay > h {461 continue462 }463 t.Resolved, t.DrawH = true, h464 if t.CommitH+ShotDelay+ShotWindow < h || p.Over() {465 // too late, or the pool closed first: void466 p.Misses++467 chain.Emit("Drawn", "id", itoa(p.ID), "shot", itoa(t.N), "bidder", t.Bidder.String(), "hit", "false", "void", "true")468 continue469 }470 t.Won = shotHits(seedFor(p.ID, t, h), t.Bps)471 chain.Emit("Drawn", "id", itoa(p.ID), "shot", itoa(t.N), "bidder", t.Bidder.String(), "hit", jbool(t.Won), "bps", itoa(t.Bps))472 if !t.Won {473 p.Misses++474 continue475 }476 player(t.Bidder).Hits++477 if p.Pot > ShotCap {478 credit(t.Bidder, ShotCap)479 p.Pot -= ShotCap480 p.ShotPaid += ShotCap481 player(t.Bidder).Won += ShotCap482 chain.Emit("Slice", "id", itoa(p.ID), "to", t.Bidder.String(), "ugnot", itoa(ShotCap))483 continue484 }485 p.Popped, p.PoppedAt, p.LastBidder = true, time.Now(), t.Bidder486 chain.Emit("Popped", "id", itoa(p.ID), "by", t.Bidder.String(), "pot", itoa(p.Pot))487 }488}489490func seedFor(poolID int64, t *Shot, h int64) uint64 {491 var b []byte492 b = append(b, pkgPath...)493 b = binary.BigEndian.AppendUint64(b, uint64(poolID))494 b = binary.BigEndian.AppendUint64(b, uint64(t.N))495 b = append(b, t.Bidder.String()...)496 b = binary.BigEndian.AppendUint64(b, uint64(t.CommitH))497 b = binary.BigEndian.AppendUint64(b, uint64(h))498 b = binary.BigEndian.AppendUint64(b, uint64(time.Now().UnixNano()))499 b = append(b, unsafe.OriginCaller().String()...)500 sum := sha256.Sum256(b)501 return binary.BigEndian.Uint64(sum[:8])502}503504// shotHits is the draw: a uniform number in [0, 10000) under the chance.505func shotHits(seed uint64, bps int64) bool {506 return int64(seed%10000) < bps507}508509// payDividends credits div to the pool's earlier bidders, pro-rata to what510// each has paid in, skipping the bidder; whatever cannot be placed (no earlier511// bidders, rounding) is returned to go into the pot.512func (p *Pool) payDividends(cur realm, bidder address, div int64) int64 {513 var total int64514 for _, m := range p.Roster {515 if m.Addr != bidder {516 total += m.Paid517 }518 }519 if total == 0 || div == 0 {520 return div521 }522 var paid int64523 for _, m := range p.Roster {524 if m.Addr == bidder {525 continue526 }527 amt := mulDiv(div, m.Paid, total)528 if amt > 0 {529 credit(m.Addr, amt)530 m.Earned += amt531 player(m.Addr).DivEarned += amt532 paid += amt533 }534 }535 p.DivPaid += paid536 return div - paid537}538539// member finds or adds a bidder on the roster; a full roster drops its oldest.540// A new member's flag is the one given; once set it is locked for this pool.541func (p *Pool) member(a address, flag string) *Member {542 for _, m := range p.Roster {543 if m.Addr == a {544 return m545 }546 }547 if len(p.Roster) >= RosterMax {548 p.Roster = p.Roster[1:]549 }550 m := &Member{Addr: a, Flag: flag}551 p.Roster = append(p.Roster, m)552 return m553}554555// Claim settles a finished pool: everything is credited, and Withdraw sends556// it. TeamShare of the pot goes to the OTHER roster members flying the557// winner's flag, pro-rata by paid-in — or, when nobody else flew it, to all558// the other roster members. Of the rest, the live part (LiveBps) is the559// winner's and the shared part is split among the whole roster pro-rata by560// paid-in. A pool with a single bidder credits them everything. Rounding goes561// to the winner. Anyone may call it.562func Claim(cur realm, id int64) {563 p := pool(id)564 if p.Cancelled {565 panic("pool was cancelled")566 }567 p.drawShots(cur)568 if !p.Over() {569 panic("clock still running")570 }571 if p.Paid {572 panic("already paid out")573 }574 p.Paid = true575 winner := p.LastBidder576 flag := ""577 if m := p.find(winner); m != nil {578 flag = m.Flag579 }580 p.WinnerFlag = flag581 pot := p.Pot582583 // the team: the others under the winner's flag, else all the others584 var team int64585 for _, m := range p.Roster {586 if m.Addr != winner && m.Flag == flag && flag != "" {587 team += m.Paid588 }589 }590 teamFlag := flag591 if team == 0 {592 teamFlag = "" // nobody else flew it: every other bidder is the team593 for _, m := range p.Roster {594 if m.Addr != winner {595 team += m.Paid596 }597 }598 }599 toWinner := pot600 if team > 0 {601 cut := pot * TeamShare / 100602 for _, m := range p.Roster {603 if m.Addr == winner || (teamFlag != "" && m.Flag != teamFlag) {604 continue605 }606 amt := mulDiv(cut, m.Paid, team)607 if amt > 0 {608 credit(m.Addr, amt)609 player(m.Addr).Won += amt610 toWinner -= amt611 p.TeamPaid += amt612 }613 }614 // the rest slides: the live part stays the winner's, the shared part is everyone's by paid-in615 rest := pot - cut616 shared := rest - mulDiv(rest, p.LiveBps(), 10000)617 if shared > 0 {618 var total int64619 for _, m := range p.Roster {620 total += m.Paid621 }622 for _, m := range p.Roster {623 amt := mulDiv(shared, m.Paid, total)624 if amt > 0 && m.Addr != winner {625 credit(m.Addr, amt)626 player(m.Addr).Won += amt627 toWinner -= amt628 p.SharedPaid += amt629 }630 }631 }632 }633 if toWinner > 0 {634 credit(winner, toWinner)635 player(winner).Won += toWinner636 }637 p.WinnerPaid = toWinner638 if flag != "" {639 teamWon[flag] += p.Pot640 }641 chain.Emit("Paid", "id", itoa(id), "winner", winner.String(), "pot", itoa(p.Pot), "winnerPaid", itoa(toWinner), "team", itoa(p.TeamPaid), "shared", itoa(p.SharedPaid), "liveBps", itoa(p.LiveBps()))642}643644// Cancel lets the creator take the seed back from a pool nobody has bid on.645func Cancel(cur realm, id int64) {646 caller := userCaller(cur)647 p := pool(id)648 if p.Creator != caller {649 panic("only the creator can cancel")650 }651 if p.Bids > 0 {652 panic("pool already has bids")653 }654 if p.Cancelled {655 panic("already cancelled")656 }657 p.Cancelled = true658 p.Paid = true659 if p.Pot > 0 {660 send(cur, caller, p.Pot)661 }662 chain.Emit("Cancelled", "id", itoa(id))663}664665func (p *Pool) find(a address) *Member {666 for _, m := range p.Roster {667 if m.Addr == a {668 return m669 }670 }671 return nil672}673674func player(a address) *Player {675 pl := players[a]676 if pl == nil {677 pl = &Player{}678 players[a] = pl679 }680 return pl681}682683func send(cur realm, to address, ugnot int64) {684 banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins(realmAddr, to, chain.Coins{{"ugnot", ugnot}})685}686687// credit books ugnot to an address; Withdraw pays it out.688func credit(to address, ugnot int64) {689 player(to).Owed += ugnot690}691692// Withdraw sends the caller everything credited to them: dividends, team693// shares, close splits, slices and won pots.694func Withdraw(cur realm) int64 {695 who := userCaller(cur)696 pl := player(who)697 amt := pl.Owed698 if amt <= 0 {699 panic("nothing to withdraw")700 }701 pl.Owed = 0702 pl.Withdrawn += amt703 send(cur, who, amt)704 chain.Emit("Withdrawn", "to", who.String(), "ugnot", itoa(amt))705 return amt706}707708// Owed is what an address could withdraw right now.709func Owed(a address) int64 { return player(a).Owed }710711func pool(id int64) *Pool {712 if id < 1 || id > int64(len(pools)) {713 panic("no such pool")714 }715 return pools[id-1]716}717718// userCaller is the signer of the transaction, refusing calls relayed through719// another realm so nobody can bid on someone else's behalf, and so no realm can720// pay for a shot and revert on a miss.721func userCaller(cur realm) address {722 prev := cur.Previous()723 if !prev.IsUserCall() {724 panic("must be a direct user call")725 }726 return prev.Address()727}728729func ugnotSent() int64 {730 sent := unsafe.OriginSend()731 for _, c := range sent {732 if c.Denom != "ugnot" {733 panic("only ugnot is accepted")734 }735 }736 return sent.AmountOf("ugnot")737}738739func cleanName(s string) string {740 s = strings.TrimSpace(s)741 if s == "" || len(s) > MaxNameLen {742 panic("name must be 1-40 characters")743 }744 for _, c := range s {745 if c < 0x20 || c == 0x7f {746 panic("name has control characters")747 }748 }749 return s750}751752// cleanFlag accepts a two-letter country code, upper-cased; anything else is "".753func cleanFlag(s string) string {754 s = strings.ToUpper(strings.TrimSpace(s))755 if len(s) != 2 || s[0] < 'A' || s[0] > 'Z' || s[1] < 'A' || s[1] > 'Z' {756 return ""757 }758 return s759}760761func itoa(n int64) string { return strconv.FormatInt(n, 10) }762763func parseID(s string) int64 {764 n, err := strconv.ParseInt(s, 10, 64)765 if err != nil {766 panic("bad pool id")767 }768 return n769}770771// GNOT formats ugnot as GNOT with the trailing zeros trimmed.772func GNOT(ugnot int64) string {773 whole, frac := ugnot/1_000_000, ugnot%1_000_000774 if frac == 0 {775 return itoa(whole) + " GNOT"776 }777 f := strconv.FormatInt(1_000_000+frac, 10)[1:]778 return itoa(whole) + "." + strings.TrimRight(f, "0") + " GNOT"779}780781func short(a address) string {782 s := a.String()783 if len(s) < 12 {784 return s785 }786 return s[:8] + "…" + s[len(s)-4:]787}788789func dur(sec int64) string {790 if sec <= 0 {791 return "0s"792 }793 d, h, m, s := sec/86400, sec%86400/3600, sec%3600/60, sec%60794 out := ""795 if d > 0 {796 out += itoa(d) + "d "797 }798 if d > 0 || h > 0 {799 out += itoa(h) + "h "800 }801 if d > 0 || h > 0 || m > 0 {802 out += itoa(m) + "m "803 }804 return out + itoa(s) + "s"805}806807// --- rendering ---------------------------------------------------------------808809// Render serves gnoweb ("" and "<id>") and the page ("json", "json/<id>",810// "me/<address>", "teams").811func Render(path string) string {812 path = strings.Trim(path, "/")813 switch {814 case path == "json":815 return jsonAll()816 case strings.HasPrefix(path, "json/"):817 return pool(parseID(path[5:])).json(true)818 case strings.HasPrefix(path, "me/"):819 return jsonMe(address(path[3:]))820 case path == "teams":821 return jsonTeams()822 case path == "":823 return markdownAll()824 default:825 return pool(parseID(path)).markdown()826 }827}828829func unix(t time.Time) string {830 if t.IsZero() {831 return "0"832 }833 return itoa(t.Unix())834}835836func jbool(b bool) string {837 if b {838 return "true"839 }840 return "false"841}842843func (p *Pool) json(detail bool) string {844 var b strings.Builder845 b.WriteString("{\"id\":" + itoa(p.ID))846 b.WriteString(",\"name\":" + strconv.Quote(p.Name))847 b.WriteString(",\"creator\":\"" + p.Creator.String() + "\"")848 b.WriteString(",\"window\":" + itoa(p.Window))849 b.WriteString(",\"life\":" + itoa(p.Life))850 b.WriteString(",\"basePrice\":" + itoa(p.BasePrice))851 b.WriteString(",\"seed\":" + itoa(p.Seed))852 b.WriteString(",\"pot\":" + itoa(p.Pot))853 b.WriteString(",\"bids\":" + itoa(p.Bids))854 b.WriteString(",\"nextPrice\":" + itoa(p.NextPrice()))855 b.WriteString(",\"lastBidder\":\"" + p.LastBidder.String() + "\"")856 b.WriteString(",\"lastBidAt\":" + unix(p.LastBidAt))857 b.WriteString(",\"firstBidAt\":" + unix(p.FirstBidAt))858 b.WriteString(",\"deadline\":" + unix(p.Deadline()))859 b.WriteString(",\"lastDay\":" + unix(p.LastDay()))860 b.WriteString(",\"liveBps\":" + itoa(p.LiveBps()))861 b.WriteString(",\"clock\":" + itoa(p.Clock))862 b.WriteString(",\"createdAt\":" + unix(p.CreatedAt))863 b.WriteString(",\"over\":" + jbool(p.Over()))864 b.WriteString(",\"popped\":" + jbool(p.Popped))865 b.WriteString(",\"paid\":" + jbool(p.Paid))866 b.WriteString(",\"cancelled\":" + jbool(p.Cancelled))867 b.WriteString(",\"bidders\":" + itoa(int64(len(p.Roster))))868 b.WriteString(",\"divPaid\":" + itoa(p.DivPaid))869 b.WriteString(",\"refPaid\":" + itoa(p.RefPaid))870 b.WriteString(",\"creatorPaid\":" + itoa(p.CreatorPaid))871 b.WriteString(",\"shotPaid\":" + itoa(p.ShotPaid))872 b.WriteString(",\"teamPaid\":" + itoa(p.TeamPaid))873 b.WriteString(",\"sharedPaid\":" + itoa(p.SharedPaid))874 b.WriteString(",\"winnerPaid\":" + itoa(p.WinnerPaid))875 pending := int64(0)876 for _, t := range p.Shots {877 if !t.Resolved {878 pending++879 }880 }881 b.WriteString(",\"shots\":" + itoa(int64(len(p.Shots))) + ",\"misses\":" + itoa(p.Misses) + ",\"pending\":" + itoa(pending))882 flag := p.WinnerFlag883 if m := p.find(p.LastBidder); m != nil && flag == "" {884 flag = m.Flag885 }886 b.WriteString(",\"flag\":\"" + flag + "\"")887 // what each flag has paid into this pool888 b.WriteString(",\"teams\":{")889 seen := map[string]int64{}890 order := []string{}891 for _, m := range p.Roster {892 if m.Flag == "" {893 continue894 }895 if _, ok := seen[m.Flag]; !ok {896 order = append(order, m.Flag)897 }898 seen[m.Flag] += m.Paid899 }900 for i, f := range order {901 if i > 0 {902 b.WriteString(",")903 }904 b.WriteString("\"" + f + "\":" + itoa(seen[f]))905 }906 b.WriteString("}")907 if detail {908 b.WriteString(",\"history\":[")909 for i := len(p.History) - 1; i >= 0; i-- { // newest first910 h := p.History[i]911 if i < len(p.History)-1 {912 b.WriteString(",")913 }914 b.WriteString("{\"n\":" + itoa(h.N) + ",\"bidder\":\"" + h.Bidder.String() + "\",\"flag\":\"" + h.Flag + "\",\"price\":" + itoa(h.Price) + ",\"shot\":" + itoa(h.Shot) + ",\"clock\":" + itoa(h.Clock) + ",\"pot\":" + itoa(h.Pot) + ",\"at\":" + unix(h.At) + "}")915 }916 b.WriteString("],\"roster\":[")917 for i, m := range p.Roster {918 if i > 0 {919 b.WriteString(",")920 }921 b.WriteString("{\"addr\":\"" + m.Addr.String() + "\",\"flag\":\"" + m.Flag + "\",\"paid\":" + itoa(m.Paid) + ",\"earned\":" + itoa(m.Earned) + "}")922 }923 b.WriteString("],\"shotlog\":[")924 for i := len(p.Shots) - 1; i >= 0; i-- { // newest first925 t := p.Shots[i]926 if i < len(p.Shots)-1 {927 b.WriteString(",")928 }929 b.WriteString("{\"n\":" + itoa(t.N) + ",\"bidder\":\"" + t.Bidder.String() + "\",\"paid\":" + itoa(t.Paid) + ",\"prize\":" + itoa(t.Prize) + ",\"bps\":" + itoa(t.Bps) + ",\"drawAt\":" + itoa(t.CommitH+ShotDelay) + ",\"resolved\":" + jbool(t.Resolved) + ",\"hit\":" + jbool(t.Won) + "}")930 }931 b.WriteString("]")932 }933 b.WriteString("}")934 return b.String()935}936937func jsonAll() string {938 var b strings.Builder939 b.WriteString("{\"now\":" + itoa(time.Now().Unix()) + ",\"height\":" + itoa(runtime.ChainHeight()) + ",\"realm\":\"" + pkgPath + "\"")940 b.WriteString(",\"split\":{\"pot\":" + itoa(PotShare) + ",\"div\":" + itoa(DivShare) + ",\"ref\":" + itoa(RefShare) + ",\"creator\":" + itoa(CreatorShare) + ",\"team\":" + itoa(TeamShare) + ",\"roster\":" + itoa(int64(RosterMax)) +941 ",\"shotCap\":" + itoa(ShotCap) + ",\"shotMaxBps\":" + itoa(ShotMaxBps) + ",\"shotFair\":" + itoa(ShotFair) + ",\"shotDelay\":" + itoa(ShotDelay) + ",\"shotWindow\":" + itoa(ShotWindow) + ",\"minClock\":" + itoa(MinClock) + "}")942 b.WriteString(",\"shotsOn\":" + jbool(shotsOn))943 b.WriteString(",\"pools\":[")944 for i, p := range pools {945 if i > 0 {946 b.WriteString(",")947 }948 b.WriteString(p.json(false))949 }950 b.WriteString("],\"teams\":" + jsonTeams() + "}")951 return b.String()952}953954// jsonTeams is the country leaderboard: paid in, won, and players per flag.955func jsonTeams() string {956 var b strings.Builder957 b.WriteString("{")958 i := 0959 for f, paid := range teamPaid {960 if i > 0 {961 b.WriteString(",")962 }963 i++964 b.WriteString("\"" + f + "\":{\"paid\":" + itoa(paid) + ",\"won\":" + itoa(teamWon[f]) + ",\"players\":" + itoa(teamSize[f]) + "}")965 }966 b.WriteString("}")967 return b.String()968}969970// jsonMe is what the page shows a connected wallet about itself.971func jsonMe(a address) string {972 pl := players[a]973 if pl == nil {974 pl = &Player{}975 }976 return "{\"addr\":\"" + a.String() + "\",\"referrer\":\"" + pl.Referrer.String() + "\",\"recruits\":" + itoa(pl.Recruits) +977 ",\"flag\":\"" + pl.Flag + "\",\"bids\":" + itoa(pl.Bids) + ",\"paid\":" + itoa(pl.Paid) +978 ",\"divEarned\":" + itoa(pl.DivEarned) + ",\"refEarned\":" + itoa(pl.RefEarned) + ",\"won\":" + itoa(pl.Won) +979 ",\"shots\":" + itoa(pl.Shots) + ",\"hits\":" + itoa(pl.Hits) + ",\"owed\":" + itoa(pl.Owed) + ",\"withdrawn\":" + itoa(pl.Withdrawn) + "}"980}981982func (p *Pool) status() string {983 switch {984 case p.Cancelled:985 return "cancelled"986 case p.Over():987 s := "won by " + short(p.LastBidder)988 if p.Popped {989 s = "popped by " + short(p.LastBidder)990 }991 if !p.Paid {992 s += " (unclaimed)"993 }994 return s995 case p.Bids == 0:996 return "no bids yet · " + dur(p.Window) + " window · next " + GNOT(p.NextPrice())997 default:998 left := p.Deadline().Unix() - time.Now().Unix()999 s := dur(left) + " left"1000 if p.Clock < p.Window {1001 s += " (⚡ " + dur(p.Clock) + " clock)"1002 }1003 return s + " · " + short(p.LastBidder) + " on top · next " + GNOT(p.NextPrice()) + " · winner's share " + itoa(p.LiveBps()/100) + "%"1004 }1005}10061007func markdownAll() string {1008 var b strings.Builder1009 b.WriteString("# Bubble Rumble v3\n\n")1010 b.WriteString("Last bid standing takes the pot. Of every bid, 55% goes into the pot, 30% is paid at once to the pool's earlier bidders, 10% to whoever invited the bidder and 5% to the pool's creator. ")1011 b.WriteString("Earnings are credited and withdrawn with Withdraw. At the close " + itoa(TeamShare) + "% of the pot goes to the other bidders who flew the winner's flag. Every pool has a last day: as it nears, the winner's own share slides toward a split of the pot among all its bidders. A bid may carry a shot at the bubble: a chance of at most " + itoa(ShotMaxBps/100) + "% to pop it, or to take a " + GNOT(ShotCap) + " slice of a bigger one; every miss is counted on the bubble.\n\n")1012 b.WriteString("Play at [bubblerumble.net](https://bubblerumble.net).\n\n## Pools\n\n")1013 if len(pools) == 0 {1014 b.WriteString("_none yet_\n")1015 }1016 for _, p := range pools {1017 b.WriteString("- [#" + itoa(p.ID) + " " + p.Name + "](/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble3:" + itoa(p.ID) + ") · " + GNOT(p.Pot) + " · " + p.status() + "\n")1018 }1019 return b.String()1020}10211022func (p *Pool) markdown() string {1023 var b strings.Builder1024 b.WriteString("# " + p.Name + "\n\n")1025 b.WriteString("- pot: **" + GNOT(p.Pot) + "**\n- status: " + p.status() + "\n- window: " + dur(p.Window) + " · life: " + dur(p.Life) + "\n- base price: " + GNOT(p.BasePrice) + " · next bid: " + GNOT(p.NextPrice()) + "\n- bids: " + itoa(p.Bids) + " · creator: " + short(p.Creator) + "\n")1026 if !p.LastDay().IsZero() {1027 b.WriteString("- last day: " + p.LastDay().UTC().Format("2006-01-02 15:04 UTC") + " · winner's share now " + itoa(p.LiveBps()/100) + "%\n")1028 }1029 if len(p.Shots) > 0 {1030 b.WriteString("- shots: " + itoa(int64(len(p.Shots))) + " · misses: " + itoa(p.Misses) + "\n")1031 }1032 b.WriteString("\n## Bids\n\n")1033 if len(p.History) == 0 {1034 b.WriteString("_none yet_\n")1035 }1036 for i := len(p.History) - 1; i >= 0; i-- {1037 h := p.History[i]1038 line := "- #" + itoa(h.N) + " " + short(h.Bidder)1039 if h.Flag != "" {1040 line += " (" + h.Flag + ")"1041 }1042 line += " paid " + GNOT(h.Price)1043 if h.Shot > 0 {1044 line += " + a " + GNOT(h.Shot) + " shot"1045 }1046 b.WriteString(line + " · pot " + GNOT(h.Pot) + "\n")1047 }1048 return b.String()1049}1050Bid(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
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
Draw(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)
GNOT(ugnot int64) string
Owed(a string) int64
Price(base int64, n int64) int64
Render(path string) 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)
ShotBps(shot int64, prize 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.