sealedbid.gno
39.82 Kb · 1120 lines
1// Realm sealedbid is a SEALED-BID SECOND-PRICE (Vickrey) auction settled in
2// ugnot, holding real coins. A SELLER lists a lot; BIDDERS commit to a price
3// they never disclose, by publishing only sha256(amount, salt) and escrowing a
4// deposit; after the commit window closes they OPEN their bids by revealing
5// (amount, salt); the highest revealed bid wins and the winner pays the SECOND
6// highest price, their deposit refunded down to it.
7//
8// It is a different clearing rule and a different threat model from this
9// portfolio's other market realm. r/auction is ascending English open-outcry:
10// every bid is public the instant it lands, bidders react to each other, and
11// the winner pays their own bid. Here no bidder learns another's number until
12// the commit window has shut, so there is nothing to react to and nothing to
13// front-run; and because the winner pays the runner-up's price, bidding one's
14// true valuation is the dominant strategy rather than a mistake. The two
15// realms answer the same question — who gets the lot, at what price — by
16// mechanisms that do not resemble each other.
17//
18// COMMIT-REVEAL is the mechanism this portfolio did not have. Nothing in it
19// hashed anything, on any of the three trees, before this realm.
20//
21// # Custody — the conservation invariant is the product
22//
23// A custodial realm, so the monetary invariant governs it as it does r/loans,
24// r/auction and r/deposits:
25//
26// Held == sealed + revealed + feeledger.Liabilities()
27//
28// where `sealed` is the sum of deposits behind commitments that have NOT been
29// opened in a non-terminal sale, `revealed` the sum of deposits behind
30// commitments that HAVE been opened and are awaiting close, and the ledger
31// holds every claimable kind — losing-bid refunds, the winner's change, seller
32// proceeds — plus the fee pot. The two held buckets are disjoint by
33// construction: opening a bid moves its deposit from the first to the second,
34// never duplicating it, and Close drains both into the ledger. Every value
35// transition preserves the equality; see the DELTA-form conservation test.
36//
37// CORRECTION, recorded rather than shipped silently: the §7 discovery gate for
38// this realm predicted THREE disjoint held buckets, naming `forfeited` as the
39// third. Architecture refuted that. A forfeited deposit is credited to the
40// seller at Close in the same transaction that voids the commitment, so it is
41// never held as a distinct balance across any two transactions — there is no
42// third bucket to be disjoint from. `forfeited` survives as a CUMULATIVE
43// STATISTIC ([ForfeitedOf], [ForfeitedTotal]), which is a report and not a
44// custody claim. The gate's file is not edited; this is the amendment.
45//
46// Payouts are PULL, never push. Close credits balances and sends nothing, so a
47// hostile or contract-dead loser cannot make Close fail for everyone else, and
48// the settlement path has no cross-call reentrancy surface.
49//
50// # Non-revelation, and why withholding never pays
51//
52// A sealed-bid auction has a liveness problem an ascending one does not: a
53// bidder who dislikes the outcome can simply never open their bid, stalling
54// settlement or distorting the price. The answer here is adopted by citation
55// rather than invented — r/moul/agents/bubblerumble6 reached it independently,
56// and its policy is the right one: a commitment not opened in its window is
57// VOID, nothing is refunded, and the deposit is credited to the seller. So
58// withholding a reveal costs the withholder their whole deposit and pays them
59// nothing, and the auction always closes: Close is permissionless and needs no
60// bidder to act.
61//
62// Two consequences of that policy are hazards, stated rather than discovered:
63//
64// - A SHORT REVEAL WINDOW IS A SELLER'S INCENTIVE TO SET. The seller keeps
65// every unopened deposit, so a seller who picks the MinRevealBlocks floor
66// profits from bidders who cannot act in time. The window is public before
67// anyone commits and is reported by [SaleInfo] and [Phase]; a bidder who
68// does not like it should not commit. The realm does not second-guess a
69// disclosed term.
70// - A COMMITMENT ABOVE ONE'S DEPOSIT CAN NEVER BE OPENED. Reveal requires
71// `amount <= deposit`, so a bidder who commits to more than they escrowed
72// has no valid opening and forfeits. That is the deposit-as-ceiling rule,
73// and it is what makes the escrow sufficient: commit to no more than you
74// put up.
75//
76// # Privacy — what is hidden, and what is not
77//
78// The BID AMOUNT is hidden until the bidder opens it. The DEPOSIT is not: it
79// is a coin transfer and is public, and since a valid bid cannot exceed its
80// deposit, the deposit leaks an UPPER BOUND on the bid. A bidder who wants
81// more privacy than that over-deposits and takes the change back at Close.
82// This is the real ceiling of the scheme and is stated here because no amount
83// of hashing moves it.
84//
85// The salt is what keeps the commitment from being brute-forced over the
86// plausible round numbers, so [MinSaltLen] bytes are required. The realm can
87// check a salt's LENGTH and can never check its ENTROPY: a 16-byte salt of
88// "aaaaaaaaaaaaaaaa" satisfies the guard and protects nothing. Use random
89// bytes. Compute the commitment locally, or with a READ-ONLY query against
90// [CommitHash] — never in a transaction, which would publish the salt and
91// defeat the whole mechanism.
92//
93// # What the realm settles, and what it does NOT
94//
95// The realm settles PAYMENT and records the WINNER; it does not custody or
96// deliver the lot, which is free text and changes hands off-chain (or by a
97// downstream realm reading [Winner]). The same scope decision r/auction states,
98// for the same reason: an on-chain-asset auction is a v2 and this v1 omits the
99// asset-escrow surface deliberately.
100//
101// REALM-CALLER CAVEAT, inherited from the siblings: coinio's receipt guard
102// admits only EOA payers, so every BIDDER is an EOA. The SELLER is whoever
103// calls [CreateSale]; a realm may sell, but a realm seller must expose its own
104// crossing path to [Claim] or its proceeds strand — the seller's concern, not a
105// third-party hazard. assertNoSend reads the ORIGIN envelope, so every
106// non-payable function refuses a transaction that attached coins anywhere.
107//
108// # Why crypto/sha256 directly
109//
110// The discovery gate found genuine prior art for the hashing scheme —
111// p/moul/agents/commit/v0, whose Hash is LENGTH-PREFIXED so that
112// Hash("ab","c") differs from Hash("a","bc") — and deferred the import-or-
113// reimplement choice to architecture. It is resolved here in favour of calling
114// crypto/sha256 directly and FOLLOWING that package's scheme, because the
115// package resolves to a DIFFERENT IMPORT PATH on each chain this portfolio
116// targets, and the namespace rewriter substitutes only our own address. An
117// import of it would make the mainnet twin of this realm non-substitutable,
118// which is a structural cost that 1.4 KB of saved code does not buy.
119package sealedbid
120
121import (
122 "chain"
123 "chain/runtime"
124 "chain/runtime/unsafe"
125 "crypto/sha256"
126 "encoding/hex"
127 "strconv"
128
129 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/coinio"
130 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/feeledger"
131 "gno.land/p/nt/avl/v0"
132 "gno.land/p/nt/markdown/sanitize/v0"
133)
134
135// Denom is the only asset this realm accepts.
136const Denom = "ugnot"
137
138// MaxFeeBps is the hard protocol-fee cap: 1000 bps = 10%. The fee applies only
139// to the seller's PROCEEDS at close — never to a refund, never to the winner's
140// change, and never to a forfeited deposit (see Close).
141const MaxFeeBps = int64(1000)
142
143// Sale status values. Only Open is non-terminal; the phase inside Open is
144// derived from the chain height, never stored.
145const (
146 SaleOpen = "open" // in its commit, reveal, or awaiting-close phase
147 SaleSettled = "settled" // closed WITH a winner; proceeds credited
148 SaleUnsold = "unsold" // closed with no valid opening, or below reserve
149 SaleCancelled = "cancelled" // seller cancelled before any commitment
150)
151
152// Phase values returned by [Phase] for a sale that is still Open.
153const (
154 PhaseCommit = "commit" // accepting commitments
155 PhaseReveal = "reveal" // accepting openings
156 PhaseCloseable = "closeable" // reveal window shut; Close will succeed
157)
158
159// Input bounds.
160const (
161 // MinBid is the dust floor for any revealed bid, any deposit and any
162 // non-zero reserve; MaxBid keeps every bid and the fee split far from
163 // int64 overflow. The bidder must ESCROW at least their bid, so the real
164 // ceiling is their balance.
165 MinBid = int64(1_000)
166 MaxBid = int64(1_000_000_000_000) // 1,000,000 GNOT
167
168 // Window bounds, in blocks. The reveal floor is higher than the commit
169 // floor on purpose: failing to commit costs nothing, while failing to
170 // reveal costs the whole deposit, so the window in which that can happen
171 // gets the wider floor. See the short-reveal-window hazard above.
172 MinCommitBlocks = int64(10)
173 MinRevealBlocks = int64(20)
174 MaxWindowBlocks = int64(7_500_000)
175
176 // CommitmentLen is the length of a commitment string: sha256 as 64
177 // lowercase hex characters, exactly as [CommitHash] emits it.
178 CommitmentLen = 2 * sha256.Size
179
180 // MinSaltLen is the required salt length. It bounds brute force over
181 // plausible bid amounts; it cannot bound a caller's choice of entropy.
182 MinSaltLen = 16
183 MaxSaltLen = 128
184
185 // MaxLotLen bounds the stored lot description.
186 MaxLotLen = 200
187
188 // MaxBiddersPerSale bounds the commitments on one sale. It is the reason
189 // the duplicate-commitment scan and the Close payout loop may be linear:
190 // both are O(MaxBiddersPerSale) and neither is caller-extensible.
191 //
192 // It is ALSO a scarce resource, which is why a sale carries minDeposit.
193 // A slot is never released — there is no withdraw-commitment path — so
194 // without a seller-sized entry price, 25 addresses committing MinBid
195 // each fill a sale for 25,000ugnot, reveal MinBid, and are refunded in
196 // full fee-free: an honest bidder is locked out for the cost of gas.
197 // Audit Y1, and the reason minDeposit exists. See [CreateSale].
198 MaxBiddersPerSale = 25
199
200 // MaxSalesPerSeller bounds one seller's stored sales; terminal sales do
201 // NOT free the slot, because a settled/unsold/cancelled sale is a
202 // permanent record a bidder and an auditor may need to read.
203 MaxSalesPerSeller = 50
204
205 // RenderLimit bounds every rendered list — an unbounded Render was YELLOW
206 // in three prior audits.
207 RenderLimit = 20
208)
209
210// bid is one bidder's sealed commitment and, once opened, its contents.
211type bid struct {
212 bidder address
213 commitment string // 64 lowercase hex chars
214 deposit int64 // escrowed at Commit; the ceiling on a valid amount
215 opened bool // true once Reveal matched the commitment
216 amount int64 // meaningful only when opened
217 revealSeq int64 // 1-based order of opening within the sale; 0 if unopened
218}
219
220type sale struct {
221 id int64
222 seller address
223 lot string // free-text description of the lot (delivery is off-chain)
224 reserve int64 // 0 (none) or a floor the HIGHEST revealed bid must meet
225 minDeposit int64 // entry price per commitment; >= max(MinBid, reserve)
226 feeBps int64 // snapshot at create; applied to the seller's proceeds
227 commitEnd int64 // commitments accepted while height < commitEnd
228 revealEnd int64 // openings accepted while commitEnd <= height < revealEnd
229 status string
230 createdHeight int64
231 bids []*bid
232 revealCount int64 // openings so far; also the revealSeq allocator
233
234 // Settlement results, written once at Close.
235 winner address
236 price int64
237 forfeited int64 // cumulative statistic, not a held bucket — see the package doc
238}
239
240var (
241 admin address
242 pendingAdmin address
243 feeRecipient address
244 feeBps int64 // snapshotted into NEW sales
245
246 self address
247 nextID int64
248
249 // The two held buckets. Disjoint by construction: Reveal moves a deposit
250 // from the first to the second, and Close drains both into the ledger.
251 sealed int64 // Σ deposits behind UNOPENED commitments in non-terminal sales
252 revealed int64 // Σ deposits behind OPENED commitments awaiting close
253
254 forfeitedTotal int64 // cumulative statistic across all sales
255
256 sales = avl.NewTree() // padID(id) -> *sale
257 sellerNum = avl.NewTree() // seller.String() -> *int64, stored sales
258 ledger = feeledger.MustNew(MaxFeeBps)
259)
260
261func init() {
262 admin = unsafe.OriginCaller()
263 feeRecipient = admin
264 self = unsafe.CurrentRealm().Address()
265}
266
267// --- the commitment scheme ---
268
269// CommitHash returns the commitment for a bid of `amount` under `salt`: the
270// sha256 of a LENGTH-PREFIXED encoding of the two parts, as 64 lowercase hex
271// characters. The length prefixes are what make the encoding unambiguous —
272// without them a commitment to (12, "34salt...") and one to (1, "234salt...")
273// could collide, and a bidder could open a commitment as a bid they never
274// made. The scheme follows p/moul/agents/commit/v0; see the package doc for
275// why it is reimplemented rather than imported.
276//
277// This is a VIEW. Compute your commitment locally or with a read-only query —
278// calling it inside a transaction publishes the salt and destroys the seal.
279func CommitHash(amount int64, salt string) string {
280 a := itoa(amount)
281 pre := itoa(int64(len(a))) + ":" + a + itoa(int64(len(salt))) + ":" + salt
282 sum := sha256.Sum256([]byte(pre))
283 return hex.EncodeToString(sum[:])
284}
285
286// --- seller side ---
287
288// CreateSale lists a lot for sealed-bid second-price sale. The caller becomes
289// the seller. `reserve` is 0 (no reserve) or a floor in [MinBid, MaxBid] that
290// the HIGHEST revealed bid must meet for the sale to settle. Commitments are
291// accepted for `commitBlocks` blocks, then openings for `revealBlocks` blocks,
292// after which anyone may [Close]. The current protocol fee is snapshotted in.
293// Returns the id.
294//
295// `minDeposit` is the ENTRY PRICE per commitment, and it is the seller's one
296// defence against slot squatting: a sale has at most MaxBiddersPerSale slots,
297// a slot is never released, and a squatter who reveals a dust bid is refunded
298// in full fee-free — so with no entry price a rival locks an honest bidder out
299// of any sale for the cost of gas. Size it to the lot, not to the dust floor.
300// 0 means "no preference", resolved to the max(MinBid, reserve) floor, which
301// is the weakest setting and leaves the squatting cost at MaxBiddersPerSale
302// times that floor. The floor includes the reserve because a bid under the
303// reserve cannot win, so escrowing less than the reserve buys only a slot.
304func CreateSale(cur realm, lot string, reserve, minDeposit, commitBlocks, revealBlocks int64) int64 {
305 assertNoSend()
306 seller := cur.Previous().Address()
307 if len(lot) == 0 || len(lot) > MaxLotLen {
308 panic("lot description must be 1.." + itoa(MaxLotLen) + " bytes")
309 }
310 if reserve != 0 && (reserve < MinBid || reserve > MaxBid) {
311 panic("reserve must be 0 or " + itoa(MinBid) + ".." + itoa(MaxBid) + Denom)
312 }
313 floor := MinBid
314 if reserve > floor {
315 floor = reserve
316 }
317 if minDeposit == 0 {
318 minDeposit = floor
319 }
320 if minDeposit < floor || minDeposit > MaxBid {
321 panic("minDeposit must be 0 or " + itoa(floor) + ".." + itoa(MaxBid) + Denom +
322 " (at least max(MinBid, reserve))")
323 }
324 if commitBlocks < MinCommitBlocks || commitBlocks > MaxWindowBlocks {
325 panic("commitBlocks must be " + itoa(MinCommitBlocks) + ".." + itoa(MaxWindowBlocks))
326 }
327 if revealBlocks < MinRevealBlocks || revealBlocks > MaxWindowBlocks {
328 panic("revealBlocks must be " + itoa(MinRevealBlocks) + ".." + itoa(MaxWindowBlocks))
329 }
330
331 n := counter(sellerNum, seller)
332 if *n >= MaxSalesPerSeller {
333 panic("per-seller sale limit reached")
334 }
335
336 now := runtime.ChainHeight()
337 cEnd, ok := addChecked(now, commitBlocks)
338 if !ok {
339 panic("commit-end height overflow")
340 }
341 rEnd, ok := addChecked(cEnd, revealBlocks)
342 if !ok {
343 panic("reveal-end height overflow")
344 }
345
346 id := nextID
347 nextID++
348 sales.Set(padID(id), &sale{
349 id: id,
350 seller: seller,
351 lot: lot,
352 reserve: reserve,
353 minDeposit: minDeposit,
354 feeBps: feeBps,
355 commitEnd: cEnd,
356 revealEnd: rEnd,
357 status: SaleOpen,
358 createdHeight: now,
359 winner: "",
360 })
361 *n++
362
363 chain.Emit("SaleCreated",
364 "saleId", itoa(id),
365 "seller", seller.String(),
366 "reserve", itoa(reserve),
367 "minDeposit", itoa(minDeposit),
368 "commitEnd", itoa(cEnd),
369 "revealEnd", itoa(rEnd),
370 "feeBps", itoa(feeBps),
371 )
372 return id
373}
374
375// Cancel withdraws a sale that has taken no commitment. Seller only. Once any
376// commitment stands the sale can only run to Close — a seller cannot pull the
377// lot out from under an escrowed bidder, and cannot strand a deposit.
378func Cancel(cur realm, saleID int64) {
379 assertNoSend()
380 s := mustSale(saleID)
381 if cur.Previous().Address() != s.seller {
382 panic("only the seller may cancel")
383 }
384 if s.status != SaleOpen {
385 panic("sale is " + s.status)
386 }
387 if len(s.bids) != 0 {
388 panic("cannot cancel after a commitment")
389 }
390 s.status = SaleCancelled
391 chain.Emit("SaleCancelled", "saleId", itoa(saleID), "seller", s.seller.String())
392}
393
394// --- bidder side ---
395
396// Commit escrows a deposit behind a sealed bid. The caller attaches the
397// deposit and supplies only `commitment` — [CommitHash](amount, salt) as 64
398// lowercase hex characters — so the realm learns the bid's CEILING (the
399// deposit) and not the bid. The deposit is the ceiling on what can later be
400// opened, so escrow at least what you intend to bid; escrow more to hide it
401// better. One commitment per bidder per sale, and a commitment already present
402// on this sale is refused (copying another bidder's hash without knowing its
403// contents would otherwise be free and could never be opened).
404func Commit(cur realm, saleID int64, commitment string) {
405 bidder, deposit := coinio.Receive(0, cur, Denom)
406 s := mustSale(saleID)
407 if s.status != SaleOpen {
408 panic("sale is " + s.status)
409 }
410 if runtime.ChainHeight() >= s.commitEnd {
411 panic("commit window has closed at height " + itoa(s.commitEnd))
412 }
413 if bidder == s.seller {
414 panic("seller cannot bid on their own sale")
415 }
416 if deposit < s.minDeposit || deposit > MaxBid {
417 panic("deposit must be " + itoa(s.minDeposit) + ".." + itoa(MaxBid) + Denom +
418 " on this sale")
419 }
420 assertCommitment(commitment)
421 if len(s.bids) >= MaxBiddersPerSale {
422 panic("per-sale bidder limit reached")
423 }
424 for _, b := range s.bids {
425 if b.bidder == bidder {
426 panic("already committed to this sale")
427 }
428 if b.commitment == commitment {
429 panic("that commitment is already present on this sale")
430 }
431 }
432
433 s.bids = append(s.bids, &bid{
434 bidder: bidder,
435 commitment: commitment,
436 deposit: deposit,
437 })
438 sealed += deposit
439
440 chain.Emit("Committed",
441 "saleId", itoa(saleID),
442 "bidder", bidder.String(),
443 "deposit", itoa(deposit),
444 "commitment", commitment,
445 )
446}
447
448// Reveal opens the caller's sealed bid on a sale whose commit window has shut.
449// `amount` and `salt` must hash to the stored commitment, `amount` must be in
450// [MinBid, MaxBid], and `amount` must not exceed the deposit — a bidder who
451// committed above their escrow has no valid opening and forfeits at Close.
452// Opening does not move coins; it moves the deposit from the `sealed` bucket
453// to the `revealed` one and records the bid for the clearing scan.
454func Reveal(cur realm, saleID int64, amount int64, salt string) {
455 assertNoSend()
456 bidder := cur.Previous().Address()
457 s := mustSale(saleID)
458 if s.status != SaleOpen {
459 panic("sale is " + s.status)
460 }
461 now := runtime.ChainHeight()
462 if now < s.commitEnd {
463 panic("reveal window opens at height " + itoa(s.commitEnd))
464 }
465 if now >= s.revealEnd {
466 panic("reveal window closed at height " + itoa(s.revealEnd))
467 }
468 b := findBid(s, bidder)
469 if b == nil {
470 panic("no commitment from caller on this sale")
471 }
472 if b.opened {
473 panic("already revealed")
474 }
475 if len(salt) < MinSaltLen || len(salt) > MaxSaltLen {
476 panic("salt must be " + itoa(MinSaltLen) + ".." + itoa(MaxSaltLen) + " bytes")
477 }
478 if amount < MinBid || amount > MaxBid {
479 panic("amount must be " + itoa(MinBid) + ".." + itoa(MaxBid) + Denom)
480 }
481 if amount > b.deposit {
482 panic("amount " + itoa(amount) + " exceeds the escrowed deposit " + itoa(b.deposit) + Denom)
483 }
484 if CommitHash(amount, salt) != b.commitment {
485 panic("amount and salt do not match the commitment")
486 }
487
488 b.opened = true
489 b.amount = amount
490 s.revealCount++
491 b.revealSeq = s.revealCount
492
493 sealed -= b.deposit
494 revealed += b.deposit
495
496 chain.Emit("Revealed",
497 "saleId", itoa(saleID),
498 "bidder", bidder.String(),
499 "amount", itoa(amount),
500 "revealSeq", itoa(b.revealSeq),
501 )
502}
503
504// --- close (permissionless, like every sibling realm's valve) ---
505
506// Close settles a sale whose reveal window has shut. Anyone may call it; no
507// sale's terminal state may depend on a party acting.
508//
509// The clearing rule: among the OPENED bids the highest wins and pays
510// max(second-highest, reserve, MinBid) — a strict Vickrey price, floored so
511// that a lone bidder does not win for nothing and so that a reserve is
512// honoured as a price and not merely as a veto. Ties on the highest amount go
513// to the EARLIEST opening, which makes the tie-break deterministic and
514// independent of storage order, and a tie sets the second price equal to the
515// first, so a tied winner pays their full bid.
516//
517// The money, all of it credited and none of it sent:
518//
519// - winner: change of `deposit - price`, fee-free (it is their own coin
520// coming back, not a sale)
521// - seller: `price`, at the sale's snapshotted fee
522// - every other opened bid: its deposit back in full, fee-free
523// - every UNOPENED commitment: deposit credited to the SELLER, fee-free.
524// Fee-free is deliberate: a fee is a cut of a SALE, and taking a cut of
525// liquidated damages would give the fee recipient a revenue interest in
526// bidders failing to reveal.
527//
528// If no bid was opened, or the highest opened bid is below the reserve, the
529// sale is Unsold: every opened bid is refunded in full and only the unopened
530// commitments forfeit.
531func Close(cur realm, saleID int64) {
532 assertNoSend()
533 s := mustSale(saleID)
534 if s.status != SaleOpen {
535 panic("sale is " + s.status)
536 }
537 if runtime.ChainHeight() < s.revealEnd {
538 panic("reveal window has not closed; closes at height " + itoa(s.revealEnd))
539 }
540
541 h1, h2, winner, winnerSeq := clearing(s)
542
543 // A sale settles only if something was opened AND it met the reserve.
544 settles := winner != "" && h1 >= s.reserve
545 price := int64(0)
546 if settles {
547 price = vickreyPrice(h2, s.reserve)
548 }
549
550 forfeited := int64(0)
551 proceeds := int64(0)
552
553 for _, b := range s.bids {
554 if !b.opened {
555 // Void commitment: the deposit is liquidated to the seller.
556 sealed -= b.deposit
557 forfeited += b.deposit
558 proceeds += b.deposit
559 continue
560 }
561 revealed -= b.deposit
562 if settles && b.bidder == winner {
563 // The winner pays `price` out of their deposit; the rest is change.
564 // price <= b.amount <= b.deposit, proven in the package tests, so
565 // change is never negative.
566 change := b.deposit - price
567 if change > 0 {
568 ledger.MustDeposit(b.bidder.String(), change, 0)
569 }
570 continue
571 }
572 ledger.MustDeposit(b.bidder.String(), b.deposit, 0)
573 }
574
575 // Forfeits are credited fee-free; only the sale price pays the fee.
576 if proceeds > 0 {
577 ledger.MustDeposit(s.seller.String(), proceeds, 0)
578 }
579 credited, fee := int64(0), int64(0)
580 if settles {
581 credited, fee = ledger.MustDeposit(s.seller.String(), price, s.feeBps)
582 }
583
584 s.forfeited = forfeited
585 forfeitedTotal += forfeited
586
587 if !settles {
588 s.status = SaleUnsold
589 chain.Emit("SaleUnsold",
590 "saleId", itoa(saleID),
591 "seller", s.seller.String(),
592 "revealed", itoa(s.revealCount),
593 "highBid", itoa(h1),
594 "reserve", itoa(s.reserve),
595 "forfeited", itoa(forfeited),
596 )
597 return
598 }
599
600 s.status = SaleSettled
601 s.winner = winner
602 s.price = price
603 chain.Emit("SaleSettled",
604 "saleId", itoa(saleID),
605 "seller", s.seller.String(),
606 "winner", winner.String(),
607 "winningBid", itoa(h1),
608 "secondBid", itoa(h2),
609 "price", itoa(price),
610 "winnerSeq", itoa(winnerSeq),
611 "credited", itoa(credited),
612 "fee", itoa(fee),
613 "forfeited", itoa(forfeited),
614 )
615}
616
617// --- claims (pull: refunds, change and seller proceeds) ---
618
619// Claim sends amount ugnot of the caller's claimable balance — refunds,
620// winner's change, and seller proceeds — to the caller.
621func Claim(cur realm, amount int64) {
622 assertNoSend()
623 caller := cur.Previous().Address()
624 if err := ledger.Withdraw(caller.String(), amount); err != nil {
625 panic(err)
626 }
627 coinio.Payout(0, cur, caller, Denom, amount)
628 chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
629}
630
631// ClaimAll sends the caller's entire claimable balance to the caller.
632func ClaimAll(cur realm) {
633 assertNoSend()
634 caller := cur.Previous().Address()
635 amount, err := ledger.WithdrawAll(caller.String())
636 if err != nil {
637 panic(err)
638 }
639 if amount == 0 {
640 panic("nothing to claim")
641 }
642 coinio.Payout(0, cur, caller, Denom, amount)
643 chain.Emit("Claim", "to", caller.String(), "amount", itoa(amount))
644}
645
646// --- administration (two-step transfer, the house pattern) ---
647
648func SetFeeBps(cur realm, bps int64) {
649 assertNoSend()
650 if cur.Previous().Address() != admin {
651 panic("admin only")
652 }
653 if bps < 0 || bps > MaxFeeBps {
654 panic("fee must be 0.." + itoa(MaxFeeBps) + " bps")
655 }
656 feeBps = bps
657 chain.Emit("FeeSet", "bps", itoa(bps))
658}
659
660func SetFeeRecipient(cur realm, recipient address) {
661 assertNoSend()
662 if cur.Previous().Address() != admin {
663 panic("admin only")
664 }
665 if !recipient.IsValid() {
666 panic("invalid recipient")
667 }
668 feeRecipient = recipient
669 chain.Emit("FeeRecipientSet", "to", recipient.String())
670}
671
672func WithdrawFees(cur realm) {
673 assertNoSend()
674 if cur.Previous().Address() != feeRecipient {
675 panic("fee recipient only")
676 }
677 amount := ledger.WithdrawFees()
678 if amount == 0 {
679 panic("no fees accrued")
680 }
681 coinio.Payout(0, cur, feeRecipient, Denom, amount)
682 chain.Emit("FeesWithdrawn", "to", feeRecipient.String(), "amount", itoa(amount))
683}
684
685// SweepDenom recovers out-of-band coins to the fee recipient. For the ledger
686// denom the reserve is sealed + revealed + Liabilities() — escrowed deposits,
687// claimable refunds, change, proceeds and the fee pot are all structurally
688// unreachable.
689func SweepDenom(cur realm, denom string) {
690 assertNoSend()
691 if cur.Previous().Address() != feeRecipient {
692 panic("fee recipient only")
693 }
694 reserve := int64(0)
695 if denom == Denom {
696 reserve = sealed + revealed + ledger.Liabilities()
697 }
698 swept := coinio.Sweep(0, cur, feeRecipient, denom, reserve)
699 chain.Emit("SurplusSwept", "to", feeRecipient.String(), "coins", itoa(swept)+denom)
700}
701
702func TransferAdmin(cur realm, successor address) {
703 assertNoSend()
704 if cur.Previous().Address() != admin {
705 panic("admin only")
706 }
707 if !successor.IsValid() {
708 panic("invalid successor")
709 }
710 pendingAdmin = successor
711 chain.Emit("AdminTransferStaged", "to", successor.String())
712}
713
714func AcceptAdmin(cur realm) {
715 assertNoSend()
716 caller := cur.Previous().Address()
717 if pendingAdmin == "" || caller != pendingAdmin {
718 panic("no admin transfer staged for caller")
719 }
720 admin = pendingAdmin
721 pendingAdmin = ""
722 chain.Emit("AdminTransferred", "to", caller.String())
723}
724
725// --- views (non-crossing: cross-realm readable) ---
726
727// SaleInfo returns a sale's terms and standing state. Lot text is a separate
728// view ([Lot]) so this stays a numeric/address tuple.
729//
730// `minDeposit` is reported as RESOLVED, never as the seller typed it: a 0 at
731// create means "no preference" and becomes the max(MinBid, reserve) floor, so
732// a bidder reading this never has to re-derive the floor to find out what
733// [Commit] will accept.
734func SaleInfo(saleID int64) (seller address, reserve, minDeposit, feeBps, commitEnd, revealEnd, createdHeight, bids, revealedBids int64, status string) {
735 s := mustSale(saleID)
736 return s.seller, s.reserve, s.minDeposit, s.feeBps, s.commitEnd, s.revealEnd,
737 s.createdHeight, int64(len(s.bids)), s.revealCount, s.status
738}
739
740// MinDeposit is the resolved entry price for one commitment on this sale: the
741// smallest deposit [Commit] accepts. It is at least max(MinBid, reserve) and
742// is the seller's defence against slot squatting (audit Y1; see [CreateSale]).
743func MinDeposit(saleID int64) int64 { return mustSale(saleID).minDeposit }
744
745// Lot returns the sale's free-text lot description.
746func Lot(saleID int64) string { return mustSale(saleID).lot }
747
748// Phase reports where a sale is: "commit", "reveal" or "closeable" while it is
749// open, else its terminal status. The phase is derived from the chain height
750// and is never stored, so it cannot disagree with the guards.
751func Phase(saleID int64) string { return phaseOf(mustSale(saleID)) }
752
753// Status is the sale's STORED state -- one of "open", "settled", "unsold" or
754// "cancelled". It answers a different question from [Phase]: an open sale has
755// three distinct phases but only one status, and a closed one has a status
756// that no longer moves with the chain height.
757func Status(saleID int64) string { return mustSale(saleID).status }
758
759// Winner is the winning bidder once the sale has Settled, else "".
760func Winner(saleID int64) address { return mustSale(saleID).winner }
761
762// Price is the SECOND price the winner actually paid once the sale has
763// Settled, else 0. It is not the winning bid; see [WinningBid].
764func Price(saleID int64) int64 { return mustSale(saleID).price }
765
766// WinningBid is the highest OPENED bid on a sale, available as soon as it is
767// revealed (a revealed bid is public). It is what the winner bid, not what
768// they pay.
769func WinningBid(saleID int64) int64 {
770 h1, _, _, _ := clearing(mustSale(saleID))
771 return h1
772}
773
774// SecondBid is the second-highest OPENED bid, or 0. With a tie on the highest
775// it equals the highest.
776func SecondBid(saleID int64) int64 {
777 _, h2, _, _ := clearing(mustSale(saleID))
778 return h2
779}
780
781// ProvisionalPrice is what the winner would pay if the sale closed right now:
782// max(second bid, reserve, MinBid), or 0 if nothing is opened yet or the
783// highest opening is below the reserve.
784func ProvisionalPrice(saleID int64) int64 {
785 s := mustSale(saleID)
786 if s.status == SaleSettled {
787 return s.price
788 }
789 h1, h2, winner, _ := clearing(s)
790 if winner == "" || h1 < s.reserve {
791 return 0
792 }
793 return vickreyPrice(h2, s.reserve)
794}
795
796// CommitmentOf returns the bidder's stored commitment on a sale ("" if none).
797func CommitmentOf(saleID int64, bidder address) string {
798 if b := findBid(mustSale(saleID), bidder); b != nil {
799 return b.commitment
800 }
801 return ""
802}
803
804// DepositOf returns the bidder's escrowed deposit on a sale (0 if none).
805func DepositOf(saleID int64, bidder address) int64 {
806 if b := findBid(mustSale(saleID), bidder); b != nil {
807 return b.deposit
808 }
809 return 0
810}
811
812// HasRevealed reports whether the bidder has opened their bid on a sale.
813func HasRevealed(saleID int64, bidder address) bool {
814 b := findBid(mustSale(saleID), bidder)
815 return b != nil && b.opened
816}
817
818// RevealedAmountOf returns the bidder's OPENED bid amount (0 while sealed). A
819// revealed bid is public by construction — it travelled in a transaction.
820func RevealedAmountOf(saleID int64, bidder address) int64 {
821 b := findBid(mustSale(saleID), bidder)
822 if b == nil || !b.opened {
823 return 0
824 }
825 return b.amount
826}
827
828// ForfeitedOf is the total deposit liquidated to the seller on a closed sale
829// (0 before close). A cumulative statistic, not a held balance.
830func ForfeitedOf(saleID int64) int64 { return mustSale(saleID).forfeited }
831
832// BidsOn is how many commitments a sale holds; RevealedOn how many are opened.
833func BidsOn(saleID int64) int64 { return int64(len(mustSale(saleID).bids)) }
834func RevealedOn(saleID int64) int64 { return mustSale(saleID).revealCount }
835
836// BlocksLeft is how many blocks remain in the sale's current window (0 once
837// the reveal window has shut or the sale is terminal).
838func BlocksLeft(saleID int64) int64 {
839 s := mustSale(saleID)
840 if s.status != SaleOpen {
841 return 0
842 }
843 now := runtime.ChainHeight()
844 target := s.revealEnd
845 if now < s.commitEnd {
846 target = s.commitEnd
847 }
848 if left := target - now; left > 0 {
849 return left
850 }
851 return 0
852}
853
854// IsCloseable reports whether Close would succeed right now.
855func IsCloseable(saleID int64) bool {
856 s := mustSale(saleID)
857 return s.status == SaleOpen && runtime.ChainHeight() >= s.revealEnd
858}
859
860func HasSale(saleID int64) bool { return sales.Get(padID(saleID)) != nil }
861
862func SalesOf(seller address) int64 {
863 v := sellerNum.Get(seller.String())
864 if v == nil {
865 return 0
866 }
867 return *v.(*int64)
868}
869
870func Admin() address { return admin }
871func PendingAdmin() address { return pendingAdmin }
872func FeeRecipient() address { return feeRecipient }
873func FeeBps() int64 { return feeBps }
874func NumSales() int64 { return nextID }
875func ClaimableOf(a address) int64 { return ledger.BalanceOf(a.String()) }
876func FeesAccrued() int64 { return ledger.FeesAccrued() }
877func Liabilities() int64 { return ledger.Liabilities() }
878func Sealed() int64 { return sealed }
879func Revealed() int64 { return revealed }
880func ForfeitedTotal() int64 { return forfeitedTotal }
881func Held() int64 { return coinio.HeldAt(self, Denom) }
882func Address() address { return self }
883func Height() int64 { return runtime.ChainHeight() }
884
885// --- internals ---
886
887// phaseOf derives the sale's phase from the chain height. Nothing stores it,
888// so it cannot disagree with the guards in Commit, Reveal and Close.
889func phaseOf(s *sale) string {
890 if s.status != SaleOpen {
891 return s.status
892 }
893 now := runtime.ChainHeight()
894 switch {
895 case now < s.commitEnd:
896 return PhaseCommit
897 case now < s.revealEnd:
898 return PhaseReveal
899 default:
900 return PhaseCloseable
901 }
902}
903
904// clearing scans the opened bids of a sale for the highest and second-highest
905// amounts and the winner. It is a pure read over a slice bounded by
906// MaxBiddersPerSale.
907//
908// The result is INDEPENDENT OF STORAGE ORDER, which matters because `bids` is
909// in commit order while the tie-break is on reveal order. A tie on the highest
910// amount resolves to the earliest revealSeq and leaves h2 == h1, so the tied
911// winner pays their full bid rather than profiting from the collision.
912func clearing(s *sale) (h1, h2 int64, winner address, winnerSeq int64) {
913 winner = ""
914 for _, b := range s.bids {
915 if !b.opened {
916 continue
917 }
918 better := b.amount > h1 || (b.amount == h1 && winner != "" && b.revealSeq < winnerSeq)
919 if better {
920 h2 = h1
921 h1 = b.amount
922 winner = b.bidder
923 winnerSeq = b.revealSeq
924 continue
925 }
926 if b.amount > h2 {
927 h2 = b.amount
928 }
929 }
930 return h1, h2, winner, winnerSeq
931}
932
933// vickreyPrice is the clearing price: the second bid, floored by the reserve
934// and by the dust floor. Each of the three is <= the winning bid in the branch
935// that uses it — h2 by definition, the reserve because a lower high bid is
936// Unsold, and MinBid because Reveal rejects anything under it — so the
937// winner's change is never negative.
938func vickreyPrice(h2, reserve int64) int64 {
939 p := h2
940 if reserve > p {
941 p = reserve
942 }
943 if MinBid > p {
944 p = MinBid
945 }
946 return p
947}
948
949func findBid(s *sale, bidder address) *bid {
950 for _, b := range s.bids {
951 if b.bidder == bidder {
952 return b
953 }
954 }
955 return nil
956}
957
958// assertCommitment accepts exactly CommitmentLen LOWERCASE hex characters.
959// Uppercase is rejected rather than normalised: CommitHash emits lowercase, so
960// an uppercase commitment could never be opened, and refusing it at Commit
961// turns a silent total loss of the deposit into an immediate error.
962func assertCommitment(c string) {
963 if len(c) != CommitmentLen {
964 panic("commitment must be " + itoa(CommitmentLen) + " lowercase hex characters")
965 }
966 for i := 0; i < len(c); i++ {
967 ch := c[i]
968 if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') {
969 continue
970 }
971 panic("commitment must be lowercase hex; got an invalid character")
972 }
973}
974
975func mustSale(id int64) *sale {
976 v := sales.Get(padID(id))
977 if v == nil {
978 panic("unknown sale " + itoa(id))
979 }
980 return v.(*sale)
981}
982
983func assertNoSend() {
984 if !unsafe.OriginSend().IsZero() {
985 panic("this function does not accept coins")
986 }
987}
988
989func counter(tree *avl.Tree, a address) *int64 {
990 k := a.String()
991 if v := tree.Get(k); v != nil {
992 return v.(*int64)
993 }
994 n := int64(0)
995 tree.Set(k, &n)
996 return &n
997}
998
999func addChecked(a, b int64) (int64, bool) {
1000 s := a + b
1001 if (b > 0 && s < a) || (b < 0 && s > a) {
1002 return 0, false
1003 }
1004 return s, true
1005}
1006
1007func padID(id int64) string {
1008 s := strconv.FormatInt(id, 10)
1009 for len(s) < 12 {
1010 s = "0" + s
1011 }
1012 return s
1013}
1014
1015func itoa(n int64) string { return strconv.FormatInt(n, 10) }
1016
1017func hasPrefix(s, p string) bool { return len(s) >= len(p) && s[:len(p)] == p }
1018
1019// --- Render ---
1020
1021func Render(path string) string {
1022 switch {
1023 case path == "":
1024 return renderHome()
1025 case hasPrefix(path, "sale/"):
1026 return renderSale(path[len("sale/"):])
1027 default:
1028 return "unknown path; try \"\" or \"sale/<id>\"\n"
1029 }
1030}
1031
1032func renderHome() string {
1033 out := "# sealedbid — sealed-bid second-price (Vickrey) auctions in ugnot\n\n"
1034 out += "Sales: " + itoa(nextID) + " · sealed deposits: " + itoa(sealed) + Denom +
1035 " · revealed deposits: " + itoa(revealed) + Denom +
1036 " · claimable+fees held: " + itoa(ledger.Liabilities()) + Denom +
1037 " · fees accrued: " + itoa(ledger.FeesAccrued()) + Denom +
1038 " · forfeited to date: " + itoa(forfeitedTotal) + Denom + "\n\n"
1039 out += "Commit a hash of your bid and escrow a deposit; open it after the commit window shuts. " +
1040 "The highest opened bid wins and pays the SECOND highest price. " +
1041 "A commitment never opened is void and its deposit goes to the seller.\n\n"
1042 out += "| id | seller | lot | reserve | deposit from | phase | bids | opened | price |\n" +
1043 "|---|---|---|---|---|---|---|---|---|\n"
1044 shown := int64(0)
1045 for id := nextID - 1; id >= 0 && shown < RenderLimit; id-- {
1046 v := sales.Get(padID(id))
1047 if v == nil {
1048 continue
1049 }
1050 s := v.(*sale)
1051 out += "| " + itoa(s.id) + " | " + sanitize.TableCell(s.seller.String()) + " | " +
1052 sanitize.TableCell(s.lot) + " | " + itoa(s.reserve) + Denom + " | " +
1053 itoa(s.minDeposit) + Denom + " | " +
1054 phaseOf(s) + " | " + itoa(int64(len(s.bids))) + " | " + itoa(s.revealCount) + " | " +
1055 priceCell(s) + " |\n"
1056 shown++
1057 }
1058 if nextID > shown {
1059 out += "\n(" + itoa(shown) + " most recent of " + itoa(nextID) + ")\n"
1060 }
1061 return out
1062}
1063
1064func renderSale(idStr string) string {
1065 id, err := strconv.ParseInt(idStr, 10, 64)
1066 if err != nil {
1067 return "bad sale id\n"
1068 }
1069 v := sales.Get(padID(id))
1070 if v == nil {
1071 return "unknown sale " + idStr + "\n"
1072 }
1073 s := v.(*sale)
1074 out := "# sale " + itoa(s.id) + "\n\n"
1075 out += "- lot: " + sanitize.InlineText(s.lot) + "\n"
1076 // Every stored string reaching markdown is sanitized, addresses included.
1077 // A bech32 address carries no markdown metacharacter, so this is not an
1078 // escape that is load-bearing today; it is here because renderHome already
1079 // routed this same field through sanitize.TableCell and this renderer did
1080 // not, and two renderers disagreeing about whether a field needs escaping
1081 // is how the next field gets the wrong answer. Audit Y2.
1082 out += "- seller: " + sanitize.InlineText(s.seller.String()) + "\n"
1083 out += "- reserve: " + itoa(s.reserve) + Denom +
1084 " · deposit from " + itoa(s.minDeposit) + Denom +
1085 " · fee: " + itoa(s.feeBps) + " bps\n"
1086 out += "- phase: " + phaseOf(s) + " · status: " + s.status +
1087 " · created at height " + itoa(s.createdHeight) + "\n"
1088 out += "- commit window ends at height " + itoa(s.commitEnd) +
1089 " · reveal window ends at height " + itoa(s.revealEnd) + "\n"
1090 out += "- commitments: " + itoa(int64(len(s.bids))) + " · opened: " + itoa(s.revealCount) + "\n"
1091 if s.status == SaleOpen {
1092 out += "- blocks left in this window: " + itoa(BlocksLeft(s.id)) + "\n"
1093 if s.revealCount > 0 {
1094 _, h2, _, _ := clearing(s)
1095 out += "- provisional second price: " + itoa(ProvisionalPrice(s.id)) + Denom +
1096 " (second bid " + itoa(h2) + Denom + ")\n"
1097 }
1098 }
1099 if s.status == SaleSettled {
1100 h1, h2, _, _ := clearing(s)
1101 out += "- winner: " + sanitize.InlineText(s.winner.String()) + " bid " + itoa(h1) + Denom +
1102 " and paid " + itoa(s.price) + Denom + " (second bid " + itoa(h2) + Denom + ")\n"
1103 }
1104 if s.status == SaleUnsold {
1105 h1, _, _, _ := clearing(s)
1106 out += "- unsold: highest opened bid " + itoa(h1) + Denom +
1107 " against reserve " + itoa(s.reserve) + Denom + "\n"
1108 }
1109 if s.forfeited > 0 {
1110 out += "- forfeited to the seller: " + itoa(s.forfeited) + Denom + "\n"
1111 }
1112 return out
1113}
1114
1115func priceCell(s *sale) string {
1116 if s.status == SaleSettled {
1117 return itoa(s.price) + Denom
1118 }
1119 return "—"
1120}