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}