Transaction
EA6ADDCE840F1E…F34A7FA163A9
Block 291,200 · index 0 · indexed
Summary
- Hash
- EA6ADDCE840F1E2E459549745C4419D107DCF09FA9576273BB77F34A7FA163A9
- Block
- 291,200
- Size
- 83234 bytes
- Gas used
- 108,141,945 / 130,000,000
- Fee
- 130000 ugnot
- Status
- success
Messages
Arguments · 9
- #1bubblerumble4
- #2bubblerumble4.gno
- #3// Package bubblerumble4 is the fourth Bubble Rumble realm. v3's game stays whole: // of every bid 55% goes to the pot, 30% is credited at once to the pool's earlier // bidders pro-rata to what they paid in, 10% to whoever invited the bidder, 5% to // the pool's creator; every pool has a last day, a flag war at the close, credited // earnings and Withdraw, a clock that can be bought down but never under MinClock. // What v4 changes is the shot: it can now win the pot. // // - A shot is still extra money on a bid, a chance to pop the bubble at once. // The chance is 0.8 × shot / take, at most 1% a shot, where the take is what // a pop would pay the shooter right now: the pot less the team's 35%, times the // winner's live share (LiveBps). So the pot keeps a fifth of every shot's fair // value however far the last day has slid, and a hit pops the bubble: the pool // closes with the shooter as its winner and Claim pays it as any pop. // - The draw's randomness is a commit-reveal by the keeper — the operator's own // box, which also settles pools on time. The keeper publishes sha256 hashes of // secrets ahead of time (Commit); a bid with a shot takes the next unused one; // the shot is drawn when someone (the keeper, or anyone) reveals that secret // (Reveal) in the blocks ShotDelay to ShotDelay+ShotWindow after the bid. The // seed is the secret with the shot's own facts and the reveal block's height // and time. Nobody without the secret can grind it: not the shooter, not a // validator. The secret's holder knows the two candidate block times a block // ahead and may pick which of the window's three blocks to reveal in — three // rolls, at most 2.4× fair on a 1% shot — which is why the keeper key and its // operator never bid or shoot. // - A shot not revealed in its window, or on a pool that closed first, is void: // it goes into the pot and counts as a miss, as in v3. Nothing is refunded, so // withholding a reveal never pays whoever withholds it. What remains is that a // keeper who leads a pot could withhold a stranger's hit and let the money fall // into the pot it leads: refused shots are counted on the pool for all to see, // and so are reveals that came in the window's later blocks (the honest keeper // always reveals at the first), so a keeper choosing its block would show. // - Shots are sold only while the keeper is alive: a shot needs a free commitment // and a sign of life (Commit or Heartbeat) within KeeperFresh. Otherwise the bid // stands and the shot's money goes straight back in the same transaction. // - Shots are refused in the last quarter of a pool's life (which also keeps every // reveal window clear of the last day) and under MinShot. RevealTip is taken // from every shot and credited to the keeper whoever sends the reveal (the // secret sits in the mempool, and a proposer could copy it), so the keeper // pays for itself. The keeper's own shots are refused outright. // - Reveals go in shot order, so among two pending shots nobody chooses which pops. // // Residuals, stated plainly: the secret holder's three rolls; the keeper-defender // veto above; a proposer who reads the keeper's reveal in the mempool holds the // secret too, and proposing the window's blocks could drop the reveal for a roll // it does not like or leave it out altogether (a void), which Late and Voided // show; keeper and validator together could grind. SetShots is the switch if any // of it is seen. SetKeeper drops every unused commitment. package bubblerumble4 import ( "chain" "chain/banker" "chain/runtime" "chain/runtime/unsafe" "crypto/sha256" "encoding/binary" "encoding/hex" "math" "math/bits" "strconv" "strings" "time" ) const ( MinWindow int64 = 10 // seconds MaxWindow int64 = 30 * 24 * 3600 // 30 days MinLife int64 = 3600 // an hour MaxLife int64 = 30 * 24 * 3600 // 30 days MinBasePrice int64 = 1_000 // ugnot, 0.001 GNOT MaxBasePrice int64 = 1_000_000_000_000 // ugnot, 1,000,000 GNOT MaxNameLen = 40 HistoryLen = 50 // bids kept per pool for the feed RosterMax = 100 // distinct bidders a pool pays dividends, team shares and the close split to MaxBoost int64 = 10 // the shortest clock a bid can buy is window/MaxBoost... MinClock int64 = 300 // ...and never under five minutes: people must be able to answer // where every bid goes, in percent PotShare int64 = 55 DivShare int64 = 30 // to earlier bidders of the pool, pro-rata by weight RefShare int64 = 10 // to the bidder's inviter CreatorShare int64 = 5 // of the pot at the close, to the other bidders under the winner's flag TeamShare int64 = 35 ShotMaxBps int64 = 100 // 1% a shot, whatever is paid ShotFair int64 = 8000 // chance in bps = ShotFair × shot / take (0.8 of fair odds) ShotDelay int64 = 3 // blocks after the shot was paid for before it can be revealed... ShotWindow int64 = 2 // ...and how many more blocks it may still be revealed in: three blocks, as v3 drew; three rolls at most for the secret's holder MinShot int64 = 100_000 // ugnot: no shot under 0.1 GNOT, so dust cannot make reveals cost more than they tip RevealTip int64 = 20_000 // ugnot: off every shot, to whoever reveals it KeeperFresh int64 = 300 // seconds: shots are sold only this soon after the keeper's last sign of life (it heartbeats every two minutes) 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) MaxCommits = 500 // unused commitments the queue holds at most CommitBatch = 100 // hashes one Commit may bring CommitCompact = 200 // used commitments kept before the queue is compacted ) // admin is the realm's deployer: the only address that may turn shots off. const admin = address("g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr") // pkgPath must match gnomod.toml: the realm's own address derives from it. const pkgPath = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble4" type Entry struct { N int64 Bidder address Flag string Price int64 // what was paid for the bid itself; at least the price at the time Shot int64 // extra paid for a chance to pop the bubble Clock int64 // seconds this bid had to stand Pot int64 // pot right after this bid At time.Time } // Member is a distinct bidder of a pool: dividends, team shares and the close // split go to the roster. The flag is the one flown at the first bid here. type Member struct { Addr address Flag string Paid int64 // gross paid into this pool (bids and shots) Earned int64 // dividends received from this pool } // Shot is a shot at the bubble waiting to be revealed, or resolved. type Shot struct { N int64 // 1-based within the pool Bidder address Paid int64 // ugnot paid for it (the tip comes out of this at the reveal) Take int64 // what a pop would have paid the shooter when the odds were set Bps int64 // chance, in basis points CommitH int64 // block height when paid; revealable at CommitH+ShotDelay and the ShotWindow blocks after Hash string // the commitment it took: sha256 of the secret that decides it, 32 raw bytes Resolved bool Won bool Void bool // not revealed in its window, or the pool closed first: a miss DrawH int64 Revealer address } type Pool struct { ID int64 Name string Creator address Window int64 // seconds the last bid must stand to win Life int64 // seconds from the first bid to the last day BasePrice int64 // ugnot; the Nth bid costs BasePrice·√N Seed int64 Pot int64 Bids int64 LastBidder address LastBidAt time.Time FirstBidAt time.Time Clock int64 // seconds the current top bid has to stand CreatedAt time.Time Paid bool // the pot went out, or the seed back to the creator Cancelled bool Popped bool // a shot ended it PoppedAt time.Time DivPaid int64 // dividends paid out of this pool so far RefPaid int64 CreatorPaid int64 ShotPaid int64 // slices paid to hits while the pool went on Misses int64 // shots resolved and lost, voids included: the scars on the bubble Voided int64 // of those, the shots nobody revealed in time Late int64 // shots revealed in their window but not at its first block: a keeper choosing its block would show here ShotEscrow int64 // shots paid for and not yet resolved: in the pot once they are WinnerFlag string TeamPaid int64 // what the winner's team received at the close SharedPaid int64 // the pro-rata part of the close WinnerPaid int64 History []Entry Roster []*Member Shots []*Shot Settled int // shots before this index are all resolved (they resolve strictly in order): where settleShots starts } // Player is what the realm remembers about an address across pools. type Player struct { Referrer address // set by the first bid, permanent Recruits int64 Flag string Bids int64 Paid int64 DivEarned int64 RefEarned int64 Won int64 // pots, team shares, close splits and shot slices received Shots int64 Hits int64 Owed int64 // credited and not yet withdrawn Withdrawn int64 } var ( shotsOn = true keeper address // who commits and heartbeats (SetKeeper); the admin may too keeperSeen time.Time // the keeper's last Commit or Heartbeat commits []string // the hashes of the keeper's secrets (32 raw bytes each), in order; the first head are used head int voided int64 // shots voided over all pools late int64 // shots revealed after the first block of their window, over all pools pools []*Pool players = map[address]*Player{} teamPaid = map[string]int64{} // flag -> gross bid volume under it teamWon = map[string]int64{} // flag -> pots closed under it teamSize = map[string]int64{} // flag -> players flying it realmAddr = chain.PackageAddress(pkgPath) ) // Price of the next bid in a pool with the given base price and n bids so far. func Price(base, n int64) int64 { return int64(math.Ceil(float64(base) * math.Sqrt(float64(n+1)))) } // LastDay is when the pool's life runs out; zero before the first bid. func (p *Pool) LastDay() time.Time { if p.FirstBidAt.IsZero() { return time.Time{} } return p.FirstBidAt.Add(time.Duration(p.Life) * time.Second) } // Deadline is when the pool closes if nothing changes: the clock or the last // day, whichever comes first; the pop, if a shot ended it. func (p *Pool) Deadline() time.Time { if p.Popped { return p.PoppedAt } d := p.LastBidAt.Add(time.Duration(p.Clock) * time.Second) if last := p.LastDay(); !last.IsZero() && last.Before(d) { return last } return d } func (p *Pool) Over() bool { return p.Bids > 0 && (p.Popped || !time.Now().Before(p.Deadline())) } func (p *Pool) NextPrice() int64 { return Price(p.BasePrice, p.Bids) } // LiveBps is how much of the pot still goes the winner's way, in basis points // of the pot: 10000 at the first bid, sliding to 0 on the last day. Frozen at // the close. func (p *Pool) LiveBps() int64 { if p.FirstBidAt.IsZero() || p.Life <= 0 { return 10000 } at := time.Now() if p.Over() { at = p.Deadline() } elapsed := at.Unix() - p.FirstBidAt.Unix() if elapsed <= 0 { return 10000 } if elapsed >= p.Life { return 0 } return 10000 * (p.Life - elapsed) / p.Life } // ClockFor is the clock a bid buys by paying sent against a price: the window // divided by the multiple paid, never below window/MaxBoost. func ClockFor(window, price, sent int64) int64 { c := int64(float64(window) * float64(price) / float64(sent)) if c < window/MaxBoost { c = window / MaxBoost } if c < MinClock { c = MinClock } if c > window { c = window } return c } // mulDiv is a×b/c without overflowing on the way: pots and paid-ins are both // in ugnot and their product passes int64 at a few thousand GNOT each. func mulDiv(a, b, c int64) int64 { if a < 0 || b < 0 || c <= 0 { panic("mulDiv: negative") } hi, lo := bits.Mul64(uint64(a), uint64(b)) if hi >= uint64(c) { panic("mulDiv: overflow") } q, _ := bits.Div64(hi, lo, uint64(c)) return int64(q) } // ShotBps is the chance a shot buys against what a pop would pay, in basis points. func ShotBps(shot, take int64) int64 { if shot <= 0 || take <= 0 { return 0 } bps := ShotFair * shot / take if bps > ShotMaxBps { bps = ShotMaxBps } return bps } // Take is the most a pop could pay this shooter if the pool closed now on a pot // of this size: Claim's own arithmetic, run dry, with the team's share counted // as the shooter's too — one bid from a friend (or a second address) under a // flag nobody else flies collects it, so the odds must assume it is collected. // What is not the shooter's is the slide: the part of the rest that the last // day has moved to the whole roster by paid-in. func (p *Pool) Take(shooter address, pot int64) int64 { toWinner, cut := p.payout(shooter, pot, false) return toWinner + cut } // CreatePool opens a pool. Any ugnot sent with the call seeds its pot. The // creator earns CreatorShare of every bid. life is the pool's life in seconds // from its first bid; 0 means ten windows (at least an hour, at most 30 days). func CreatePool(cur realm, name string, window int64, basePrice int64, life int64) int64 { creator := userCaller(cur) name = cleanName(name) if window < MinWindow || window > MaxWindow { panic("window must be between 10s and 30d") } if basePrice < MinBasePrice || basePrice > MaxBasePrice { panic("base price must be between 0.001 and 1,000,000 GNOT") } if life == 0 { // ten windows of fighting, within the bounds life = 10 * window if life < MinLife { life = MinLife } if life > MaxLife { life = MaxLife } } if life < MinLife || life > MaxLife { panic("life must be between 1h and 30d") } if life < window { panic("life must be at least the window") } seed := ugnotSent() p := newPool(name, creator, window, basePrice, life, seed) chain.Emit("PoolCreated", "id", itoa(p.ID), "creator", creator.String(), "seed", itoa(seed)) return p.ID } func newPool(name string, creator address, window, basePrice, life, seed int64) *Pool { p := &Pool{ ID: int64(len(pools)) + 1, Name: name, Creator: creator, Window: window, Life: life, BasePrice: basePrice, Seed: seed, Pot: seed, CreatedAt: time.Now(), } pools = append(pools, p) return p } // Bid pays at least the current price and resets the clock. ref is the address // that invited the bidder ("" for none; only the first bid ever sets it), flag // a two-letter country to play under ("" keeps the last one; the flag is locked // per pool at the first bid there), shot how much of the money sent is a shot // at popping the bubble (0 for none): the rest must cover the price, and paying // more than the price shortens the clock. func Bid(cur realm, id int64, ref string, flag string, shot int64) { bidder := userCaller(cur) p := pool(id) sent := ugnotSent() if p.Cancelled { panic("pool was cancelled") } // a pool that closed (or popped) since the bidder looked: the money goes back if p.Over() { if sent > 0 { send(cur, bidder, sent) chain.Emit("Refund", "id", itoa(id), "to", bidder.String(), "ugnot", itoa(sent)) } if p.Popped { return } panic("pool is closed") } if shot < 0 || shot > sent { panic("shot must be between 0 and the amount sent") } price := p.NextPrice() bid := sent - shot if bid < price { panic("send at least " + itoa(price) + "ugnot for the bid, got " + itoa(bid) + "ugnot") } // shots of earlier bids whose window has passed are settled first p.settleShots() pl := player(bidder) if f := cleanFlag(flag); f != "" && f != pl.Flag { if pl.Flag != "" { teamSize[pl.Flag]-- } pl.Flag = f teamSize[f]++ } if pl.Referrer == "" && ref != "" { r := address(ref) if r.IsValid() && r != bidder { pl.Referrer = r player(r).Recruits++ } } // where the bid goes div, refCut, creatorCut := bid*DivShare/100, bid*RefShare/100, bid*CreatorShare/100 toPot := bid - div - refCut - creatorCut if creatorCut > 0 { send(cur, p.Creator, creatorCut) p.CreatorPaid += creatorCut } if pl.Referrer != "" && refCut > 0 { send(cur, pl.Referrer, refCut) p.RefPaid += refCut player(pl.Referrer).RefEarned += refCut } else { toPot += refCut } divPaid := div - p.payDividends(cur, bidder, div) toPot += div - divPaid p.Pot += toPot m := p.member(bidder, pl.Flag) if m.Flag == "" { // a flagless first bid does not lock "no flag": the first flag flown here does m.Flag = pl.Flag } // the shot, if there can be one: else its money goes straight back and the bid stands why := "" if shot > 0 { why = p.shotRefusal(bidder, shot) } m.Paid += sent pl.Bids++ pl.Paid += sent if pl.Flag != "" { teamPaid[pl.Flag] += sent } // the odds, on the pool as it stands with this bid booked (the shooter's own paid-in weighs in the slide): // priced here once, and a shot that buys no chance at all is refused like the rest var take, bps int64 if shot > 0 && why == "" { take = p.Take(bidder, p.Pot+shot-RevealTip) // the pot a pop would pay from: this bid's share in, the shot itself in, the tip out bps = ShotBps(shot-RevealTip, take) if bps == 0 { why = "too small for any chance at this pot" } } if why != "" { m.Paid -= shot pl.Paid -= shot if pl.Flag != "" { teamPaid[pl.Flag] -= shot } send(cur, bidder, shot) chain.Emit("ShotReturned", "id", itoa(id), "bidder", bidder.String(), "ugnot", itoa(shot), "why", why) sent -= shot shot = 0 } p.Bids++ if p.FirstBidAt.IsZero() { p.FirstBidAt = time.Now() } p.LastBidder = bidder p.LastBidAt = time.Now() p.Clock = ClockFor(p.Window, price, bid) 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}) if len(p.History) > HistoryLen { p.History = p.History[len(p.History)-HistoryLen:] } 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)) if shot > 0 { t := &Shot{N: int64(len(p.Shots)) + 1, Bidder: bidder, Paid: shot, Take: take, Bps: bps, CommitH: runtime.ChainHeight(), Hash: commits[head]} head++ p.Shots = append(p.Shots, t) p.ShotEscrow += shot pl.Shots++ 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)) } } // shotRefusal is why a shot cannot be sold on this pool right now ("" when it // can): the checks a bid runs before taking one, in the order the page shows. // The odds themselves are checked by the caller, on the booked state. func (p *Pool) shotRefusal(shooter address, shot int64) string { switch { case !shotsOn: return "shots are off" case keeper != "" && shooter == keeper: return "the keeper never shoots" case shot < MinShot: return "under the smallest shot, " + GNOT(MinShot) case head >= len(commits): return "no commitment free: the keeper is away" case keeperSeen.IsZero() || time.Now().Unix()-keeperSeen.Unix() > KeeperFresh: return "the keeper has not been seen for " + dur(KeeperFresh) case p.LiveBps() < LiveFloor: return "the winner's share has slid under a quarter" } return "" } // SetShots turns shots off (or on again); only the deployer may. func SetShots(cur realm, on bool) { if userCaller(cur) != admin { panic("only the deployer") } shotsOn = on chain.Emit("Shots", "on", jbool(on)) } // SetKeeper names the address that commits and heartbeats; only the deployer // may. Every unused commitment is dropped with it: a rotated or compromised // keeper's secrets never decide a shot. func SetKeeper(cur realm, addr address) { if userCaller(cur) != admin { panic("only the deployer") } if !addr.IsValid() { panic("bad address") } keeper = addr commits = commits[:head] chain.Emit("Keeper", "addr", addr.String()) } func keeperCaller(cur realm) address { who := userCaller(cur) if who == "" || who != keeper { panic("only the keeper") } return who } func keeperOrAdmin(cur realm) address { who := userCaller(cur) if who != admin && (who == "" || who != keeper) { panic("only the keeper or the deployer") } return who } // Heartbeat is the keeper's sign of life: shots are sold only within // KeeperFresh of the last one (a Commit counts too). func Heartbeat(cur realm) { keeperCaller(cur) keeperSeen = time.Now() } // Commit queues the hashes of secrets the keeper will reveal later, in order, // as a comma-separated list of 64 hex characters each; only the keeper, whose // box holds the secrets. A hash still in the queue (used or not) is refused: a // repeated secret would be public after its first reveal (a safety net against // the keeper repeating itself; compaction forgets the oldest). The queue holds // at most MaxCommits unused; the used ones in front of it are compacted away // past CommitCompact. func Commit(cur realm, hashes string) { keeperCaller(cur) keeperSeen = time.Now() parts := strings.Split(hashes, ",") if len(parts) == 0 || len(parts) > CommitBatch { panic("1 to " + strconv.Itoa(CommitBatch) + " hashes") } if len(commits)-head+len(parts) > MaxCommits { panic("the queue holds " + strconv.Itoa(MaxCommits) + " unused commitments at most") } if head > CommitCompact { commits = append([]string(nil), commits[head:]...) head = 0 } for _, part := range parts { b, err := hex.DecodeString(strings.TrimSpace(part)) if err != nil || len(b) != 32 { panic("a hash is 64 hex characters") } h := string(b) for _, c := range commits { // a safety net against the keeper repeating itself, over what the queue still holds if c == h { panic("hash already committed: " + part) } } commits = append(commits, h) } chain.Emit("Commit", "n", itoa(int64(len(parts))), "free", itoa(int64(len(commits)-head))) } // Retire drops the first n unused commitments: a keeper that lost the secrets // behind them (the box rebuilt, the file gone) clears them so shots can go on. // The deployer may too. It is not a sign of life. func Retire(cur realm, n int64) { keeperOrAdmin(cur) if n <= 0 { panic("retire at least one") } if int(n) > len(commits)-head { n = int64(len(commits) - head) } commits = append(commits[:head], commits[head+int(n):]...) chain.Emit("Retire", "n", itoa(n), "free", itoa(int64(len(commits)-head))) } // Reveal draws shots with the secrets behind their commitments: a comma-separated // list of pool:shot:secretHex, any pools, in one transaction — the keeper sends // every shot that is due in one call, so a run of shots on one block never // outpaces it. Anyone may call it; the secret is the proof, and the caller is // tipped RevealTip out of each shot revealed. Shots go in order: an earlier one // still pending must come first (in the list, or before). An entry not yet due // is skipped, one with a wrong secret or already resolved is skipped with an // event, so a stale entry never voids the rest; a malformed one (no such pool // or shot, a cancelled pool, not pool:shot:secret) aborts the whole call, which // only the keeper's own bug could send. The reveal must land in the // blocks CommitH+ShotDelay through CommitH+ShotDelay+ShotWindow; later, or on a // pool that has closed, the shot is already void (settleShots runs first): into // the pot as a miss. A hit pops the bubble: the pool closes now with the shooter // as its winner, settled at Claim. func Reveal(cur realm, reveals string) { who := userCaller(cur) h := runtime.ChainHeight() done := 0 for _, entry := range strings.Split(reveals, ",") { f := strings.Split(strings.TrimSpace(entry), ":") if len(f) != 3 { panic("each entry is pool:shot:secret") } id, n := parseID(f[0]), parseNum(f[1]) p := pool(id) if p.Cancelled { panic("pool was cancelled") } p.settleShots() if n < 1 || int(n) > len(p.Shots) { panic("no such shot") } t := p.Shots[n-1] if t.Resolved || (n > 1 && !p.Shots[n-2].Resolved) { chain.Emit("RevealSkipped", "id", itoa(id), "shot", itoa(n), "why", jif(t.Resolved, "resolved", "earlier shot pending")) continue } if h < t.CommitH+ShotDelay { continue // not due yet: the keeper sends a block early to be sure of the window } secret, err := hex.DecodeString(strings.TrimSpace(f[2])) sum := sha256.Sum256(secret) if err != nil || string(sum[:]) != t.Hash { chain.Emit("RevealSkipped", "id", itoa(id), "shot", itoa(n), "why", "wrong secret") continue } // (a shot past its window, or on a pool that closed, was voided by settleShots above and skipped as resolved) done++ t.Resolved, t.DrawH, t.Revealer = true, h, who p.ShotEscrow -= t.Paid // the tip is the keeper's whoever sent the reveal: the secret sits in the mempool, and a proposer could // copy it into a reveal of its own; the seed does not care who reveals, so nothing else changes tip := RevealTip if tip > t.Paid { tip = t.Paid } if keeper != "" { credit(keeper, tip) } else { credit(who, tip) } p.Pot += t.Paid - tip if h > t.CommitH+ShotDelay { // revealed in the window but not at its first block: the honest keeper's tell, counted p.Late++ late++ } t.Won = shotHits(seedFor(id, t, secret, h), t.Bps) chain.Emit("Revealed", "id", itoa(id), "shot", itoa(n), "bidder", t.Bidder.String(), "hit", jbool(t.Won), "bps", itoa(t.Bps), "by", who.String()) if !t.Won { p.Misses++ continue } player(t.Bidder).Hits++ p.Popped, p.PoppedAt, p.LastBidder = true, time.Now(), t.Bidder chain.Emit("Popped", "id", itoa(id), "by", t.Bidder.String(), "pot", itoa(p.Pot)) } if done == 0 { panic("nothing revealed") } } // Settle voids the shots at a pool that nobody revealed in time, or that its // close overtook. Anyone may call it; every bid, reveal and claim on the pool // does the same first. func Settle(cur realm, id int64) { pool(id).settleShots() } // settleShots voids, in order, every pending shot whose window has passed or // whose pool is over: the shot's money goes into the pot and counts as a miss; // nobody is tipped. func (p *Pool) settleShots() { h := runtime.ChainHeight() over := p.Over() for p.Settled < len(p.Shots) && p.Shots[p.Settled].Resolved { p.Settled++ } for _, t := range p.Shots[p.Settled:] { if t.Resolved || (h <= t.CommitH+ShotDelay+ShotWindow && !over) { continue } t.Resolved, t.Void, t.DrawH = true, true, h p.ShotEscrow -= t.Paid p.Pot += t.Paid p.Misses++ p.Voided++ voided++ chain.Emit("Voided", "id", itoa(p.ID), "shot", itoa(t.N), "bidder", t.Bidder.String(), "why", jif(over, "closed", "unrevealed")) } } func jif(c bool, a, b string) string { if c { return a } return b } // seedFor is the draw's seed: the secret with the shot's own facts and the // reveal block's height and time. Everything but the secret is public, and the // secret is the keeper's until the reveal; the block's time is the validators'. func seedFor(poolID int64, t *Shot, secret []byte, h int64) uint64 { var b []byte b = append(b, pkgPath...) b = binary.BigEndian.AppendUint64(b, uint64(poolID)) b = binary.BigEndian.AppendUint64(b, uint64(t.N)) b = append(b, t.Bidder.String()...) b = binary.BigEndian.AppendUint64(b, uint64(t.CommitH)) b = append(b, secret...) b = binary.BigEndian.AppendUint64(b, uint64(h)) b = binary.BigEndian.AppendUint64(b, uint64(time.Now().UnixNano())) sum := sha256.Sum256(b) return binary.BigEndian.Uint64(sum[:8]) } // shotHits is the draw: a uniform number in [0, 10000) under the chance. func shotHits(seed uint64, bps int64) bool { return int64(seed%10000) < bps } // payDividends credits div to the pool's earlier bidders, pro-rata to what // each has paid in, skipping the bidder; whatever cannot be placed (no earlier // bidders, rounding) is returned to go into the pot. func (p *Pool) payDividends(cur realm, bidder address, div int64) int64 { var total int64 for _, m := range p.Roster { if m.Addr != bidder { total += m.Paid } } if total == 0 || div == 0 { return div } var paid int64 for _, m := range p.Roster { if m.Addr == bidder { continue } amt := mulDiv(div, m.Paid, total) if amt > 0 { credit(m.Addr, amt) m.Earned += amt player(m.Addr).DivEarned += amt paid += amt } } p.DivPaid += paid return div - paid } // member finds or adds a bidder on the roster; a full roster drops its oldest. // A new member's flag is the one given; once set it is locked for this pool. func (p *Pool) member(a address, flag string) *Member { for _, m := range p.Roster { if m.Addr == a { return m } } if len(p.Roster) >= RosterMax { p.Roster = p.Roster[1:] } m := &Member{Addr: a, Flag: flag} p.Roster = append(p.Roster, m) return m } // Claim settles a finished pool: everything is credited, and Withdraw sends // it. TeamShare of the pot goes to the OTHER roster members flying the // winner's flag, pro-rata by paid-in — or, when nobody else flew it, to all // the other roster members. Of the rest, the live part (LiveBps) is the // winner's and the shared part is split among the whole roster pro-rata by // paid-in. A pool with a single bidder credits them everything. Rounding goes // to the winner. Anyone may call it. func Claim(cur realm, id int64) { p := pool(id) if p.Cancelled { panic("pool was cancelled") } p.settleShots() if !p.Over() { panic("clock still running") } if p.Paid { panic("already paid out") } p.Paid = true winner := p.LastBidder flag := "" if m := p.find(winner); m != nil { flag = m.Flag } p.WinnerFlag = flag toWinner, _ := p.payout(winner, p.Pot, true) if toWinner > 0 { credit(winner, toWinner) player(winner).Won += toWinner } p.WinnerPaid = toWinner if flag != "" { teamWon[flag] += p.Pot } 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())) } // payout is the close's arithmetic for a winner and a pot: TeamShare to the // other roster members under the winner's flag (or, when nobody else flew it, // to all the others), then the shared part of the rest — what the live share // has slid away from — to the whole roster pro-rata by paid-in; the winner // keeps the rest and the rounding. With pay it credits and books the shares; // dry, it only says what the winner would get and what the team would (Take // prices shots with both). func (p *Pool) payout(winner address, pot int64, pay bool) (toWinner, teamCut int64) { flag := "" if m := p.find(winner); m != nil { flag = m.Flag } var team int64 for _, m := range p.Roster { if m.Addr != winner && m.Flag == flag && flag != "" { team += m.Paid } } teamFlag := flag if team == 0 { teamFlag = "" // nobody else flew it: every other bidder is the team for _, m := range p.Roster { if m.Addr != winner { team += m.Paid } } } toWinner = pot if team == 0 { return toWinner, 0 // alone on the roster: everything } cut := pot * TeamShare / 100 for _, m := range p.Roster { if m.Addr == winner || (teamFlag != "" && m.Flag != teamFlag) { continue } amt := mulDiv(cut, m.Paid, team) if amt > 0 { toWinner -= amt teamCut += amt if pay { credit(m.Addr, amt) player(m.Addr).Won += amt p.TeamPaid += amt } } } // the rest slides: the live part stays the winner's, the shared part is everyone's by paid-in rest := pot - cut shared := rest - mulDiv(rest, p.LiveBps(), 10000) if shared > 0 { var total int64 for _, m := range p.Roster { total += m.Paid } for _, m := range p.Roster { amt := mulDiv(shared, m.Paid, total) if amt > 0 && m.Addr != winner { toWinner -= amt if pay { credit(m.Addr, amt) player(m.Addr).Won += amt p.SharedPaid += amt } } } } return toWinner, teamCut } // Cancel lets the creator take the seed back from a pool nobody has bid on. func Cancel(cur realm, id int64) { caller := userCaller(cur) p := pool(id) if p.Creator != caller { panic("only the creator can cancel") } if p.Bids > 0 { panic("pool already has bids") } if p.Cancelled { panic("already cancelled") } p.Cancelled = true p.Paid = true if p.Pot > 0 { send(cur, caller, p.Pot) } chain.Emit("Cancelled", "id", itoa(id)) } func (p *Pool) find(a address) *Member { for _, m := range p.Roster { if m.Addr == a { return m } } return nil } func player(a address) *Player { pl := players[a] if pl == nil { pl = &Player{} players[a] = pl } return pl } func send(cur realm, to address, ugnot int64) { banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins(realmAddr, to, chain.Coins{{"ugnot", ugnot}}) } // credit books ugnot to an address; Withdraw pays it out. func credit(to address, ugnot int64) { player(to).Owed += ugnot } // Withdraw sends the caller everything credited to them: dividends, team // shares, close splits, slices and won pots. func Withdraw(cur realm) int64 { who := userCaller(cur) pl := player(who) amt := pl.Owed if amt <= 0 { panic("nothing to withdraw") } pl.Owed = 0 pl.Withdrawn += amt send(cur, who, amt) chain.Emit("Withdrawn", "to", who.String(), "ugnot", itoa(amt)) return amt } // Owed is what an address could withdraw right now. func Owed(a address) int64 { return player(a).Owed } func pool(id int64) *Pool { if id < 1 || id > int64(len(pools)) { panic("no such pool") } return pools[id-1] } // userCaller is the signer of the transaction, refusing calls relayed through // another realm so nobody can bid on someone else's behalf, and so no realm can // pay for a shot and revert on a miss. func userCaller(cur realm) address { prev := cur.Previous() if !prev.IsUserCall() { panic("must be a direct user call") } return prev.Address() } func ugnotSent() int64 { sent := unsafe.OriginSend() for _, c := range sent { if c.Denom != "ugnot" { panic("only ugnot is accepted") } } return sent.AmountOf("ugnot") } func cleanName(s string) string { s = strings.TrimSpace(s) if s == "" || len(s) > MaxNameLen { panic("name must be 1-40 characters") } for _, c := range s { if c < 0x20 || c == 0x7f { panic("name has control characters") } } return s } // cleanFlag accepts a two-letter country code, upper-cased; anything else is "". func cleanFlag(s string) string { s = strings.ToUpper(strings.TrimSpace(s)) if len(s) != 2 || s[0] < 'A' || s[0] > 'Z' || s[1] < 'A' || s[1] > 'Z' { return "" } return s } func itoa(n int64) string { return strconv.FormatInt(n, 10) } func parseID(s string) int64 { n, err := strconv.ParseInt(s, 10, 64) if err != nil { panic("bad pool id") } return n } func parseNum(s string) int64 { n, err := strconv.ParseInt(s, 10, 64) if err != nil { panic("bad number") } return n } // GNOT formats ugnot as GNOT with the trailing zeros trimmed. func GNOT(ugnot int64) string { whole, frac := ugnot/1_000_000, ugnot%1_000_000 if frac == 0 { return itoa(whole) + " GNOT" } f := strconv.FormatInt(1_000_000+frac, 10)[1:] return itoa(whole) + "." + strings.TrimRight(f, "0") + " GNOT" } func short(a address) string { s := a.String() if len(s) < 12 { return s } return s[:8] + "…" + s[len(s)-4:] } func dur(sec int64) string { if sec <= 0 { return "0s" } d, h, m, s := sec/86400, sec%86400/3600, sec%3600/60, sec%60 out := "" if d > 0 { out += itoa(d) + "d " } if d > 0 || h > 0 { out += itoa(h) + "h " } if d > 0 || h > 0 || m > 0 { out += itoa(m) + "m " } return out + itoa(s) + "s" } // --- rendering --------------------------------------------------------------- // Render serves gnoweb ("" and "<id>") and the page ("json", "json/<id>", // "me/<address>", "teams"). func Render(path string) string { path = strings.Trim(path, "/") switch { case path == "json": return jsonAll() case strings.HasPrefix(path, "json/"): return pool(parseID(path[5:])).json(true) case strings.HasPrefix(path, "me/"): return jsonMe(address(path[3:])) case path == "teams": return jsonTeams() case path == "commits": return jsonCommits() case path == "": return markdownAll() default: return pool(parseID(path)).markdown() } } func unix(t time.Time) string { if t.IsZero() { return "0" } return itoa(t.Unix()) } func jbool(b bool) string { if b { return "true" } return "false" } func (p *Pool) json(detail bool) string { var b strings.Builder b.WriteString("{\"id\":" + itoa(p.ID)) b.WriteString(",\"name\":" + strconv.Quote(p.Name)) b.WriteString(",\"creator\":\"" + p.Creator.String() + "\"") b.WriteString(",\"window\":" + itoa(p.Window)) b.WriteString(",\"life\":" + itoa(p.Life)) b.WriteString(",\"basePrice\":" + itoa(p.BasePrice)) b.WriteString(",\"seed\":" + itoa(p.Seed)) b.WriteString(",\"pot\":" + itoa(p.Pot)) b.WriteString(",\"bids\":" + itoa(p.Bids)) b.WriteString(",\"nextPrice\":" + itoa(p.NextPrice())) b.WriteString(",\"lastBidder\":\"" + p.LastBidder.String() + "\"") b.WriteString(",\"lastBidAt\":" + unix(p.LastBidAt)) b.WriteString(",\"firstBidAt\":" + unix(p.FirstBidAt)) b.WriteString(",\"deadline\":" + unix(p.Deadline())) b.WriteString(",\"lastDay\":" + unix(p.LastDay())) b.WriteString(",\"liveBps\":" + itoa(p.LiveBps())) b.WriteString(",\"clock\":" + itoa(p.Clock)) b.WriteString(",\"createdAt\":" + unix(p.CreatedAt)) b.WriteString(",\"over\":" + jbool(p.Over())) b.WriteString(",\"popped\":" + jbool(p.Popped)) b.WriteString(",\"paid\":" + jbool(p.Paid)) b.WriteString(",\"cancelled\":" + jbool(p.Cancelled)) b.WriteString(",\"bidders\":" + itoa(int64(len(p.Roster)))) b.WriteString(",\"divPaid\":" + itoa(p.DivPaid)) b.WriteString(",\"refPaid\":" + itoa(p.RefPaid)) b.WriteString(",\"creatorPaid\":" + itoa(p.CreatorPaid)) b.WriteString(",\"shotPaid\":" + itoa(p.ShotPaid)) b.WriteString(",\"teamPaid\":" + itoa(p.TeamPaid)) b.WriteString(",\"sharedPaid\":" + itoa(p.SharedPaid)) b.WriteString(",\"winnerPaid\":" + itoa(p.WinnerPaid)) pending := int64(0) for _, t := range p.Shots { if !t.Resolved { pending++ } } 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)) flag := p.WinnerFlag if m := p.find(p.LastBidder); m != nil && flag == "" { flag = m.Flag } b.WriteString(",\"flag\":\"" + flag + "\"") // what each flag has paid into this pool b.WriteString(",\"teams\":{") seen := map[string]int64{} order := []string{} for _, m := range p.Roster { if m.Flag == "" { continue } if _, ok := seen[m.Flag]; !ok { order = append(order, m.Flag) } seen[m.Flag] += m.Paid } for i, f := range order { if i > 0 { b.WriteString(",") } b.WriteString("\"" + f + "\":" + itoa(seen[f])) } b.WriteString("}") if detail { b.WriteString(",\"history\":[") for i := len(p.History) - 1; i >= 0; i-- { // newest first h := p.History[i] if i < len(p.History)-1 { b.WriteString(",") } 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) + "}") } b.WriteString("],\"roster\":[") for i, m := range p.Roster { if i > 0 { b.WriteString(",") } b.WriteString("{\"addr\":\"" + m.Addr.String() + "\",\"flag\":\"" + m.Flag + "\",\"paid\":" + itoa(m.Paid) + ",\"earned\":" + itoa(m.Earned) + "}") } b.WriteString("],\"shotlog\":[") for i := len(p.Shots) - 1; i >= 0; i-- { // newest first t := p.Shots[i] if i < len(p.Shots)-1 { b.WriteString(",") } 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) + "}") } b.WriteString("]") } b.WriteString("}") return b.String() } // jsonCommits lists the unused commitments in order, hex, so a keeper that // starts can tell which of them it holds the secrets for and Retire the rest. func jsonCommits() string { var b strings.Builder b.WriteString("{\"height\":" + itoa(runtime.ChainHeight()) + ",\"commits\":[") for i := head; i < len(commits); i++ { if i > head { b.WriteString(",") } b.WriteString("\"" + hex.EncodeToString([]byte(commits[i])) + "\"") } b.WriteString("]}") return b.String() } // commitHead is the next commitment a shot would take, hex, "" when none is free. func commitHead() string { if head >= len(commits) { return "" } return hex.EncodeToString([]byte(commits[head])) } func jsonAll() string { var b strings.Builder b.WriteString("{\"now\":" + itoa(time.Now().Unix()) + ",\"height\":" + itoa(runtime.ChainHeight()) + ",\"realm\":\"" + pkgPath + "\"") b.WriteString(",\"split\":{\"pot\":" + itoa(PotShare) + ",\"div\":" + itoa(DivShare) + ",\"ref\":" + itoa(RefShare) + ",\"creator\":" + itoa(CreatorShare) + ",\"team\":" + itoa(TeamShare) + ",\"roster\":" + itoa(int64(RosterMax)) + ",\"shotMaxBps\":" + itoa(ShotMaxBps) + ",\"shotFair\":" + itoa(ShotFair) + ",\"shotDelay\":" + itoa(ShotDelay) + ",\"shotWindow\":" + itoa(ShotWindow) + ",\"minClock\":" + itoa(MinClock) + ",\"minShot\":" + itoa(MinShot) + ",\"revealTip\":" + itoa(RevealTip) + ",\"keeperFresh\":" + itoa(KeeperFresh) + ",\"liveFloor\":" + itoa(LiveFloor) + "}") b.WriteString(",\"shotsOn\":" + jbool(shotsOn)) b.WriteString(",\"keeper\":\"" + keeper.String() + "\",\"keeperSeen\":" + unix(keeperSeen) + ",\"commitsFree\":" + itoa(int64(len(commits)-head)) + ",\"commitHead\":\"" + commitHead() + "\",\"voided\":" + itoa(voided) + ",\"late\":" + itoa(late)) b.WriteString(",\"pools\":[") for i, p := range pools { if i > 0 { b.WriteString(",") } b.WriteString(p.json(false)) } b.WriteString("],\"teams\":" + jsonTeams() + "}") return b.String() } // jsonTeams is the country leaderboard: paid in, won, and players per flag. func jsonTeams() string { var b strings.Builder b.WriteString("{") i := 0 for f, paid := range teamPaid { if i > 0 { b.WriteString(",") } i++ b.WriteString("\"" + f + "\":{\"paid\":" + itoa(paid) + ",\"won\":" + itoa(teamWon[f]) + ",\"players\":" + itoa(teamSize[f]) + "}") } b.WriteString("}") return b.String() } // jsonMe is what the page shows a connected wallet about itself. func jsonMe(a address) string { pl := players[a] if pl == nil { pl = &Player{} } return "{\"addr\":\"" + a.String() + "\",\"referrer\":\"" + pl.Referrer.String() + "\",\"recruits\":" + itoa(pl.Recruits) + ",\"flag\":\"" + pl.Flag + "\",\"bids\":" + itoa(pl.Bids) + ",\"paid\":" + itoa(pl.Paid) + ",\"divEarned\":" + itoa(pl.DivEarned) + ",\"refEarned\":" + itoa(pl.RefEarned) + ",\"won\":" + itoa(pl.Won) + ",\"shots\":" + itoa(pl.Shots) + ",\"hits\":" + itoa(pl.Hits) + ",\"owed\":" + itoa(pl.Owed) + ",\"withdrawn\":" + itoa(pl.Withdrawn) + "}" } func (p *Pool) status() string { switch { case p.Cancelled: return "cancelled" case p.Over(): s := "won by " + short(p.LastBidder) if p.Popped { s = "popped by " + short(p.LastBidder) } if !p.Paid { s += " (unclaimed)" } return s case p.Bids == 0: return "no bids yet · " + dur(p.Window) + " window · next " + GNOT(p.NextPrice()) default: left := p.Deadline().Unix() - time.Now().Unix() s := dur(left) + " left" if p.Clock < p.Window { s += " (⚡ " + dur(p.Clock) + " clock)" } return s + " · " + short(p.LastBidder) + " on top · next " + GNOT(p.NextPrice()) + " · winner's share " + itoa(p.LiveBps()/100) + "%" } } func markdownAll() string { var b strings.Builder b.WriteString("# Bubble Rumble v4\n\n") 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. ") 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") b.WriteString("Play at [bubblerumble.net](https://bubblerumble.net).\n\n## Pools\n\n") if len(pools) == 0 { b.WriteString("_none yet_\n") } for _, p := range pools { b.WriteString("- [#" + itoa(p.ID) + " " + p.Name + "](/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble4:" + itoa(p.ID) + ") · " + GNOT(p.Pot) + " · " + p.status() + "\n") } return b.String() } func (p *Pool) markdown() string { var b strings.Builder b.WriteString("# " + p.Name + "\n\n") 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") if !p.LastDay().IsZero() { b.WriteString("- last day: " + p.LastDay().UTC().Format("2006-01-02 15:04 UTC") + " · winner's share now " + itoa(p.LiveBps()/100) + "%\n") } if len(p.Shots) > 0 { b.WriteString("- shots: " + itoa(int64(len(p.Shots))) + " · misses: " + itoa(p.Misses) + " · unrevealed: " + itoa(p.Voided) + "\n") } b.WriteString("\n## Bids\n\n") if len(p.History) == 0 { b.WriteString("_none yet_\n") } for i := len(p.History) - 1; i >= 0; i-- { h := p.History[i] line := "- #" + itoa(h.N) + " " + short(h.Bidder) if h.Flag != "" { line += " (" + h.Flag + ")" } line += " paid " + GNOT(h.Price) if h.Shot > 0 { line += " + a " + GNOT(h.Shot) + " shot" } b.WriteString(line + " · pot " + GNOT(h.Pot) + "\n") } return b.String() }
- #4bubblerumble4_test.gno
- #5package bubblerumble4 import ( "chain" "chain/banker" "chain/runtime" "crypto/sha256" "encoding/hex" "strings" "testing" "time" ) const realmPath = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble4" var ( alice = address("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") bob = address("g1dmt3sa5ucvecxuhf3j6ne5r0e3z4x7h6c03xc0") carol = address("g1akeqsvhucjt8gf5yupyzjxsjd29wv8fayng37c") dave = address("g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr") // also the admin ) func aborts(t *testing.T, want string, f func()) { t.Helper() r := revive(f) if r == nil { t.Fatalf("expected an abort containing %q, got none", want) } msg := "" switch e := r.(type) { case error: msg = e.Error() case string: msg = e } if !strings.Contains(msg, want) { t.Fatalf("abort %q does not contain %q", msg, want) } } func held(who address) int64 { return banker.NewReadonlyBanker().GetCoins(who).AmountOf("ugnot") } // fund attaches ugnot to the next call the way a transaction would. The caller // is set with testing.SetRealm in the test body: it binds to the calling frame. func fund(ugnot int64) { if ugnot > 0 { testing.IssueCoins(chain.PackageAddress(realmPath), chain.Coins{{"ugnot", ugnot}}) testing.SetOriginSend(chain.Coins{{"ugnot", ugnot}}) } else { testing.SetOriginSend(nil) } } // heldAtReset is what the realm held when the scenario began: coins from // earlier scenarios stay in the test realm after reset() forgets their pools. var heldAtReset int64 func reset() { pools = nil players = map[address]*Player{} teamPaid = map[string]int64{} teamWon = map[string]int64{} teamSize = map[string]int64{} shotsOn = true keeper, keeperSeen, commits, head, voided, late = "", time.Time{}, nil, 0, 0, 0 heldAtReset = held(chain.PackageAddress(realmPath)) } // the keeper in these tests is carol; dave (the admin) names her, and she commits func keeperUp(cur realm, t *testing.T, secrets ...[]byte) { t.Helper() fund(0) testing.SetRealm(testing.NewUserRealm(dave)) SetKeeper(cross(cur), carol) if len(secrets) > 0 { commitSecrets(cur, secrets...) } else { fund(0) testing.SetRealm(testing.NewUserRealm(carol)) Heartbeat(cross(cur)) } } func hashOf(secret []byte) string { sum := sha256.Sum256(secret); return hex.EncodeToString(sum[:]) } func commitSecrets(cur realm, secrets ...[]byte) { hs := []string{} for _, s := range secrets { hs = append(hs, hashOf(s)) } fund(0) testing.SetRealm(testing.NewUserRealm(carol)) Commit(cross(cur), strings.Join(hs, ",")) } func secret(n byte) []byte { return []byte{n, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31} } // a pool with dave's first bid, alice's second carrying a shot; the shot is sold when sold is true (else why says // what the realm should have refused it for) func shotPool(cur realm, t *testing.T, shot int64, sold bool, why ...string) (int64, *Pool) { t.Helper() fund(0) testing.SetRealm(testing.NewUserRealm(dave)) id := CreatePool(cross(cur), "shots", 3600, 1_000_000, 0) p := pool(id) fund(5_000_000) testing.SetRealm(testing.NewUserRealm(dave)) Bid(cross(cur), id, "", "", 0) a0 := held(alice) if len(why) > 0 { if got := p.shotRefusal(alice, shot); !strings.Contains(got, why[0]) { t.Fatalf("refusal %q, want %q", got, why[0]) } } fund(1_414_214 + shot) testing.SetRealm(testing.NewUserRealm(alice)) Bid(cross(cur), id, "", "", shot) if p.Bids != 2 { t.Fatalf("the bid did not land: %+v", *p) } if sold && (len(p.Shots) != 1 || p.ShotEscrow != shot) { t.Fatalf("the shot was not sold: shots %d escrow %d", len(p.Shots), p.ShotEscrow) } // (a test's fund() issues the coins to the realm; alice's own balance only ever sees what comes back) if !sold && (len(p.Shots) != 0 || p.ShotEscrow != 0 || held(alice)-a0 != shot || p.find(alice).Paid != 1_414_214) { t.Fatalf("the shot should have come back: shots %d escrow %d alice %d paid %d", len(p.Shots), p.ShotEscrow, held(alice)-a0, p.find(alice).Paid) } return id, p } // The conservation check: every ugnot the realm took in during the scenario is // either in an open pot or credited to somebody. func realmHeld() int64 { return held(chain.PackageAddress(realmPath)) - heldAtReset } func owedAll() int64 { var sum int64 for _, p := range pools { if !p.Paid { sum += p.Pot + p.ShotEscrow } } for _, pl := range players { sum += pl.Owed } return sum } func conserved(t *testing.T) { t.Helper() if realmHeld() != owedAll() { t.Fatalf("realm holds %d, pots and credits account for %d", realmHeld(), owedAll()) } } func owed(a address) int64 { return player(a).Owed } func TestSplitSelfRebidFlagLockAndWithdraw(cur realm, t *testing.T) { reset() fund(0) testing.SetRealm(testing.NewUserRealm(dave)) id := CreatePool(cross(cur), "split", 3600, 1_000_000, 0) p := pool(id) if p.Life != 10*3600 { t.Fatalf("default life is ten windows: %d", p.Life) } // first bid: dividends and the inviter's cut fall back into the pot, the creator is paid 5% at once d0 := held(dave) fund(1_000_000) testing.SetRealm(testing.NewUserRealm(alice)) Bid(cross(cur), id, "", "kr", 0) if p.Pot != 950_000 || held(dave)-d0 != 50_000 || p.DivPaid != 0 || p.FirstBidAt.IsZero() { t.Fatalf("first bid: pot %d creator +%d div %d", p.Pot, held(dave)-d0, p.DivPaid) } // bob, invited by alice, bids 2 GNOT: alice is credited all the dividends and paid 10% as inviter at once a0 := held(alice) fund(2_000_000) testing.SetRealm(testing.NewUserRealm(bob)) Bid(cross(cur), id, alice.String(), "us", 0) if owed(alice) != 600_000 || held(alice)-a0 != 200_000 || p.Pot != 950_000+2_000_000-600_000-200_000-100_000 { t.Fatalf("second bid: alice owed %d, +%d in hand, pot %d", owed(alice), held(alice)-a0, p.Pot) } if player(bob).Referrer != alice || player(alice).Recruits != 1 || p.find(alice).Earned != 600_000 { t.Fatalf("inviter: %+v", *player(bob)) } // bob rebids on himself, switching his flag to KR: allowed; his dividends go // to alice, not himself; his flag in THIS pool stays US (locked at his first bid) b1 := held(bob) fund(2_000_000) testing.SetRealm(testing.NewUserRealm(bob)) Bid(cross(cur), id, "", "kr", 0) if p.LastBidder != bob || p.Bids != 3 || owed(alice) != 1_200_000 || owed(bob) != 0 || b1-held(bob) != 0 { t.Fatalf("self rebid: last %s bids %d alice owed %d bob owed %d", p.LastBidder, p.Bids, owed(alice), owed(bob)) } if player(bob).Flag != "KR" || p.find(bob).Flag != "US" || p.History[2].Flag != "US" { t.Fatalf("flag lock: player %s member %s history %s", player(bob).Flag, p.find(bob).Flag, p.History[2].Flag) } conserved(t) // alice withdraws what she is owed; a second withdraw has nothing a1 := held(alice) fund(0) testing.SetRealm(testing.NewUserRealm(alice)) if got := Withdraw(cross(cur)); got != 1_200_000 || held(alice)-a1 != 1_200_000 || owed(alice) != 0 || player(alice).Withdrawn != 1_200_000 { t.Fatalf("withdraw: %d, +%d, owed %d", got, held(alice)-a1, owed(alice)) } aborts(t, "nothing to withdraw", func() { Withdraw(cross(cur)) }) conserved(t) // in a new pool, bob's locked flag is his current one fund(0) testing.SetRealm(testing.NewUserRealm(dave)) id2 := CreatePool(cross(cur), "next", 3600, 1_000_000, 0) fund(1_000_000) testing.SetRealm(testing.NewUserRealm(bob)) Bid(cross(cur), id2, "", "", 0) if pool(id2).find(bob).Flag != "KR" { t.Fatalf("new pool flag %s", pool(id2).find(bob).Flag) } } func TestBoostFloor(t *testing.T) { if c := ClockFor(3600, 100, 1000); c != 360 { t.Fatalf("ten times the price on an hour: %d", c) } if c := ClockFor(600, 100, 1000); c != MinClock { t.Fatalf("ten times the price on ten minutes floors at five: %d", c) } if c := ClockFor(60, 100, 100); c != 60 { t.Fatalf("a plain bid on a one-minute window keeps the window: %d", c) } if c := ClockFor(60, 100, 1000); c != 60 { t.Fatalf("no boost can shorten a window under the floor: %d", c) } if mulDiv(80_000_000_000, 3_000_000_000, 7_000_000_000) != 34_285_714_285 { t.Fatal("mulDiv on numbers whose product passes int64") } } func TestLastDaySlidesToProRata(cur realm, t *testing.T) { reset() fund(0) testing.SetRealm(testing.NewUserRealm(dave)) // a one-hour window and a 90-minute life: the life ends before the second clock would id := CreatePool(cross(cur), "slide", 3600, 1_000_000, 5400) p := pool(id) fund(1_000_000) testing.SetRealm(testing.NewUserRealm(alice)) Bid(cross(cur), id, "", "FR", 0) if p.LiveBps() != 10000 { t.Fatalf("fresh pool live %d", p.LiveBps()) } // 50 minutes pass (5s a block), bob bids exactly the price (more would shorten // his clock): 2400 of 5400 seconds of life are left testing.SkipHeights(600) fund(1_414_214) testing.SetRealm(testing.NewUserRealm(bob)) Bid(cross(cur), id, "", "US", 0) if p.LiveBps() != 10000*2400/5400 { t.Fatalf("live %d", p.LiveBps()) } if !p.Deadline().Equal(p.LastDay()) { t.Fatalf("deadline %v should be the last day %v, not the clock", p.Deadline(), p.LastDay()) } // the last day comes with time still on bob's clock: the pool is over anyway testing.SkipHeights(600) if !p.Over() || p.LiveBps() != 0 { t.Fatalf("over %v live %d", p.Over(), p.LiveBps()) } pot := p.Pot a0, b0 := owed(alice), owed(bob) // alice already holds dividends from bob's bid fund(0) testing.SetRealm(testing.NewUserRealm(carol)) Claim(cross(cur), id) // bob (US) won alone under his flag, so the team share goes to the other bidder, // alice; nothing is live, so the rest is split by paid-in cut := pot * TeamShare / 100 rest := pot - cut total := p.find(alice).Paid + p.find(bob).Paid wantA := cut + rest*p.find(alice).Paid/total if owed(alice)-a0 != wantA || owed(bob)-b0 != pot-wantA { t.Fatalf("alice +%d (want %d) bob +%d (want %d)", owed(alice)-a0, wantA, owed(bob)-b0, pot-wantA) } if p.TeamPaid != cut || p.SharedPaid != rest*p.find(alice).Paid/total || p.WinnerPaid != pot-wantA { t.Fatalf("bookkeeping: team %d shared %d winner %d", p.TeamPaid, p.SharedPaid, p.WinnerPaid) } conserved(t) // a pool that closes early with a single bidder credits them everything reset() fund(0) testing.SetRealm(testing.NewUserRealm(dave)) id = CreatePool(cross(cur), "quick", 60, 1_000_000, 7200) p = pool(id) fund(1_000_000) testing.SetRealm(testing.NewUserRealm(alice)) Bid(cross(cur), id, "", "FR", 0) testing.SkipHeights(13) // 65s: the clock ran out, the life barely started fund(0) testing.SetRealm(testing.NewUserRealm(bob)) Claim(cross(cur), id) if owed(alice) != p.Pot || p.SharedPaid != 0 || p.TeamPaid != 0 { t.Fatalf("solo: alice owed %d of %d, shared %d team %d", owed(alice), p.Pot, p.SharedPaid, p.TeamPaid) } conserved(t) } func TestFlagWar(cur realm, t *testing.T) { reset() fund(0) testing.SetRealm(testing.NewUserRealm(dave)) id := CreatePool(cross(cur), "team", 3600, 1_000_000, 0) p := pool(id) fund(20_000_000) testing.SetRealm(testing.NewUserRealm(dave)) Bid(cross(cur), id, "", "FR", 0) fund(1_414_214) testing.SetRealm(testing.NewUserRealm(alice)) Bid(cross(cur), id, "", "FR", 0) fund(1_732_051) testing.SetRealm(testing.NewUserRealm(carol)) Bid(cross(cur), id, "", "US", 0) fund(2_000_000) testing.SetRealm(testing.NewUserRealm(alice)) Bid(cross(cur), id, "", "", 0) // alice on top, FR testing.SkipHeights(721) pot := p.Pot live := p.LiveBps() if live == 0 || live == 10000 { t.Fatalf("live %d should be partway", live) } a0, d0, c0 := owed(alice), owed(dave), owed(carol) fund(0) testing.SetRealm(testing.NewUserRealm(bob)) Claim(cross(cur), id) // the team share goes to dave, the only OTHER FR bidder; the winner takes none of it cut := pot * TeamShare / 100 rest := pot - cut shared := rest - rest*live/10000 total := p.find(alice).Paid + p.find(dave).Paid + p.find(carol).Paid daveShare, carolShare := shared*p.find(dave).Paid/total, shared*p.find(carol).Paid/total if owed(dave)-d0 != cut+daveShare { t.Fatalf("dave: +%d want team %d + share %d", owed(dave)-d0, cut, daveShare) } if owed(carol)-c0 != carolShare { t.Fatalf("carol (US) gets only her pro-rata share: +%d want %d", owed(carol)-c0, carolShare) } if owed(alice)-a0 != pot-cut-daveShare-carolShare { t.Fatalf("alice: +%d want %d", owed(alice)-a0, pot-cut-daveShare-carolShare) } if p.WinnerFlag != "FR" || teamWon["FR"] != pot || p.TeamPaid != cut || p.SharedPaid != daveShare+carolShare { t.Fatalf("bookkeeping: %+v", *p) } conserved(t) aborts(t, "already paid out", func() { Claim(cross(cur), id) }) // a private flag buys nothing: a winner nobody else flew with hands the team // share to all the other bidders, pro-rata reset() fund(0) testing.SetRealm(testing.NewUserRealm(dave)) id = CreatePool(cross(cur), "lone", 60, 1_000_000, 0) p = pool(id) fund(1_000_000) testing.SetRealm(testing.NewUserRealm(alice)) Bid(cross(cur), id, "", "FR", 0) fund(1_414_214) testing.SetRealm(testing.NewUserRealm(carol)) Bid(cross(cur), id, "", "US", 0) fund(1_732_051) testing.SetRealm(testing.NewUserRealm(bob)) Bid(cross(cur), id, "", "AQ", 0) // bob, alone under AQ, on top testing.SkipHeights(13) pot = p.Pot a0, c0, b0 := owed(alice), owed(carol), owed(bob) fund(0) testing.SetRealm(testing.NewUserRealm(dave)) Claim(cross(cur), id) cut = pot * TeamShare / 100 others := p.find(alice).Paid + p.find(carol).Paid // the life (ten minutes) had 65 seconds on it: a sliver of the rest is shared too rest = pot - cut shared = rest - rest*p.LiveBps()/10000 total = others + p.find(bob).Paid wantA := cut*p.find(alice).Paid/others + shared*p.find(alice).Paid/total wantC := cut*p.find(carol).Paid/others + shared*p.find(carol).Paid/total if owed(alice)-a0 != wantA || owed(carol)-c0 != wantC || owed(bob)-b0 != pot-wantA-wantC { t.Fatalf("lone flag: alice +%d (want %d) carol +%d (want %d) bob +%d", owed(alice)-a0, wantA, owed(carol)-c0, wantC, owed(bob)-b0) } conserved(t) } func TestRosterCapAndCreateBounds(cur realm, t *testing.T) { reset() fund(0) testing.SetRealm(testing.NewUserRealm(dave)) id := CreatePool(cross(cur), "cap", 3600, 1_000, 0) p := pool(id) p.Roster = nil for i := 0; i < RosterMax; i++ { p.Roster = append(p.Roster, &Member{Addr: address("g1x" + itoa(int64(i))), Paid: 1}) } fund(1_000) testing.SetRealm(testing.NewUserRealm(alice)) Bid(cross(cur), id, "", "", 0) if len(p.Roster) != RosterMax || p.Roster[len(p.Roster)-1].Addr != alice || p.Roster[0].Addr != address("g1x1") { t.Fatalf("roster cap: len %d first %s last %s", len(p.Roster), p.Roster[0].Addr, p.Roster[len(p.Roster)-1].Addr) } fund(0) testing.SetRealm(testing.NewUserRealm(dave)) aborts(t, "life must be at least the window", func() { CreatePool(cross(cur), "x", 7200, 1_000_000, 3600) }) aborts(t, "life must be between", func() { CreatePool(cross(cur), "x", 3600, 1_000_000, 31*24*3600) }) id = CreatePool(cross(cur), "x", 20*24*3600, 1_000_000, 0) if pool(id).Life != MaxLife { t.Fatalf("ten windows past the maximum life is the maximum: %d", pool(id).Life) } id = CreatePool(cross(cur), "y", 10, 1_000_000, 0) if pool(id).Life != MinLife { t.Fatalf("ten windows under the minimum life is the minimum: %d", pool(id).Life) } } func TestPkgPath(t *testing.T) { if !strings.HasSuffix(pkgPath, "/bubblerumble4") || realmPath != pkgPath { t.Fatalf("pkgPath %q realmPath %q", pkgPath, realmPath) } } func TestKeeperAndCommits(cur realm, t *testing.T) { reset() // the keeper is named by the deployer alone fund(0) testing.SetRealm(testing.NewUserRealm(alice)) aborts(t, "only the deployer", func() { SetKeeper(cross(cur), alice) }) fund(0) testing.SetRealm(testing.NewUserRealm(dave)) aborts(t, "bad address", func() { SetKeeper(cross(cur), address("nope")) }) // nobody commits or heartbeats but the keeper — not even the deployer fund(0) testing.SetRealm(testing.NewUserRealm(dave)) aborts(t, "only the keeper", func() { Commit(cross(cur), hashOf(secret(1))) }) aborts(t, "only the keeper", func() { Heartbeat(cross(cur)) }) keeperUp(cur, t, secret(1), secret(2)) if len(commits) != 2 || head != 0 || keeperSeen.IsZero() || commitHead() != hashOf(secret(1)) { t.Fatalf("commits %d head %d seen %v", len(commits), head, keeperSeen) } fund(0) testing.SetRealm(testing.NewUserRealm(alice)) aborts(t, "only the keeper", func() { Commit(cross(cur), hashOf(secret(3))) }) fund(0) testing.SetRealm(testing.NewUserRealm(carol)) aborts(t, "64 hex", func() { Commit(cross(cur), "abc") }) aborts(t, "already committed", func() { Commit(cross(cur), hashOf(secret(2))) }) many := []string{} for i := 0; i < CommitBatch+1; i++ { many = append(many, hashOf(secret(byte(100+i)))) } aborts(t, "hashes", func() { Commit(cross(cur), strings.Join(many, ",")) }) // the unused list, for a keeper that starts and must know what it holds if c := Render("commits"); !strings.Contains(c, hashOf(secret(1))+"\",\""+hashOf(secret(2))) { t.Fatalf("commits: %s", c) } // retire clamps, is the keeper's or the deployer's, and is no sign of life seen := keeperSeen testing.SkipHeights(2) fund(0) testing.SetRealm(testing.NewUserRealm(alice)) aborts(t, "only the keeper or the deployer", func() { Retire(cross(cur), 1) }) fund(0) testing.SetRealm(testing.NewUserRealm(dave)) Retire(cross(cur), 5) if len(commits) != 0 || keeperSeen != seen { t.Fatalf("retire: %d left, seen moved %v", len(commits), keeperSeen != seen) } // a new keeper drops what the old one queued commitSecrets(cur, secret(7)) fund(0) testing.SetRealm(testing.NewUserRealm(dave)) SetKeeper(cross(cur), bob) if len(commits) != 0 || keeper != bob { t.Fatalf("set keeper: %d commits, keeper %s", len(commits), keeper) } // the queue's bound reset() keeperUp(cur, t) for i := 0; i < MaxCommits/CommitBatch; i++ { hs := []string{} for j := 0; j < CommitBatch; j++ { hs = append(hs, hashOf([]byte{byte(i), byte(j), 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32})) } fund(0) testing.SetRealm(testing.NewUserRealm(carol)) Commit(cross(cur), strings.Join(hs, ",")) } fund(0) testing.SetRealm(testing.NewUserRealm(carol)) aborts(t, "at most", func() { Commit(cross(cur), hashOf(secret(9))) }) // compaction: past CommitCompact used ones, the queue is rebuilt from the head head = CommitCompact + 1 fund(0) testing.SetRealm(testing.NewUserRealm(carol)) Commit(cross(cur), hashOf(secret(9))) if head != 0 || len(commits) != MaxCommits-CommitCompact-1+1 { t.Fatalf("compaction: head %d len %d", head, len(commits)) } } func TestShotSoldPricedOnTake(cur realm, t *testing.T) { reset() keeperUp(cur, t, secret(1)) id, p := shotPool(cur, t, 1_000_000, true) sh := p.Shots[0] // the odds: on the pot a pop would pay from (the shot in, the tip out), against the most alice could take — // dave alone is the team, so the 35% cut counts as hers, and nothing has slid: the take is the pot potThen := p.Pot + 1_000_000 - RevealTip if sh.Take != potThen || sh.Bps != ShotBps(1_000_000-RevealTip, potThen) || sh.Bps == 0 || sh.Bps > ShotMaxBps { t.Fatalf("shot: %+v pot %d", *sh, p.Pot) } if sh.Hash != commits[0] || head != 1 || sh.CommitH != runtime.ChainHeight() || p.find(alice).Paid != 1_414_214+1_000_000 { t.Fatalf("commitment: %+v head %d", *sh, head) } if _, cut := p.payout(alice, potThen, false); p.Take(alice, potThen) != potThen-0 || cut != potThen*TeamShare/100 { t.Fatalf("take %d cut %d of %d", p.Take(alice, potThen), cut, potThen) } conserved(t) _ = id } func TestShotComesBack(cur realm, t *testing.T) { // no commitment free: the bid stands, the shot's money comes back reset() keeperUp(cur, t) shotPool(cur, t, 1_000_000, false, "no commitment free") // the keeper not seen for KeeperFresh: the same reset() keeperUp(cur, t, secret(1)) testing.SkipHeights(KeeperFresh/5 + 1) shotPool(cur, t, 1_000_000, false, "not been seen") // under MinShot reset() keeperUp(cur, t, secret(1)) shotPool(cur, t, MinShot-1, false, "smallest shot") // shots off reset() keeperUp(cur, t, secret(1)) fund(0) testing.SetRealm(testing.NewUserRealm(dave)) SetShots(cross(cur), false) shotPool(cur, t, 1_000_000, false, "shots are off") // the keeper itself never shoots reset() keeperUp(cur, t, secret(1)) fund(0) testing.SetRealm(testing.NewUserRealm(dave)) id := CreatePool(cross(cur), "keeper", 3600, 1_000_000, 0) p := pool(id) fund(1_000_000 + 1_000_000) testing.SetRealm(testing.NewUserRealm(carol)) Bid(cross(cur), id, "", "", 1_000_000) if len(p.Shots) != 0 || p.Bids != 1 || p.find(carol).Paid != 1_000_000 { t.Fatalf("the keeper's shot should come back: %+v", *p) } // a shot that buys no chance at all: a pot so large that MinShot is under one basis point reset() keeperUp(cur, t, secret(1)) fund(0) testing.SetRealm(testing.NewUserRealm(dave)) id = CreatePool(cross(cur), "huge", 3600, 1_000_000, 0) p = pool(id) fund(2_000_000_000_000) // a 2,000,000 GNOT seed testing.SetRealm(testing.NewUserRealm(dave)) Bid(cross(cur), id, "", "", 0) fund(1_414_214 + MinShot) testing.SetRealm(testing.NewUserRealm(alice)) Bid(cross(cur), id, "", "", MinShot) if len(p.Shots) != 0 || p.ShotEscrow != 0 || p.Bids != 2 || p.find(alice).Paid != 1_414_214 { t.Fatalf("a chanceless shot should come back: %+v", *p) } conserved(t) // the winner's share slid under the floor: a 1h window, a 90-minute life; bob keeps the clock alive at 50 minutes, // and 70 minutes in the live share is 2222 bps reset() keeperUp(cur, t, secret(1)) fund(0) testing.SetRealm(testing.NewUserRealm(dave)) id = CreatePool(cross(cur), "late life", 3600, 1_000_000, 5400) p = pool(id) fund(1_000_000) testing.SetRealm(testing.NewUserRealm(dave)) Bid(cross(cur), id, "", "", 0) testing.SkipHeights(600) // 50 minutes fund(1_414_214) testing.SetRealm(testing.NewUserRealm(bob)) Bid(cross(cur), id, "", "", 0) testing.SkipHeights(240) // 70 minutes in all fund(0) testing.SetRealm(testing.NewUserRealm(carol)) Heartbeat(cross(cur)) if p.LiveBps() >= LiveFloor || p.Over() { t.Fatalf("live %d over %v", p.LiveBps(), p.Over()) } if got := p.shotRefusal(alice, 1_000_000); !strings.Contains(got, "slid under a quarter") { t.Fatalf("refusal %q", got) } fund(1_732_051 + 1_000_000) testing.SetRealm(testing.NewUserRealm(alice)) Bid(cross(cur), id, "", "", 1_000_000) if len(p.Shots) != 0 || p.ShotEscrow != 0 || p.Bids != 3 { t.Fatalf("a shot in the last quarter should come back: %+v", *p) } conserved(t) } func TestRevealHitOrMiss(cur realm, t *testing.T) { reset() keeperUp(cur, t, secret(1)) id, p := shotPool(cur, t, 1_000_000, true) sh := p.Shots[0] s1 := hex.EncodeToString(secret(1)) // too early: nothing to reveal yet fund(0) testing.SetRealm(testing.NewUserRealm(carol)) aborts(t, "nothing revealed", func() { Reveal(cross(cur), itoa(id)+":1:"+s1) }) testing.SkipHeights(ShotDelay) h := runtime.ChainHeight() fund(0) testing.SetRealm(testing.NewUserRealm(carol)) aborts(t, "nothing revealed", func() { Reveal(cross(cur), itoa(id)+":1:"+hex.EncodeToString(secret(2))) }) aborts(t, "pool:shot:secret", func() { Reveal(cross(cur), "1:1") }) aborts(t, "no such shot", func() { Reveal(cross(cur), itoa(id)+":2:"+s1) }) pot0, k0 := p.Pot, owed(carol) want := shotHits(seedFor(id, sh, secret(1), h), sh.Bps) // the same inputs the realm hashes, in the same block fund(0) testing.SetRealm(testing.NewUserRealm(bob)) // anyone may reveal; the tip is still the keeper's Reveal(cross(cur), itoa(id)+":1:"+s1) if !sh.Resolved || sh.Void || sh.Won != want || sh.DrawH != h || sh.Revealer != bob || p.ShotEscrow != 0 || p.Pot != pot0+1_000_000-RevealTip || owed(carol)-k0 != RevealTip || p.Late != 0 { t.Fatalf("revealed: %+v pot %d→%d carol +%d", *sh, pot0, p.Pot, owed(carol)-k0) } if want { if !p.Popped || p.LastBidder != alice || !p.Over() || p.Misses != 0 || player(alice).Hits != 1 { t.Fatalf("a hit pops: %+v", *p) } } else if p.Popped || p.LastBidder != alice || p.Misses != 1 { t.Fatalf("a miss is counted and changes nothing else: %+v", *p) } fund(0) testing.SetRealm(testing.NewUserRealm(carol)) aborts(t, "nothing revealed", func() { Reveal(cross(cur), itoa(id)+":1:"+s1) }) // twice: skipped conserved(t) // the draw itself: over every seed in a cycle, the chance is exactly bps in 10000 hits := 0 for s := uint64(0); s < 10000; s++ { if shotHits(s, 32) { hits++ } } if hits != 32 { t.Fatalf("32 bps should hit 32 of 10000 seeds, got %d", hits) } // the two outcomes, made sure of: a shot that cannot miss pops the pool and Claim pays the shooter as any // winner (dave, the only other bidder, is the team); one that cannot hit is a miss and the pool goes on for _, sure := range []int64{10000, 0} { reset() keeperUp(cur, t, secret(1)) id, p := shotPool(cur, t, 1_000_000, true) p.Shots[0].Bps = sure testing.SkipHeights(ShotDelay) pot0 := p.Pot fund(0) testing.SetRealm(testing.NewUserRealm(carol)) Reveal(cross(cur), itoa(id)+":1:"+hex.EncodeToString(secret(1))) if sure == 0 { if p.Popped || p.Misses != 1 || p.Over() || p.Pot != pot0+1_000_000-RevealTip { t.Fatalf("sure miss: %+v", *p) } conserved(t) continue } if !p.Popped || p.LastBidder != alice || !p.Over() || player(alice).Hits != 1 || p.Pot != pot0+1_000_000-RevealTip { t.Fatalf("sure hit: %+v", *p) } // a bid after the pop is refunded whole, shot included b0 := held(bob) fund(2_000_000 + 500_000) testing.SetRealm(testing.NewUserRealm(bob)) Bid(cross(cur), id, "", "", 500_000) if held(bob)-b0 != 2_500_000 || p.Bids != 2 { t.Fatalf("after the pop: bob %d, bids %d", held(bob)-b0, p.Bids) } fund(0) testing.SetRealm(testing.NewUserRealm(bob)) Claim(cross(cur), id) // the team's cut to dave, and the sliver of the slide the few blocks since the first bid moved his way too cut := p.Pot * TeamShare / 100 if owed(alice) != p.Pot-cut-p.SharedPaid || p.TeamPaid != cut || p.WinnerPaid != owed(alice) || owed(dave) != cut+424_264+p.SharedPaid || p.SharedPaid > cut/100 { t.Fatalf("popped payout: alice owed %d of %d, team %d, shared %d, dave %d", owed(alice), p.Pot, p.TeamPaid, p.SharedPaid, owed(dave)) } conserved(t) } } func TestRevealOrderAndMany(cur realm, t *testing.T) { reset() keeperUp(cur, t, secret(1), secret(2)) id, p := shotPool(cur, t, 1_000_000, true) fund(1_732_051 + 1_000_000) testing.SetRealm(testing.NewUserRealm(bob)) Bid(cross(cur), id, "", "", 1_000_000) if len(p.Shots) != 2 || p.Shots[1].Hash != commits[1] || head != 2 || p.ShotEscrow != 2_000_000 { t.Fatalf("two shots: %d escrow %d head %d", len(p.Shots), p.ShotEscrow, head) } testing.SkipHeights(ShotDelay) s1, s2 := hex.EncodeToString(secret(1)), hex.EncodeToString(secret(2)) // the second alone waits for the first fund(0) testing.SetRealm(testing.NewUserRealm(carol)) aborts(t, "nothing revealed", func() { Reveal(cross(cur), itoa(id)+":2:"+s2) }) if p.Shots[1].Resolved { t.Fatal("out of order") } // both in one call, in order; a hit on the first closes the pool and voids the second as closed fund(0) testing.SetRealm(testing.NewUserRealm(carol)) Reveal(cross(cur), itoa(id)+":1:"+s1+","+itoa(id)+":2:"+s2) if !p.Shots[0].Resolved || !p.Shots[1].Resolved || p.ShotEscrow != 0 { t.Fatalf("both: %+v %+v", *p.Shots[0], *p.Shots[1]) } if p.Shots[0].Won && (!p.Shots[1].Void || p.Voided != 1) { t.Fatalf("a shot after the pop is void: %+v", *p.Shots[1]) } if !p.Shots[0].Won && p.Shots[1].Void { t.Fatalf("the second should have been drawn: %+v", *p.Shots[1]) } conserved(t) // and made sure of: a first shot that cannot miss voids the second as closed, in the same call reset() keeperUp(cur, t, secret(1), secret(2)) id, p = shotPool(cur, t, 1_000_000, true) fund(1_732_051 + 1_000_000) testing.SetRealm(testing.NewUserRealm(bob)) Bid(cross(cur), id, "", "", 1_000_000) p.Shots[0].Bps = 10000 testing.SkipHeights(ShotDelay) pot0 := p.Pot fund(0) testing.SetRealm(testing.NewUserRealm(carol)) Reveal(cross(cur), itoa(id)+":1:"+s1+","+itoa(id)+":2:"+s2) if !p.Popped || !p.Shots[1].Void || p.Voided != 1 || p.ShotEscrow != 0 || p.Pot != pot0+2_000_000-RevealTip { t.Fatalf("pop then void: %+v %+v pot %d→%d", *p.Shots[0], *p.Shots[1], pot0, p.Pot) } conserved(t) } func TestVoidsAndLate(cur realm, t *testing.T) { // unrevealed past the window: Settle voids it into the pot, no tip, a miss reset() keeperUp(cur, t, secret(1), secret(2), secret(3)) id, p := shotPool(cur, t, 1_000_000, true) testing.SkipHeights(ShotDelay + ShotWindow + 1) pot0, k0 := p.Pot, owed(carol) fund(0) testing.SetRealm(testing.NewUserRealm(bob)) Settle(cross(cur), id) sh := p.Shots[0] if !sh.Resolved || !sh.Void || sh.Won || p.Misses != 1 || p.Voided != 1 || voided != 1 || p.Pot != pot0+1_000_000 || p.ShotEscrow != 0 || owed(carol) != k0 { t.Fatalf("void: %+v pot %d→%d", *sh, pot0, p.Pot) } // a reveal that comes late is a void too, whoever sends it fund(1_732_051 + 1_000_000) testing.SetRealm(testing.NewUserRealm(alice)) Bid(cross(cur), id, "", "", 1_000_000) testing.SkipHeights(ShotDelay + ShotWindow + 1) fund(0) testing.SetRealm(testing.NewUserRealm(carol)) aborts(t, "nothing revealed", func() { Reveal(cross(cur), itoa(id)+":2:"+hex.EncodeToString(secret(2))) }) // on chain the abort reverts its own settling fund(0) testing.SetRealm(testing.NewUserRealm(carol)) Settle(cross(cur), id) if !p.Shots[1].Void || p.Voided != 2 || p.Misses != 2 || owed(carol) != k0 { t.Fatalf("late reveal: %+v", *p.Shots[1]) } // the last block of the window still draws, and counts as late fund(2_000_000 + 1_000_000) testing.SetRealm(testing.NewUserRealm(alice)) Bid(cross(cur), id, "", "", 1_000_000) testing.SkipHeights(ShotDelay + ShotWindow) fund(0) testing.SetRealm(testing.NewUserRealm(carol)) Reveal(cross(cur), itoa(id)+":3:"+hex.EncodeToString(secret(3))) if !p.Shots[2].Resolved || p.Shots[2].Void || p.Late != 1 || late != 1 || p.Shots[2].DrawH != runtime.ChainHeight() { t.Fatalf("window end: %+v late %d", *p.Shots[2], p.Late) } conserved(t) // a pool that closes with a shot pending: Claim settles it first, into the pot the winner takes reset() keeperUp(cur, t, secret(4)) fund(0) testing.SetRealm(testing.NewUserRealm(dave)) id = CreatePool(cross(cur), "closing", 60, 1_000_000, 0) p = pool(id) fund(1_000_000) testing.SetRealm(testing.NewUserRealm(dave)) Bid(cross(cur), id, "", "", 0) // a shot that would be due: planted, since MinClock keeps a real one clear of the close p.Shots = append(p.Shots, &Shot{N: 1, Bidder: alice, Paid: 500_000, Take: p.Pot, Bps: 100, CommitH: runtime.ChainHeight(), Hash: commits[0]}) p.ShotEscrow += 500_000 testing.IssueCoins(chain.PackageAddress(realmPath), chain.Coins{{"ugnot", 500_000}}) testing.SkipHeights(13) // the clock ran out fund(0) testing.SetRealm(testing.NewUserRealm(carol)) aborts(t, "nothing revealed", func() { Reveal(cross(cur), itoa(id)+":1:"+hex.EncodeToString(secret(4))) }) // the pool closed first fund(0) testing.SetRealm(testing.NewUserRealm(carol)) Settle(cross(cur), id) if !p.Shots[0].Void || p.ShotEscrow != 0 || p.Pot != 950_000+500_000 { // the creator's 5% left the pot at the bid t.Fatalf("closed first: %+v pot %d", *p.Shots[0], p.Pot) } fund(0) testing.SetRealm(testing.NewUserRealm(bob)) Claim(cross(cur), id) if owed(dave) != p.Pot || !p.Paid { t.Fatalf("the pot, shot included, is the winner's: owed %d of %d", owed(dave), p.Pot) } conserved(t) } func TestTakeCountsTeamAndSlide(cur realm, t *testing.T) { reset() keeperUp(cur, t, secret(1)) // alice under TV, bob under TV with a small bid, dave flagless: a pop for alice pays her the pot less what has // slid; the team's 35% goes to bob, and the take counts it as hers fund(0) testing.SetRealm(testing.NewUserRealm(dave)) id := CreatePool(cross(cur), "team", 3600, 1_000_000, 7200) p := pool(id) fund(1_000_000) testing.SetRealm(testing.NewUserRealm(dave)) Bid(cross(cur), id, "", "", 0) fund(1_414_214) testing.SetRealm(testing.NewUserRealm(bob)) Bid(cross(cur), id, "", "TV", 0) fund(1_732_051) testing.SetRealm(testing.NewUserRealm(alice)) Bid(cross(cur), id, "", "TV", 0) pot := p.Pot toWinner, cut := p.payout(alice, pot, false) if cut != pot*TeamShare/100 || toWinner != pot-cut || p.Take(alice, pot) != pot { t.Fatalf("fresh pool: toWinner %d cut %d take %d of %d", toWinner, cut, p.Take(alice, pot), pot) } // halfway through the life, half the rest has slid to the roster by paid-in: the take is the pot less the others' part testing.SkipHeights(720) if p.LiveBps() > 5000 || p.LiveBps() < 4990 { t.Fatalf("live %d", p.LiveBps()) } toWinner, cut = p.payout(alice, pot, false) rest := pot - cut shared := rest - mulDiv(rest, p.LiveBps(), 10000) var total, others int64 for _, m := range p.Roster { total += m.Paid if m.Addr != alice { others += mulDiv(shared, m.Paid, total) } } _ = others if p.Take(alice, pot) != toWinner+cut || toWinner >= pot-cut || p.Take(alice, pot) <= pot/2 { t.Fatalf("slid: toWinner %d cut %d take %d of %d", toWinner, cut, p.Take(alice, pot), pot) } // a shooter alone on the roster takes everything reset() keeperUp(cur, t, secret(1)) fund(0) testing.SetRealm(testing.NewUserRealm(dave)) id = CreatePool(cross(cur), "alone", 3600, 1_000_000, 0) p = pool(id) fund(1_000_000 + 1_000_000) testing.SetRealm(testing.NewUserRealm(alice)) Bid(cross(cur), id, "", "", 1_000_000) if len(p.Shots) != 1 || p.Shots[0].Take != p.Pot+1_000_000-RevealTip { t.Fatalf("alone: %+v pot %d", *p.Shots[0], p.Pot) } conserved(t) } func TestRender(cur realm, t *testing.T) { reset() keeperUp(cur, t, secret(1)) fund(0) testing.SetRealm(testing.NewUserRealm(dave)) id := CreatePool(cross(cur), "r", 3600, 1_000_000, 0) fund(1_000_000) testing.SetRealm(testing.NewUserRealm(alice)) Bid(cross(cur), id, "", "FR", 0) fund(1_414_214 + 200_000) testing.SetRealm(testing.NewUserRealm(bob)) Bid(cross(cur), id, alice.String(), "US", 200_000) all := Render("json") for _, want := range []string{"\"split\":{\"pot\":55,\"div\":30,\"ref\":10,\"creator\":5,\"team\":35,\"roster\":100,\"shotMaxBps\":100,\"shotFair\":8000,\"shotDelay\":3,\"shotWindow\":2,\"minClock\":300,\"minShot\":100000,\"revealTip\":20000,\"keeperFresh\":300,\"liveFloor\":2500}", "\"shotsOn\":true", "\"keeper\":\"" + carol.String() + "\"", "\"keeperSeen\":", "\"commitsFree\":0,\"commitHead\":\"\",\"voided\":0,\"late\":0", "\"bidders\":2", "\"flag\":\"US\"", "\"liveBps\":", "\"lastDay\":", "\"shots\":1,\"misses\":0,\"voided\":0,\"late\":0,\"pending\":1,\"escrow\":200000", "\"height\":"} { if !strings.Contains(all, want) { t.Errorf("json missing %s in %s", want, all) } } one := Render("json/1") for _, want := range []string{"\"shot\":200000", "\"shotlog\":[{\"n\":1", "\"commit\":\"" + hashOf(secret(1)) + "\"", "\"revealAt\":", "\"revealBy\":", "\"hit\":false,\"void\":false", "\"take\":"} { if !strings.Contains(one, want) { t.Errorf("detail missing %s in %s", want, one) } } me := Render("me/" + alice.String()) if !strings.Contains(me, "\"owed\":424264") || !strings.Contains(me, "\"withdrawn\":0") { t.Errorf("me: %s", me) } if !strings.Contains(Render(""), "Bubble Rumble v4") || !strings.Contains(Render("1"), "a 0.2 GNOT shot") || !strings.Contains(Render("1"), "unrevealed: 0") { t.Errorf("markdown: %s", Render("1")) } if !strings.Contains(Render("commits"), "\"commits\":[]") { t.Errorf("commits: %s", Render("commits")) } }
- #6gnomod.toml
- #7module = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble4" gno = "0.9"
- #8/gno.MemPackageType
- #9 MPUserAll
Result log
msg:0,success:true,log:,events:[]