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.

speed.gno

141.64 Kb · 2732 lines
   1// Package speed is the SPLASH HOURLY ticket draw: a draw every hour instead of
   2// every week.
   3//
   4// WHY IT IS A SEPARATE REALM AND NOT A PARAMETER ON r/splash/arcade/raffle. The
   5// weekly raffle's cadence is woven into eight constants, four build guards and the
   6// refund grace window, and every one of them had to be re-derived from first
   7// principles here (see "the two clocks" below). A shared realm with a cadence dial
   8// would have had to satisfy BOTH sets of constraints simultaneously, which is how a
   9// margin gets shaved: the hourly window's pin offset is 2400 rounds and the weekly
  10// one's is 230400, and no single guard can assert both. Two realms, each with its own
  11// derivation and its own asserted margins, is the honest shape. The mechanics below
  12// are the raffle's, deliberately: the ticket index, the Commit/Capture/Resolve
  13// three-phase cycle, the FROZEN-TOTAL property, the one-way refund exit, and the
  14// pin-at-record-creation rule. Read r/splash/arcade/raffle/AUDIT-2026-09-28.md before
  15// changing any of them; this file's job was to re-scale the timing without
  16// re-introducing one of its fourteen findings.
  17//
  18// ==========================================================================
  19// V1 IS SELF-CONTAINED: THERE IS NO SPONSORSHIP AND THERE IS NO FundPot.
  20// ==========================================================================
  21//
  22// The pot is ENTRY MONEY ONLY. Nothing outside this realm can put a coin into a
  23// window's pot, and this realm calls no other realm's code except r/gnoland/wugnot
  24// (to move money) and r/splash/beacon (to read a round). There is no cross-realm feed
  25// in either direction, on purpose, in both directions:
  26//
  27//   - NOTHING FEEDS THIS POT. r/splash/arcade/duel's rake goes to the weekly raffle
  28//     and stays there. Adding a second consumer of that rake would make the duel's
  29//     settlement depend on two realms' bookkeeping instead of one, and the duel's
  30//     Collect depending on the raffle's bookkeeping was AUDIT-2026-09-28.md finding
  31//     14 — the worst liveness defect of the day, in which any raffle panic trapped a
  32//     resolved duel's whole pool in both realms. It was severed that evening (Collect
  33//     books, FlushPot delivers) and this realm does not reinstate the shape.
  34//   - THIS REALM FEEDS NOTHING. No rake, no share, no callback. Its only outbound
  35//     transfers are wugnot to a winner's wallet, wugnot to a refunding buyer's wallet,
  36//     and wugnot to opsVaultAddr.
  37//
  38// AND THE PROPERTY THAT MAKES THE ABSENCE OF SPONSORSHIP A SECURITY FEATURE RATHER
  39// THAN A MISSING ONE — THE SOLE-ENTRANT PROPERTY:
  40//
  41//	WITH NO HOUSE MONEY IN THE POT, A SOLE ENTRANT MERELY WINS BACK THEIR OWN STAKE
  42//	MINUS THE HOUSE CUT. The weekly raffle still has the sole-entrant attack, and it
  43//	is a real one there: the duel's rake is sponsored into the live week's pot, so
  44//	somebody who buys one ticket into a quiet week with a large sponsored balance
  45//	collects the sponsorship for the price of the fee on that ticket. Here Pot is
  46//	exactly sum(spendOf) by construction, so a sole entrant's pot IS their own
  47//	post-cut spend: they pay 100,000 ugnot, and if they are the only entrant they win
  48//	95,000 back. THERE IS NO POSITIVE-EXPECTATION SOLO PLAY, so the attack cannot
  49//	exist — not mitigated, not made expensive, ABSENT, as an arithmetic consequence
  50//	of the pot's definition.
  51//
  52//	(A SOLE ENTRANT IS NO LONGER INDIFFERENT BETWEEN Resolve AND Refund, AND THIS
  53//	PARAGRAPH SAID THEY WERE. It read: "The same arithmetic makes a sole entrant
  54//	indifferent between Resolve and Refund: both return exactly their own 95,000."
  55//	Since 2026-09-29 the fee is ESCROWED per window and earned only by a draw
  56//	(AUDIT-2026-09-28.md finding 25), so a refund returns the whole 100,000 and a
  57//	resolve returns 95,000: THE SOLE ENTRANT STRICTLY PREFERS THE DRAW TO FAIL, by
  58//	exactly the fee. The conclusion this paragraph was supporting is unaffected —
  59//	solo play is still never positive-expectation, which is what kills the
  60//	sole-entrant attack — but the incentive it describes is now the general one the
  61//	owner is weighing: with a refund at 100% of stake, every non-winner prefers a
  62//	failed window to a drawn one. That is a product decision and is deliberately NOT
  63//	fixed here; it is recorded in AUDIT-2026-09-28.md so that the next reader of this
  64//	paragraph does not mistake it for a property of this realm's design. The refund
  65//	grace window below is therefore not only a multi-entrant concern any more.)
  66//
  67// SO DO NOT ADD FundPot, A SPONSORED FIELD, A ROLLOVER OR A JACKPOT WITHOUT
  68// RE-AUDITING THIS PARAGRAPH. Every one of them puts money in the pot that belongs to
  69// no entrant, which (a) resurrects the sole-entrant attack, (b) turns the invariant
  70// Pot == sum(spendOf) into Pot == sum(spendOf) + Sponsored with everything that
  71// follows from it (see the raffle's Draw.Sponsored, Refund's sponsorship transition
  72// and SweepDrawlessWeek — three separate mechanisms that exist only because house
  73// money belongs to nobody), and (c) makes this realm's liveness someone else's
  74// problem again.
  75//
  76// ==========================================================================
  77// THE ONE-HOUR CADENCE, AND THE TWO CLOCKS
  78// ==========================================================================
  79//
  80// Every duration in the weekly raffle shrinks by roughly 168x here, and THE FAILURE
  81// MODE OF GETTING ONE WRONG IS SILENT — it is not a panic, it is a draw whose winning
  82// randomness was public on api.drand.sh before entries closed (finding 10). So each
  83// constant below is derived in place, and each is asserted by a build guard that obeys
  84// the doctrine that finding 11 cost three attempts to learn:
  85//
  86//	A GUARD THAT PROTECTS A REAL-WORLD DURATION MUST HAVE SECONDS ON BOTH SIDES,
  87//	SOURCED FROM TWO DIFFERENT CLOCKS, OR IT CANNOT FAIL.
  88//
  89// The two clocks available to a realm on this toolchain, neither of which it can
  90// measure at runtime (there is no block-timestamp getter):
  91//
  92//   - DRAND'S PERIOD: beacon.PeriodSeconds = 3 s, EXACT. A round count multiplied by
  93//     it is real seconds by definition of the beacon.
  94//   - THE CHAIN'S BLOCK TIME: assumed 86400/blocksPerDay = 3.29998 s, written as the
  95//     integer 33/10 in guards because 3.3 is not an integer constant. MEASURED ON LIVE
  96//     gno.land MAINNET ON 2026-09-28: 3.306 s/block over 200,000 blocks and 3.311 s
  97//     over 20,000. So reality is very slightly SLOWER than this file assumes, by about
  98//     0.2%. That is the benign direction for every margin here (a slower chain means
  99//     the beacon's estimate LAGS drand rather than outrunning it, and lagging is
 100//     correctable by any relay of drand's head — see speedMaxStaleBlocks), but the
 101//     guards below are stated against a ±10% band rather than against the measurement,
 102//     because the measurement is of the past.
 103//
 104// Nothing in this realm is a timestamp. A window is a sequence number derived from
 105// chain height; no ticket, spend or draw is dated.
 106package speed
 107
 108import (
 109	"math/bits"
 110	"strconv"
 111
 112	"chain"
 113	"chain/runtime"
 114	"chain/runtime/unsafe"
 115
 116	"gno.land/p/nt/avl/v0"
 117	"gno.land/p/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/fenwick"
 118
 119	"gno.land/r/gnoland/wugnot"
 120	"gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/beacon"
 121)
 122
 123// --- the economics -------------------------------------------------------
 124
 125const (
 126	// TicketPrice is the cost of one ticket in ugnot: 1 GNOT. Money here is wrapped
 127	// GNOT (r/gnoland/wugnot), a GRC20 with 6 decimals, so 1 GNOT is 1_000_000 ugnot
 128	// and every amount in this file is an int64 count of ugnot.
 129	//
 130	// IT IS NO LONGER "THE SAME PRICE AS THE WEEKLY RAFFLE'S", which is what this
 131	// comment said until 2026-09-29. The two are now deliberately different — 1 GNOT
 132	// for an hourly draw, 10 GNOT for a weekly one — and the sentence is corrected
 133	// rather than deleted, because a reader who remembers the old claim should learn
 134	// that it moved instead of finding it silently gone.
 135	//
 136	// EXPORTED because a front end must be able to quote the price without
 137	// hardcoding it. A buyer's cost is tickets * this and nothing else: no dynamic
 138	// pricing, no discount tier, no bonding curve.
 139	TicketPrice int64 = 1_000_000
 140
 141	// THERE IS NO EntryUnitPrice IN THIS REALM AND THERE MUST NOT BE ONE. DO NOT ADD
 142	// IT IN THE NAME OF CONSISTENCY WITH r/splash/arcade/raffle, which grew exactly
 143	// such a constant on 2026-09-29.
 144	//
 145	// HERE ONE TICKET IS ONE UNIT OF WEIGHT, AND THAT IS CORRECT RATHER THAN AN
 146	// OVERSIGHT, because every unit of weight in this realm is BOUGHT. There is no
 147	// FundPot, no rake feed, no FundPotWithEntries and no earned entry of any kind
 148	// (read the package doc — the absence of sponsorship is the simplification this
 149	// realm is built on), so no money ever has to be turned into weight at a
 150	// granularity finer than one ticket.
 151	//
 152	// The raffle needs the second constant because it has a SECOND, INDEPENDENT
 153	// producer of weight: duel play, priced off a rake far smaller than a ticket. One
 154	// constant serving both jobs is what silently deleted that feature for three of
 155	// the duel's four rungs when the ticket price rose — AUDIT-2026-09-28.md finding
 156	// 22. That pressure does not exist here, and inventing the constant anyway would
 157	// buy a divisibility build guard, a units-per-ticket conversion and a second
 158	// number to keep in step, all to express `units = tickets`.
 159
 160	// houseCutBps is the share of every ticket sale that is the house's FEE rather
 161	// than prize money: 500 bps, 5%. Same as the raffle's and the duel's.
 162	//
 163	// IT IS ESCROWED PER WINDOW AND EARNED ONLY BY A DRAW, since 2026-09-29, exactly
 164	// as in the raffle: Buy credits Draw.Cut and not opsPot, Resolve moves it, and a
 165	// window that never draws refunds the FULL ticket price. AUDIT-2026-09-28.md
 166	// finding 25.
 167	houseCutBps int64 = 500
 168	bpsDenom    int64 = 10_000
 169
 170	// cutPerTicket is the house fee on ONE ticket: 50_000 ugnot. It exists for the
 171	// build guard below, not for the arithmetic — read the guard.
 172	cutPerTicket = TicketPrice * houseCutBps / bpsDenom
 173)
 174
 175// GUARD 1 (DESIGN.md section 4 numbers all seven). A NEGATIVE ARRAY LENGTH IS A COMPILE
 176// ERROR, so this line fails the BUILD if a
 177// future edit pushes the house cut to or past the denominator — the case in which
 178// the prize remainder is zero or negative and tickets are sold into a pot that can
 179// never hold anything.
 180//
 181// THE `- 1` IS NOT DECORATION. Without it the guard admits houseCutBps == bpsDenom,
 182// which compiles to a length of ZERO and is therefore SATISFIED, while the house
 183// takes 100% of every sale and Buy credits the prize pot with nothing at all. That
 184// exact omission shipped in the raffle and is AUDIT-2026-09-28.md finding 11's
 185// second half; the cost of the character is nothing.
 186var _ [bpsDenom - houseCutBps - 1]struct{}
 187
 188// GUARD 1b — THE HOUSE CUT MUST BE EXACT ON A SINGLE TICKET. Length 0 when
 189// TicketPrice * houseCutBps divides bpsDenom exactly, NEGATIVE — a compile error —
 190// otherwise.
 191//
 192// IT IS THE GUARD THE ESCROWED FEE'S REFUND PATH RESTS ON, and it is a verbatim
 193// transcription of the raffle's, for the same reason. Buy floors the cut over a whole
 194// call's cost and books it to Draw.Cut; Refund re-derives one buyer's share of that Cut
 195// by flooring over their accumulated GROSS. The two agree for every possible sequence
 196// of purchases only if the flooring never discards anything, because
 197// floor(a/n) + floor(b/n) can be one LESS than floor((a+b)/n). With a remainder, two
 198// separate one-ticket buys would book less Cut than a single refund re-derives,
 199// Draw.Cut would go NEGATIVE and checkInvariant would abort the refund of a buyer whose
 200// own money sits in this realm — a "money in, no way out" path manufactured by a
 201// rounding error. With the guard, cut == tickets * cutPerTicket for every call and every
 202// partial sum. AUDIT-2026-09-28.md finding 25.
 203var _ [cutPerTicket*bpsDenom - TicketPrice*houseCutBps]struct{}
 204
 205// --- the ops destination -------------------------------------------------
 206//
 207// opsVaultAddr — gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/ops. THE ONE PLACE THE HOUSE'S MONEY CAN GO,
 208// fixed at compile time, and a VERBATIM DUPLICATE of r/splash/arcade/raffle's,
 209// r/splash/arcade/duel's and r/splash/exchange's constant of the same name.
 210//
 211// IT IS DUPLICATED RATHER THAN IMPORTED for the reason those three state: a realm's
 212// address is a pure function of its package path (gnolang.DerivePkgCryptoAddr), the
 213// sibling realms' constants are unexported, and this is a different package. The
 214// realm at that path need not exist yet for the address to be right.
 215//
 216// tools/speed_addr_test.go re-derives it from the package path on every
 217// `go test ./tools`, fails on a mismatch, fails on any address constant in this file
 218// it has not been told about, and pins it to the raffle's and the duel's copies. A
 219// Gno-side test cannot be that guard: an assertion that WithdrawOps' transfer landed
 220// at opsVaultAddr is true for ANY value of opsVaultAddr, including a wrong one, so a
 221// passing suite would pin the defect rather than catch it. That is exactly how
 222// r/splash/exchange's R3 finding (a label hash shipped as a live destination)
 223// happened.
 224//
 225// BEING A COMPILE-TIME CONSTANT IS ALSO THE WHOLE AUTHORISATION ARGUMENT FOR
 226// WithdrawOps BEING PERMISSIONLESS — see there.
 227//
 228// KNOWN AND NAMED, NOT DISCOVERED: gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/ops DOES NOT EXIST YET. This is
 229// AUDIT-2026-09-28.md's fourth unfixed defect (findings 11-14 preamble) and it is
 230// still blocking for deployment — of this realm as much as of the other three. Until
 231// a realm with a withdrawal path is deployed at that package path, WithdrawOps
 232// pushes wugnot to a package-derived address with no code at it, which is money out
 233// of this realm and into one nobody can spend from. THIS REALM DOES NOT MAKE THAT
 234// WORSE (it adds a fourth stream to a destination that already has three) AND IT
 235// MUST NOT BE READ AS ACCEPTING IT: the owner's address is the missing input.
 236const opsVaultAddr address = "g1dqnxfp5zj89ggfcfzvampmhmrkjq36d2zvdyk7"
 237
 238// --- the chain's clock ---------------------------------------------------
 239
 240// blocksPerDay is the block-count day unit, copied verbatim from r/splash/game,
 241// r/splash/arcade/raffle, r/splash/arcade/duel and r/splash/beacon, all of which
 242// use the same figure: this toolchain exposes no block-timestamp getter, so the
 243// only clock a realm can read is chain height, and 26182 blocks is one day at the
 244// assumed 3.29998 s a block.
 245//
 246// IT IS AN ASSUMPTION AND IT IS DEPLOYMENT-BLOCKING, and that is the beacon's
 247// finding-12 residual, inherited here unchanged: if the chain's average block time
 248// settles persistently BELOW 3.29998 s, beacon.EstimatedLatestRound's refusal
 249// becomes permanent and no new window can ever be opened in this realm. Already-open
 250// windows still resolve or refund on their own clocks and WithdrawOps still runs, so
 251// no money is trapped; new business stops. Measured on live mainnet 2026-09-28 at
 252// 3.306-3.311 s, i.e. on the SAFE side of that threshold by ~0.2%, which is not much
 253// margin — see beacon.maxEstimateStaleBlocks.
 254const blocksPerDay = 26182
 255
 256// windowBlocks is ONE DRAW WINDOW in blocks: one hour.
 257//
 258// THE DERIVATION. blocksPerDay / 24 = 26182/24 = 1090.9167, and a block count must
 259// be an integer, so the integer division truncates to 1090 blocks. At the assumed
 260// 3.29998 s a block that is 3596.98 real seconds — 3.02 s short of an hour, an error
 261// of 0.08%, which is two orders of magnitude smaller than the ±10% block-time band
 262// every margin below is stated against. Rounding UP to 1091 (3600.28 s) would be
 263// equally defensible; truncation is chosen because `blocksPerDay / 24` is a
 264// derivation a reader can check without a calculator, and because a slightly SHORT
 265// window is the safe direction for the pin guard (the pin must outlast the window).
 266//
 267// THERE IS NO INTERMEDIATE DAY UNIT AND NO windowHours DIAL. The raffle has
 268// currentDay() and raffleWeekDays because a week is naturally seven of something;
 269// an hour is not naturally n of anything shorter that this chain can measure, so
 270// this constant is the single dial and currentWindow() divides by it directly.
 271const windowBlocks = blocksPerDay / 24
 272
 273// GUARDS 2 AND 3 — THE WINDOW IS AN HOUR, TO WITHIN 1%, AND THESE TWO LINES FAIL THE
 274// BUILD IF IT STOPS
 275// BEING ONE. A negative array length is a compile error.
 276//
 277// SECONDS ON BOTH SIDES, FROM TWO DIFFERENT CLOCKS, as the doctrine requires:
 278//
 279//	LEFT/RIGHT — the window's real duration via the CHAIN's clock:
 280//	             windowBlocks * 33/10 = 1090 * 3.3 = 3597 s
 281//	THE OTHER  — 3600 seconds, which is what an hour IS. Not a constant of this file,
 282//	             not derived from blocksPerDay, and impossible to cancel against
 283//	             anything on the left: it is the wall clock.
 284//
 285// The band is ±1% (3564 s .. 3636 s). 3597 sits 33 s above the floor and 39 s below
 286// the ceiling, so either guard fails on a ~1% move in blocksPerDay — which is the
 287// point. If the owner re-measures the block time and blocksPerDay moves by more than
 288// a percent, the build stops and every derivation below has to be re-read rather than
 289// silently rescaled. That is the whole reason a "window length" guard exists at all:
 290// an hourly draw whose window is quietly 40 minutes is not a rounding error, it is a
 291// different product with a pin offset sized for a different one.
 292var _ [windowBlocks*33/10 - 3564]struct{}
 293var _ [3636 - windowBlocks*33/10]struct{}
 294
 295// randomnessHexLen is the length of a beacon randomness string: sha256, hex-encoded,
 296// 64 characters.
 297const randomnessHexLen = 64
 298
 299// --- the beacon's cadence ------------------------------------------------
 300
 301// roundsPerWindow is one draw window in DRAND ROUNDS: 28800/24 = 1200 rounds an hour
 302// at drand evmnet's three-second period.
 303//
 304// IT IS TAKEN FROM THE BEACON AND IS NOT A NUMBER TYPED INTO THIS FILE, and that is
 305// the single most important line of the migration this realm inherits. The weekly
 306// raffle carried `const beaconRoundsPerDay = 288` — the cadence of the beacon SPLASH
 307// itself used to run, one round every five minutes — and when r/splash/beacon
 308// switched to drand evmnet (period THREE seconds) every round-denominated constant
 309// derived from it was wrong by a factor of 100 the moment the key changed, silently
 310// re-opening finding 10. Reading the cadence from beacon.RoundsPerDay means the two
 311// can never disagree again. See AUDIT-2026-09-28.md section A1.
 312const roundsPerWindow = beacon.RoundsPerDay / 24
 313
 314// --- pinning the window's beacon round -----------------------------------
 315//
 316// THE ROUND A WINDOW IS DRAWN ON IS FIXED WHEN THE WINDOW'S RECORD IS CREATED — by
 317// its first ticket sale — AND NEVER WHEN IT IS COMMITTED. getOrCreateDraw writes
 318// Draw.Round exactly once and nothing else in this file ever writes it again.
 319//
 320// WHY, IN ONE PARAGRAPH, BECAUSE THE FULL ACCOUNT IS AUDIT-2026-09-28.md FINDING 10
 321// AND IT IS THE WORST DEFECT OF THAT DAY. If the committer chose the round — the
 322// raffle bound Round = beacon.Latest() + 1 inside Commit — then the round was chosen
 323// AFTER the ticket set was already frozen and public, so anybody who could produce or
 324// foresee a signature could enumerate candidate rounds off-chain, compute
 325// ownerOfUnit(d, randomness(r) mod FrozenTotal) for each, and steer the draw onto one
 326// that named them. Pinning at record creation removes the choice entirely: at the
 327// instant the number is written there is no ticket set for it to be advantageous
 328// against, and Commit has nothing to compute.
 329//
 330// THE PROPERTY THE OFFSET MUST HAVE, AND IT IS A REAL-TIME PROPERTY:
 331//
 332//	THE PINNED ROUND MUST BE SIGNED AFTER ENTRIES CLOSE. If it is signed before,
 333//	the last buyer of the window can read its randomness off api.drand.sh, compute
 334//	the winning unit for every candidate purchase size, and buy exactly enough
 335//	tickets to land on a unit they own. That is not a theoretical attack against
 336//	drand: drand's randomness is PUBLIC the moment it is signed, which makes this
 337//	strictly easier than the version finding 10 describes (there it needed the
 338//	beacon's key).
 339//
 340//	THE WORST CASE IS A WINDOW WHOSE RECORD IS CREATED IN ITS FIRST BLOCK: the whole
 341//	window still has to elapse before entries close, so the offset must exceed ONE
 342//	WHOLE WINDOW of real time. That is 3597 s at the nominal block time, and more
 343//	when the chain is slow, because a window is a BLOCK count while the offset is a
 344//	ROUND count and drand rounds are real time by definition.
 345//
 346// THE DERIVATION OF THE MARGIN, WHICH IS ONE WHOLE WINDOW AND NOT A FRACTION OF ONE.
 347// The raffle's margin is one day on a seven-day week — 1/7 — and copying that ratio
 348// here would give ~8.5 minutes, which is NOT ENOUGH, and the reason is the term that
 349// does not shrink with the cadence: the beacon's own admitted extrapolation error.
 350// beacon.EstimatedLatestRound is a line fitted at the assumed block time, so its
 351// error is |A - B| seconds of drand time per block since its anchor was last raised —
 352// 0.33 s a block at the edges of the ±10% band — and over the beacon's default
 353// backstop of one day of blocks that is 8640 s, 2.4 HOURS. That single term is 2.4
 354// times a whole hourly window. So:
 355//
 356//	EITHER the pin offset is several hours (and the game stops being hourly, because
 357//	Resolve cannot run until the pinned round is signed),
 358//	OR the staleness tolerance is tightened far below the beacon's backstop so that
 359//	the error term shrinks to fit inside an hour-scale margin.
 360//
 361// THE SECOND, and that is what speedMaxStaleBlocks below is. It is the reason
 362// beacon.EstimatedLatestRoundWithin exists ("a consumer can tighten and cannot
 363// loosen"), and r/splash/arcade/duel already uses it at the other extreme of the
 364// scale (450 blocks, for a 300-second bind margin).
 365//
 366// SO: ONE WINDOW FOR THE WINDOW, PLUS ONE WINDOW OF MARGIN.
 367//
 368//	pinOffsetWindows = 2
 369//	pinOffsetRounds  = 2 * 1200 = 2400 rounds = 7200 seconds EXACTLY (drand's clock)
 370//
 371//	MARGIN AT THE NOMINAL BLOCK TIME  = 7200 - 3597          = 3603 s  (1.00 h)
 372//	MARGIN AT THE SLOW EDGE (3.63 s)  = 7200 - 3956          = 3244 s  (0.90 h)
 373//	LESS THE BEACON'S ADMITTED ERROR  = 3244 - 719           = 2525 s  (0.70 h)
 374//
 375// and the last line is asserted at compile time by guard 5 below. A 42-minute
 376// margin on a one-hour window, in the worst admitted combination of a 10% block-time
 377// drift and a maximally stale beacon.
 378//
 379// WHAT THE MARGIN COSTS, STATED BECAUSE IT IS NOT FREE: a window pinned in its LAST
 380// block has its round signed pinOffsetRounds = 2 hours after it closes, so Resolve may
 381// have to wait that long after Commit. That wait is the bounded part of the honest
 382// cycle, and the refund grace window has to outlast it — see refundGraceBlocks, where
 383// this constant reappears.
 384//
 385// WHY A ROUND NUMBER AND NOT A HEIGHT: rounds are a beacon counter with no on-chain
 386// mapping to chain height, so the only anchor a realm has is what the beacon reports
 387// at pin time. That is also why pinning requires the beacon to have published at least
 388// once — see getOrCreateDraw.
 389const (
 390	// pinOffsetWindows is how many whole windows past the pin the round sits: the
 391	// window itself, plus one window of margin. THE ONE DIAL — raising it lengthens
 392	// the honest cycle and therefore feeds refundGraceBlocks.
 393	pinOffsetWindows = 2
 394
 395	// pinOffsetRounds is that in beacon rounds: 2 * 1200 = 2400.
 396	pinOffsetRounds = roundsPerWindow * pinOffsetWindows
 397)
 398
 399// GUARD 4 (DESIGN.md section 4's numbering) — THE PINNED ROUND MUST OUTLAST THE WINDOW'S
 400// ENTRIES, AT THE NOMINAL BLOCK
 401// TIME. This line fails the BUILD if it ever stops doing so.
 402//
 403// READ THE UNITS, BECAUSE THE UNITS ARE THE WHOLE POINT. The guard this replaces in
 404// the raffle's history was
 405//
 406//	var _ [pinOffsetRounds - raffleWeekDays*beaconRoundsPerDay - 1]struct{}
 407//
 408// which is ROUNDS on the left and ROUNDS on the right with the cadence on both sides,
 409// CANCELLING: set the cadence to 288, to 28800 or to 3 and it is equally satisfied
 410// while the real-time distance it protects changes by two orders of magnitude. It is
 411// the reason the five-minute-to-three-second switch could have shipped with finding 10
 412// wide open. An assertion that cannot fail is not an assertion.
 413//
 414// THIS ONE COMPARES SECONDS TO SECONDS FROM TWO DIFFERENT CLOCKS:
 415//
 416//	LEFT  — the pinned round's distance in real seconds, via DRAND's period:
 417//	        pinOffsetRounds * beacon.PeriodSeconds = 2400 * 3 = 7200 s = 2.000 hours
 418//	RIGHT — one window in real seconds, via the CHAIN's ~3.3 s block time:
 419//	        windowBlocks * 33/10 = 1090 * 3.3 = 3597 s = 0.99916 hours
 420//	MARGIN = 3603 seconds, the one window pinOffsetWindows asks for.
 421//
 422// MUTATION-TESTED, NOT REASONED ABOUT, and the failing build output is recorded in
 423// DESIGN.md section 4: replacing the derived `beacon.RoundsPerDay / 24` with a
 424// hardcoded five-minute-cadence figure (`const roundsPerWindow = 12`, the exact class
 425// of error section A1 is about) makes this length -3526 and the build fails.
 426var _ [pinOffsetRounds*beacon.PeriodSeconds - windowBlocks*33/10 - 1]struct{}
 427
 428// GUARD 5 — AND THE MARGIN MUST SURVIVE THE WORST ADMITTED COMBINATION: THE WINDOW
 429// MEASURED AT THE SLOW EDGE OF THE BLOCK-TIME BAND, LESS THE BEACON'S WORST ADMITTED
 430// EXTRAPOLATION ERROR AT THIS REALM'S OWN STALENESS TOLERANCE.
 431//
 432// Guard 4 above compares the pin against a window at the NOMINAL block time and says
 433// nothing about either the window LENGTHENING or the anchor being WRONG. Both terms are
 434// real, and omitting the first of them was AUDIT-2026-09-28.md finding 13's "term nobody
 435// had written down".
 436//
 437// THREE TERMS, THREE CLOCKS, NO CONSTANT ON BOTH SIDES:
 438//
 439//	LEFT   = pinOffsetRounds * beacon.PeriodSeconds        = 7200 s   (drand's clock, exact)
 440//	MINUS    windowBlocks * 363/100                        = 3956 s   (the chain's, slow edge
 441//	                                                                   of the ±10% band: 3.63 s)
 442//	MINUS    speedMaxStaleBlocks * 33/100                  =  719 s   (the beacon's admitted
 443//	                                                                   error: 0.33 s a block over
 444//	                                                                   this realm's own tolerance)
 445//	MARGIN                                                 = 2524 s   = 42 minutes
 446//
 447// THE WINDOW LENGTHENS AND THE PIN DOES NOT, which is the second term's whole reason:
 448// a window is a BLOCK count (windowBlocks, read through currentWindow) while
 449// pinOffsetRounds is a ROUND count, and drand's rounds are real time by definition. So
 450// at a block time B the margin is 7200 - 1090*B seconds:
 451//
 452//	B = 3.29998 (nominal)   margin = 3603 s = 60.0 min
 453//	B = 3.63    (10% slow)  margin = 3244 s = 54.1 min   — before any staleness at all
 454//	B = 6.606   (2x slow)   margin = 0      — GONE
 455//
 456// so this realm's finding-10 defence rests on a 100% tolerance in blocksPerDay, where
 457// the weekly raffle's rests on 14.3%. THAT IS THE ONE PLACE THE HOURLY CADENCE IS
 458// STRUCTURALLY SAFER THAN THE WEEKLY ONE, and it is worth stating: the pin margin is a
 459// whole window rather than 1/7 of one, so a block-time error has to be enormous rather
 460// than merely large before it eats the margin. The cost of that ratio is paid in
 461// Resolve's wait, not in safety.
 462//
 463// IT CAN FAIL: tighten nothing and set pinOffsetWindows = 1 and the length goes
 464// negative (3600 - 3956 - 719 - 1 = -1076); loosen speedMaxStaleBlocks to the beacon's
 465// backstop and it goes negative (7200 - 3956 - 8640 - 1 = -5397); lengthen windowBlocks
 466// and it goes negative. Each of those is a real design mistake and each stops the build.
 467var _ [pinOffsetRounds*beacon.PeriodSeconds - windowBlocks*363/100 - speedMaxStaleBlocks*33/100 - 1]struct{}
 468
 469// --- the beacon's staleness tolerance ------------------------------------
 470
 471// speedMaxStaleBlocks is how stale this realm is willing to let r/splash/beacon's
 472// anchor be before it refuses to open a new window: TWO WINDOWS of blocks, 2180, about
 473// two hours.
 474//
 475// IT IS PASSED TO beacon.EstimatedLatestRoundWithin RATHER THAN USING
 476// beacon.EstimatedLatestRound, AND THAT IS NOT A MICRO-OPTIMISATION — THE DEFAULT IS
 477// FAR TOO LOOSE FOR AN HOURLY DRAW. The beacon's backstop is
 478// beacon.MaxEstimateStaleBlocks = blocksPerDay = 26182 blocks, sized for the weekly
 479// raffle, and it admits 26182 * 0.33 = 8640 s = 2.4 HOURS of error in the pin. The
 480// weekly raffle's nominal pin margin is 24 hours, so 2.4 hours fits with room. THIS
 481// realm's whole pin margin is ONE HOUR. Anchoring against the default would admit an
 482// error 2.4x the entire margin, which is finding 10 with the guard satisfied — the
 483// pinned round's randomness public before entries close, and no guard in the file able
 484// to see it, because nothing distinguishes a fresh anchor from a stale one.
 485//
 486// THE DERIVATION, FROM THE MARGIN IT PROTECTS:
 487//
 488//	The error is |A - B| seconds of drand time per block since the anchor was last
 489//	RAISED, where A = 3.29998 s is assumed and B is real — so 0.33 s a block at the
 490//	edges of the stated ±10% band (2.97 s .. 3.63 s; the band is r/splash/beacon's,
 491//	stated there, and 2026-09-28's live measurement of 3.306-3.311 s sits well inside
 492//	it). For S blocks of staleness the error is S * 0.33 seconds.
 493//
 494//	The margin it must fit inside is the slow-edge pin margin, 7200 - 3956 = 3244 s.
 495//	Spending ALL of it would leave zero, so the tolerance is sized to spend about a
 496//	fifth of it:
 497//
 498//	    S = 2 * windowBlocks = 2180 blocks  ->  2180 * 0.33 = 719 s of error
 499//	    remaining margin                    ->  3244 - 719  = 2525 s = 42 minutes
 500//
 501//	i.e. a 4.5x margin over the error admitted, against the raffle's 3x. Guard 5 above
 502//	asserts exactly this relation at compile time.
 503//
 504// WHY TWO WINDOWS AND NOT ONE, OR TEN. It is a LIVENESS price, not a safety one, and
 505// the two directions are not symmetric:
 506//
 507//   - TOO TIGHT and nobody can open a window unless somebody relayed a drand round very
 508//     recently, so the game is unplayable between relays. One relay costs ~17.0M gas
 509//     (~0.57% of a 3e9 block), so this is a real cost somebody has to want to pay.
 510//   - TOO LOOSE and the pin margin goes, which is finding 10.
 511//
 512// Two hours is chosen because it is the same order as the honest cycle it sits beside:
 513// resolving a window requires relaying its pinned round, which is signed 2 hours after
 514// the window opened, so A REALM BEING PLAYED AT ALL PRODUCES A RELAY ROUGHLY EVERY
 515// WINDOW. The tolerance is set to twice that so one skipped hour does not close the
 516// game, and any caller can clear a refusal with a single Publish of drand's current
 517// round.
 518//
 519// AND THE RELAY THAT CLEARS IT IS ALWAYS AVAILABLE IN THE DIRECTION THE CHAIN ACTUALLY
 520// DRIFTS. Raising the beacon's anchor needs a signature for a round ABOVE its current
 521// estimate, which exists precisely when the estimate is BEHIND drand's head — i.e. when
 522// the chain is slower than 3.29998 s a block. Live mainnet measures 3.306-3.311 s, so
 523// that is today's direction and the refusal is clearable. In the other direction it is
 524// not, and that is the beacon's finding-12 residual, recorded at blocksPerDay above.
 525const speedMaxStaleBlocks = windowBlocks * 2
 526
 527// GUARD 6 — THIS REALM MAY ONLY EVER TIGHTEN THE BEACON'S BOUND, NEVER LOOSEN IT, and
 528// this line
 529// fails the BUILD if it stops holding. beacon.EstimatedLatestRoundWithin PANICS on a
 530// tolerance above beacon.MaxEstimateStaleBlocks, so without this guard a raised
 531// speedMaxStaleBlocks would compile and then refuse every single Buy at runtime — a
 532// realm that cannot sell a ticket, discovered on chain rather than at build time.
 533//
 534// BLOCKS ON BOTH SIDES, FROM THE SAME CLOCK, AND THAT IS CORRECT HERE: this guard does
 535// NOT protect a real-world duration, it protects an API PRECONDITION whose own units
 536// are blocks on both sides by definition (beacon.MaxEstimateStaleBlocks is a block
 537// count and the argument is a block count). The seconds-from-two-clocks doctrine
 538// applies to the duration guards — 2, 3, 4, 5 and 7 — and applying it here would be
 539// cargo-culting it. 26182 - 2180 = 24002.
 540var _ [beacon.MaxEstimateStaleBlocks - speedMaxStaleBlocks]struct{}
 541
 542// maxPinnableLatest is the highest anchor round from which a round can still be pinned
 543// without the int64 addition wrapping. A wrapped pin would be a NEGATIVE round — a
 544// round nobody can ever publish, and therefore a window that could never be resolved.
 545// getOrCreateDraw refuses instead, loudly.
 546//
 547// IT IS BELT-AND-BRACES ON AN ADDITION AND NOT A LIVE GRIEFING SURFACE, because drand
 548// cannot sign a round whose time has not come: the beacon's latest round is bounded
 549// above by drand's real head (~2.1e7, gaining ~1.05e7 a year, reaching 9.2e18 in about
 550// 880 billion years). It stays because the arithmetic still must not wrap, and because
 551// a future change of beacon must not silently inherit the assumption that it cannot.
 552// See AUDIT-2026-09-28.md section C.
 553const maxPinnableLatest = int64(maxInt64) - pinOffsetRounds
 554
 555// --- the refund grace window ---------------------------------------------
 556//
 557// refundGraceBlocks IS A BEACON-INDEPENDENT FLOOR ON BUYER ACCESS, and it is the ONLY
 558// thing in this file that lets a committed window's pot out when nobody relays its
 559// pinned round. Read Refund with it.
 560//
 561// THE DEFECT IT CLOSES, inherited from AUDIT-2026-09-28.md finding 7 rather than
 562// rediscovered: a window's round is pinned at anchor + pinOffsetRounds, and if nobody
 563// ever relays that round then Resolve panics "beacon round not published yet" for
 564// ever. Refund requires Committed, so not committing is not a defence — the only route
 565// out of a stranded window runs through the very act that would set the trap. Without
 566// a clock-based exit the window's whole pot — the buyers' OWN money — would be
 567// unreachable by every path in this file while heldPots went on counting it, so
 568// checkInvariant and AuditFailure would both stay green over money that was gone. That
 569// is the same class of defect as findings 1, 5, 6 and 7 and it is the class this realm
 570// was written to avoid reproducing.
 571//
 572// A BEACON-STATE GATE CANNOT DO THIS JOB, and the reason is worth keeping: since the
 573// drand migration nothing is ever LOST — every round is served for ever by at least
 574// three public relays and beacon.Publish is permissionless — so the failure mode is
 575// pure LIVENESS, "nobody has bothered to relay it". No property of the beacon's state
 576// distinguishes "not relayed yet" from "never will be". A CLOCK CAN. That is precisely
 577// what this constant is, and it is why r/splash/arcade/raffle deleted its resolvable()
 578// helper entirely rather than rescaling it (see the note at Refund).
 579//
 580// THE DERIVATION, because the number must not be picked.
 581//
 582//	THE BOUNDED PART OF THE HONEST CYCLE, measured from Commit because Commit is what
 583//	CommittedAt records:
 584//
 585//	  1. WAITING FOR THE PINNED ROUND TO BE SIGNED: up to pinOffsetRounds = 2400
 586//	     rounds = 7200 s = 2 HOURS EXACTLY. The worst case is a window whose record was
 587//	     created in its final block, so the pin is measured from almost exactly the
 588//	     moment entries close and the round lands the whole offset after Commit. (A
 589//	     window opened early pins a round that is already signed by Commit time, which
 590//	     is fine and is the point: nobody CHOSE it.)
 591//	  2. WAITING FOR SOMEBODY TO RELAY IT: UNBOUNDED. Nothing forces anyone to spend
 592//	     ~17.0M gas relaying a round for a pot of a few tenths of a GNOT, and no
 593//	     deadline exists past which the round becomes unobtainable. So this term cannot
 594//	     appear in the derivation at all, and the grace window is not one of two
 595//	     independent openers — IT IS THE ONLY ONE.
 596//
 597//	SO THE GRACE MUST BE STRICTLY LONGER THAN 2 HOURS, or refunds would open on windows
 598//	whose pinned round has not even been signed yet, which is a FUND DRAIN and not an
 599//	inconvenience: a buyer who can compute that they did not win would refund, annulling
 600//	a live draw.
 601//
 602//	TWELVE WINDOWS — TWELVE HOURS — IS CHOSEN, giving a 6x margin over the bounded part
 603//	(43164 s against 7200 s; asserted below). The ten hours above the bound are the
 604//	RELAY ALLOWANCE: how long this realm is willing to wait for somebody to relay one
 605//	round before it hands the money back.
 606//
 607//	WHY NOT THE RAFFLE'S RATIO. The raffle's window is 21 days against an 8-day bound,
 608//	2.6x. Copying 2.6x here would give ~5 hours. Twelve hours is chosen instead for a
 609//	reason specific to this cadence, and it is the one place the hourly draw is WORSE
 610//	than the weekly one:
 611//
 612//	  THE FAIRNESS RACE FINDING 7 RECORDS GETS CHEAPER AS THE WINDOW SHORTENS. Past the
 613//	  grace window, on a window whose round HAS since been relayed, Resolve and Refund
 614//	  are both open and the first caller wins — so a buyer who can see they did not win
 615//	  refunds instead of losing, which sets Abandoned and ANNULS THE DRAW for everyone
 616//	  (every other buyer then takes their own spend back too). Nobody can take anybody
 617//	  else's money — that is what Abandoned and the per-buyer spend record guarantee —
 618//	  so this is a fairness and liveness cost, not a solvency one. But the state that
 619//	  produces it needs only "nobody relayed one round for twelve hours" here, where the
 620//	  raffle needs "nobody relayed one round for three weeks". A LONGER WINDOW MAKES THE
 621//	  STATE RARER; a shorter one gets buyers their money back sooner. Twelve hours
 622//	  (same-day) is the balance struck, it is A DIAL, and closing the race properly
 623//	  still means a two-phase refund — announce, then claim — which is a separate change
 624//	  in this realm exactly as it is in the raffle.
 625const (
 626	// refundGraceWindows is the window count. THE ONE DIAL: refundGraceBlocks is
 627	// derived from it, so re-tuning it cannot leave two units disagreeing.
 628	refundGraceWindows = 12
 629
 630	// refundGraceBlocks is that in blocks — the value Refund actually compares
 631	// against runtime.ChainHeight(). 12 * 1090 = 13080.
 632	refundGraceBlocks = windowBlocks * refundGraceWindows
 633)
 634
 635// GUARD 7 — THE GRACE MUST OUTLAST THE BOUNDED PART OF THE HONEST CYCLE, and this line
 636// fails the BUILD if it ever stops doing so.
 637//
 638// IT IS NOT A BARE INTEGER COMPARISON, AND THE BARE VERSION IS AUDIT-2026-09-28.md
 639// FINDING 11. The raffle carried
 640//
 641//	var _ [refundGraceDays - pinOffsetDays - 1]struct{}   // 21 - 8 - 1 = 12
 642//
 643// with a comment claiming "the units are days on both sides, and days are real time".
 644// THEY ARE NOT THE SAME DAY: the left became BLOCKS (a real duration of blocks x an
 645// unknown block time B) and the right became DRAND ROUNDS (a real duration of exactly
 646// 3 s each). The guard compared the bare integers 21 and 8, named neither cadence, and
 647// therefore COULD NOT FAIL FOR ANY VALUE OF ANYTHING — at blocksPerDay = 2618 it still
 648// compiled while the grace shrank to 2.4 real days against an 8-real-day bound. The
 649// equivalent mistake here would be `[refundGraceWindows - pinOffsetWindows - 1]`
 650// (12 - 2 - 1 = 9), which would compile happily at any block time and any drand period.
 651//
 652// SO IT COMPARES SECONDS TO SECONDS, FROM TWO DIFFERENT CLOCKS:
 653//
 654//	LEFT  — the grace window in real seconds, via the CHAIN's ~3.3 s block time:
 655//	        refundGraceBlocks * 33/10 = 13080 * 3.3 = 43164 s = 11.99 hours
 656//	RIGHT — the bounded honest cycle in real seconds, via DRAND's period:
 657//	        pinOffsetRounds * beacon.PeriodSeconds = 2400 * 3 = 7200 s = 2 hours exactly
 658//	MARGIN = 35963 seconds = 9.99 hours, the relay allowance the derivation asks for —
 659//	        a 6x margin, and it NAMES the constants it depends on.
 660//
 661// MUTATION-TESTED, with the real failing build output recorded in DESIGN.md section 4:
 662// blocksPerDay = 26182 -> 2618 (a tenfold error in the chain-clock constant, the exact
 663// mutation finding 11 used) makes this length negative and the build fails, while the
 664// bare-integer form above compiles unchanged at that value.
 665var _ [refundGraceBlocks*33/10 - pinOffsetRounds*beacon.PeriodSeconds - 1]struct{}
 666
 667// --- state ---------------------------------------------------------------
 668
 669// Draw is one hourly window's draw: the pot, the ticket index that decides it, and
 670// the beacon round it is committed to.
 671//
 672// THERE IS NO Sponsored FIELD, AND ITS ABSENCE IS A DELIBERATE SIMPLIFICATION, NOT AN
 673// OVERSIGHT. The raffle needs one because house money in a prize pot belongs to no
 674// buyer, so its refund-safety property has to be stated as Pot == sum(spendOf) +
 675// Sponsored, and several separate mechanisms exist only to keep that term from being
 676// stranded (heldSponsored, retireSponsorship, the per-unit claim it writes, ClaimDeadWeek's
 677// whole entry point, and SweepDrawlessWeek). THE THREE THIS SENTENCE USED TO NAME WERE
 678// "Refund's move to opsPot, SweepDrawlessWeek's roll-forward, and heldSponsored", and the
 679// first two of those no longer exist: an abandoned week's sponsorship goes to NEITHER the
 680// house NOR a later week — it becomes a per-unit claim on the week it is already in, payable
 681// to the holders of that week's EARNED entries (AUDIT-2026-09-28.md findings 16, 28, 31 and
 682// 34). The count went UP, not down, which is the point this paragraph is making.
 683// HERE THE POT IS ENTRY MONEY ONLY, so the identity is simply
 684//
 685//	Pot + Cut == sum(spendOf)
 686//
 687// for every unresolved window, all three mechanisms are unnecessary, and the
 688// sole-entrant attack the sponsorship enables cannot exist. Read the package comment
 689// before adding one.
 690//
 691// THE Cut TERM IS NEW SINCE 2026-09-29 AND IT IS NOT SPONSORSHIP BY ANOTHER NAME. It is
 692// the house's own fee, held in escrow for the window rather than banked at sale time, so
 693// that a window which never draws returns the FULL ticket price. It belongs to specific
 694// buyers until a draw happens, which is exactly why it appears on the same side of the
 695// identity as the pot and not as a third party's money. AUDIT-2026-09-28.md finding 25.
 696type Draw struct {
 697	Window int64
 698
 699	// Pot is the prize pot in ugnot: the sum of every entrant's contribution AFTER
 700	// the house cut, and NOTHING ELSE. Zeroed by Resolve when it pays the winner,
 701	// and decremented by Refund as each buyer reclaims their share.
 702	//
 703	// THE IDENTITY IS NOW Pot + Cut == sum(spendOf), and it is what makes Refund
 704	// unable to overdraw and what makes a sole entrant's prize exactly their own
 705	// post-fee stake back. It was Pot == sum(spendOf) until 2026-09-29, when spendOf
 706	// started recording the GROSS and the fee started being escrowed in Cut. Read Cut.
 707	Pot int64
 708
 709	// Cut is THE HOUSE'S FEE FOR THIS WINDOW, HELD IN ESCROW AND NOT YET EARNED: the
 710	// sum of houseCutBps of every ticket sold into this window.
 711	//
 712	// IT IS NOT PART OF Pot AND THE WINNER MUST NEVER WIN IT. Resolve pays Pot to the
 713	// winner and moves Cut to opsPot, as two separate amounts.
 714	//
 715	// THE RULE IT IMPLEMENTS IS THE OWNER'S, AND IT IS THE SAME RULE THE WEEKLY REALM
 716	// NOW FOLLOWS: WE EARN WHEN THE GAME RUNS, NOT WHEN IT FAILS. Two destinations and
 717	// no third — a successful Resolve moves it to opsPot, and any other outcome refunds
 718	// it as part of each buyer's gross. AUDIT-2026-09-28.md finding 25.
 719	//
 720	// IT IS A TERM IN BOTH SOLVENCY STATEMENTS. The realm holds this wugnot, so
 721	// checkInvariant is heldPots + heldCuts + opsPot == balance and AuditFailure
 722	// re-derives both new terms by iteration.
 723	//
 724	// A SWEPT, TICKETLESS WINDOW HAS Cut == 0 BY CONSTRUCTION, because a fee can only
 725	// arrive with a ticket sale and SweepDrawlessWindow requires a zero ticket total.
 726	// That is asserted there rather than assumed.
 727	Cut int64
 728
 729	Committed   bool
 730	FrozenTotal int64 // ticket units at commit. NEVER recomputed at resolve.
 731
 732	// Round is the beacon round this window is drawn on. WRITTEN EXACTLY ONCE, by
 733	// getOrCreateDraw, at the moment this record is created — which is before the
 734	// window's first ticket exists — and never written again by anything, Commit
 735	// included. Read pinOffsetRounds for why.
 736	Round int64
 737
 738	// CommittedAt is the CHAIN HEIGHT at which Commit froze this window, and it is
 739	// the only clock the refund grace window reads. Zero on an uncommitted window.
 740	//
 741	// IT IS STORED AND CANNOT BE DERIVED. The window number comes from the same
 742	// global clock, but a window may be committed at any time after it ends (Commit
 743	// takes the window as an argument precisely so a stranded window is always
 744	// recoverable), so "when was this committed" is not a function of which window
 745	// it is. Round cannot stand in for it either: Round is beacon state, and the
 746	// whole point of this field is to give Refund a gate the beacon cannot hold shut.
 747	CommittedAt int64
 748
 749	// Randomness is the round's randomness, copied in by Capture. Empty means "not
 750	// captured"; Resolve then reads the beacon directly. A CONVENIENCE, NOT A
 751	// RESCUE: the beacon cannot lose a round, so nothing is preserved by copying it
 752	// here. See Capture.
 753	Randomness string
 754
 755	Resolved bool
 756	Winner   address
 757
 758	// Abandoned is set by the FIRST successful Refund for this window and is never
 759	// cleared by anything. It makes the window's exit ONE-WAY: once one buyer has
 760	// taken their money back, this window can never be resolved, and every later
 761	// refund proceeds without re-asking whether the draw looks resolvable again.
 762	//
 763	// IT CARRIES THE WHOLE MUTUAL-EXCLUSION BURDEN, and it has to, because a pinned
 764	// round can be relayed by ANYONE, from drand, AT ANY TIME, FOR EVER — so a
 765	// window in refund would otherwise stay resolvable indefinitely. Without this
 766	// flag, anyone could let refunds open, wait for some buyers to reclaim, then
 767	// relay the round and Resolve — drawing over the ORIGINAL frozen range, so a
 768	// buyer who had already been refunded could win and collect what was left in the
 769	// pot: the money of the buyers who had not reclaimed yet. Someone who had
 770	// already fetched the signature did strictly better still, computing the winner
 771	// first and then choosing Refund if it was not them and relay-and-Resolve if it
 772	// was. Pot == sum(spendOf) held throughout, so neither checkInvariant nor
 773	// AuditFailure saw anything — the identity is sound, it is just blind to WHO is
 774	// paid. That is AUDIT-2026-09-28.md finding 3, verbatim.
 775	Abandoned bool
 776
 777	// tickets is the weighted ticket index for this window. ONE INDEX PER PURCHASE
 778	// is appended and no existing index's value is ever changed — that immutability
 779	// is the whole frozen-ownership argument on Commit.
 780	tickets *fenwick.Tree
 781
 782	// ownerOf is index -> string(address), the reverse lookup that turns a drawn
 783	// unit back into a winner. MANY INDICES MAP TO THE SAME ADDRESS and that is
 784	// expected: one entry per purchase.
 785	ownerOf *avl.Tree
 786
 787	// ticketsOf is string(address) -> int64 ticket units, a read-side running total
 788	// so TicketsOf is O(log N) and never has to walk the index. It is NOT read by
 789	// the draw: Select and ownerOf decide the winner.
 790	ticketsOf *avl.Tree
 791
 792	// spendOf is string(address) -> int64, the buyer's GROSS payment for this window's
 793	// tickets — the whole ticket price, house fee included — which is exactly what
 794	// Refund returns.
 795	//
 796	// IT WAS THE POST-CUT CONTRIBUTION UNTIL 2026-09-29 and the old comment said "the
 797	// house cut is not in here and is never refunded". BOTH HALVES OF THAT ARE NOW
 798	// FALSE, and the sentence is corrected rather than removed because it is the one a
 799	// reader will have in their head. AUDIT-2026-09-28.md finding 25.
 800	spendOf *avl.Tree
 801}
 802
 803var (
 804	// draws is window -> *Draw. AN avl.Tree, NEVER A MAP: Render iterates it, and a
 805	// Go map's iteration order is deliberately randomised, which would make this
 806	// realm's rendered output non-deterministic and its audit unreproducible.
 807	draws = avl.NewTree()
 808
 809	// opsPot is the house's money this realm holds, so it is a term in
 810	// checkInvariant. TWO STREAMS FLOW IN — the 5% cut on every ticket (Buy) and any
 811	// unsolicited wugnot arrival (sweepDonations) — plus one that should be
 812	// unreachable (SweepDrawlessWindow; see there). ONE FUNCTION FLOWS OUT:
 813	// WithdrawOps. That there is an outflow at all is AUDIT-2026-09-28.md finding 6,
 814	// which the raffle shipped without and which burned the product's entire revenue.
 815	opsPot int64
 816
 817	// heldPots is the sum of Pot over every draw, maintained incrementally so
 818	// checkInvariant is O(1). Resolve zeroes a window's Pot, so a resolved window
 819	// contributes nothing and this is exactly "the sum of all unresolved window
 820	// pots". AuditFailure re-derives it by iteration so it cannot drift.
 821	heldPots int64
 822
 823	// heldCuts is the sum of Cut over every draw — every window's ESCROWED, NOT YET
 824	// EARNED house fee — maintained incrementally for the same reason heldPots is, and
 825	// re-derived by AuditFailure the same way.
 826	//
 827	// IT IS A THIRD TERM IN THE SOLVENCY EQUALITY AND ADDING IT IS WHAT MAKES THE ESCROW
 828	// SAFE. The fee is wugnot this realm holds, so before 2026-09-29 it was counted by
 829	// being inside opsPot. A balance term the invariant does not mention is how money goes
 830	// missing quietly. It rises only in Buy and falls in exactly two places: Resolve (the
 831	// fee is earned and moves to opsPot, so the balance does not move) and Refund (the fee
 832	// goes back to the buyer with their gross, so the balance falls with it).
 833	heldCuts int64
 834
 835	// launchHeight is the chain height at deploy, so window numbers start at 0 for
 836	// this realm rather than at some arbitrary point in chain history.
 837	launchHeight int64
 838)
 839
 840func init() {
 841	launchHeight = runtime.ChainHeight()
 842}
 843
 844// realmSelf is this realm's own address, resolved ONCE at package initialisation and
 845// never recomputed.
 846//
 847// WHY NOT unsafe.CurrentRealm() PER USE: CurrentRealm resolves against whatever frame
 848// is executing. Inside a crossing entrypoint that is this realm, which is correct —
 849// but Render and the audit reads are deliberately NOT crossing functions (anyone must
 850// be able to read them), so when they are called directly the "current realm" is the
 851// CALLER and BalanceOf(selfAddr()) would silently read the caller's wallet.
 852// r/splash/game learned this the hard way; the fix is to resolve the address once,
 853// since it is constant for the life of the realm.
 854var realmSelf = unsafe.CurrentRealm().Address()
 855
 856func selfAddr() address {
 857	return realmSelf
 858}
 859
 860// --- keys ----------------------------------------------------------------
 861
 862// keyWidth is enough decimal digits for any non-negative int64 (max is
 863// 9223372036854775807, 19 digits). Keys are fixed-width and zero-padded so that
 864// avl.Tree's lexicographic order matches numeric order — the same convention as
 865// r/splash/beacon's roundKey, and the reason Render lists windows as 1, 2, ... 10
 866// rather than 1, 10, 2.
 867const keyWidth = 19
 868
 869const zeroPad = "0000000000000000000"
 870
 871func pad(s string) string {
 872	if len(s) < keyWidth {
 873		return zeroPad[:keyWidth-len(s)] + s
 874	}
 875	return s
 876}
 877
 878func windowKey(window int64) string { return pad(strconv.FormatInt(window, 10)) }
 879
 880func indexKey(i int) string { return pad(strconv.Itoa(i)) }
 881
 882// --- clock ---------------------------------------------------------------
 883
 884// currentWindow is the window tickets are being sold into right now: the single
 885// global clock this realm reads, never a per-ticket one. See windowBlocks.
 886func currentWindow() int64 {
 887	h := runtime.ChainHeight() - launchHeight
 888	if h < 0 {
 889		h = 0
 890	}
 891	return h / windowBlocks
 892}
 893
 894// windowEndHeight is the chain height at which a window stops being the live one — the
 895// FIRST height of window+1. Derived from the same constant and the same launchHeight
 896// currentWindow reads, so the two clocks can never disagree: for every height h >=
 897// launchHeight, currentWindow() == w exactly while h is in [windowEndHeight(w-1),
 898// windowEndHeight(w)).
 899//
 900// IT GATES NOTHING, and that is the difference between this and the raffle's
 901// weekEndHeight, which exists for SweepDrawlessWeek's ticketless clock. This realm's
 902// sweep compares window indices (`window >= currentWindow()`) and its refund clock
 903// counts from CommittedAt, so NO MONEY PATH IN THIS FILE READS THIS FUNCTION. It is here
 904// only so WindowEndHeight has one place to compute the boundary, in the clock section
 905// where the rest of the clock lives rather than in the read surface.
 906//
 907// NO OVERFLOW for any window this realm has a record for: such a window is bounded by
 908// the chain's own height divided by windowBlocks.
 909func windowEndHeight(window int64) int64 {
 910	return launchHeight + (window+1)*windowBlocks
 911}
 912
 913// --- arithmetic ----------------------------------------------------------
 914
 915// mulDiv returns floor(a*b/d) via a 128-bit intermediate product. Raw a*b/d on
 916// token-scale quantities wraps silently on the GnoVM (see
 917// ../../../../tools/muldiv_probe.gno) instead of panicking, so every split in this
 918// realm goes through here.
 919func mulDiv(a, b, d uint64) uint64 {
 920	hi, lo := bits.Mul64(a, b)
 921	if d == 0 {
 922		panic("speed: mulDiv: division by zero")
 923	}
 924	if hi >= d {
 925		panic("speed: mulDiv: overflow")
 926	}
 927	q, _ := bits.Div64(hi, lo, d)
 928	return q
 929}
 930
 931// maxInt64 as a uint64, for the overflow guards below.
 932const maxInt64 = uint64(1)<<63 - 1
 933
 934// ticketCost is tickets * TicketPrice, GUARDED AGAINST int64 OVERFLOW.
 935//
 936// A raw multiplication here is the classic money bug: on the GnoVM an int64 product
 937// wraps silently, so a caller passing a large enough ticket count would buy an
 938// enormous number of ticket units for a small — or negative — cost. The product is
 939// therefore taken in 128 bits and REJECTED unless it fits in an int64: the high word
 940// must be zero AND the low word must be within int64 range, because hi == 0 alone
 941// still admits values above 2^63-1 that would land negative.
 942func ticketCost(tickets int64) int64 {
 943	if tickets <= 0 {
 944		panic("speed: ticket count must be positive")
 945	}
 946	hi, lo := bits.Mul64(uint64(tickets), uint64(TicketPrice))
 947	if hi != 0 || lo > maxInt64 {
 948		panic("speed: ticket count overflows int64 ugnot")
 949	}
 950	return int64(lo)
 951}
 952
 953// hexDigit decodes one lowercase-or-uppercase hex character.
 954func hexDigit(c byte) (uint64, bool) {
 955	switch {
 956	case c >= '0' && c <= '9':
 957		return uint64(c - '0'), true
 958	case c >= 'a' && c <= 'f':
 959		return uint64(c-'a') + 10, true
 960	case c >= 'A' && c <= 'F':
 961		return uint64(c-'A') + 10, true
 962	}
 963	return 0, false
 964}
 965
 966// randomnessMod reduces a 256-bit hex randomness string modulo m, which gives the
 967// draw's winning ticket unit. Verbatim in behaviour from r/splash/arcade/raffle, for
 968// the reasons stated there:
 969//
 970// The randomness is 256 bits; an int64 holds 63. Any truncation (taking the first or
 971// last 16 hex digits, say) throws away most of the beacon's entropy and does it in a
 972// way an attacker can reason about. math/big does not exist on this toolchain, so the
 973// reduction is schoolbook: fold four bits at a time, reducing as we go. The
 974// intermediate r*16 + digit does NOT fit in 64 bits when m is near the int64 maximum,
 975// so it goes through the 128-bit path rather than wrapping. `hi %= um` before the
 976// divide is required by bits.Div64 (which panics unless the high word is smaller than
 977// the divisor) and is sound because hi*2^64 == (hi mod m)*2^64 (mod m).
 978func randomnessMod(randomness string, m int64) int64 {
 979	if m <= 0 {
 980		panic("speed: randomness modulus must be positive")
 981	}
 982	if len(randomness) == 0 {
 983		panic("speed: empty randomness")
 984	}
 985	um := uint64(m)
 986	r := uint64(0)
 987	for i := 0; i < len(randomness); i++ {
 988		d, ok := hexDigit(randomness[i])
 989		if !ok {
 990			panic("speed: beacon randomness is not hex")
 991		}
 992		hi, lo := bits.Mul64(r, 16)
 993		if lo > ^uint64(0)-d {
 994			hi++
 995		}
 996		lo += d
 997		hi %= um
 998		_, r = bits.Div64(hi, lo, um)
 999	}
1000	return int64(r)
1001}
1002
1003// --- window records ------------------------------------------------------
1004
1005// getDraw returns the draw for window, or nil. avl's Get returns a SINGLE value and
1006// existence is a separate Has, so the check comes first — otherwise a missing window
1007// type-asserts nil and panics with avl's message rather than this realm's.
1008func getDraw(window int64) *Draw {
1009	key := windowKey(window)
1010	if !draws.Has(key) {
1011		return nil
1012	}
1013	return draws.Get(key).(*Draw)
1014}
1015
1016// getOrCreateDraw returns the window's draw, creating an empty one if needed. Called
1017// only AFTER a sale's money has moved, so a rejected Buy never leaves an empty record
1018// behind.
1019//
1020// THIS IS WHERE THE WINDOW'S BEACON ROUND IS PINNED, AND IT IS THE ONLY WRITER OF
1021// Draw.Round IN THIS FILE. Read pinOffsetRounds for the attack this closes and for
1022// the derivation of the offset. Buy is its only caller in v1; it is factored out
1023// anyway, because the raffle's history is that a SECOND caller appeared (FundPot) and
1024// the pin had to happen exactly once per window whichever of them got there first. A
1025// second pin would be a second choice, and choice is the whole defect.
1026//
1027// THE ANCHOR IS beacon.EstimatedLatestRoundWithin(speedMaxStaleBlocks), AND EVERY WORD
1028// OF THAT IS LOAD-BEARING:
1029//
1030//   - NOT beacon.Latest(). That is whatever round somebody last had a reason to relay,
1031//     which under an on-demand record is arbitrarily stale; pinning against a stale
1032//     anchor measures the offset FROM THE PAST, so the pinned round lands earlier in
1033//     real time than intended and its randomness can be public before entries close.
1034//     With a one-hour window and a two-hour offset, 3600 seconds of staleness — about
1035//     1090 blocks — is enough to destroy the property completely. That is
1036//     AUDIT-2026-09-28.md finding 10's last-buyer attack and no guard in this file can
1037//     see it, because nothing here distinguishes a fresh anchor from a stale one.
1038//   - NOT the default EstimatedLatestRound() either, which tolerates a whole day of
1039//     staleness — an admitted error of 2.4 hours against this realm's one-hour pin
1040//     margin. See speedMaxStaleBlocks for the derivation of the tighter tolerance.
1041//
1042// IT THEREFORE NEEDS A BEACON THAT HAS PUBLISHED AT LEAST ONCE AND HAS BEEN RELAYED
1043// RECENTLY, and if either is untrue THE SALE IS REFUSED with the beacon's own message.
1044// That is the safe direction and is strictly better than what it replaces: before the
1045// pin existed, such a Buy succeeded and the window could then never be committed,
1046// never resolved and never refunded, so the money entered a window no path in the
1047// realm could ever empty. A refused sale leaves the money in the buyer's wallet. The
1048// refusal happens after the wugnot pull inside Buy, which is harmless — a panic
1049// reverts the whole transaction, exactly as checkInvariant's panics already rely on.
1050//
1051// AND THE REFUSAL COSTS NOTHING CROSS-REALM, WHICH IS WHY IT COULD BE ADOPTED AT THE
1052// TIGHTER TOLERANCE. Nothing outside this realm calls into it (see the package
1053// comment: there is no FundPot and no rake feed), so a refusal here reverts one
1054// buyer's transaction and affects nobody else. The raffle could not say that until
1055// AUDIT-2026-09-28.md finding 14 severed duel.Collect's dependency on its bookkeeping,
1056// and the cross-realm coupling is exactly why bounding the extrapolation looked like a
1057// liveness cost there for as long as it did.
1058func getOrCreateDraw(window int64) *Draw {
1059	if d := getDraw(window); d != nil {
1060		return d
1061	}
1062	anchor := beacon.EstimatedLatestRoundWithin(speedMaxStaleBlocks)
1063	if anchor > maxPinnableLatest {
1064		// Loud rather than pinning a wrapped, negative round nobody could ever
1065		// publish. drand cannot sign a round anywhere near the int64 maximum so this
1066		// is unreachable in practice; see maxPinnableLatest.
1067		panic("speed: the beacon's estimated round is too high to pin a future round against")
1068	}
1069	d := &Draw{
1070		Window: window,
1071		// THE PIN. Never recomputed, never rebound — not by a later Buy and above all
1072		// not by Commit.
1073		Round:     anchor + pinOffsetRounds,
1074		tickets:   fenwick.NewTree(),
1075		ownerOf:   avl.NewTree(),
1076		ticketsOf: avl.NewTree(),
1077		spendOf:   avl.NewTree(),
1078	}
1079	draws.Set(windowKey(window), d)
1080
1081	// EMITTED SO THE PIN IS AUDITABLE FROM THE BLOCK LOG ALONE. The whole security
1082	// argument is "this number was fixed before the ticket set existed", and that is a
1083	// statement about WHEN it was written — which no later read of the realm's state
1084	// can establish on its own. The height and the anchor are included for the same
1085	// reason: together they let a reader recompute the pin and check the staleness.
1086	chain.Emit("PinRound",
1087		"window", strconv.FormatInt(window, 10),
1088		"round", strconv.FormatInt(d.Round, 10),
1089		"beaconAnchor", strconv.FormatInt(anchor, 10),
1090		"height", strconv.FormatInt(runtime.ChainHeight(), 10))
1091	return d
1092}
1093
1094func treeInt64(t *avl.Tree, key string) int64 {
1095	if !t.Has(key) {
1096		return 0
1097	}
1098	return t.Get(key).(int64)
1099}
1100
1101// appendTickets records a purchase of units by addr by APPENDING A NEW INDEX to this
1102// window's ticket index. It never touches an existing index, and that is the whole
1103// point — see Commit's frozen-ownership argument.
1104func appendTickets(d *Draw, addr address, units int64) {
1105	if units <= 0 {
1106		// fenwick.Append would reject a negative itself; failing here names the
1107		// caller that got it wrong, and a zero-unit purchase would create an index
1108		// that owns nothing.
1109		panic("speed: ticket units must be positive")
1110	}
1111	i := d.tickets.Append(units)
1112	d.ownerOf.Set(indexKey(i), string(addr))
1113	k := string(addr)
1114	d.ticketsOf.Set(k, treeInt64(d.ticketsOf, k)+units)
1115}
1116
1117// ownerOfUnit is the draw rule's second half: who owns cumulative ticket unit k in
1118// this window's index. Factored out so the boundary tests can exercise the mapping
1119// over every interesting k without needing a signed beacon round for each one.
1120func ownerOfUnit(d *Draw, k int64) address {
1121	if d.tickets == nil || k < 0 || k >= d.tickets.Total() {
1122		panic("speed: ticket unit out of range")
1123	}
1124	key := indexKey(d.tickets.Select(k))
1125	if !d.ownerOf.Has(key) {
1126		// Unreachable while appendTickets is the only writer: it sets the reverse
1127		// entry in the same statement group as the Append. Loud rather than returning
1128		// a zero address that would look like a winner.
1129		panic("speed: ticket index has no owner")
1130	}
1131	return address(d.ownerOf.Get(key).(string))
1132}
1133
1134// --- native coin -----------------------------------------------------------
1135
1136// rejectAttachedCoin panics if the caller attached native coin to a call that
1137// has no use for it. Every money-in path in this realm is the wugnot GRC20
1138// allowance (see Buy), never the transaction's send envelope, so any attached
1139// ugnot would simply sit in this realm forever with no code path that ever
1140// looks at it — AUDIT-2026-10-07.md Y1. Call this as the first statement of
1141// every crossing entrypoint so the transaction reverts and the sender keeps
1142// their coin instead.
1143//
1144// unsafe.OriginSend() reads the envelope attached at the CHAIN ROOT, and that
1145// envelope is shared by every realm in the call chain: an intermediate realm
1146// can consume it, or have already consumed it, before this call is ever
1147// reached. Reading it is only sound when the immediate caller is an EOA, so
1148// that whatever it describes was paid to THIS realm and nothing else first.
1149// cur.Previous().IsUserCall() is that check (IsUser() is not — it also
1150// accepts `maketx run` ephemeral realms, which can pre-consume the envelope);
1151// see r/splash/exchange/exchange.gno's Buy/Seed comment for the fuller
1152// argument, which this mirrors rather than contradicts. WHEN THE CALL DID NOT
1153// COME DIRECTLY FROM A USER, THIS FUNCTION DOES NOT LOOK AT THE ENVELOPE AT
1154// ALL: it cannot be attributed to this call either way, so there is nothing
1155// sound to enforce, and guessing would be worse than staying silent.
1156//
1157// THE OVERWHELMINGLY COMMON CASE — a plain call with nothing attached — is
1158// cheap: one stack-walk for IsUserCall, then unsafe.OriginSend() always
1159// allocates (even a len-0 slice) and len(sent) == 0 is a single comparison.
1160// No iteration, no second cross-realm call.
1161func rejectAttachedCoin(cur realm, what string) {
1162	if !cur.Previous().IsUserCall() {
1163		return
1164	}
1165	if sent := unsafe.OriginSend(); len(sent) != 0 {
1166		panic("speed: " + what + " does not take native coin — pay through the wugnot allowance instead")
1167	}
1168}
1169
1170// --- solvency ------------------------------------------------------------
1171
1172// sweepDonations accounts for any wugnot that has arrived without going through Buy.
1173//
1174// IT HAS TO EXIST. wugnot.Transfer is permissionless: any address may push wugnot at
1175// this realm with no memo, no callback and no way for this realm to learn who sent it
1176// or why. Without a sweep, one unsolicited transfer would fail checkInvariant for
1177// every later caller and BRICK THE REALM.
1178//
1179// IT GOES TO opsPot, NOT TO THE CURRENT WINDOW'S POT. Here Pot == sum(spendOf) is a
1180// load-bearing identity — it is what makes Refund unable to pay out more than was put
1181// in, and what makes a sole entrant win exactly their own stake back — and a donation
1182// credited to a pot would break it, silently converting somebody's gift into
1183// refundable balance nobody has a claim on. The ops pot has no such identity to
1184// violate.
1185//
1186// IT EMITS A Donation EVENT FOR EVERY NON-ZERO ABSORPTION, and that is not
1187// bookkeeping decoration: nothing else in this realm distinguishes a swept arrival
1188// from the house cut, since both land in opsPot and opsPot is a single number.
1189//
1190// WHAT THE INVARIANT'S EQUALITY IS ACTUALLY WORTH, recorded here so the next reader
1191// does not overtrust it (AUDIT-2026-09-28.md finding 9). Suppose heldPots OVERCOUNTS
1192// by D because of a bug in this file — the realm believes it owes D more than it holds
1193// — and donations totalling X > D have arrived. This function computes
1194// extra = bal - (heldPots + opsPot) = X - D, absorbs it, and afterwards
1195// heldPots + opsPot == bal EXACTLY. checkInvariant then passes and so does
1196// AuditFailure's one-sided balance test. THE DEFICIT HAS NOT BEEN DETECTED, IT HAS
1197// BEEN PAID FOR OUT OF A STRANGER'S MONEY. The equality therefore says "the books add
1198// up after absorbing whatever arrived", not "the books were already right". This event
1199// is what makes the absorption visible so the case can be noticed off-chain — a
1200// Donation for an amount no donor sent is the signature of exactly that state. Fixing
1201// it properly needs a separate counter for absorbed donations so the invariant can
1202// state the pre-donation equality, which changes what checkInvariant MEANS in a realm
1203// where every mutator asserts it; it is a separately-audited change, here as there.
1204func sweepDonations() {
1205	if extra := absorbDonation(); extra > 0 {
1206		// EMITTED AFTER THE CREDIT, so the reported opsPot is the post-sweep figure a
1207		// reader can reconcile against OpsPot(). NOTHING IS EMITTED WHEN NOTHING WAS
1208		// ABSORBED: an empty sweep runs at the top of every mutator in this file, and
1209		// an event on each would bury the one that means something.
1210		chain.Emit("Donation",
1211			"amount", strconv.FormatInt(extra, 10),
1212			"opsPot", strconv.FormatInt(opsPot, 10))
1213	}
1214}
1215
1216// absorbDonation is sweepDonations without the event: it credits opsPot with any
1217// unattributed arrival and RETURNS THE AMOUNT IT ABSORBED, zero if there was nothing.
1218//
1219// IT IS SPLIT OUT FOR A TESTABILITY REASON, STATED PLAINLY: this toolchain gives a Gno
1220// test no way to read emitted events, so the only assertable statements about the
1221// Donation event are the value it would carry and whether it fires at all. Both are
1222// exactly this return value, and speed_test.gno asserts it on both sides. Do not
1223// inline it back without putting the event under test some other way.
1224func absorbDonation() int64 {
1225	bal := wugnot.BalanceOf(selfAddr())
1226	// heldCuts IS IN HERE BECAUSE IT IS WUGNOT THIS REALM HOLDS. Omitting it would make
1227	// every window's escrowed fee look like an unattributed arrival, and the first sweep
1228	// after any sale would move the whole of it into opsPot — banking the fee at sale time
1229	// by accident, and leaving every refund unable to return a gross the realm no longer
1230	// holds. Finding 25.
1231	extra := bal - (heldPots + heldCuts + opsPot)
1232	if extra <= 0 {
1233		return 0
1234	}
1235	opsPot += extra
1236	return extra
1237}
1238
1239// checkInvariant is this realm's solvency statement: the wugnot it holds is exactly
1240// the sum of every unresolved window's pot plus the ops pot, and nothing else. O(1),
1241// using the incrementally-maintained heldPots; AuditFailure re-derives the same thing
1242// by iteration.
1243//
1244// CALLED AT THE END OF EVERY STATE-CHANGING FUNCTION. An equality, not a ">=":
1245// sweepDonations runs first in each of them, so unattributed arrivals are already
1246// accounted for and any remaining gap is a bug in this file.
1247//
1248// THERE IS NO SPONSORSHIP TERM HERE and there is nothing missing: the per-window
1249// identity is Pot + Cut == sum(spendOf) with no house money in it, and AuditFailure
1250// re-derives it ONE WINDOW AT A TIME (finding 27 — it used to do it summed over the
1251// unresolved windows, which two windows with compensating errors passed through
1252// silently). See Draw.
1253func checkInvariant() {
1254	bal := wugnot.BalanceOf(selfAddr())
1255	// THREE TERMS SINCE 2026-09-29, NOT TWO. The house fee is escrowed per window in
1256	// Draw.Cut rather than banked into opsPot at sale time (finding 25), so the wugnot this
1257	// realm holds is the unresolved prize pots, PLUS the unearned fees held against those
1258	// windows, PLUS the ops pot. Read heldCuts.
1259	if heldPots+heldCuts+opsPot != bal {
1260		panic("speed: GNOT invariant violated")
1261	}
1262	if heldPots < 0 {
1263		// Would mean a pot was paid out twice.
1264		panic("speed: heldPots negative")
1265	}
1266	if heldCuts < 0 {
1267		// Would mean a window's escrowed fee was released twice — a Resolve and a Refund
1268		// both taking it, or a refund re-deriving more fee than the buyer ever paid. The
1269		// cutPerTicket build guard is what makes the second impossible.
1270		panic("speed: heldCuts negative")
1271	}
1272	if opsPot < 0 {
1273		panic("speed: opsPot negative")
1274	}
1275}
1276
1277// AuditFailure re-derives the invariant BY ITERATION and reports the empty string when
1278// the books balance, otherwise which check failed and by how much. Never called from a
1279// state-changing path — checkInvariant is the O(1) guard for that — and exists so
1280// anyone can verify off-chain, and so a test can assert it after every operation.
1281//
1282// IT MUST BE ROBUST AGAINST ANYONE WHO CAN SEND THIS REALM TOKENS, which is everyone:
1283// wugnot.Transfer needs no permission. So the balance test below is ONE-SIDED — a
1284// shortfall is a violation, an unswept surplus is not. See the comment on that check.
1285func AuditFailure() string {
1286	i64 := strconv.FormatInt
1287
1288	// ONE PASS THAT COLLECTS, THEN A LOOP THAT SUMS — never an Iterate inside an
1289	// Iterate. Nesting a second avl traversal inside the first one's callback crashes
1290	// this toolchain's VM outright (a Go-level nil dereference, not a gno panic), so
1291	// the two levels are kept strictly apart. A slice is fine here because it is local
1292	// scratch and never realm state.
1293	all := []*Draw{}
1294	sumPots := int64(0)
1295	sumCuts := int64(0)
1296	unresolvedPots := int64(0)
1297	unresolvedCuts := int64(0)
1298	draws.Iterate("", "", func(_ string, value any) bool {
1299		d := value.(*Draw)
1300		all = append(all, d)
1301		sumPots += d.Pot
1302		sumCuts += d.Cut
1303		if !d.Resolved {
1304			unresolvedPots += d.Pot
1305			unresolvedCuts += d.Cut
1306		}
1307		return false
1308	})
1309
1310	// THE SECOND PASS, AND IT IS NOW PER WINDOW — AUDIT-2026-09-28.md finding 27. It sums
1311	// each window's spendOf on its own and tests THAT WINDOW'S identity immediately, instead
1312	// of accumulating one grand total and testing it once at the end. The cost is one
1313	// comparison per unresolved window; the loop, the slice and the single level of
1314	// iteration are all exactly as they were.
1315	//
1316	// WHY THE AGGREGATE FORM WAS NOT GOOD ENOUGH. Summed over the windows, two windows with
1317	// EQUAL AND OPPOSITE errors (+X here, -X there) cancel and the check passes with both
1318	// windows wrong. This realm has no path that moves money between windows at all — which
1319	// is why the defect was found in the raffle and not here — so the per-window form is a
1320	// strengthening against a class of bug rather than a fix for a live one; it costs one
1321	// comparison and it is the shape the comment always claimed.
1322	//
1323	// THE FAILURE IS RECORDED AND REPORTED LOWER DOWN rather than returned from here, so that
1324	// the ORDER in which this function reports problems does not change.
1325	sumSpends := int64(0)
1326	perWindow := ""
1327	for _, d := range all {
1328		if d.Resolved {
1329			continue
1330		}
1331		windowSpends := int64(0)
1332		d.spendOf.Iterate("", "", func(_ string, v any) bool {
1333			windowSpends += v.(int64)
1334			return false
1335		})
1336		sumSpends += windowSpends
1337		if perWindow == "" && windowSpends != d.Pot+d.Cut {
1338			perWindow = "window " + i64(d.Window, 10) + ": pot " + i64(d.Pot, 10) +
1339				" and escrowed fee " + i64(d.Cut, 10) +
1340				" but buyers' gross claims total " + i64(windowSpends, 10)
1341		}
1342	}
1343
1344	if sumPots != heldPots {
1345		return "heldPots is " + i64(heldPots, 10) + ", iterating the draws gives " + i64(sumPots, 10)
1346	}
1347	// THE ESCROWED FEES, RE-DERIVED THE SAME WAY AND FOR THE SAME REASON (finding 25).
1348	// Resolve zeroes a window's Cut when it releases it, so a resolved window contributes
1349	// nothing and the sum over ALL draws is the same number as the sum over the unresolved
1350	// ones — asserted below rather than assumed, because the two would part company if
1351	// anything ever released a fee without zeroing the field.
1352	if sumCuts != heldCuts {
1353		return "heldCuts is " + i64(heldCuts, 10) + ", iterating the draws gives " + i64(sumCuts, 10)
1354	}
1355	if unresolvedCuts != sumCuts {
1356		return "escrowed fees total " + i64(sumCuts, 10) +
1357			" but only " + i64(unresolvedCuts, 10) + " of it is on an unresolved window" +
1358			" — a resolved window is holding a fee it should have released"
1359	}
1360	// ONLY A DEFICIT IS A VIOLATION HERE — a surplus is not, and that asymmetry is
1361	// deliberate (AUDIT-2026-09-28.md finding 9). This function is a READ: it cannot
1362	// sweep, so any wugnot that has arrived without going through Buy is still
1363	// unattributed when it runs. wugnot.Transfer is permissionless, so with an
1364	// equality here a stranger could make AuditInvariant() false and AuditFailure()
1365	// shout by sending this realm ONE ugnot — free, repeatable, attacker-controlled
1366	// alert fatigue on the one solvency signal an operator would page on, with nothing
1367	// actually wrong and nobody harmed. An unswept donation is a benign, expected
1368	// state: the next state-changing call sweeps it into the ops pot.
1369	//
1370	// A DEFICIT IS THE CASE THAT CAN HURT SOMEBODY: it means this realm has promised
1371	// more than it holds, so some pot cannot be paid. That is reported, always.
1372	//
1373	// checkInvariant, in the mutators, KEEPS ITS EQUALITY and is untouched by this: it
1374	// runs after sweepDonations, so a surplus there really would be a bug.
1375	// STILL ONE-SIDED, AND THE ESCROWED FEES JOIN THE SIDE THEY BELONG ON: heldCuts is
1376	// money the realm holds for somebody — the house if the window draws, the buyers if it
1377	// does not — so it counts towards what the realm must be able to pay, exactly like a
1378	// pot. A DEFICIT IS STILL THE ONLY VIOLATION.
1379	bal := wugnot.BalanceOf(selfAddr())
1380	if bal < sumPots+sumCuts+opsPot {
1381		return "pots + escrowed fees + ops is " + i64(sumPots+sumCuts+opsPot, 10) +
1382			", but the wugnot balance is only " + i64(bal, 10) +
1383			" — the realm is short by " + i64(sumPots+sumCuts+opsPot-bal, 10)
1384	}
1385	// EVERY UNRESOLVED WINDOW'S POT PLUS ITS ESCROWED FEE MUST BE EXACTLY ITS BUYERS'
1386	// GROSS CLAIMS, or Refund could pay out more or less than was put in. AN EQUALITY, NOT
1387	// A TOLERANCE: there is no sponsorship term here and no ugnot in any pot that belongs
1388	// to nobody, so a discrepancy of ANY size is a bug and is reported. A resolved window
1389	// is excluded: its pot is gone to the winner, its fee to the house, and its spend
1390	// records are dead.
1391	//
1392	// THE FEE TERM IS ON THE LEFT BECAUSE spendOf IS NOW THE GROSS (finding 25): that gross
1393	// was split at sale time into the prize part and the escrowed fee part, so both must be
1394	// on the pot side or the equation is short by every fee ever taken. IT IS THIS
1395	// IDENTITY, NOT AN INEQUALITY, THAT MAKES Refund PROVABLY UNABLE TO OVERDRAW.
1396	//
1397	// AND IT IS TESTED ONE WINDOW AT A TIME SINCE 2026-09-29 (finding 27). The loop above did
1398	// the work; this is where its verdict is reported, in the position the old aggregate test
1399	// occupied.
1400	if perWindow != "" {
1401		return perWindow
1402	}
1403	// THE AGGREGATE FORM, KEPT AS A SECOND LOCK AND NO LONGER ABLE TO FIRE ON ITS OWN. It is
1404	// IMPLIED by the per-window loop — a sum of equalities is an equality — so what it still
1405	// guards is the ACCUMULATION: that the same numbers the per-window test read add up to the
1406	// aggregates the O(1) invariant is written in. Deliberately not deleted, because if a
1407	// future edit ever narrows the per-window loop this line is what still notices.
1408	if sumSpends != unresolvedPots+unresolvedCuts {
1409		return "unresolved pots total " + i64(unresolvedPots, 10) +
1410			" and escrowed fees " + i64(unresolvedCuts, 10) +
1411			" but buyers' gross claims total " + i64(sumSpends, 10)
1412	}
1413	return ""
1414}
1415
1416// AuditInvariant is AuditFailure reduced to a bool.
1417func AuditInvariant() bool { return AuditFailure() == "" }
1418
1419// --- Buy -----------------------------------------------------------------
1420
1421// Buy sells `tickets` tickets in the CURRENT window to the caller.
1422//
1423// Cost is tickets * TicketPrice, pulled from the caller with the GRC20 allowance path,
1424// so the caller wraps native ugnot with wugnot.Deposit and approves this realm first.
1425// houseCutBps of the cost goes to the ops pot; the remainder — and all
1426// integer-division dust, since the cut is floored — joins this window's prize pot and
1427// is recorded in the caller's spend for the window.
1428//
1429// EVERY CHECK RUNS BEFORE THE PULL, and the window record is created only after it, so
1430// a failed Buy moves nothing and creates nothing.
1431//
1432// Each purchase APPENDS ITS OWN ticket index rather than growing an existing one. That
1433// is not a style choice: it is the property Commit depends on, and the reason is
1434// spelled out there.
1435//
1436// THE WINDOW'S FIRST BUY PINS THE WINDOW'S BEACON ROUND, as a side effect of creating
1437// the record (getOrCreateDraw). That is deliberate and is the only moment the round is
1438// ever chosen. Two consequences a caller can see. First, a sale needs a beacon that has
1439// published at least once AND been relayed within speedMaxStaleBlocks, or it is refused
1440// with the beacon's own message — a refused sale rather than money accepted into a
1441// window that could never be drawn fairly. Second, the buyer who opens a window does
1442// not get to pick anything useful: the round is fixed two hours ahead of the moment the
1443// ticket set closes, so at the instant it is pinned there is no ticket set for it to be
1444// advantageous against.
1445//
1446// Panics if tickets is not positive, if the cost overflows int64, if the allowance is
1447// short, or if THIS WINDOW IS ALREADY COMMITTED — the last of which is the
1448// frozen-total rule, not a convenience check. Once a window's total is frozen, no
1449// further ticket may join it.
1450//
1451// THAT LAST REFUSAL IS UNREACHABLE THROUGH THE ENTRY POINTS, and is kept deliberately.
1452// Commit only accepts a window that is already over, and Buy only ever sells into
1453// currentWindow(), so the live window cannot be in a committed state — which is
1454// exactly why a commit can no longer shut a window's sales down early
1455// (AUDIT-2026-09-28.md finding 2). The check stays as a second lock on the
1456// frozen-total rule: if the window's identity ever stops being "the one the clock is
1457// on", this is the line that still refuses to grow a frozen ticket set.
1458//
1459// ONLY A USER MAY BUY, AND THE REASON IS THE PAYOUT, NOT THE PURCHASE
1460// (AUDIT-2026-09-28.md finding 8). A ticket is a claim on two transfers this realm
1461// will later push OUT: Resolve's whole pot to the winner's address, and Refund's spend
1462// back to the buyer's. Both go to cur.Previous().Address(), which for a realm caller
1463// is that realm's PACKAGE-DERIVED address — an address whose only "key" is its package
1464// path, so nothing can move wugnot out of it unless that realm happens to contain code
1465// that does. A realm with no such code that won a draw would have the entire window's
1466// pot transferred into an address it can never spend from: money that has left this
1467// realm's books (heldPots drops, the invariant stays green) and is unreachable by
1468// anybody, for ever. This realm cannot tell which realms those are and must not try,
1469// so it refuses them all at the door instead of stranding a pot at settlement time.
1470//
1471// cur.Previous().IsUserCall() IS THE CHECK — AND IsUser() IS NOT. IsUser() also
1472// accepts a `maketx run` ephemeral realm, which is code executing under a user's
1473// signature and NOT an EOA: its address is still package-derived, so it can win a pot
1474// and then cease to exist with the money in it. r/splash/exchange states the same
1475// distinction about the same word for a different failure mode (a shared send
1476// envelope); the property being asked for — "the thing in front of me is a wallet
1477// somebody holds a private key for" — is identical.
1478//
1479// THERE IS NO SECOND, UNGUARDED ENTRY POINT HERE, and that is a difference from the
1480// raffle worth naming: the raffle must leave FundPot realm-callable for the duel's
1481// rake, so its asymmetry has to be documented at both ends to stop someone "making it
1482// consistent". THIS REALM HAS NO FundPot (see the package comment), so THE GUARD ABOVE
1483// IS THE ONLY DOOR MONEY COMES IN BY, and every ugnot in a pot is therefore
1484// attributable to a user address that can receive it back.
1485func Buy(cur realm, tickets int64) {
1486	rejectAttachedCoin(cur, "Buy")
1487	sweepDonations()
1488
1489	if !cur.Previous().IsUserCall() {
1490		panic("speed: Buy must be called directly by a user")
1491	}
1492
1493	if tickets <= 0 {
1494		panic("speed: ticket count must be positive")
1495	}
1496	cost := ticketCost(tickets)
1497
1498	window := currentWindow()
1499	if d := getDraw(window); d != nil && d.Committed {
1500		panic("speed: this window is already committed — tickets for it are closed")
1501	}
1502
1503	caller := cur.Previous().Address()
1504	if wugnot.Allowance(caller, selfAddr()) < cost {
1505		panic("speed: insufficient allowance — approve this realm for the ticket cost first")
1506	}
1507
1508	wugnot.TransferFrom(cross(cur), caller, selfAddr(), cost)
1509
1510	cut := int64(mulDiv(uint64(cost), uint64(houseCutBps), uint64(bpsDenom)))
1511	net := cost - cut
1512
1513	d := getOrCreateDraw(window)
1514	// THE FEE IS ESCROWED IN THE WINDOW, NOT BANKED — AUDIT-2026-09-28.md finding 25. This
1515	// line was `opsPot += cut` until 2026-09-29, which meant the house had been paid the
1516	// moment a ticket was sold and a window that never drew refunded only 95%. The owner's
1517	// rule is that WE EARN WHEN THE GAME RUNS, NOT WHEN IT FAILS, so the fee now sits in
1518	// d.Cut — inside this window, outside the prize — and reaches opsPot only when Resolve
1519	// names a winner. If instead the window is abandoned and refunded, d.Cut goes back to
1520	// the buyers it came from, because spendOf below records the GROSS.
1521	//
1522	// d.Cut IS NOT IN d.Pot AND MUST NEVER BE: the winner must not win our fee, and both
1523	// identities name the two as separate terms.
1524	d.Cut += cut
1525	heldCuts += cut
1526	d.Pot += net
1527	heldPots += net
1528	// ONE TICKET IS ONE UNIT OF WEIGHT HERE, deliberately, and this line is where that is
1529	// true. Do not "make it consistent" with r/splash/arcade/raffle, which multiplies by a
1530	// units-per-ticket factor: read TicketPrice's comment for why this realm has no entry
1531	// unit and must not acquire one.
1532	appendTickets(d, caller, tickets)
1533	k := string(caller)
1534	// THE GROSS, NOT THE NET. This is what Refund returns, and it is the buyer's whole
1535	// payment: the fee is only ours once the draw happens. The per-window identity is
1536	// preserved exactly — the gross recorded here equals the net that went to d.Pot plus
1537	// the fee that went to d.Cut, by construction, on this line and the ones above it.
1538	d.spendOf.Set(k, treeInt64(d.spendOf, k)+cost)
1539
1540	chain.Emit("Buy",
1541		"window", strconv.FormatInt(window, 10),
1542		"buyer", string(caller),
1543		"tickets", strconv.FormatInt(tickets, 10),
1544		"cost", strconv.FormatInt(cost, 10),
1545		"cut", strconv.FormatInt(cut, 10),
1546		"escrowedCut", strconv.FormatInt(d.Cut, 10),
1547		"pot", strconv.FormatInt(d.Pot, 10))
1548	checkInvariant()
1549}
1550
1551// --- Commit --------------------------------------------------------------
1552
1553// Commit commits an ALREADY-FINISHED window to its pinned beacon round. Returns that
1554// round.
1555//
1556// THE WINDOW IS A PARAMETER AND IT MUST BE OVER — window < currentWindow(). Both
1557// halves are load-bearing, and each closes a defect the raffle's first version had:
1558//
1559//   - IT TAKES A WINDOW, so any past window can be committed at any time, by anyone. A
1560//     window whose clock ran out before anybody called it would otherwise be
1561//     unreachable for ever — Resolve and Refund both require Committed — so its whole
1562//     pot would be lost while heldPots went on counting it and both solvency checks
1563//     stayed green over money nobody could ever get back. That is
1564//     AUDIT-2026-09-28.md finding 1, and at an hourly cadence it would happen 24 times
1565//     a day rather than once a week: MOST WINDOWS OF A QUIET DAY WILL BE COMMITTED LATE
1566//     BY SOMEBODY WHO WANTS THEIR MONEY, and that path has to work.
1567//   - THE LIVE WINDOW CAN NEVER BE COMMITTED, so a commit can no longer cut a window's
1568//     sales short. Otherwise anyone could Buy(1) and immediately Commit: that makes
1569//     them the sole owner of a FrozenTotal of 1 — the guaranteed winner — and, because
1570//     Buy refuses a committed window, shuts the window's sales for the rest of its
1571//     hour. That is AUDIT-2026-09-28.md finding 2. Note that the PRIZE half of that
1572//     attack is already dead here by the sole-entrant property (winning a one-ticket
1573//     window returns the attacker's own 95,000 ugnot out of the 100,000 they staked), so
1574//     what remains is pure griefing — and refusing the live window closes it at the
1575//     root anyway, rather than with a minimum-tickets or minimum-elapsed heuristic.
1576//     THIS USED TO SAY THE GRIEFER'S WIN "costs them the 5,000 cut", AND IT COSTS THEM
1577//     NOTHING NOW: since finding 25 the fee is escrowed and earned only by a draw, so
1578//     the griefer who DOES NOT resolve pays nothing at all and reclaims the whole
1579//     100,000 through Refund. The reason the attack is dead is the refusal below, not a
1580//     5,000 ugnot price on it. AUDIT-2026-09-28.md finding 29.
1581//
1582// PERMISSIONLESS: there is no keeper and no admin. Anyone who wants the draw to happen
1583// — every ticket holder, for a start — can call it once the window is up.
1584//
1585// It freezes two numbers and CHOOSES NOTHING:
1586//
1587//   - FrozenTotal = the window's final ticket total. The window is over and Buy only
1588//     ever sells into currentWindow(), so this total can no longer grow BY
1589//     CONSTRUCTION; it is still assigned explicitly, because Resolve reads this field
1590//     and not the live total.
1591//   - CommittedAt, the height the refund grace window counts from.
1592//   - AND IT USES Draw.Round EXACTLY AS PINNED, which was written when this window's
1593//     record was created and is not this function's business.
1594//
1595// THE SECURITY RULE, AND THE ONLY REASON THIS IS THREE PHASES: the ticket set must be
1596// frozen BEFORE the randomness is knowable. If the total were read at resolve time,
1597// anybody who saw the published round could compute the winning unit and then buy
1598// precisely enough tickets to own it — which turns a draw into a purchase.
1599//
1600// THERE IS DELIBERATELY NO "THE ROUND MUST STILL BE IN THE FUTURE" CHECK HERE. A
1601// reader who expects one has the pin backwards. A window is committed at least an hour
1602// after its round was pinned, so by now the pinned round MAY LEGITIMATELY ALREADY BE
1603// SIGNED AND RELAYED — that is the ordinary case for a window whose record opened
1604// early in its hour, and it is harmless precisely because nobody CHOSE it. Requiring
1605// it to be unpublished would refuse the honest case and, worse, would reintroduce
1606// round selection through the back door, since the only way to satisfy such a check
1607// would be to recompute the round here. The int64 wrap case lives where the arithmetic
1608// is, at maxPinnableLatest.
1609//
1610// WHY FREEZING THE TOTAL ALSO FREEZES WHO OWNS UNIT k — the part r/splash/game got
1611// wrong first time, and the reason Buy appends:
1612//
1613//   - Every purchase APPENDS A NEW ticket index (appendTickets). No index's value is
1614//     ever increased afterwards, and fenwick.Add is not called from this realm at all.
1615//   - An appended index therefore starts at a prefix equal to the whole total at the
1616//     moment it was appended, so anything appended after Commit lies at or beyond
1617//     FrozenTotal — outside the frozen range by construction, not by convention.
1618//   - And because no earlier index's value changes, no earlier prefix moves: the owner
1619//     of every unit below FrozenTotal is fixed from the instant that unit was sold.
1620//
1621// WITH ONE INDEX PER BUYER INSTEAD, a further purchase would grow that buyer's
1622// existing value, widening their range IN PLACE, from its start, inside the frozen
1623// range, and pushing every later buyer along. The earliest buyer (prefix 0 for ever)
1624// could then wait for the round to publish, compute k = randomness mod FrozenTotal,
1625// buy until their own value exceeded k, and call Resolve to take the pot: a free option
1626// on the whole pot. That exploit was real in r/splash/game.
1627//
1628// Panics if the window is not over yet, if it is already committed, or if it has NO
1629// TICKETS AT ALL. The last is not a formality: there is no rollover, so a drawless
1630// window must never be allowed to exist — there is no unit to draw and no owner to
1631// pay. It reads no beacon state at all; the "beacon must exist and be fresh" refusal
1632// lives at the pin, which is to say in Buy, where a sale is refused rather than
1633// accepted into a window that could never be drawn.
1634func Commit(cur realm, window int64) int64 {
1635	rejectAttachedCoin(cur, "Commit")
1636	sweepDonations()
1637
1638	if window >= currentWindow() {
1639		// THE WHOLE OF THE GRIEFING FIX. Tickets are still being sold into
1640		// currentWindow(), and a future window is not a window at all yet.
1641		panic("speed: a window can only be committed once it is over")
1642	}
1643
1644	d := getDraw(window)
1645	if d == nil {
1646		panic("speed: no tickets sold in that window — there is no rollover, so a drawless window must not exist")
1647	}
1648	if d.Committed {
1649		panic("speed: this window is already committed")
1650	}
1651	total := d.tickets.Total()
1652	if total == 0 {
1653		panic("speed: no tickets sold in that window — there is no rollover, so a drawless window must not exist")
1654	}
1655
1656	// THE ROUND IS READ, NOT CHOSEN. getOrCreateDraw pinned it when this window's
1657	// record was created; see pinOffsetRounds.
1658	round := d.Round
1659	if round <= 0 {
1660		// Unreachable: getOrCreateDraw is the only way a Draw comes into existence and
1661		// it always pins a positive round. Stated loudly because the pin IS the
1662		// security property — and deliberately NOT "pin one now as a fallback", which
1663		// would hand the round choice straight back to the committer.
1664		panic("speed: this window's record has no pinned beacon round")
1665	}
1666
1667	d.Committed = true
1668	d.FrozenTotal = total
1669	// d.Round IS NOT ASSIGNED HERE. It was pinned at record creation and this function
1670	// must never write it; `round` above is a read.
1671	//
1672	// THE HEIGHT THE REFUND GRACE WINDOW COUNTS FROM. Recorded here and nowhere else,
1673	// because this is the moment the pot becomes unreachable by any route except
1674	// Resolve or Refund — so it is the moment from which a buyer's beacon-independent
1675	// claim on their own money starts to age. See refundGraceBlocks and Refund.
1676	d.CommittedAt = runtime.ChainHeight()
1677
1678	chain.Emit("Commit",
1679		"window", strconv.FormatInt(window, 10),
1680		"frozenTotal", strconv.FormatInt(total, 10),
1681		"round", strconv.FormatInt(round, 10),
1682		"pot", strconv.FormatInt(d.Pot, 10),
1683		"committedAt", strconv.FormatInt(d.CommittedAt, 10),
1684		"refundOpensAt", strconv.FormatInt(d.CommittedAt+refundGraceBlocks, 10))
1685	checkInvariant()
1686	return round
1687}
1688
1689// --- Capture -------------------------------------------------------------
1690
1691// Capture copies a committed window's beacon randomness into the draw.
1692//
1693// IT IS A CONVENIENCE AND NOT A SAFETY MECHANISM, and saying so is the point of this
1694// comment. In the raffle's pre-drand design its job was "save the randomness before
1695// the beacon prunes it"; r/splash/beacon has no retention window and no Prune, drand
1696// serves every round for ever from at least three public relays, and Publish is
1697// permissionless, so THERE IS NOTHING TO PRESERVE THE VALUE FROM. What it still does:
1698//
1699//   - it copies a public value into the draw's own state, so Resolve needs no beacon
1700//     read later and the settled draw is self-contained;
1701//   - it makes the value visible through CapturedRandomness and the Capture event
1702//     before the window is settled, which a front end and an auditor can both use.
1703//
1704// NOTHING IN THIS REALM'S SAFETY DEPENDS ON ANYONE CALLING IT.
1705//
1706// PERMISSIONLESS, deliberately. Its only effect is to store a value that is already
1707// public — the round's randomness, readable by anyone the moment it is relayed. Gating
1708// it on an operator would buy nothing and would put a liveness dependency for the
1709// whole pot onto one key.
1710//
1711// WHAT IS STORED IS THE 64-CHARACTER HEX RANDOMNESS — sha256 of the signature, a hash,
1712// not the signature itself — and it is NOT re-verified here. The pairing check happened
1713// once already inside beacon.Publish on the way in; redoing it would be expensive
1714// duplicated work on an input that is already known good, and the beacon has no route
1715// by which a published round's randomness can change afterwards.
1716//
1717// A SECOND Capture NEVER OVERWRITES THE FIRST, and is a silent no-op rather than a
1718// panic: two callers racing to perform a permissionless public good should not make one
1719// of them fail.
1720//
1721// Capturing before the round is relayed PANICS. There is nothing to preserve yet, and
1722// storing anything else — least of all some already-published value — would hand the
1723// draw a randomness for a round nobody pinned, which is the exact attack the pin exists
1724// to prevent.
1725//
1726// COMMIT DOES NOT CAPTURE, AND MUST NOT START. Since the round is pinned at record
1727// creation, a window's round may well be relayed by the time it is committed, so a
1728// capture there would often succeed — and it would put a beacon read back inside the
1729// one function whose job is to choose nothing.
1730func Capture(cur realm, window int64) {
1731	rejectAttachedCoin(cur, "Capture")
1732	sweepDonations()
1733
1734	d := getDraw(window)
1735	if d == nil {
1736		panic("speed: no draw for that window")
1737	}
1738	if !d.Committed {
1739		panic("speed: this window is not committed yet")
1740	}
1741	if d.Resolved {
1742		panic("speed: this window's draw is already resolved")
1743	}
1744	if d.Abandoned {
1745		// The window is in refund and can never be resolved, so there is nothing left
1746		// for a captured randomness to be used for. Refusing keeps the one exit one-way
1747		// at every entry point rather than only at Resolve.
1748		panic("speed: this window was abandoned and refunded; there is nothing to capture")
1749	}
1750	if d.Randomness != "" {
1751		// Already captured: keep the first value, without failing the caller.
1752		return
1753	}
1754	if !beacon.Published(d.Round) {
1755		panic("speed: beacon round not published yet")
1756	}
1757
1758	rnd := beacon.Randomness(d.Round)
1759	if len(rnd) != randomnessHexLen {
1760		// A length check, NOT a re-verification: it only asserts the shape of the value
1761		// this realm is about to depend on, so that an empty or truncated string can
1762		// never be mistaken for "captured" (the empty string is this field's "not
1763		// captured" sentinel).
1764		panic("speed: beacon randomness has the wrong length")
1765	}
1766	d.Randomness = rnd
1767
1768	chain.Emit("Capture",
1769		"window", strconv.FormatInt(window, 10),
1770		"round", strconv.FormatInt(d.Round, 10),
1771		"randomness", rnd)
1772	checkInvariant()
1773}
1774
1775// --- Resolve -------------------------------------------------------------
1776
1777// Resolve picks the window's winner and PAYS THEIR WALLET. PERMISSIONLESS.
1778//
1779// The whole pot leaves this realm in a single wugnot.Transfer to the winner's address.
1780// No internal balance is credited, there is nothing left to claim afterwards, and no
1781// buyer's money is held past the window they bought into.
1782//
1783// winner = ownerOfUnit(randomness mod FrozenTotal). FrozenTotal, NOT the live ticket
1784// total: recomputing it here would reopen the buy-the-winning-unit attack described on
1785// Commit.
1786//
1787// The pot pays out UNTAXED. The house cut was already taken on the way in, at sale
1788// time; taxing the payout as well would be charging twice for the same ticket.
1789//
1790// RANDOMNESS SOURCE: a captured value if there is one, otherwise the beacon directly —
1791// so the happy path needs no extra transaction.
1792//
1793// THERE IS ONE FAILURE MESSAGE AND THERE CAN ONLY BE ONE. "Not relayed yet" is always
1794// a wait-and-retry, never "this draw is dead": no round is ever unobtainable, so
1795// anybody who sees that message can make it stop being true by relaying the round
1796// themselves for ~17.0M gas. A realm that offered a second, terminal message would be
1797// steering buyers into refunds they do not need.
1798//
1799// THERE IS NO HEIGHT GATE HERE AND THERE MUST NEVER BE ONE. refundGraceBlocks does not
1800// appear in this function and CommittedAt is not read: the grace window is a FLOOR ON
1801// BUYER ACCESS, NOT A DEADLINE ON THE DRAW. It only ever opens Refund; it never closes
1802// Resolve. An honest relay that arrives a day after Commit, before anybody has
1803// refunded, should still pay the winner it always would have, over exactly the
1804// FrozenTotal it was frozen with. Adding a height deadline here would manufacture a
1805// second class of dead window — past the grace, relayed, and resolvable by nobody —
1806// which is the very shape of defect the grace window exists to remove.
1807//
1808// WHAT DOES CLOSE THIS FUNCTION IS Abandoned, and that is enough. Once the grace window
1809// has opened, Resolve and Refund are both available and the first one called wins: a
1810// refund sets Abandoned and this function refuses for ever, a resolve pays the winner
1811// and Refund's Resolved check refuses for ever. NEITHER ORDERING CAN PAY THE SAME UGNOT
1812// TWICE, which is the property that matters. The fairness cost of that race — a buyer
1813// who can see they did not win refunds instead, annulling the draw — is
1814// AUDIT-2026-09-28.md finding 7's recorded residual, and it is CHEAPER HERE THAN THERE
1815// because the window that produces it is twelve hours rather than three weeks. It is
1816// derived and discussed at refundGraceBlocks rather than buried here.
1817func Resolve(cur realm, window int64) {
1818	rejectAttachedCoin(cur, "Resolve")
1819	sweepDonations()
1820
1821	d := getDraw(window)
1822	if d == nil {
1823		panic("speed: no draw for that window")
1824	}
1825	if !d.Committed {
1826		panic("speed: this window is not committed yet")
1827	}
1828	if d.Resolved {
1829		panic("speed: this window's draw is already resolved")
1830	}
1831	if d.Abandoned {
1832		// PERMANENT, AND DELIBERATELY NOT CONDITIONAL ON THE BEACON. Refunds are open
1833		// on this window and at least one buyer has already taken their money out, so
1834		// the frozen range no longer corresponds to who is still owed. Resolving now
1835		// would draw over the original range and pay whatever is left of the pot —
1836		// other buyers' money — to a possibly-refunded winner. See Draw.Abandoned.
1837		panic("speed: this window was abandoned and refunded; it can never be resolved")
1838	}
1839	if d.FrozenTotal <= 0 {
1840		// Unreachable: Commit refuses a zero total. Loud rather than a division by zero
1841		// inside randomnessMod.
1842		panic("speed: draw has no frozen tickets")
1843	}
1844
1845	rnd := d.Randomness
1846	if rnd == "" {
1847		if beacon.Published(d.Round) {
1848			rnd = beacon.Randomness(d.Round)
1849		} else {
1850			panic("speed: beacon round not published yet")
1851		}
1852	}
1853
1854	winner := ownerOfUnit(d, randomnessMod(rnd, d.FrozenTotal))
1855
1856	pot := d.Pot
1857	// THE ONE MOMENT THE HOUSE'S FEE IS EARNED — AUDIT-2026-09-28.md finding 25. The draw
1858	// happened, so the window's escrowed Cut becomes ops revenue here and NOWHERE ELSE.
1859	// Buy does not credit opsPot, Commit does not, Capture does not; a window that reaches
1860	// any other terminal state returns the fee to its buyers instead.
1861	//
1862	// IT IS MOVED, NOT PAID: no wugnot leaves the realm for it, so heldCuts falls by
1863	// exactly what opsPot gains and the balance side of checkInvariant does not move for
1864	// this half at all. AND IT IS DELIBERATELY NOT ADDED TO `pot` — the winner must not win
1865	// our fee.
1866	cut := d.Cut
1867	d.Resolved = true
1868	d.Winner = winner
1869	d.Pot = 0
1870	d.Cut = 0
1871	heldPots -= pot
1872	heldCuts -= cut
1873	opsPot += cut
1874
1875	if pot > 0 {
1876		// Books first, transfer second. wugnot's Transfer has no hook or callback, so
1877		// there is no reentrancy to fear either way, but writing the state first means
1878		// a future GRC20 with a hook cannot see a pot that has already been promised
1879		// and not yet zeroed.
1880		wugnot.Transfer(cross(cur), winner, pot)
1881	}
1882
1883	chain.Emit("Resolve",
1884		"window", strconv.FormatInt(window, 10),
1885		"round", strconv.FormatInt(d.Round, 10),
1886		"winner", string(winner),
1887		"pot", strconv.FormatInt(pot, 10),
1888		"cutEarned", strconv.FormatInt(cut, 10))
1889	checkInvariant()
1890}
1891
1892// --- Refund --------------------------------------------------------------
1893
1894// THERE IS NO resolvable() HELPER IN THIS FILE AND NOTHING SHOULD ADD ONE. The raffle
1895// had one and it was DELETED in the drand migration rather than rescaled. It read
1896// "the round came and went, nobody kept it, and so this draw can NEVER be resolved",
1897// which was a claim about a beacon that forgot things. r/splash/beacon has no
1898// PruneWindow and no Prune, drand serves every round for ever from at least three
1899// independent relays, and Publish is permissionless — so A PINNED ROUND IS ALWAYS
1900// EVENTUALLY RESOLVABLE and such a function is identically true.
1901//
1902// AND MIND THE TRAP IN THE OBVIOUS ALTERNATIVE, because it is a fund drain and not a
1903// tidy-up. A "resolvable" predicate that merely checked `d.Randomness != "" ||
1904// beacon.Published(d.Round)` would report a freshly committed window whose round is not
1905// yet relayed as NOT resolvable — and a refund gate that opens when resolvable() is
1906// false would then open REFUNDS ON EVERY LIVE DRAW IMMEDIATELY. The height gate below
1907// is the only correct shape: a beacon-state gate cannot distinguish "not relayed yet"
1908// from "never will be", and a clock can.
1909
1910// refundGraceElapsed reports whether refundGraceBlocks have passed since Commit froze
1911// this window. IT READS NO BEACON STATE AT ALL, and that is its entire purpose: it is
1912// the one condition in this file that a stopped, lost or simply uninterested relayer
1913// cannot hold false. See refundGraceBlocks for the derivation.
1914//
1915// IT IS MONOTONE: chain height only rises and CommittedAt never moves once set, so this
1916// goes true exactly once and stays true.
1917//
1918// A ZERO CommittedAt RETURNS false, and false is the safe direction. It is unreachable
1919// through the entry points — Refund tests Committed first, and Commit sets both fields
1920// in the same statement group. If it ever were reachable, withholding the escape hatch
1921// is a liveness cost, whereas granting it would open refunds on a live draw at any
1922// height past refundGraceBlocks.
1923func refundGraceElapsed(d *Draw) bool {
1924	if d.CommittedAt <= 0 {
1925		return false
1926	}
1927	return runtime.ChainHeight()-d.CommittedAt >= refundGraceBlocks
1928}
1929
1930// Refund returns the caller's own ticket money for a window nobody settled.
1931// PERMISSIONLESS, and self-service only: a caller can only ever reclaim their own
1932// record.
1933//
1934// WHAT IT IS FOR IS LIVENESS, NOT LOSS. A window's pot must not be hostage to whether
1935// anybody spends ~17.0M gas relaying one drand round, and at an hourly cadence with
1936// pots of a few tenths of a GNOT that is not an exotic worry — IT IS THE EXPECTED
1937// OUTCOME OF A QUIET HOUR. That is the only failure left, it is unbounded in time, so
1938// the exit is A CLOCK: refunds open refundGraceBlocks (twelve hours) after Commit and
1939// not before.
1940//
1941// THE PRECONDITION IS THEREFORE NOT THE COMPLEMENT OF Resolve's — the two OVERLAP for
1942// ever once the window opens, and that is fine and is handled by Abandoned. There is no
1943// state in which Resolve is impossible, so a refund gate that waited for one would never
1944// open at all.
1945//
1946// AND THE EXIT IS ONE-WAY, WHICHEVER WAY IT GOES. Refund and Resolve are permanently
1947// mutually exclusive per window: the first refund that pays out sets Abandoned, after
1948// which Resolve and Capture refuse that window for ever and every later refund proceeds
1949// without consulting the beacon again. Read Draw.Abandoned for the theft this prevents
1950// — it is AUDIT-2026-09-28.md finding 3 and it carries more weight since the drand
1951// migration, not less, because a pinned round can be relayed by anyone at any time for
1952// ever.
1953//
1954// EACH BUYER RECLAIMS EXACTLY THEIR OWN RECORDED SPEND, ONCE. The record is zeroed
1955// before the transfer, so a second call finds nothing and panics; and because
1956// Pot + Cut == sum(spendOf) for an unresolved window — WITH NO SPONSORSHIP TERM TO GET IN
1957// THE WAY, which is the whole simplification this realm buys by having no FundPot — the
1958// refunds can never total more than the realm holds against this window.
1959//
1960// THE HOUSE FEE *IS* REFUNDED, SINCE 2026-09-29, AND THE PARAGRAPH THAT USED TO BE HERE
1961// SAID THE OPPOSITE. It read: "What a buyer gets back is their post-cut contribution — the
1962// part that was ever in the prize pot — not the gross ticket price. The 5% was taken at
1963// sale time and is already in the ops pot; clawing it back would mean the ops pot could go
1964// negative, or that this realm had to hold every cut in escrow against the chance of an
1965// unrelayed round." IT IS KEPT VERBATIM BECAUSE IT NAMES THE COST OF THE FIX AND THE FIX
1966// PAID IT: the realm now DOES hold every fee in escrow per window (Draw.Cut), which is the
1967// second option that sentence dismissed, and the ops pot cannot go negative because
1968// nothing credits it until a draw succeeds.
1969//
1970// WHAT A BUYER GETS BACK IS THEREFORE THE WHOLE TICKET PRICE. A refunded window is a
1971// window that did not run, and the fee was a charge for a draw that never happened. This
1972// is a REDUCTION IN HOUSE REVENUE in the failure case, which needs no permission and is
1973// the safe direction. AUDIT-2026-09-28.md finding 25.
1974//
1975// A WINDOW THAT WAS NEVER COMMITTED IS NOT REFUNDABLE HERE, and needs no separate path
1976// — but only because Commit TAKES THE WINDOW AS AN ARGUMENT and any window that is over
1977// can be committed by anyone at any time. So a buyer holding a stranded window's tickets
1978// names that window to Commit themselves, and then either Resolves it (the normal
1979// outcome, once somebody relays the round) or refunds it here twelve hours later. Before
1980// Commit took that argument the equivalent sentence in the raffle was simply FALSE, and
1981// that was AUDIT-2026-09-28.md finding 1.
1982func Refund(cur realm, window int64) {
1983	rejectAttachedCoin(cur, "Refund")
1984	sweepDonations()
1985
1986	d := getDraw(window)
1987	if d == nil {
1988		panic("speed: no draw for that window")
1989	}
1990	if d.Resolved {
1991		panic("speed: this window's draw is already resolved")
1992	}
1993	if !d.Committed {
1994		panic("speed: this window is not committed yet — commit it first; refunds only open on a window nobody settled")
1995	}
1996	// THE Abandoned CHECK COMES FIRST, and it must: once this window is in refund it
1997	// stays in refund, whatever the beacon says later. A pinned round can be relayed by
1998	// anybody at any time, for ever, so without this a window with some buyers already
1999	// refunded could be resolved over the original frozen range.
2000	//
2001	// THE HEIGHT GATE IS THE ONLY OPENER. Refusing needs both terms — not abandoned, and
2002	// still inside the grace window. The message tells the caller which of the two things
2003	// to do, because both are available to them: wait for the window, or relay the round
2004	// themselves and resolve. The height the window opens at is in Render, in DrawOf and
2005	// in the Commit event.
2006	if !d.Abandoned && !refundGraceElapsed(d) {
2007		panic("speed: this window's draw is still live — refunds only open once the grace window has elapsed")
2008	}
2009
2010	caller := cur.Previous().Address()
2011	k := string(caller)
2012	amount := treeInt64(d.spendOf, k)
2013	if amount <= 0 {
2014		panic("speed: nothing to reclaim for that window")
2015	}
2016
2017	// THE POINT OF NO RETURN, set on the first refund that actually pays out — never by
2018	// a refusal above, which must leave the window exactly as it was.
2019	//
2020	// AND THERE IS NOTHING ELSE TO DO HERE, which is worth noticing. The raffle's
2021	// equivalent branch has to RETIRE that week's SPONSORSHIP, because house money is
2022	// attributable to no buyer and would otherwise sit for ever in a pot whose every claim
2023	// had been zeroed — money in, no way out, the class of defect seven of its findings are
2024	// about. This realm has no sponsorship, so Pot == sum(spendOf) already and the whole pot
2025	// is claimable by the people who own it.
2026	//
2027	// THIS PARAGRAPH SAID "MOVE THAT WEEK'S SPONSORSHIP TO opsPot" UNTIL 2026-09-29 AND THAT
2028	// DESTINATION IS DELETED — AUDIT-2026-09-28.md findings 16, 28, 31 and 34. The raffle's
2029	// transition now converts the sponsorship into a PER-UNIT CLAIM ON THE SAME WEEK, held by
2030	// the addresses that EARNED entries in it through duel play, and moves nothing anywhere;
2031	// only a week with no earned holders at all reaches opsPot. The correction is recorded
2032	// rather than made silently because a reader comparing the two files will be looking for
2033	// the branch this sentence describes.
2034	if !d.Abandoned {
2035		d.Abandoned = true
2036		chain.Emit("Abandon",
2037			"window", strconv.FormatInt(window, 10),
2038			"pot", strconv.FormatInt(d.Pot, 10),
2039			"escrowedCut", strconv.FormatInt(d.Cut, 10))
2040	}
2041
2042	// AND THE ESCROWED FEE IS NOT TOUCHED BY THE TRANSITION EITHER, WHICH IS THE POINT.
2043	// The raffle's equivalent branch has to RETIRE that week's SPONSORSHIP, because it belongs
2044	// to no buyer — and "move it somewhere", which is what this sentence said until
2045	// 2026-09-29, is precisely the thing three of that realm's findings were about: there is
2046	// no destination any more, only a per-unit claim on the week the money is already in
2047	// (AUDIT-2026-09-28.md findings 28, 31 and 34). A FEE BELONGS TO SPECIFIC BUYERS, so it
2048	// needs no transition at all: it goes back to them one gross refund at a time, in the
2049	// split below. Finding 25's
2050	// abandonment rule — we did not run a draw, so we do not keep the fee — is therefore
2051	// implemented here by doing nothing special, and that is worth stating so a reader does
2052	// not go looking for the missing branch.
2053	//
2054	// A BUYER WHO NEVER CALLS THIS LEAVES THEIR OWN GROSS SITTING IN THE WINDOW, fee part
2055	// included, and that is not a new stranded-money path: there is no deadline on a refund,
2056	// Abandoned is one-way so nothing else can take it, and the only address that can ever
2057	// reclaim it is theirs.
2058
2059	// THE REFUNDED BUYER KEEPS THEIR TICKETS ON PURPOSE. d.tickets, d.ownerOf and
2060	// d.ticketsOf are deliberately NOT touched here: fenwick rejects negative deltas by
2061	// design and FrozenTotal must never move, so a ticket cannot be retired once sold.
2062	// That would mean a refunded buyer kept their full winning probability while holding
2063	// their money back — harmless ONLY because the line above makes this window
2064	// unresolvable for ever, so no draw over this ticket set will ever happen. DO NOT
2065	// "FIX" THE UN-RETIRED TICKETS by reopening Resolve on an abandoned window: the
2066	// tickets are dead weight, and the window's exit being one-way is what makes them
2067	// safe.
2068	// THE GROSS IS SPLIT BACK INTO THE TWO TERMS IT ARRIVED IN — AUDIT-2026-09-28.md
2069	// finding 25, and this is where the rounding lives. Transcribed from the raffle's
2070	// Refund, which has the full argument; the short form:
2071	//
2072	//   - THE TWO PARTS ALWAYS SUM TO THE GROSS, unconditionally, because cutPart is
2073	//     computed and potPart is the REMAINDER. So the per-window identity
2074	//     Pot + Cut == sum(spendOf) survives ANY rounding: this call removes `amount` from
2075	//     both sides. That is what makes Refund unable to overdraw, and it holds after a
2076	//     partial refund and in every ordering.
2077	//   - THE SPLIT ALSO LANDS ON THE SAME SIDE AS BUY'S, because the cutPerTicket build
2078	//     guard forbids a TicketPrice/houseCutBps pair for which the flooring discards
2079	//     anything — so every cut is tickets * cutPerTicket exactly, and so is every
2080	//     partial sum of them. Without that guard the two could differ by one ugnot per
2081	//     buyer and d.Cut could be driven negative by a refund of a buyer's own money.
2082	cutPart := int64(mulDiv(uint64(amount), uint64(houseCutBps), uint64(bpsDenom)))
2083	potPart := amount - cutPart
2084
2085	d.spendOf.Set(k, int64(0))
2086	d.Pot -= potPart
2087	d.Cut -= cutPart
2088	heldPots -= potPart
2089	heldCuts -= cutPart
2090	if d.Pot < 0 {
2091		// Unreachable while Pot + Cut == sum(spendOf). Loud rather than paying out money
2092		// that belongs to another window.
2093		panic("speed: refund would overdraw the window's pot")
2094	}
2095	if d.Cut < 0 {
2096		// Unreachable for the reason above: the build guard makes the per-buyer fee
2097		// derivation exact. Loud rather than releasing a fee twice or paying one buyer out
2098		// of another window's escrow.
2099		panic("speed: refund would overdraw the window's escrowed fee")
2100	}
2101
2102	// THE FULL TICKET PRICE GOES BACK, NOT 95% OF IT. That is the behaviour change finding
2103	// 25 is about: the fee was never earned, because the draw never ran.
2104	wugnot.Transfer(cross(cur), caller, amount)
2105
2106	chain.Emit("Refund",
2107		"window", strconv.FormatInt(window, 10),
2108		"buyer", string(caller),
2109		"amount", strconv.FormatInt(amount, 10),
2110		"prizePart", strconv.FormatInt(potPart, 10),
2111		"feeReturned", strconv.FormatInt(cutPart, 10),
2112		"pot", strconv.FormatInt(d.Pot, 10),
2113		"escrowedCut", strconv.FormatInt(d.Cut, 10))
2114	checkInvariant()
2115}
2116
2117// --- SweepDrawlessWindow -------------------------------------------------
2118
2119// SweepDrawlessWindow moves a FINISHED, TICKETLESS window's pot to the ops pot, so
2120// that money in such a window can leave. PERMISSIONLESS, like Commit. Returns the
2121// amount moved.
2122//
2123// READ THIS WHOLE COMMENT BEFORE DELETING THE FUNCTION AS DEAD CODE, because in v1 IT
2124// IS UNREACHABLE THROUGH EVERY ENTRY POINT and that is deliberate.
2125//
2126// WHY IT IS UNREACHABLE HERE. A Draw record comes into existence in exactly one place
2127// (getOrCreateDraw) with exactly one caller (Buy), and Buy appends a positive ticket
2128// count in the same transaction; a panic anywhere after the record is created reverts
2129// the whole transaction, record included. So there is NO SEQUENCE OF CALLS that leaves
2130// a window with a record, a zero ticket total and a positive pot. That argument is
2131// asserted by the suite (a ticketless window cannot be produced through Buy) and the
2132// function's own preconditions are exercised by installing the state directly.
2133//
2134// WHY IT EXISTS ANYWAY. It is the shape of AUDIT-2026-09-28.md's fifth fix, and the
2135// defect it exists against is the single most common one in that document — SIX of the
2136// fourteen findings are "money is in the realm and no path can ever get it out", and
2137// four of those six were found by an engineer implementing something else, not by an
2138// audit. In the raffle the state is REACHABLE: FundPot credits the live week and
2139// creates its record, so a week in which the duel's rake arrived but nobody bought a
2140// ticket ends holding money in a Draw that Commit rightly refuses (no tickets), that
2141// Resolve and Refund cannot touch (both require Committed) and that FundPot will never
2142// revisit (it only funds the live week). ADDING ANY OF THE THINGS THE PACKAGE COMMENT
2143// WARNS AGAINST — sponsorship, a rake feed, a jackpot, a rollover — MAKES IT REACHABLE
2144// HERE TOO, and the day that happens this exit should already be in place and tested
2145// rather than being the thing somebody forgot.
2146//
2147// WHERE THE MONEY GOES, AND WHY IT IS NOT "ROLLED FORWARD" INTO THE LIVE WINDOW'S POT.
2148//
2149// THE PARAGRAPH THAT STOOD HERE RECOMMENDED A DESIGN TWO AUDITS FOUND EXPLOITABLE, AND
2150// THAT IS WHY IT IS QUOTED RATHER THAN QUIETLY REPLACED — AUDIT-2026-09-28.md finding 36.
2151// It read:
2152//
2153//	"The raffle rolls a drawless week's sponsorship into the live week's pot, AND IT IS
2154//	RIGHT TO: that money is the PLAYERS' side of the duel rake, so handing it to the house
2155//	would take the players' share away from players, and the raffle has a Sponsored field
2156//	that lets the destination week's pot exceed its spend records without breaking
2157//	anything."
2158//
2159// EVERY CLAUSE OF IT IS NOW FALSE, AND THE "AND IT IS RIGHT TO" WAS THE DEFECT. The raffle
2160// does not roll a dead week's sponsorship anywhere: rolling it into the live week was
2161// AUDIT-2026-09-28.md finding 28 (a destination evaluated at call time is a destination the
2162// permissionless caller chooses, by choosing when to fire), and the register that replaced it
2163// for one day was finding 31 (the caller who opens the destination week is the caller who
2164// calls Buy, so they could be its only entrant and capture the whole amount for the price of
2165// the house cut). A dead week's sponsorship now becomes a PER-UNIT CLAIM ON THE WEEK IT IS
2166// ALREADY IN, payable to the holders of that week's EARNED entries — read the raffle's
2167// retireSponsorship and ClaimDeadWeek. There is no destination at all.
2168//
2169// SO A READER WHO PORTS SPONSORSHIP INTO THIS FILE MUST NOT PORT THE SENTENCE ABOVE. The
2170// half of it that was sound — this money is the PLAYERS' share and must not end up with the
2171// house — is satisfied by giving it back to the players who earned it, in place, and never by
2172// moving it to a week somebody can pick. This comment is where an engineer adding a rake feed
2173// here would look first, which is exactly why a recommendation for a broken design sitting in
2174// it is a defect and not untidiness.
2175//
2176// NEITHER HALF OF THE ORIGINAL APPLIED HERE IN ANY CASE.
2177//
2178//   - This realm has no sponsorship and no rake, so there is no players' share to
2179//     protect: any money in a ticketless window is unattributable by construction —
2180//     exactly like a stranger's unsolicited transfer, which sweepDonations also sends
2181//     to opsPot.
2182//   - Crediting the live window's Pot would BREAK Pot == sum(spendOf) for that window,
2183//     which is the identity that makes Refund unable to overdraw and makes a sole
2184//     entrant's prize exactly their own stake. AuditFailure would then report the live
2185//     window as short, correctly, and the only way to "fix" it would be to add the
2186//     Sponsored field and the three mechanisms that come with it — porting the
2187//     complexity the package comment says not to port, to launder money that cannot
2188//     exist.
2189//
2190// SO opsPot — AND SINCE 2026-09-29 THE TWO REALMS AGREE ON THIS SHAPE RATHER THAN
2191// DIFFERING, which is the opposite of what this paragraph used to say. It read: "SO opsPot,
2192// WHICH IS A DELIBERATE DEVIATION FROM THE WEEKLY REALM'S CHOICE AND IS NAMED AS ONE ... the
2193// routing question ('where does it belong') is answered differently because the money has a
2194// different origin." The raffle's own no-claimant branch now sends a dead week's sponsorship
2195// to opsPot for exactly the reason given here — there is provably no address holding a unit of
2196// that week, so there is nobody to wrong — and it only pays holders when holders exist. Read
2197// the raffle's retireSponsorship: the branch is on its EARNED unit total and nothing else.
2198// The essential property of the fifth fix is preserved in full either way: money in the
2199// realm has a path out, here through WithdrawOps.
2200//
2201// Panics if the window is not over yet (a live window may still sell its first ticket,
2202// which would make it a real draw whose buyers are entitled to everything in its pot),
2203// if there is no record for it, if it sold ANY tickets, if it holds nothing, or —
2204// defensively — if it is committed, resolved or abandoned.
2205func SweepDrawlessWindow(cur realm, window int64) int64 {
2206	rejectAttachedCoin(cur, "SweepDrawlessWindow")
2207	sweepDonations()
2208
2209	if window >= currentWindow() {
2210		// THE SAME "ONLY ONCE IT IS OVER" RULE AS Commit, and for a reason of its own:
2211		// the live window can still sell its first ticket, at which point it is a real
2212		// draw whose buyers are entitled to the whole pot. Sweeping it would take the
2213		// prize out from under a buyer mid-window.
2214		panic("speed: a window can only be swept once it is over")
2215	}
2216
2217	d := getDraw(window)
2218	if d == nil {
2219		panic("speed: no draw for that window")
2220	}
2221	if total := d.tickets.Total(); total != 0 {
2222		// A WINDOW WITH TICKETS IS A REAL DRAW and belongs to Commit/Resolve/Refund.
2223		// Sweeping one would take a pot away from the buyers who paid into it.
2224		panic("speed: that window sold tickets — it is a real draw, so Commit and Resolve it (or Refund it), never sweep it")
2225	}
2226	// THE THREE CHECKS BELOW ARE DEFENSIVE AND SHOULD BE DOUBLY UNREACHABLE. With a
2227	// zero ticket total the window can never have been committed (Commit refuses a zero
2228	// total), so it can never have been resolved or abandoned either (both require
2229	// Committed). They are stated anyway, loudly, because this function moves money and
2230	// a silent sweep of a window that had somehow reached one of those states would be
2231	// far worse than a failed transaction.
2232	if d.Committed {
2233		panic("speed: that window is committed — it is a real draw, not a drawless one")
2234	}
2235	if d.Resolved {
2236		panic("speed: that window's draw is already resolved")
2237	}
2238	if d.Abandoned {
2239		panic("speed: that window was abandoned and refunded")
2240	}
2241
2242	amount := d.Pot
2243	if amount <= 0 {
2244		panic("speed: that window holds nothing — there is nothing to sweep")
2245	}
2246	if d.Cut != 0 {
2247		// Unreachable, and asserted rather than assumed — AUDIT-2026-09-28.md finding 25.
2248		// An escrowed fee can only arrive with a ticket SALE, and this function has already
2249		// refused any window with a non-zero ticket total, so a sweepable window has no fee
2250		// to release. Stating it stops this function silently moving a window's pot to the
2251		// house while leaving an unearned fee behind in a window with no spendOf record to
2252		// refund it through — which nobody could ever reclaim. A loud refusal, not a
2253		// rollover.
2254		panic("speed: a ticketless window is holding an escrowed fee")
2255	}
2256
2257	// NO WUGNOT MOVES HERE, and a reader looking for the missing Transfer should stop
2258	// looking. The tokens are already inside this realm, so this is PURE BOOKKEEPING:
2259	// the realm's wugnot balance is identical before and after, heldPots loses exactly
2260	// what opsPot gains, and checkInvariant's equality therefore still holds at the end.
2261	// WithdrawOps is what actually takes it out.
2262	d.Pot = 0
2263	heldPots -= amount
2264	opsPot += amount
2265
2266	chain.Emit("SweepDrawlessWindow",
2267		"window", strconv.FormatInt(window, 10),
2268		"amount", strconv.FormatInt(amount, 10),
2269		"opsPot", strconv.FormatInt(opsPot, 10))
2270	checkInvariant()
2271	return amount
2272}
2273
2274// --- WithdrawOps ---------------------------------------------------------
2275
2276// WithdrawOps pushes the whole ops pot to opsVaultAddr and returns the amount moved.
2277// PERMISSIONLESS.
2278//
2279// THE DEFECT IT CLOSES IS AUDIT-2026-09-28.md FINDING 6, and it is in this file from
2280// the start rather than added later: without it opsPot only ever increases — the 5% cut
2281// on every ticket (the product's entire revenue), every swept donation, and anything
2282// SweepDrawlessWindow ever moves — and all of it would be permanently unreachable by
2283// every path in the realm while checkInvariant and AuditFailure both stayed green,
2284// because both count opsPot as money the realm holds and neither has an opinion about
2285// whether anybody can reach it.
2286//
2287// PERMISSIONLESS, AND THAT IS SAFE PRECISELY BECAUSE THE DESTINATION IS FIXED AT
2288// COMPILE TIME. A reader will look for a caller check here and there is none, on
2289// purpose: opsVaultAddr is a const, so the caller does not choose where the money goes,
2290// cannot name themselves, and cannot name a window's pot. All they can do is push the
2291// house's money to the house — and paying the gas to do somebody else's bookkeeping is
2292// a favour, not an attack. Gating it on an operator key would buy nothing and would put
2293// a liveness dependency for the realm's revenue onto one key, exactly as it would for
2294// Commit, Capture and Resolve. The property that makes it sound is the one
2295// tools/speed_addr_test.go exists to keep true.
2296//
2297// IT NEVER REACHES A PLAYER'S MONEY. No Draw is touched: not Pot, not spendOf, not a
2298// ticket. The only book that moves is opsPot, which by construction holds nothing any
2299// buyer can claim — the fee in it was EARNED BY A WINDOW THAT ACTUALLY DREW (Resolve
2300// releases that window's escrow; a window that fails refunds it with the gross) and a
2301// donation belongs to whoever chose to give it away.
2302//
2303// THE CITATION THAT USED TO STAND HERE IS RETIRED: this paragraph said "the cut was
2304// taken at sale time (see Refund's 'THE HOUSE CUT IS NOT REFUNDED')", and neither half
2305// survives — finding 25 stopped the fee being taken at sale time and deleted that
2306// section of Refund. A citation that outlives the thing it cites is how a reader ends
2307// up trusting a rule the code stopped following. AUDIT-2026-09-28.md finding 29.
2308//
2309// sweepDonations RUNS FIRST, as in every other mutator, so a donation that arrived
2310// since the last state change is included in this withdrawal rather than left behind.
2311//
2312// BOOKS FIRST, TRANSFER SECOND, exactly as Resolve and Refund do it: opsPot is zeroed
2313// BEFORE the wugnot leaves, so a re-entrant call — which wugnot's Transfer cannot cause
2314// today, having no hook or callback, but a future GRC20 might — finds an empty pot and
2315// panics rather than a balance it can take twice.
2316//
2317// THE INVARIANT IS EXACT ACROSS IT, not merely preserved one-sidedly: checkInvariant
2318// asserts heldPots + opsPot == balance, and this call reduces both sides by the same
2319// amount. heldPots is deliberately untouched.
2320//
2321// Panics if there is nothing to withdraw.
2322func WithdrawOps(cur realm) int64 {
2323	rejectAttachedCoin(cur, "WithdrawOps")
2324	sweepDonations()
2325
2326	amount := opsPot
2327	if amount <= 0 {
2328		panic("speed: the ops pot is empty — there is nothing to withdraw")
2329	}
2330
2331	opsPot = 0
2332	wugnot.Transfer(cross(cur), opsVaultAddr, amount)
2333
2334	chain.Emit("WithdrawOps",
2335		"amount", strconv.FormatInt(amount, 10),
2336		"destination", string(opsVaultAddr))
2337	checkInvariant()
2338	return amount
2339}
2340
2341// --- reads ---------------------------------------------------------------
2342//
2343// All plain and non-crossing: a read needs no authority check.
2344
2345// PotSize is the CURRENT window's prize pot in ugnot: ticket sales after the house cut,
2346// and nothing else. It is the whole amount the winner takes.
2347func PotSize() int64 {
2348	d := getDraw(currentWindow())
2349	if d == nil {
2350		return 0
2351	}
2352	return d.Pot
2353}
2354
2355// TicketTotal is the CURRENT window's ticket units sold.
2356func TicketTotal() int64 {
2357	return TicketTotalOfWindow(currentWindow())
2358}
2359
2360// TicketTotalOfWindow is a given window's ticket units sold: the LIVE total, not
2361// FrozenTotal, so it is readable before a commit and is zero for a window that never
2362// sold a ticket. DrawOf reports FrozenTotal, which is zero on any uncommitted window,
2363// so a caller cannot use it to tell "sold nothing" from "sold plenty and was never
2364// committed"; this read is that distinction.
2365func TicketTotalOfWindow(window int64) int64 {
2366	d := getDraw(window)
2367	if d == nil {
2368		return 0
2369	}
2370	return d.tickets.Total()
2371}
2372
2373// TicketsOf is addr's ticket units in the CURRENT window.
2374func TicketsOf(addr address) int64 {
2375	return TicketsOfWindow(currentWindow(), addr)
2376}
2377
2378// TicketsOfWindow is addr's ticket units in a given window, so a front end can still
2379// show a buyer their past windows once the clock has moved on.
2380func TicketsOfWindow(window int64, addr address) int64 {
2381	d := getDraw(window)
2382	if d == nil {
2383		return 0
2384	}
2385	return treeInt64(d.ticketsOf, string(addr))
2386}
2387
2388// SpendOf is addr's refundable contribution to the CURRENT window's pot in ugnot: what
2389// they paid MINUS the house cut, which is what Refund would return. It is not the gross
2390// ticket price they paid.
2391func SpendOf(addr address) int64 {
2392	return SpendOfWindow(currentWindow(), addr)
2393}
2394
2395// SpendOfWindow is addr's remaining refundable contribution to a given window's pot.
2396// Zero once they have been refunded.
2397func SpendOfWindow(window int64, addr address) int64 {
2398	d := getDraw(window)
2399	if d == nil {
2400		return 0
2401	}
2402	return treeInt64(d.spendOf, string(addr))
2403}
2404
2405// CurrentWindow is the window tickets are being sold into right now.
2406func CurrentWindow() int64 {
2407	return currentWindow()
2408}
2409
2410// WindowBlocks is windowBlocks, EXPORTED so a front end can show how long a window
2411// lasts and compute when the current one ends without hardcoding the cadence.
2412func WindowBlocks() int64 { return windowBlocks }
2413
2414// WindowEndHeight is windowEndHeight, EXPORTED: the chain height at which a given window
2415// stops being the live one. A window is live for heights [WindowEndHeight(window-1),
2416// WindowEndHeight(window)) — the returned height is the FIRST height of window+1, NOT
2417// the last height of window, so at exactly this height CurrentWindow already reads
2418// window+1. A countdown that showed "1 block left" here would be off by one.
2419//
2420// IT IS A BLOCK HEIGHT AND DELIBERATELY NOT A TIME. A window is windowBlocks =
2421// blocksPerDay/24 = 1090 blocks, and blocksPerDay = 26182 encodes an ASSUMED
2422// 86400/26182 = 3.29998 s per block (read blocksPerDay and windowBlocks for how much
2423// that assumption is allowed to be wrong). The chain does not actually run at that rate:
2424// 3.306 s measured on gno.land mainnet and 3.376 s on onyx-1. So a caller wanting a
2425// wall-clock countdown must multiply by a block time IT chooses and own the error; this
2426// realm will not return seconds or "time remaining", because that would bake one clock's
2427// rounding into every front end that reads it and make the contract the source of a
2428// number it cannot know. Nothing on chain converts blocks to seconds and neither does
2429// this.
2430//
2431// WHY THIS AND NOT AN EXPORTED launchHeight: WindowBlocks is already exported, so
2432// LaunchHeight() + WindowBlocks() would indeed let a caller derive the boundary — but it
2433// would leave the +1 above to be re-implemented off chain in every reader, and the
2434// raffle cannot offer the same pair at all (both raffleWeekDays and blocksPerDay are
2435// unexported there). One read with one name in both realms beats two constants and a
2436// formula the caller has to get right twice. This read also subsumes the constant:
2437// launchHeight is WindowEndHeight(-1).
2438//
2439// NEGATIVE OR FAR-FUTURE window IS NOT REFUSED and returns the arithmetic for it,
2440// exactly as windowEndHeight does — this is a pure function of window and launchHeight
2441// with no record lookup, so there is no "no such window" state to report.
2442func WindowEndHeight(window int64) int64 {
2443	return windowEndHeight(window)
2444}
2445
2446// CurrentWindowEndHeight is the chain height at which the LIVE window ends: the one
2447// number a countdown wants. WindowEndHeight(CurrentWindow()), read in a single call.
2448//
2449// IT IS ONE CALL ON PURPOSE, and it matters more here than in the raffle because this
2450// realm's window is an HOUR rather than a week. Composing the two exported reads off
2451// chain means two queries at two different chain heights, and a window that turns over
2452// between them yields a boundary for a window that has already ended — a countdown that
2453// jumps backwards by a whole hour exactly at the moment it matters most. Read here, both
2454// halves see one height.
2455//
2456// A BLOCK HEIGHT, NOT A TIME, for the reasons on WindowEndHeight.
2457func CurrentWindowEndHeight() int64 {
2458	return windowEndHeight(currentWindow())
2459}
2460
2461// PinOffsetRounds is pinOffsetRounds, EXPORTED so a front end (or an auditor) can check
2462// a window's pinned round against the anchor in its PinRound event without hardcoding
2463// the offset. Read pinOffsetRounds for what it protects.
2464func PinOffsetRounds() int64 { return pinOffsetRounds }
2465
2466// MaxStaleBlocks is speedMaxStaleBlocks, EXPORTED so a front end can tell a player why
2467// a Buy was refused — the beacon needs a relay — rather than showing them a panic.
2468// beacon.StaleBlocks() is the number to compare it against.
2469func MaxStaleBlocks() int64 { return speedMaxStaleBlocks }
2470
2471// DrawOf reports a window's record: committed, frozen ticket total, beacon round, pot,
2472// resolved, winner, abandoned, committed-at-height. All zero for a window that never
2473// sold a ticket.
2474//
2475// ABANDONED IS PART OF THIS TUPLE rather than a separate read because it is not a
2476// detail: it is the difference between a window that is waiting for its randomness and
2477// a window that is paying its buyers back and will never have a winner. A front end that
2478// showed "unresolved" for both would be lying to a buyer about whether to expect a draw
2479// or to go and claim a refund.
2480//
2481// COMMITTED-AT IS THE LAST ELEMENT, and it is here so a front end can compute the one
2482// number a buyer of a stuck window actually wants: CommittedAt + refundGraceBlocks, the
2483// height at which their money becomes reclaimable whatever the beacon does.
2484// RefundOpensAt does that arithmetic; this returns the raw height so a caller can also
2485// show how long the window has been waiting. Zero on an uncommitted window, which is
2486// exactly the "no refund clock is running yet" case.
2487//
2488// THERE IS NO SPONSORED ELEMENT, unlike the raffle's nine-tuple: this realm's pot is
2489// entry money only, so "pot minus sponsored" — the number a raffle buyer needs to know
2490// which part of the pot is a claim of theirs — is just the pot.
2491func DrawOf(window int64) (bool, int64, int64, int64, bool, address, bool, int64) {
2492	d := getDraw(window)
2493	if d == nil {
2494		return false, 0, 0, 0, false, address(""), false, 0
2495	}
2496	return d.Committed, d.FrozenTotal, d.Round, d.Pot, d.Resolved, d.Winner, d.Abandoned, d.CommittedAt
2497}
2498
2499// RefundGraceBlocks is refundGraceBlocks, EXPORTED so a front end can state the window
2500// without hardcoding it. Read refundGraceBlocks for what it means and how it was
2501// derived.
2502func RefundGraceBlocks() int64 { return refundGraceBlocks }
2503
2504// RefundOpensAt is the chain height from which Refund will pay this window's buyers
2505// REGARDLESS OF THE BEACON: CommittedAt + refundGraceBlocks.
2506//
2507// ZERO MEANS "THERE IS NO FUTURE HEIGHT TO WAIT FOR", which covers four unrelated
2508// states and a caller must not read any of them as "refundable now": no record for the
2509// window, not committed yet (nothing is locked up), already resolved (the pot has gone
2510// to its winner), or already abandoned (refunds are open this instant and the height is
2511// behind us). Render draws the same distinction, as "n/a".
2512func RefundOpensAt(window int64) int64 {
2513	d := getDraw(window)
2514	if d == nil || !d.Committed || d.Resolved || d.Abandoned || d.CommittedAt <= 0 {
2515		return 0
2516	}
2517	return d.CommittedAt + refundGraceBlocks
2518}
2519
2520// CapturedRandomness returns what Capture stored for a window, or the empty string if
2521// nothing was captured. Kept out of DrawOf so DrawOf's shape stays stable for callers.
2522func CapturedRandomness(window int64) string {
2523	d := getDraw(window)
2524	if d == nil {
2525		return ""
2526	}
2527	return d.Randomness
2528}
2529
2530// OpsPot is the house's EARNED revenue this realm holds, in ugnot: the fees of windows
2531// that actually drew, plus absorbed donations, plus any swept ticketless window's pot.
2532//
2533// IT IS NOT "THE ACCUMULATED HOUSE CUT" ANY MORE, which is what this comment said until
2534// 2026-09-29. A live window's fee is NOT in here — it is escrowed in that window and is
2535// still refundable. Read EscrowedCut and HeldCuts, and finding 25.
2536func OpsPot() int64 {
2537	return opsPot
2538}
2539
2540// EscrowedCut is the house fee held against one window and NOT YET EARNED, in ugnot: it
2541// becomes ops revenue if that window resolves and goes back to the buyers if it does not.
2542// Zero for a window that has resolved, been fully refunded, or never sold a ticket.
2543func EscrowedCut(window int64) int64 {
2544	d := getDraw(window)
2545	if d == nil {
2546		return 0
2547	}
2548	return d.Cut
2549}
2550
2551// HeldCuts is the total unearned house fee this realm holds across every window, in ugnot
2552// — the aggregate of EscrowedCut and the third term in the solvency equality.
2553func HeldCuts() int64 {
2554	return heldCuts
2555}
2556
2557// --- enumerating the windows ---------------------------------------------
2558//
2559// WHY A LISTER EXISTS AT ALL. Every other read here needs a window number the caller
2560// already has, and Render is prose. AND AT AN HOURLY CADENCE THE NEED IS SHARPER THAN
2561// IN THE WEEKLY REALM: 24 windows a day accumulate, most of them settled promptly and
2562// some of them not, so a front end looking for the windows that still need a Commit, a
2563// Resolve or a Refund has a real list to walk. Same shape as
2564// r/splash/arcade/raffle's Weeks and r/splash/arcade/duel's OfferIDs, down to the
2565// clamp: an offset and a limit in, at most `limit` window numbers out, `limit` clamped
2566// to PageLimit rather than refused. Window numbers only — DrawOf and
2567// TicketTotalOfWindow are the per-window readers, and a batched row reader would mean
2568// inventing a second serialisation of the same page for callers to parse.
2569
2570// PageLimit is the most window numbers one Windows call returns. A larger limit is
2571// clamped to it, not refused.
2572const PageLimit int64 = 100
2573
2574// WindowCount is the number of windows that have a record: the windows that sold at
2575// least one ticket. O(1); avl keeps its size.
2576func WindowCount() int64 {
2577	return int64(draws.Size())
2578}
2579
2580// Windows is a window over the recorded window numbers in ASCENDING ORDER: it skips
2581// `offset` of them and holds at most min(limit, PageLimit). Empty past the end.
2582// Negative arguments are a caller error and panic.
2583//
2584// Ascending order is window order, not merely key order, because windowKey is
2585// fixed-width and zero-padded (see keyWidth) — the same property Render relies on.
2586//
2587// A CALLER FINDS THE WINDOWS THAT STILL NEED SOMETHING DOING by walking this and
2588// keeping those where window < CurrentWindow() and DrawOf reports not resolved: those
2589// need a Commit if not committed, a Resolve once their round is relayed, and a Refund
2590// past RefundOpensAt if nobody relays it.
2591func Windows(offset, limit int64) []int64 {
2592	if offset < 0 {
2593		panic("speed: negative offset")
2594	}
2595	if limit < 0 {
2596		panic("speed: negative limit")
2597	}
2598	if limit > PageLimit {
2599		limit = PageLimit
2600	}
2601	remaining := int64(draws.Size()) - offset
2602	if remaining < limit {
2603		limit = remaining
2604	}
2605	if limit <= 0 {
2606		return []int64{}
2607	}
2608	windows := make([]int64, 0, limit)
2609	// IterateByOffset is avl's own offset-and-count traversal, O(log n + limit); it is
2610	// not a full scan followed by a slice.
2611	draws.IterateByOffset(int(offset), int(limit), func(key string, _ any) bool {
2612		windows = append(windows, windowOfKey(key))
2613		return false
2614	})
2615	return windows
2616}
2617
2618// windowOfKey inverts windowKey. ParseInt reads the zero padding fine; a key that does
2619// not parse cannot have come from windowKey, so it is a corrupt tree and not a case to
2620// paper over.
2621func windowOfKey(key string) int64 {
2622	window, err := strconv.ParseInt(key, 10, 64)
2623	if err != nil {
2624		panic("speed: malformed window key " + key)
2625	}
2626	return window
2627}
2628
2629// Render is the human-readable view. It ITERATES THE avl.Tree in key order, which is
2630// window order because windowKey is fixed-width and zero-padded, so the output is
2631// byte-identical across runs. A Go map here would randomise the order and make the
2632// rendered state unreproducible.
2633//
2634// IT LISTS EVERY RECORDED WINDOW, and at 24 a day that table grows without bound —
2635// which is the same unbounded growth r/splash/arcade/raffle's Render has at 52 a year,
2636// reached ~168x faster. It is left unbounded here deliberately rather than silently
2637// truncated: Windows/PageLimit is the paged reader for a front end, and a Render that
2638// quietly stopped at N would be a page that lies about the realm's state. If gnoweb's
2639// response size ever becomes the binding constraint, the right change is an explicit
2640// "showing the last N of M" line, not a silent cap.
2641func Render(path string) string {
2642	i64 := strconv.FormatInt
2643
2644	out := "# SPLASH hourly draw\n\n"
2645	out += "A draw every hour: " + i64(windowBlocks, 10) + " blocks, about one hour at ~3.3s a block.\n"
2646	out += "Tickets are " + i64(TicketPrice, 10) + " ugnot each (wrapped GNOT, 6 decimals: 1 GNOT = 1000000 ugnot).\n"
2647	out += "The house takes " + i64(houseCutBps, 10) + " bps of each sale; the rest is the prize, and the whole prize is paid to the winner's wallet.\n"
2648	out += "THE HOUSE FEE IS HELD IN ESCROW PER WINDOW AND IS EARNED ONLY BY A DRAW: a window that is refunded returns the FULL ticket price.\n"
2649	out += "One ticket is one unit of draw weight here — unlike the weekly raffle, every unit in this realm is bought.\n\n"
2650	// THE SELF-CONTAINED PROPERTY, SAID ON THE PAGE AND NOT ONLY IN THE SOURCE. A
2651	// player deciding whether to enter alone deserves to know that the pot is entry
2652	// money only, because it is exactly what makes playing alone pointless rather than
2653	// profitable.
2654	out += "The pot is entry money only: there is no sponsorship and nothing outside this realm can add to it.\n"
2655	out += "So a sole entrant simply wins their own stake back less the house cut — there is no jackpot to farm.\n\n"
2656	out += "- current window: " + i64(currentWindow(), 10) + "\n"
2657	out += "- this window's pot: " + i64(PotSize(), 10) + " ugnot\n"
2658	out += "- this window's tickets: " + i64(TicketTotal(), 10) + "\n"
2659	out += "- held across all unresolved windows: " + i64(heldPots, 10) + " ugnot\n"
2660	out += "- house fees held in escrow, NOT yet earned and still refundable: " + i64(heldCuts, 10) + " ugnot\n"
2661	out += "- ops pot (earned): " + i64(opsPot, 10) + " ugnot\n"
2662	out += "- windows recorded: " + strconv.Itoa(draws.Size()) + "\n"
2663	out += "- current chain height: " + i64(runtime.ChainHeight(), 10) + "\n"
2664	// THE TWO NUMBERS A BUYER OF A STUCK WINDOW NEEDS. The grace window is stated once
2665	// here and the per-window table below gives the height it lands on, so a front end
2666	// can tell a buyer when their money becomes reclaimable even if nobody ever relays
2667	// their round. See refundGraceBlocks.
2668	out += "- refunds open unconditionally " + i64(refundGraceBlocks, 10) +
2669		" blocks after a window is committed (about " + i64(refundGraceWindows, 10) +
2670		" hours), whatever the beacon does\n"
2671	// THE PIN, ON THE PAGE, because the honesty of the draw rests on it and a reader
2672	// should not have to read the source to find the number.
2673	out += "- each window's beacon round is pinned " + i64(pinOffsetRounds, 10) +
2674		" drand rounds ahead when the window opens (" + i64(pinOffsetRounds*beacon.PeriodSeconds, 10) +
2675		" seconds), so it is signed after entries close\n"
2676
2677	if draws.Size() == 0 {
2678		out += "\nNo tickets have been sold yet.\n"
2679		return out
2680	}
2681
2682	out += "\n| window | tickets | pot | escrowed fee | committed | round | captured | winner | abandoned | refund opens at |\n"
2683	out += "|---|---|---|---|---|---|---|---|---|---|\n"
2684	draws.Iterate("", "", func(_ string, value any) bool {
2685		d := value.(*Draw)
2686		committed := "no"
2687		total := d.tickets.Total()
2688		if d.Committed {
2689			committed = "yes"
2690			total = d.FrozenTotal
2691		}
2692		captured := "no"
2693		if d.Randomness != "" {
2694			captured = "yes"
2695		}
2696		winner := "unresolved"
2697		if d.Resolved {
2698			winner = string(d.Winner)
2699		}
2700		// ABANDONED IS SHOWN, and shown separately from the winner column: a reader must
2701		// be able to tell a window still waiting for its randomness from one that is
2702		// refunding and will never be drawn.
2703		abandoned := "no"
2704		if d.Abandoned {
2705			abandoned = "yes"
2706			if !d.Resolved {
2707				winner = "refunding"
2708			}
2709		}
2710		// THE HEIGHT A BUYER CAN STOP WAITING AT, per window. "n/a" on a window with no
2711		// refund clock running: uncommitted (nothing is locked yet), already resolved
2712		// (the pot has gone to its winner), or already abandoned (refunds are open now
2713		// and this number is behind us). A bare height rather than a countdown, because
2714		// Render must be byte-identical for the same state.
2715		refundOpens := "n/a"
2716		if d.Committed && !d.Resolved && !d.Abandoned && d.CommittedAt > 0 {
2717			refundOpens = i64(d.CommittedAt+refundGraceBlocks, 10)
2718		}
2719		out += "| " + i64(d.Window, 10) +
2720			" | " + i64(total, 10) +
2721			" | " + i64(d.Pot, 10) +
2722			" | " + i64(d.Cut, 10) +
2723			" | " + committed +
2724			" | " + i64(d.Round, 10) +
2725			" | " + captured +
2726			" | " + winner +
2727			" | " + abandoned +
2728			" | " + refundOpens + " |\n"
2729		return false
2730	})
2731	return out
2732}