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}