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.

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}