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.

auction.gno

22.31 Kb · 679 lines
  1// Realm auction is an ascending (English) auction settled in ugnot, holding
  2// real coins. A SELLER lists a lot with a reserve price and a duration;
  3// BIDDERS raise each other by attaching coins; the standing high bid is
  4// escrowed by the realm and every OUTBID bid becomes a pull-claimable refund;
  5// at close the winner's payment is credited to the seller, the protocol fee
  6// splitting off at the ledger. A bid landing inside the anti-snipe window
  7// extends the end, so the auction closes only when bidding actually stops.
  8//
  9// It is the one market shape this portfolio never had: COMPETITIVE PRICE
 10// DISCOVERY with escrowed settlement. The routing realms move a known amount
 11// to known parties (r/fee_split, r/revshare), the disbursement realms pay a
 12// known total out on a schedule (r/vesting, r/payroll, r/budgets), the credit
 13// realm lends principal out and takes a larger total back (r/loans), and
 14// r/crowdfund POOLS many contributions toward one shared goal. An auction is
 15// none of those: N rival bidders, one escrowed winner, automatic refunds for
 16// the rest, and a price nobody knows in advance. See [DISCOVERY.md].
 17//
 18// # Custody — the conservation invariant is the product
 19//
 20// A custodial realm, so the monetary invariant governs it exactly as it does
 21// r/loans and r/subscriptions, with one extra bucket for the escrowed bids:
 22//
 23//	Held == locked + feeledger.Liabilities()
 24//
 25// where `locked` is the sum of the CURRENT high bids across all open auctions
 26// (coins the realm holds but that are not yet claimable by anyone), and the
 27// ledger holds the two claimable kinds — OUTBID refunds and, after close,
 28// SELLER proceeds — plus the fee pot. Every value transition preserves the
 29// equality; see the DELTA-form conservation test.
 30//
 31// Refunds are PULL, never push: an outbid bid is credited to the bidder's
 32// claimable balance (fee 0 — a refund is a full return, not a fee event) and
 33// the bidder withdraws it with [Claim]. Nothing is sent to a third party
 34// during someone else's Bid, so a bid cannot be griefed by a hostile previous
 35// bidder and there is no cross-call reentrancy surface in the bid path.
 36//
 37// # What the realm settles, and what it does NOT
 38//
 39// The realm settles PAYMENT and records the WINNER; it does not custody or
 40// deliver the lot, which is identified by a free-text description and changes
 41// hands off-chain (or by a downstream realm reading [Winner]). This is the
 42// scope decision, stated rather than discovered: an on-chain-asset auction
 43// (grc20/grc721 custody) is a v2, and it is exactly the escrow-of-the-asset
 44// surface this v1 deliberately omits. A winner pays and is recorded; delivery
 45// is the parties' concern, as it is for r/loans' off-chain use of principal.
 46//
 47// REALM-CALLER CAVEAT, inherited from the siblings: coinio's receipt guard
 48// admits only EOA payers, so every BIDDER is an EOA. The SELLER is whoever
 49// calls [CreateAuction]; a realm may create an auction, but like revshare's
 50// members a realm seller must expose its own crossing path to [Claim] or its
 51// proceeds strand — the seller's concern, not a realm or third-party hazard.
 52// assertNoSend reads the ORIGIN envelope, so every non-payable function
 53// refuses any transaction that attached coins anywhere.
 54package auction
 55
 56import (
 57	"chain"
 58	"chain/runtime"
 59	"chain/runtime/unsafe"
 60	"strconv"
 61
 62	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/coinio"
 63	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger"
 64	"gno.land/p/nt/avl/v0"
 65	"gno.land/p/nt/markdown/sanitize/v0"
 66)
 67
 68// Denom is the only asset this realm accepts.
 69const Denom = "ugnot"
 70
 71// MaxFeeBps is the hard protocol-fee cap: 1000 bps = 10%. The fee applies only
 72// to the WINNER's payment at close, never to a refund.
 73const MaxFeeBps = int64(1000)
 74
 75// Auction status values. Settled, Closed and Cancelled are terminal.
 76const (
 77	AuctionOpen      = "open"      // accepting bids until endHeight
 78	AuctionSettled   = "settled"   // closed WITH a winner; proceeds credited
 79	AuctionClosed    = "closed"    // closed with NO bids; nothing to settle
 80	AuctionCancelled = "cancelled" // seller cancelled before any bid
 81)
 82
 83// MinIncrementBps is the minimum raise over the standing high bid: 500 = 5%.
 84// It stops penny-increment griefing without a per-auction knob.
 85const MinIncrementBps = int64(500)
 86
 87// Input bounds.
 88const (
 89	// MinBid is the dust floor for any bid and any non-zero reserve; MaxBid
 90	// keeps every bid, the increment and the fee split far from int64
 91	// overflow. The bidder must ATTACH the bid, so the real ceiling is their
 92	// balance.
 93	MinBid = int64(1_000)
 94	MaxBid = int64(1_000_000_000_000) // 1,000,000 GNOT
 95
 96	// Duration and anti-snipe bounds, in blocks. The floor keeps an auction
 97	// off a one-block treadmill; the ceiling keeps height arithmetic far from
 98	// overflow. A 0 anti-snipe window disables the extension.
 99	MinDuration  = int64(10)
100	MaxDuration  = int64(7_500_000)
101	MaxAntiSnipe = int64(7_500_000)
102
103	// MaxLotLen bounds the stored lot description.
104	MaxLotLen = 200
105
106	// MaxAuctionsPerSeller bounds one seller's stored auctions; terminal
107	// auctions do NOT free the slot, because a settled/closed/cancelled
108	// auction is a permanent record a bidder and auditor may need to read.
109	MaxAuctionsPerSeller = 50
110
111	// RenderLimit bounds every rendered list — an unbounded Render was YELLOW
112	// in three prior audits.
113	RenderLimit = 20
114)
115
116type auction struct {
117	id            int64
118	seller        address
119	lot           string // free-text description of the lot (delivery is off-chain)
120	reserve       int64  // first bid must meet max(reserve, MinBid)
121	antiSnipe     int64  // a bid within this many blocks of end extends end to now+antiSnipe
122	feeBps        int64  // snapshot at create; applied to the winner's payment at close
123	endHeight     int64
124	highBid       int64
125	highBidder    address // "" until the first bid
126	status        string
127	createdHeight int64
128}
129
130var (
131	admin        address
132	pendingAdmin address
133	feeRecipient address
134	feeBps       int64 // snapshotted into NEW auctions
135
136	self   address
137	nextID int64
138	locked int64 // Σ current high bids of OPEN auctions (held, not yet claimable)
139
140	auctions  = avl.NewTree() // padID(id)       -> *auction
141	sellerNum = avl.NewTree() // seller.String() -> *int64, stored auctions
142	ledger    = feeledger.MustNew(MaxFeeBps)
143)
144
145func init() {
146	admin = unsafe.OriginCaller()
147	feeRecipient = admin
148	self = unsafe.CurrentRealm().Address()
149}
150
151// --- seller side ---
152
153// CreateAuction lists a lot for ascending-bid sale. The caller becomes the
154// seller. `reserve` is 0 (no reserve) or a floor in [MinBid, MaxBid] the first
155// bid must meet; the auction runs for `durationBlocks` blocks; a bid landing
156// within `antiSnipe` blocks of the end extends the end to now+antiSnipe (0
157// disables). The current protocol fee is snapshotted in. Returns the id.
158func CreateAuction(cur realm, lot string, reserve, durationBlocks, antiSnipe int64) int64 {
159	assertNoSend()
160	seller := cur.Previous().Address()
161	if len(lot) == 0 || len(lot) > MaxLotLen {
162		panic("lot description must be 1.." + itoa(MaxLotLen) + " bytes")
163	}
164	if reserve != 0 && (reserve < MinBid || reserve > MaxBid) {
165		panic("reserve must be 0 or " + itoa(MinBid) + ".." + itoa(MaxBid) + Denom)
166	}
167	if durationBlocks < MinDuration || durationBlocks > MaxDuration {
168		panic("durationBlocks must be " + itoa(MinDuration) + ".." + itoa(MaxDuration))
169	}
170	if antiSnipe < 0 || antiSnipe > MaxAntiSnipe {
171		panic("antiSnipe must be 0.." + itoa(MaxAntiSnipe))
172	}
173
174	n := counter(sellerNum, seller)
175	if *n >= MaxAuctionsPerSeller {
176		panic("per-seller auction limit reached")
177	}
178
179	now := runtime.ChainHeight()
180	end, ok := addChecked(now, durationBlocks)
181	if !ok {
182		panic("end-height overflow")
183	}
184	id := nextID
185	nextID++
186	auctions.Set(padID(id), &auction{
187		id:            id,
188		seller:        seller,
189		lot:           lot,
190		reserve:       reserve,
191		antiSnipe:     antiSnipe,
192		feeBps:        feeBps,
193		endHeight:     end,
194		highBidder:    "",
195		status:        AuctionOpen,
196		createdHeight: now,
197	})
198	*n++
199
200	chain.Emit("AuctionCreated",
201		"auctionId", itoa(id),
202		"seller", seller.String(),
203		"reserve", itoa(reserve),
204		"endHeight", itoa(end),
205		"antiSnipe", itoa(antiSnipe),
206		"feeBps", itoa(feeBps),
207	)
208	return id
209}
210
211// Cancel withdraws an auction that has not yet received a bid. Seller only.
212// Once any bid stands, the auction can only run to close — a seller cannot
213// pull the lot out from under a committed bidder.
214func Cancel(cur realm, auctionID int64) {
215	assertNoSend()
216	a := mustAuction(auctionID)
217	if cur.Previous().Address() != a.seller {
218		panic("only the seller may cancel")
219	}
220	if a.status != AuctionOpen {
221		panic("auction is " + a.status)
222	}
223	if a.highBidder != "" {
224		panic("cannot cancel after a bid")
225	}
226	a.status = AuctionCancelled
227	chain.Emit("AuctionCancelled", "auctionId", itoa(auctionID), "seller", a.seller.String())
228}
229
230// --- bidder side ---
231
232// Bid raises the standing high bid on an open auction. The caller attaches the
233// bid; it must beat the current high bid by at least MinIncrementBps (or meet
234// max(reserve, MinBid) if it is the first bid), and the auction must still be
235// open and before its end. The previous high bidder's escrowed bid becomes a
236// pull-claimable refund. A bid inside the anti-snipe window extends the end.
237func Bid(cur realm, auctionID int64) {
238	bidder, amount := coinio.Receive(0, cur, Denom)
239	a := mustAuction(auctionID)
240	if a.status != AuctionOpen {
241		panic("auction is " + a.status)
242	}
243	now := runtime.ChainHeight()
244	if now >= a.endHeight {
245		panic("auction has ended; call Close")
246	}
247	if bidder == a.seller {
248		panic("seller cannot bid on their own auction")
249	}
250	if amount > MaxBid {
251		panic("bid exceeds the maximum " + itoa(MaxBid) + Denom)
252	}
253	min := minNextBid(a)
254	if amount < min {
255		panic("bid " + itoa(amount) + " is below the minimum next bid " + itoa(min) + Denom)
256	}
257
258	// Refund the previous high bidder (pull), then relock at the new high bid.
259	// coinio already received `amount`, so Held rose by amount; moving the old
260	// high bid from `locked` into the ledger and locking the new one keeps
261	// Held == locked + Liabilities exactly.
262	if a.highBidder != "" {
263		ledger.MustDeposit(a.highBidder.String(), a.highBid, 0)
264		locked -= a.highBid
265	}
266	locked += amount
267
268	a.highBid = amount
269	a.highBidder = bidder
270
271	extended := false
272	if a.antiSnipe > 0 && a.endHeight-now < a.antiSnipe {
273		// Checked like the create-time end (overflow is unreachable under the
274		// height bounds, but the checked-arithmetic rule holds everywhere).
275		end, ok := addChecked(now, a.antiSnipe)
276		if !ok {
277			panic("anti-snipe end-height overflow")
278		}
279		a.endHeight = end
280		extended = true
281	}
282
283	chain.Emit("Bid",
284		"auctionId", itoa(auctionID),
285		"bidder", bidder.String(),
286		"amount", itoa(amount),
287		"endHeight", itoa(a.endHeight),
288		"extended", boolStr(extended),
289	)
290}
291
292// --- close (permissionless, like a sibling realm's valve) ---
293
294// Close finalizes an auction whose end height has passed. Anyone may call it;
295// no auction's terminal state may depend on a party acting. With a winner, the
296// high bid is credited to the seller (fee split at the ledger) and the auction
297// is Settled; with no bids it is Closed. The winner's escrowed payment moves
298// from `locked` into the seller's claimable balance.
299func Close(cur realm, auctionID int64) {
300	assertNoSend()
301	a := mustAuction(auctionID)
302	if a.status != AuctionOpen {
303		panic("auction is " + a.status)
304	}
305	if runtime.ChainHeight() < a.endHeight {
306		panic("auction has not ended; ends at height " + itoa(a.endHeight))
307	}
308	if a.highBidder == "" {
309		a.status = AuctionClosed
310		chain.Emit("AuctionClosed", "auctionId", itoa(auctionID), "seller", a.seller.String())
311		return
312	}
313	credited, fee := ledger.MustDeposit(a.seller.String(), a.highBid, a.feeBps)
314	locked -= a.highBid
315	a.status = AuctionSettled
316	chain.Emit("AuctionSettled",
317		"auctionId", itoa(auctionID),
318		"seller", a.seller.String(),
319		"winner", a.highBidder.String(),
320		"price", itoa(a.highBid),
321		"credited", itoa(credited),
322		"fee", itoa(fee),
323	)
324}
325
326// --- claims (pull: refunds and seller proceeds) ---
327
328// Claim sends amount ugnot of the caller's claimable balance — outbid refunds
329// and, if the caller is a seller of a settled auction, proceeds — to the
330// caller.
331func Claim(cur realm, amount int64) {
332	assertNoSend()
333	caller := cur.Previous().Address()
334	if err := ledger.Withdraw(caller.String(), amount); err != nil {
335		panic(err)
336	}
337	coinio.Payout(0, cur, caller, Denom, amount)
338	chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
339}
340
341// ClaimAll sends the caller's entire claimable balance to the caller.
342func ClaimAll(cur realm) {
343	assertNoSend()
344	caller := cur.Previous().Address()
345	amount, err := ledger.WithdrawAll(caller.String())
346	if err != nil {
347		panic(err)
348	}
349	if amount == 0 {
350		panic("nothing to claim")
351	}
352	coinio.Payout(0, cur, caller, Denom, amount)
353	chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
354}
355
356// --- administration (two-step transfer, the house pattern) ---
357
358func SetFeeBps(cur realm, bps int64) {
359	assertNoSend()
360	if cur.Previous().Address() != admin {
361		panic("admin only")
362	}
363	if bps < 0 || bps > MaxFeeBps {
364		panic("fee must be 0.." + itoa(MaxFeeBps) + " bps")
365	}
366	feeBps = bps
367	chain.Emit("FeeSet", "bps", itoa(bps))
368}
369
370func SetFeeRecipient(cur realm, recipient address) {
371	assertNoSend()
372	if cur.Previous().Address() != admin {
373		panic("admin only")
374	}
375	if !recipient.IsValid() {
376		panic("invalid recipient")
377	}
378	feeRecipient = recipient
379	chain.Emit("FeeRecipientSet", "to", recipient.String())
380}
381
382func WithdrawFees(cur realm) {
383	assertNoSend()
384	if cur.Previous().Address() != feeRecipient {
385		panic("fee recipient only")
386	}
387	amount := ledger.WithdrawFees()
388	if amount == 0 {
389		panic("no fees accrued")
390	}
391	coinio.Payout(0, cur, feeRecipient, Denom, amount)
392	chain.Emit("FeesWithdrawn", "to", feeRecipient.String(), "amount", itoa(amount))
393}
394
395// SweepDenom recovers out-of-band coins to the fee recipient. For the ledger
396// denom the reserve is locked + Liabilities() — escrowed high bids, claimable
397// refunds, seller proceeds and the fee pot are all structurally unreachable.
398func SweepDenom(cur realm, denom string) {
399	assertNoSend()
400	if cur.Previous().Address() != feeRecipient {
401		panic("fee recipient only")
402	}
403	reserve := int64(0)
404	if denom == Denom {
405		reserve = locked + ledger.Liabilities()
406	}
407	swept := coinio.Sweep(0, cur, feeRecipient, denom, reserve)
408	chain.Emit("SurplusSwept", "to", feeRecipient.String(), "coins", itoa(swept)+denom)
409}
410
411func TransferAdmin(cur realm, successor address) {
412	assertNoSend()
413	if cur.Previous().Address() != admin {
414		panic("admin only")
415	}
416	if !successor.IsValid() {
417		panic("invalid successor")
418	}
419	pendingAdmin = successor
420	chain.Emit("AdminTransferStaged", "to", successor.String())
421}
422
423func AcceptAdmin(cur realm) {
424	assertNoSend()
425	caller := cur.Previous().Address()
426	if pendingAdmin == "" || caller != pendingAdmin {
427		panic("no admin transfer staged for caller")
428	}
429	admin = pendingAdmin
430	pendingAdmin = ""
431	chain.Emit("AdminTransferred", "to", caller.String())
432}
433
434// --- views (non-crossing: cross-realm readable) ---
435
436// AuctionInfo returns an auction's terms and standing state. Lot text is a
437// separate view ([Lot]) so this stays a numeric/address tuple.
438func AuctionInfo(auctionID int64) (seller address, reserve, highBid, endHeight, antiSnipe, feeBps, createdHeight int64, highBidder address, status string) {
439	a := mustAuction(auctionID)
440	return a.seller, a.reserve, a.highBid, a.endHeight, a.antiSnipe, a.feeBps, a.createdHeight, a.highBidder, a.status
441}
442
443// Lot returns the auction's free-text lot description.
444func Lot(auctionID int64) string { return mustAuction(auctionID).lot }
445
446// MinNextBid is the smallest bid that would be accepted right now: the first
447// bid's floor when there are no bids, else the standing high bid plus the
448// minimum increment.
449func MinNextBid(auctionID int64) int64 { return minNextBid(mustAuction(auctionID)) }
450
451// HighBid is the standing high bid (0 if none).
452func HighBid(auctionID int64) int64 { return mustAuction(auctionID).highBid }
453
454// HighBidder is the standing high bidder ("" if none).
455func HighBidder(auctionID int64) address { return mustAuction(auctionID).highBidder }
456
457// Winner is the high bidder once the auction has Settled, else "".
458func Winner(auctionID int64) address {
459	a := mustAuction(auctionID)
460	if a.status == AuctionSettled {
461		return a.highBidder
462	}
463	return ""
464}
465
466// EndHeight is the current end height (which anti-snipe may have extended).
467func EndHeight(auctionID int64) int64 { return mustAuction(auctionID).endHeight }
468
469// Reserve is the auction's reserve price (0 if none).
470func Reserve(auctionID int64) int64 { return mustAuction(auctionID).reserve }
471
472// Status is the auction's status string.
473func Status(auctionID int64) string { return mustAuction(auctionID).status }
474
475// Seller is the auction's seller.
476func Seller(auctionID int64) address { return mustAuction(auctionID).seller }
477
478// IsOpen reports whether the auction is open and still before its end.
479func IsOpen(auctionID int64) bool {
480	a := mustAuction(auctionID)
481	return a.status == AuctionOpen && runtime.ChainHeight() < a.endHeight
482}
483
484// IsCloseable reports whether Close would succeed right now.
485func IsCloseable(auctionID int64) bool {
486	a := mustAuction(auctionID)
487	return a.status == AuctionOpen && runtime.ChainHeight() >= a.endHeight
488}
489
490// BlocksLeft is how many blocks until the end (0 once ended).
491func BlocksLeft(auctionID int64) int64 {
492	a := mustAuction(auctionID)
493	left := a.endHeight - runtime.ChainHeight()
494	if left < 0 {
495		return 0
496	}
497	return left
498}
499
500func HasAuction(auctionID int64) bool { return auctions.Get(padID(auctionID)) != nil }
501
502func AuctionsOf(seller address) int64 {
503	v := sellerNum.Get(seller.String())
504	if v == nil {
505		return 0
506	}
507	return *v.(*int64)
508}
509
510func Admin() address              { return admin }
511func PendingAdmin() address       { return pendingAdmin }
512func FeeRecipient() address       { return feeRecipient }
513func FeeBps() int64               { return feeBps }
514func NumAuctions() int64          { return nextID }
515func ClaimableOf(a address) int64 { return ledger.BalanceOf(a.String()) }
516func FeesAccrued() int64          { return ledger.FeesAccrued() }
517func Liabilities() int64          { return ledger.Liabilities() }
518func Locked() int64               { return locked }
519func Held() int64                 { return coinio.HeldAt(self, Denom) }
520func Address() address            { return self }
521func Height() int64               { return runtime.ChainHeight() }
522
523// --- internals ---
524
525// minNextBid is the smallest acceptable bid: the first-bid floor when no bid
526// stands, else the high bid plus ceil(highBid·MinIncrementBps/10000). highBid
527// is bounded by MaxBid, so the increment product cannot overflow.
528func minNextBid(a *auction) int64 {
529	if a.highBidder == "" {
530		if a.reserve > MinBid {
531			return a.reserve
532		}
533		return MinBid
534	}
535	return a.highBid + ceilDiv(a.highBid*MinIncrementBps, 10_000)
536}
537
538func mustAuction(id int64) *auction {
539	v := auctions.Get(padID(id))
540	if v == nil {
541		panic("unknown auction " + itoa(id))
542	}
543	return v.(*auction)
544}
545
546func assertNoSend() {
547	if !unsafe.OriginSend().IsZero() {
548		panic("this function does not accept coins")
549	}
550}
551
552func counter(tree *avl.Tree, a address) *int64 {
553	k := a.String()
554	if v := tree.Get(k); v != nil {
555		return v.(*int64)
556	}
557	n := int64(0)
558	tree.Set(k, &n)
559	return &n
560}
561
562// ceilDiv returns ceil(a/b) for b>0; a assumed non-negative.
563func ceilDiv(a, b int64) int64 {
564	if a <= 0 {
565		return 0
566	}
567	return (a + b - 1) / b
568}
569
570func addChecked(a, b int64) (int64, bool) {
571	s := a + b
572	if (b > 0 && s < a) || (b < 0 && s > a) {
573		return 0, false
574	}
575	return s, true
576}
577
578func padID(id int64) string {
579	s := strconv.FormatInt(id, 10)
580	for len(s) < 12 {
581		s = "0" + s
582	}
583	return s
584}
585
586func itoa(n int64) string { return strconv.FormatInt(n, 10) }
587
588func boolStr(b bool) string {
589	if b {
590		return "true"
591	}
592	return "false"
593}
594
595func hasPrefix(s, p string) bool { return len(s) >= len(p) && s[:len(p)] == p }
596
597// --- Render ---
598
599func Render(path string) string {
600	switch {
601	case path == "":
602		return renderHome()
603	case hasPrefix(path, "auction/"):
604		return renderAuction(path[len("auction/"):])
605	default:
606		return "unknown path; try \"\" or \"auction/<id>\"\n"
607	}
608}
609
610func renderHome() string {
611	out := "# auction — ascending auctions settled in ugnot\n\n"
612	out += "Auctions: " + itoa(nextID) + " · escrowed high bids: " + itoa(locked) + Denom +
613		" · claimable+fees held: " + itoa(ledger.Liabilities()) + Denom +
614		" · fees accrued: " + itoa(ledger.FeesAccrued()) + Denom + "\n\n"
615	out += "The realm escrows the standing high bid of every open auction; an outbid bid is refunded as a pull-claimable balance.\n\n"
616	out += "| id | seller | lot | reserve | high bid | high bidder | status |\n|---|---|---|---|---|---|---|\n"
617	shown := int64(0)
618	for id := nextID - 1; id >= 0 && shown < RenderLimit; id-- {
619		v := auctions.Get(padID(id))
620		if v == nil {
621			continue
622		}
623		a := v.(*auction)
624		out += "| " + itoa(a.id) + " | " + sanitize.TableCell(a.seller.String()) + " | " +
625			sanitize.TableCell(a.lot) + " | " + itoa(a.reserve) + Denom + " | " +
626			itoa(a.highBid) + Denom + " | " + sanitize.TableCell(highBidderStr(a)) + " | " + a.status + " |\n"
627		shown++
628	}
629	if nextID > shown {
630		out += "\n(" + itoa(shown) + " most recent of " + itoa(nextID) + ")\n"
631	}
632	return out
633}
634
635func renderAuction(idStr string) string {
636	id, err := strconv.ParseInt(idStr, 10, 64)
637	if err != nil {
638		return "bad auction id\n"
639	}
640	v := auctions.Get(padID(id))
641	if v == nil {
642		return "unknown auction " + idStr + "\n"
643	}
644	a := v.(*auction)
645	now := runtime.ChainHeight()
646	out := "# auction " + itoa(a.id) + "\n\n"
647	out += "- lot: " + sanitize.InlineText(a.lot) + "\n"
648	out += "- seller: " + a.seller.String() + "\n"
649	out += "- reserve: " + itoa(a.reserve) + Denom + " · fee: " + itoa(a.feeBps) + " bps\n"
650	out += "- high bid: " + itoa(a.highBid) + Denom + " · high bidder: " + highBidderStr(a) + "\n"
651	out += "- status: " + a.status + " · created at height " + itoa(a.createdHeight) +
652		" · ends at height " + itoa(a.endHeight)
653	if a.antiSnipe > 0 {
654		out += " · anti-snipe " + itoa(a.antiSnipe) + " blocks"
655	}
656	out += "\n"
657	if a.status == AuctionOpen {
658		left := a.endHeight - now
659		if left < 0 {
660			left = 0
661		}
662		state := "accepting bids"
663		if now >= a.endHeight {
664			state = "ended — awaiting Close"
665		}
666		out += "- " + state + " · blocks left " + itoa(left) + " · minimum next bid " + itoa(minNextBid(a)) + Denom + "\n"
667	}
668	if a.status == AuctionSettled {
669		out += "- winner: " + a.highBidder.String() + " at " + itoa(a.highBid) + Denom + "\n"
670	}
671	return out
672}
673
674func highBidderStr(a *auction) string {
675	if a.highBidder == "" {
676		return "(none)"
677	}
678	return a.highBidder.String()
679}