Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

bubblerumble2.gno

18.74 Kb · 664 lines
  1// Package bubblerumble2 is the second Bubble Rumble realm: the same last-bidder-
  2// wins clock as v1, with the money rearranged so that players want company.
  3//
  4// Of every bid: 55% goes to the pot, 30% is paid at once to the earlier bidders
  5// of that pool pro-rata to what they have paid in, 10% goes to whoever first
  6// invited the bidder (forever, on every bid they make), 5% to the pool's creator.
  7// When the clock runs out, 80% of the pot goes to the last bidder and 20% is
  8// shared among everyone who played under the winner's flag — or all of it to the
  9// winner if nobody else flew that flag.
 10//
 11// So the player on top wants more bids (each one pays them), everyone wants
 12// their invitees to bid (10% of everything), and countries are teams.
 13package bubblerumble2
 14
 15import (
 16	"chain"
 17	"chain/banker"
 18	"chain/runtime/unsafe"
 19	"math"
 20	"strconv"
 21	"strings"
 22	"time"
 23)
 24
 25const (
 26	MinWindow    int64 = 10                // seconds
 27	MaxWindow    int64 = 30 * 24 * 3600    // 30 days
 28	MinBasePrice int64 = 1_000             // ugnot, 0.001 GNOT
 29	MaxBasePrice int64 = 1_000_000_000_000 // ugnot, 1,000,000 GNOT
 30	MaxNameLen         = 40
 31	HistoryLen         = 50  // bids kept per pool for the feed
 32	RosterMax          = 200 // distinct bidders a pool pays dividends and team shares to
 33	MaxBoost     int64 = 10  // the shortest clock a bid can buy is window/MaxBoost
 34
 35	// where every bid goes, in percent
 36	PotShare     int64 = 55
 37	DivShare     int64 = 30 // to earlier bidders of the pool, pro-rata by paid-in
 38	RefShare     int64 = 10 // to the bidder's inviter
 39	CreatorShare int64 = 5
 40	// of the pot at the close: the rest goes to the winner
 41	TeamShare int64 = 20
 42)
 43
 44// pkgPath must match gnomod.toml: the realm's own address derives from it.
 45const pkgPath = "gno.land/r/g1leu8d2vsplhehcfkjg50mwgdpxdkt8tztu95wr/bubblerumble2"
 46
 47type Entry struct {
 48	N      int64
 49	Bidder address
 50	Flag   string
 51	Price  int64 // what was paid; at least the price at the time
 52	Clock  int64 // seconds this bid had to stand
 53	Pot    int64 // pot right after this bid
 54	At     time.Time
 55}
 56
 57// Member is a distinct bidder of a pool: the roster dividends and team shares go to.
 58type Member struct {
 59	Addr   address
 60	Flag   string
 61	Paid   int64 // gross paid into this pool; the dividend weight
 62	Earned int64 // dividends received from this pool
 63}
 64
 65type Pool struct {
 66	ID          int64
 67	Name        string
 68	Creator     address
 69	Window      int64 // seconds the last bid must stand to win
 70	BasePrice   int64 // ugnot; the Nth bid costs BasePrice·√N
 71	Seed        int64
 72	Pot         int64
 73	Bids        int64
 74	LastBidder  address
 75	LastBidAt   time.Time
 76	Clock       int64 // seconds the current top bid has to stand
 77	CreatedAt   time.Time
 78	Paid        bool // the pot went out, or the seed back to the creator
 79	Cancelled   bool
 80	DivPaid     int64 // dividends paid out of this pool so far
 81	RefPaid     int64
 82	CreatorPaid int64
 83	WinnerFlag  string
 84	TeamPaid    int64 // what the winner's team received at the close
 85	History     []Entry
 86	Roster      []*Member
 87}
 88
 89// Player is what the realm remembers about an address across pools.
 90type Player struct {
 91	Referrer  address // set by the first bid, permanent
 92	Recruits  int64
 93	Flag      string
 94	Bids      int64
 95	Paid      int64
 96	DivEarned int64
 97	RefEarned int64
 98	Won       int64 // pots and team shares received
 99}
100
101var (
102	pools     []*Pool
103	players   = map[address]*Player{}
104	teamPaid  = map[string]int64{} // flag -> gross bid volume under it
105	teamWon   = map[string]int64{} // flag -> pots closed under it
106	teamSize  = map[string]int64{} // flag -> players flying it
107	realmAddr = chain.PackageAddress(pkgPath)
108)
109
110// Price of the next bid in a pool with the given base price and n bids so far.
111func Price(base, n int64) int64 {
112	return int64(math.Ceil(float64(base) * math.Sqrt(float64(n+1))))
113}
114
115func (p *Pool) Deadline() time.Time { return p.LastBidAt.Add(time.Duration(p.Clock) * time.Second) }
116func (p *Pool) Over() bool           { return p.Bids > 0 && !time.Now().Before(p.Deadline()) }
117func (p *Pool) NextPrice() int64     { return Price(p.BasePrice, p.Bids) }
118
119// ClockFor is the clock a bid buys by paying sent against a price: the window
120// divided by the multiple paid, never below window/MaxBoost.
121func ClockFor(window, price, sent int64) int64 {
122	c := int64(float64(window) * float64(price) / float64(sent))
123	if c < window/MaxBoost {
124		c = window / MaxBoost
125	}
126	if c < 1 {
127		c = 1
128	}
129	return c
130}
131
132// CreatePool opens a pool. Any ugnot sent with the call seeds its pot. The
133// creator earns CreatorShare of every bid.
134func CreatePool(cur realm, name string, window int64, basePrice int64) int64 {
135	creator := userCaller(cur)
136	name = cleanName(name)
137	if window < MinWindow || window > MaxWindow {
138		panic("window must be between 10s and 30d")
139	}
140	if basePrice < MinBasePrice || basePrice > MaxBasePrice {
141		panic("base price must be between 0.001 and 1,000,000 GNOT")
142	}
143	seed := ugnotSent()
144	p := &Pool{
145		ID: int64(len(pools)) + 1, Name: name, Creator: creator, Window: window,
146		BasePrice: basePrice, Seed: seed, Pot: seed, CreatedAt: time.Now(),
147	}
148	pools = append(pools, p)
149	chain.Emit("PoolCreated", "id", itoa(p.ID), "creator", creator.String(), "seed", itoa(seed))
150	return p.ID
151}
152
153// Bid pays at least the current price and resets the clock. ref is the address
154// that invited the bidder ("" for none; only the first bid ever sets it), flag
155// a two-letter country to play under ("" keeps the last one).
156func Bid(cur realm, id int64, ref string, flag string) {
157	bidder := userCaller(cur)
158	p := pool(id)
159	if p.Cancelled {
160		panic("pool was cancelled")
161	}
162	if p.Over() {
163		panic("pool is closed")
164	}
165	if p.LastBidder == bidder {
166		panic("you are already the last bidder")
167	}
168	price := p.NextPrice()
169	sent := ugnotSent()
170	if sent < price {
171		panic("send at least " + itoa(price) + "ugnot, got " + itoa(sent) + "ugnot")
172	}
173	pl := player(bidder)
174	if f := cleanFlag(flag); f != "" && f != pl.Flag {
175		if pl.Flag != "" {
176			teamSize[pl.Flag]--
177		}
178		pl.Flag = f
179		teamSize[f]++
180	}
181	if pl.Referrer == "" && ref != "" {
182		r := address(ref)
183		if r.IsValid() && r != bidder {
184			pl.Referrer = r
185			player(r).Recruits++
186		}
187	}
188
189	// where the money goes
190	div, refCut, creatorCut := sent*DivShare/100, sent*RefShare/100, sent*CreatorShare/100
191	toPot := sent - div - refCut - creatorCut
192	if creatorCut > 0 {
193		send(cur, p.Creator, creatorCut)
194		p.CreatorPaid += creatorCut
195	}
196	if pl.Referrer != "" && refCut > 0 {
197		send(cur, pl.Referrer, refCut)
198		p.RefPaid += refCut
199		player(pl.Referrer).RefEarned += refCut
200	} else {
201		toPot += refCut
202	}
203	toPot += p.payDividends(cur, bidder, div)
204	p.Pot += toPot
205
206	m := p.member(bidder)
207	m.Paid += sent
208	m.Flag = pl.Flag
209	pl.Bids++
210	pl.Paid += sent
211	if pl.Flag != "" {
212		teamPaid[pl.Flag] += sent
213	}
214	p.Bids++
215	p.LastBidder = bidder
216	p.LastBidAt = time.Now()
217	p.Clock = ClockFor(p.Window, price, sent)
218	p.History = append(p.History, Entry{N: p.Bids, Bidder: bidder, Flag: pl.Flag, Price: sent, Clock: p.Clock, Pot: p.Pot, At: p.LastBidAt})
219	if len(p.History) > HistoryLen {
220		p.History = p.History[len(p.History)-HistoryLen:]
221	}
222	chain.Emit("Bid", "id", itoa(id), "n", itoa(p.Bids), "bidder", bidder.String(), "paid", itoa(sent), "pot", itoa(p.Pot), "div", itoa(div-(toPot-(sent-div-refCut-creatorCut))))
223}
224
225// payDividends splits div among the pool's earlier bidders, pro-rata to what
226// each has paid in, skipping the bidder; whatever cannot be placed (no earlier
227// bidders, rounding) is returned to go into the pot.
228func (p *Pool) payDividends(cur realm, bidder address, div int64) int64 {
229	var total int64
230	for _, m := range p.Roster {
231		if m.Addr != bidder {
232			total += m.Paid
233		}
234	}
235	if total == 0 || div == 0 {
236		return div
237	}
238	var paid int64
239	for _, m := range p.Roster {
240		if m.Addr == bidder {
241			continue
242		}
243		amt := div * m.Paid / total
244		if amt > 0 {
245			send(cur, m.Addr, amt)
246			m.Earned += amt
247			player(m.Addr).DivEarned += amt
248			paid += amt
249		}
250	}
251	p.DivPaid += paid
252	return div - paid
253}
254
255// member finds or adds a bidder on the roster; a full roster drops its oldest.
256func (p *Pool) member(a address) *Member {
257	for _, m := range p.Roster {
258		if m.Addr == a {
259			return m
260		}
261	}
262	if len(p.Roster) >= RosterMax {
263		p.Roster = p.Roster[1:]
264	}
265	m := &Member{Addr: a}
266	p.Roster = append(p.Roster, m)
267	return m
268}
269
270// Claim pays out a finished pool: 80% to the winner, 20% shared among the
271// roster members flying the winner's flag (pro-rata by paid-in, the winner
272// included) — or everything to the winner when nobody else flew it. Anyone may call it.
273func Claim(cur realm, id int64) {
274	p := pool(id)
275	if p.Cancelled {
276		panic("pool was cancelled")
277	}
278	if !p.Over() {
279		panic("clock still running")
280	}
281	if p.Paid {
282		panic("already paid out")
283	}
284	p.Paid = true
285	winner := p.LastBidder
286	flag := ""
287	if m := p.find(winner); m != nil {
288		flag = m.Flag
289	}
290	p.WinnerFlag = flag
291	var team int64
292	var mates int
293	if flag != "" {
294		for _, m := range p.Roster {
295			if m.Flag == flag {
296				team += m.Paid
297				mates++
298			}
299		}
300	}
301	rest := p.Pot
302	if mates > 1 && team > 0 {
303		cut := p.Pot * TeamShare / 100
304		for _, m := range p.Roster {
305			if m.Flag != flag {
306				continue
307			}
308			amt := cut * m.Paid / team
309			if amt > 0 {
310				send(cur, m.Addr, amt)
311				player(m.Addr).Won += amt
312				rest -= amt
313				p.TeamPaid += amt
314			}
315		}
316	}
317	send(cur, winner, rest)
318	player(winner).Won += rest
319	if flag != "" {
320		teamWon[flag] += p.Pot
321	}
322	chain.Emit("Paid", "id", itoa(id), "winner", winner.String(), "pot", itoa(p.Pot), "team", itoa(p.TeamPaid))
323}
324
325// Cancel lets the creator take the seed back from a pool nobody has bid on.
326func Cancel(cur realm, id int64) {
327	caller := userCaller(cur)
328	p := pool(id)
329	if p.Creator != caller {
330		panic("only the creator can cancel")
331	}
332	if p.Bids > 0 {
333		panic("pool already has bids")
334	}
335	if p.Cancelled {
336		panic("already cancelled")
337	}
338	p.Cancelled = true
339	p.Paid = true
340	if p.Pot > 0 {
341		send(cur, caller, p.Pot)
342	}
343	chain.Emit("Cancelled", "id", itoa(id))
344}
345
346func (p *Pool) find(a address) *Member {
347	for _, m := range p.Roster {
348		if m.Addr == a {
349			return m
350		}
351	}
352	return nil
353}
354
355func player(a address) *Player {
356	pl := players[a]
357	if pl == nil {
358		pl = &Player{}
359		players[a] = pl
360	}
361	return pl
362}
363
364func send(cur realm, to address, ugnot int64) {
365	banker.NewBanker(banker.BankerTypeRealmSend, cur).SendCoins(realmAddr, to, chain.Coins{{"ugnot", ugnot}})
366}
367
368func pool(id int64) *Pool {
369	if id < 1 || id > int64(len(pools)) {
370		panic("no such pool")
371	}
372	return pools[id-1]
373}
374
375// userCaller is the signer of the transaction, refusing calls relayed through
376// another realm so nobody can bid on someone else's behalf.
377func userCaller(cur realm) address {
378	prev := cur.Previous()
379	if !prev.IsUserCall() {
380		panic("must be a direct user call")
381	}
382	return prev.Address()
383}
384
385func ugnotSent() int64 {
386	sent := unsafe.OriginSend()
387	for _, c := range sent {
388		if c.Denom != "ugnot" {
389			panic("only ugnot is accepted")
390		}
391	}
392	return sent.AmountOf("ugnot")
393}
394
395func cleanName(s string) string {
396	s = strings.TrimSpace(s)
397	if s == "" || len(s) > MaxNameLen {
398		panic("name must be 1-40 characters")
399	}
400	for _, c := range s {
401		if c < 0x20 || c == 0x7f {
402			panic("name has control characters")
403		}
404	}
405	return s
406}
407
408// cleanFlag accepts a two-letter country code, upper-cased; anything else is "".
409func cleanFlag(s string) string {
410	s = strings.ToUpper(strings.TrimSpace(s))
411	if len(s) != 2 || s[0] < 'A' || s[0] > 'Z' || s[1] < 'A' || s[1] > 'Z' {
412		return ""
413	}
414	return s
415}
416
417func itoa(n int64) string { return strconv.FormatInt(n, 10) }
418
419func parseID(s string) int64 {
420	n, err := strconv.ParseInt(s, 10, 64)
421	if err != nil {
422		panic("bad pool id")
423	}
424	return n
425}
426
427// GNOT formats ugnot as GNOT with the trailing zeros trimmed.
428func GNOT(ugnot int64) string {
429	whole, frac := ugnot/1_000_000, ugnot%1_000_000
430	if frac == 0 {
431		return itoa(whole) + " GNOT"
432	}
433	f := strconv.FormatInt(1_000_000+frac, 10)[1:]
434	return itoa(whole) + "." + strings.TrimRight(f, "0") + " GNOT"
435}
436
437func short(a address) string {
438	s := a.String()
439	if len(s) < 12 {
440		return s
441	}
442	return s[:8] + "…" + s[len(s)-4:]
443}
444
445func dur(sec int64) string {
446	if sec <= 0 {
447		return "0s"
448	}
449	d, h, m, s := sec/86400, sec%86400/3600, sec%3600/60, sec%60
450	out := ""
451	if d > 0 {
452		out += itoa(d) + "d "
453	}
454	if d > 0 || h > 0 {
455		out += itoa(h) + "h "
456	}
457	if d > 0 || h > 0 || m > 0 {
458		out += itoa(m) + "m "
459	}
460	return out + itoa(s) + "s"
461}
462
463// --- rendering ---------------------------------------------------------------
464
465// Render serves gnoweb ("" and "<id>") and the page ("json", "json/<id>",
466// "me/<address>", "teams").
467func Render(path string) string {
468	path = strings.Trim(path, "/")
469	switch {
470	case path == "json":
471		return jsonAll()
472	case strings.HasPrefix(path, "json/"):
473		return pool(parseID(path[5:])).json(true)
474	case strings.HasPrefix(path, "me/"):
475		return jsonMe(address(path[3:]))
476	case path == "teams":
477		return jsonTeams()
478	case path == "":
479		return markdownAll()
480	default:
481		return pool(parseID(path)).markdown()
482	}
483}
484
485func unix(t time.Time) string {
486	if t.IsZero() {
487		return "0"
488	}
489	return itoa(t.Unix())
490}
491
492func jbool(b bool) string {
493	if b {
494		return "true"
495	}
496	return "false"
497}
498
499func (p *Pool) json(detail bool) string {
500	var b strings.Builder
501	b.WriteString("{\"id\":" + itoa(p.ID))
502	b.WriteString(",\"name\":" + strconv.Quote(p.Name))
503	b.WriteString(",\"creator\":\"" + p.Creator.String() + "\"")
504	b.WriteString(",\"window\":" + itoa(p.Window))
505	b.WriteString(",\"basePrice\":" + itoa(p.BasePrice))
506	b.WriteString(",\"seed\":" + itoa(p.Seed))
507	b.WriteString(",\"pot\":" + itoa(p.Pot))
508	b.WriteString(",\"bids\":" + itoa(p.Bids))
509	b.WriteString(",\"nextPrice\":" + itoa(p.NextPrice()))
510	b.WriteString(",\"lastBidder\":\"" + p.LastBidder.String() + "\"")
511	b.WriteString(",\"lastBidAt\":" + unix(p.LastBidAt))
512	b.WriteString(",\"clock\":" + itoa(p.Clock))
513	b.WriteString(",\"createdAt\":" + unix(p.CreatedAt))
514	b.WriteString(",\"over\":" + jbool(p.Over()))
515	b.WriteString(",\"paid\":" + jbool(p.Paid))
516	b.WriteString(",\"cancelled\":" + jbool(p.Cancelled))
517	b.WriteString(",\"bidders\":" + itoa(int64(len(p.Roster))))
518	b.WriteString(",\"divPaid\":" + itoa(p.DivPaid))
519	b.WriteString(",\"refPaid\":" + itoa(p.RefPaid))
520	b.WriteString(",\"creatorPaid\":" + itoa(p.CreatorPaid))
521	b.WriteString(",\"teamPaid\":" + itoa(p.TeamPaid))
522	flag := p.WinnerFlag
523	if m := p.find(p.LastBidder); m != nil && flag == "" {
524		flag = m.Flag
525	}
526	b.WriteString(",\"flag\":\"" + flag + "\"")
527	// what each flag has paid into this pool
528	b.WriteString(",\"teams\":{")
529	seen := map[string]int64{}
530	order := []string{}
531	for _, m := range p.Roster {
532		if m.Flag == "" {
533			continue
534		}
535		if _, ok := seen[m.Flag]; !ok {
536			order = append(order, m.Flag)
537		}
538		seen[m.Flag] += m.Paid
539	}
540	for i, f := range order {
541		if i > 0 {
542			b.WriteString(",")
543		}
544		b.WriteString("\"" + f + "\":" + itoa(seen[f]))
545	}
546	b.WriteString("}")
547	if detail {
548		b.WriteString(",\"history\":[")
549		for i := len(p.History) - 1; i >= 0; i-- { // newest first
550			h := p.History[i]
551			if i < len(p.History)-1 {
552				b.WriteString(",")
553			}
554			b.WriteString("{\"n\":" + itoa(h.N) + ",\"bidder\":\"" + h.Bidder.String() + "\",\"flag\":\"" + h.Flag + "\",\"price\":" + itoa(h.Price) + ",\"clock\":" + itoa(h.Clock) + ",\"pot\":" + itoa(h.Pot) + ",\"at\":" + unix(h.At) + "}")
555		}
556		b.WriteString("],\"roster\":[")
557		for i, m := range p.Roster {
558			if i > 0 {
559				b.WriteString(",")
560			}
561			b.WriteString("{\"addr\":\"" + m.Addr.String() + "\",\"flag\":\"" + m.Flag + "\",\"paid\":" + itoa(m.Paid) + ",\"earned\":" + itoa(m.Earned) + "}")
562		}
563		b.WriteString("]")
564	}
565	b.WriteString("}")
566	return b.String()
567}
568
569func jsonAll() string {
570	var b strings.Builder
571	b.WriteString("{\"now\":" + itoa(time.Now().Unix()) + ",\"realm\":\"" + pkgPath + "\"")
572	b.WriteString(",\"split\":{\"pot\":" + itoa(PotShare) + ",\"div\":" + itoa(DivShare) + ",\"ref\":" + itoa(RefShare) + ",\"creator\":" + itoa(CreatorShare) + ",\"team\":" + itoa(TeamShare) + ",\"roster\":" + itoa(int64(RosterMax)) + "}")
573	b.WriteString(",\"pools\":[")
574	for i, p := range pools {
575		if i > 0 {
576			b.WriteString(",")
577		}
578		b.WriteString(p.json(false))
579	}
580	b.WriteString("],\"teams\":" + jsonTeams() + "}")
581	return b.String()
582}
583
584// jsonTeams is the country leaderboard: paid in, won, and players per flag.
585func jsonTeams() string {
586	var b strings.Builder
587	b.WriteString("{")
588	i := 0
589	for f, paid := range teamPaid {
590		if i > 0 {
591			b.WriteString(",")
592		}
593		i++
594		b.WriteString("\"" + f + "\":{\"paid\":" + itoa(paid) + ",\"won\":" + itoa(teamWon[f]) + ",\"players\":" + itoa(teamSize[f]) + "}")
595	}
596	b.WriteString("}")
597	return b.String()
598}
599
600// jsonMe is what the page shows a connected wallet about itself.
601func jsonMe(a address) string {
602	pl := players[a]
603	if pl == nil {
604		pl = &Player{}
605	}
606	return "{\"addr\":\"" + a.String() + "\",\"referrer\":\"" + pl.Referrer.String() + "\",\"recruits\":" + itoa(pl.Recruits) +
607		",\"flag\":\"" + pl.Flag + "\",\"bids\":" + itoa(pl.Bids) + ",\"paid\":" + itoa(pl.Paid) +
608		",\"divEarned\":" + itoa(pl.DivEarned) + ",\"refEarned\":" + itoa(pl.RefEarned) + ",\"won\":" + itoa(pl.Won) + "}"
609}
610
611func (p *Pool) status() string {
612	switch {
613	case p.Cancelled:
614		return "cancelled"
615	case p.Over():
616		s := "won by " + short(p.LastBidder)
617		if !p.Paid {
618			s += " (unclaimed)"
619		}
620		return s
621	case p.Bids == 0:
622		return "no bids yet · " + dur(p.Window) + " window · next " + GNOT(p.NextPrice())
623	default:
624		left := p.Deadline().Unix() - time.Now().Unix()
625		s := dur(left) + " left"
626		if p.Clock < p.Window {
627			s += " of a " + dur(p.Clock) + " clock"
628		}
629		return s + " · " + short(p.LastBidder) + " on top · next " + GNOT(p.NextPrice())
630	}
631}
632
633func markdownAll() string {
634	var b strings.Builder
635	b.WriteString("# Bubble Rumble\n\n")
636	b.WriteString("Every bid goes into a pool's pot and resets its clock; the Nth bid costs base·√N. ")
637	b.WriteString("Of every bid, 55% is pot, 30% is paid at once to the pool's earlier bidders, 10% to whoever invited you, 5% to the pool's creator. ")
638	b.WriteString("When the clock runs out the last bidder takes 80% and the players under the winner's flag share 20%. Play at [bubblerumble.net](https://bubblerumble.net).\n\n")
639	if len(pools) == 0 {
640		b.WriteString("_No pools yet._\n")
641		return b.String()
642	}
643	b.WriteString("## Pools\n\n")
644	for i := len(pools) - 1; i >= 0; i-- {
645		p := pools[i]
646		b.WriteString("- [" + p.Name + "](?" + itoa(p.ID) + ") — **" + GNOT(p.Pot) + "** · " + itoa(p.Bids) + " bids · " + p.status() + "\n")
647	}
648	return b.String()
649}
650
651func (p *Pool) markdown() string {
652	var b strings.Builder
653	b.WriteString("# " + p.Name + "\n\n")
654	b.WriteString("**" + GNOT(p.Pot) + "** in the pot · " + p.status() + "\n\n")
655	b.WriteString("Opened by " + short(p.Creator) + " · " + dur(p.Window) + " window · base " + GNOT(p.BasePrice) + " · seed " + GNOT(p.Seed) + " · " + GNOT(p.DivPaid) + " paid to earlier bidders so far\n\n")
656	if len(p.History) > 0 {
657		b.WriteString("## Bids\n\n")
658		for i := len(p.History) - 1; i >= 0; i-- {
659			h := p.History[i]
660			b.WriteString("- #" + itoa(h.N) + " " + short(h.Bidder) + " " + h.Flag + " paid " + GNOT(h.Price) + " → pot " + GNOT(h.Pot) + "\n")
661		}
662	}
663	return b.String()
664}