raffle.gno
318.68 Kb · 5838 lines
1// Package raffle is the SPLASH weekly ticket raffle.
2//
3// WHAT THIS REPLACES, AND WHY IT IS A SEPARATE REALM.
4//
5// r/splash/game is a permanent-deposit yield product: you deposit GNOT, you
6// can never withdraw, your position size IS your ticket count, and winning the
7// weekly draw ADDS the pot to your locked position rather than paying your
8// wallet. Its raffle had to live inside that realm because the draw mutated
9// three pieces of that realm's state atomically (see the long comment at the
10// top of r/splash/game/raffle.gno).
11//
12// This realm sells TICKETS, not positions. A ticket costs TicketPrice and buys
13// exactly unitsPerTicket units of one week's draw — one ticket was one unit until
14// 2026-09-29, and EntryUnitPrice says why the two numbers had to come apart; the
15// pot pays out to the winner's WALLET;
16// and nothing a buyer paid is held beyond the week they bought into. Because
17// there is no position, no yield index and no permanent deposit, the draw needs
18// nothing from r/splash/game and this realm holds no authority over it, over
19// r/splash/beacon, or over r/gnoland/wugnot.
20//
21// THERE IS EXACTLY ONE PACKAGE-PATH GUARD IN THIS FILE, AND UNTIL 2026-09-29
22// THERE WERE NONE. The sentence that used to be here — "there is no
23// package-path guard anywhere in this file and nothing for one to protect" —
24// is no longer true and is not quietly deleted. FundPotWithEntries admits
25// r/splash/arcade/duel and nobody else, because duel play now EARNS entries in
26// the live week's draw (AUDIT-2026-09-28.md finding 15). Read that function in
27// full before touching it: it is the only privileged door in this product, it
28// is the shape r/splash/game declined to add five times, and what keeps it
29// small is that everything it can do is bounded by the money delivered through
30// it in the same call. Every OTHER entry point here is still permissionless and
31// can still only do the one thing the week's state machine allows next.
32//
33// THE MECHANICS ARE r/splash/game/raffle.gno's, DELIBERATELY UNCHANGED: the
34// week number derived from the global clock, the Commit/Capture/Resolve
35// three-phase cycle, and above all the FROZEN-TOTAL property. Read Commit
36// below before changing anything: freezing the ticket total is what stops a
37// buyer from waiting for the randomness to publish and then buying exactly the
38// winning ticket index, and that attack was live in an earlier version of the
39// old realm.
40//
41// ONE MECHANIC IS NOT THE OLD REALM'S ANY MORE: WHICH ROUND DECIDES A WEEK. The
42// old realm — and this one until 2026-09-28 — bound Round = beacon.Latest() + 1
43// inside Commit, which handed the round choice to whoever called Commit, AFTER the
44// week's ticket set was already frozen and public. The publisher's key can sign any
45// round at will and beacon.Publish accepts rounds in any order, so that was a
46// deterministic, risk-free win for the key holder. The round is now PINNED WHEN THE
47// WEEK'S RECORD IS CREATED — before the first ticket of that week exists — and is
48// never written again. See getOrCreateDraw and pinOffsetRounds.
49//
50// THAT DOES NOT MAKE THE DRAW TRUSTLESS, and nothing in this file should be read
51// as claiming it does. The beacon's key holder still knows every round's
52// randomness in advance, so they can still rig a week by choosing the MODULUS —
53// buying last, just before the week closes, to move FrozenTotal and the unit
54// ranges. Pinning removes the round-selection freedom, which was the cheap,
55// deniable and exact version of the attack; the residual one leaves an on-chain
56// trace as a suspicious late purchase. THE ARCHITECTURAL PROBLEM REMAINS OPEN and
57// is the owner's decision: closing it needs an externally-controlled beacon or a
58// VRF. See AUDIT-2026-09-28.md findings 4 and 10.
59//
60// THE ARCADE FEEDS THIS POT, AND SINCE 2026-09-29 IT ALSO FILLS THE DRAW.
61// r/splash/arcade/duel sends its rake here, so every coin flip makes this week's
62// prize bigger — that is what makes the duel and the raffle one product instead of
63// two contracts. The rake itself still buys no tickets and no winning chance: it is
64// tracked separately as Draw.Sponsored, because the refund safety property is the
65// identity Pot == sum(spends) + Sponsored and house money belongs to no buyer. Read
66// Draw.Sponsored and FundPot together before changing either.
67//
68// WHAT CHANGED IS THAT THE PLAYERS WHO PAID THAT RAKE NOW ARRIVE WITH IT. Until
69// 2026-09-29 the money came and the entrants did not, so a week of busy coin
70// flipping and no ticket sales could be won outright by one cheap ticket bought
71// in its final block — the whole pot, to somebody who had contributed none of it.
72// FundPotWithEntries is the fix: the duel delivers the same rake and, in the same
73// call, the two players of each settled duel are credited entries worth EXACTLY
74// what that rake would have bought at EntryUnitPrice. The money and the entrants now
75// arrive together. Earned entries carry ticket weight and NO spendOf record, which
76// is consistent with every identity in this file and is spelled out at
77// FundPotWithEntries. AUDIT-2026-09-28.md finding 15.
78//
79// WHAT IS NEW, and could not have existed in the old design: Refund. There,
80// the pot was accrued protocol fees and nobody's stake was ever at risk, so an
81// unresolvable draw could simply wait forever. Here the pot is buyers' own money, so a
82// week nobody settles must have a way back out. SINCE THE DRAND MIGRATION THAT IS A
83// LIVENESS EXIT AND NOT A LOSS EXIT — no round is ever unobtainable, so what Refund
84// guards against is nobody bothering to relay one. See Refund.
85package raffle
86
87import (
88 "math/bits"
89 "strconv"
90 "strings"
91 "unicode/utf8"
92
93 "chain"
94 "chain/runtime"
95 "chain/runtime/unsafe"
96
97 "gno.land/p/nt/avl/v0"
98 "gno.land/p/nt/markdown/sanitize/v0"
99 "gno.land/p/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/fenwick"
100
101 "gno.land/r/gnoland/wugnot"
102 "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/beacon"
103)
104
105// --- the economics -------------------------------------------------------
106
107const (
108 // TicketPrice is the cost of one ticket in ugnot: 10 GNOT. Money here is
109 // wrapped GNOT (r/gnoland/wugnot), a GRC20 with 6 decimals, so 1 GNOT is
110 // 1_000_000 ugnot and every amount in this file is an int64 count of ugnot.
111 //
112 // EXPORTED because a front end must be able to quote the price without
113 // hardcoding it, and because a buyer's cost is tickets * this and nothing
114 // else — there is no dynamic pricing, no discount tier and no bonding curve.
115 //
116 // IT IS NO LONGER THE GRANULARITY OF THE DRAW, AND THAT SEPARATION IS THE WHOLE
117 // POINT OF EntryUnitPrice BELOW. Until 2026-09-29 this one constant was both "what
118 // a ticket costs" and "what one unit of winning weight is worth", and raising it
119 // from 0.1 to 10 GNOT would have silently deleted the earned-entry feature for
120 // three of the duel's four rungs. AUDIT-2026-09-28.md finding 22.
121 TicketPrice int64 = 10_000_000
122
123 // EntryUnitPrice is the value of ONE UNIT OF WEIGHT IN THE DRAW: 0.25 GNOT.
124 //
125 // IT IS THE SECOND, INDEPENDENT PRODUCT PARAMETER THAT USED TO SHARE TicketPrice'S
126 // CONSTANT, and the numbers that forced them apart are worth keeping here because
127 // they are what a future edit will recreate:
128 //
129 // The duel's players-side rake per rung is 0.36 / 1.80 / 7.20 / 36.00 GNOT
130 // (after the 60/40 split), off rakes of 0.6 / 3 / 12 / 60 GNOT. An earned entry
131 // is priced at what the RAKE would have bought (r/splash/arcade/duel's
132 // rakeEntryUnits — that equality is the security property of the whole feature).
133 // Price an entry at a 10 GNOT TICKET and rungs 0, 1 and 2 — 0.6, 3 and 12 GNOT
134 // of rake — earn 0, 0 and 1 entries. THREE OF THE FOUR RUNGS EARN NOTHING, and
135 // finding 15's fix, which exists so that the people whose rake built the pot are
136 // in the draw for it, dies for everyone but whales.
137 //
138 // So the two numbers are separated: a TICKET is a price, an ENTRY UNIT is a
139 // granularity, and one ticket is simply worth unitsPerTicket of them. Everything
140 // downstream of appendTickets — the fenwick index, ownerOfUnit, randomnessMod,
141 // FrozenTotal — has always worked in arbitrary units of weight and needed no
142 // change at all; what had to change is every place that turned money into weight
143 // or weight into money.
144 //
145 // EXPORTED for the same reason TicketPrice is: r/splash/arcade/duel prices its
146 // earned entries against it, and a front end must be able to quote "one ticket is
147 // 40 entries" without hardcoding either number.
148 EntryUnitPrice int64 = 250_000
149
150 // unitsPerTicket is how many units of draw weight one ticket buys: 40.
151 //
152 // DERIVED, NEVER WRITTEN OUT, and the build guard below refuses any pair for which
153 // it is not exact — see there for why dust in the weight domain would be
154 // unrecoverable rather than merely untidy.
155 unitsPerTicket = TicketPrice / EntryUnitPrice
156
157 // HouseCutBps is the share of every ticket sale that is the house's fee rather
158 // than prize money: 500 bps, 5%.
159 //
160 // IT IS ESCROWED PER WEEK AND EARNED ONLY BY A DRAW, since 2026-09-29. Buy no
161 // longer credits opsPot; it credits Draw.Cut, and that money reaches opsPot only
162 // on a successful Resolve. A week that never draws refunds it as part of each
163 // buyer's GROSS ticket price. AUDIT-2026-09-28.md finding 25.
164 //
165 // EXPORTED ON 2026-09-30, AND FOR A REASON THAT IS NOT A FRONT END'S CONVENIENCE.
166 // r/splash/arcade/duel's earned entries are safe only while flipping is never CHEAPER
167 // than buying, and the two prices meet in the amount of PRIZE MONEY each route puts
168 // behind one unit of draw weight: a bought unit delivers
169 // EntryUnitPrice x (bpsDenom - HouseCutBps) / bpsDenom, a flipped one
170 // EntryUnitPrice x rakePotShareBps / bpsDenom. So THIS CONSTANT IS ONE OF THE TWO
171 // TERMS OF THE DUEL'S SECURITY ARGUMENT, and the duel now carries a build guard that
172 // refuses a rake split which would invert it (see its guard beside rakePotShareBps,
173 // and AUDIT-duel-2026-09-27.md addendum 2, finding D-2). A guard cannot be written
174 // against a constant it cannot name.
175 //
176 // IT IS A COMPILE-TIME CONST AND NOT A FUNCTION, DELIBERATELY, exactly as TicketPrice
177 // and EntryUnitPrice are: the duel reads it in an array length and in comments, so it
178 // must add NO runtime dependency on this realm to any path over there — which is the
179 // property finding 14 rests on (see the duel's checkInvariant).
180 HouseCutBps int64 = 500
181 bpsDenom int64 = 10_000
182
183 // cutPerTicket is the house fee on ONE ticket: 500_000 ugnot.
184 //
185 // IT EXISTS FOR THE BUILD GUARD, NOT FOR THE ARITHMETIC. Buy still computes its
186 // cut with mulDiv over the whole cost, exactly as it always did. What this
187 // constant and its guard establish is that the cut is EXACTLY PROPORTIONAL to the
188 // number of tickets — no flooring anywhere — which is precisely the property
189 // Refund's per-buyer split needs in order to be exact. Read the guard.
190 cutPerTicket = TicketPrice * HouseCutBps / bpsDenom
191
192 // MinSponsorship is the floor on one Sponsor call: ten tickets' worth.
193 //
194 // IT IS A PRODUCT RULE, NOT A SAFETY ONE — the owner's, 2026-09-30: "it should be
195 // a meaningful set amount, should be a threshold for sponsorship". A credit line
196 // beside the prize is advertising, and advertising bought for dust is spam. The
197 // figure is a hundred tickets so it reads against a number a player already
198 // knows, rather than being a new denomination to learn.
199 //
200 // IT WAS TEN TICKETS FOR ABOUT AN HOUR AND THAT WAS TOO CHEAP IN REAL MONEY.
201 // GNOT was $0.074 on 2026-09-30, so ten tickets is ~$7.40 — the price of putting
202 // your name beside a prize for a week, which is not a price. The owner: "it's too
203 // cheap, needs to be 10x at least". A hundred tickets is ~$74.
204 //
205 // AND IT IS A GNOT FIGURE, SO ITS REAL VALUE MOVES. Anyone revisiting this should
206 // price it in fiat on the day rather than trusting the number: at a 10x in GNOT
207 // this floor becomes ~$740 without anybody deciding that.
208 //
209 // NOTE WHAT IT DOES NOT GATE. FundPot — the duel's door — has no floor and must
210 // not gain one: a flush carries whatever rake has accumulated, frequently far less
211 // than this, and a floor there would strand it. The two doors are separate
212 // precisely so this constant can exist.
213 MinSponsorship int64 = 100 * TicketPrice
214
215 // maxSponsorLabelLen bounds the credit line, in BYTES rather than runes, because
216 // a byte bound is what makes the storage cost predictable. Same figure and same
217 // reasoning as the duel's label.
218 maxSponsorLabelLen = 64
219
220 // maxSponsorsPerWeek bounds the list Render walks and the storage one week can be
221 // made to hold. At MinSponsorship each slot costs a sponsor 100 GNOT, so filling a
222 // week costs 1,600 GNOT — the bound is belt-and-braces over a price that already
223 // makes flooding expensive, and it is here so the cost of a full week is a fixed
224 // number rather than whatever an attacker's budget happens to be.
225 maxSponsorsPerWeek = 16
226)
227
228// A NEGATIVE ARRAY LENGTH IS A COMPILE ERROR, so this line fails the BUILD if a
229// future edit pushes the house cut past the denominator — the case in which the
230// prize remainder would go negative and a sale would credit the pot with less
231// than nothing. Same guard shape as r/splash/arcade/duel's rake split.
232//
233// THE `- 1` IS NOT DECORATION AND WAS MISSING UNTIL TODAY. Without it the guard admits
234// HouseCutBps == bpsDenom, which compiles to a length of zero and is therefore SATISFIED
235// — while the house takes 100% of every sale, net is 0, and Buy credits the prize pot with
236// nothing at all. Tickets sold into a zero pot, with the build green. Dead at 500 bps, and
237// the cost of the character is nothing (AUDIT-2026-09-28.md finding 11).
238var _ [bpsDenom - HouseCutBps - 1]struct{}
239
240// A TICKET MUST BE A WHOLE NUMBER OF ENTRY UNITS, and this line fails the BUILD if
241// TicketPrice % EntryUnitPrice != 0. Length 0 when they divide exactly, NEGATIVE — and
242// therefore a compile error — otherwise.
243//
244// WHY DUST IN THE WEIGHT DOMAIN IS NOT A ROUNDING NUISANCE BUT AN UNRECOVERABLE ONE.
245// Buy charges tickets * TicketPrice and appends tickets * unitsPerTicket of weight. If
246// the division had a remainder, the buyer would pay for a fraction of a unit they can
247// never hold: the fenwick index takes int64 deltas and REFUSES NEGATIVE ONES BY DESIGN,
248// FrozenTotal must never move once a week is committed, and nothing in this realm can
249// retire or adjust an index once appended (see Draw.tickets, and finding 3 on why
250// refunds cannot retire tickets). So there is no place to carry a remainder to and no
251// later call that could reconcile it — the money would be in the pot and the weight it
252// paid for would not exist. Refusing the pair at compile time is the only fail-closed
253// answer available.
254//
255// IT IS A GUARD THAT CAN ACTUALLY FAIL, which the doctrine of finding 11 demands:
256// BOTH constants appear, neither cancels, and the two sides are the same physical
257// quantity (ugnot) reached by two different routes — one a product of the derived unit
258// count, one the price itself. Set EntryUnitPrice to 300_000 against a 10 GNOT ticket
259// and the build stops; the mutation and its exact output are recorded in finding 22.
260var _ [unitsPerTicket*EntryUnitPrice - TicketPrice]struct{}
261
262// AND A TICKET MUST BE WORTH AT LEAST ONE UNIT. Exact divisibility alone admits
263// EntryUnitPrice > TicketPrice, where unitsPerTicket floors to zero and every purchase
264// would append nothing at all — money into a pot, no weight in the draw, and a week
265// whose FrozenTotal is zero that Commit then refuses for ever. 39 elements as the
266// constants stand, which is small enough for the GnoVM to materialise (the duel's
267// ladder note is why token-scale array guards are forbidden; this one is a unit count).
268var _ [unitsPerTicket - 1]struct{}
269
270// THE HOUSE CUT MUST BE EXACT ON A SINGLE TICKET, and this is the guard the escrowed
271// fee's refund path rests on. Length 0 when TicketPrice * HouseCutBps divides bpsDenom
272// exactly, negative otherwise.
273//
274// WHAT BREAKS WITHOUT IT, precisely. Buy floors the cut over a whole call's cost and
275// adds it to Draw.Cut; Refund re-derives one buyer's share of that Cut by flooring over
276// their accumulated GROSS. Those two only agree for every possible sequence of
277// purchases if the flooring never actually discards anything — because
278// floor(a/n) + floor(b/n) can be one LESS than floor((a+b)/n). With a remainder, two
279// separate one-ticket buys would book less Cut than a single refund of the pair
280// re-derives, Draw.Cut would go NEGATIVE, and checkInvariant would abort the refund of
281// a buyer whose own money is sitting in the realm — a "money in, no way out" path
282// created by a rounding error. With the guard, cut == tickets * cutPerTicket for every
283// call and every partial sum, so the derivation is exact by construction rather than by
284// argument. AUDIT-2026-09-28.md finding 25.
285var _ [cutPerTicket*bpsDenom - TicketPrice*HouseCutBps]struct{}
286
287// --- the ops destination -------------------------------------------------
288//
289// opsVaultAddr — gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/ops. THE ONE PLACE THE HOUSE'S MONEY CAN GO,
290// fixed at compile time, and a VERBATIM DUPLICATE of r/splash/arcade/duel's and
291// r/splash/exchange's constant of the same name.
292//
293// IT IS DUPLICATED RATHER THAN IMPORTED for the reason stated in duel.gno: a
294// realm's address is a pure function of its package path
295// (gnolang.DerivePkgCryptoAddr), the sibling realms' constants are unexported,
296// and this is a different package. The realm at that path need not exist yet for
297// the address to be right.
298//
299// tools/raffle_addr_test.go re-derives it from the package path on every
300// `go test ./tools`, fails on a mismatch, and fails on any address constant in
301// this file it has not been told about — the guard r/splash/exchange's R3 finding
302// (a label hash shipped as a live destination) showed to be necessary. A Gno-side
303// test cannot be that guard: an assertion that WithdrawOps' transfer landed at
304// opsVaultAddr is true for ANY value of opsVaultAddr, including a wrong one, so a
305// passing suite would pin the defect rather than catch it.
306//
307// BEING A COMPILE-TIME CONSTANT IS ALSO THE WHOLE AUTHORISATION ARGUMENT FOR
308// WithdrawOps BEING PERMISSIONLESS — see there.
309const opsVaultAddr address = "g1dqnxfp5zj89ggfcfzvampmhmrkjq36d2zvdyk7"
310
311// blocksPerDay is the block-count day unit, copied from r/splash/game (which
312// documents the choice): this toolchain exposes no block-timestamp getter, so
313// the clock is chain height. NOTHING IN THIS REALM IS A TIMESTAMP — a week is a
314// sequence number derived from the global clock, and no ticket, spend or draw
315// is dated.
316const blocksPerDay = 26182
317
318// raffleWeekDays is the draw period in those day units. A FREE DIAL, as in
319// r/splash/game: referenced from exactly one place (currentWeek), and nothing
320// else in the file assumes seven of anything.
321const raffleWeekDays = 7
322
323// randomnessHexLen is the length of a beacon randomness string: sha256,
324// hex-encoded, 64 characters.
325const randomnessHexLen = 64
326
327// --- the beacon's cadence ------------------------------------------------
328//
329// beaconRoundsPerDay IS THE ONE CADENCE ASSUMPTION THIS FILE MAKES, and the round
330// pin below is a duration expressed through it.
331//
332// IT IS NO LONGER A NUMBER TYPED INTO THIS FILE, and that change is the single
333// most important line in the drand migration. It used to read
334// `const beaconRoundsPerDay = 288` — one round every five minutes, the cadence of
335// the beacon SPLASH itself ran. r/splash/beacon now verifies drand evmnet, whose
336// period is THREE SECONDS, so the true figure is 86400/3 = 28800 and every
337// round-denominated window in this file was wrong by a factor of 100 the moment
338// the key changed. Taking the number from the beacon means the two can never
339// disagree again.
340//
341// WHY 100x WRONG WAS NOT MERELY UNTIDY — IT SILENTLY RE-OPENED
342// AUDIT-2026-09-28.md FINDING 10. pinOffsetRounds is derived from this constant
343// and exists to make the week's pinned round fall AFTER ticket sales close. At 288
344// the offset measured 1.92 hours of real time instead of 8 days, so the winning
345// randomness would have been published on a public API days before the week ended
346// and the last purchaser of the week could buy the winning unit outright. AND THE
347// BUILD GUARD DID NOT CARE: the old
348// `var _ [pinOffsetRounds - raffleWeekDays*beaconRoundsPerDay - 1]struct{}`
349// compares rounds to rounds, this constant appears on both sides and cancels, so
350// the assertion stayed satisfied while measuring a hundredth of the real time. AN
351// ASSERTION THAT CANNOT FAIL IS NOT AN ASSERTION. It is replaced below by one that
352// compares SECONDS to SECONDS, drawing the two sides from different clocks so that
353// no single wrong constant can cancel itself out.
354//
355// THE OTHER CLOCK, unchanged: a block is ~3.3s, so a day is blocksPerDay = 26182
356// blocks (ECONOMICS.md §7a-bis's measured block time, the constant r/splash/game
357// and r/splash/arcade/duel also use). NOTE THAT THE BLOCK-DENOMINATED CONSTANTS DID
358// NOT MOVE IN THE MIGRATION AND THE ROUND-DENOMINATED ONES ALL MOVED BY 100x —
359// half this file's durations were right and half were wrong, which is exactly why
360// the error was invisible. There is no block-timestamp getter here, so a realm
361// cannot measure either clock at runtime; both are assumptions, and they are
362// written down rather than implied.
363const beaconRoundsPerDay = beacon.RoundsPerDay
364
365// --- pinning the week's beacon round -------------------------------------
366//
367// THE ROUND A WEEK IS DRAWN ON IS FIXED WHEN THE WEEK'S RECORD IS CREATED, never
368// when it is committed. getOrCreateDraw writes Draw.Round exactly once and nothing
369// else in this file ever writes it again.
370//
371// THE DEFECT THAT FORCED THIS, and it was the operator's cheapest attack on the
372// whole product. Commit used to bind Round = beacon.Latest() + 1. The beacon's
373// publisher BLS key is compiled in with no rotation path and the signed message is
374// derived ONLY from the chain id and the round number (see r/splash/beacon), so the
375// key holder can produce a valid signature for ANY round at will and therefore knows
376// every round's randomness in advance, permanently. beacon.Publish applies no
377// sequence check and no window check, and latestRound only ever rises. So the key
378// holder could: buy one ticket during the week; wait for the week to close, which
379// froze the ticket set into something public and immutable; enumerate candidate
380// rounds off-chain computing ownerOfUnit(d, randomness(r) mod FrozenTotal) for each;
381// Publish rounds until beacon.Latest()+1 was a round that named them; and then
382// Commit. A DETERMINISTIC, RISK-FREE WIN THAT LOOKED EXACTLY LIKE A NORMAL
383// SETTLEMENT. Note the irony, so it is not repeated: requiring Commit to take a
384// FINISHED week (the 2026-09-28 griefing fix) made this strictly worse, because
385// freezing the ticket set before the round was chosen turned an available attack
386// into an exact one.
387//
388// WHAT PINNING AT CREATION ACHIEVES. The round is fixed before the week's first
389// ticket exists, so nobody — key holder or not — can move the draw onto a round
390// that suits the ticket set, because there is no ticket set yet. The committer has
391// no round choice left at all: Commit reads d.Round and cannot compute one.
392//
393// WHAT IT DOES NOT ACHIEVE, STATED PLAINLY BECAUSE OVERSTATING IT WOULD BE WORSE
394// THAN NOT DOING IT. It does NOT stop the key holder rigging the draw. They still
395// know randomness(d.Round) from the moment the week opens, so they can still choose
396// the MODULUS: be the final purchaser before the week closes, sizing the buy so that
397// randomness mod FrozenTotal lands on a unit they own. That is a strictly worse
398// attack for them — it costs real tickets and leaves an on-chain trace as a
399// suspicious late buy — but it is not closed here and this realm cannot close it.
400// THE ARCHITECTURAL PROBLEM (the project holding the beacon key) REMAINS OPEN AND IS
401// THE OWNER'S DECISION: an externally-controlled beacon or a VRF. See
402// AUDIT-2026-09-28.md findings 4 and 10.
403//
404// THE DERIVATION OF THE OFFSET, which must not be a picked number:
405//
406// THE PROPERTY REQUIRED is that the pinned round falls AFTER that week's ticket
407// sales close, so that in honest operation the round's randomness does not exist
408// until the ticket set is already frozen. A week's record can be created at any
409// point inside the week it belongs to (Buy and FundPot both act on currentWeek()),
410// and the worst case is the FIRST BLOCK of that week: the whole week — raffleWeekDays
411// days — still has to elapse before sales close.
412//
413// SO THE OFFSET MUST EXCEED ONE WEEK OF ROUNDS: raffleWeekDays * beaconRoundsPerDay
414// = 7 * 28800 = 201600 rounds. One day of margin on top absorbs ordinary cadence
415// jitter and the estimation error described below, giving pinOffsetDays = 8 and
416// pinOffsetRounds = 230400.
417//
418// THE COST OF THE MARGIN IS PAID IN WAITING, WHICH IS WHY IT IS ONE DAY AND NOT
419// SEVEN. A week whose record is created late in the week pins a round up to
420// pinOffsetDays after the week closes, so Resolve may have to wait that long after
421// Commit. That wait is the top half of the honest cycle the refund grace window has
422// to outlast; see refundGraceDays, where this constant appears in the derivation.
423//
424// WHY THE ROUND NUMBER, NOT A HEIGHT: rounds are a beacon counter with no on-chain
425// mapping to chain height, so the only anchor a realm has is what the beacon reports
426// at pin time. That is also why pinning requires the beacon to have published at
427// least once — see getOrCreateDraw.
428//
429// AND WHY THE ANCHOR IS beacon.EstimatedLatestRound() AND NOT beacon.Latest().
430// THIS IS A DRAND-MIGRATION CHANGE AND IT IS SECURITY-CRITICAL. Under the old
431// SPLASH beacon, Latest() was approximately the current round by assumption: the
432// publisher was this project, emitting every five minutes, so "highest published"
433// and "now" were within one period of each other. r/splash/beacon is now an
434// ON-DEMAND RECORD of drand — nothing relays routinely, because nothing needs to —
435// so Latest() is whatever round somebody last had a reason to relay. That is
436// plausibly last week's pinned round, and after a quiet spell, months old.
437//
438// Pinning Latest() + pinOffsetRounds against a stale anchor measures the offset
439// from the past, so the pinned round lands EARLIER in real time than intended. Let
440// the anchor be stale by Δ rounds: the pin lands at now − 3Δ seconds + 8 days, and
441// sales close at most 7 days from now, so as soon as 3Δ > 1 day — Δ > 28800 rounds,
442// ONE DAY OF STALENESS — the pinned round's randomness is public before sales
443// close. THAT IS FINDING 10'S LAST-BUYER ATTACK, VERBATIM, and no guard in this file
444// can see it, because the file has no way to tell a fresh anchor from a stale one.
445//
446// beacon.EstimatedLatestRound() corrects for exactly that: it adds the blocks
447// elapsed since the anchor round was relayed, converted at the two cadences, using
448// a clock no relayer controls. It equals Latest() when nothing is stale, so this is
449// a strict improvement at no liveness cost.
450//
451// THE RESIDUAL ERROR, AND THE FIGURE IN THIS COMMENT WAS WRONG BY A FACTOR OF NINE
452// UNTIL TODAY. It read "a day of staleness at 10% block drift is ~14 minutes and does
453// not matter", and so did DRAND-MIGRATION.md §5b/§9.4(a), AUDIT-2026-09-28.md §A2 and
454// the duel's audit addendum. Derived rather than copied:
455//
456// A = 86400/blocksPerDay = 3.29998 s the block time this file and the beacon assume
457// B = the chain's real average block time
458// S = blocks since the beacon's anchor was last raised
459//
460// error = S*(A − B)/PeriodSeconds rounds = S*(A − B) SECONDS of drand time
461//
462// which in terms of elapsed real time T = S*B is T*(A/B − 1), i.e. |T*(1 − A/B)|:
463//
464// ONE DAY of staleness at 10% drift: 86400*(1 − 3.29998/3.63) = 7854 s = 2.2 HOURS
465// (or 2.4 hours in the fast direction, B = 3.0 s)
466// ONE YEAR at the same 10%: ~36 days — which was RIGHT, and that is what
467// localises the slip: 2.2 hours x 365 is 33 days, so the year
468// figure was computed correctly and the day figure was not.
469//
470// THE SLIP WAS A DOUBLE DIVISION BY THE PERIOD: the formula "staleness x (real block
471// time − 3.3s) / 3s" yields ROUNDS when staleness is in seconds, and the ~14-minute
472// number comes from reading that as seconds and dividing by 3 again. 7854/9 = 873 s =
473// 14.5 minutes. See AUDIT-2026-09-28.md finding 13.
474//
475// AND THERE IS A SECOND TERM THE OLD FRAMING OMITTED ENTIRELY: THE WEEK LENGTHENS AND
476// THE PIN DOES NOT. A week here is a BLOCK count and pinOffsetRounds is a ROUND count,
477// so the pin margin is 691200 − raffleWeekDays*blocksPerDay*B seconds:
478//
479// B = 3.29998 (nominal) margin = 86400 s = 24 hours
480// B = 3.63 (10% slow) margin = 25916 s = 7.2 hours — BEFORE any staleness at all
481// B = 3.7714 margin = 0 — gone, at a 14.3% drift in one constant
482//
483// So finding 10's defence rests on a 14% tolerance in blocksPerDay, and the staleness
484// error eats into what is left of it. Both terms are now asserted at compile time by
485// the second guard below pinOffsetRounds.
486//
487// IT IS NO LONGER LEFT TO THE OWNER, AND THAT IS THE CHANGE OF 2026-09-28's LATER FIX.
488// Bounding the error means refusing to open a week against a beacon nobody has relayed
489// to recently, and the recorded reason for not doing it was cross-realm: getOrCreateDraw
490// is reached from FundPot, FundPot was called inside r/splash/arcade/duel's Collect, and
491// a refusing FundPot therefore stranded every later duel winner. THAT COUPLING IS GONE —
492// Collect books its rake and FlushPot delivers it, so a refusing FundPot now costs the
493// flusher a reverted transaction and nothing else — and the bound is in
494// r/splash/beacon itself, where a fifth consumer cannot forget it. See
495// beacon.maxEstimateStaleBlocks, and AUDIT-2026-09-28.md findings 12 and 14.
496const (
497 // pinMarginDays is the cadence-jitter and estimation margin described above.
498 // A FREE DIAL, but raising it lengthens the honest cycle and therefore feeds
499 // refundGraceDays.
500 pinMarginDays = 1
501
502 // pinOffsetDays is how far past the pin the round sits, in days: 7 + 1 = 8.
503 pinOffsetDays = raffleWeekDays + pinMarginDays
504
505 // pinOffsetRounds is that in beacon rounds — the number added to the beacon's
506 // estimated current round at record creation. 8 * 28800 = 230400.
507 pinOffsetRounds = beaconRoundsPerDay * pinOffsetDays
508)
509
510// THE PINNED ROUND MUST OUTLAST THE WEEK'S SALES, and this line fails the BUILD if
511// it ever stops doing so. A negative array length is a compile error.
512//
513// READ THE UNITS, BECAUSE THE UNITS ARE THE WHOLE POINT. This guard replaces
514//
515// var _ [pinOffsetRounds - raffleWeekDays*beaconRoundsPerDay - 1]struct{}
516//
517// which was dimensionally consistent and WRONG ABOUT THE WORLD: rounds on the left,
518// rounds on the right, beaconRoundsPerDay on both sides, cancelling. Set the cadence
519// to 288, to 28800 or to 3 and that guard was equally satisfied while the real-time
520// distance it was protecting changed by orders of magnitude. It is the reason the
521// five-minute-to-three-second switch could have shipped with finding 10 wide open.
522//
523// THIS GUARD COMPARES SECONDS TO SECONDS, and the two sides come from DIFFERENT
524// clocks so that no single constant appears on both and cancels:
525//
526// LEFT — the pinned round's distance in real seconds, via drand's period:
527// pinOffsetRounds * beacon.PeriodSeconds = 230400 * 3 = 691200 s = 8.000 days
528// RIGHT — one raffle week in real seconds, via the chain's ~3.3s block time:
529// raffleWeekDays * blocksPerDay * 33/10 = 183274 * 3.3 = 604804 s = 6.99995 days
530// (33/10 is integer constant arithmetic; 3.3 is not expressible as an int)
531// MARGIN = 86395 seconds, just under the one day pinMarginDays asks for.
532//
533// With the pre-migration cadence the left side reads 2304 * 3 = 6912 seconds against
534// the same 604804, the array length is negative, AND THE BUILD FAILS. That is the
535// assertion this file was missing.
536var _ [pinOffsetRounds*beacon.PeriodSeconds - raffleWeekDays*blocksPerDay*33/10 - 1]struct{}
537
538// AND THE MARGIN MUST ALSO SURVIVE THE BEACON'S WORST ADMITTED ESTIMATION ERROR, WITH THE
539// WEEK MEASURED AT THE SLOW EDGE OF THE BLOCK-TIME BAND. This second guard is the one the
540// drand migration did not have, and its absence is AUDIT-2026-09-28.md finding 12: the
541// guard above compares the pin against a week at the NOMINAL block time and says nothing
542// about either the week LENGTHENING or the anchor being WRONG.
543//
544// TWO TERMS, AND THE FIRST ONE IS THE ONE THAT WAS MISSING ENTIRELY:
545//
546// THE WEEK LENGTHENS AND THE PIN DOES NOT. A raffle week is a BLOCK count
547// (raffleWeekDays * blocksPerDay, read through currentWeek) while pinOffsetRounds is a
548// ROUND count, and drand's rounds are real time by definition. So at a block time B the
549// pin margin is 691200 - 183274*B seconds, not the flat 86400 the guard above implies:
550// B = 3.29998 (nominal) margin = 86400 s = 24 hours
551// B = 3.63 (10% slow) margin = 25915 s = 7.2 hours
552// B = 3.771 margin = 0 — GONE, at a 14% drift in one constant
553// The right-hand term below is therefore the week at 363/100 s a block, the slow edge of
554// the band, not at 33/10.
555//
556// THE ANCHOR IS ONLY AS GOOD AS THE BEACON IS FRESH. r/splash/beacon refuses to
557// extrapolate more than beacon.MaxEstimateStaleBlocks past the last relay that raised
558// its anchor, and its error over that distance is at most 33/100 s per block at the
559// same 10% drift — 26182 * 33/100 = 8640 s, 2.4 hours. That is the third term.
560//
561// LEFT = pinOffsetRounds * beacon.PeriodSeconds = 691200 s (drand's clock)
562// MINUS raffleWeekDays * blocksPerDay * 363/100 = 665284 s (the chain's, slow edge)
563// MINUS beacon.MaxEstimateStaleBlocks * 33/100 = 8640 s (the beacon's admitted error)
564// MARGIN = 17275 s = 4.8 hours
565//
566// THREE CLOCKS, NO CONSTANT ON BOTH SIDES, AND IT CAN FAIL: lengthen the beacon's
567// staleness bound, lengthen the week, shorten the pin offset, or widen the drift band, and
568// the build fails. That is what distinguishes it from the guard this file had at line 470
569// until today, which compared the bare integers 21 and 8 and could not fail for any value
570// of anything (AUDIT-2026-09-28.md finding 11).
571var _ [pinOffsetRounds*beacon.PeriodSeconds - raffleWeekDays*blocksPerDay*363/100 - beacon.MaxEstimateStaleBlocks*33/100 - 1]struct{}
572
573// maxPinnableLatest is the highest anchor round from which a round can still be
574// pinned without the int64 addition wrapping. A wrapped pin would be a negative
575// round — a round nobody can ever publish. getOrCreateDraw refuses instead, loudly.
576//
577// SINCE THE DRAND MIGRATION THIS IS BELT-AND-BRACES ON AN ADDITION, NOT A LIVE
578// GRIEFING SURFACE, and the change is worth stating because the paragraph it
579// replaces was one of the longest in this file.
580//
581// It used to be reachable by a deliberate act: the SPLASH beacon's key holder could
582// sign ANY round, including one near the int64 maximum, and one Publish moved
583// latestRound there for ever — after which no new week could be opened in this realm
584// again, because Buy and FundPot both refuse a live week with no record. That
585// reached across a realm boundary, since r/splash/arcade/duel's Collect CALLED FundPot
586// DIRECTLY at the time, so every later duel winner would have been unable to collect; the
587// recorded decision was that choosing between refusing and pinning a clamped
588// unpublishable round is the owner's, not this file's. (THAT COUPLING IS GONE — Collect
589// books its rake and duel.FlushPot delivers it, so a refusing FundPot now costs the
590// flusher a reverted transaction and no winner anything. AUDIT-2026-09-28.md finding 14.
591// The sentence is kept because the cross-realm reasoning it records is what that finding
592// is about.)
593//
594// DRAND CANNOT SIGN A ROUND WHOSE TIME HAS NOT COME, so latestRound is now bounded
595// above by drand's real head: ~2.1e7, gaining ~1.05e7 a year, reaching 9.2e18 in
596// about 880 billion years. The trigger is gone and the cross-realm decision is moot.
597// This constant stays because the arithmetic still must not wrap, and because a
598// future change of beacon must not silently inherit the assumption that it cannot.
599//
600// (The same act was the operator-triggered half of AUDIT-2026-09-28.md finding 7 —
601// one far-future Publish moving latest past the prune window and abandoning every
602// pending week at once. That is dead too, for the same reason. The STALLED-beacon
603// half of finding 7 is very much alive and the height gate below is still the only
604// answer to it.)
605const maxPinnableLatest = int64(maxInt64) - pinOffsetRounds
606
607// --- the refund grace window ---------------------------------------------
608//
609// refundGraceBlocks IS A BEACON-INDEPENDENT FLOOR ON BUYER ACCESS, and it is the
610// only thing in this file that lets a committed week's pot out when the beacon
611// simply STOPS. Read Refund and resolvable() with it.
612//
613// THE DEFECT IT CLOSES, and the lesson that goes with it: r/splash/arcade/duel
614// had already solved exactly this problem and this realm had not. The duel's
615// VoidStale gates on runtime.ChainHeight() - d.AcceptedAt >= voidGraceBlocks —
616// CHAIN HEIGHT, NOT BEACON STATE — and its comment says in as many words that
617// without it "a stopped publisher freezes every pending duel with both stakes
618// locked, permanently, on a contract that cannot be patched". That sentence
619// described this realm verbatim until this constant existed. A week's round is
620// pinned at A + pinOffsetRounds, where A is the beacon's anchor round at record
621// creation (it was latest+1, bound in Commit, when this window was first written;
622// the pin moves the round further out, which only widens the trap below). Freeze
623// the beacon at that same A and BOTH exits refuse forever: Resolve panics "beacon
624// round not published yet", and Refund panicked "still resolvable" because
625// resolvable() reduced to A+pinOffsetRounds > A-PruneWindow, which is true for
626// ever. The week's whole pot — the buyers' own money — was then unreachable by
627// every path in the file, while heldPots went on counting it so checkInvariant and
628// AuditFailure both stayed green. Not committing was no defence either: Refund
629// requires Committed, so the only way out of a stranded week is through the very
630// act that sets the trap.
631//
632// AND SINCE THE DRAND MIGRATION THIS GATE IS NOT A SECOND OPENER, IT IS THE ONLY
633// ONE. resolvable() is gone (see Refund), because the state it detected — "the
634// round came and went and its signature is destroyed" — cannot occur against a
635// beacon that never prunes and an API that serves every round for ever. What
636// replaced that failure mode is a pure liveness failure: nobody has bothered to
637// relay the round. A beacon-state gate can never detect that, because there is
638// nothing about the beacon's state that distinguishes "not relayed yet" from
639// "never will be". A CLOCK CAN. That is precisely what this constant is.
640//
641// THE OPERATOR-TRIGGERED VARIANT OF THE SAME ROOT IS NOW DEAD, AND THE PARAGRAPH IS
642// KEPT BECAUSE ITS LESSON IS NOT. Under the SPLASH key, beacon.Publish applied no
643// sequence check and latestRound only ever rose, so ONE Publish of a far-future round
644// moved latest by more than beacon.PruneWindow in a single transaction — flipping
645// resolvable() false for every pending committed week at once and forcing mass
646// abandonment. drand cannot sign a round whose time has not come, so latestRound is
647// bounded by drand's real head and nobody can jump it. The STALLED-beacon variant
648// above is untouched and is now the ONLY thing standing between a quiet week and a
649// trapped pot — and it got LARGER, because with an on-demand record nobody relays
650// unless they want something (see below).
651//
652// THE DERIVATION, because the number must not be arbitrary.
653//
654// THE CADENCE ASSUMPTION THIS RESTS ON is beaconRoundsPerDay and blocksPerDay —
655// stated in full at beaconRoundsPerDay, above. This window is a duration
656// expressed in the only clock a realm can read here, so if either assumption
657// moves materially it must be re-derived.
658//
659// THE UPPER BOUND ON AN HONEST CYCLE USED TO HAVE TWO TERMS AND NOW HAS ONE AND A
660// HALF, and the change is a drand-migration change. It is measured from Commit,
661// because Commit is what CommittedAt records and therefore what this window counts
662// from:
663//
664// 1. WAITING FOR THE PINNED ROUND TO BE SIGNED: up to pinOffsetRounds = 230400
665// rounds, which at three seconds is 8 days. The worst case is a week whose
666// record was created in its final block — the pin is then measured from almost
667// exactly the moment the week closes, so the round lands the whole offset
668// after Commit. (A week created early pins a round that is already signed by
669// Commit, which is fine and is the point: nobody CHOSE it.)
670// 2. WAITING FOR SOMEBODY TO RELAY IT. This term used to be
671// beacon.PruneWindow — a further 7 days, after which the signature was GONE,
672// resolvable() went false on its own, and refunds opened through the
673// beacon-derived half of Refund's gate. THAT TERM NO LONGER EXISTS AND
674// NEITHER DOES ITS DEADLINE. Every drand round is served for ever by the
675// public API and r/splash/beacon neither prunes nor forgets, so a pinned
676// round is obtainable indefinitely — and correspondingly nothing FORCES
677// anyone to obtain it. The second term is therefore UNBOUNDED, and the height
678// gate is not one of two independent openers any more, IT IS THE ONLY ONE.
679// See resolvable's removal and Refund's gate.
680//
681// SO THE BOUNDED PART OF THE HONEST CYCLE IS pinOffsetDays = 8 DAYS from Commit,
682// and the height gate MUST be strictly longer than that: a shorter one would open
683// refunds on a week whose pinned round has not even been signed yet, let alone
684// failed to resolve. The 13 days of slack above it are now the RELAY ALLOWANCE —
685// how long this realm is willing to wait for somebody to spend ~17.0M gas relaying
686// one round — and that is what the margin is for, so it says so.
687//
688// 21 DAYS IS KEPT RATHER THAN RECOMPUTED, on purpose. The bound it has to clear
689// fell from 15 days to 8 when the retention term disappeared, so 21 is now more
690// generous than strictly required. Shortening it would be a change to buyers'
691// exposure made as a side effect of a beacon migration, and the direction is not
692// obviously right: the second term is unbounded now, so the case for a long window
693// is stronger, not weaker.
694const (
695 // refundGraceDays is the window in days. THE ONE DIAL: everything below is
696 // derived from it, so re-tuning it cannot leave the two units disagreeing.
697 refundGraceDays = 3 * raffleWeekDays
698
699 // refundGraceBlocks is that window in blocks — the value Refund actually
700 // compares against runtime.ChainHeight(). 21 * 26182 = 549822.
701 refundGraceBlocks = blocksPerDay * refundGraceDays
702)
703
704// THE GRACE MUST OUTLAST THE BOUNDED PART OF THE HONEST CYCLE, and this line fails
705// the BUILD if it ever stops doing so: a negative array length is a compile error,
706// so refundGraceDays must stay strictly greater than pinOffsetDays. 21 > 8, with 12
707// spare. Without it, someone lengthening the pin offset would silently open refunds
708// on weeks whose pinned round has not been signed yet.
709//
710// "THE UNITS ARE DAYS ON BOTH SIDES, AND DAYS ARE REAL TIME" IS WHAT THIS COMMENT SAID
711// UNTIL TODAY, AND IT WAS WRONG — THE GUARD IT DEFENDED WAS
712//
713// var _ [refundGraceDays - pinOffsetDays - 1]struct{} // 21 - 8 - 1 = 12
714//
715// THEY ARE NOT THE SAME DAY. refundGraceDays becomes BLOCKS (refundGraceBlocks =
716// blocksPerDay * 21 = 549822 blocks, a real duration of 549822 * B for an unknown block
717// time B) and pinOffsetDays becomes DRAND ROUNDS (pinOffsetRounds = RoundsPerDay * 8 =
718// 230400 rounds, a real duration of exactly 691200 seconds). The guard compared the bare
719// integers 21 and 8, named NEITHER cadence, and therefore COULD NOT FAIL FOR ANY VALUE of
720// blocksPerDay, beacon.PeriodSeconds or beacon.RoundsPerDay. Set blocksPerDay = 2618 and
721// it still compiled while the grace window shrank to 2.4 real days against a bounded wait
722// of 8 real days — refunds opening on weeks whose pinned round had not been signed yet.
723//
724// IT IS THE SAME DEFECT AS THE GUARD IT REPLACED, REINTRODUCED IN THE CHANGE WRITTEN TO
725// ELIMINATE IT. The old `[pinOffsetRounds - raffleWeekDays*beaconRoundsPerDay - 1]` was
726// rounds-versus-rounds with the cadence cancelling; this one was days-versus-days with
727// BOTH cadences omitted, which is worse — there was not even a constant to notice.
728// AUDIT-2026-09-28.md finding 11 records the general rule: A GUARD THAT PROTECTS A
729// REAL-WORLD DURATION MUST HAVE SECONDS ON BOTH SIDES, SOURCED FROM TWO DIFFERENT CLOCKS,
730// OR IT CANNOT FAIL. The doctrine was applied correctly at the pinOffsetRounds guard above
731// and at r/splash/arcade/duel's voidGraceBlocks guard, and missed here.
732//
733// SO IT NOW COMPARES SECONDS TO SECONDS, in the style of the guard above:
734//
735// LEFT — the grace window in real seconds, via the chain's ~3.3s block time:
736// refundGraceDays * blocksPerDay * 33/10 = 549822 * 3.3 = 1814412 s = 21.000 days
737// RIGHT — the bounded part of the honest cycle in real seconds, via drand's period:
738// pinOffsetRounds * beacon.PeriodSeconds = 230400 * 3 = 691200 s = 8 days exactly
739// MARGIN = 1123212 seconds, 13 days, the relay allowance the derivation above asks for —
740// a 2.6x margin, and it NAMES the constants it depends on.
741//
742// MUTATION-TESTED, not reasoned about: setting blocksPerDay = 2618 in the working tree
743// makes THIS guard's length negative and the build fail, while the old days-versus-days
744// guard compiled unchanged at that value. The failing build output is in
745// AUDIT-2026-09-28.md finding 11.
746//
747// THE TWO GUARDS THIS ULTIMATELY REPLACES, for the reader who comes looking for them:
748//
749// var _ [refundGraceRounds - beacon.PruneWindow - 1]struct{}
750// var _ [refundGraceRounds - pinOffsetRounds - beacon.PruneWindow - 1]struct{}
751//
752// BOTH would have rescaled to 28800 rounds a day without complaint (604800 > 230400 +
753// 201600) while asserting a relationship to a retention window that no longer exists.
754// beacon.PruneWindow is gone, so they no longer compile. refundGraceRounds is gone with
755// them: it existed ONLY to feed them, and one fewer round-denominated constant is one
756// fewer place a cadence error can hide.
757var _ [refundGraceDays*blocksPerDay*33/10 - pinOffsetRounds*beacon.PeriodSeconds - 1]struct{}
758
759// --- state ---------------------------------------------------------------
760
761// Sponsorship is one credit line beside a week's prize: who paid, what they
762// asked to be called, and what they put in.
763//
764// Amount IS THE GROSS, the figure the sponsor actually parted with, not the net
765// that reached the pot. A credit that understated what someone paid would be a
766// strange thing to sell them, and the house's share of it is derivable from
767// HouseCutBps by anybody who cares.
768type Sponsorship struct {
769 By address
770 Label string
771 Amount int64
772}
773
774// Draw is one week's raffle: the pot, the ticket index that decides it, and
775// the beacon round it is committed to.
776type Draw struct {
777 Week int64
778
779 // Pot is the prize pot in ugnot: the sum of every buyer's contribution
780 // AFTER the house cut, PLUS any house money sponsored into this week
781 // (see Sponsored). Zeroed by Resolve when it pays the winner, and
782 // decremented by Refund as each buyer reclaims their share.
783 //
784 // THE IDENTITY IS NOW Pot + Cut == sum(spendOf) + Sponsored, and it is what makes
785 // Refund unable to overdraw. It was Pot == sum(spendOf) + Sponsored until
786 // 2026-09-29, when spendOf started recording the buyer's GROSS payment and the
787 // house's fee started being escrowed in Cut instead of banked at sale time. Read
788 // Cut, and AUDIT-2026-09-28.md finding 25.
789 Pot int64
790
791 // Cut is THE HOUSE'S FEE FOR THIS WEEK, HELD IN ESCROW AND NOT YET EARNED: the sum
792 // of HouseCutBps of every ticket sold into this week. In ugnot, like everything
793 // else here.
794 //
795 // IT IS NOT PART OF Pot AND THE WINNER MUST NEVER WIN IT. Resolve pays Pot to the
796 // winner and moves Cut to opsPot, in that order, as two separate amounts. If the
797 // fee were folded into Pot the winner would be paid the house's money; if it were
798 // in opsPot from the moment of sale — which is what happened until 2026-09-29 —
799 // then a week that never drew would refund only 95% of what its buyers paid.
800 //
801 // THE RULE IT IMPLEMENTS IS THE OWNER'S: WE EARN WHEN THE GAME RUNS, NOT WHEN IT
802 // FAILS. So the fee has exactly two destinations and no third:
803 //
804 // - A SUCCESSFUL Resolve moves it to opsPot. The draw happened; we ran it.
805 // - ANY OTHER OUTCOME REFUNDS IT. Refund returns each buyer's GROSS spend and
806 // decrements this field by exactly the part of that gross which came here in
807 // the first place (see Refund's split, and cutPerTicket's build guard for why
808 // that re-derivation is exact rather than approximately right). An ABANDONED
809 // week therefore hands the fee back with the prize money, which is the same
810 // principle finding 16 applied to Sponsored: we did not run a draw, so we do
811 // not keep the fee.
812 //
813 // AND IT IS A TERM IN BOTH SOLVENCY STATEMENTS, not a note on the side. The realm
814 // holds this wugnot, so checkInvariant is heldPots + heldCuts + opsPot == balance
815 // and AuditFailure re-derives both new terms by iteration. A field the realm holds
816 // money for and the invariant does not mention is the shape of a hole.
817 //
818 // A SWEPT OR DRAWLESS WEEK HAS Cut == 0 ONLY AFTER foldSponsorCut RUNS, AND IT USED
819 // TO SAY 'BY CONSTRUCTION'. That was true until Sponsor gave a fee a second way in,
820 // with no ticket sale behind it, and the stale sentence is exactly what produced the
821 // 2026-09-30 audit's RED finding: SweepDrawlessWeek asserted the old claim and
822 // stranded the whole week. The original reasoning, kept because it is still why the
823 // assertion is worth making, because both sweepable
824 // shapes require sum(spendOf) == 0 and a fee can only arrive with a ticket sale.
825 // SweepDrawlessWeek asserts that rather than assuming it.
826 Cut int64
827
828 // Sponsored is the part of Pot that came from FundPot rather than from a
829 // ticket sale: house or rake money, ATTRIBUTABLE TO NO BUYER. It buys no
830 // tickets and no winning chance; it only makes the prize bigger.
831 //
832 // THE MONEY IS STILL ATTRIBUTABLE TO NO BUYER AFTER FINDING 15, AND THAT IS
833 // WORTH BEING PRECISE ABOUT. Duel play now earns ENTRIES as well as sending
834 // rake (FundPotWithEntries), so the same transaction that raises this field
835 // also appends ticket weight for the two players. The two are still separate
836 // facts: the entries are weight with no spendOf record, this field is money
837 // with no owner, and neither is a claim on the other. The funder — the duel
838 // realm — still gets nothing at all, so a realm that funds a pot still cannot
839 // be the address a payout is pushed to.
840 //
841 // IT IS A SEPARATE FIELD BECAUSE THE REFUND SAFETY PROPERTY IS AN IDENTITY,
842 // NOT AN INEQUALITY. Every ugnot of a week's pot must be attributable either
843 // to a buyer who can reclaim exactly their own contribution, or to this
844 // field. Adding sponsorship straight into Pot with nothing recording it
845 // would leave Pot > sum(spends) — refunds could then no longer be shown to
846 // be unable to overdraw, and the books could not tell house money from a
847 // buyer's claim. So the invariant is Pot == sum(spendOf) + Sponsored, and
848 // both checkInvariant (through heldSponsored) and AuditFailure (by
849 // iteration) are written in those terms.
850 //
851 // IT IS NEVER LEFT BEHIND IN A POT NOBODY CAN REACH, and there are two such
852 // pots. On an ABANDONED week (see Refund) nobody can reclaim it, so leaving it in Pot
853 // while every spend is zeroed and nothing could ever move it would strand it forever.
854 // On a finished week that NEVER SOLD A TICKET (see SweepDrawlessWeek) that week can
855 // never be committed, so it can never be resolved or refunded either, and the money
856 // would be just as stranded. Both are the same class of defect as the unreachable week
857 // the 2026-09-28 audit's first finding was about.
858 //
859 // WHAT REACHES IT IN BOTH CASES IS retireSponsorship, SINCE 2026-09-29 (SECOND
860 // REVISION — finding 31). This field is zeroed AT THE MOMENT THE WEEK IS ABANDONED and
861 // the money is turned into A PER-UNIT CLAIM ON THIS SAME WEEK (ClaimPerUnit, Claims),
862 // payable to the people who hold units IN THIS WEEK. It never changes weeks, never
863 // waits in a register, and has no destination anybody can name. So `Sponsored > 0 on an
864 // abandoned week` is now a STATE THAT CANNOT EXIST, and AuditFailure says so.
865 //
866 // THE DESTINATION USED TO DISAGREE WITH ITSELF, THEN AGREED, THEN BECAME CHOOSABLE,
867 // THEN BECAME A REGISTER, AND IS NOW GONE. The full sequence, because it is the whole
868 // lesson:
869 //
870 // - Until 2026-09-29 the abandoned-week path sent this money to opsPot while the
871 // drawless-week path rolled it forward, over the same field, in the same file
872 // (AUDIT-2026-09-28.md finding 16: that asymmetry is a finding, not untidiness).
873 // - Both then rolled it into currentWeek() — the week live at the moment a
874 // permissionless caller fired, which let the caller pick the destination by picking
875 // the moment (finding 28).
876 // - Then a REGISTER (carryPending) held it until the next week record pulled it in at
877 // its own birth. That was meant to make the destination unchoosable. It did not:
878 // a caller could BUY ONE TICKET IN THE FINAL BLOCK OF A QUIET WEEK, which both
879 // created that week's record and absorbed every pending carry into a week whose
880 // entire ticket set was their own — 100% capture for the price of the house cut
881 // (finding 31). The "destination is young" property the design rested on was
882 // asserted in a test and never enforced in the code.
883 // - Now there is NO DESTINATION AT ALL. The money goes back to the addresses that
884 // hold units in the week it was already in.
885 Sponsored int64
886
887 // SponsorCut is HOW MUCH OF d.Cut CAME FROM SPONSORS rather than from ticket
888 // sales, and it exists for exactly one reason: so that fee has a way out of an
889 // abandoned week.
890 //
891 // THE DEFECT IT PREVENTS, STATED PLAINLY BECAUSE IT IS INVISIBLE OTHERWISE. Refund
892 // is what retires a buyer's share of d.Cut, and it does so by RE-DERIVING the fee
893 // from that buyer's own spendOf. A sponsor has no spendOf — sponsorship buys no
894 // units, that is its definition — so no Refund call will ever reach a sponsor's
895 // fee. Left in d.Cut it would be money that entered the realm and could not leave,
896 // and checkInvariant would go on balancing happily the whole time, because a
897 // balanced invariant is not evidence of a reachable exit.
898 //
899 // AND FOLDING SPONSORSHIP INTO spendOf TO REUSE Refund IS NOT THE ALTERNATIVE. That
900 // re-derivation is exact ONLY because a spendOf is a whole number of TicketPrice;
901 // an arbitrary sponsorship is not, so d.Cut would go negative and a BUYER's own
902 // reclaim would start panicking. That arithmetic is AUDIT-2026-09-28.md finding 31.
903 //
904 // SO IT LEAVES BY THE DOOR THE MONEY IT WAS TAKEN FROM LEAVES BY: retireSponsorship
905 // folds it back into Sponsored on the abandonment transition, and it is divided
906 // among that week's unit holders with the rest. On a week that DOES draw it is not
907 // folded and goes to ops with every other fee, which is the owner's rule for fees
908 // generally — earned only if the draw actually runs.
909 SponsorCut int64
910
911 // SponsorPaid is the GROSS total of every Sponsor call on this week, and it is
912 // what separates money with an owner from money without one.
913 //
914 // d.Sponsored MIXES TWO SOURCES AND ONLY ONE OF THEM HAS A PAYER. FundPot — the
915 // duel's door — puts ownerless rake there, money attributable to no address.
916 // Sponsor puts a named person's payment there. Until 2026-09-30 an abandoned week
917 // treated both the same and divided them among unit holders, or sent them to the
918 // house when there were no earned units. The owner ruled that out: a sponsor's
919 // money is "either returned to sponsor or rolls over until a draw happens", and
920 // rolling forward is what AUDIT-2026-09-28.md findings 28 and 31 removed, because
921 // the caller picked the destination week by picking when to call. So it is
922 // returned, and this is the figure that says how much of the pot is owed back.
923 SponsorPaid int64
924
925 // SponsorRefundable is what is left of SponsorPaid once the week has died and
926 // before its sponsors have pulled it back. It is still sitting in d.Pot.
927 SponsorRefundable int64
928
929 // sponsorRefundedOf marks the addresses that have already pulled their refund, so
930 // a second call pays nothing rather than paying twice.
931 sponsorRefundedOf *avl.Tree
932
933 // Sponsors is the credit line each sponsorship bought, in the order they were
934 // bought, bounded by maxSponsorsPerWeek.
935 //
936 // THE TEXT IS A STRANGER'S AND IT IS PERMANENT. This is the first untrusted text
937 // this realm has ever stored. NOTHING ESCAPES IT TODAY, and an earlier version of
938 // this sentence claimed both ends did. checkSponsorLabel deliberately does not
939 // sanitise — escaping belongs where the target syntax is known — and Render does
940 // not yet emit this field at all.
941 //
942 // WHOEVER ADDS THAT DISPLAY MUST ESCAPE IT. Render builds a PIPE-DELIMITED
943 // MARKDOWN TABLE; the label may legally hold '|', '[', ']', '<', '>' and
944 // backticks, and 64 bytes is ample to forge extra cells and fake a column. Use the
945 // duel's approach: sanitize.InlineText, plus escaping '|'. Flagged by the
946 // 2026-09-30 audit, finding Y3.
947 //
948 // Escaping cannot strip CONTENT either way, and nothing can delete it
949 // once written — a later realm version does not erase what an earlier one wrote.
950 // The owner was told exactly that on 2026-09-30 and accepted it: "whatever, we can
951 // always redeploy with 2.0 or whatever, it's a game".
952 Sponsors []Sponsorship
953
954 // ClaimPerUnit is the UGNOT EACH *EARNED* ENTRY UNIT OF THIS WEEK MAY RECLAIM from the
955 // sponsorship this week's draw never got to pay out. ZERO ON EVERY LIVE WEEK and
956 // written exactly once, by retireSponsorship, at the moment the week is abandoned.
957 //
958 // "EARNED" IS LOAD-BEARING AND THIS COMMENT SAID "EACH TICKET UNIT" UNTIL
959 // AUDIT-2026-09-28.md finding 34 — so did the code. A BOUGHT unit contributed nothing to
960 // the sponsorship and recovers its own money in full through Refund; an earned unit IS the
961 // sponsorship, at 150,000 ugnot, and has no other route home. Dividing on both kinds paid
962 // a refunded buyer out of the duel players' rake. Read Draw.earnedOf and
963 // retireSponsorship.
964 //
965 // IT IS THE WHOLE OF finding 31's FIX AND IT IS A RATE, NOT A BALANCE. Writing one
966 // number instead of one tree entry per holder is what keeps the abandonment transition
967 // O(1): the transition happens inside Refund, which MUST NOT FAIL (a buyer is waiting),
968 // and a loop over an unbounded holder set is a gas limit waiting to make a week's
969 // refunds impossible — which would be a fresh "money in, no way out" path created by
970 // the fix. A holder's claim is derived on demand in ClaimDeadWeek as
971 // earnedOf[holder] * this, which is O(log N) and cannot be starved.
972 //
973 // IT IS A WHOLE NUMBER OF UGNOT PER EARNED UNIT, so the shares sum to
974 // ClaimPerUnit * earnedTotal EXACTLY and never to more than the sponsorship. The
975 // remainder — strictly less than the week's EARNED unit count, in ugnot, which is under
976 // seven parts per million of the sponsorship — is dust with no
977 // unit to attach to; retireSponsorship moves it to opsPot in the same statement and
978 // records it in the event, so it is never left in a pot with no claim on it. Read that
979 // function for the arithmetic, for the improved bound, and for why the alternatives (a
980 // largest-remainder pass, or first-claimant-takes-the-dust) were rejected.
981 //
982 // NON-ZERO IMPLIES Abandoned, always, and AuditFailure re-derives that. An unabandoned
983 // week can still sell tickets and still resolve, so a claim rate on one would be a
984 // claim on a prize somebody is about to win.
985 ClaimPerUnit int64
986
987 // Claims is the UNCLAIMED part of what retireSponsorship distributed: it starts at
988 // ClaimPerUnit * earnedTotal and falls by each holder's share as ClaimDeadWeek pays
989 // it. IT IS PART OF Pot, exactly as Sponsored was, so no wugnot moved when it came into
990 // existence and the realm's balance did not change.
991 //
992 // THE DIVISOR IN THAT PRODUCT WAS tickets.Total() UNTIL AUDIT-2026-09-28.md finding 34,
993 // and the sum of every possible claim is EXACTLY this number under either basis — that was
994 // never the defect. What was wrong is WHO the claimants were: bought units took a share of
995 // money they had not contributed. Read Draw.earnedOf.
996 //
997 // IT IS A TERM IN BOTH SOLVENCY STATEMENTS. The per-week identity is
998 //
999 // Pot + Cut == sum(spendOf) + Sponsored + Claims
1000 //
1001 // and the global one gains heldClaims. A field the realm holds money for and the
1002 // invariant does not mention is the shape of a hole — the same sentence Cut carries,
1003 // for the same reason.
1004 Claims int64
1005
1006 // claimedOf is string(address) -> the amount that holder has already taken out of
1007 // Claims. AN ENTRY MEANS "PAID", so ClaimDeadWeek is all-or-nothing per holder and can
1008 // never pay twice: a holder's EARNED unit count cannot change after abandonment (an
1009 // abandoned week is always a past week, and FundPotWithEntries — the only writer of
1010 // earnedOf — only ever touches currentWeek()), so their share is a constant and one entry
1011 // settles it for ever.
1012 //
1013 // THE FROZEN-BASIS PROPERTY IS ENFORCED BY THREE PANICS AND NOT BY THIS SENTENCE, which
1014 // is the difference between this and finding 31's second attempt — that one rested on a
1015 // property asserted in a test and never enforced in code. The three, named so a future
1016 // reader knows they are load-bearing and not hygiene:
1017 //
1018 // 1. FundPot's refusal of a committed or abandoned week. It is the money path
1019 // FundPotWithEntries calls before it appends anything, so no earned unit can ever
1020 // reach a week that is already dead.
1021 // 2. retireSponsorship's "only an abandoned, unresolved week's sponsorship may be
1022 // retired". The rate cannot be written before the week's exit is one-way.
1023 // 3. retireSponsorship's "that week's sponsorship has already been retired". The rate
1024 // cannot be written twice, so it cannot be re-derived against a basis that has moved.
1025 //
1026 // TestFix34_ThreePanicsEnforceTheFrozenBasis names all three and drives each one.
1027 claimedOf *avl.Tree
1028
1029 Committed bool
1030 FrozenTotal int64 // ticket units at commit. NEVER recomputed at resolve.
1031
1032 // Round is the beacon round this week is drawn on. WRITTEN EXACTLY ONCE, by
1033 // getOrCreateDraw, at the moment this record is created — which is before the
1034 // week's first ticket exists — and never written again by anything, Commit
1035 // included. Read pinOffsetRounds for why, and for what that does and does not
1036 // protect against; the one-sentence version is that the committer used to
1037 // choose this number after the ticket set was already frozen and public, which
1038 // handed the beacon's key holder a deterministic win.
1039 Round int64
1040
1041 // CommittedAt is the CHAIN HEIGHT at which Commit froze this week, and it is
1042 // the only clock the refund grace window reads. Zero on an uncommitted week.
1043 //
1044 // IT IS STORED, AND IT CANNOT BE DERIVED FROM ANYTHING ELSE. The week number
1045 // comes from the same global clock, but a week may be committed at any time
1046 // after it ends (Commit takes the week as an argument precisely so a stranded
1047 // week is always recoverable), so "when was this week committed" is not a
1048 // function of which week it is. Round cannot stand in for it either: Round is
1049 // beacon state, and the whole point of this field is to give Refund a gate the
1050 // beacon cannot hold shut. See refundGraceBlocks.
1051 CommittedAt int64
1052
1053 // Randomness is the round's randomness, copied in by Capture. Empty means "not
1054 // captured"; Resolve then reads the beacon directly. See Capture — and note that
1055 // since the drand migration this field is a convenience, not a rescue: the
1056 // beacon cannot lose a round, so nothing is preserved by copying it here.
1057 Randomness string
1058
1059 Resolved bool
1060 Winner address
1061
1062 // Abandoned is set by the FIRST successful Refund for this week and is
1063 // never cleared by anything. It makes the week's exit one-way: once one
1064 // buyer has taken their money back, this week can never be resolved, and
1065 // every later refund proceeds without re-asking whether the draw looks
1066 // resolvable again.
1067 //
1068 // IT CANNOT BE DERIVED FROM THE BEACON, which is why it is stored — AND THE
1069 // DRAND MIGRATION MADE THAT MORE TRUE, NOT LESS. The old reason was that
1070 // resolvable() was not monotone: Publish had no window check and Prune removed a
1071 // round without moving latestRound, so a pruned round's Published() went back to
1072 // false and the same round could be published again, arbitrarily late. There is no
1073 // Prune now, so Published() IS monotone — but resolvable() is gone entirely and
1074 // a pinned round can be relayed by ANYONE, from drand, AT ANY TIME, FOR EVER. So a
1075 // week in refund would otherwise stay resolvable indefinitely, which is worse than
1076 // a window that occasionally reopened. Without this flag, anyone could let refunds
1077 // open on a
1078 // week, wait for some buyers to reclaim, then re-publish or late-publish the
1079 // round and Resolve — drawing over the ORIGINAL frozen range, so a buyer who
1080 // had already been refunded could win and collect what was left in the pot:
1081 // the money of the buyers who had not reclaimed yet. Someone holding the
1082 // signature did strictly better still, computing the winner first and then
1083 // choosing Refund if it was not them and publish-and-Resolve if it was. Pot
1084 // == sum(spends) held throughout, so neither checkInvariant nor AuditFailure
1085 // saw anything — the identity is sound, it is just blind to WHO is paid.
1086 Abandoned bool
1087
1088 // tickets is the weighted ticket index for this week. ONE INDEX PER
1089 // PURCHASE is appended and no existing index's value is ever changed —
1090 // that immutability is the whole frozen-ownership argument on Commit.
1091 //
1092 // SINCE FINDING 15 A PURCHASE IS NOT THE ONLY THING THAT APPENDS ONE: an
1093 // earned entry credited through FundPotWithEntries appends exactly as a
1094 // purchase does, through the same appendTickets, and is indistinguishable
1095 // here by design. What distinguishes the two is spendOf, which an earned
1096 // entry does not touch. Nothing may ever INSERT or ADJUST an index — fenwick
1097 // refuses negative deltas by design and Commit's argument depends on it.
1098 tickets *fenwick.Tree
1099
1100 // ownerOf is index -> string(address), the reverse lookup that turns a
1101 // drawn unit back into a winner. MANY INDICES MAP TO THE SAME ADDRESS and
1102 // that is expected: one entry per purchase.
1103 ownerOf *avl.Tree
1104
1105 // ticketsOf is string(address) -> int64 ticket units, a read-side running
1106 // total so TicketsOf is O(log N) and never has to walk the index. It is
1107 // NOT read by the draw: Select and ownerOf decide the winner.
1108 //
1109 // IT IS NOT THE DEAD-WEEK CLAIM BASIS AND IT USED TO BE — AUDIT-2026-09-28.md finding
1110 // 34. It counts BOUGHT and EARNED units together, which is exactly right for the draw
1111 // (a unit is a unit, whoever paid for it, and that indistinguishability is finding 15's
1112 // whole design) and exactly wrong for dividing up a dead week's sponsorship. Read
1113 // earnedOf below for the arithmetic.
1114 ticketsOf *avl.Tree
1115
1116 // earnedOf is string(address) -> int64 EARNED entry units: the subset of ticketsOf that
1117 // was credited by FundPotWithEntries rather than bought with Buy. IT IS THE BASIS ON
1118 // WHICH A DEAD WEEK'S SPONSORSHIP IS DIVIDED, and that is the whole of
1119 // AUDIT-2026-09-28.md finding 34.
1120 //
1121 // WHY THE DRAW AND THE CLAIM NEED DIFFERENT BASES, WHICH IS THE ONE THING WORTH
1122 // UNDERSTANDING HERE. Both kinds of unit are identical in the draw and must stay so.
1123 // They are NOT identical in what they contributed to the thing a dead week divides:
1124 //
1125 // An EARNED unit put EntryUnitPrice * arcadeRakePotShareBps / arcadeBpsDenom =
1126 // 150,000 ugnot INTO Sponsored — it IS the sponsorship — and its holder can recover
1127 // that money by no other route, because an earned entry has no spendOf record and
1128 // Refund pays spendOf and nothing else.
1129 //
1130 // A BOUGHT unit put NOTHING into Sponsored. It put TicketPrice / unitsPerTicket =
1131 // 250,000 ugnot into Pot and Cut, and its holder recovers THAT IN FULL through Refund
1132 // — the whole gross, fee included, since finding 25.
1133 //
1134 // SO A REFUNDED BUYER ON AN ABANDONED WEEK ENDS WITH ZERO NET STAKE. Their money came
1135 // back entirely and they KEEP THEIR UNITS, deliberately (fenwick rejects negative deltas
1136 // by design, so a ticket cannot be retired once sold — see Refund). Dividing the
1137 // sponsorship on ticketsOf therefore handed that buyer a positive claim on the duel
1138 // players' rake for nothing at all: with equal counts of the two kinds, each player
1139 // recovered HALF of what their own rake contributed and a refunded buyer took the other
1140 // half. Paying those players is finding 31's entire stated purpose, so that was not a
1141 // rounding error in the fix, it was the fix missing its target.
1142 //
1143 // sum(earnedOf) == earnedTotal IDENTICALLY, and it is guaranteed the same way
1144 // sum(ticketsOf) == tickets.Total() is: appendEarnedTickets is the ONLY writer of either,
1145 // it writes both in one statement group from the same `units` argument, and nothing in
1146 // this file ever decrements or removes an entry. There is no second writer to drift from
1147 // the first. AuditFailure re-derives the sum by iteration anyway
1148 // (deadWeekClaimFailure), because an identity nothing re-derives is an identity nobody
1149 // finds out about.
1150 earnedOf *avl.Tree
1151
1152 // earnedTotal is the sum of earnedOf — the O(1) counter that keeps the abandonment
1153 // transition constant-time. IT IS NOT tickets.Total() AND THE GAP IS THE BOUGHT UNITS:
1154 // 0 <= earnedTotal <= tickets.Total(), with equality exactly on a week where no ticket
1155 // was ever bought (the PURE-EARNED shape SweepDrawlessWeek calls shape 2).
1156 //
1157 // A SEPARATE COUNTER RATHER THAN AN ITERATION, for the same reason ClaimPerUnit is a rate
1158 // rather than a tree of balances: retireSponsorship runs inside Refund, Refund MUST NOT
1159 // FAIL, and a week's earned-holder set is unbounded (two more holders for every duel
1160 // played in the week). Reading a field is O(1); summing a tree is a gas ceiling on a
1161 // payout path. Read retireSponsorship.
1162 //
1163 // ZERO IS A REAL AND REACHABLE VALUE ON A WEEK WITH SPONSORSHIP: plain FundPot money from
1164 // any caller that is not the duel, a duel rake too small to buy one whole entry unit, or
1165 // a duel both of whose parties played from realm code. That money has no player behind
1166 // it, so retireSponsorship's no-claimant branch sends it to opsPot — the destination
1167 // nobody can choose. A week that has ever had an earned holder can never fall into that
1168 // branch, because this counter only ever increases.
1169 earnedTotal int64
1170
1171 // spendOf is string(address) -> int64, the buyer's GROSS payment for this week's
1172 // tickets — the whole ticket price, house fee included — which is exactly what
1173 // Refund returns.
1174 //
1175 // IT WAS THE POST-CUT CONTRIBUTION UNTIL 2026-09-29 and the old comment said "the
1176 // house cut is not in here and is never refunded". BOTH HALVES OF THAT ARE NOW
1177 // FALSE and the sentence is corrected rather than removed, because it is the one a
1178 // reader will have in their head. The fee is escrowed in Cut for the week rather
1179 // than banked at sale time, so it IS part of what a buyer can reclaim; what a
1180 // refund returns is the gross, and the week's Pot and Cut are each decremented by
1181 // the part of that gross they received. AUDIT-2026-09-28.md finding 25.
1182 //
1183 // IT IS NOT A TICKET COUNT AND SINCE FINDING 15 THE DIFFERENCE IS REACHABLE.
1184 // An earned entry (FundPotWithEntries) adds ticket weight and NOTHING here,
1185 // so an address can hold tickets in a week with a zero spend, and a whole
1186 // week can have tickets and no spend records at all. That is intended: the
1187 // money behind those entries is in Sponsored, which no refund can return.
1188 // Refund reads this tree and only this tree.
1189 spendOf *avl.Tree
1190}
1191
1192var (
1193 // draws is week -> *Draw. AN avl.Tree, NEVER A MAP: Render iterates it,
1194 // and a Go map's iteration order is deliberately randomised, which would
1195 // make this realm's rendered output non-deterministic and its audit
1196 // unreproducible.
1197 draws = avl.NewTree()
1198
1199 // opsPot is the house's money this realm holds. It is wugnot this realm
1200 // holds, so it is a term in checkInvariant.
1201 //
1202 // THREE STREAMS FLOW IN, AND ALL THREE ARE NAMED HERE because until
1203 // 2026-09-28 this counter had no outflow at all and every one of them was
1204 // permanently unreachable — the realm's whole revenue included:
1205 //
1206 // - the 5% house cut on every ticket sale (Buy),
1207 // - every unsolicited wugnot arrival, swept so one stranger's transfer
1208 // cannot brick the realm (sweepDonations),
1209 // - a dead week's sponsorship, BUT ONLY IN THE ONE CASE WHERE IT PROVABLY HAS NO
1210 // CLAIMANT, and the list of ways it can get here has shrunk twice. It used to
1211 // arrive from Refund (finding 16), then only from ReclaimStrandedCarry's long stop
1212 // (finding 28), and since finding 31 it arrives from retireSponsorship in exactly
1213 // two shapes, both of which are stated at that function's two branches:
1214 // A WEEK WITH SPONSORSHIP AND ZERO TICKET UNITS (a plain FundPot when the duel had
1215 // no eligible holders, or a rake below one entry unit) — nobody holds a unit, so
1216 // nobody is wronged; and THE SUB-UNIT DUST of a week that DOES have holders, which
1217 // is strictly less than that week's unit count in ugnot and has no unit to attach
1218 // to. A week that has holders can never route the WHOLE amount here: the branch is
1219 // taken on tickets.Total() == 0 and nothing else.
1220 //
1221 // ONE FUNCTION FLOWS OUT: WithdrawOps, which pushes the whole of it to
1222 // opsVaultAddr and nowhere else. Where each stream GOES is a separate
1223 // decision from whether it can leave at all, and this comment deliberately
1224 // does not relitigate the first: Refund's and sweepDonations' destinations are
1225 // unchanged.
1226 opsPot int64
1227
1228 // heldPots is the sum of Pot over every draw, maintained incrementally so
1229 // checkInvariant is O(1). Resolve zeroes a week's Pot, so a resolved week
1230 // contributes nothing and this is exactly "the sum of all unresolved week
1231 // pots". AuditFailure re-derives it by iteration so it cannot drift.
1232 heldPots int64
1233
1234 // heldCuts is the sum of Cut over every draw — every week's ESCROWED, NOT YET
1235 // EARNED house fee — maintained incrementally for exactly the same reason heldPots
1236 // is, and re-derived by AuditFailure the same way.
1237 //
1238 // IT IS A THIRD TERM IN THE SOLVENCY EQUALITY AND ADDING IT IS WHAT MAKES THE
1239 // ESCROW SAFE. The fee is wugnot this realm holds, so before 2026-09-29 it was
1240 // counted by being inside opsPot. Moving it into the weeks without also moving it
1241 // into the invariant would have made checkInvariant's heldPots + opsPot == balance
1242 // fail on the very next Buy — which is the good direction for a bug, but the point
1243 // is that the equality must continue to state the WHOLE of what the realm holds. A
1244 // balance term the invariant does not mention is how money goes missing quietly.
1245 //
1246 // IT RISES IN Buy AND IN Sponsor, AND FALLS IN THREE PLACES — foldSponsorCut
1247 // being the third, added 2026-09-30. The sentence used to say it rose only in Buy and
1248 // fell in exactly two places: Resolve (the week's fee is
1249 // earned and moves to opsPot, so this falls by what opsPot gains and the balance
1250 // does not move) and Refund (the fee is returned to the buyer as part of their
1251 // gross, so this falls by that buyer's share and the balance falls with it).
1252 // Nothing else in the file touches it.
1253 heldCuts int64
1254
1255 // heldSponsored is the sum of Sponsored over every UNRESOLVED draw — the
1256 // part of heldPots that belongs to no buyer. Maintained incrementally for
1257 // the same reason heldPots is: checkInvariant runs at the end of every
1258 // mutator and must not iterate the weeks, let alone each week's buyers, to
1259 // state the sponsorship half of the pot identity. AuditFailure re-derives it
1260 // by iteration, alongside the per-week identity itself, so it cannot drift.
1261 //
1262 // IT DROPS AT TWO PLACES AND ONLY TWO SINCE FINDING 31: Resolve (the whole pot,
1263 // sponsored part included, leaves for the winner) and retireSponsorship (the week is
1264 // abandoned, so the sponsorship stops being sponsorship and becomes either this week's
1265 // holders' claims or — with no holders — ops money). Note that a resolved week KEEPS
1266 // its Draw.Sponsored as a record of what its prize was made of, which is why this
1267 // counter is over unresolved weeks only, exactly as spendOf is read for unresolved
1268 // weeks only.
1269 //
1270 // THE LIST HAS ONLY EVER GOT SHORTER AND THAT IS THE POINT. It was Refund-and-Sweep
1271 // (two destinations that disagreed, finding 16), then Refund-and-Sweep-into-currentWeek
1272 // (one destination the caller chose, finding 28), then
1273 // ReclaimStrandedCarry-and-absorbCarryPending (a register that still had a destination
1274 // and could still be aimed, finding 31). It is now a field that is zeroed where it
1275 // stands.
1276 heldSponsored int64
1277
1278 // heldClaims is the sum of Claims over every UNRESOLVED draw — the part of heldPots
1279 // that a dead week's unit holders have not taken out yet. Maintained incrementally for
1280 // exactly the reason heldPots and heldSponsored are, and re-derived by AuditFailure the
1281 // same way.
1282 //
1283 // IT IS A FOURTH CLAIM TERM AND IT REPLACES A REGISTER. carryPending, which this
1284 // replaces, deliberately held NO money and added NO term — the money stayed in the dead
1285 // week's Sponsored and the register only named it. That was the shape that made finding
1286 // 31 possible: the money was still pending, still movable, and still had somewhere to
1287 // go. This is the opposite trade — one more term in both solvency statements, in
1288 // exchange for money that is already sitting on the exact addresses entitled to it and
1289 // has nowhere left to travel.
1290 //
1291 // IT RISES ONLY IN retireSponsorship AND FALLS ONLY IN ClaimDeadWeek. Nothing else in
1292 // the file touches it.
1293 heldClaims int64
1294
1295 // heldSponsorRefunds is the sum of SponsorRefundable over every unresolved draw —
1296 // the part of heldPots that is owed back to named sponsors of dead weeks and is
1297 // waiting to be pulled. Tracked for the same reason heldClaims is: so the realm
1298 // can say, without iterating, how much of what it holds already has an owner.
1299 heldSponsorRefunds int64
1300
1301 // launchHeight is the chain height at deploy, so week numbers start at 0
1302 // for this realm rather than at some arbitrary point in chain history.
1303 launchHeight int64
1304)
1305
1306func init() {
1307 launchHeight = runtime.ChainHeight()
1308}
1309
1310// realmSelf is this realm's own address, resolved ONCE at package
1311// initialisation and never recomputed.
1312//
1313// WHY NOT CALL unsafe.CurrentRealm() PER USE: CurrentRealm resolves against
1314// whatever frame is executing. Inside a crossing entrypoint that is this realm,
1315// which is correct — but Render and the audit reads are deliberately NOT
1316// crossing functions (anyone must be able to read them), so when they are
1317// called directly the "current realm" is the caller and BalanceOf(selfAddr())
1318// would silently read the CALLER'S wallet. r/splash/game learned this the hard
1319// way; the fix is to resolve the address once, since it is constant for the
1320// life of the realm.
1321var realmSelf = unsafe.CurrentRealm().Address()
1322
1323func selfAddr() address {
1324 return realmSelf
1325}
1326
1327// --- keys ----------------------------------------------------------------
1328
1329// keyWidth is enough decimal digits for any non-negative int64 (max is
1330// 9223372036854775807, 19 digits). Keys are fixed-width and zero-padded so
1331// that avl.Tree's lexicographic order matches numeric order — the same
1332// convention as r/splash/beacon's roundKey, and the reason Render lists weeks
1333// as 1, 2, ... 10 rather than 1, 10, 2.
1334const keyWidth = 19
1335
1336const zeroPad = "0000000000000000000"
1337
1338func pad(s string) string {
1339 if len(s) < keyWidth {
1340 return zeroPad[:keyWidth-len(s)] + s
1341 }
1342 return s
1343}
1344
1345func weekKey(week int64) string { return pad(strconv.FormatInt(week, 10)) }
1346
1347func indexKey(i int) string { return pad(strconv.Itoa(i)) }
1348
1349// --- clock ---------------------------------------------------------------
1350
1351// currentDay is the single global clock this realm reads — never a per-ticket
1352// one. See blocksPerDay.
1353func currentDay() int64 {
1354 h := runtime.ChainHeight() - launchHeight
1355 if h < 0 {
1356 h = 0
1357 }
1358 return h / blocksPerDay
1359}
1360
1361// currentWeek is the week tickets are being sold into right now.
1362func currentWeek() int64 {
1363 return currentDay() / raffleWeekDays
1364}
1365
1366// weekEndHeight is the chain height at which a week stops being the live one — the
1367// first height of week+1. Derived from the same two constants and the same
1368// launchHeight currentWeek reads, so the two clocks can never disagree.
1369//
1370// IT EXISTS FOR ONE GATE: the ticketless sweep shape, which has no CommittedAt to
1371// count from (such a week can never be committed) and therefore cannot use
1372// refundGraceElapsed. Read SweepDrawlessWeek's shape-1 clock for why that gate is
1373// there at all — in one line, a sweep that is free and instant the moment a week ends
1374// is a sweep that can be repeated week after week, and finding 28's chain was built
1375// out of exactly that repeat.
1376//
1377// NO OVERFLOW: week here is always a week this realm has a record for, so it is
1378// bounded by the chain's own height divided by a week of blocks.
1379func weekEndHeight(week int64) int64 {
1380 return launchHeight + (week+1)*raffleWeekDays*blocksPerDay
1381}
1382
1383// --- arithmetic ----------------------------------------------------------
1384
1385// mulDiv returns floor(a*b/d) via a 128-bit intermediate product. Raw a*b/d on
1386// token-scale quantities wraps silently on the GnoVM (see
1387// ../../../../tools/muldiv_probe.gno) instead of panicking, so every split in
1388// this realm goes through here.
1389func mulDiv(a, b, d uint64) uint64 {
1390 hi, lo := bits.Mul64(a, b)
1391 if d == 0 {
1392 panic("raffle: mulDiv: division by zero")
1393 }
1394 if hi >= d {
1395 panic("raffle: mulDiv: overflow")
1396 }
1397 q, _ := bits.Div64(hi, lo, d)
1398 return q
1399}
1400
1401// maxInt64 as a uint64, for the overflow guard below.
1402const maxInt64 = uint64(1)<<63 - 1
1403
1404// ticketCost is tickets * TicketPrice, GUARDED AGAINST int64 OVERFLOW.
1405//
1406// A raw multiplication here is the classic money bug: on the GnoVM an int64
1407// product wraps silently, so a caller passing a large enough ticket count
1408// would buy an enormous number of ticket units for a small — or negative —
1409// cost. The product is therefore taken in 128 bits (the same bits.Mul64 path
1410// as mulDiv) and REJECTED unless it fits in an int64: the high word must be
1411// zero AND the low word must be within int64 range, because hi == 0 alone
1412// still admits values above 2^63-1 that would land negative.
1413func ticketCost(tickets int64) int64 {
1414 if tickets <= 0 {
1415 panic("raffle: ticket count must be positive")
1416 }
1417 hi, lo := bits.Mul64(uint64(tickets), uint64(TicketPrice))
1418 if hi != 0 || lo > maxInt64 {
1419 panic("raffle: ticket count overflows int64 ugnot")
1420 }
1421 return int64(lo)
1422}
1423
1424// hexDigit decodes one lowercase-or-uppercase hex character.
1425func hexDigit(c byte) (uint64, bool) {
1426 switch {
1427 case c >= '0' && c <= '9':
1428 return uint64(c - '0'), true
1429 case c >= 'a' && c <= 'f':
1430 return uint64(c-'a') + 10, true
1431 case c >= 'A' && c <= 'F':
1432 return uint64(c-'A') + 10, true
1433 }
1434 return 0, false
1435}
1436
1437// randomnessMod reduces a 256-bit hex randomness string modulo m, which gives
1438// the draw's winning ticket unit. Verbatim in behaviour from
1439// r/splash/game/raffle.gno, for the reasons stated there:
1440//
1441// The randomness is 256 bits; an int64 holds 63. Any truncation (taking the
1442// first or last 16 hex digits, say) throws away most of the beacon's entropy
1443// and does it in a way an attacker can reason about. math/big does not exist on
1444// this toolchain, so the reduction is schoolbook: fold four bits at a time,
1445// reducing as we go. The intermediate r*16 + digit does NOT fit in 64 bits when
1446// m is near the int64 maximum, so it goes through the 128-bit path rather than
1447// wrapping. `hi %= um` before the divide is required by bits.Div64 (which
1448// panics unless the high word is smaller than the divisor) and is sound because
1449// hi*2^64 == (hi mod m)*2^64 (mod m).
1450func randomnessMod(randomness string, m int64) int64 {
1451 if m <= 0 {
1452 panic("raffle: randomness modulus must be positive")
1453 }
1454 if len(randomness) == 0 {
1455 panic("raffle: empty randomness")
1456 }
1457 um := uint64(m)
1458 r := uint64(0)
1459 for i := 0; i < len(randomness); i++ {
1460 d, ok := hexDigit(randomness[i])
1461 if !ok {
1462 panic("raffle: beacon randomness is not hex")
1463 }
1464 hi, lo := bits.Mul64(r, 16)
1465 if lo > ^uint64(0)-d {
1466 hi++
1467 }
1468 lo += d
1469 hi %= um
1470 _, r = bits.Div64(hi, lo, um)
1471 }
1472 return int64(r)
1473}
1474
1475// --- week records --------------------------------------------------------
1476
1477// getDraw returns the draw for week, or nil. avl's Get returns a SINGLE value
1478// and existence is a separate Has, so the check comes first — otherwise a
1479// missing week type-asserts nil and panics with avl's message rather than this
1480// realm's.
1481func getDraw(week int64) *Draw {
1482 key := weekKey(week)
1483 if !draws.Has(key) {
1484 return nil
1485 }
1486 return draws.Get(key).(*Draw)
1487}
1488
1489// getOrCreateDraw returns the week's draw, creating an empty one if needed.
1490// Called only AFTER a sale's money has moved, so a rejected Buy never leaves an
1491// empty record behind — which matters, because "this week has no tickets" is
1492// one of Commit's refusals and an empty husk must not look like a live week.
1493//
1494// THIS IS WHERE THE WEEK'S BEACON ROUND IS PINNED, AND IT IS THE ONLY WRITER OF
1495// Draw.Round IN THIS FILE. It is here, rather than in Buy and FundPot separately,
1496// so that the pin happens EXACTLY ONCE per week whichever of them opens the week —
1497// a second pin would be a second choice, and choice is the whole defect. Read
1498// pinOffsetRounds for the attack this closes, for the derivation of the offset, and
1499// for the part of the problem it deliberately does not close.
1500//
1501// IT NEEDS THE BEACON TO HAVE PUBLISHED AT LEAST ONCE, because beacon.Latest() is
1502// the only anchor a realm has on the round counter. On a chain where the beacon has
1503// never published a round, beacon.Latest() panics with its own message and the sale
1504// or sponsorship is REFUSED. That is the safe direction and is strictly better than
1505// what it replaces: before the pin, such a Buy succeeded, and the week could then
1506// never be committed (Commit's own beacon.Latest() panicked), never resolved and
1507// never refunded — Refund requires Committed — so the money entered a week no path
1508// in the realm could ever empty. Refusing the sale keeps the money in the buyer's
1509// wallet instead. The refusal happens after the wugnot pull inside Buy, which is
1510// harmless: a panic reverts the whole transaction, exactly as checkInvariant's
1511// panics already rely on.
1512func getOrCreateDraw(week int64) *Draw {
1513 if d := getDraw(week); d != nil {
1514 return d
1515 }
1516 // THE ANCHOR IS THE BEACON'S ESTIMATE OF THE CURRENT ROUND, NOT ITS HIGHEST
1517 // PUBLISHED ONE. beacon.Latest() is whatever somebody last had a reason to
1518 // relay, which under an on-demand record is arbitrarily stale, and pinning
1519 // against a stale anchor puts the week's randomness on drand's public API
1520 // BEFORE sales close — AUDIT-2026-09-28.md finding 10's last-buyer attack. One
1521 // day of staleness is enough. Read pinOffsetRounds in full before changing this
1522 // line. EstimatedLatestRound panics if no round has ever been published, which
1523 // is the deliberate refusal described below.
1524 //
1525 // AND IT NOW ALSO PANICS IF NOBODY HAS RELAYED A ROUND THAT RAISED THE BEACON'S
1526 // ANCHOR WITHIN beacon.MaxEstimateStaleBlocks — one day of blocks. THAT REFUSAL IS
1527 // LOAD-BEARING FOR THIS REALM AND IS NOT A COST IT MERELY TOLERATES: without it the
1528 // anchor's error is unbounded in both directions, which is finding 10 in the lagging
1529 // direction and a permanently unresolvable week in the drifting one. The price is that
1530 // OPENING A WEEK needs a beacon relayed within the last day — one Publish per week,
1531 // permissionless, by whoever wants the week open, alongside the one that already has
1532 // to happen to resolve it. Read pinOffsetRounds' residual-error section and
1533 // beacon.maxEstimateStaleBlocks.
1534 anchor := beacon.EstimatedLatestRound()
1535 if anchor > maxPinnableLatest {
1536 // Loud rather than pinning a wrapped, negative round nobody could ever
1537 // publish. drand cannot sign a round anywhere near the int64 maximum so
1538 // this is now unreachable in practice; see maxPinnableLatest.
1539 panic("raffle: the beacon's latest round is too high to pin a future round against")
1540 }
1541 d := &Draw{
1542 Week: week,
1543 // THE PIN. Never recomputed, never rebound — not by a later Buy, not by
1544 // FundPot, and above all not by Commit.
1545 Round: anchor + pinOffsetRounds,
1546 tickets: fenwick.NewTree(),
1547 ownerOf: avl.NewTree(),
1548 ticketsOf: avl.NewTree(),
1549 earnedOf: avl.NewTree(),
1550 spendOf: avl.NewTree(),
1551 claimedOf: avl.NewTree(),
1552 }
1553 draws.Set(weekKey(week), d)
1554
1555 // EMITTED SO THE PIN IS AUDITABLE FROM THE BLOCK LOG ALONE. The whole security
1556 // argument is "this number was fixed before the ticket set existed", and that is
1557 // a statement about WHEN it was written — which no later read of the realm's
1558 // state can establish on its own. The height is included for the same reason.
1559 chain.Emit("PinRound",
1560 "week", strconv.FormatInt(week, 10),
1561 "round", strconv.FormatInt(d.Round, 10),
1562 "beaconAnchor", strconv.FormatInt(anchor, 10),
1563 "height", strconv.FormatInt(runtime.ChainHeight(), 10))
1564
1565 // THIS FUNCTION NO LONGER MOVES ANY OTHER WEEK'S MONEY — AUDIT-2026-09-28.md finding
1566 // 31, and the four lines that stood here are recorded because the reader who comes
1567 // looking for them needs to know they were not merely tidied away:
1568 //
1569 // if week == currentWeek() {
1570 // absorbCarryPending(d)
1571 // }
1572 //
1573 // WHAT THEY DID AND WHY THEY WERE WRONG. Finding 28 had made a dead week's sponsorship
1574 // PENDING in a register (carryPending) instead of being pushed into a week a caller
1575 // named, and this branch was the PULL: the week being born absorbed every pending
1576 // carry. The safety of that rested entirely on the destination being YOUNG, so that no
1577 // attacker could be its only entrant — and THAT PROPERTY WAS ASSERTED IN A TEST AND
1578 // NEVER ENFORCED HERE. The exploit needed only public entry points: wait for a live
1579 // week with no Draw record (the same realm-quietness that makes carries accumulate in
1580 // the first place), call Buy(1) at weekEndHeight(W) - 1, and this branch absorbed every
1581 // pending carry into a week whose entire ticket set was the attacker's 40 units. The
1582 // week then closed — Buy only sells into currentWeek() — so Commit and Resolve paid
1583 // them the whole carry plus their own stake back less the cut. Cost 500,000 ugnot, gain
1584 // the entire carry, win probability 1. Strictly worse than the pushed destination it
1585 // replaced, which at least forced the attacker to share a pot they had diluted.
1586 //
1587 // AND DELETING IT IS A WIN IN ITS OWN RIGHT, SEPARATELY FROM THE EXPLOIT. This branch
1588 // put a PANIC on the buyer's and the duel's rake-delivery path: absorbCarryPending's
1589 // consistency assertion fired inside Buy, FundPot and FlushPot, so a register that had
1590 // drifted for any reason would refuse ticket sales and refuse the duel's rake. Opening
1591 // a week now touches nothing but the week being opened.
1592 return d
1593}
1594
1595// retireSponsorship turns a dead week's sponsorship into a claim held BY THE PEOPLE WHO
1596// EARNED ENTRIES IN THAT SAME WEEK. It is the whole of AUDIT-2026-09-28.md finding 31's fix and
1597// the ONE statement that is worth making about it is that IT HAS NO DESTINATION: nothing
1598// here takes a week as a parameter, reads currentWeek(), reads the beacon, or looks at any
1599// Draw but the one it is given.
1600//
1601// WHY THAT IS THE FIX RATHER THAN A BETTER-CHOSEN DESTINATION. Three designs in three days
1602// each moved this money somewhere: to opsPot (finding 16 — the house was the party best
1603// placed to trigger the move and the party that received it), to currentWeek() (finding 28
1604// — a destination evaluated at call time is a destination the caller chooses), and into the
1605// next week to be created (finding 31 — the caller creates that week, with one Buy, and can
1606// be its only entrant). THE COMMON FACTOR IS NOT WHICH DESTINATION WAS PICKED, IT IS THAT
1607// THERE WAS ONE. This money came from the duel players whose rake it is; giving it back to
1608// them, pro rata, in the week it is already in, leaves nothing for anybody to aim.
1609//
1610// AND "THEM" IS THE EARNED-ENTRY HOLDERS, NOT EVERY UNIT HOLDER — AUDIT-2026-09-28.md
1611// finding 34. THE SENTENCE ABOVE SAID THE WRONG THING FOR ONE DAY and the code divided on
1612// d.tickets.Total() to match it. The retired text, because it is the defect stated out loud:
1613//
1614// "This money came from the duel players whose rake it is AND FROM THE BUYERS WHO BOUGHT
1615// INTO THIS WEEK; giving it back to them, pro rata, in the week it is already in, leaves
1616// nothing for anybody to aim."
1617//
1618// THE TWO UNIT KINDS DID NOT CONTRIBUTE EQUALLY TO THE THING BEING DIVIDED, so "pro rata"
1619// over both of them divided it on the wrong basis:
1620//
1621// An EARNED unit PUT 150,000 ugnot INTO Sponsored — EntryUnitPrice scaled by the duel's
1622// players' share, arcadeRakePotShareBps/arcadeBpsDenom — and IT IS the sponsorship. Its
1623// holder can recover that money by no other route at all, because an earned entry has no
1624// spendOf record and Refund pays spendOf and nothing else.
1625//
1626// A BOUGHT unit PUT NOTHING INTO Sponsored. It put 250,000 ugnot into Pot and Cut, and its
1627// holder recovers THAT IN FULL through Refund — the whole gross, fee included (finding 25).
1628//
1629// SO A REFUNDED BUYER ENDS AN ABANDONED WEEK WITH ZERO NET STAKE AND, UNDER THE OLD BASIS, A
1630// POSITIVE CLAIM ON THE DUEL PLAYERS' RAKE. They keep their units deliberately — fenwick
1631// refuses negative deltas by design, so a ticket cannot be retired once sold (see Refund) —
1632// so the refund returns every ugnot they paid AND leaves their share of somebody else's money
1633// standing. With equal counts of the two kinds, each duel player recovered HALF of what their
1634// own rake contributed and a buyer took the other half for nothing. Paying those players is
1635// finding 31's entire stated purpose, so this was not a rounding error in that fix: it was
1636// that fix missing its target.
1637//
1638// THE DRAW'S BASIS IS UNTOUCHED AND MUST STAY SO. tickets.Total(), ticketsOf, ownerOf and
1639// FrozenTotal still count both kinds identically, because a unit is a unit in the draw
1640// whoever paid for it — that indistinguishability is finding 15's design and the earned
1641// entry's whole point. Only the CLAIM has a second basis. Read Draw.earnedOf.
1642//
1643// PURE BOOKKEEPING: NO WUGNOT MOVES. The tokens have been inside this realm since FundPot
1644// pulled them in. Sponsored falls, Claims rises by the same amount less the dust, and the
1645// dust is reclassified from a pot to opsPot — both of which the realm already holds.
1646//
1647// IT IS O(1) AND THAT IS LOAD-BEARING, NOT AN OPTIMISATION. Its main caller is Refund's
1648// abandonment transition, and Refund MUST NOT FAIL: a buyer is waiting for their own money,
1649// so a path that can be made too expensive to execute is a "money in, no way out" defect
1650// wearing a gas limit. The obvious implementation — walk earnedOf and add each holder's
1651// share to a tree — is O(number of earned holders), and that set is unbounded (every
1652// duel played in the week appends two through FundPotWithEntries). One rate is written
1653// instead, and ClaimDeadWeek derives a holder's share from it in O(log N).
1654//
1655// FINDING 34 DID NOT COST THAT PROPERTY AND COULD NOT BE ALLOWED TO. Changing the basis from
1656// tickets.Total() to earnedTotal is ONE FIELD READ FOR ANOTHER: both are O(1) counters
1657// maintained incrementally by their single writer (fenwick's total, and appendEarnedTickets
1658// respectively). This function reads no tree, iterates nothing, and its statement count is
1659// unchanged. The ten-holder gas twins exist so that a reintroduced loop shows up as a number
1660// rather than as a review comment.
1661//
1662// THE TWO CASES ARE NAMED HERE AND AT EVERY CALL SITE, and the branch between them is
1663// earnedTotal and nothing else — IT WAS tickets.Total() UNTIL finding 34:
1664//
1665// - THERE ARE EARNED HOLDERS (earnedTotal > 0). The money is theirs, and theirs alone:
1666// every ugnot except the sub-unit dust becomes claimable in proportion to EARNED weight.
1667// A buyer's bought weight is worth nothing here, because it contributed nothing here and
1668// Refund gives it back in full.
1669// - THERE ARE NO EARNED HOLDERS AT ALL (earnedTotal == 0). This is reachable and real, and
1670// it is now the WIDER branch of the two: a plain FundPot from any caller that is not the
1671// duel (into a week nobody entered OR into a week full of buyers), a duel flush whose
1672// rake was below one entry unit, or a duel both of whose parties played from realm code.
1673// That money PROVABLY HAS NO PLAYER BEHIND IT — no address earned a unit of this week —
1674// so it goes to opsPot, the destination nobody can choose, and nobody is wronged. Note
1675// what this branch does NOT do: it does not hand plain house money to the week's buyers,
1676// who paid nothing towards it and get their own gross back regardless.
1677// A WEEK WITH EARNED HOLDERS CAN NEVER FALL INTO THIS BRANCH: earnedTotal is
1678// monotonically non-decreasing (appendEarnedTickets only ever adds, and nothing in this
1679// file decrements or removes an earnedOf entry), so a week that has ever had one earned
1680// unit has a positive earnedTotal for ever.
1681//
1682// THE DUST, EXPLICITLY, BECAUSE THE IDENTITY MUST HOLD EXACTLY AND NOT APPROXIMATELY.
1683// perUnit is floor(Sponsored / earnedTotal), so the shares sum to perUnit * earnedTotal, which
1684// is Sponsored - dust where 0 <= dust < earnedTotal. That dust has NO UNIT TO ATTACH TO: it is
1685// strictly smaller than one ugnot per earned unit, so there is no holder it could be given a
1686// share of without giving some holder more per unit than another. IT GOES TO opsPot IN THIS
1687// SAME STATEMENT and is reported in the event, so it is never left sitting in a pot with no
1688// claim on it and it CANNOT ACCUMULATE — there is exactly one retirement per week, for ever
1689// (Abandoned is one-way and this function refuses a second run).
1690//
1691// THE BOUND IMPROVED BY TWO ORDERS OF MAGNITUDE AS A SIDE EFFECT OF finding 34, AND THE
1692// ARITHMETIC IS WORTH WRITING OUT BECAUSE THE OLD BOUND WAS STATED AGAINST THE WRONG
1693// QUANTITY. It was "bounded above by the week's unit count in ugnot: 40 ugnot per
1694// ticket-equivalent against a 10,000,000 ugnot ticket, so at most four parts per million of a
1695// week's SALES" — a fraction of the sales, which have nothing to do with the sponsorship being
1696// divided. As a fraction of the money actually at stake it was unbounded:
1697//
1698// 1,000 tickets sold (40,000 bought units) and one rung-1 duel's rake of 1,800,000 ugnot
1699// (12 earned units). Old divisor 40,012: perUnit = 44, distributed 1,760,528, DUST 39,472
1700// — 2.2% of the players' rake to the house. New divisor 12: perUnit = 150,000,
1701// distributed 1,800,000, DUST ZERO.
1702//
1703// THE NEW BOUND IS A FRACTION OF THE SPONSORSHIP ITSELF AND IS INDEPENDENT OF TICKET SALES:
1704//
1705// dust < earnedTotal, and arcadeEntryUnitsBound caps every delivery at
1706// units <= floor(floor(amount * arcadeBpsDenom / arcadeRakePotShareBps) / EntryUnitPrice)
1707// <= amount * (10000/6000) / 250000 = amount / 150,000, which sums over deliveries to
1708// earnedTotal <= Sponsored / 150,000.
1709//
1710// So dust < Sponsored / 150,000: STRICTLY UNDER SEVEN PARTS PER MILLION of the amount
1711// being retired, against 2.2% of it in the worked example above.
1712//
1713// AND THE SAME INEQUALITY MAKES perUnit >= 150,000 WHENEVER earnedTotal > 0, since
1714// Sponsored >= 150,000 * earnedTotal. So the "the whole amount was sub-unit dust" state —
1715// perUnit == 0 on a week that HAS holders — IS NO LONGER REACHABLE through the duel. THE CODE
1716// FOR IT IS KEPT ANYWAY AND IS NOT CONVERTED INTO AN ASSERTION, deliberately, for the reason
1717// that governs this whole function: a panic here is a panic inside Refund. Plain FundPot
1718// money can be any size and arcadeEntryUnitsBound is a constant somebody may retune, so an
1719// assertion would trade a bounded, already-handled rounding case for a buyer who cannot
1720// reclaim their own ticket money. Handle it, do not assert it. ClaimDeadWeek's
1721// "no dead-week share to claim" names the state for a caller either way.
1722//
1723// THE TWO ALTERNATIVES AND WHY NOT. A LARGEST-REMAINDER OR RUNNING-CUMULATIVE PASS
1724// distributes the dust exactly and leaves nothing for the house, but it is the O(holders)
1725// loop this function exists to avoid. GIVING THE DUST TO THE FIRST CLAIMANT keeps it with
1726// the players but makes it a RACE for a caller-chosen benefit, which is the exact shape of
1727// the three defects above, at a tiny scale — and this realm has now been wrong twice by
1728// accepting a small choosable benefit. opsPot is the destination nobody can choose.
1729//
1730// Panics, defensively, if the week is not in the state its two callers guarantee.
1731// foldSponsorCut turns a week's escrowed SPONSOR fee back into sponsorship.
1732//
1733// IDEMPOTENT, AND CALLED FROM BOTH EXITS A DEAD WEEK HAS. retireSponsorship runs
1734// it for the Refund path; SweepDrawlessWeek runs it BEFORE its own assertions,
1735// because one of those assertions is "a swept week holds no escrowed fee" and a
1736// sponsorship is the one way a fee can be present with no ticket sale behind it.
1737// Leaving the fold until after that check was a REAL DEFECT and not a tidiness
1738// one: the assertion fired, the sweep was the only exit a ticketless week has,
1739// and the whole week's pot — including unrelated duel rake — was stranded with
1740// AuditFailure reporting green over it. Found by the 2026-09-30 audit, R1.
1741//
1742// IT MOVES BETWEEN TWO TRACKED TOTALS AND CREATES NOTHING: heldCuts falls by
1743// exactly what heldPots rises by, so heldPots + heldCuts + opsPot is untouched.
1744func foldSponsorCut(d *Draw) {
1745 c := d.SponsorCut
1746 if c <= 0 {
1747 return
1748 }
1749 d.SponsorCut = 0
1750 d.Cut -= c
1751 heldCuts -= c
1752 d.Pot += c
1753 d.Sponsored += c
1754 heldPots += c
1755 heldSponsored += c
1756 if d.Cut < 0 {
1757 panic("raffle: retiring the sponsors' fee would overdraw the week's escrowed fee")
1758 }
1759}
1760
1761func retireSponsorship(d *Draw) {
1762 // THE SPONSORS' FEE NEVER BECAME THE HOUSE'S, BECAUSE THE DRAW NEVER RAN. Fold it
1763 // back into the sponsorship it was taken from, so it leaves by the same door as
1764 // the rest of that money. Read Draw.SponsorCut for why it cannot leave by Refund's
1765 // door instead: Refund retires a fee it RE-DERIVES from a buyer's spendOf, and a
1766 // sponsor has no spendOf to derive one from.
1767 //
1768 // BEFORE THE REFUSALS BELOW ON PURPOSE, AND SAFE THERE: a panic reverts every
1769 // write in the transaction, so a fold followed by a refusal leaves nothing behind.
1770 // Putting it first means the guards see the folded figures and cannot be satisfied
1771 // by a state this function is about to change.
1772 //
1773 // IT MOVES BETWEEN TWO TRACKED TOTALS AND CREATES NOTHING: heldCuts falls by
1774 // exactly what heldPots rises by, so heldPots + heldCuts + opsPot is untouched.
1775 foldSponsorCut(d)
1776
1777 s := d.Sponsored
1778 if s <= 0 {
1779 // Both callers test this before calling; a zero here would mean writing a claim
1780 // rate of zero over a week and emitting an event about nothing.
1781 panic("raffle: that week holds no sponsorship to retire")
1782 }
1783 if d.Resolved || !d.Abandoned {
1784 // Unreachable: both callers set Abandoned in the statement above the call, and a
1785 // week in refund or swept can never be resolved. Loud, because this function
1786 // stops money being refundable-as-sponsorship and starts it being claimable, and
1787 // doing that to a live week would take a prize away from a winner.
1788 panic("raffle: only an abandoned, unresolved week's sponsorship may be retired")
1789 }
1790 // d.SponsorRefundable JOINS THIS GUARD because the early-return shape — a week
1791 // whose whole sponsorship had a payer — leaves ClaimPerUnit and Claims at zero,
1792 // so the two fields below do not witness that retirement at all. Without this the
1793 // only thing standing between a second retirement and a doubled
1794 // heldSponsorRefunds would be the s <= 0 panic above, whose message describes a
1795 // different condition. 2026-09-30 audit, Y2.
1796 if d.ClaimPerUnit != 0 || d.Claims != 0 || d.SponsorRefundable != 0 {
1797 // Unreachable: Abandoned is one-way, Refund's transition is inside !d.Abandoned
1798 // and SweepDrawlessWeek refuses an abandoned week outright. Loud rather than
1799 // overwriting a rate holders have already been paid against.
1800 panic("raffle: that week's sponsorship has already been retired")
1801 }
1802
1803 // THE EARNED UNIT TOTAL IS THE BASIS, NOT THE WEEK'S WHOLE UNIT TOTAL —
1804 // AUDIT-2026-09-28.md finding 34. This line read `total := d.tickets.Total()`, which
1805 // counted bought units alongside earned ones and so paid a refunded buyer out of the duel
1806 // players' rake. Read the block above for the arithmetic and Draw.earnedOf for the two
1807 // unit kinds. IT IS STILL ONE FIELD READ: the O(1) promise this function's whole shape
1808 // rests on is unaffected.
1809 total := d.earnedTotal
1810 if total < 0 || total > d.tickets.Total() {
1811 // Unreachable by construction: appendEarnedTickets is the only writer of earnedTotal
1812 // and it calls appendTickets in the same statement group, so every earned unit is also
1813 // a ticket unit and neither counter is ever decremented. Loud rather than dividing a
1814 // real balance by a counter that has drifted from the tree ClaimDeadWeek will pay
1815 // against — that is the one way the claim sum could stop being exact.
1816 panic("raffle: that week's earned unit total is not a subset of its ticket units")
1817 }
1818 // THE SPONSORS' MONEY IS CARVED OUT FIRST AND NEVER REACHES EITHER BRANCH BELOW.
1819 // Both of them dispose of money that has no payer — the duel's ownerless rake —
1820 // by dividing it among unit holders, or by sending it to the house when there are
1821 // no earned units. A sponsor's payment HAS a payer, and the owner ruled on
1822 // 2026-09-30 that it goes back to them rather than to either destination. So it
1823 // leaves d.Sponsored here, stays in d.Pot, and waits to be pulled.
1824 //
1825 // A PULL AND NOT A PUSH, deliberately: paying up to maxSponsorsPerWeek addresses
1826 // inside this function would put every one of those transfers on the critical
1827 // path of a buyer's own Refund, and one failing recipient would block the week.
1828 // ClaimSponsorRefund is the door; it is the same shape as ClaimDeadWeek.
1829 // A NOTE FOR THE DUST ARGUMENT ABOVE, WHICH IS STATED AGAINST d.Sponsored AND IS
1830 // NOW APPLIED TO s. The bound still holds, and it is worth one line saying WHY
1831 // rather than leaving the next reader to re-derive it: sponsor money buys no
1832 // entry units, so every earned unit was paid for out of the duel's rake alone.
1833 // F >= 150,000 * earnedTotal independently of anything a sponsor put in, and F is
1834 // exactly what survives the carve-out. 2026-09-30 audit, Y5.
1835 refundable := d.SponsorPaid
1836 if refundable > s {
1837 // Unreachable: foldSponsorCut has already returned the sponsors' fee to
1838 // Sponsored, so gross is fully inside s. Asserted because if it ever stopped
1839 // being true the carve-out would take money belonging to the duel's rake.
1840 panic("raffle: a week owes its sponsors more than the sponsorship it holds")
1841 }
1842 d.SponsorRefundable = refundable
1843 heldSponsorRefunds += refundable
1844 s -= refundable
1845
1846 // heldSponsored falls by the WHOLE original figure, refundable part included:
1847 // none of it is sponsorship any more. The refundable part is now tracked by
1848 // heldSponsorRefunds instead, and both are subsets of heldPots, so the global
1849 // three-term equality is untouched and the ceiling below still holds.
1850 d.Sponsored = 0
1851 heldSponsored -= refundable + s
1852
1853 if s == 0 {
1854 // EVERY UGNOT OF IT HAD A PAYER. Nothing is left for either branch below to
1855 // dispose of, and dividing zero among holders or sending zero to the house
1856 // would just be noise in the event log.
1857 chain.Emit("DeadWeekSponsorshipRefundable",
1858 "week", strconv.FormatInt(d.Week, 10),
1859 "owedToSponsors", strconv.FormatInt(refundable, 10),
1860 "ownerlessRake", "0",
1861 "height", strconv.FormatInt(runtime.ChainHeight(), 10))
1862 return
1863 }
1864
1865 if total == 0 {
1866 // THE NO-CLAIMANT CASE, stated at the site as the doctrine demands. NO ADDRESS EARNED
1867 // A UNIT of this week, so no player is behind this money: there is no pro-rata share
1868 // to compute and no person whose money it is. The week may well have BUYERS — since
1869 // finding 34 that is the common shape of this branch, a plain FundPot into a week
1870 // with ticket sales — and they are not the claimants: they paid nothing towards this
1871 // amount and Refund returns their own gross in full regardless.
1872 d.Pot -= s
1873 heldPots -= s
1874 opsPot += s
1875 if d.Pot < 0 {
1876 // AUDIT-2026-09-28.md finding 35. Unreachable while
1877 // Pot + Cut == sum(spendOf) + Sponsored + Claims holds, because s is this week's
1878 // whole Sponsored term and the pot contains it by that identity. It is asserted
1879 // because THIS WAS THE ONE MONEY MOVER IN THE FILE WITHOUT THE GUARD: Refund
1880 // (twice) and ClaimDeadWeek all state it, and checkInvariant cannot stand in for
1881 // it — that checks the AGGREGATE heldPots, so a negative pot here could be masked
1882 // by a compensating positive pot on another week. Only AuditFailure's per-week
1883 // identity would see it, and AuditFailure never runs on a state-changing path.
1884 panic("raffle: retiring that week's sponsorship would overdraw its pot")
1885 }
1886 chain.Emit("DeadWeekSponsorshipToOps",
1887 "week", strconv.FormatInt(d.Week, 10),
1888 "amount", strconv.FormatInt(s, 10),
1889 "reason", "no earned-entry holders",
1890 "tickets", strconv.FormatInt(d.tickets.Total(), 10),
1891 "pot", strconv.FormatInt(d.Pot, 10),
1892 "height", strconv.FormatInt(runtime.ChainHeight(), 10))
1893 return
1894 }
1895
1896 // THE CLAIMANT CASE. These are the duel players whose rake this is, and ONLY them: their
1897 // earned units ARE the sponsorship, at 150,000 ugnot each.
1898 perUnit := s / total
1899 distributed := perUnit * total
1900 dust := s - distributed
1901 if dust < 0 || dust >= total {
1902 // Unreachable by the definition of integer division. Stated because the whole
1903 // exactness argument is this one line.
1904 panic("raffle: dead-week dust is out of range")
1905 }
1906
1907 d.ClaimPerUnit = perUnit
1908 d.Claims = distributed
1909 heldClaims += distributed
1910 if dust > 0 {
1911 d.Pot -= dust
1912 heldPots -= dust
1913 opsPot += dust
1914 }
1915
1916 chain.Emit("DeadWeekSponsorshipDistributed",
1917 "week", strconv.FormatInt(d.Week, 10),
1918 "amount", strconv.FormatInt(s, 10),
1919 // BOTH TOTALS ARE REPORTED, AND THE DIVISOR IS THE EARNED ONE — finding 34. An
1920 // operator reading this event can see at a glance how much of the week was bought and
1921 // therefore excluded from the division; `units` alone used to be the ticket total and
1922 // silently WAS the divisor.
1923 "units", strconv.FormatInt(total, 10),
1924 "ticketUnits", strconv.FormatInt(d.tickets.Total(), 10),
1925 "perUnit", strconv.FormatInt(perUnit, 10),
1926 "distributed", strconv.FormatInt(distributed, 10),
1927 "dustToOps", strconv.FormatInt(dust, 10),
1928 "pot", strconv.FormatInt(d.Pot, 10),
1929 "height", strconv.FormatInt(runtime.ChainHeight(), 10))
1930}
1931
1932// weekIsOpenable IS GONE WITH ReclaimStrandedCarry — AUDIT-2026-09-28.md finding 31 — and
1933// its absence is worth a paragraph because it was finding 12's residual made visible. It
1934// read:
1935//
1936// func weekIsOpenable() bool {
1937// if !beacon.EstimateIsUsable() {
1938// return false
1939// }
1940// return beacon.EstimatedLatestRound() <= maxPinnableLatest
1941// }
1942//
1943// and it answered "could a new week's record be created right now?" without panicking, so
1944// that a caller who must not panic could ask before committing to a destination. EVERY
1945// CALLER OF IT WAS A CALLER THAT NEEDED A DESTINATION. There are none now: a dead week's
1946// money is claimed out of the week it is already in, and ClaimDeadWeek reads no beacon at
1947// all. So the state finding 12 recorded as a permanent residual — a chain whose average
1948// block time settles below 3.29998 s, in which no week can ever be opened again — NO LONGER
1949// STRANDS A DEAD WEEK'S SPONSORSHIP AT ALL, where before it needed a whole extra entry
1950// point (ReclaimStrandedCarry) and a three-week long stop to escape. Removing the need for
1951// a destination removed the need for the fallback.
1952
1953// weekHasNoSpend reports whether NOBODY EVER BOUGHT A TICKET in this week — that is,
1954// whether sum(spendOf) is zero. It is the test SweepDrawlessWeek uses to tell money
1955// that belongs to nobody from money that belongs to buyers.
1956//
1957// IT IS AN ITERATION AND NOT A COUNTER, on purpose: a per-week spend total would be a
1958// fourth aggregate to keep in step with heldPots, heldSponsored and the per-week
1959// identity, and this is not on a hot path (SweepDrawlessWeek runs at most once per
1960// week, ever). AuditFailure already re-derives the same sum the same way.
1961//
1962// IT STOPS AT THE FIRST NON-ZERO ENTRY rather than summing, so it cannot overflow and
1963// costs one lookup on the common case.
1964//
1965// A ZERO ENTRY IS NOT A CLAIM, and that distinction is why this reads the values
1966// instead of asking Size(). Refund ZEROES a spend record rather than removing it
1967// (d.spendOf.Set(k, 0)), so a week that has taken refunds has entries whose values are
1968// zero — but such a week is Abandoned and SweepDrawlessWeek refuses it before asking
1969// this, so the two can never be confused.
1970//
1971// NEVER CALL IT FROM INSIDE ANOTHER avl.Iterate: nesting a second traversal inside the
1972// first's callback crashes this toolchain's VM outright (see AuditFailure).
1973func weekHasNoSpend(d *Draw) bool {
1974 none := true
1975 d.spendOf.Iterate("", "", func(_ string, v any) bool {
1976 if v.(int64) > 0 {
1977 none = false
1978 return true // stop
1979 }
1980 return false
1981 })
1982 return none
1983}
1984
1985func treeInt64(t *avl.Tree, key string) int64 {
1986 if !t.Has(key) {
1987 return 0
1988 }
1989 return t.Get(key).(int64)
1990}
1991
1992// appendTickets records a purchase of units by addr by APPENDING A NEW INDEX
1993// to this week's ticket index. It never touches an existing index, and that is
1994// the whole point — see Commit's frozen-ownership argument.
1995func appendTickets(d *Draw, addr address, units int64) {
1996 if units <= 0 {
1997 // fenwick.Append would reject a negative itself; failing here names
1998 // the caller that got it wrong, and a zero-unit purchase would create
1999 // an index that owns nothing.
2000 panic("raffle: ticket units must be positive")
2001 }
2002 i := d.tickets.Append(units)
2003 d.ownerOf.Set(indexKey(i), string(addr))
2004 k := string(addr)
2005 d.ticketsOf.Set(k, treeInt64(d.ticketsOf, k)+units)
2006}
2007
2008// appendEarnedTickets is appendTickets PLUS the record that these units were EARNED rather
2009// than bought. It is the only writer of earnedOf and earnedTotal, and FundPotWithEntries is
2010// its only caller. AUDIT-2026-09-28.md finding 34.
2011//
2012// IT WRAPS appendTickets RATHER THAN SITTING BESIDE IT, AND THAT IS THE WHOLE GUARANTEE.
2013// sum(earnedOf) == earnedTotal has to hold identically, not approximately, because
2014// retireSponsorship divides a real wugnot balance by the counter and ClaimDeadWeek pays out
2015// against the tree — a drift of one unit between them is a week whose claims sum to more or
2016// less than its Claims. Two sibling calls in a caller's loop can drift the moment somebody
2017// adds an early `continue`; one function that cannot append the weight without recording the
2018// basis cannot. The three writes are one statement group from one `units` argument, and
2019// nothing in this file ever decrements or removes an entry of either.
2020//
2021// THERE IS DELIBERATELY NO `earned bool` PARAMETER ON appendTickets INSTEAD. Buy's call would
2022// then have to pass `false`, which is a place a future edit can pass `true` by accident and
2023// hand a buyer a claim on the duel's rake — the exact defect finding 34 is about. The
2024// bought path cannot name this function at all.
2025func appendEarnedTickets(d *Draw, addr address, units int64) {
2026 appendTickets(d, addr, units) // panics on a non-positive count before anything is written
2027 k := string(addr)
2028 d.earnedOf.Set(k, treeInt64(d.earnedOf, k)+units)
2029 d.earnedTotal += units
2030}
2031
2032// ownerOfUnit is the draw rule's second half: who owns cumulative ticket unit
2033// k in this week's index. Factored out so the boundary tests can exercise the
2034// mapping over every interesting k without needing a signed beacon round for
2035// each one.
2036func ownerOfUnit(d *Draw, k int64) address {
2037 if d.tickets == nil || k < 0 || k >= d.tickets.Total() {
2038 panic("raffle: ticket unit out of range")
2039 }
2040 key := indexKey(d.tickets.Select(k))
2041 if !d.ownerOf.Has(key) {
2042 // Unreachable while appendTickets is the only writer: it sets the
2043 // reverse entry in the same statement group as the Append. Loud rather
2044 // than returning a zero address that would look like a winner.
2045 panic("raffle: ticket index has no owner")
2046 }
2047 return address(d.ownerOf.Get(key).(string))
2048}
2049
2050// --- native coin -----------------------------------------------------------
2051
2052// rejectAttachedCoin panics if the caller attached native coin to a call that
2053// has no use for it. Every money-in path in this realm is the wugnot GRC20
2054// allowance (Buy, Sponsor, FundPot, FundPotWithEntries), never the
2055// transaction's send envelope, so any attached ugnot would simply sit in this
2056// realm forever with no code path that ever looks at it — AUDIT-2026-10-07.md
2057// Y1. Call this as the first statement of every crossing entrypoint so the
2058// transaction reverts and the sender keeps their coin instead.
2059//
2060// unsafe.OriginSend() reads the envelope attached at the CHAIN ROOT, and that
2061// envelope is shared by every realm in the call chain: an intermediate realm
2062// can consume it, or have already consumed it, before this call is ever
2063// reached. Reading it is only sound when the immediate caller is an EOA, so
2064// that whatever it describes was paid to THIS realm and nothing else first.
2065// cur.Previous().IsUserCall() is that check (IsUser() is not — it also
2066// accepts `maketx run` ephemeral realms, which can pre-consume the envelope);
2067// see r/splash/exchange/exchange.gno's Buy/Seed comment for the fuller
2068// argument, which this mirrors rather than contradicts. WHEN THE CALL DID NOT
2069// COME DIRECTLY FROM A USER — which is every call FundPot and
2070// FundPotWithEntries ever accept, since both require cur.Previous().PkgPath()
2071// to be the duel's — THIS FUNCTION DOES NOT LOOK AT THE ENVELOPE AT ALL: it
2072// cannot be attributed to this call either way, so there is nothing sound to
2073// enforce, and guessing would be worse than staying silent.
2074//
2075// THE OVERWHELMINGLY COMMON CASE — a plain call with nothing attached — is
2076// cheap: one stack-walk for IsUserCall, then unsafe.OriginSend() always
2077// allocates (even a len-0 slice) and len(sent) == 0 is a single comparison.
2078// No iteration, no second cross-realm call.
2079func rejectAttachedCoin(cur realm, what string) {
2080 if !cur.Previous().IsUserCall() {
2081 return
2082 }
2083 if sent := unsafe.OriginSend(); len(sent) != 0 {
2084 panic("raffle: " + what + " does not take native coin — pay through the wugnot allowance instead")
2085 }
2086}
2087
2088// --- solvency ------------------------------------------------------------
2089
2090// sweepDonations accounts for any wugnot that has arrived without going
2091// through Buy.
2092//
2093// IT HAS TO EXIST. wugnot.Transfer is permissionless: any address may push
2094// wugnot at this realm with no memo, no callback and no way for this realm to
2095// learn who sent it or why. Without a sweep, one unsolicited transfer would
2096// fail checkInvariant for every later caller and brick the realm.
2097//
2098// IT GOES TO opsPot, NOT TO THE CURRENT WEEK'S POT, which is the opposite of
2099// r/splash/game's choice and for a reason specific to this design: here
2100// Pot == sum(this week's spend records) is a load-bearing identity — it is what
2101// makes Refund unable to pay out more than was put in — and a donation credited
2102// to a pot would break it, silently converting somebody's gift into refundable
2103// balance that nobody has a claim on. The ops pot has no such identity to
2104// violate, and a donation there enlarges nothing it should not.
2105//
2106// IT EMITS A Donation EVENT FOR EVERY NON-ZERO ABSORPTION, and that is not
2107// bookkeeping decoration — it is the only record that the stream exists. Nothing
2108// else in this realm distinguishes a swept arrival from the house cut: both land
2109// in opsPot, both are withdrawn by WithdrawOps, and opsPot is a single number. So
2110// without this event an operator reading the chain cannot tell revenue from gifts,
2111// cannot see a donation arrive at all, and — the reason it matters — cannot see the
2112// masking case below.
2113//
2114// WHAT THE INVARIANT'S EQUALITY IS ACTUALLY WORTH, RECORDED HERE SO THE NEXT
2115// READER DOES NOT OVERTRUST IT. Suppose heldPots OVERCOUNTS by D because of a bug
2116// in this file — the realm believes it owes D more than it holds. Suppose donations
2117// totalling X > D have arrived. This function computes
2118// extra = bal - (heldPots + opsPot), which is X - D, absorbs it into opsPot, and
2119// afterwards heldPots + opsPot == bal EXACTLY. checkInvariant then passes, and so
2120// does AuditFailure's one-sided balance test. THE DEFICIT HAS NOT BEEN DETECTED, IT
2121// HAS BEEN PAID FOR OUT OF A STRANGER'S MONEY, and both solvency checks report
2122// green over it. The equality in checkInvariant is therefore weaker than it looks:
2123// it says "the books add up after absorbing whatever arrived", not "the books were
2124// already right". This event is what makes the absorption visible so the case can
2125// be noticed off-chain — a Donation for an amount no donor sent is the signature of
2126// exactly that state.
2127//
2128// FIXING THAT PROPERLY IS OUT OF SCOPE HERE, deliberately. It needs a separate
2129// counter for absorbed donations so the invariant can subtract them and state the
2130// pre-donation equality — a change to what the invariant MEANS, in a realm where
2131// every mutator asserts it, and therefore a separately-audited one. This function's
2132// destination is unchanged: donations still go to opsPot, not to any week's pot,
2133// for the reason above.
2134func sweepDonations() {
2135 if extra := absorbDonation(); extra > 0 {
2136 // EMITTED AFTER THE CREDIT, so the reported opsPot is the post-sweep figure
2137 // a reader can reconcile against OpsPot(). NOTHING IS EMITTED WHEN NOTHING
2138 // WAS ABSORBED: an empty sweep runs at the top of every mutator in this
2139 // file, and an event on each would bury the one that means something.
2140 chain.Emit("Donation",
2141 "amount", strconv.FormatInt(extra, 10),
2142 "opsPot", strconv.FormatInt(opsPot, 10))
2143 }
2144}
2145
2146// absorbDonation is sweepDonations without the event: it credits opsPot with any
2147// unattributed arrival and RETURNS THE AMOUNT IT ABSORBED, zero if there was
2148// nothing. Everything said about the destination and the masking case is on
2149// sweepDonations.
2150//
2151// IT IS SPLIT OUT FOR ONE REASON, AND IT IS A TESTABILITY REASON STATED PLAINLY:
2152// this toolchain gives a Gno test no way to read emitted events, so the only
2153// statements about the Donation event that can be asserted on-chain are the value
2154// it would carry and whether it fires at all. Both are exactly this return value,
2155// and raffle_test.gno asserts it on both sides — a real arrival absorbed to the
2156// ugnot, and an empty sweep returning zero so the caller above emits nothing. The
2157// Emit is then a one-line function of a tested quantity rather than an untested
2158// branch. Do not inline it back without putting the event under test some other
2159// way.
2160func absorbDonation() int64 {
2161 bal := wugnot.BalanceOf(selfAddr())
2162 // heldCuts IS IN HERE BECAUSE IT IS WUGNOT THIS REALM HOLDS. Omitting it would make
2163 // every week's escrowed fee look like an unattributed arrival, and the first sweep
2164 // after any sale would move the whole of it into opsPot — banking the fee at sale
2165 // time by accident, which is exactly the behaviour finding 25 removed, and leaving
2166 // every refund unable to return a gross it no longer holds.
2167 extra := bal - (heldPots + heldCuts + opsPot)
2168 if extra <= 0 {
2169 return 0
2170 }
2171 opsPot += extra
2172 return extra
2173}
2174
2175// checkInvariant is this realm's solvency statement: the wugnot it holds is
2176// exactly the sum of every unresolved week's pot plus the ops pot, and nothing
2177// else. O(1), using the incrementally-maintained heldPots; AuditFailure
2178// re-derives the same thing by iteration.
2179//
2180// CALLED AT THE END OF EVERY STATE-CHANGING FUNCTION. An equality, not a ">=":
2181// sweepDonations runs first in each of them, so unattributed arrivals are
2182// already accounted for and any remaining gap is a bug in this file.
2183//
2184// THE SPONSORSHIP HALF OF THE POT IDENTITY IS CHECKED HERE ONLY IN ITS O(1)
2185// FORM. The full statement is per week — Pot + Cut == sum(spendOf) + Sponsored — and
2186// verifying it directly would mean iterating every buyer of every week on every
2187// call, which is exactly the cost this function exists to avoid. What is
2188// affordable, and is asserted, is that the aggregate sponsorship is a
2189// non-negative part of the pots that hold it: more sponsorship than pots would
2190// mean the buyers' collective claim had gone negative somewhere. AuditFailure
2191// re-derives the per-week identity ONE WEEK AT A TIME (finding 27 — it used to do it
2192// summed over the unresolved weeks, which two weeks with compensating errors passed
2193// through silently).
2194func checkInvariant() {
2195 bal := wugnot.BalanceOf(selfAddr())
2196 // THREE TERMS SINCE 2026-09-29, NOT TWO. The house fee is escrowed per week in
2197 // Draw.Cut rather than banked into opsPot at sale time (finding 25), so the wugnot
2198 // this realm holds is the unresolved prize pots, PLUS the unearned fees held against
2199 // those weeks, PLUS the ops pot. Read heldCuts.
2200 if heldPots+heldCuts+opsPot != bal {
2201 panic("raffle: GNOT invariant violated")
2202 }
2203 if heldPots < 0 {
2204 // Would mean a pot was paid out twice.
2205 panic("raffle: heldPots negative")
2206 }
2207 if heldCuts < 0 {
2208 // Would mean a week's escrowed fee was released twice — a Resolve and a Refund
2209 // both taking it, or a refund re-deriving more cut than the buyer ever paid.
2210 // cutPerTicket's build guard is what makes the second impossible.
2211 panic("raffle: heldCuts negative")
2212 }
2213 if opsPot < 0 {
2214 panic("raffle: opsPot negative")
2215 }
2216 if heldSponsored < 0 {
2217 // Would mean sponsorship was removed from a week twice — retireSponsorship
2218 // running more than once over the same week, say.
2219 panic("raffle: heldSponsored negative")
2220 }
2221 if heldSponsorRefunds < 0 {
2222 panic("raffle: heldSponsorRefunds negative")
2223 }
2224 if heldClaims < 0 {
2225 // Would mean a dead week's share was paid twice: two ClaimDeadWeek calls for one
2226 // holder, or a claim derived from a unit count that grew after the retirement.
2227 panic("raffle: heldClaims negative")
2228 }
2229 if heldSponsored+heldClaims+heldSponsorRefunds > heldPots {
2230 // BOTH NON-BUYER CLAIM TERMS AT ONCE, SINCE FINDING 31, and they are summed rather
2231 // than checked one at a time because they are two states of the same money: a
2232 // week's sponsorship BEFORE it is abandoned and its holders' unclaimed shares
2233 // AFTER. Each is part of the pot it sits in, so together they can never exceed the
2234 // pots held. If they did, buyers' claims on those pots would sum to less than
2235 // nothing and Refund's overdraw guard would be all that stood between a caller and
2236 // another week's money.
2237 panic("raffle: sponsorship and unclaimed dead-week shares exceed the pots holding them")
2238 }
2239}
2240
2241// AuditFailure re-derives the invariant BY ITERATION and reports the empty
2242// string when the books balance, otherwise which check failed and by how much.
2243// Never called from a state-changing path — checkInvariant is the O(1) guard
2244// for that — and exists so anyone can verify off-chain, and so a test can
2245// assert it after every operation.
2246//
2247// IT MUST BE ROBUST AGAINST ANYONE WHO CAN SEND THIS REALM TOKENS, which is
2248// everyone: wugnot.Transfer needs no permission. So the balance test below is a
2249// one-sided one — a shortfall is a violation, an unswept surplus is not. See the
2250// comment on that check.
2251func AuditFailure() string {
2252 i64 := strconv.FormatInt
2253
2254 // ONE PASS THAT COLLECTS, THEN A LOOP THAT SUMS — never an Iterate inside
2255 // an Iterate. Nesting a second avl traversal inside the first one's callback
2256 // crashes this toolchain's VM outright (a Go-level nil dereference, not a
2257 // gno panic), so the two levels are kept strictly apart. A slice is fine
2258 // here because it is local scratch and never realm state.
2259 all := []*Draw{}
2260 sumPots := int64(0)
2261 sumCuts := int64(0)
2262 unresolvedPots := int64(0)
2263 unresolvedCuts := int64(0)
2264 sumSponsored := int64(0)
2265 sumSponsorCut := int64(0)
2266 sumSponsorRefunds := int64(0)
2267 sumClaims := int64(0)
2268 unresolvedClaims := int64(0)
2269 draws.Iterate("", "", func(_ string, value any) bool {
2270 d := value.(*Draw)
2271 all = append(all, d)
2272 sumPots += d.Pot
2273 sumCuts += d.Cut
2274 sumClaims += d.Claims
2275 if !d.Resolved {
2276 unresolvedPots += d.Pot
2277 unresolvedCuts += d.Cut
2278 sumSponsored += d.Sponsored
2279 sumSponsorCut += d.SponsorCut
2280 sumSponsorRefunds += d.SponsorRefundable
2281 unresolvedClaims += d.Claims
2282 }
2283 return false
2284 })
2285
2286 // THE SECOND PASS, AND IT IS NOW PER WEEK — AUDIT-2026-09-28.md finding 27. It sums
2287 // each week's spendOf on its own and tests THAT WEEK'S identity immediately, instead
2288 // of accumulating one grand total and testing it once at the end. The cost is one
2289 // comparison per unresolved week; the loop, the slice and the single level of
2290 // iteration are all exactly as they were.
2291 //
2292 // WHY THE AGGREGATE FORM WAS NOT GOOD ENOUGH. Summed over the weeks, two weeks with
2293 // EQUAL AND OPPOSITE errors (+X here, -X there) cancel and the check passes with
2294 // both weeks wrong — and every path that moves money BETWEEN weeks is precisely the
2295 // shape that produces such a pair. This realm has two of them (Refund's abandonment
2296 // transition and SweepDrawlessWeek, now joined by the carry absorption in
2297 // getOrCreateDraw), all three new, and a half-applied move — one side of the
2298 // transfer written and not the other — is their most likely bug. The aggregate check
2299 // was blind to exactly that.
2300 //
2301 // THE FAILURE IS RECORDED AND REPORTED LOWER DOWN rather than returned from here, so
2302 // that the ORDER in which this function reports problems does not change: heldPots,
2303 // heldCuts and the balance shortfall still come first, as they did when the identity
2304 // was one aggregate test at the end.
2305 sumSpends := int64(0)
2306 perWeek := ""
2307 deadWeek := ""
2308 for _, d := range all {
2309 // THE DEAD-WEEK CLAIM SHAPE IS CHECKED ON EVERY WEEK, RESOLVED OR NOT, because
2310 // "a resolved week holding claims" is one of the states worth naming — see below.
2311 if deadWeek == "" {
2312 deadWeek = deadWeekClaimFailure(d, i64)
2313 }
2314 if d.Resolved {
2315 continue
2316 }
2317 weekSpends := int64(0)
2318 d.spendOf.Iterate("", "", func(_ string, v any) bool {
2319 weekSpends += v.(int64)
2320 return false
2321 })
2322 sumSpends += weekSpends
2323 // THE UNCLAIMED DEAD-WEEK SHARES JOIN THE CLAIMS SIDE — finding 31. They are the
2324 // same money d.Sponsored used to be, in its second state: not a buyer's, not the
2325 // house's, and part of d.Pot either way. A week is in exactly one of the two states
2326 // (retireSponsorship zeroes the one as it writes the other), so in practice one of
2327 // the two terms is always zero — which is asserted rather than assumed in
2328 // deadWeekClaimFailure.
2329 // d.SponsorCut JOINED THIS IDENTITY ON 2026-09-30 AND DOES NOT WEAKEN IT. A
2330 // sponsorship's GROSS splits the way a ticket's does — net to Pot and recorded
2331 // in Sponsored, fee to Cut and recorded in SponsorCut — so without this term
2332 // the identity is short by precisely the sponsors' escrowed fee. Every ugnot in
2333 // Pot+Cut still has to name an origin; there is one more origin than there was.
2334 if perWeek == "" && weekSpends+d.Sponsored+d.SponsorCut+d.Claims+d.SponsorRefundable != d.Pot+d.Cut {
2335 perWeek = "week " + i64(d.Week, 10) + ": pot " + i64(d.Pot, 10) +
2336 " and escrowed fee " + i64(d.Cut, 10) +
2337 " but buyers' gross claims total " + i64(weekSpends, 10) +
2338 ", sponsorship " + i64(d.Sponsored, 10) +
2339 ", sponsors' escrowed fee " + i64(d.SponsorCut, 10) +
2340 ", owed back to sponsors " + i64(d.SponsorRefundable, 10) +
2341 " and unclaimed dead-week shares " + i64(d.Claims, 10)
2342 }
2343 }
2344
2345 if sumPots != heldPots {
2346 return "heldPots is " + i64(heldPots, 10) + ", iterating the draws gives " + i64(sumPots, 10)
2347 }
2348 // THE ESCROWED FEES, RE-DERIVED THE SAME WAY AND FOR THE SAME REASON (finding 25).
2349 // Resolve zeroes a week's Cut when it releases it, so a resolved week contributes
2350 // nothing here and the sum over ALL draws is the same number as the sum over the
2351 // unresolved ones — which is asserted below rather than assumed, because the two
2352 // would part company if anything ever released a fee without zeroing the field.
2353 if sumCuts != heldCuts {
2354 return "heldCuts is " + i64(heldCuts, 10) + ", iterating the draws gives " + i64(sumCuts, 10)
2355 }
2356 if unresolvedCuts != sumCuts {
2357 return "escrowed fees total " + i64(sumCuts, 10) +
2358 " but only " + i64(unresolvedCuts, 10) + " of it is on an unresolved week" +
2359 " — a resolved week is holding a fee it should have released"
2360 }
2361 if sumSponsored != heldSponsored {
2362 return "heldSponsored is " + i64(heldSponsored, 10) +
2363 ", iterating the unresolved draws gives " + i64(sumSponsored, 10)
2364 }
2365 // THE UNCLAIMED DEAD-WEEK SHARES, RE-DERIVED THE SAME WAY AND FOR THE SAME REASON
2366 // (finding 31). A resolved week can hold no claims — Abandoned and Resolved are mutually
2367 // exclusive and only an abandoned week ever gets a claim rate — so the sum over ALL
2368 // draws is the same number as the sum over the unresolved ones, which is asserted rather
2369 // than assumed just below.
2370 // EVERY OTHER MONEY AGGREGATE IN THIS FILE IS RE-DERIVED AND THIS ONE WAS NOT.
2371 // It feeds the ceiling panic below, and checkInvariant runs at the end of every
2372 // state-changing function — so an upward drift would freeze Buy, Refund, Resolve
2373 // and both claim doors at once, with AuditFailure still returning "". An identity
2374 // nothing re-derives is an identity nobody finds out about. 2026-09-30 audit, Y1.
2375 if sumSponsorRefunds != heldSponsorRefunds {
2376 return "heldSponsorRefunds is " + i64(heldSponsorRefunds, 10) +
2377 ", iterating the draws gives " + i64(sumSponsorRefunds, 10)
2378 }
2379 if sumClaims != heldClaims {
2380 return "heldClaims is " + i64(heldClaims, 10) +
2381 ", iterating the draws gives " + i64(sumClaims, 10)
2382 }
2383 if unresolvedClaims != sumClaims {
2384 return "unclaimed dead-week shares total " + i64(sumClaims, 10) +
2385 " but only " + i64(unresolvedClaims, 10) + " of it is on an unresolved week" +
2386 " — a resolved week is holding claims against a prize that has been paid"
2387 }
2388 // ONLY A DEFICIT IS A VIOLATION HERE — a surplus is not, and that asymmetry
2389 // is deliberate. This function is a READ: it cannot sweep, so any wugnot that
2390 // has arrived without going through Buy is still unattributed when it runs.
2391 // wugnot.Transfer is permissionless, so with an equality here a stranger could
2392 // make AuditInvariant() false and AuditFailure() shout by sending this realm
2393 // one ugnot — free, repeatable, attacker-controlled alert fatigue on the one
2394 // solvency signal an operator would page on, with nothing actually wrong and
2395 // nobody harmed. An unswept donation is a benign, expected state: the next
2396 // state-changing call sweeps it into the ops pot (see sweepDonations).
2397 //
2398 // A DEFICIT IS THE CASE THAT CAN HURT SOMEBODY: it means this realm has
2399 // promised more than it holds, so some pot cannot be paid. That is reported,
2400 // always.
2401 //
2402 // checkInvariant, in the mutators, KEEPS ITS EQUALITY and is untouched by
2403 // this: it runs after sweepDonations, so a surplus there really would be a
2404 // bug in this file.
2405 // STILL ONE-SIDED, AND THE ESCROWED FEES JOIN THE SIDE THEY BELONG ON. heldCuts is
2406 // money the realm is holding for somebody — either the house, once the week draws,
2407 // or the buyers, if it does not — so it counts towards what the realm must be able
2408 // to pay, exactly like a pot. A DEFICIT IS STILL THE ONLY VIOLATION: a surplus is
2409 // still benign and still must not be reported, or a stranger could make this
2410 // function shout for one ugnot (see above). Finding 9's asymmetry is unchanged.
2411 // heldClaims NEEDS NO TERM OF ITS OWN HERE, AND THAT IS NOT AN OVERSIGHT: an unclaimed
2412 // dead-week share is part of the week's Pot and is therefore already inside sumPots,
2413 // exactly as d.Sponsored always has been. The register this replaced held no money and
2414 // so added no term either; the difference is that this one is money the realm holds
2415 // against named addresses, which is why it gets its own re-derivation above.
2416 bal := wugnot.BalanceOf(selfAddr())
2417 if bal < sumPots+sumCuts+opsPot {
2418 return "pots + escrowed fees + ops is " + i64(sumPots+sumCuts+opsPot, 10) +
2419 ", but the wugnot balance is only " + i64(bal, 10) +
2420 " — the realm is short by " + i64(sumPots+sumCuts+opsPot-bal, 10)
2421 }
2422 // Every unresolved week's pot must still be exactly its buyers' claims PLUS
2423 // the house money sponsored into it, or Refund could pay out more or less
2424 // than was put in. A resolved week is excluded: its pot is gone to the winner
2425 // and its spend records are dead.
2426 //
2427 // SPONSORSHIP IS ON THE CLAIMS SIDE OF THIS EQUATION and is not a tolerance:
2428 // it is a named, tracked quantity that belongs to no buyer, so a pot that
2429 // exceeded the spends by anything OTHER than the recorded sponsorship is
2430 // still reported here. That is the difference between this and weakening the
2431 // identity into an inequality.
2432 // THE PER-WEEK IDENTITY, WITH THE ESCROWED FEE ON THE POT SIDE OF IT:
2433 //
2434 // Pot + Cut == sum(spendOf) + Sponsored
2435 //
2436 // summed over the unresolved weeks. A buyer's spendOf is their GROSS payment since
2437 // finding 25, and that gross was split at sale time into the part that went to the
2438 // prize (Pot) and the part held as the house's unearned fee (Cut) — so both must be
2439 // on the left or the equation is short by every fee ever taken. Sponsorship carries
2440 // no fee at all and goes wholly to Pot, which is why it appears only once.
2441 //
2442 // IT IS THIS IDENTITY, NOT AN INEQUALITY, THAT MAKES Refund PROVABLY UNABLE TO
2443 // OVERDRAW: a refund removes exactly one buyer's gross from the right and exactly
2444 // the same amount from the left, split across the two terms it arrived in.
2445 //
2446 // AND IT IS TESTED ONE WEEK AT A TIME SINCE 2026-09-29 (finding 27). The loop above
2447 // did the work; this is where its verdict is reported, in the position the old
2448 // aggregate test occupied.
2449 if perWeek != "" {
2450 return perWeek
2451 }
2452 // THE AGGREGATE FORM, KEPT AS A SECOND LOCK AND NO LONGER ABLE TO FIRE ON ITS OWN.
2453 // It is IMPLIED by the per-week loop — a sum of equalities is an equality — so it is
2454 // now a defensive check in the same sense as this file's several "unreachable" panics:
2455 // what it still guards is the ACCUMULATION, i.e. that the same numbers the per-week
2456 // test read add up to the aggregates the O(1) invariant is written in. It is deliberately
2457 // NOT deleted, because if a future edit ever narrows the per-week loop (by skipping a
2458 // class of week, say) this line is what still notices.
2459 if sumSpends+sumSponsored+sumSponsorCut+sumClaims+sumSponsorRefunds != unresolvedPots+unresolvedCuts {
2460 return "unresolved pots total " + i64(unresolvedPots, 10) +
2461 " and escrowed fees " + i64(unresolvedCuts, 10) +
2462 ", buyers' gross claims total " + i64(sumSpends, 10) +
2463 ", sponsorship totals " + i64(sumSponsored, 10) +
2464 ", owed back to sponsors " + i64(sumSponsorRefunds, 10) +
2465 ", sponsors' escrowed fees total " + i64(sumSponsorCut, 10) +
2466 " and unclaimed dead-week shares total " + i64(sumClaims, 10)
2467 }
2468
2469 // AND THE DEAD-WEEK CLAIM SHAPE, REPORTED IN THE POSITION THE CARRY REGISTER'S
2470 // RE-DERIVATION OCCUPIED — finding 31. The register's version of this check was:
2471 //
2472 // every entry must describe a week that exists, is unresolved, is abandoned and
2473 // still holds the sponsorship the register claims for it
2474 //
2475 // and it was a separate pass over carryPending. THERE IS NO REGISTER TO DRIFT NOW, so
2476 // the equivalent statement is made about the weeks themselves and is computed in the
2477 // per-week loop above (deadWeekClaimFailure). The verdict is reported here so that the
2478 // ORDER in which this function reports problems does not change.
2479 if deadWeek != "" {
2480 return deadWeek
2481 }
2482 return ""
2483}
2484
2485// deadWeekClaimFailure re-derives everything finding 31's fix asserts about ONE week's
2486// dead-money state, and returns the empty string when that week is consistent. Factored out
2487// of AuditFailure so the per-week loop stays readable, and called for EVERY week rather than
2488// only the unresolved ones — two of the five statements below are about a resolved week.
2489//
2490// THE FIVE STATEMENTS, and each one is a state that would mean money the realm holds has no
2491// claimant or two claimants:
2492//
2493// 1. AN ABANDONED WEEK HOLDS NO SPONSORSHIP. This is the one that replaces the whole carry
2494// register and it is the strongest of the five: retireSponsorship runs in the same
2495// statement group that sets Abandoned, so a non-zero Sponsored on an abandoned week
2496// means money sitting in a pot whose draw can never happen and whose buyers' claims are
2497// being zeroed one by one — the exact "money in, no way out" shape this realm's first
2498// finding was about, and the shape the register only NAMED rather than removed.
2499// 2. A CLAIM RATE IMPLIES ABANDONMENT. A live week can still sell tickets and still
2500// resolve, so a claim rate on one would be a claim against a prize somebody is about to
2501// win.
2502// 3. A RESOLVED WEEK HOLDS NO CLAIMS AND NO RATE. Resolved and Abandoned are mutually
2503// exclusive, so this is implied by (2) — and it is stated anyway because it is the
2504// statement that the winner was not paid a pot somebody else still has a claim on.
2505// 4. THE UNCLAIMED TOTAL MATCHES THE RATE AND THE PAYMENTS. Claims must equal
2506// ClaimPerUnit * earnedTotal minus everything claimedOf records as paid. This is the
2507// independent re-derivation of the O(1) counter: ClaimDeadWeek writes both sides and a
2508// half-applied payment is its most likely bug.
2509// 5. EVERY PAYMENT MATCHES ITS HOLDER'S EARNED WEIGHT. Each claimedOf entry must be exactly
2510// that holder's EARNED units times the rate. Without this, (4) would still pass if one
2511// holder had been overpaid and another underpaid by the same amount — the same
2512// cancelling-pair blindness finding 27 was about, one level down.
2513// 6. THE EARNED BASIS IS INTERNALLY CONSISTENT — AUDIT-2026-09-28.md finding 34, and it is
2514// checked on EVERY week including live ones, before any of the five above. Two statements:
2515// sum(earnedOf) == earnedTotal, and earnedTotal <= tickets.Total().
2516//
2517// WHY IT IS RE-DERIVED AT ALL, GIVEN THAT ONE FUNCTION WRITES BOTH. Because the whole
2518// exactness argument of finding 31's fix — that every possible claim sums to exactly
2519// Claims — now rests on it. retireSponsorship divides a real wugnot balance by the
2520// COUNTER and ClaimDeadWeek pays out against the TREE; if the two ever disagreed by one
2521// unit, the claims would sum to more or less than Claims and (4) would be the only thing
2522// that noticed, one abandonment too late. The old design got this identity free because
2523// sum(ticketsOf) == tickets.Total() was maintained by fenwick itself. The earned pair is
2524// maintained by appendEarnedTickets instead, so the equivalent statement is made here
2525// rather than assumed from the type.
2526//
2527// THE SECOND HALF IS THE STRUCTURAL ONE: an earned unit is appended through appendTickets
2528// in the same statement group, so it is always also a ticket unit. An earnedTotal larger
2529// than the week's ticket total would mean weight claimed that the draw never saw.
2530//
2531// COLLECT-THEN-LOOP, NEVER A NESTED avl.Iterate. The claimedOf keys are gathered in one pass
2532// and every ticketsOf lookup happens after it, because nesting a second traversal inside the
2533// first's callback crashes this toolchain's VM outright (a Go-level nil dereference, not a
2534// gno panic). Its caller has already finished the draws traversal before calling, for the
2535// same reason.
2536func deadWeekClaimFailure(d *Draw, i64 func(int64, int) string) string {
2537 w := i64(d.Week, 10)
2538
2539 // THE EARNED BASIS FIRST, AND ON EVERY WEEK — statement (6), finding 34. It is checked
2540 // before the dead-money statements because it is a property of the week's ticket
2541 // bookkeeping rather than of its abandonment: it must hold on a live week too, and a live
2542 // week is where a drift would be introduced. One traversal, and it finishes before the
2543 // claimedOf traversal below begins.
2544 sumEarned := int64(0)
2545 d.earnedOf.Iterate("", "", func(_ string, v any) bool {
2546 sumEarned += v.(int64)
2547 return false
2548 })
2549 if sumEarned != d.earnedTotal {
2550 return "week " + w + " counts " + i64(d.earnedTotal, 10) +
2551 " earned units but its per-holder records sum to " + i64(sumEarned, 10) +
2552 " — the dead-week claim basis and its divisor have drifted apart"
2553 }
2554 if d.earnedTotal < 0 || d.earnedTotal > d.tickets.Total() {
2555 return "week " + w + " counts " + i64(d.earnedTotal, 10) +
2556 " earned units against a ticket total of " + i64(d.tickets.Total(), 10) +
2557 " — every earned unit must also be a ticket unit"
2558 }
2559
2560 if d.Abandoned && d.Sponsored != 0 {
2561 return "week " + w + " is abandoned but still holds " + i64(d.Sponsored, 10) +
2562 " of sponsorship — an abandoned week's draw can never happen, so that money has no claimant"
2563 }
2564 if d.ClaimPerUnit != 0 && !d.Abandoned {
2565 return "week " + w + " has a dead-week claim rate of " + i64(d.ClaimPerUnit, 10) +
2566 " but is not abandoned"
2567 }
2568 if d.Resolved && (d.Claims != 0 || d.ClaimPerUnit != 0) {
2569 return "week " + w + " is resolved but holds " + i64(d.Claims, 10) +
2570 " of unclaimed dead-week shares at a rate of " + i64(d.ClaimPerUnit, 10)
2571 }
2572 if d.Claims < 0 {
2573 return "week " + w + " has negative unclaimed dead-week shares: " + i64(d.Claims, 10)
2574 }
2575 if d.ClaimPerUnit == 0 {
2576 if d.Claims != 0 {
2577 return "week " + w + " holds " + i64(d.Claims, 10) +
2578 " of unclaimed dead-week shares with no claim rate to derive them from"
2579 }
2580 return ""
2581 }
2582
2583 keys := []string{}
2584 paid := int64(0)
2585 d.claimedOf.Iterate("", "", func(k string, v any) bool {
2586 keys = append(keys, k)
2587 paid += v.(int64)
2588 return false
2589 })
2590 // THE DIVISOR IS earnedTotal, NOT tickets.Total() — finding 34. Both expressions were the
2591 // same number on a pure-earned week, which is why the old form survived its own tests; they
2592 // differ by exactly the bought units, which is the defect.
2593 if d.ClaimPerUnit*d.earnedTotal-paid != d.Claims {
2594 return "week " + w + " distributed " + i64(d.ClaimPerUnit*d.earnedTotal, 10) +
2595 " of dead-week shares and has paid out " + i64(paid, 10) +
2596 ", so " + i64(d.ClaimPerUnit*d.earnedTotal-paid, 10) +
2597 " should be unclaimed, but the week says " + i64(d.Claims, 10)
2598 }
2599 for _, k := range keys {
2600 got := treeInt64(d.claimedOf, k)
2601 want := treeInt64(d.earnedOf, k) * d.ClaimPerUnit
2602 if got != want {
2603 return "week " + w + ": holder " + k + " was paid " + i64(got, 10) +
2604 " of dead-week share but holds " + i64(treeInt64(d.earnedOf, k), 10) +
2605 " earned units at " + i64(d.ClaimPerUnit, 10) + " each, which is " + i64(want, 10)
2606 }
2607 }
2608 return ""
2609}
2610
2611// AuditInvariant is AuditFailure reduced to a bool.
2612func AuditInvariant() bool { return AuditFailure() == "" }
2613
2614// --- Buy -----------------------------------------------------------------
2615
2616// Buy sells `tickets` tickets in the CURRENT week to the caller.
2617//
2618// Cost is tickets * TicketPrice, pulled from the caller with the GRC20
2619// allowance path, so the caller wraps native ugnot with wugnot.Deposit and
2620// approves this realm first. HouseCutBps of the cost is ESCROWED AGAINST THIS WEEK in
2621// Draw.Cut; the remainder — and all integer-division dust, since the cut is floored —
2622// joins this week's prize pot.
2623//
2624// IT SAID "goes to the ops pot" UNTIL NOW AND THAT HAS BEEN FALSE SINCE 2026-09-29. A
2625// sale banks nothing for the house: the fee is unearned until the week actually draws,
2626// Resolve is what moves it to opsPot, and a week that fails hands it back as part of
2627// each buyer's gross refund. The distinction is the whole of finding 25 — "we earn when
2628// the game runs, not when it fails" — and a comment that says the money is already the
2629// house's describes a realm that cannot refund in full. AUDIT-2026-09-28.md finding 29.
2630//
2631// EVERY CHECK RUNS BEFORE THE PULL, and the week record is created only after
2632// it, so a failed Buy moves nothing and creates nothing.
2633//
2634// Each purchase APPENDS its own ticket index rather than growing an existing
2635// one. That is not a style choice: it is the property Commit depends on, and
2636// the reason is spelled out there.
2637//
2638// THE WEEK'S FIRST BUY PINS THE WEEK'S BEACON ROUND, as a side effect of creating
2639// the record (getOrCreateDraw). That is deliberate and is the only moment the round
2640// is ever chosen: read pinOffsetRounds. Two consequences a caller can see. First, a
2641// sale needs the beacon to have published at least one round ever, or it is refused
2642// with the beacon's own "no round published yet" — which is a refused sale rather
2643// than money accepted into a week that could never be drawn. Second, the buyer who
2644// opens a week does not get to pick anything useful: the round is fixed a week and
2645// more ahead of the moment the ticket set closes, so at the instant it is pinned
2646// there is no ticket set for it to be advantageous against.
2647//
2648// Panics if tickets is not positive, if the cost overflows int64, if the
2649// allowance is short, or if THIS WEEK IS ALREADY COMMITTED — the last of which
2650// is the frozen-total rule, not a convenience check. Once a week's total is
2651// frozen, no further ticket may join it.
2652//
2653// THAT LAST REFUSAL IS NOW UNREACHABLE, and is kept deliberately. Commit only
2654// accepts a week that is already over, and Buy only ever sells into
2655// currentWeek(), so the live week cannot be in a committed state — which is
2656// exactly why a commit can no longer shut a week's sales down early. The check
2657// stays as a second lock on the frozen-total rule: if the week's identity ever
2658// stops being "the one the clock is on", this is the line that still refuses to
2659// grow a frozen ticket set.
2660//
2661// ONLY A USER MAY BUY, AND THE REASON IS THE PAYOUT, NOT THE PURCHASE. A ticket
2662// is a claim on two transfers this realm will later push OUT: Resolve's whole pot
2663// to the winner's address, and Refund's spend back to the buyer's. Both go to
2664// cur.Previous().Address(), which for a realm caller is that realm's
2665// package-derived address — an address whose only "key" is its package path, so
2666// nothing can move wugnot out of it unless that realm happens to contain code
2667// that does. A realm with no such code that won a draw would have the entire
2668// week's pot transferred into an address it can never spend from: money that has
2669// left this realm's books (heldPots drops, the invariant stays green) and is
2670// unreachable by anybody, for ever. This realm cannot tell which realms those are
2671// and must not try, so it refuses them all at the door instead of stranding a pot
2672// at settlement time.
2673//
2674// cur.Previous().IsUserCall() IS THE CHECK, COPIED FROM r/splash/exchange:378 —
2675// AND IsUser() IS NOT. IsUser() also accepts a `maketx run` ephemeral realm, which
2676// is code executing under a user's signature and NOT an EOA: its address is still
2677// package-derived, so it can win a pot and then cease to exist with the money in
2678// it. The exchange states the same distinction for the same word, in the context
2679// of a send envelope rather than a payout; the failure mode differs but the
2680// property being asked for — "the thing in front of me is a wallet somebody holds
2681// a private key for" — is identical.
2682//
2683// FundPot IS DELIBERATELY NOT GUARDED THIS WAY, AND MUST NOT BE "MADE
2684// CONSISTENT". r/splash/arcade/duel calls it from realm code — from FlushPot, once per
2685// batch of collected duels; it was from Collect itself until AUDIT-2026-09-28.md finding
2686// 14 — so adding this check there would break the arcade's rake, the thing that makes the
2687// duel and the raffle one product. It is safe there precisely because sponsorship
2688// buys nothing: no ticket unit, no winning chance, no refundable claim (see
2689// FundPot and Draw.Sponsored), so a realm that funds a pot can never be the
2690// address a payout is pushed to, and the defect above has no way to arise. The
2691// asymmetry is the point: the guard belongs on the entry point that creates a
2692// claim on an outbound transfer, not on the one that only ever pays in.
2693//
2694// AND FundPotWithEntries DOES CREATE SUCH A CLAIM, WHICH IS WHY IT IS THE ONE
2695// GUARDED FUNCTION IN THIS FILE — WITH A DIFFERENT GUARD, FOR A DIFFERENT REASON.
2696// It appends ticket weight for addresses the CALLER names, so this finding's defect
2697// would arise there twice over if it were open: any caller could credit a realm
2698// address that can never spend a pot, and could do it for somebody else's benefit.
2699// IsUserCall cannot be the check there, because the only legitimate caller IS a
2700// realm. So that function admits exactly one package path, and the finding-8
2701// property — every holder of an earned entry is an address a payout can actually
2702// reach — is enforced where it is knowable: r/splash/arcade/duel records whether
2703// each party's own Post or Accept was a direct user call and credits only those.
2704// A REALM CANNOT PLAY A DUEL AT ALL SINCE 2026-09-30, and the sentence that stood here —
2705// "a realm may still play duels; it simply earns no raffle entries by doing so" — is
2706// corrected rather than deleted because the reason it stopped being true is this finding
2707// travelling one realm further. r/splash/arcade/duel found that leaving SETTLEMENT open to
2708// realms had the same defect in its own escrow: its Collect is winner-only, so a winning
2709// realm with no code calling it stranded both stakes with no exit. Post and Accept there
2710// now require a direct user call. The duel still records each party's flag and still
2711// credits entries only to eligible parties — nothing this realm relies on has weakened,
2712// and the property below is now enforced twice over.
2713// AUDIT-duel-2026-09-27.md addendum 2, finding D-1.
2714func Buy(cur realm, tickets int64) {
2715 rejectAttachedCoin(cur, "Buy")
2716 sweepDonations()
2717
2718 if !cur.Previous().IsUserCall() {
2719 panic("raffle: Buy must be called directly by a user")
2720 }
2721
2722 if tickets <= 0 {
2723 panic("raffle: ticket count must be positive")
2724 }
2725 cost := ticketCost(tickets)
2726
2727 week := currentWeek()
2728 if d := getDraw(week); d != nil && d.Committed {
2729 panic("raffle: this week is already committed — tickets for it are closed")
2730 }
2731
2732 caller := cur.Previous().Address()
2733 if wugnot.Allowance(caller, selfAddr()) < cost {
2734 panic("raffle: insufficient allowance — approve this realm for the ticket cost first")
2735 }
2736
2737 wugnot.TransferFrom(cross(cur), caller, selfAddr(), cost)
2738
2739 cut := int64(mulDiv(uint64(cost), uint64(HouseCutBps), uint64(bpsDenom)))
2740 net := cost - cut
2741
2742 // THE WEIGHT THIS PURCHASE CARRIES, IN ENTRY UNITS AND NOT IN TICKETS. One ticket
2743 // is unitsPerTicket (40) units of weight; read EntryUnitPrice for why the two are
2744 // separate numbers now.
2745 //
2746 // IT IS DIVIDED OUT OF cost RATHER THAN MULTIPLIED UP FROM tickets, deliberately,
2747 // and the reason is the overflow argument rather than taste: cost has ALREADY been
2748 // range-checked into an int64 by ticketCost, and dividing an int64 by a positive
2749 // constant cannot overflow — so this line needs no second bits.Mul64 guard and no
2750 // new reasoning about how large a ticket count may be. The two are equal by the
2751 // build guard above (cost == tickets * TicketPrice and
2752 // TicketPrice == unitsPerTicket * EntryUnitPrice), which is exactly what
2753 // TestFix22_ATicketIsExactlyTicketPriceOverEntryUnitPrice asserts through the front door.
2754 units := cost / EntryUnitPrice
2755 if units <= 0 {
2756 // Unreachable: tickets >= 1 and unitsPerTicket >= 1 by the build guards above.
2757 // Loud rather than taking money for a purchase that appends no weight.
2758 panic("raffle: ticket purchase carries no entry units")
2759 }
2760
2761 d := getOrCreateDraw(week)
2762 // THE FEE IS ESCROWED IN THE WEEK, NOT BANKED — AUDIT-2026-09-28.md finding 25.
2763 // This line was `opsPot += cut` until 2026-09-29, which meant the house had been
2764 // paid the moment a ticket was sold and a week that never drew refunded only 95%.
2765 // The owner's rule is that WE EARN WHEN THE GAME RUNS, NOT WHEN IT FAILS, so the
2766 // fee now sits in d.Cut — inside this week, outside the prize — and reaches opsPot
2767 // only when Resolve names a winner. If instead the week is abandoned or refunded,
2768 // d.Cut is returned to the buyers it came from, because spendOf below records the
2769 // GROSS and Refund pays the gross.
2770 //
2771 // d.Cut IS NOT IN d.Pot AND MUST NEVER BE. The winner must not win our fee: Resolve
2772 // pays d.Pot and moves d.Cut, and the two are separate terms in both the per-week
2773 // identity (d.Pot + d.Cut == sum(spendOf) + d.Sponsored) and the realm's solvency
2774 // statement (heldPots + heldCuts + opsPot == balance).
2775 d.Cut += cut
2776 heldCuts += cut
2777 d.Pot += net
2778 heldPots += net
2779 appendTickets(d, caller, units)
2780 k := string(caller)
2781 // THE GROSS, NOT THE NET. This is what Refund returns, and it is the buyer's whole
2782 // payment: the fee is only ours once the draw happens. The per-week identity is
2783 // preserved exactly — the gross recorded here equals the net that went to d.Pot plus
2784 // the cut that went to d.Cut, by construction, on this line and the two above it.
2785 d.spendOf.Set(k, treeInt64(d.spendOf, k)+cost)
2786
2787 chain.Emit("Buy",
2788 "week", strconv.FormatInt(week, 10),
2789 "buyer", string(caller),
2790 "tickets", strconv.FormatInt(tickets, 10),
2791 "units", strconv.FormatInt(units, 10),
2792 "cost", strconv.FormatInt(cost, 10),
2793 "cut", strconv.FormatInt(cut, 10),
2794 "escrowedCut", strconv.FormatInt(d.Cut, 10),
2795 "pot", strconv.FormatInt(d.Pot, 10))
2796 checkInvariant()
2797}
2798
2799// --- FundPot -------------------------------------------------------------
2800
2801// FundPot adds house money to the CURRENT week's prize pot without buying a
2802// single ticket. PERMISSIONLESS and CALLER-PAYS: the funder pulls from their own
2803// allowance, exactly as Buy does, so this realm gains no authority over anybody
2804// and any address can call it with its own money.
2805//
2806// IT IS REALM-CALLABLE ON PURPOSE AND HAS NO IsUserCall GUARD. Do not add one for
2807// consistency with Buy. r/splash/arcade/duel calls this from realm code on every
2808// Collect — that is the arcade's rake, and gating it on an EOA would break the one
2809// mechanism that makes the duel and the raffle a single product. Buy's guard exists
2810// because a ticket is a claim on an outbound transfer (Resolve's pot, Refund's
2811// spend) and a realm's package-derived address may have no way to spend what
2812// arrives there; sponsorship creates no such claim at all — no ticket unit, no
2813// winning chance, no refundable record, see below — so a funder can never be the
2814// address a payout is pushed to and the defect Buy's guard closes cannot arise
2815// here. The asymmetry is deliberate and is stated at both ends.
2816//
2817// WHY IT EXISTS. r/splash/arcade/duel sends its rake here: every coin flip makes
2818// this week's raffle prize bigger, which is what makes the two contracts one
2819// product rather than two features. It used to fund r/splash/game's yield and
2820// prize pots; that product is retired, and nothing in SPLASH holds money beyond
2821// a week any more.
2822//
2823// IT CAN OPEN A WEEK, AND THEREFORE CAN PIN THAT WEEK'S BEACON ROUND. When the
2824// duel's rake arrives before anybody has bought a raffle ticket, this is the call
2825// that creates the week's record — and getOrCreateDraw pins Draw.Round there, once,
2826// for whichever entry point got there first (read pinOffsetRounds). A later Buy in
2827// the same week finds the record and never re-pins. The same beacon prerequisites as
2828// Buy therefore apply here, and there are now TWO: with no round ever published, and
2829// with no relay inside beacon.MaxEstimateStaleBlocks that raised the beacon's anchor,
2830// EstimatedLatestRound panics and THE SPONSORSHIP IS REFUSED.
2831//
2832// AND THAT REFUSAL IS NO LONGER A CROSS-REALM HAZARD, WHICH IS WHY THE STALENESS BOUND
2833// COULD BE ADOPTED AT ALL. It used to be: r/splash/arcade/duel's Collect called this
2834// function IN THE SAME TRANSACTION as the winner's payout, so a refusal here reverted
2835// that payout — permanently, since VoidStale refuses a resolved duel. Collect now BOOKS
2836// its rake and duel.FlushPot delivers it, so a refusal here costs the flusher a reverted
2837// transaction and the booked balance is still there for the next attempt. Any caller can
2838// clear the refusal with one Publish of drand's current round. AUDIT-2026-09-28.md
2839// findings 12 and 14.
2840//
2841// SPONSORSHIP BUYS NOTHING. d.tickets, d.ownerOf, d.ticketsOf and spendOf are
2842// all left alone: the funder gets no ticket unit, no winning chance, and no
2843// refundable claim. Only d.Sponsored and d.Pot move. A funder who never bought a
2844// ticket therefore cannot win the week they funded — ownerOfUnit can only ever
2845// name an address that appears in d.ownerOf, and nothing here writes to it.
2846//
2847// THAT IS STILL TRUE OF THIS FUNCTION AFTER FINDING 15, AND IT IS THE REASON THE
2848// EARNED-ENTRY DOOR IS A SEPARATE FUNCTION RATHER THAN A PARAMETER HERE.
2849// FundPotWithEntries appends ticket weight for named holders and calls this
2850// function for the money; this function appends nothing for anybody, and every
2851// caller of it — including a realm, including the duel when it has no eligible
2852// holders to credit — still buys exactly nothing. Do not add holders to this
2853// signature: the guard that makes the other door safe would then have to be here,
2854// on the permissionless path the arcade's rake depends on.
2855//
2856// WHY THE SEPARATE FIELD RATHER THAN JUST d.Pot += amount: read Draw.Sponsored.
2857// The one-line version is that d.Pot == sum(spendOf) is what makes Refund
2858// provably unable to overdraw, house money belongs to no buyer, and so the
2859// identity becomes d.Pot == sum(spendOf) + d.Sponsored rather than being
2860// quietly abandoned.
2861//
2862// IT FUNDS THE LIVE WEEK ONLY, AND THERE IS NO COMMITTED-WEEK RACE HERE. Commit
2863// accepts only a week that is already over (week < currentWeek()), so the week
2864// currentWeek() names is uncommitted BY CONSTRUCTION — no concurrent commit can
2865// freeze it under a funder's feet, and money can never land in a pot whose
2866// winner is already being drawn. The refusals below are therefore defensive
2867// rather than reachable, and are kept for the same reason Buy keeps its own
2868// committed-week check: if the week this funds ever stops being "the one the
2869// clock is on", these are the lines that still refuse.
2870//
2871// A SPONSORED WEEK WITH NO TICKETS IS NOT A DRAWABLE WEEK. Creating the week's
2872// record here is the same act Buy performs, and it does not make the week
2873// committable: Commit still panics on a zero ticket total, because there is no
2874// rollover and a drawless week must not exist. Such a week's sponsorship sits in
2875// the pot until somebody buys a ticket into it (the week is live, so they can) —
2876// and if the week ends with nobody having bought one, SweepDrawlessWeek registers that
2877// sponsorship for the next week to open (it names no destination itself — finding 28 —
2878// and it waits out a grace window first). THAT FUNCTION IS WHAT KEEPS THIS
2879// ONE SAFE: without it, funding a week that never sold a ticket stranded the money
2880// in a Draw that could never be committed, resolved or refunded. Refusing to create
2881// the record instead would mean the duel's rake bounced whenever it arrived before
2882// the week's first ticket sale, which would make settling a duel depend on somebody
2883// else's purchase.
2884//
2885// Panics if amount is not positive, if the allowance is short, or — defensively
2886// — if the target week is already committed or has been abandoned into refund
2887// (there is nothing left to fund: its buyers are taking their money back).
2888func FundPot(cur realm, amount int64) {
2889 rejectAttachedCoin(cur, "FundPot")
2890 // THE DUEL'S DOOR, AND ONLY THE DUEL'S, SINCE 2026-09-30. It was public and
2891 // unpriced until then, which made it a way to put money into a prize with no
2892 // floor, no fee and no credit line — i.e. everything Sponsor now charges for,
2893 // available for free next door. A priced door beside a free one prices nothing.
2894 //
2895 // SAME GUARD AND SAME REASONING AS FundPotWithEntries: PkgPath() is empty for a
2896 // user call and is the package path for realm code, so this admits exactly one
2897 // caller. IsUserCall would be wrong in both directions — the intended caller is a
2898 // realm, and any OTHER realm must still be refused.
2899 //
2900 // FundPotWithEntries REACHES THIS THROUGH ITS OWN cur, which it passes down
2901 // unchanged, so the previous realm is still the duel and this holds for that path
2902 // too. A person wanting to add to a prize calls Sponsor.
2903 if cur.Previous().PkgPath() != duelRealmPkgPath {
2904 panic("raffle: FundPot is the duel's door for its rake — to add to a prize, call Sponsor")
2905 }
2906 sweepDonations()
2907
2908 if amount <= 0 {
2909 panic("raffle: sponsorship amount must be positive")
2910 }
2911
2912 week := currentWeek()
2913 if d := getDraw(week); d != nil {
2914 if d.Committed {
2915 panic("raffle: this week is already committed — its pot is frozen")
2916 }
2917 if d.Abandoned {
2918 panic("raffle: this week was abandoned and refunded; there is nothing to fund")
2919 }
2920 }
2921
2922 caller := cur.Previous().Address()
2923 if wugnot.Allowance(caller, selfAddr()) < amount {
2924 panic("raffle: insufficient allowance — approve this realm for the sponsorship amount first")
2925 }
2926
2927 // EVERY CHECK ABOVE RUNS BEFORE THE PULL and the week record is created only
2928 // after it, so a refused call moves nothing and creates nothing. No overflow
2929 // guard is needed on the additions below: the transfer has already succeeded,
2930 // so `amount` is bounded by a real wugnot balance, and wugnot's supply is
2931 // bounded by the chain's ugnot supply.
2932 wugnot.TransferFrom(cross(cur), caller, selfAddr(), amount)
2933
2934 d := getOrCreateDraw(week)
2935 d.Sponsored += amount
2936 d.Pot += amount
2937 heldPots += amount
2938 heldSponsored += amount
2939
2940 chain.Emit("FundPot",
2941 "week", strconv.FormatInt(week, 10),
2942 "funder", string(caller),
2943 "amount", strconv.FormatInt(amount, 10),
2944 "sponsored", strconv.FormatInt(d.Sponsored, 10),
2945 "pot", strconv.FormatInt(d.Pot, 10))
2946 checkInvariant()
2947}
2948
2949// Sponsor puts money into this week's prize and buys a credit line beside it.
2950// The sponsor gets no entries and cannot win: that is what separates a
2951// sponsorship from a ticket.
2952//
2953// THE PUBLIC DOOR, WHERE FundPot IS THE DUEL'S. They were one function until
2954// 2026-09-30 and splitting them is the whole reason the rules below can exist.
2955// FundPot carries the duel's rake, in whatever amount has accumulated — often
2956// far less than MinSponsorship, and already charged its ops share on the duel's
2957// side. Putting a floor or a second fee on that path would strand small flushes
2958// and double-charge money that has already paid. So the duel keeps its own
2959// unpriced door and this one is for people buying advertising.
2960//
2961// WHAT HAPPENS ON A WEEK THAT NEVER DRAWS, because a sponsor should be able to
2962// find it out before paying: the money stays with this week for as long as the
2963// week can still be drawn, which is indefinitely — Resolve has no deadline and
2964// the round stays publishable. A week only ends without drawing when a TICKET
2965// BUYER reclaims their own stake after the grace window, and at that moment this
2966// sponsorship, the house's cut of it included, is divided among the addresses
2967// holding units in this week. It does not roll forward to a future week: that
2968// was removed by AUDIT-2026-09-28.md findings 28 and 31, because the caller
2969// chose the destination by choosing when to call.
2970func Sponsor(cur realm, amount int64, label string) {
2971 rejectAttachedCoin(cur, "Sponsor")
2972 sweepDonations()
2973
2974 if amount < MinSponsorship {
2975 panic("raffle: a sponsorship must be at least MinSponsorship — smaller top-ups have no credit line and no door")
2976 }
2977 checkSponsorLabel(label)
2978
2979 week := currentWeek()
2980 if d := getDraw(week); d != nil {
2981 if d.Committed {
2982 panic("raffle: this week is already committed — its pot is frozen")
2983 }
2984 if d.Abandoned {
2985 panic("raffle: this week was abandoned and refunded; there is nothing to sponsor")
2986 }
2987 if len(d.Sponsors) >= maxSponsorsPerWeek {
2988 panic("raffle: this week already holds the maximum number of sponsorships")
2989 }
2990 }
2991
2992 caller := cur.Previous().Address()
2993 if wugnot.Allowance(caller, selfAddr()) < amount {
2994 panic("raffle: insufficient allowance — approve this realm for the sponsorship amount first")
2995 }
2996
2997 // THE SPLIT IS THE TICKET SPLIT, deliberately: one fee number across the whole
2998 // product rather than a second one to explain. mulDiv over the gross, exactly as
2999 // Buy does it, so the two doors cannot drift apart arithmetically.
3000 cutPart := int64(mulDiv(uint64(amount), uint64(HouseCutBps), uint64(bpsDenom)))
3001 potPart := amount - cutPart
3002 if potPart <= 0 || cutPart < 0 {
3003 // Unreachable at 500 bps against a floor of 100 tickets, and checked anyway:
3004 // this is the line where a future constant change would otherwise credit the
3005 // prize with nothing while still taking the fee.
3006 panic("raffle: sponsorship split left nothing for the pot")
3007 }
3008
3009 // EVERY CHECK ABOVE RUNS BEFORE THE PULL and the week record is created only
3010 // after it, so a refused call moves nothing and creates nothing.
3011 wugnot.TransferFrom(cross(cur), caller, selfAddr(), amount)
3012
3013 d := getOrCreateDraw(week)
3014 d.Sponsored += potPart
3015 d.Pot += potPart
3016 d.Cut += cutPart
3017 d.SponsorCut += cutPart
3018 d.SponsorPaid += amount
3019 heldPots += potPart
3020 heldCuts += cutPart
3021 heldSponsored += potPart
3022 d.Sponsors = append(d.Sponsors, Sponsorship{By: caller, Label: label, Amount: amount})
3023
3024 chain.Emit("Sponsor",
3025 "week", strconv.FormatInt(week, 10),
3026 "sponsor", string(caller),
3027 "amount", strconv.FormatInt(amount, 10),
3028 "toPot", strconv.FormatInt(potPart, 10),
3029 "fee", strconv.FormatInt(cutPart, 10),
3030 "label", label,
3031 "sponsored", strconv.FormatInt(d.Sponsored, 10),
3032 "pot", strconv.FormatInt(d.Pot, 10))
3033 checkInvariant()
3034}
3035
3036// checkSponsorLabel refuses anything that is not plain, bounded, single-line
3037// text. Same shape as the duel's checkLabel, and for the same reason: this is a
3038// stranger's text and it is about to be stored for ever.
3039//
3040// IT DOES NOT SANITISE. Escaping belongs at the point of rendering, where the
3041// target syntax is known — storing a pre-escaped string would mean the stored
3042// value and the value the sponsor typed were different things, and every later
3043// reader would have to know which one they held.
3044func checkSponsorLabel(label string) {
3045 if len(label) == 0 {
3046 panic("raffle: a sponsorship needs a credit line — that is what it buys")
3047 }
3048 if len(label) > maxSponsorLabelLen {
3049 panic("raffle: sponsor label too long")
3050 }
3051 if !utf8.ValidString(label) {
3052 panic("raffle: sponsor label is not valid UTF-8")
3053 }
3054 for i := 0; i < len(label); i++ {
3055 if label[i] < 0x20 || label[i] == 0x7f {
3056 panic("raffle: sponsor label contains a control character")
3057 }
3058 }
3059}
3060
3061// --- the arcade's earned entries -----------------------------------------
3062//
3063// THE DEFECT THIS CLOSES (AUDIT-2026-09-28.md finding 15), in the owner's words:
3064// "so people could be gambling money on coinflip all week and some asshole with a
3065// 1cent ticket can win the pot?" Yes. The duel's rake flowed in here as
3066// sponsorship and PLAYING THE DUEL BOUGHT NOTHING, so a week with a busy coin
3067// flip and no ticket sales had a large pot, a ticket total of zero, and could be
3068// taken in its entirety by one 0.1 GNOT ticket bought in its final block. The
3069// money and the entrants came from different places.
3070//
3071// THE FIX IS THE OWNER'S OWN AND IT ADDRESSES THE CAUSE RATHER THAN GATING THE
3072// SYMPTOM. Duel players earn entries in the live week's draw, so the people whose
3073// money built the pot are in the draw for it and a lone late ticket is one entry
3074// among many. Nothing is refused, no minimum is imposed and no buyer is gated.
3075//
3076// RULE 1, AND IT IS THE ONE THAT MAKES THE FEATURE SAFE: ENTRIES ARE PRICED OFF
3077// THE RAKE PAID, NEVER OFF THE STAKE.
3078//
3079// The duel lets a poster address an offer to one opponent, so two accounts
3080// under one hand can flip each other all day and the only real cost of doing
3081// it is the rake. WEIGHT ENTRIES BY STAKE AND THAT IS FREE MONEY: a 10 GNOT
3082// rung is a 20 GNOT pool on a 0.6 GNOT rake, so stake-weighting would hand out
3083// 80 units of weight (20 GNOT / EntryUnitPrice) for 0.6 GNOT of cost — entries
3084// at a THIRTY-THIRD of face value. Nobody would ever buy a ticket again and one
3085// funded account would own every draw.
3086//
3087// PRICING ENTRIES AT EXACTLY WHAT THE RAKE WOULD HAVE BOUGHT AT EntryUnitPrice
3088// MAKES FLIPPING AND BUYING IDENTICAL VALUE. A self-dealer's net cost for a
3089// rung-1 flip is the 3000000 ugnot rake and they earn 12 units: 250000 ugnot a
3090// unit, which is EntryUnitPrice exactly. Self-dealing therefore gains nothing
3091// — and is in fact weakly WORSE than buying, on two counts since 2026-09-29.
3092// Only 60% of a rake reaches the pot against 95% of a ticket price, so the
3093// flipper pays the same per unit into a smaller prize; and at rung 0 the floor
3094// bites (600000 / 250000 = 2.4 units, floored to 2), so that rung pays 300000
3095// a unit — 1.2x face value. BOTH ERRORS RUN THE SAFE WAY: flipping can be
3096// dearer than buying and can never be cheaper. THAT INEQUALITY IS THE SECURITY
3097// PROPERTY OF THIS WHOLE FEATURE, it is what the bound below enforces at the
3098// door, and raffle_test.gno and duel_test.gno both assert the rate against
3099// EntryUnitPrice rather than against a hardcoded entry count.
3100//
3101// IT WAS TicketPrice ON BOTH SIDES OF THAT SENTENCE UNTIL 2026-09-29, when a
3102// 10 GNOT ticket made three of the four rungs earn nothing at all. The entry
3103// unit exists so that the ticket price and the draw's granularity can move
3104// independently; read EntryUnitPrice and AUDIT-2026-09-28.md finding 22.
3105//
3106// RULE 2: BOTH PLAYERS EARN, AND THE LOSER NEVER EARNS LESS. The split is the
3107// duel's to compute (at least one entry each, the remainder two thirds to the
3108// loser — see its splitEntries) and this realm neither knows nor cares which
3109// holder was which. What this realm enforces is the TOTAL, because the total is the
3110// only part that could be an attack: an uneven split of a fixed total costs nothing
3111// and creates no edge.
3112//
3113// "AND THE LOSER EARNS MORE" IS WHAT THIS SAID UNTIL 2026-09-29, and it is corrected
3114// rather than left: at the duel's bottom rung the two shares are now EQUAL, one each
3115// out of a two-unit total, because a floored third of 2 gave the WINNER ZERO.
3116// AUDIT-2026-09-28.md finding 26. Nothing this realm enforces moved — the total per
3117// rung is the same number it was, which is exactly why the change needed no
3118// permission from this side of the door.
3119//
3120// RULE 3: IT TRAVELS THE SAFE ROUTE. duel.Collect books; duel.FlushPot delivers
3121// and is the only caller of the function below. Collect still calls NO raffle code
3122// at all, which is AUDIT-2026-09-28.md finding 14 and must stay true: a panic in
3123// here — including this function's own refusals — costs the flusher a reverted
3124// transaction and can never stop a duel winner collecting.
3125const (
3126 // duelRealmPkgPath is the ONE package path FundPotWithEntries admits.
3127 //
3128 // IT IS THE FIRST AUTHORITY ANY REALM HAS EVER HELD OVER THIS ONE, and that is
3129 // not a small change: r/splash/game records declining a privileged cross-realm
3130 // entry point FIVE times, calling it "a write path on the largest pot in the
3131 // system sitting behind the single most commonly botched check class in Gno".
3132 // The reasons it is unavoidable here, each checked against the alternative:
3133 //
3134 // - A PERMISSIONLESS entry-granting door cannot exist. Whoever calls it names
3135 // the holders, so it would let anyone credit entries to any address —
3136 // including a realm address whose package-derived key nobody holds, which
3137 // is AUDIT-2026-09-28.md finding 8 reopened (a winner that cannot spend its
3138 // pot strands the whole week's money). It would also bypass Buy's 5% house
3139 // cut, so every rational buyer would use it instead of Buy and the realm's
3140 // entire revenue would go to zero.
3141 // - PRICING ENTRIES FROM THE DELIVERED MONEY ALONE does not need a guard, but
3142 // it cannot express rule 1: the duel delivers the rake's POT SHARE (60%
3143 // since 2026-09-29), so a self-enforcing floor(amount/EntryUnitPrice) would
3144 // credit 1 unit for a rung-0 flip that paid 2 units' worth of rake, and the
3145 // owner's design is the rake, not the part of it this realm happens to
3146 // receive. The mismatch got WIDER with the 60/40 split, not narrower.
3147 // - PAYING THE WHOLE RAKE THROUGH THIS REALM would make the bound exact with
3148 // no guard at all, but it moves the duel's 40% ops share onto this realm's
3149 // books and therefore onto its liveness. The house/player split must not
3150 // move (BIBLE.md §2.6, and AUDIT-2026-09-28.md finding 14's "the split does
3151 // not move on the delivery path"), so it was not done.
3152 //
3153 // SO THE GUARD IS REAL AND WHAT LIMITS IT IS THE BOUND, NOT THE GUARD. Read
3154 // arcadeEntryUnitsBound: a compromised duel cannot mint entries, it can only buy them,
3155 // and the most it can do is buy them AT 60% OF FACE VALUE with money it actually
3156 // delivers. It can never move a single ugnot OUT of this realm, touch spendOf, reach a
3157 // past week, or resolve anything.
3158 //
3159 // THE FIGURE WAS 80% UNTIL NOW AND THE CONSTANT IT DEPENDS ON MOVED WITHOUT IT —
3160 // AUDIT-2026-09-28.md finding 30. arcadeEntryUnitsBound grosses a delivery up by
3161 // arcadeBpsDenom/arcadeRakePotShareBps, which was 10000/8000 and became 10000/6000 when
3162 // the duel's rake split went 80/20 -> 60/40 (finding 24). RECOMPUTED FROM THE CONSTANTS
3163 // AS THEY STAND: one entry unit is admitted for every EntryUnitPrice of GROSSED-UP rake,
3164 // so the DELIVERED money per unit is EntryUnitPrice * 6000/10000 = 150,000 ugnot against
3165 // a face value of 250,000 — 60%, and 150,000 x 40 = 6,000,000 buys a whole ticket's
3166 // worth of weight for a 10,000,000 ticket price. The old figure was not wrong when it
3167 // was written; it was a live number written down as prose, which is how it went stale.
3168 //
3169 // IS THE DOOR STILL ADEQUATELY CONTAINED AT 60%? YES, AND THE REASON IS NOT THE
3170 // PERCENTAGE — this is the sentence a future reviewer should rely on, so it is written
3171 // out rather than implied:
3172 //
3173 // - WHAT THE DOOR CAN DO IS UNCHANGED IN KIND. A compromised duel still cannot mint
3174 // weight (every unit is paid for in delivered wugnot), cannot take money out (this
3175 // realm has no outbound path it can reach), cannot touch spendOf or any past week,
3176 // and cannot resolve, commit or refund anything. Its entries land in the live week
3177 // and compete for a prize its own delivery enlarges.
3178 // - WHAT CHANGED IS THE SIZE OF THE DISCOUNT, and it is a dilution advantage rather
3179 // than a theft. Per ugnot delivered, the door now buys 1/0.6 = 1.67x the weight a
3180 // buyer's ticket buys, up from 1.25x. Measured against POT CONTRIBUTION — the
3181 // comparison that decides who is diluting whom, since a buyer's ticket leaves 5% in
3182 // escrow — it is 237,500/150,000 = 1.58x, up from 1.19x. The attacker's cost is
3183 // still real money it must hand over and which mostly ends up in the pot it is
3184 // competing for; the honest buyers' loss is bounded by that dilution and is not a
3185 // transfer of anything they have already paid.
3186 // - AND THE LIMIT IS THE SPLIT ITSELF, WHICH IS WHY THE GUARD BELOW EXISTS. The
3187 // gross-up is 10000/arcadeRakePotShareBps, so the discount is a pure function of the
3188 // players' share: 80/20 gave 80%, 60/40 gives 60%, and a hypothetical 20/80 would
3189 // give 20% — four ugnot of face value per ugnot delivered, at which point "it can
3190 // only buy" stops being a containment argument at all. The build guard under
3191 // arcadeBpsDenom refuses any split below half, so this paragraph cannot be quietly
3192 // invalidated again by a change to a constant in another realm.
3193 duelRealmPkgPath = "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/duel"
3194
3195 // arcadeRakePotShareBps and arcadeBpsDenom RESTATE r/splash/arcade/duel's
3196 // rakePotShareBps and bpsDenom — 6000 of 10000, the players' side of the rake.
3197 //
3198 // IT WAS 8000 UNTIL 2026-09-29. The rake total is unchanged at 300 bps of the pool
3199 // and the player's edge against the house in the duel did not move; only the
3200 // internal split did, 80/20 to 60/40 (AUDIT-2026-09-28.md finding 24). If this
3201 // number ever drifts from the duel's, honest batches are either refused (a stuck
3202 // flush) or over-admitted (finding 15 reopened) — which is why the coupling is
3203 // asserted from the only side that can see both, below.
3204 //
3205 // THEY ARE RESTATED BECAUSE THE IMPORT ONLY GOES ONE WAY: the duel imports this
3206 // realm, so this realm cannot import the duel to read them, and they are
3207 // unexported there in any case. Same situation as opsVaultAddr and blocksPerDay,
3208 // which are duplicated across three realms for the same reason.
3209 //
3210 // AND THE DUPLICATION IS CHECKED, FROM THE SIDE THAT CAN SEE BOTH. A Gno test in
3211 // THIS package cannot import the duel either, so the coupling is asserted in
3212 // duel_test.gno — TestFix15_TheRafflesEntryBoundIsExactlyThisRealmsRakeEquivalent
3213 // computes a rake from the duel's own constants and requires
3214 // raffle.ArcadeEntryUnitsBound(potShare) to equal it, to the entry. If either
3215 // side's split ever moves, that test fails and names both files. Do not "tidy"
3216 // these constants away into bpsDenom above: bpsDenom is this realm's house cut
3217 // denominator and has no business being load-bearing for another realm's rake.
3218 arcadeRakePotShareBps int64 = 6_000
3219 arcadeBpsDenom int64 = 10_000
3220)
3221
3222// THE PLAYERS' SHARE MUST BE AT LEAST HALF THE RAKE, and this line fails the BUILD if a
3223// future edit takes it below — a negative array length is a compile error. 6000 - 5000 =
3224// 1000 as the constants stand.
3225//
3226// IT GUARDS AN ARGUMENT, NOT AN ARITHMETIC PROPERTY, and that is exactly why it is here.
3227// arcadeEntryUnitsBound grosses a delivery up by 10000/arcadeRakePotShareBps, so the
3228// privileged door buys entry weight at arcadeRakePotShareBps/10000 of face value: 80% at
3229// the old split, 60% at this one. The containment argument above — "the most it can do is
3230// buy them at 60% of face value" — is a statement about THAT RATIO, and it decays as the
3231// players' share falls, with no arithmetic anywhere in either realm going wrong as it
3232// does. The figure in that paragraph had already gone stale once by exactly this route
3233// (AUDIT-2026-09-28.md finding 30). Half is the line at which a delivery still buys less
3234// than two units of face value per unit of money; below it, "it can only buy entries"
3235// stops containing anything and the door has to be re-audited rather than re-worded.
3236// Moving the split further towards ops must therefore break the build and send the editor
3237// to the paragraph above duelRealmPkgPath. AUDIT-2026-09-28.md findings 24 and 30.
3238var _ [arcadeRakePotShareBps - 5_000]struct{}
3239
3240// arcadeEntryUnitsBound is the MOST entry units a delivery of `amount` ugnot of
3241// duel rake pot share may ever carry, and it is the security property of finding
3242// 15 stated in one line of arithmetic at the door.
3243//
3244// THE DERIVATION. The duel's rake splits potShare = rake - floor(rake * 4000 /
3245// 10000), so potShare >= rake * 6000/10000 and therefore
3246//
3247// rake <= amount * arcadeBpsDenom / arcadeRakePotShareBps
3248//
3249// for a delivery of amount = potShare. Rule 1 prices an entry at ONE EntryUnitPrice
3250// of rake, so the unit ceiling is that gross-up divided by EntryUnitPrice. At rung 0:
3251// amount 360000 -> gross 600000 -> 2 units, which is EXACTLY what the duel credits, so
3252// this bound is tight rather than generous and one unit of over-issuance fails it.
3253//
3254// IT IS PRICED PER ENTRY UNIT AND NOT PER TICKET, SINCE 2026-09-29, AND THAT IS
3255// FINDING 22. Divided by a 10 GNOT TicketPrice this ceiling would be 0 at rung 0, 0 at
3256// rung 1 and 1 at rung 2 — it would refuse the duel's own honest batch at three of the
3257// four rungs, and a refused batch is a FlushPot that can never land, i.e. rake that
3258// reaches this realm's prize pot never at all. The ceiling and the duel's own
3259// rakeEntryUnits have to be denominated in the same unit or one of them is wrong; the
3260// unit is EntryUnitPrice.
3261//
3262// AND NOTE WHAT IT IS *NOT*: floor(amount / EntryUnitPrice), i.e. the units the
3263// DELIVERED money alone would buy. That is 1 unit at rung 0 (360000 / 250000), and it
3264// would refuse the honest 2-unit batch for exactly the reason the gross-up exists —
3265// this realm receives the players' share and prices entries off the whole rake. It is
3266// alternative 2 from the block above duelRealmPkgPath, rejected there because it cannot
3267// express the owner's pricing rule.
3268//
3269// IT NEVER REFUSES AN HONEST BATCH, INCLUDING AN AGGREGATED ONE. FlushPot delivers
3270// the sum of several duels' pot shares against the sum of their entries. Each
3271// duel's rake_i <= potShare_i * 1.25 exactly, sum(rake_i) is an integer no greater
3272// than amount * 1.25, and floor() of a real bound is still >= any integer below
3273// it — so the aggregate bound is never smaller than the aggregate claim. Dust can
3274// only ever make the bound looser, never tighter.
3275//
3276// EXPORTED so the duel's suite can pin the two realms' arithmetic to each other
3277// (see arcadeRakePotShareBps), and so anybody can check the rate off-chain. A
3278// plain read: no state, no authority, nothing it can be called for.
3279func ArcadeEntryUnitsBound(amount int64) int64 {
3280 if amount <= 0 {
3281 return 0
3282 }
3283 return arcadeEntryUnitsBound(amount)
3284}
3285
3286func arcadeEntryUnitsBound(amount int64) int64 {
3287 // mulDiv, not a raw product: amount is token-scale and bps-scale multiplication
3288 // on int64 wraps silently on this VM (see mulDiv).
3289 gross := int64(mulDiv(uint64(amount), uint64(arcadeBpsDenom), uint64(arcadeRakePotShareBps)))
3290 return gross / EntryUnitPrice
3291}
3292
3293// FundPotWithEntries is FundPot plus the earned entries that rake bought, in ONE
3294// call, and it is callable only by r/splash/arcade/duel. Returns the total entry
3295// units credited.
3296//
3297// READ THE BLOCK ABOVE duelRealmPkgPath FIRST: it has the defect, the three rules,
3298// why this door is privileged and why the bound rather than the guard is what
3299// makes it safe.
3300//
3301// WHY ONE CALL AND NOT TWO. The money and the entries must land in the SAME week
3302// and the same transaction, or the pot identity and the ticket index could
3303// disagree about which week the duel's rake belonged to. A caller cannot deliver
3304// one without the other, cannot deliver entries worth more than the money, and
3305// cannot choose the week — currentWeek() decides, exactly as FundPot's does.
3306//
3307// THE MONEY PATH IS FundPot'S, UNCHANGED AND NOT COPIED. This function calls
3308// FundPot directly (a non-crossing call within this realm, so cur.Previous() is
3309// still the duel and the pull is still caller-pays out of the duel's own
3310// allowance). Every refusal FundPot makes — non-positive amount, a committed or
3311// abandoned week, a short allowance, a beacon too stale to pin a new week's round
3312// — therefore applies here identically, and no second version of the sponsorship
3313// bookkeeping exists to drift from the first.
3314//
3315// WHAT AN EARNED ENTRY IS, EXACTLY: one unit appended to this week's ticket index
3316// with NO spendOf record.
3317//
3318// APPENDED, NEVER INSERTED OR ADJUSTED. appendTickets is the same function Buy
3319// uses, so an earned entry is a new index whose prefix starts at the whole
3320// total at the moment it was appended. That is the frozen-ownership property
3321// Commit's argument rests on (finding 10) and the reason fenwick rejects
3322// negative deltas by design. Nothing here touches an existing index.
3323//
3324// AND NO spendOf RECORD, WHICH IS CORRECT AND IS NOT AN OVERSIGHT. The holder
3325// paid no ticket money to this realm; what they paid was the duel's rake, and
3326// that money is in this week's pot as Sponsored. So:
3327//
3328// - Pot == sum(spendOf) + Sponsored is UNAFFECTED. Entries carry no money of
3329// their own; the money term is FundPot's, and it is counted once.
3330// - Refund pays spendOf only, so a holder of nothing but earned entries
3331// reclaims nothing, which is right: they have no ticket money here to
3332// reclaim, and their rake is in Sponsored.
3333// - On an abandoned week THE SPONSORSHIP GOES TO THE EARNED-ENTRY HOLDERS, pro
3334// rata, as a claim on that same week, and buyers reclaim their own gross. The
3335// sentence that stood here was PRECISELY INVERTED and is kept because a reader
3336// who has read this file before will be carrying it: "On an abandoned week
3337// Sponsored goes to opsPot and buyers reclaim their spend; EARNED-ENTRY HOLDERS
3338// GET NOTHING. Also right, and it is the same money-belongs-to-no-buyer rule
3339// Refund already applies." Earned-entry holders are the INTENDED BENEFICIARIES —
3340// the rake is theirs and they can recover it by no other route — and it is
3341// BUYERS who get nothing from it, having contributed nothing to it and having
3342// their whole ticket price back. AUDIT-2026-09-28.md findings 16, 31 and 34; read
3343// retireSponsorship and ClaimDeadWeek. Only a week with NO earned holder at all
3344// sends this money to opsPot.
3345// - A WEEK CAN NOW HAVE TICKETS AND ZERO spendOf — every entry earned, none
3346// bought. Commit requires tickets > 0 and gets it, Resolve pays the winner
3347// the sponsorship, and that is precisely the intent: the flippers draw for
3348// the pot their own rake built. SWEEPING SUCH A WEEK IS ITS SHAPE 2 AND FIRES ON
3349// EXACTLY IT, once committed and once the grace window has passed with no draw.
3350// The retired claim, which was wrong from the day finding 17 was implemented:
3351// "SweepDrawlessWeek requires ZERO tickets and therefore correctly will not fire
3352// on such a week — its sponsorship is a real prize now, not stranded money." The
3353// ZERO-TICKET requirement is shape 1 only. Finding 17 added shape 2 for this very
3354// week, because Resolve was otherwise its ONLY exit and no clock could rescue it —
3355// and shape 2 then retires the sponsorship to the earned holders rather than
3356// stranding it. Read SweepDrawlessWeek's two shapes.
3357// - Finding 3's one-way Abandoned flag still covers the fact that refunds
3358// cannot retire tickets. An earned entry is as un-retirable as a bought
3359// one and is safe for the same reason: no draw over an abandoned week's
3360// ticket set will ever happen.
3361//
3362// THE HOLDERS ARE THE DUEL'S TO NAME AND THIS REALM DOES NOT SECOND-GUESS THEM,
3363// with one exception it cannot check and one it can. It cannot check that a holder
3364// is an EOA — there is no such predicate for a bare address on this toolchain —
3365// so THE DUEL credits only parties whose own Post or Accept was a direct user
3366// call, which is where finding 8's reasoning has to live for earned entries. It
3367// can and does check that every holder is a valid non-empty address and every
3368// unit count is positive, so a malformed batch fails loudly instead of appending
3369// an index nobody owns.
3370//
3371// Panics if the caller is not the duel realm, if the two slices disagree in
3372// length, if either is empty, if any unit count is not positive, if any holder is
3373// invalid, or if the batch claims more units than the delivered money could have
3374// bought.
3375func FundPotWithEntries(cur realm, amount int64, holders []address, units []int64) int64 {
3376 rejectAttachedCoin(cur, "FundPotWithEntries")
3377 // THE GUARD. There are TWO in this file since 2026-09-30 — FundPot gained the same one. PkgPath() is empty for a user
3378 // call and is the package path for realm code, so this admits exactly one
3379 // caller: the duel's FlushPot. IsUserCall/IsUser would be the wrong check here
3380 // in both directions — the intended caller is a realm, and any OTHER realm must
3381 // still be refused.
3382 if cur.Previous().PkgPath() != duelRealmPkgPath {
3383 panic("raffle: only " + duelRealmPkgPath + " may credit earned entries")
3384 }
3385 if len(holders) != len(units) {
3386 panic("raffle: earned entries: holders and units must have the same length")
3387 }
3388 if len(holders) == 0 {
3389 panic("raffle: earned entries: nothing to credit — use FundPot for a plain sponsorship")
3390 }
3391
3392 // THE BOUND IS COMPUTED AND CHECKED BEFORE ANY MONEY MOVES, this file's
3393 // discipline everywhere. The per-element test is not redundant with the running
3394 // one: without it a single absurd unit count could carry the sum past an int64
3395 // and land back inside the bound. With both, the sum never exceeds 2 * bound.
3396 bound := arcadeEntryUnitsBound(amount)
3397 total := int64(0)
3398 for i := 0; i < len(units); i++ {
3399 if units[i] <= 0 {
3400 panic("raffle: earned entry units must be positive")
3401 }
3402 if holders[i] == "" || !holders[i].IsValid() {
3403 panic("raffle: earned entries must name a valid holder address")
3404 }
3405 if holders[i] == cur.Previous().Address() {
3406 // THE CALLER MAY NOT CREDIT ITSELF, which is the one finding-8 case this
3407 // realm CAN see: the caller is a realm by construction here, so its address
3408 // is package-derived, and a realm holding ticket weight could win a week and
3409 // strand the whole pot in an address nobody can spend from. No honest batch
3410 // contains it — the duel credits the two players of a settled duel and never
3411 // the realm — so this costs nothing and closes the most likely shape of a
3412 // future bug on the other side of the boundary.
3413 panic("raffle: earned entries may not be credited to the calling realm itself")
3414 }
3415 // AND THE DOOR MUST REFUSE THIS REALM'S OWN ADDRESS TOO — AUDIT-2026-09-28.md
3416 // finding 18. The guard above rejected the duel and not the raffle, which left the
3417 // containment argument one address short of complete.
3418 //
3419 // WHAT IT COSTS TO OMIT IT, AND NOTE THAT THE CEILING DOES NOT HELP HERE. The
3420 // whole authorisation argument for this privileged door is "the money-backed
3421 // ceiling limits what it can do, not the guard" (see duelRealmPkgPath and
3422 // arcadeEntryUnitsBound) — but the ceiling has no opinion about WHO holds a unit.
3423 // A holder of selfAddr() that wins reaches
3424 // wugnot.Transfer(cross(cur), winner = selfAddr(), pot) in Resolve, AFTER
3425 // heldPots -= pot has already been written. Either wugnot refuses a self-transfer
3426 // and Resolve panics, or it succeeds as a no-op while heldPots has dropped and
3427 // checkInvariant's heldPots + opsPot == balance fails. BOTH BRANCHES REVERT Resolve
3428 // PERMANENTLY AND STRAND THAT WEEK'S WHOLE POT — the same class as findings 1, 6
3429 // and 7. Which branch actually applies is pinned by
3430 // TestFix18_WugnotRefusesAnAddressTransferringToItself rather than reasoned about.
3431 //
3432 // THE OPS VAULT IS REFUSED ON THE SAME REASONING. opsVaultAddr is
3433 // gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/ops, a PACKAGE-DERIVED address at which no code exists yet
3434 // (AUDIT-2026-09-28.md findings 11-14's fourth, still-blocking defect), so a pot
3435 // pushed there is a pot nobody can spend — finding 8 exactly, and the reason Buy
3436 // requires IsUserCall.
3437 //
3438 // UNREACHABLE THROUGH THE HONEST DUEL TODAY, which is why this is a contingency
3439 // fix and not an emergency: the duel credits the two parties of a settled duel and
3440 // they are EOAs by its own PosterIsUser/TakerIsUser gate. It restores the
3441 // containment this door's authorisation rests on, and it costs an honest batch two
3442 // comparisons.
3443 if holders[i] == selfAddr() {
3444 panic("raffle: earned entries may not be credited to this realm itself")
3445 }
3446 if holders[i] == opsVaultAddr {
3447 panic("raffle: earned entries may not be credited to the ops vault")
3448 }
3449 if units[i] > bound {
3450 panic("raffle: earned entries exceed what the delivered rake could buy at the entry unit price")
3451 }
3452 total += units[i]
3453 if total > bound {
3454 panic("raffle: earned entries exceed what the delivered rake could buy at the entry unit price")
3455 }
3456 }
3457
3458 // THE MONEY, THROUGH THE AUDITED PATH. Non-crossing, so cur.Previous() inside
3459 // FundPot is still the duel: it sweeps donations, applies every one of its own
3460 // refusals, pulls the duel's own wugnot, credits Sponsored and Pot, and checks
3461 // the invariant. If it refuses, this whole transaction reverts and the duel's
3462 // booked rake is untouched — which is finding 14's property and the reason
3463 // Collect must never call any of this.
3464 FundPot(cur, amount)
3465
3466 week := currentWeek()
3467 d := getDraw(week)
3468 if d == nil {
3469 // Unreachable: FundPot above created the week's record if it did not exist.
3470 // Loud rather than dropping entries the money has already been taken for.
3471 panic("raffle: earned entries: the week has no record after funding it")
3472 }
3473
3474 // appendEarnedTickets, NOT appendTickets — AUDIT-2026-09-28.md finding 34. It appends the
3475 // draw weight exactly as a purchase does AND records that this weight was EARNED, which is
3476 // the basis a dead week's sponsorship is divided on. Read Draw.earnedOf for why the draw
3477 // and the claim cannot share one basis, and appendEarnedTickets for why the two writes are
3478 // inside one function rather than two lines of this loop.
3479 for i := 0; i < len(holders); i++ {
3480 appendEarnedTickets(d, holders[i], units[i])
3481 chain.Emit("ArcadeEntries",
3482 "week", strconv.FormatInt(week, 10),
3483 "holder", string(holders[i]),
3484 "units", strconv.FormatInt(units[i], 10),
3485 "heldUnits", strconv.FormatInt(treeInt64(d.ticketsOf, string(holders[i])), 10),
3486 "heldEarnedUnits", strconv.FormatInt(treeInt64(d.earnedOf, string(holders[i])), 10),
3487 "weekTickets", strconv.FormatInt(d.tickets.Total(), 10),
3488 "weekEarned", strconv.FormatInt(d.earnedTotal, 10))
3489 }
3490
3491 chain.Emit("ArcadeEntriesCredited",
3492 "week", strconv.FormatInt(week, 10),
3493 "holders", strconv.Itoa(len(holders)),
3494 "units", strconv.FormatInt(total, 10),
3495 "weekEarned", strconv.FormatInt(d.earnedTotal, 10),
3496 "amount", strconv.FormatInt(amount, 10),
3497 "unitsBound", strconv.FormatInt(bound, 10),
3498 "entryUnitPrice", strconv.FormatInt(EntryUnitPrice, 10))
3499 checkInvariant()
3500 return total
3501}
3502
3503// --- Commit --------------------------------------------------------------
3504
3505// Commit commits an ALREADY-FINISHED week to a future beacon round. Returns
3506// that round.
3507//
3508// THE WEEK IS A PARAMETER AND IT MUST BE OVER — week < currentWeek(). Both
3509// halves of that sentence are load-bearing, and each fixes a defect the first
3510// version of this function had:
3511//
3512// - IT TAKES A WEEK, so any past week can be committed at any time, by
3513// anyone. Before, Commit could only ever act on currentWeek(), which meant
3514// a week whose clock ran out before anybody called it could never be
3515// committed — and because Resolve and Refund both require Committed, that
3516// week's whole pot was unreachable forever while heldPots went on counting
3517// it, so the invariant and the audit both stayed green over money nobody
3518// could ever get back. A stranded week is now always recoverable: commit
3519// it, then resolve it, or refund it if its round was lost.
3520//
3521// - THE LIVE WEEK CAN NEVER BE COMMITTED, so a commit can no longer cut a
3522// week's sales short. Before, anyone could Buy(1) and immediately Commit:
3523// that made them the sole owner of a FrozenTotal of 1 — the guaranteed
3524// winner — and, because Buy refuses a committed week, shut the week's sales
3525// for its remaining days. It cost the attacker the 5% cut on one ticket and
3526// was repeatable every week with no admin and no pause to stop it; the
3527// front-running variant landed a commit just ahead of somebody's large Buy
3528// and made their purchase panic. Refusing week >= currentWeek() closes all
3529// of that at the root rather than with a minimum-tickets or minimum-elapsed
3530// heuristic: the week being sold into is simply not a committable week.
3531//
3532// PERMISSIONLESS: there is no keeper and no admin. Anyone who wants the draw to
3533// happen — every ticket holder, for a start — can call it, once the week is up.
3534//
3535// It freezes two numbers, and CHOOSES NOTHING:
3536//
3537// - FrozenTotal = the week's final ticket total. The week is over and Buy
3538// only ever sells into currentWeek(), so this total can no longer grow
3539// BY CONSTRUCTION; it is still assigned explicitly, because Resolve reads
3540// this field and not the live total.
3541// - Pot, which stops growing for the same reason (and Buy also refuses a
3542// committed week, which is now a second, unreachable-by-design lock).
3543// - AND IT USES Draw.Round EXACTLY AS PINNED, which was written when this week's
3544// record was created and is not this function's business. See below.
3545//
3546// THE SECURITY RULE, AND THE ONLY REASON THIS IS THREE PHASES: the ticket set
3547// must be frozen BEFORE the randomness is knowable. If the total were read at
3548// resolve time, anybody who saw the published round could compute the winning
3549// unit and then buy precisely enough tickets to own it — which turns a raffle
3550// into a purchase.
3551//
3552// THIS FUNCTION NO LONGER BINDS THE ROUND, AND THAT IS THE 2026-09-28 FIX. It used
3553// to set Round = beacon.Latest() + 1 here, which looked like the conservative
3554// choice — the round is unpublished at commit time, so its randomness "does not
3555// exist yet" — and was in fact the operator's cheapest attack on the product: the
3556// beacon's key holder knows every round's randomness in advance, and Commit ran
3557// AFTER the week's ticket set was frozen and public, so they could enumerate
3558// candidate rounds, Publish until Latest()+1 named them, and commit. The round is
3559// now pinned at record creation instead; read pinOffsetRounds for the full account,
3560// including what remains open.
3561//
3562// THERE IS DELIBERATELY NO "THE ROUND MUST STILL BE IN THE FUTURE" CHECK HERE, and
3563// the old `round <= latest` wrap check is gone with it. A reader who expects one
3564// has the fix backwards. A week is committed at least a week after its round was
3565// pinned, so by now the pinned round MAY LEGITIMATELY ALREADY BE PUBLISHED — that
3566// is the ordinary case for a week whose record opened early, and it is harmless
3567// precisely because nobody chose it: the round was fixed before a single ticket of
3568// that week had been sold. Requiring it to be unpublished would refuse the honest
3569// case and, worse, would reintroduce round selection through the back door, since
3570// the only way to satisfy such a check would be to recompute the round here. The
3571// wrap case the old check guarded is now handled where the arithmetic happens, at
3572// maxPinnableLatest.
3573//
3574// WHY FREEZING THE TOTAL ALSO FREEZES WHO OWNS UNIT k — the part the old realm
3575// got wrong first time, and the reason Buy appends:
3576//
3577// - Every purchase APPENDS A NEW ticket index (appendTickets). No index's
3578// value is ever increased afterwards, and fenwick.Add is not called from
3579// this realm at all.
3580// - An appended index therefore starts at a prefix equal to the whole total
3581// at the moment it was appended, so anything appended after Commit lies at
3582// or beyond FrozenTotal — outside the frozen range by construction, not by
3583// convention.
3584// - And because no earlier index's value changes, no earlier prefix moves:
3585// the owner of every unit below FrozenTotal is fixed from the instant that
3586// unit was sold.
3587//
3588// WITH ONE INDEX PER BUYER INSTEAD, a further purchase would grow that buyer's
3589// existing value, widening their range IN PLACE, from its start, inside the
3590// frozen range, and pushing every later buyer along. The earliest buyer (prefix
3591// 0 forever) could then wait for the round to publish, compute
3592// k = randomness mod FrozenTotal, buy until their own value exceeded k, and
3593// call Resolve to take the pot: a free option on the whole pot. That exploit
3594// was real in r/splash/game and its fix is what this realm inherits. Buy's
3595// refusal after commit closes the same door a second time.
3596//
3597// A WEEK'S TICKET TOTAL MAY BE ENTIRELY EARNED AND THIS FUNCTION DOES NOT CARE.
3598// Since finding 15 a week can reach this point with tickets and NO spend records at
3599// all — every entry credited to a duel player through FundPotWithEntries, not one
3600// ticket bought. total > 0 is satisfied, the week commits, and Resolve pays the
3601// winner a pot made purely of the rake those players paid. That is the intent, and
3602// it is why the zero-ticket refusal below is a check on the TICKET INDEX and not on
3603// the spend records: a drawless week is one nobody entered, not one nobody paid
3604// this realm directly.
3605//
3606// Panics if the week is not over yet, if it is already committed, or if it has no
3607// tickets at all (there is NO ROLLOVER, so a drawless week must never be allowed
3608// to exist). It no longer reads the beacon at all: the "no round has ever been
3609// published" refusal moved to the pin, which is to say to Buy and FundPot, where a
3610// sale is refused rather than accepted into a week that could never be drawn.
3611func Commit(cur realm, week int64) int64 {
3612 rejectAttachedCoin(cur, "Commit")
3613 sweepDonations()
3614
3615 if week >= currentWeek() {
3616 // THE WHOLE OF THE GRIEFING FIX. Tickets are still being sold into
3617 // currentWeek(), and a future week is not a week at all yet.
3618 panic("raffle: a week can only be committed once it is over")
3619 }
3620
3621 d := getDraw(week)
3622 if d == nil {
3623 panic("raffle: no tickets sold this week — there is no rollover, so a drawless week must not exist")
3624 }
3625 if d.Committed {
3626 panic("raffle: this week is already committed")
3627 }
3628 total := d.tickets.Total()
3629 if total == 0 {
3630 panic("raffle: no tickets sold this week — there is no rollover, so a drawless week must not exist")
3631 }
3632
3633 // THE ROUND IS READ, NOT CHOSEN. getOrCreateDraw pinned it when this week's
3634 // record was created; see pinOffsetRounds and the note above about why there is
3635 // no check on its publication state here.
3636 round := d.Round
3637 if round <= 0 {
3638 // Unreachable: getOrCreateDraw is the only way a Draw comes into existence
3639 // and it always pins a positive round. Stated loudly because the pin IS the
3640 // security property — and deliberately NOT "pin one now as a fallback",
3641 // which would hand the round choice straight back to the committer.
3642 panic("raffle: this week's record has no pinned beacon round")
3643 }
3644
3645 d.Committed = true
3646 d.FrozenTotal = total
3647 // d.Round IS NOT ASSIGNED HERE. It was pinned at record creation and this
3648 // function must never write it; `round` above is a read.
3649 //
3650 // THE HEIGHT THE REFUND GRACE WINDOW COUNTS FROM. Recorded here and nowhere
3651 // else, because this is the moment the pot becomes unreachable by any route
3652 // except Resolve or Refund — so it is the moment from which a buyer's
3653 // beacon-independent claim on their own money starts to age. See
3654 // refundGraceBlocks and Refund.
3655 d.CommittedAt = runtime.ChainHeight()
3656
3657 chain.Emit("Commit",
3658 "week", strconv.FormatInt(week, 10),
3659 "frozenTotal", strconv.FormatInt(total, 10),
3660 "round", strconv.FormatInt(round, 10),
3661 "pot", strconv.FormatInt(d.Pot, 10),
3662 "committedAt", strconv.FormatInt(d.CommittedAt, 10),
3663 "refundOpensAt", strconv.FormatInt(d.CommittedAt+refundGraceBlocks, 10))
3664 checkInvariant()
3665 return round
3666}
3667
3668// --- Capture -------------------------------------------------------------
3669
3670// Capture copies a committed week's beacon randomness into the draw.
3671//
3672// IT IS NO LONGER A SAFETY MECHANISM, AND SAYING SO IS THE POINT OF THIS COMMENT.
3673// What it used to say: "the beacon keeps signatures for beacon.PruneWindow = 2016
3674// rounds, about seven days at a five-minute cadence; this raffle is WEEKLY, so a draw
3675// settled late can find its round already pruned and the pot would have no way to name
3676// a winner. Capture preserves the value while it is still there." Every clause of that
3677// is now false. r/splash/beacon has no retention window and no Prune, drand serves
3678// every round for ever from at least three public relays, and Publish is
3679// permissionless — SO THERE IS NOTHING TO PRESERVE THE VALUE FROM. There is no capture
3680// window either; the old paragraph computed one, and it does not exist.
3681//
3682// WHAT IT STILL DOES, which is why it is kept rather than removed:
3683//
3684// - it copies a public value into the draw's own state, so Resolve needs no beacon
3685// read later and the settled draw is self-contained;
3686// - it makes the value visible through CapturedRandomness and the Capture event
3687// before the week is settled, which a front end and an auditor can both use.
3688//
3689// A CONVENIENCE, then. Removing it would delete an exported realm function and a
3690// large block of correct, tested behaviour in order to retire a comment; retiring the
3691// comment is enough. Nothing in this realm's safety now depends on anyone calling it —
3692// see resolvable()'s removal and Refund's gate.
3693//
3694// PERMISSIONLESS, deliberately. Its only effect is to store a value that is
3695// already public — the round's randomness, readable by anyone the moment it is
3696// published. Gating it on an operator would buy nothing and would put a
3697// liveness dependency for the whole pot back onto one key.
3698//
3699// WHAT IS STORED IS THE 64-CHARACTER HEX RANDOMNESS — sha256 of the signature,
3700// a hash, not the signature itself — and it is NOT re-verified here. The
3701// pairing check happened once already inside beacon.Publish on the way in;
3702// redoing it would be expensive duplicated work on an input that is already
3703// known good, and the beacon has no route by which a published round's
3704// randomness can change afterwards.
3705//
3706// A SECOND Capture NEVER OVERWRITES THE FIRST, and is a silent no-op rather
3707// than a panic: two callers racing to perform a permissionless public good
3708// should not make one of them fail.
3709//
3710// Capturing before the round is published PANICS. There is nothing to preserve
3711// yet, and storing anything else — least of all some already-published value —
3712// would hand the draw a randomness for a round nobody pinned, which is the exact
3713// attack the pin exists to prevent.
3714//
3715// COMMIT DOES NOT CAPTURE, AND MUST NOT START. Since the round is pinned at record
3716// creation, a week's round may well be published by the time it is committed, so a
3717// capture there would often succeed — and it would put a beacon read back inside the
3718// one function whose job is to choose nothing. Capture staying separate and
3719// permissionless is what keeps the commit path free of any beacon dependency at all;
3720// see Commit's note on why there is no publication check there either.
3721func Capture(cur realm, week int64) {
3722 rejectAttachedCoin(cur, "Capture")
3723 sweepDonations()
3724
3725 d := getDraw(week)
3726 if d == nil {
3727 panic("raffle: no draw for that week")
3728 }
3729 if !d.Committed {
3730 panic("raffle: this week is not committed yet")
3731 }
3732 if d.Resolved {
3733 panic("raffle: this week's draw is already resolved")
3734 }
3735 if d.Abandoned {
3736 // The week is in refund and can never be resolved, so there is nothing
3737 // left for a captured randomness to be used for. Refusing keeps the one
3738 // exit one-way at every entry point rather than only at Resolve.
3739 panic("raffle: this week was abandoned and refunded; there is nothing to capture")
3740 }
3741 if d.Randomness != "" {
3742 // Already captured: keep the first value, without failing the caller.
3743 return
3744 }
3745 if !beacon.Published(d.Round) {
3746 panic("raffle: beacon round not published yet")
3747 }
3748
3749 rnd := beacon.Randomness(d.Round)
3750 if len(rnd) != randomnessHexLen {
3751 // A length check, NOT a re-verification: it only asserts the shape of
3752 // the value this realm is about to depend on, so that an empty or
3753 // truncated string can never be mistaken for "captured" (the empty
3754 // string is this field's "not captured" sentinel).
3755 panic("raffle: beacon randomness has the wrong length")
3756 }
3757 d.Randomness = rnd
3758
3759 chain.Emit("Capture",
3760 "week", strconv.FormatInt(week, 10),
3761 "round", strconv.FormatInt(d.Round, 10),
3762 "randomness", rnd)
3763 checkInvariant()
3764}
3765
3766// --- Resolve -------------------------------------------------------------
3767
3768// Resolve picks the week's winner and PAYS THEIR WALLET. PERMISSIONLESS.
3769//
3770// THIS IS THE ONE PLACE THIS REALM DIVERGES FROM r/splash/game ON PURPOSE.
3771// There, the pot was credited to the winner's locked position, which meant the
3772// prize could never be spent and the winner's stake grew instead. Here the
3773// whole pot leaves this realm in a single wugnot.Transfer to the winner's
3774// address. No internal balance is credited, there is nothing left to claim
3775// afterwards, and no buyer's money is held past the week they bought into.
3776//
3777// winner = ownerOfUnit(randomness mod FrozenTotal). FrozenTotal, NOT the live
3778// ticket total: recomputing it here would reopen the buy-the-winning-unit
3779// attack described on Commit.
3780//
3781// THE POT PAYS OUT UNTAXED, AND THE FEE IS TAKEN HERE — NOT AT SALE TIME. This
3782// paragraph read "The house cut was already taken on the way in, at sale time; taxing
3783// the payout as well would be charging twice for the same ticket" until 2026-09-29, and
3784// the first half stopped being true that day: since finding 25 a sale ESCROWS the fee in
3785// Draw.Cut and this function is what earns it, moving the week's whole escrow to opsPot
3786// in a separate step from the prize. The conclusion is unchanged and so is the money —
3787// the winner still receives Pot and nothing is deducted from it here — but a reader
3788// looking for where the house is paid must look at this function and not at Buy.
3789// AUDIT-2026-09-28.md finding 29.
3790//
3791// RANDOMNESS SOURCE: a captured value if there is one, otherwise the beacon
3792// directly — so the happy path needs no extra transaction.
3793//
3794// THERE IS ONE FAILURE MESSAGE, AND THERE USED TO BE TWO. The pair was
3795// "not published yet" (wait and retry; the draw is fine) and "published and is now
3796// pruned, and nobody captured it" (the signature is gone, retrying will never help,
3797// Refund is the way out), and keeping them distinguishable was load-bearing because
3798// Refund's precondition WAS that distinction. Since the drand migration the second
3799// state cannot occur: no round is ever unobtainable, so "retrying will never help" is
3800// never true. Collapsing the two was therefore required, not a simplification — a
3801// realm that still said it would be steering buyers into refunds they do not need.
3802// Anyone who sees the remaining message can relay the round themselves.
3803//
3804// THERE IS NO HEIGHT GATE HERE, AND THERE MUST NEVER BE ONE. refundGraceBlocks
3805// does not appear in this function and CommittedAt is not read: the grace window
3806// is a FLOOR ON BUYER ACCESS, NOT A DEADLINE ON THE DRAW. It only ever opens
3807// Refund; it never closes Resolve.
3808//
3809// WHY THAT ASYMMETRY IS THE RIGHT ONE. The defect the grace window closes is that
3810// a stalled beacon left the buyers with no way to reach their own money. It is not
3811// that a late draw is illegitimate — an honest publication that arrives a fortnight
3812// after commit, before anybody has refunded, should still pay the winner it always
3813// would have, and the FrozenTotal it draws over is exactly the set it was frozen
3814// with. Adding a height deadline here would create a second class of dead week —
3815// past the grace, published, and resolvable by nobody — which is the very shape of
3816// defect this window exists to remove.
3817//
3818// WHAT DOES CLOSE THIS FUNCTION IS Abandoned, as before, and that is enough. Once
3819// the grace window has opened, Resolve and Refund are both available and the first
3820// one called wins: a refund sets Abandoned and this function refuses for ever, a
3821// resolve pays the winner and Refund's Resolved check refuses for ever. Neither
3822// ordering can pay the same ugnot twice, which is the property that matters. (The
3823// fairness cost of that race is recorded in AUDIT-2026-09-28.md finding 7: past the
3824// grace window, on a week whose round HAS since been published, a buyer who can see
3825// they did not win can refund rather than lose. It is bounded by the window being
3826// twice the beacon's entire retention — the state only exists after an outage — and
3827// closing it properly means a two-phase refund, which is a separate change.)
3828func Resolve(cur realm, week int64) {
3829 rejectAttachedCoin(cur, "Resolve")
3830 sweepDonations()
3831
3832 d := getDraw(week)
3833 if d == nil {
3834 panic("raffle: no draw for that week")
3835 }
3836 if !d.Committed {
3837 panic("raffle: this week is not committed yet")
3838 }
3839 if d.Resolved {
3840 panic("raffle: this week's draw is already resolved")
3841 }
3842 if d.Abandoned {
3843 // PERMANENT, AND DELIBERATELY NOT CONDITIONAL ON THE BEACON. Refunds are
3844 // open on this week and at least one buyer has already taken their money
3845 // out, so the frozen range no longer corresponds to who is still owed.
3846 // Resolving now would draw over the original range and pay whatever is
3847 // left of the pot — other buyers' money — to a possibly-refunded winner.
3848 // See Draw.Abandoned for why the beacon cannot be trusted to keep this
3849 // week unresolvable on its own.
3850 panic("raffle: this week was abandoned and refunded; it can never be resolved")
3851 }
3852 if d.FrozenTotal <= 0 {
3853 // Unreachable: Commit refuses a zero total. Loud rather than a
3854 // division by zero inside randomnessMod.
3855 panic("raffle: draw has no frozen tickets")
3856 }
3857
3858 rnd := d.Randomness
3859 if rnd == "" {
3860 if beacon.Published(d.Round) {
3861 rnd = beacon.Randomness(d.Round)
3862 } else {
3863 // ONE MESSAGE, NOT TWO, SINCE THE DRAND MIGRATION — AND THE OTHER ONE
3864 // MUST NOT COME BACK. There used to be a second branch here, reached
3865 // when d.Round fell outside beacon.PruneWindow, saying "beacon round
3866 // was published and is now pruned, and nobody captured it": the draw is
3867 // dead, retrying will never help, Refund is the way out. THAT STATE NO
3868 // LONGER EXISTS. r/splash/beacon has no retention window and no Prune,
3869 // every drand round is served for ever by at least three public relays,
3870 // and Publish is permissionless — so any caller who sees the message
3871 // below can make it stop being true for ~17.0M gas. "Retrying will never
3872 // help" is never true again, and a realm that said otherwise would be
3873 // steering buyers into a refund they did not need.
3874 // The wording is UNCHANGED from before the migration, deliberately:
3875 // it was already the correct thing to say, and it is now the only
3876 // thing there is to say.
3877 panic("raffle: beacon round not published yet")
3878 }
3879 }
3880
3881 winner := ownerOfUnit(d, randomnessMod(rnd, d.FrozenTotal))
3882
3883 pot := d.Pot
3884 // THE ONE MOMENT THE HOUSE'S FEE IS EARNED — AUDIT-2026-09-28.md finding 25. The
3885 // draw happened, so the week's escrowed Cut becomes ops revenue here and NOWHERE
3886 // ELSE. Buy does not credit opsPot, Commit does not, Capture does not; a week that
3887 // reaches any other terminal state returns the fee to its buyers instead.
3888 //
3889 // IT IS MOVED, NOT PAID: no wugnot leaves the realm for it, so the balance side of
3890 // checkInvariant does not move for this half at all. heldCuts falls by exactly what
3891 // opsPot gains, which is why the three-term equality still holds across the whole
3892 // function even though the transfer below reduces the balance by `pot`.
3893 //
3894 // AND IT IS DELIBERATELY NOT ADDED TO `pot`. The winner must not win our fee (read
3895 // Draw.Cut); the two amounts are computed, booked and reported separately, and the
3896 // only transfer out of this function is the prize.
3897 cut := d.Cut
3898 d.Resolved = true
3899 d.Winner = winner
3900 d.Pot = 0
3901 d.Cut = 0
3902 // AND SponsorCut WITH IT: it describes a SUBSET of d.Cut, and d.Cut is now zero.
3903 // Leaving it set made SponsorCutOf report an escrowed fee on a week whose fee had
3904 // already been released to ops — the only state in which SponsorCut > Cut, and a
3905 // direct contradiction of that read's own documented contract. 2026-09-30 audit,
3906 // finding Y1. No money moves here; the fee went to ops with the rest, which is the
3907 // rule for a week that DID draw.
3908 d.SponsorCut = 0
3909 heldPots -= pot
3910 heldCuts -= cut
3911 opsPot += cut
3912 // THE WINNER TAKES THE SPONSORSHIP TOO — that is the point of it: the pot is
3913 // the buyers' money plus whatever the duel's rake and any other funder added,
3914 // and it leaves in one transfer. d.Sponsored is deliberately NOT zeroed: it
3915 // stays as the record of what this week's prize was made of, and heldSponsored
3916 // counts unresolved weeks only (see its comment), so it is what drops here.
3917 heldSponsored -= d.Sponsored
3918
3919 if pot > 0 {
3920 // Books first, transfer second. wugnot's Transfer has no hook or
3921 // callback, so there is no reentrancy to fear either way, but writing
3922 // the state first means a future GRC20 with a hook cannot see a pot
3923 // that has already been promised and not yet zeroed.
3924 wugnot.Transfer(cross(cur), winner, pot)
3925 }
3926
3927 chain.Emit("Resolve",
3928 "week", strconv.FormatInt(week, 10),
3929 "round", strconv.FormatInt(d.Round, 10),
3930 "winner", string(winner),
3931 "pot", strconv.FormatInt(pot, 10),
3932 "cutEarned", strconv.FormatInt(cut, 10),
3933 "sponsored", strconv.FormatInt(d.Sponsored, 10))
3934 checkInvariant()
3935}
3936
3937// --- Refund --------------------------------------------------------------
3938
3939// resolvable() IS GONE, AND ITS ABSENCE IS THE LARGEST SINGLE CONSEQUENCE OF THE
3940// DRAND MIGRATION IN THIS FILE. It read:
3941//
3942// func resolvable(d *Draw) bool {
3943// if d.Randomness != "" { return true }
3944// if beacon.Published(d.Round) { return true }
3945// latest, _ := beacon.Latest()
3946// return d.Round > latest-beacon.PruneWindow
3947// }
3948//
3949// The third line was the whole function: it is how this realm decided "the round
3950// came and went, nobody kept it, and so this draw can NEVER be resolved". Refund
3951// existed for precisely that state and said so.
3952//
3953// THAT STATE CANNOT OCCUR AGAINST DRAND. Every round from 1 to the head is served
3954// for ever by at least three independent public relays; r/splash/beacon has no
3955// PruneWindow and no Prune, so nothing on this chain deletes a stored round either;
3956// and Publish is permissionless, so a round that is missing here is a round anyone
3957// can put here. A pinned round is ALWAYS eventually resolvable. The failure mode
3958// that remains is not "the randomness is lost", it is "nobody has bothered to relay
3959// it" — a LIVENESS failure.
3960//
3961// SO THE FUNCTION IS IDENTICALLY TRUE, AND IT IS DELETED RATHER THAN LEFT RETURNING
3962// true. A stub that always returns true is a place for someone to add a condition.
3963//
3964// AND MIND THE TRAP IN THE OBVIOUS ALTERNATIVE, because it is a fund-drain and not
3965// a cosmetic error. Dropping only the PruneWindow term and keeping the other two
3966// would make a freshly committed week whose round is not yet relayed report NOT
3967// resolvable — and Refund's gate opens as soon as resolvable() is false. Refunds
3968// would open IMMEDIATELY on every live draw. Deleting the term looked like the small
3969// change; deleting the function is the correct one.
3970//
3971// WHAT REFUND NOW GATES ON is refundGraceElapsed alone — the height window added as
3972// AUDIT-2026-09-28.md finding 7. It was one of two independent openers and is now
3973// the only one, which is the right shape: a beacon-state gate cannot distinguish
3974// "not relayed yet" from "never will be", and a clock can.
3975//
3976// AND FINDING 7'S RECORDED FAIRNESS COST STOPS BEING EXOTIC, which must be said out
3977// loud rather than inherited. Past the grace window, on a week whose round HAS since
3978// been relayed, a buyer who can see they did not win can refund instead of losing.
3979// Under the old design that state only existed after a beacon OUTAGE — the window was
3980// twice the beacon's entire retention. Now it exists whenever nobody relayed one
3981// round for three weeks, which is an ordinary thing rather than an incident. Closing
3982// it properly still means a two-phase refund, and that is still a separate change.
3983
3984
3985
3986// refundGraceElapsed reports whether refundGraceBlocks have passed since Commit
3987// froze this week. IT READS NO BEACON STATE AT ALL, and that is its entire
3988// purpose: it is the one condition in this file that a stopped, lost or hostile
3989// publisher cannot hold false. See refundGraceBlocks for the derivation and for
3990// the two ways the beacon otherwise traps a pot forever.
3991//
3992// IT IS MONOTONE, unlike resolvable(): chain height only rises and CommittedAt
3993// never moves once set, so this goes true exactly once and stays true. Refund
3994// therefore never needs Abandoned to protect it from this half of the gate — only
3995// from resolvable().
3996//
3997// A ZERO CommittedAt RETURNS false, and false is the safe direction. It is
3998// unreachable through the entry points: Refund tests Committed first, and Commit
3999// sets both fields in the same statement group. If it ever were reachable — a
4000// week committed by some future code path that forgot this field — withholding
4001// the extra escape hatch is a liveness cost, whereas granting it would open
4002// refunds on a live draw at any height past refundGraceBlocks.
4003func refundGraceElapsed(d *Draw) bool {
4004 if d.CommittedAt <= 0 {
4005 return false
4006 }
4007 return runtime.ChainHeight()-d.CommittedAt >= refundGraceBlocks
4008}
4009
4010// --- the three retired destinations ------------------------------------------
4011//
4012// THERE IS NO DESTINATION IN THIS FILE ANY MORE, AND THIS SECTION IS THE RECORD OF THE
4013// THREE THAT WERE TRIED. A dead week's sponsorship is now returned to the addresses
4014// holding units in THAT week (retireSponsorship, ClaimDeadWeek), so nothing below has a
4015// successor — read this section as a list of closed defects, not as a pointer to current
4016// code. AUDIT-2026-09-28.md findings 16, 28 and 31.
4017//
4018// rolloverTarget IS GONE AS OF 2026-09-29 AND ITS WHOLE COMMENT IS KEPT BELOW, BECAUSE
4019// THE CLAIM IT RESTED ON IS THE DEFECT. It returned currentWeek()'s draw — "the LIVE
4020// week" — and both paths that retire a dead week's sponsorship pushed money into
4021// whatever week that happened to be at the moment a permissionless caller fired. That
4022// is AUDIT-2026-09-28.md finding 28: A DESTINATION EVALUATED AT CALL TIME IS A
4023// DESTINATION THE CALLER CHOOSES. Nothing about the function was wrong on its own
4024// terms; what was wrong is that its answer depended on WHEN it was asked, and every
4025// caller of it was permissionless.
4026//
4027// THE RETIRED TEXT, VERBATIM:
4028//
4029// rolloverTarget returns the LIVE week's draw, to roll a dead week's sponsorship
4030// forward into, or nil if no such week can be had. IT NEVER PANICS, and that is its
4031// entire reason for existing as a separate function.
4032//
4033// WHO USES IT AND WHY IT CANNOT BE THE SAME CODE AS SweepDrawlessWeek'S. Refund's
4034// abandonment transition calls it (see there for the finding that put it here).
4035// SweepDrawlessWeek deliberately does NOT: that function is allowed to refuse, and
4036// refusing is the right answer for it — a drawless week's sponsorship has no deadline,
4037// so leaving it where it is and letting any caller retry after one beacon Publish
4038// costs nobody anything. A REFUND HAS A CLAIMANT WAITING AND MUST NOT BE REFUSED FOR A
4039// REASON IN ANOTHER REALM. If this returned a panic instead of nil, a stale beacon
4040// would mean a buyer could never reclaim their own ticket money — a fresh "money in,
4041// no way out" path, which is the shape six of this product's findings have had.
4042//
4043// THE THREE WAYS IT RETURNS nil, all of them states in which there is genuinely
4044// nowhere to put the money:
4045//
4046// - THE BEACON IS TOO STALE TO OPEN A WEEK. getOrCreateDraw pins the new week's
4047// round against beacon.EstimatedLatestRound(), which REFUSES past
4048// beacon.MaxEstimateStaleBlocks (AUDIT-2026-09-28.md finding 12). EstimateIsUsable
4049// is the non-panicking form of exactly that condition, so it is asked FIRST and
4050// the pin is only attempted when it cannot fail. In the one state where that
4051// refusal is permanent — a chain whose average block time has settled below
4052// 3.29998 s — no week can be opened at all, which is finding 12's own recorded
4053// residual and is the same state in which SweepDrawlessWeek stops working.
4054// - THE PIN WOULD WRAP. Checked here rather than left to getOrCreateDraw's panic,
4055// for the same no-panic reason. Unreachable in practice (drand cannot sign a round
4056// near the int64 maximum), and the cost of checking it is one beacon read.
4057// - THE LIVE WEEK IS COMMITTED OR ABANDONED. Unreachable by construction — Commit
4058// refuses week >= currentWeek() and abandonment requires a commit — and these are
4059// the same two defensive refusals SweepDrawlessWeek states about the same
4060// destination. Here they are a nil rather than a panic, because the caller has a
4061// fallback and must not be stopped.
4062//
4063// If it returns a draw, that draw is safe to add sponsorship to: it is the live week,
4064// uncommitted and unabandoned, and its round is pinned.
4065//
4066// (END OF THE RETIRED TEXT.)
4067//
4068// WHAT SURVIVED OF IT FOR ONE DAY WAS THE LAST BULLET'S REASONING, IN A FUNCTION CALLED
4069// ReclaimStrandedCarry, AND THAT IS GONE TOO — finding 31. The argument was: in the one
4070// state where NO week can ever be opened again there is genuinely nowhere to CARRY the
4071// money to, so it must still end in opsPot, behind the same beacon test (weekIsOpenable)
4072// and a three-week long stop. Every word of that was conditional on the money needing to
4073// GO somewhere. It does not any more: a dead week's sponsorship is claimed by that week's
4074// own unit holders, ClaimDeadWeek reads no beacon and has no clock, and the whole
4075// "nowhere to put it" state stops existing. AN ENTRY POINT, A REGISTER, A LONG STOP AND A
4076// BEACON TEST ALL DELETED BY REMOVING THE NEED FOR A DESTINATION RATHER THAN BY CHOOSING
4077// A BETTER ONE.
4078//
4079// AND ONE SENTENCE IN IT WAS SIMPLY WRONG, WHICH IS WORTH RECORDING BECAUSE IT WAS THE
4080// SENTENCE THAT MADE THE REGISTER LOOK SAFE. It said of the state the function required:
4081//
4082// One Buy, one FundPot or one duel flush in three weeks defeats it. The state it
4083// requires is not "the beacon went quiet for a day", it is "this realm is dead".
4084//
4085// Only a call that CREATED A WEEK RECORD absorbed a carry, so the true condition was "one
4086// week-OPENING event in three weeks" — and a Buy, a FundPot or a flush into a week that
4087// already had a record did nothing at all. The sentence overstated how easily the pending
4088// state cleared, which is the optimistic direction, and the same mis-statement is the
4089// reason finding 31's exploit had pending carries to collect in the first place: quiet
4090// weeks that never opened a record are exactly what let them accumulate.
4091
4092// Refund returns the caller's own ticket money for a week whose draw can never
4093// be resolved. PERMISSIONLESS, and self-service only: a caller can only ever
4094// reclaim their own record.
4095//
4096// THIS IS THE SAFETY VALVE r/splash/game COULD NOT HAVE. There, the pot was
4097// accrued protocol fees, nobody had staked anything, and an unresolved draw
4098// could simply wait forever with no one out of pocket — so a void path would
4099// have been the only route by which a prize could move without a winner, and
4100// was deliberately omitted. Here the pot is the buyers' own money, and without
4101// this function a week nobody settles would lose every ticket bought into it.
4102//
4103// ITS JUSTIFICATION CHANGED COMPLETELY IN THE DRAND MIGRATION, AND THE OLD ONE IS
4104// WRITTEN OUT HERE BECAUSE A READER WILL COME LOOKING FOR IT. It used to be: "the
4105// committed beacon round was published and PRUNED with nobody calling Capture, so no
4106// winner can ever be named". That was a claim about a beacon that forgot things.
4107// r/splash/beacon does not forget, drand's API serves every round for ever, and
4108// Publish is permissionless — so no draw is ever unresolvable, and the precondition
4109// this function was built on no longer exists (see resolvable()'s removal, above).
4110//
4111// WHAT IT IS FOR NOW IS LIVENESS, NOT LOSS. A week's pot must not be hostage to
4112// whether anybody spends ~17.0M gas relaying one drand round. That is the only
4113// failure left, and it is unbounded in time, so the exit is a CLOCK: refunds open
4114// refundGraceBlocks (21 days) after Commit and not before.
4115//
4116// THE PRECONDITION IS THEREFORE NO LONGER THE COMPLEMENT OF Resolve's — the two now
4117// OVERLAP for ever once the window opens, and that is fine and is handled by
4118// Abandoned. There is no state in which Resolve is impossible, so a refund gate that
4119// waited for one would never open at all.
4120//
4121// AND THE EXIT IS ONE-WAY, WHICHEVER WAY IT GOES. Refund and Resolve are
4122// permanently mutually exclusive per week: the first refund that pays out sets
4123// Abandoned, after which Resolve and Capture refuse that week forever and every
4124// later refund proceeds without consulting the beacon again. THE ONE-WAY EXIT
4125// MATTERS MORE NOW, NOT LESS: it used to protect against a round being published
4126// late or re-published after a prune, and it now carries the whole burden, because a
4127// pinned round can be relayed by anyone at any time for ever — so a week in refund
4128// would otherwise be re-resolvable indefinitely. Without it, a week could take
4129// refunds from some buyers and then be resolved over the original frozen ticket set,
4130// paying the remaining pot — those other buyers' money — to a winner who may already
4131// have been refunded.
4132//
4133// AND THE ESCROWED FEE FOLLOWS THE SAME PRINCIPLE AS THE SPONSORSHIP, BY A DIFFERENT
4134// ROUTE — BUT THE SPONSORSHIP DOES NOT ROLL ANYWHERE AND HAS NOT SINCE 2026-09-29. The
4135// retired sentence, ~240 lines above a transition that does nothing of the kind:
4136//
4137// "An abandoned week's Sponsored ROLLS FORWARD TO THE LIVE WEEK (finding 16), because it
4138// belongs to no buyer and there is nobody to hand it back to."
4139//
4140// BOTH HALVES WERE WRONG BY THE TIME IT WAS WRITTEN. Rolling forward to the live week was
4141// AUDIT-2026-09-28.md finding 28 — the live week is whichever week the permissionless caller
4142// chose to fire in, so the caller picked the destination by picking the moment — and the
4143// premise "there is nobody to hand it back to" is false whenever a duel was played in the
4144// week: THE EARNED-ENTRY HOLDERS ARE EXACTLY THE PEOPLE WHOSE RAKE IT IS (findings 31 and
4145// 34). The sponsorship becomes a per-unit claim on THIS week, payable to the holders of its
4146// EARNED entries, and it never changes weeks.
4147//
4148// SO THE TWO AMOUNTS NOW ANSWER THE SAME QUESTION THE SAME WAY, AND THE PARALLEL IS TIGHTER
4149// THAN IT WAS. An abandoned week's Cut does not roll anywhere: it belongs to specific buyers,
4150// so it goes BACK to them, one gross refund at a time, and the abandonment transition below
4151// deliberately does not touch d.Cut at all. The sponsorship likewise goes back to the people
4152// it came from, in place. We did not run a draw, so we keep neither — and the only money that
4153// reaches the house is a sponsorship with no player behind it at all.
4154//
4155// A BUYER WHO NEVER CALLS THIS LEAVES THEIR OWN GROSS SITTING IN THE WEEK, fee part
4156// included, and that is not a new stranded-money path: there is no deadline on a refund,
4157// Abandoned is one-way so nothing else can take it, and the only address that can ever
4158// reclaim it is theirs. It is the same "unclaimed, not unreachable" state the prize half
4159// has always had.
4160//
4161// AN EARNED ENTRY RECLAIMS NOTHING *HERE*, AND THAT IS CORRECT RATHER THAN A GAP — BUT IT
4162// RECLAIMS EVERYTHING THROUGH ClaimDeadWeek. Finding 15 lets duel play earn ticket weight with
4163// no spendOf record (FundPotWithEntries), and this function pays spendOf and nothing else — so
4164// a holder of only earned entries is refused with "nothing to reclaim for that week". They
4165// paid this realm no ticket money to get back: what they paid was the duel's rake, and the
4166// abandonment transition below turns that rake into THEIR claim on this same week
4167// (AUDIT-2026-09-28.md findings 31 and 34).
4168//
4169// THE TWO SENTENCES THAT FOLLOWED WERE BOTH FALSE AND ARE KEPT BECAUSE EACH WAS LOAD-BEARING
4170// FOR A DIFFERENT READER:
4171//
4172// "A week whose entries are ALL earned therefore refunds nobody and MOVES ITS WHOLE POT TO
4173// opsPot ON THE FIRST REFUND ATTEMPT — except that there can be no first refund attempt,
4174// because no caller has a spend record, so such a week simply stays unrefundable and
4175// RESOLVE REMAINS ITS ONLY EXIT."
4176//
4177// The first clause contradicted itself in its own sentence (a refund attempt that cannot
4178// happen cannot move anything) and named a destination that is deleted. The second was true
4179// when it was written and was FIXED BY finding 17 in the same audit: a pure-earned week has a
4180// SECOND exit, SweepDrawlessWeek's shape 2, gated on the same grace window this function uses,
4181// and that exit retires the sponsorship to the earned holders. Leaving "Resolve remains its
4182// only exit" standing is how a reader concludes that the stranding finding 17 closed is still
4183// open — or, worse, re-derives finding 17 as a new idea and implements it a second time.
4184//
4185// WHAT IS TRUE: such a week refunds nobody through this function, its draw is Resolve, and if
4186// no draw happens for the whole grace window then SweepDrawlessWeek marks it abandoned and its
4187// sponsorship becomes its earned holders' claim. It strands nothing by either route.
4188//
4189// EACH BUYER RECLAIMS EXACTLY THEIR OWN RECORDED SPEND, ONCE. The record is
4190// zeroed before the transfer, so a second call finds nothing and panics; and
4191// because Pot == sum(spends) + Sponsored for an unresolved week — with the
4192// sponsorship term taken out of the pot the moment the week is abandoned, see
4193// below — the refunds can never total more than the pot holds.
4194//
4195// SPONSORSHIP IS NOT REFUNDABLE AND IS NOT LEFT BEHIND. House money added by
4196// FundPot (the duel's rake, for one) belongs to no buyer, so nobody can claim it
4197// here; the first refund that pays out ROLLS IT FORWARD INTO THE LIVE WEEK'S POT —
4198// exactly as SweepDrawlessWeek does for a week nobody entered — instead of leaving
4199// it stranded in a pot whose every claim has been zeroed. It went to opsPot until
4200// 2026-09-29, and the two paths disagreeing about the same money was
4201// AUDIT-2026-09-28.md finding 16. The code and the reasoning are at the transition
4202// below.
4203//
4204// THE HOUSE FEE *IS* REFUNDED, SINCE 2026-09-29, AND THE PARAGRAPH THAT USED TO BE
4205// HERE SAID THE OPPOSITE. It read: "What a buyer gets back is their post-cut
4206// contribution — the part that was ever in the prize pot — not the gross ticket price.
4207// The 5% was taken at sale time and is already in the ops pot; clawing it back would
4208// mean the ops pot could go negative, or that this realm had to hold every cut in
4209// escrow for a week against the small chance of a pruned round." IT IS KEPT VERBATIM
4210// BECAUSE IT NAMES THE COST OF THE FIX AND THE FIX PAID IT: the realm now DOES hold
4211// every fee in escrow per week (Draw.Cut), which is the second option that sentence
4212// dismissed, and the ops pot cannot go negative because nothing credits it until a
4213// draw succeeds.
4214//
4215// WHAT A BUYER GETS BACK IS THEREFORE THE WHOLE TICKET PRICE. The owner's rule is that
4216// WE EARN WHEN THE GAME RUNS, NOT WHEN IT FAILS, and a refunded week is a week that did
4217// not run: the fee was a charge for a draw that never happened. This is a REDUCTION IN
4218// HOUSE REVENUE in the failure case, which needs no permission and is the safe
4219// direction — the same shape as finding 16 sending an abandoned week's sponsorship back
4220// to players rather than to the house.
4221//
4222// THE SPLIT IS IN THE BODY, and it is where the only rounding risk in the change lives;
4223// read it there. AUDIT-2026-09-28.md finding 25.
4224//
4225// A WEEK THAT WAS NEVER COMMITTED IS NOT REFUNDABLE HERE, and needs no separate
4226// path — but only because Commit TAKES THE WEEK AS AN ARGUMENT and any week
4227// that is over can be committed by anyone at any time. So a buyer holding a
4228// stranded week's tickets names that week to Commit themselves, and then either
4229// Resolves it (the normal outcome) or refunds it here if its round was lost.
4230// Before Commit took that argument this paragraph was simply false: a caller
4231// could not name the week, Commit could only act on the live one, and a week
4232// the clock had passed was unreachable by every path in this file — committed
4233// never, resolved never, refunded never, and counted in heldPots forever.
4234func Refund(cur realm, week int64) {
4235 rejectAttachedCoin(cur, "Refund")
4236 sweepDonations()
4237
4238 d := getDraw(week)
4239 if d == nil {
4240 panic("raffle: no draw for that week")
4241 }
4242 if d.Resolved {
4243 panic("raffle: this week's draw is already resolved")
4244 }
4245 if !d.Committed {
4246 panic("raffle: this week is not committed yet — commit it first; refunds only open on a week nobody settled")
4247 }
4248 // TWO TERMS, NOT THREE, SINCE THE DRAND MIGRATION. This gate read
4249 //
4250 // if !d.Abandoned && resolvable(d) && !refundGraceElapsed(d) {
4251 //
4252 // where resolvable(d) was the beacon-derived opener: "the round's signature is
4253 // gone, so this draw is dead". THAT OPENER IS DELETED, not rescaled, because the
4254 // state it detected cannot occur against drand — see resolvable()'s removal
4255 // above for why, and for why keeping a reduced version of it would have opened
4256 // refunds on every live draw immediately.
4257 //
4258 // THE Abandoned CHECK STILL COMES FIRST, and its reason is unchanged and is now
4259 // load-bearing for longer: once this week is in refund it stays in refund,
4260 // whatever the beacon says later. A pinned round can be relayed by anybody at any
4261 // time, for ever, so without this a week with some buyers already refunded could
4262 // be resolved over the original frozen range.
4263 //
4264 // THE HEIGHT GATE IS NOW THE ONLY OPENER. Refusing needs both terms — not
4265 // abandoned, and still inside the grace window. Read refundGraceBlocks for the
4266 // derivation and for why a beacon-state gate cannot do this job: it cannot
4267 // distinguish "nobody has relayed the round yet" from "nobody ever will".
4268 //
4269 // THE MESSAGE TELLS THE CALLER WHICH OF THE TWO THINGS TO DO, because both are
4270 // available to them: wait for the window, or relay the round themselves and
4271 // resolve. The height the window opens at is in Render, in DrawOf and in the
4272 // Commit event.
4273 if !d.Abandoned && !refundGraceElapsed(d) {
4274 panic("raffle: this week's draw is still live — refunds only open once the grace window has elapsed")
4275 }
4276
4277 caller := cur.Previous().Address()
4278 k := string(caller)
4279 amount := treeInt64(d.spendOf, k)
4280 if amount <= 0 {
4281 panic("raffle: nothing to reclaim for that week")
4282 }
4283
4284 // THE POINT OF NO RETURN, set on the first refund that actually pays out —
4285 // never by a refusal above, which must leave the week exactly as it was.
4286 //
4287 // AND THE ONE MOMENT SPONSORSHIP IS RETIRED FROM THE DRAW. A sponsored ugnot is
4288 // attributable to no buyer (see Draw.Sponsored and FundPot), so no Refund can
4289 // ever return it: if it just sat in d.Pot while every spendOf was zeroed AND NO
4290 // PATH COULD REACH IT, this week would end with Pot > 0, no claimant and no exit —
4291 // permanently unreachable money the invariant would go on counting, which is
4292 // precisely the defect the 2026-09-28 audit's first finding was about.
4293 //
4294 // THE FIRST HALF OF THAT SENTENCE IS NO LONGER WHAT HAPPENS AND THE SECOND HALF IS
4295 // STILL WHY. The money stays in d.Pot and STOPS BEING SPONSORSHIP: retireSponsorship
4296 // turns it into a per-unit claim held by the addresses that hold units in THIS week —
4297 // the duel players whose rake it is and the buyers who bought in. So there is a path to
4298 // it (ClaimDeadWeek), the claim is on named addresses rather than named in a register,
4299 // and the "unreachable money" objection is answered by the money already being where its
4300 // owners are. The call is INSIDE the `!d.Abandoned` branch, so it happens exactly once:
4301 // on the transition, not on every later refund.
4302 //
4303 // IT IS ALWAYS THE CLAIMANT CASE HERE, AND THAT IS STRUCTURAL RATHER THAN CHECKED. To
4304 // reach this line a caller must have a positive spendOf for this week, which only Buy
4305 // writes, and Buy appends unit weight in the same statement group — so d.tickets.Total()
4306 // is positive whenever this runs and retireSponsorship's no-holders branch is
4307 // unreachable from Refund. The no-holders branch belongs to SweepDrawlessWeek's shape 1.
4308 //
4309 // AND IT DOES NOT DISTURB THIS CALLER'S OWN REFUND. `amount` was read from spendOf
4310 // above and retireSponsorship does not touch spendOf, Pot's buyer-facing part, or Cut —
4311 // so the split below is over exactly the gross this buyer paid. Their share of the dead
4312 // week's sponsorship is a SEPARATE, later ClaimDeadWeek call. That separation is
4313 // deliberate: folding the share into spendOf instead would have made Refund re-derive a
4314 // house cut over money that never paid one, which drives d.Cut negative and turns a
4315 // buyer's own reclaim into a panic. The arithmetic is in AUDIT-2026-09-28.md finding 31.
4316 //
4317 // WHERE IT GOES CHANGED ON 2026-09-29, AND THE OLD DESTINATION WAS A FINDING
4318 // RATHER THAN UNTIDINESS. This branch used to do `opsPot += s` — the players'
4319 // side of the duel rake to the house — while SweepDrawlessWeek, twenty lines
4320 // further down this file, rolled THE SAME MONEY forward into the live week's pot
4321 // and said in as many words that "a week nobody entered should not hand the
4322 // players' share back to the house". Two functions moved identical money to
4323 // opposite destinations and nothing reconciled them. AUDIT-2026-09-28.md finding
4324 // 16 records the general lesson: THAT ASYMMETRY IS A FINDING, NOT A TIDINESS
4325 // PROBLEM — it will be exploited in whichever direction pays.
4326 //
4327 // AND SINCE EARNED ENTRIES EXIST IT WAS EXPLOITABLE, CHEAPLY. A week can now
4328 // accumulate a whole week of duel rake as ticket weight with no spendOf backing
4329 // (FundPotWithEntries). A griefer bought ONE ticket into such a week, waited for
4330 // it to end, committed it (permissionless), let refundGraceBlocks pass with
4331 // nobody relaying the round, and then called Refund: the branch fired, the ENTIRE
4332 // week of rake moved to opsPot, Resolve was shut for ever, every earned-entry
4333 // holder got nothing, and WithdrawOps — permissionless, fixed destination — sent
4334 // the lot to the ops vault. Net cost 5,000 ugnot of house cut; net beneficiary
4335 // the house. THE PARTY BEST PLACED TO TRIGGER IT WAS THE PARTY THAT RECEIVED THE
4336 // PROCEEDS, which is what makes it unacceptable regardless of how long the window
4337 // is.
4338 //
4339 // SO IT DOES NOT GO TO THE HOUSE, AND THE TWO PATHS AGREE: an abandoned week's
4340 // sponsorship is the players' money and it stays the players' money. It reduces house
4341 // revenue in an edge case, which is the safe direction.
4342 //
4343 // IT NO LONGER GOES FORWARD TO A FUTURE WEEK EITHER, AND THE PARAGRAPH THAT SAID IT DID
4344 // IS KEPT BECAUSE IT IS THE ONE A READER WILL HAVE IN THEIR HEAD. It said: "SO IT NOW
4345 // GOES FORWARD TO A FUTURE WEEK'S POT RATHER THAN TO THE HOUSE, EXACTLY AS
4346 // SweepDrawlessWeek DOES... it becomes part of a prize somebody can still win." Which
4347 // week that prize belonged to was the whole of findings 28 and 31, and forward motion is
4348 // what both of them had in common. It now goes NOWHERE: the week's own unit holders take
4349 // it, in the week it is already in.
4350 //
4351 // AND WHICH FUTURE WEEK IS NOT THIS CALLER'S TO DECIDE — FINDING 28, AND THE
4352 // PARAGRAPH THAT USED TO BE HERE IS WHY. It said: "THE ONE CASE SweepDrawlessWeek
4353 // ALREADY DOCUMENTS IS THE ONE CASE THAT FALLS BACK: if no live week can be opened
4354 // there is nowhere to roll it to. See rolloverTarget, which is the shared decision...
4355 // So when there is no live week the money goes to opsPot as it always did, which is
4356 // finding 12's residual and nothing wider." BOTH SENTENCES WERE TRUE AND TOGETHER
4357 // THEY WERE A DEFECT:
4358 //
4359 // - THE DESTINATION WAS currentWeek(), evaluated in this call, so a caller chose
4360 // it by choosing when to call. Buy into a week at its very end, refund the dead
4361 // week in the same block, and a week of duel rake joins a pot whose competing
4362 // ticket set is already known and closed. The loss fell on the dead week's
4363 // earned-entry holders — duel players who paid the rake — and the beneficiary
4364 // was whoever dominated the week the caller picked.
4365 // - AND THE "no live week" FALLBACK WAS NOT AN EDGE CASE AT ALL. Refunds open 21
4366 // days after a commit; the beacon's estimate goes unusable after ONE day without
4367 // a relay. In the scenario a refund exists for, the beacon is stale by
4368 // definition, so on a quiet realm the fallback was the NORMAL path and the rake
4369 // went to the house — finding 16 undone by its own escape hatch.
4370 //
4371 // WHAT HAPPENS INSTEAD, IN ONE LINE: the sponsorship becomes a per-unit claim on THIS
4372 // week, payable to THIS week's unit holders, and nothing in this call names a week,
4373 // reads a clock or reads the beacon. Read retireSponsorship for the arithmetic and the
4374 // dust, and ClaimDeadWeek for the exit.
4375 //
4376 // THE INTERMEDIATE DESIGN, RECORDED BECAUSE IT WAS WRONG IN A WAY THAT LOOKED RIGHT.
4377 // For one day this branch called markCarryPending: the week was REGISTERED and the next
4378 // week record to be created pulled the money in at its own birth, which was meant to
4379 // make the destination unchoosable. It did not. The caller who creates that week is the
4380 // caller who calls Buy, so buying one ticket in the final block of a quiet week both
4381 // opened the record and absorbed every pending carry into a week whose entire ticket set
4382 // was theirs — certainty, for the price of the house cut. AUDIT-2026-09-28.md finding 31.
4383 //
4384 // THIS FUNCTION THEREFORE NO LONGER HAS A DESTINATION TO FAIL TO FIND, which is a
4385 // strictly stronger form of the promise it always had to keep: Refund must never
4386 // panic for a reason in another realm, because a payout path a stale beacon could
4387 // hold shut would be a fresh "money in, no way out" defect. It now reads no beacon at
4388 // all on this path. THE SWEEP MAY STILL REFUSE AND THIS FUNCTION STILL MAY NOT — see
4389 // SweepDrawlessWeek's clock — but the asymmetry is now about a clock, not about
4390 // whether a destination exists.
4391 //
4392 // AND IT CANNOT BE MADE EXPENSIVE ENOUGH TO FAIL EITHER, WHICH IS THE OTHER HALF OF THAT
4393 // PROMISE AND IS WHY retireSponsorship IS O(1). The obvious way to return money pro rata
4394 // is to walk the holders and credit each one, and a week's holder set is unbounded — two
4395 // more holders for every duel played. A loop here would have put a gas ceiling on the
4396 // FIRST REFUND OF A BUSY WEEK, which is a "money in, no way out" path created by the fix
4397 // for one. One rate is written instead, in constant time, and each holder derives their
4398 // own share when they claim.
4399 //
4400 // NO WUGNOT LEAVES THE REALM FOR THE TRANSITION, and the week's identity
4401 // Pot + Cut == sum(spendOf) + Sponsored + Claims holds across it: Sponsored falls by
4402 // exactly what Claims rises by, except for the sub-unit dust, which leaves Pot and
4403 // enters opsPot in the same statement. It holds across every refund below too, each of
4404 // which removes one buyer's gross from both sides. After the last refund this week is
4405 // Pot == Claims, and every ugnot of that has a named holder who can take it.
4406 if !d.Abandoned {
4407 d.Abandoned = true
4408 if d.Sponsored > 0 {
4409 retireSponsorship(d)
4410 }
4411 }
4412
4413 // THE REFUNDED BUYER KEEPS THEIR TICKETS ON PURPOSE. d.tickets, d.ownerOf and
4414 // d.ticketsOf are deliberately NOT touched here: fenwick rejects negative
4415 // deltas by design and FrozenTotal must never move, so a ticket cannot be
4416 // retired once sold. That used to mean a refunded buyer kept their full
4417 // winning probability while holding their money back — harmless now, and only
4418 // because the line above makes this week unresolvable forever, so no draw over
4419 // this ticket set will ever happen. DO NOT "FIX" THE UN-RETIRED TICKETS by
4420 // reopening Resolve on an abandoned week: the tickets are dead weight, the
4421 // week's exit being one-way is what makes them safe.
4422 // THE GROSS IS SPLIT BACK INTO THE TWO TERMS IT ARRIVED IN — AUDIT-2026-09-28.md
4423 // finding 25, and this is the part of that change where the rounding lives.
4424 //
4425 // `amount` is the buyer's GROSS payment. At sale time Buy put
4426 // floor(cost * HouseCutBps / bpsDenom) of each purchase into d.Cut and the rest into
4427 // d.Pot. To take the money back out without leaving dust in either term, the split
4428 // is DERIVED THE SAME WAY BUY DERIVED IT — the same mulDiv, the same constants, over
4429 // this buyer's accumulated gross.
4430 //
4431 // WHY THAT IS EXACT AND NOT MERELY CLOSE, which is the whole question:
4432 //
4433 // - THE TWO PARTS ALWAYS SUM TO THE GROSS, unconditionally, because cutPart is
4434 // computed and potPart is the REMAINDER. So the per-week identity
4435 // Pot + Cut == sum(spendOf) + Sponsored survives ANY rounding whatever: this
4436 // call removes `amount` from the right and `amount` from the left. That is what
4437 // makes Refund unable to overdraw, and it holds after a partial refund, after
4438 // every ordering, and for every value of HouseCutBps.
4439 // - THE SPLIT ALSO LANDS ON THE SAME SIDE AS BUY'S, because cutPerTicket's build
4440 // guard forbids a TicketPrice/HouseCutBps pair for which the flooring discards
4441 // anything. Every cost is a whole number of tickets, so every cut is
4442 // tickets * cutPerTicket exactly, and so is every partial sum of them —
4443 // therefore floor over the accumulated gross returns precisely the sum of the
4444 // cuts that gross contributed. WITHOUT THAT GUARD the two could differ by one
4445 // ugnot per buyer, d.Cut could be driven negative by a refund, and
4446 // checkInvariant would abort a buyer's reclaim of their own money. The guard is
4447 // load-bearing, not hygiene.
4448 //
4449 // THE SPONSORSHIP TERM IS ALREADY OUT OF THE POT by the time this runs (the
4450 // transition above), so the only claims left on this week are gross spends.
4451 cutPart := int64(mulDiv(uint64(amount), uint64(HouseCutBps), uint64(bpsDenom)))
4452 potPart := amount - cutPart
4453
4454 d.spendOf.Set(k, int64(0))
4455 d.Pot -= potPart
4456 d.Cut -= cutPart
4457 heldPots -= potPart
4458 heldCuts -= cutPart
4459 if d.Pot < 0 {
4460 // Unreachable while Pot + Cut == sum(spends) + Sponsored. Loud rather than
4461 // paying out money that belongs to another week.
4462 panic("raffle: refund would overdraw the week's pot")
4463 }
4464 if d.Cut < 0 {
4465 // Unreachable for the reason spelled out above: the build guard makes the
4466 // per-buyer cut derivation exact, so the cut parts of a week's buyers sum to
4467 // exactly the Cut those buys booked. Loud rather than releasing a fee twice or
4468 // paying one buyer out of another week's escrow.
4469 panic("raffle: refund would overdraw the week's escrowed fee")
4470 }
4471
4472 // THE FULL TICKET PRICE GOES BACK, NOT 95% OF IT. This is the behaviour change
4473 // finding 25 is about: the fee was never earned, because the draw never ran.
4474 wugnot.Transfer(cross(cur), caller, amount)
4475
4476 chain.Emit("Refund",
4477 "week", strconv.FormatInt(week, 10),
4478 "buyer", string(caller),
4479 "amount", strconv.FormatInt(amount, 10),
4480 "prizePart", strconv.FormatInt(potPart, 10),
4481 "feeReturned", strconv.FormatInt(cutPart, 10),
4482 "pot", strconv.FormatInt(d.Pot, 10),
4483 "escrowedCut", strconv.FormatInt(d.Cut, 10))
4484 checkInvariant()
4485}
4486
4487// --- ClaimDeadWeek -------------------------------------------------------
4488
4489// ClaimDeadWeek pays the caller their pro-rata share of the sponsorship that a DEAD week's
4490// draw never got to pay out, and returns the amount. PERMISSIONLESS, self-service only —
4491// a caller can only ever claim their own share — and it is the EXIT for the claim term
4492// retireSponsorship creates. AUDIT-2026-09-28.md finding 31.
4493//
4494// WHY IT IS A NEW ENTRY POINT AT ALL, WHICH IS THE ONE QUESTION WORTH ANSWERING HERE. The
4495// cheaper design is to fold each holder's share into their spendOf and let the existing,
4496// already-audited Refund be the exit — no new function, no new term, no new tree. THAT DOES
4497// NOT WORK, AND THE ARITHMETIC IS WHY RATHER THAN A JUDGEMENT CALL:
4498//
4499// Refund re-derives the house fee it must give back as
4500// cutPart = floor(gross * HouseCutBps / bpsDenom)
4501// and decrements d.Cut by it. That derivation is EXACT — see cutPerTicket's build guard —
4502// only because a spendOf is always a whole number of ticket prices, so its floor is
4503// precisely the sum of the cuts those purchases booked.
4504//
4505// A sponsorship share booked NO CUT. Add a share s to a buyer whose gross was
4506// g = n * TicketPrice and Refund derives floor((g+s)/20) = n*500_000 + floor(s/20), while
4507// d.Cut only ever received n*500_000. d.Cut goes NEGATIVE by floor(s/20) and Refund's own
4508// "refund would overdraw the week's escrowed fee" panic fires — so the buyer cannot
4509// reclaim their own ticket money at all. Worse in the pure-earned case: a holder with no
4510// purchase gets gross s against a week whose Cut is 0, and floor(s/20) is stolen from
4511// nothing. THE FIX FOR A FUND-STRANDING DEFECT WOULD HAVE CREATED ONE.
4512//
4513// The identity itself survives — Refund removes `amount` from both sides whatever the
4514// rounding — which is exactly what makes this dangerous: checkInvariant and AuditFailure
4515// stay green right up to the panic. It is the Cut term, not the identity, that breaks.
4516//
4517// SO THE CLAIM IS A SEPARATE TERM WITH ITS OWN EXIT, AND IT CARRIES NO FEE BY CONSTRUCTION:
4518// sponsorship never paid a house cut on the way in and this pays none on the way out. The
4519// term appears in checkInvariant (heldClaims) and in AuditFailure (per week, and in the
4520// aggregate, and re-derived against claimedOf by deadWeekClaimFailure), which is the price
4521// of not reusing Refund and is paid in full.
4522//
4523// IT HAS NO CLOCK, NO BEACON READ AND NO REFUSABLE DEPENDENCY, deliberately. By the time a
4524// claim exists the week is already abandoned — every gate that had to be waited out
4525// (Refund's grace window, or SweepDrawlessWeek's) was waited out before retireSponsorship
4526// ran — so a second wait would only be a second thing that could go wrong on a payout path.
4527// Compare ReclaimStrandedCarry, which this replaces: that needed a beacon test AND a
4528// three-week long stop because it was trying to find a destination. There is nothing to
4529// find.
4530//
4531// IT IS O(log N) AND ALL-OR-NOTHING PER HOLDER. The share is derived from the rate and the
4532// holder's unit weight at call time rather than stored per holder at abandonment, which is
4533// what keeps the abandonment transition constant-time (read retireSponsorship). The weight
4534// cannot have changed since: an abandoned week is always a past week, and Buy and
4535// FundPotWithEntries only ever touch currentWeek(). One claimedOf entry therefore settles a
4536// holder for ever, and a second call finds it and refuses.
4537//
4538// A REFUNDED BUYER MAY STILL CLAIM, AND THAT IS CORRECT, NOT A LOOPHOLE. Refund returns the
4539// gross they paid for their tickets; this returns their share of money that was never
4540// theirs to pay and never anybody's to win. The two are different money with different
4541// owners, and the week's identity keeps them apart — spendOf on one side, Claims on the
4542// other. A holder with earned entries and no purchase has a claim here and nothing to
4543// refund, which is the whole point of finding 15's players being paid.
4544//
4545// BOOKS FIRST, TRANSFER SECOND, as in Resolve, Refund and WithdrawOps.
4546//
4547// Panics if there is no record for the week, if that week never distributed anything, if
4548// the caller holds no units in it, or if the caller has already claimed.
4549// ClaimSponsorRefund returns a sponsor's payment from a week that died without
4550// drawing. It pays the GROSS — the house's 5% included — because the fee was only
4551// ever earned by a draw actually running, and this draw never ran.
4552//
4553// THE OWNER'S RULING, 2026-09-30: a sponsor's money is "either returned to
4554// sponsor or rolls over until a draw happens". Rolling forward is what
4555// AUDIT-2026-09-28.md findings 28 and 31 removed — the caller chose which week it
4556// landed in by choosing when to call, and could aim it at a draw they already
4557// dominated. So it is returned, and it is PULLED rather than pushed: paying up to
4558// maxSponsorsPerWeek addresses inside retireSponsorship would put those transfers
4559// on the critical path of a buyer's own Refund, where one failing recipient could
4560// block the week.
4561//
4562// IT DOES NOT COMPETE WITH ClaimDeadWeek. That pays earned-entry holders out of
4563// the duel's OWNERLESS rake; this pays named sponsors out of money that never
4564// entered that pool, because retireSponsorship carves it out first. The two draw
4565// on different halves of the same pot and neither can reach the other's.
4566func ClaimSponsorRefund(cur realm, week int64) int64 {
4567 rejectAttachedCoin(cur, "ClaimSponsorRefund")
4568 sweepDonations()
4569
4570 d := getDraw(week)
4571 if d == nil {
4572 panic("raffle: no draw for that week")
4573 }
4574 if d.Resolved {
4575 panic("raffle: that week's draw was resolved — the sponsorship did its job and there is nothing to refund")
4576 }
4577 if !d.Abandoned {
4578 panic("raffle: that week is not dead — a sponsorship is only refundable once the week has been abandoned")
4579 }
4580 // THE ALREADY-CLAIMED CHECK COMES FIRST, and the order is the whole point. The
4581 // last sponsor to claim takes SponsorRefundable to zero, so a caller checking
4582 // their own second attempt would otherwise be told the week owes nobody anything
4583 // — true, and useless to them. They want to know THEY have already had it.
4584 caller := cur.Previous().Address()
4585 k := string(caller)
4586 if d.sponsorRefundedOf != nil && d.sponsorRefundedOf.Has(k) {
4587 panic("raffle: you have already reclaimed this week's sponsorship")
4588 }
4589 if d.SponsorRefundable <= 0 {
4590 panic("raffle: that week owes its sponsors nothing")
4591 }
4592
4593 // THE SUM OF THIS CALLER'S OWN ENTRIES, and nobody else's. Walking the list is
4594 // bounded by maxSponsorsPerWeek, which is why that bound exists.
4595 owed := int64(0)
4596 for _, sp := range d.Sponsors {
4597 if sp.By == caller {
4598 owed += sp.Amount
4599 }
4600 }
4601 if owed <= 0 {
4602 panic("raffle: you did not sponsor that week")
4603 }
4604 if owed > d.SponsorRefundable {
4605 // Unreachable while the carve-out is exact, and asserted rather than assumed:
4606 // this is the line between paying a sponsor and overdrawing the pool that
4607 // holds every other sponsor's money.
4608 panic("raffle: that refund would overdraw what the week owes its sponsors")
4609 }
4610
4611 if d.sponsorRefundedOf == nil {
4612 d.sponsorRefundedOf = avl.NewTree()
4613 }
4614 d.sponsorRefundedOf.Set(k, true)
4615 d.SponsorRefundable -= owed
4616 heldSponsorRefunds -= owed
4617 d.Pot -= owed
4618 heldPots -= owed
4619 if d.Pot < 0 {
4620 panic("raffle: that refund would overdraw the week's pot")
4621 }
4622
4623 wugnot.Transfer(cross(cur), caller, owed)
4624
4625 chain.Emit("ClaimSponsorRefund",
4626 "week", strconv.FormatInt(week, 10),
4627 "sponsor", string(caller),
4628 "amount", strconv.FormatInt(owed, 10),
4629 "stillOwed", strconv.FormatInt(d.SponsorRefundable, 10),
4630 "pot", strconv.FormatInt(d.Pot, 10))
4631 checkInvariant()
4632 return owed
4633}
4634
4635// SponsorRefundableFor is what a dead week owes ONE sponsor, and zero once they
4636// have pulled it. Without it a sponsor has to fetch SponsorsOf, sum their own
4637// rows by hand, and still cannot tell whether they already claimed — the tree
4638// that records that is unexported. 2026-09-30 audit, Y3.
4639func SponsorRefundableFor(week int64, addr address) int64 {
4640 d := getDraw(week)
4641 if d == nil || d.SponsorRefundable <= 0 {
4642 return 0
4643 }
4644 if d.sponsorRefundedOf != nil && d.sponsorRefundedOf.Has(string(addr)) {
4645 return 0
4646 }
4647 owed := int64(0)
4648 for _, sp := range d.Sponsors {
4649 if sp.By == addr {
4650 owed += sp.Amount
4651 }
4652 }
4653 return owed
4654}
4655
4656// SponsorRefundableOf is what a dead week still owes its sponsors.
4657func SponsorRefundableOf(week int64) int64 {
4658 d := getDraw(week)
4659 if d == nil {
4660 return 0
4661 }
4662 return d.SponsorRefundable
4663}
4664
4665func ClaimDeadWeek(cur realm, week int64) int64 {
4666 rejectAttachedCoin(cur, "ClaimDeadWeek")
4667 sweepDonations()
4668
4669 d := getDraw(week)
4670 if d == nil {
4671 panic("raffle: no draw for that week")
4672 }
4673 if d.ClaimPerUnit <= 0 {
4674 // COVERS THREE STATES AND THE MESSAGE NAMES THE USEFUL ONE: the week is still live,
4675 // or it is dead but held no sponsorship, or its sponsorship was smaller than its
4676 // unit count and was therefore all sub-unit dust (read retireSponsorship). None of
4677 // them owes the caller anything.
4678 panic("raffle: that week has no dead-week share to claim")
4679 }
4680 if d.Resolved {
4681 // Unreachable: a claim rate is only ever written on an abandoned week and an
4682 // abandoned week can never be resolved. Loud rather than paying a claim out of a
4683 // pot that has already gone to a winner.
4684 panic("raffle: that week's draw was resolved — its pot went to the winner")
4685 }
4686
4687 caller := cur.Previous().Address()
4688 k := string(caller)
4689 // EARNED UNITS ONLY — AUDIT-2026-09-28.md finding 34. This read was
4690 // treeInt64(d.ticketsOf, k), which counted the caller's BOUGHT units too and so paid a
4691 // refunded buyer a share of the duel players' rake, against a refund that had already
4692 // returned their whole gross. The two bases must agree with the divisor
4693 // retireSponsorship used, and that divisor is earnedTotal.
4694 units := treeInt64(d.earnedOf, k)
4695 if units <= 0 {
4696 // THE MESSAGE NAMES THE BASIS, because "I hold 400 units of that week" is exactly what
4697 // a refused buyer will be looking at. Bought units are not a claim on this money: they
4698 // contributed nothing to it and Refund returns what they did contribute, in full.
4699 panic("raffle: you hold no earned entries in that week")
4700 }
4701 if d.claimedOf.Has(k) {
4702 panic("raffle: you have already claimed that week's share")
4703 }
4704
4705 // NO OVERFLOW GUARD IS NEEDED AND THE BOUND IS WORTH STATING. units <= earnedTotal
4706 // always (earnedOf and earnedTotal have one writer and it increments both by the same
4707 // amount), and ClaimPerUnit was floor(Sponsored / earnedTotal), so
4708 // units * ClaimPerUnit <= earnedTotal * ClaimPerUnit <= Sponsored, which was a real wugnot
4709 // balance. The Claims comparison below is the belt to that: it is the same statement
4710 // made against a number the audit re-derives independently.
4711 amount := units * d.ClaimPerUnit
4712 if amount <= 0 || amount > d.Claims {
4713 // Unreachable while deadWeekClaimFailure's re-derivation holds. Loud rather than
4714 // paying one holder out of another's share.
4715 panic("raffle: that week's dead-week share does not cover this holder's claim")
4716 }
4717
4718 d.claimedOf.Set(k, amount)
4719 d.Claims -= amount
4720 d.Pot -= amount
4721 heldPots -= amount
4722 heldClaims -= amount
4723 if d.Pot < 0 {
4724 // Unreachable while Pot + Cut == sum(spendOf) + Sponsored + Claims. Loud rather
4725 // than paying out money that belongs to another week.
4726 panic("raffle: claim would overdraw the week's pot")
4727 }
4728
4729 wugnot.Transfer(cross(cur), caller, amount)
4730
4731 chain.Emit("ClaimDeadWeek",
4732 "week", strconv.FormatInt(week, 10),
4733 "holder", string(caller),
4734 "units", strconv.FormatInt(units, 10),
4735 "perUnit", strconv.FormatInt(d.ClaimPerUnit, 10),
4736 "amount", strconv.FormatInt(amount, 10),
4737 "unclaimed", strconv.FormatInt(d.Claims, 10),
4738 "pot", strconv.FormatInt(d.Pot, 10))
4739
4740 // AND WHEN THE LAST HOLDER IS PAID, SAY SO — AUDIT-2026-09-28.md finding 37, which is the
4741 // 2026-09-29 audit's open question about heldClaims. THE AGGREGATE CANNOT DISTINGUISH
4742 // "AWAITING HOLDERS WHO WILL COME" FROM "FROZEN FOR EVER": both are a positive number. This
4743 // event is the moment one week stops being either, it fires exactly ONCE per week — on the
4744 // claim that empties it, and Claims can never rise again — so an operator can subtract
4745 // settled weeks from heldClaims by reading the log alone, with no iteration over the realm.
4746 // UnclaimedHoldersOf answers the complementary question for a week that has NOT emptied:
4747 // how many named addresses are still owed.
4748 //
4749 // EMITTED AFTER THE PAYMENT'S OWN EVENT AND AFTER THE TRANSFER, so a reader of the log sees
4750 // the claim and then the week closing, in that order, and a reverted transaction emits
4751 // neither.
4752 if d.Claims == 0 {
4753 chain.Emit("DeadWeekFullyClaimed",
4754 "week", strconv.FormatInt(week, 10),
4755 "perUnit", strconv.FormatInt(d.ClaimPerUnit, 10),
4756 "earnedUnits", strconv.FormatInt(d.earnedTotal, 10),
4757 "heldClaims", strconv.FormatInt(heldClaims, 10),
4758 "height", strconv.FormatInt(runtime.ChainHeight(), 10))
4759 }
4760 checkInvariant()
4761 return amount
4762}
4763
4764// --- SweepDrawlessWeek ---------------------------------------------------
4765
4766// SweepDrawlessWeek rolls a FINISHED week's sponsorship forward into the live week's
4767// pot, when nobody ever bought a ticket in it. PERMISSIONLESS, like Commit.
4768//
4769// THERE ARE TWO SWEEPABLE SHAPES SINCE 2026-09-29 AND THE SECOND HAS A CLOCK ON IT:
4770// a week with NO TICKETS AT ALL (the original drawless week, sweepable as soon as it is
4771// over) and a week with TICKETS BUT NO SPEND — every entry earned by duel play — which
4772// is sweepable only once it is committed and refundGraceElapsed has gone true, and
4773// which is marked Abandoned when it is. The full reasoning, including why "zero spend"
4774// alone would have been a fund-destroying gate, is in the body above the shape test.
4775// AUDIT-2026-09-28.md finding 17.
4776//
4777// FINDING 15 MADE THIS FUNCTION MUCH HARDER TO REACH AND IT IS DELIBERATELY KEPT.
4778// A week of duel rake used to end with sponsorship and a zero ticket total, which is
4779// exactly the state this function exists for. Now the duel's deliveries bring
4780// ENTRIES with them (FundPotWithEntries), so such a week normally has tickets, is a
4781// real draw, and this function correctly refuses it — its sponsorship is a prize
4782// somebody can win rather than money nobody can reach. The states that still land
4783// here are real and small: a rake too small to buy one whole entry
4784// (rake < EntryUnitPrice), a duel both of whose parties played from realm code and so
4785// earned nothing, and any plain FundPot sponsorship from a caller that is not the
4786// duel. DO NOT DELETE IT ON THE GROUNDS THAT THE COMMON CASE IS GONE: every one of
4787// those states still strands money without it, and that was the sixth of this
4788// product's "money in, no way out" findings.
4789//
4790// THE DEFECT IT CLOSES. FundPot credits the live week and creates that week's
4791// record if it does not exist yet, so a week in which duels were collected but
4792// nobody ever bought a raffle ticket ends the week holding sponsorship in a Draw
4793// with a zero ticket total. Commit rightly refuses that week (no tickets, no unit
4794// to draw, no owner to pay), Resolve and Refund both require Committed, and
4795// FundPot only ever funds the live week — so before this function that
4796// sponsorship was unreachable by EVERY path in this realm, permanently, while
4797// heldPots went on counting it and checkInvariant and AuditFailure stayed green
4798// over money that was gone. That is the same class of defect as the 2026-09-28
4799// audit's first and most serious finding, and it is not an exotic state: a quiet
4800// raffle week with an active coin flip is an ordinary Tuesday.
4801//
4802// WHERE THE MONEY GOES, AND WHY NOT opsPot, AND WHY IT DOES NOT GO ANYWHERE AT ALL. It
4803// goes BACK TO THE PEOPLE WHO HOLD UNITS IN THIS WEEK, pro rata — and for shape 1, which
4804// has no unit holders whatsoever, it goes to opsPot because there is provably nobody else.
4805// Read retireSponsorship; the branch this function takes is exactly `pureEarned` and is
4806// stated again at the call site. This money is the PLAYERS' side of the duel rake (see
4807// r/splash/arcade/duel's Collect: the ops share went to r/splash/ops already, and what
4808// arrives here is deliberately the rest), so on shape 2 it lands back on the duel players
4809// who generated it.
4810//
4811// TWO SENTENCES THIS REPLACES, IN ORDER, BECAUSE THE SEQUENCE IS THE LESSON. First: "So it
4812// rolls forward to the live week's pot" — the live week being whichever week the caller
4813// chose to fire in (finding 28). Then: "It marks the week and registers it
4814// (markCarryPending); the next week record to be created absorbs it at its own birth...
4815// to a week nobody in this transaction can name" — except that the caller who opens that
4816// week is the caller who calls Buy, so they could name it after all, and be its only
4817// entrant (finding 31). Both sentences described a destination. There is none now.
4818//
4819// AND REFUND NOW AGREES, WHICH IT DID NOT UNTIL 2026-09-29. This paragraph used to
4820// end "Contrast Refund, which moves an abandoned week's sponsorship to opsPot: there
4821// the week HAD entrants and they are being paid back individually, so there is no
4822// prize left for the sponsorship to enlarge." THE CONTRAST WAS THE DEFECT. Two
4823// functions in one file moved the same field's money to opposite destinations, and
4824// since earned entries exist an abandoned week's "entrants" are mostly duel players
4825// with no spendOf to be paid back at all — so the rationale did not hold either. One
4826// ticket plus the grace window turned the whole argument into a cheap transfer of a
4827// week of rake to the house. AUDIT-2026-09-28.md finding 16. Refund's transition now
4828// retires the sponsorship exactly as this function does, and retireSponsorship is the
4829// shared step — a step with no destination in it.
4830//
4831// THIS FUNCTION STILL DIFFERS FROM REFUND IN ONE WAY, AND MUST, BUT THE DIFFERENCE IS NOW
4832// A CLOCK RATHER THAN A DESTINATION. It may REFUSE — the live-week rule, the shape tests,
4833// and the grace window each shape must wait out — because a dead week's sponsorship has no
4834// claimant and no deadline, so a refusal costs nobody anything and any caller may retry
4835// for ever. Refund may not refuse, because a buyer is waiting; and since neither function
4836// looks for a destination any more, there is no longer any state in which Refund COULD be
4837// refused for want of one. That is strictly stronger than both fallbacks it replaced — the
4838// immediate opsPot bank of finding 16 and ReclaimStrandedCarry's three-week long stop of
4839// finding 28 — and both of them are now deleted rather than relocated, because a claim on
4840// the week the money is already in cannot fail to find anything.
4841//
4842// THE DESIGN TENSION, RESOLVED EXPLICITLY SO IT IS NOT "FIXED" BACK. This realm's
4843// stated principle is that THERE IS NO ROLLOVER, so a drawless week must not
4844// exist — and Commit still enforces exactly that, unchanged. That principle is
4845// about TICKET HOLDERS' pots: it stops a week's prize being deferred away from the
4846// people who entered that week and paid for it. Rolling SPONSORSHIP forward from a
4847// week that had no entrants whatsoever does not violate it, because there was
4848// nobody to defer anything away from — no ticket was sold, so no buyer's
4849// expectation of this week's prize exists to be broken. Do not "restore
4850// consistency" by deleting this function or by letting Commit accept a zero total;
4851// the second would reopen the drawless-draw hole, and the first would reopen the
4852// stranded-money hole.
4853//
4854// NO WUGNOT MOVES HERE, and a reader looking for the missing Transfer should stop
4855// looking. The tokens are already inside this realm — FundPot pulled them in when
4856// the dead week was sponsored — so this is PURE BOOKKEEPING between two weeks of
4857// the same realm. The realm's wugnot balance is identical before and after;
4858// heldPots and heldSponsored each lose and regain exactly the same amount, which
4859// is why checkInvariant's equality still holds at the end.
4860//
4861// Panics if the week is not over yet (a live week may still sell a ticket, which
4862// would make it a real draw), if there is no record for it, if anybody BOUGHT a
4863// ticket in it, if it holds no sponsorship, if it is resolved or already abandoned,
4864// if its entries are all earned and its draw can still happen (uncommitted, or
4865// inside the grace window), or — defensively — if a ticketless week is somehow
4866// committed.
4867func SweepDrawlessWeek(cur realm, week int64) {
4868 rejectAttachedCoin(cur, "SweepDrawlessWeek")
4869 sweepDonations()
4870
4871 if week >= currentWeek() {
4872 // THE SAME "ONLY ONCE IT IS OVER" RULE AS Commit, and for a reason of its
4873 // own: the live week can still sell its first ticket, at which point it is
4874 // a real draw whose buyers are entitled to the sponsorship sitting in it.
4875 // Sweeping it would take the prize out from under a buyer mid-week.
4876 panic("raffle: a week can only be swept once it is over")
4877 }
4878
4879 d := getDraw(week)
4880 if d == nil {
4881 panic("raffle: no draw for that week")
4882 }
4883 // RESOLVED AND ABANDONED ARE ASKED FIRST, and for the PURE-EARNED shape below they
4884 // are not defensive at all: a swept pure-earned week IS abandoned afterwards, so
4885 // this is what makes a second sweep of one impossible.
4886 if d.Resolved {
4887 panic("raffle: that week's draw is already resolved")
4888 }
4889 if d.Abandoned {
4890 panic("raffle: that week was abandoned and refunded; its sponsorship has already left the pot")
4891 }
4892
4893 // THERE ARE TWO SWEEPABLE SHAPES AND THE SECOND IS GATED ON A CLOCK. Both are
4894 // "money arrived and no ticket was ever BOUGHT", which is what weekHasNoSpend
4895 // tests; they differ in whether any ticket WEIGHT exists.
4896 //
4897 // 1. NO TICKETS AT ALL — the original drawless week. Never committable, so never
4898 // resolvable or refundable: sweepable the moment it is over.
4899 // 2. TICKETS BUT NO SPEND — a PURE-EARNED week (finding 15). Every unit credited
4900 // to a duel player through FundPotWithEntries, not one ticket bought. It IS a
4901 // real draw and Resolve is its proper exit, so it is sweepable ONLY once its
4902 // draw can no longer happen: committed, and refundGraceElapsed.
4903 //
4904 // WHY SHAPE 2 EXISTS AT ALL — AUDIT-2026-09-28.md finding 17. Such a week is
4905 // committable, is not shape 1, and CANNOT BE REFUNDED BY ANYBODY: Refund pays
4906 // spendOf and no caller has a record, so it panics "nothing to reclaim for that
4907 // week" before it can even set Abandoned. Resolve was therefore its ONLY exit, with
4908 // no clock-based fallback — and that contradicts this realm's own doctrine at
4909 // refundGraceBlocks, which says in as many words that a beacon-state gate cannot
4910 // detect "nobody bothered to relay one" and a clock can. Finding 15 named the state
4911 // and argued it strands nothing because "the round can always be relayed"; that is
4912 // an argument about possibility, not about anybody's incentive to spend ~17.0M gas.
4913 //
4914 // AND THE GATE IS THE GRACE WINDOW, NOT MERELY "ZERO SPEND", BECAUSE THE NAIVE
4915 // VERSION IS THE TRAP. Letting this function act on any week with sum(spendOf) == 0
4916 // would let ANYONE sweep a LIVE, RESOLVABLE pure-earned week forward the instant it
4917 // ended — destroying a legitimate draw and taking the prize away from the very
4918 // earned-entry holders the whole of finding 15 exists to pay. That would be worse
4919 // than the problem. Gating on refundGraceElapsed(d) — the same clock, the same
4920 // constant and the same derivation that opens Refund for a week with buyers — means
4921 // the sweep can only fire once the draw has demonstrably NOT happened for the full
4922 // window. Before that, Resolve is the only exit and that is correct.
4923 //
4924 // IT MARKS THE WEEK WITH THE EXISTING ONE-WAY Abandoned FLAG rather than inventing a
4925 // second one, so Resolve and Capture refuse it for ever by exactly the checks they
4926 // already apply to a refunded week. See the transition further down.
4927 noSpend := weekHasNoSpend(d)
4928 if !noSpend {
4929 // A WEEK SOMEBODY BOUGHT A TICKET IN IS A REAL DRAW and belongs to
4930 // Commit/Resolve/Refund. Sweeping one would take a pot away from the buyers who
4931 // paid into it, which is the very thing the no-rollover principle forbids.
4932 panic("raffle: that week sold tickets — it is a real draw, so Commit and Resolve it (or Refund it), never sweep it")
4933 }
4934 pureEarned := d.tickets.Total() != 0
4935 if pureEarned {
4936 if !d.Committed {
4937 // THE DRAW CAN STILL HAPPEN AND IS THE RIGHT EXIT. Commit is permissionless
4938 // and takes the week as an argument, so anyone who wants this week settled —
4939 // every earned-entry holder, for a start — can commit it right now.
4940 panic("raffle: that week's entries were all earned and its draw can still happen — commit it and resolve it, and only sweep it if the grace window passes with no draw")
4941 }
4942 if !refundGraceElapsed(d) {
4943 // THE SAME CLOCK REFUND USES, FOR THE SAME REASON. Read the block above: a
4944 // sweep before this point would destroy a live draw.
4945 panic("raffle: that week's draw is still live — a week whose entries were all earned may only be swept once the grace window has elapsed")
4946 }
4947 } else {
4948 // DEFENSIVE AND UNREACHABLE. With a zero ticket total the week can never have
4949 // been committed (Commit refuses a zero total). Stated anyway, loudly, because
4950 // this function retires money from a week and a silent sweep of a week that had
4951 // somehow reached that state would be far worse than a failed transaction. (The
4952 // Resolved and Abandoned halves of the old trio moved above, where they are
4953 // load-bearing for shape 2.)
4954 if d.Committed {
4955 panic("raffle: that week is committed — it is a real draw, not a drawless one")
4956 }
4957
4958 // AND SHAPE 1 HAS A CLOCK TOO, SINCE 2026-09-29 — AUDIT-2026-09-28.md finding 28.
4959 // It is the SAME WINDOW shape 2 waits out (refundGraceBlocks), counted from the
4960 // height the week ENDED rather than from a commit, because a ticketless week can
4961 // never be committed and therefore has no CommittedAt to count from.
4962 //
4963 // WHY A TICKETLESS WEEK NEEDS A CLOCK AT ALL, GIVEN THAT ITS MONEY HAS NO
4964 // CLAIMANT AND NO DEADLINE. Because without one, the sweep was FREE AND INSTANT
4965 // THE MOMENT A WEEK ENDED, and that is what made finding 28's chain possible: a
4966 // week that receives carried sponsorship is created EMPTY, so if nobody buys into
4967 // it, it is a ticketless, spend-free week holding sponsorship the instant it ends
4968 // — this exact shape. One transaction per hop, no wait, and the money could be
4969 // walked week by week until it stood where the caller wanted it.
4970 //
4971 // THAT CHAIN CANNOT BE BUILT ANY MORE AND THE CLOCK IS KEPT ANYWAY, DELIBERATELY.
4972 // Finding 31 removed the hop itself: no week ever receives another week's
4973 // sponsorship, so there is no such thing as a week "created empty holding carried
4974 // money", and the shape this clock was the belt for no longer exists. The gate is
4975 // retained because it costs nobody anything — the money has no claimant and no
4976 // deadline, and any caller may sweep the moment the window passes or a year later —
4977 // and because removing a gate is a change with its own risk, made on its own day.
4978 // THIS COMMENT IS THE RECORD THAT ITS ORIGINAL JUSTIFICATION IS RETIRED, so that
4979 // nobody later cites the chain as a live reason.
4980 //
4981 // IT STRANDS NOTHING. The money stays in the week, visible in SponsoredOf and in
4982 // Render, and the sweep has no expiry: any caller may sweep it the moment the
4983 // window passes, or a year later. The only thing the clock removes is the ability
4984 // to do it IMMEDIATELY AND REPEATEDLY.
4985 if runtime.ChainHeight() < weekEndHeight(week)+refundGraceBlocks {
4986 panic("raffle: that week has no tickets, but its sweep window has not opened yet — a ticketless week may only be swept once the grace window has elapsed since it ended")
4987 }
4988 }
4989
4990 // BEFORE THE ASSERTIONS BELOW, WHICH IS THE WHOLE POINT. d.Cut != 0 asserts that
4991 // a swept week holds no escrowed fee, on the reasoning that a fee can only arrive
4992 // with a ticket SALE — true until Sponsor gave a fee a second way in. Folding
4993 // first restores the assertion's original meaning exactly: whatever Cut remains
4994 // after the sponsors' share has gone home must still be a ticket-sale fee, and
4995 // must still be impossible here.
4996 foldSponsorCut(d)
4997
4998 amount := d.Sponsored
4999 if amount <= 0 {
5000 panic("raffle: that week holds no sponsorship — there is nothing to sweep")
5001 }
5002 if d.Pot != amount {
5003 // Unreachable: BOTH sweepable shapes have no spend record — that is what
5004 // weekHasNoSpend established above — so the pot identity
5005 // Pot + Cut == sum(spendOf) + Sponsored reduces to Pot == Sponsored for either
5006 // of them, given the zero Cut asserted just below. Loud rather than moving a
5007 // number that does not describe the week it came from.
5008 panic("raffle: drawless week's pot does not equal its sponsorship")
5009 }
5010 if d.Cut != 0 {
5011 // Unreachable, and asserted rather than assumed — AUDIT-2026-09-28.md finding
5012 // 25. An escrowed fee can only arrive with a ticket SALE, and both sweepable
5013 // shapes require sum(spendOf) == 0, so a swept week has no fee to release.
5014 // Stating it here is what stops this function silently moving a week's
5015 // sponsorship forward while leaving an unearned fee behind in a week it has
5016 // just marked Abandoned — which, having no spendOf record to refund it
5017 // through, nobody could ever reclaim. That would be a new "money in, no way
5018 // out" path, so it is a loud refusal and not a rollover.
5019 panic("raffle: a week with no spend is holding an escrowed fee")
5020 }
5021
5022 // THE DESTINATION USED TO BE CHOSEN HERE AND IT IS NOT CHOSEN ANYWHERE ANY MORE —
5023 // AUDIT-2026-09-28.md finding 28. These lines stood here:
5024 //
5025 // dest := currentWeek()
5026 // if dd := getDraw(dest); dd != nil {
5027 // if dd.Committed { panic(...) }
5028 // if dd.Abandoned { panic(...) }
5029 // }
5030 //
5031 // with a `dd := getOrCreateDraw(dest)` and a two-sided transfer below. Every one of
5032 // those lines was correct about the week it named; what was wrong is that it named
5033 // the week that was live AT THE MOMENT A PERMISSIONLESS CALLER CHOSE TO FIRE, so the
5034 // caller selected where other people's money went by selecting when to call. The
5035 // defensive refusals are gone with it because there is nothing left to refuse: this
5036 // function no longer touches a second week, creates no record, reads no beacon, and
5037 // takes no destination. It retires the money where it stands (retireSponsorship).
5038 //
5039 // THE SENTENCE THAT STOOD HERE FOR ONE DAY — "It registers, and the destination pulls at
5040 // its own birth" — WAS FINDING 31. The destination that pulls is a week the caller
5041 // creates, so the caller still chose it, and could be its only entrant.
5042
5043 // A SWEPT PURE-EARNED WEEK IS ABANDONED, PERMANENTLY, AND THAT IS THE OTHER HALF
5044 // OF FINDING 17. Its ticket weight is real and its prize has just left, so
5045 // without this flag Resolve would still name a winner over the frozen range and
5046 // pay them a pot of zero — or, worse, a pot the NEXT sweep or sponsorship had
5047 // refilled. The existing one-way flag is reused rather than a second one invented:
5048 // Resolve and Capture already refuse an abandoned week with the exact words they
5049 // use for a refunded one, Refund's own gate then finds nothing to reclaim, and
5050 // this function refuses it too (the Abandoned check at the top). There is no path
5051 // that un-sets it.
5052 //
5053 // A SHAPE-1 WEEK IS NOW MARKED TOO, AND IT USED NOT TO BE. The retired reasoning:
5054 // "A SHAPE-1 WEEK IS NOT MARKED, deliberately: it has no tickets, so Commit refuses
5055 // it for ever anyway and Resolve and Capture cannot be reached at all. Marking it
5056 // would be a state change with no meaning." THE FLAG HAS A MEANING NOW, which is what
5057 // changed: Abandoned is the flag retireSponsorship demands before it will convert a
5058 // week's sponsorship into a claim, the flag that stops this function sweeping the same
5059 // week twice (the Abandoned check at the top, which for shape 1 was previously
5060 // unreachable and is now what makes a second sweep impossible), the flag Refund's gate
5061 // reads, and the flag AuditFailure's "an abandoned week holds no sponsorship" statement
5062 // is written in. So both shapes are marked, by one line, and "abandoned" now means
5063 // exactly "this week's draw will never happen and its money has stopped being a prize".
5064 d.Abandoned = true
5065 if pureEarned {
5066 chain.Emit("SweepAbandonedPureEarnedWeek",
5067 "week", strconv.FormatInt(week, 10),
5068 "frozenTotal", strconv.FormatInt(d.FrozenTotal, 10),
5069 "tickets", strconv.FormatInt(d.tickets.Total(), 10),
5070 "committedAt", strconv.FormatInt(d.CommittedAt, 10),
5071 "height", strconv.FormatInt(runtime.ChainHeight(), 10))
5072 }
5073
5074 // AND THE MONEY IS RETIRED WHERE IT STANDS. The two-sided transfer that used to be here
5075 // — d.Sponsored to zero, d.Pot down, heldPots and heldSponsored down and back up
5076 // against a destination week — is gone with the destination, and so is the register
5077 // (markCarryPending) that replaced it for one day. Read retireSponsorship.
5078 //
5079 // WHICH OF ITS TWO BRANCHES RUNS IS STILL EXACTLY `pureEarned`, AND THAT IS THE WHOLE OF
5080 // THE "SAY WHICH CASE YOU ARE IN" REQUIREMENT AT THIS SITE — but since
5081 // AUDIT-2026-09-28.md finding 34 the branch retireSponsorship actually tests is
5082 // d.earnedTotal, so the equality of the two is asserted below rather than assumed:
5083 //
5084 // - SHAPE 2, pureEarned == true: THE CLAIMANT CASE runs. Every unit of this week was
5085 // credited to a duel player through FundPotWithEntries — that is what "no spend and a
5086 // non-zero ticket total" means — so earnedTotal == tickets.Total() != 0, and the people
5087 // who get this money back are exactly the players whose rake it was. This is the case
5088 // finding 16 was about, answered without a destination.
5089 // - SHAPE 1, pureEarned == false: d.tickets.Total() == 0, so earnedTotal is 0 too (it can
5090 // never exceed the ticket total) and THE NO-CLAIMANT CASE runs: the money goes to
5091 // opsPot. No address earned a unit of this week — this is a plain FundPot into a week
5092 // nobody entered, a duel rake below one entry unit, or a duel both of whose parties
5093 // played from realm code. Nobody is wronged because there is nobody to wrong. A week
5094 // that HAS earned holders cannot reach this branch: earnedTotal never decreases.
5095 //
5096 // THE SHAPE-2 EQUALITY IS ASSERTED, NOT ASSUMED — finding 34's one new obligation at this
5097 // site. "No spend implies every unit was earned" is true only because appendTickets has
5098 // exactly two callers and Buy writes a spendOf record in the same transaction as its
5099 // append; Refund ZEROES a spend record rather than removing it, but an abandoned week is
5100 // refused at the top of this function, so a refunded week can never arrive here looking
5101 // spend-free. That is a four-step argument about three other functions, and it decides
5102 // whether a week's whole sponsorship goes to its players or to the house. So it is
5103 // checked, loudly, in the one place that depends on it. UNLIKE THE GUARDS INSIDE
5104 // retireSponsorship, A PANIC HERE COSTS NOBODY THEIR OWN MONEY: this is the sweep, which
5105 // may refuse (a dead week's sponsorship has no claimant and no deadline, so any caller may
5106 // retry for ever) — it is emphatically not Refund.
5107 if pureEarned && d.earnedTotal != d.tickets.Total() {
5108 panic("raffle: that week has no spend but its earned units do not account for all its ticket units")
5109 }
5110 retireSponsorship(d)
5111
5112 chain.Emit("SweepDrawlessWeek",
5113 "week", strconv.FormatInt(week, 10),
5114 "amount", strconv.FormatInt(amount, 10),
5115 "pureEarned", strconv.FormatBool(pureEarned),
5116 "pot", strconv.FormatInt(d.Pot, 10),
5117 "height", strconv.FormatInt(runtime.ChainHeight(), 10))
5118 checkInvariant()
5119}
5120
5121// --- WithdrawOps ---------------------------------------------------------
5122
5123// WithdrawOps pushes the whole ops pot to opsVaultAddr and returns the amount
5124// moved. PERMISSIONLESS.
5125//
5126// THE DEFECT IT CLOSES, found 2026-09-28 by the engineer implementing that day's
5127// fifth fix rather than by the audit: opsPot only ever increased. It was written
5128// in three places — Buy's house cut (now Resolve's released escrow, finding 25),
5129// sweepDonations' absorbed arrivals, and
5130// Refund's move of an abandoned week's sponsorship — and read by nothing but the
5131// invariant and the reads. There was no outflow anywhere in this file, so three
5132// separate streams of money became permanently unreachable by every path in the
5133// realm, THE PRODUCT'S ENTIRE REVENUE AMONG THEM. checkInvariant and AuditFailure
5134// both stayed green over it, because both count opsPot as money the realm holds
5135// and neither has an opinion about whether anybody can ever reach it. That is the
5136// same class of defect as the unreachable week (the audit's first finding) and the
5137// stranded drawless-week sponsorship (SweepDrawlessWeek).
5138//
5139// PERMISSIONLESS, AND THAT IS SAFE PRECISELY BECAUSE THE DESTINATION IS FIXED AT
5140// COMPILE TIME. A reader will look for a caller check here and there is none, on
5141// purpose: opsVaultAddr is a const, so the caller does not choose where the money
5142// goes, cannot name themselves, and cannot name a week's pot. All they can do is
5143// push the house's money to the house — and paying the gas to do somebody else's
5144// bookkeeping is a favour, not an attack. Gating it on an operator key would buy
5145// nothing and would put a liveness dependency for the realm's revenue onto one
5146// key, exactly as it would for Commit, Capture and Resolve. This is
5147// r/splash/exchange's SettleOps shape, and the property that makes it sound is the
5148// one tools/raffle_addr_test.go exists to keep true.
5149//
5150// IT NEVER REACHES A PLAYER'S MONEY. No Draw is touched: not Pot, not Sponsored,
5151// not spendOf, not a ticket. The only book that moves is opsPot, which by
5152// construction holds nothing any buyer can claim — the fee in it was EARNED BY A DRAW
5153// THAT ACTUALLY RAN (Resolve releases the week's escrow; a week that fails refunds it
5154// with the gross, finding 25), a donation belongs to whoever chose to give it away, and
5155// a dead week's sponsorship was never attributable to a buyer at all.
5156//
5157// THE CITATION THAT USED TO STAND HERE IS RETIRED: this paragraph said "the cut was
5158// taken at sale time (see Refund's 'THE HOUSE CUT IS NOT REFUNDED')", and there is no
5159// such section in Refund any more — finding 25 deleted the behaviour and Refund records
5160// the retired paragraph verbatim in its place. A citation that outlives the thing it
5161// cites is how a reader ends up trusting a rule the code stopped following.
5162// AUDIT-2026-09-28.md finding 29. NOTE THAT THE THIRD STREAM IS NOW ALMOST CLOSED:
5163// since 2026-09-29 a dead week's sponsorship goes forward to a future week's prize
5164// and reaches this counter only when no week can be opened at all (Refund's
5165// transition, and AUDIT-2026-09-28.md finding 16). That is a deliberate reduction in
5166// house revenue and it is the safe direction.
5167//
5168// AND SINCE 2026-09-29 IT CANNOT REACH AN UNEARNED FEE EITHER, which is the point of
5169// finding 25 from this function's side. The house cut used to arrive in opsPot the
5170// instant a ticket was sold, so this call could push out money for draws that had not
5171// happened yet — and the buyers of a week that then failed got 95% back while the
5172// missing 5% had already left the realm. The fee now sits in Draw.Cut until Resolve
5173// releases it, heldCuts is a separate term in checkInvariant, and this function does not
5174// mention either. A caller who withdraws every block still cannot touch a live week's
5175// escrow.
5176//
5177// sweepDonations RUNS FIRST, as in every other mutator, so a donation that
5178// arrived since the last state change is included in this withdrawal rather than
5179// left behind for the next one.
5180//
5181// BOOKS FIRST, TRANSFER SECOND, exactly as Resolve and Refund do it: opsPot is
5182// zeroed BEFORE the wugnot leaves, so a re-entrant call — which wugnot's Transfer
5183// cannot cause today, having no hook or callback, but a future GRC20 might — finds
5184// an empty pot and panics rather than a balance it can take twice.
5185//
5186// THE INVARIANT IS EXACT ACROSS IT, not merely preserved one-sidedly:
5187// checkInvariant asserts heldPots + opsPot == balance, and this call reduces both
5188// sides by the same amount. heldPots and heldSponsored are deliberately untouched.
5189//
5190// Panics if there is nothing to withdraw.
5191func WithdrawOps(cur realm) int64 {
5192 rejectAttachedCoin(cur, "WithdrawOps")
5193 sweepDonations()
5194
5195 amount := opsPot
5196 if amount <= 0 {
5197 panic("raffle: the ops pot is empty — there is nothing to withdraw")
5198 }
5199
5200 opsPot = 0
5201 wugnot.Transfer(cross(cur), opsVaultAddr, amount)
5202
5203 chain.Emit("WithdrawOps",
5204 "amount", strconv.FormatInt(amount, 10),
5205 "destination", string(opsVaultAddr))
5206 checkInvariant()
5207 return amount
5208}
5209
5210// --- reads ---------------------------------------------------------------
5211//
5212// All plain and non-crossing: a read needs no authority check.
5213
5214// PotSize is the CURRENT week's prize pot in ugnot: ticket sales after the house
5215// cut, PLUS whatever FundPot has sponsored into it (Sponsored, below). It is the
5216// whole amount the winner takes.
5217func PotSize() int64 {
5218 d := getDraw(currentWeek())
5219 if d == nil {
5220 return 0
5221 }
5222 return d.Pot
5223}
5224
5225// TicketTotal is the CURRENT week's ticket units sold.
5226func TicketTotal() int64 {
5227 return TicketTotalOfWeek(currentWeek())
5228}
5229
5230// TicketTotalOfWeek is a given week's ticket units sold: the LIVE total, not
5231// FrozenTotal, so it is readable before a commit and is zero for a week that
5232// never sold a ticket.
5233//
5234// IT IS THE READ THAT MAKES A DRAWLESS WEEK VISIBLE. DrawOf reports FrozenTotal,
5235// which is zero on any uncommitted week, so a caller cannot use it to tell "sold
5236// nothing" from "sold plenty and was never committed". Sweepability turns on
5237// exactly that distinction: a past week is sweepable when this is zero and
5238// SponsoredOf is positive. See SweepDrawlessWeek.
5239func TicketTotalOfWeek(week int64) int64 {
5240 d := getDraw(week)
5241 if d == nil {
5242 return 0
5243 }
5244 return d.tickets.Total()
5245}
5246
5247// TicketsOf is addr's ticket units in the CURRENT week.
5248func TicketsOf(addr address) int64 {
5249 return TicketsOfWeek(currentWeek(), addr)
5250}
5251
5252// SpendOf is addr's refundable contribution to the CURRENT week in ugnot: THE GROSS
5253// TICKET PRICE THEY PAID, house fee included, which is exactly what Refund returns.
5254//
5255// THIS COMMENT SAID THE OPPOSITE UNTIL NOW AND THE VALUE NEVER CHANGED. It read: "what
5256// they paid MINUS the house cut, which is what Refund would return. It is not the gross
5257// ticket price they paid." That was true until 2026-09-29, when the house fee stopped
5258// being banked at sale time and started being escrowed per week in Draw.Cut
5259// (AUDIT-2026-09-28.md finding 25): spendOf became the GROSS in the same change, and
5260// Refund returns the whole ticket price. The function was correct and its documentation
5261// was not — THE WORST SHAPE OF ALL FOR AN EXPORTED, FRONT-END-FACING READ, because a
5262// caller who believed it would display a refund quote 5% short and nothing would ever
5263// fail. Corrected rather than deleted, because the old sentence is the one a reader may
5264// have in their head. AUDIT-2026-09-28.md finding 29.
5265func SpendOf(addr address) int64 {
5266 return SpendOfWeek(currentWeek(), addr)
5267}
5268
5269// TicketsOfWeek is addr's ticket units in a given week, so a front end can
5270// still show a buyer their past weeks once the clock has moved on.
5271func TicketsOfWeek(week int64, addr address) int64 {
5272 d := getDraw(week)
5273 if d == nil {
5274 return 0
5275 }
5276 return treeInt64(d.ticketsOf, string(addr))
5277}
5278
5279// SpendOfWeek is addr's remaining refundable contribution to a given week: their GROSS
5280// ticket spend, fee included, which is what Refund pays them. Zero once they have been
5281// refunded. Read SpendOf for the claim this pair used to make and why it is retired.
5282func SpendOfWeek(week int64, addr address) int64 {
5283 d := getDraw(week)
5284 if d == nil {
5285 return 0
5286 }
5287 return treeInt64(d.spendOf, string(addr))
5288}
5289
5290// CurrentWeek is the week tickets are being sold into right now.
5291func CurrentWeek() int64 {
5292 return currentWeek()
5293}
5294
5295// WeekEndHeight is weekEndHeight, EXPORTED: the chain height at which a given week
5296// stops being the live one. A week is live for heights [WeekEndHeight(week-1),
5297// WeekEndHeight(week)) — the returned height is the FIRST height of week+1, NOT the
5298// last height of week, so at exactly this height CurrentWeek already reads week+1.
5299// A countdown that showed "1 block left" here would be off by one.
5300//
5301// IT IS A BLOCK HEIGHT AND DELIBERATELY NOT A TIME. This realm's week is
5302// raffleWeekDays*blocksPerDay blocks, and blocksPerDay = 26182 encodes an ASSUMED
5303// 86400/26182 = 3.29998 s per block (read blocksPerDay for how much that assumption is
5304// allowed to be wrong). The chain does not actually run at that rate: 3.306 s measured
5305// on gno.land mainnet and 3.376 s on onyx-1. So a caller wanting a wall-clock countdown
5306// must multiply by a block time IT chooses and own the error; this realm will not
5307// return seconds or "time remaining", because that would bake one clock's rounding into
5308// every front end that reads it and make the contract the source of a number it cannot
5309// know. Nothing on chain converts blocks to seconds and neither does this.
5310//
5311// WHY THIS AND NOT AN EXPORTED launchHeight: it is the stronger primitive, not the
5312// weaker one. raffleWeekDays and blocksPerDay are both unexported, so a LaunchHeight()
5313// alone could not be turned into a boundary without a caller hardcoding 7*26182 and
5314// re-implementing the +1 above. This read goes the other way: launchHeight is
5315// WeekEndHeight(0) - (WeekEndHeight(1) - WeekEndHeight(0)) and the week's length is
5316// WeekEndHeight(1) - WeekEndHeight(0), so one function answers every question the
5317// constant would have, with the off-by-one computed once, here, on chain.
5318//
5319// NEGATIVE OR FAR-FUTURE week IS NOT REFUSED and returns the arithmetic for it, exactly
5320// as weekEndHeight does — this is a pure function of week and launchHeight with no
5321// record lookup, so there is no "no such week" state to report. WeekEndHeight(-1) is
5322// launchHeight, the height this realm's week 0 began at, which is a useful answer rather
5323// than an error.
5324func WeekEndHeight(week int64) int64 {
5325 return weekEndHeight(week)
5326}
5327
5328// CurrentWeekEndHeight is the chain height at which the LIVE week ends: the one number
5329// a countdown wants. WeekEndHeight(CurrentWeek()), read in a single call.
5330//
5331// IT IS ONE CALL ON PURPOSE. Composing the two exported reads off chain means two
5332// queries at two different chain heights, and a week that turns over between them yields
5333// a boundary for a week that has already ended — a countdown that jumps backwards by a
5334// whole week exactly at the moment it matters most. Read here, both halves see one
5335// height.
5336//
5337// A BLOCK HEIGHT, NOT A TIME, for the reasons on WeekEndHeight.
5338func CurrentWeekEndHeight() int64 {
5339 return weekEndHeight(currentWeek())
5340}
5341
5342// DrawOf reports a week's record: committed, frozen ticket total, beacon round,
5343// pot, resolved, winner, abandoned, sponsored. All zero for a week that never
5344// sold a ticket.
5345//
5346// ABANDONED IS PART OF THIS TUPLE rather than a separate read because it is not
5347// a detail: it is the difference between a week that is waiting for its
5348// randomness and a week that is paying its buyers back and will never have a
5349// winner. A front end that showed "unresolved" for both would be lying to a
5350// buyer about whether to expect a draw or to go and claim a refund.
5351//
5352// SPONSORED IS PART OF IT for a related reason: pot minus sponsored is what the
5353// week's buyers put in and could reclaim, and sponsored is what the arcade added
5354// on top. A buyer looking at a pot deserves to know which part of it is a claim
5355// of theirs and which is house money that will never be refunded.
5356//
5357// COMMITTED-AT IS THE LAST ELEMENT, and it is here so a front end can compute the
5358// one number a buyer of a stuck week actually wants: CommittedAt +
5359// refundGraceBlocks, the height at which their money becomes reclaimable whatever
5360// the beacon does. RefundOpensAt does that arithmetic; this returns the raw
5361// height so a caller can also show how long the week has been waiting. Zero on an
5362// uncommitted week, which is exactly the "no refund clock is running yet" case.
5363func DrawOf(week int64) (bool, int64, int64, int64, bool, address, bool, int64, int64) {
5364 d := getDraw(week)
5365 if d == nil {
5366 return false, 0, 0, 0, false, address(""), false, 0, 0
5367 }
5368 return d.Committed, d.FrozenTotal, d.Round, d.Pot, d.Resolved, d.Winner, d.Abandoned, d.Sponsored, d.CommittedAt
5369}
5370
5371// RefundGraceBlocks is refundGraceBlocks, EXPORTED so a front end can state the
5372// window without hardcoding it — the same reason TicketPrice is exported. Read
5373// refundGraceBlocks for what it means and how it was derived.
5374func RefundGraceBlocks() int64 { return refundGraceBlocks }
5375
5376// RefundOpensAt is the chain height from which Refund will pay this week's buyers
5377// REGARDLESS OF THE BEACON: CommittedAt + refundGraceBlocks.
5378//
5379// ZERO MEANS "THERE IS NO FUTURE HEIGHT TO WAIT FOR", which covers three unrelated
5380// states and a caller must not read any of them as "refundable now": no record for
5381// the week, not committed yet (nothing is locked up), already resolved (the pot has
5382// gone to its winner), or already abandoned (refunds are open this instant and the
5383// height is behind us). Render draws the same distinction, as "n/a".
5384//
5385// IT IS NOW AN EXACT HEIGHT, NOT A CEILING, and the change is worth noting because the
5386// comment used to hedge. It said refunds "may open much earlier through the
5387// beacon-derived half of Refund's gate — the moment the committed round falls outside
5388// beacon.PruneWindow unresolved". THAT HALF OF THE GATE NO LONGER EXISTS (see
5389// resolvable()'s removal), so this height is the only one at which a refund opens, and
5390// a front end can show it as a fact rather than as a worst case.
5391func RefundOpensAt(week int64) int64 {
5392 d := getDraw(week)
5393 if d == nil || !d.Committed || d.Resolved || d.Abandoned || d.CommittedAt <= 0 {
5394 return 0
5395 }
5396 return d.CommittedAt + refundGraceBlocks
5397}
5398
5399// SponsoredOf is the house money in a given week's pot: what FundPot added, and
5400// therefore the part of the pot no buyer can ever reclaim. Zero for a week that
5401// was never sponsored.
5402//
5403// THE SECOND HALF OF THIS COMMENT WAS FALSE, WAS CORRECTED, AND IS NOW TRUE AGAIN FOR A
5404// DIFFERENT REASON. It originally said "and zero again once an abandoned week's
5405// sponsorship has been moved to the ops pot"; that became wrong when the money started
5406// going forward to a future week (finding 16) and wrong twice over when it stopped moving
5407// at the moment of abandonment at all (finding 28).
5408//
5409// SINCE FINDING 31 THIS IS ZERO ON EVERY ABANDONED WEEK, and that is a guarantee rather
5410// than an accident: retireSponsorship zeroes the field in the same statement group that
5411// sets Abandoned, and AuditFailure reports any week where the two disagree. What the money
5412// became is a per-unit claim on the SAME week — read ClaimPerUnitOf, UnclaimedOf and
5413// ClaimableOf, and ClaimDeadWeek for the exit. A front end showing an abandoned week should
5414// show those three, not this one.
5415// SponsorsOf is the credit lines a week carries, in the order they were bought.
5416//
5417// A COPY, NOT THE SLICE. Returning d.Sponsors itself would hand a caller a
5418// reference into this realm's own storage; the copy is the same discipline as
5419// every other read here returning values rather than pointers.
5420func SponsorsOf(week int64) []Sponsorship {
5421 d := getDraw(week)
5422 if d == nil {
5423 return nil
5424 }
5425 out := make([]Sponsorship, len(d.Sponsors))
5426 copy(out, d.Sponsors)
5427 return out
5428}
5429
5430// SponsorCutOf is how much of a week's escrowed fee came from sponsorships
5431// rather than ticket sales. Zero on a week that has retired its sponsorship,
5432// which is what makes "the fee found its way out" checkable from outside.
5433func SponsorCutOf(week int64) int64 {
5434 d := getDraw(week)
5435 if d == nil {
5436 return 0
5437 }
5438 return d.SponsorCut
5439}
5440
5441func SponsoredOf(week int64) int64 {
5442 d := getDraw(week)
5443 if d == nil {
5444 return 0
5445 }
5446 return d.Sponsored
5447}
5448
5449// CarryPendingSince AND CarryPendingTotal ARE GONE — AUDIT-2026-09-28.md finding 31. They
5450// read the carry register, which no longer exists, and there is deliberately no successor
5451// with the same shape: "how much money is waiting to be moved somewhere" is not a question
5452// this realm can be asked any more, because no money is waiting and there is nowhere to
5453// move it to. The three reads below answer the questions that replace it.
5454
5455// ClaimPerUnitOf is the ugnot each ticket UNIT of a week may reclaim from the sponsorship
5456// that week's draw never paid out, or 0 for any week that is not abandoned. Read
5457// Draw.ClaimPerUnit and retireSponsorship.
5458//
5459// ZERO ON AN ABANDONED WEEK IS MEANINGFUL AND IS NOT AN ERROR: it means either that the
5460// week held no sponsorship at all, or that the sponsorship was smaller than the week's unit
5461// count, so no unit could be given a whole ugnot and the whole amount was sub-unit dust. The
5462// DeadWeekSponsorshipDistributed and DeadWeekSponsorshipToOps events distinguish the cases.
5463func ClaimPerUnitOf(week int64) int64 {
5464 d := getDraw(week)
5465 if d == nil {
5466 return 0
5467 }
5468 return d.ClaimPerUnit
5469}
5470
5471// UnclaimedOf is how much of a dead week's distributed sponsorship its holders have not
5472// taken yet — Draw.Claims. It is PART OF THAT WEEK'S POT, so PotSizeOf-style reads already
5473// include it; this read exists so an operator can see how much of a dead week's money is
5474// still sitting there, and so AuditFailure's heldClaims re-derivation can be read alongside
5475// it.
5476func UnclaimedOf(week int64) int64 {
5477 d := getDraw(week)
5478 if d == nil {
5479 return 0
5480 }
5481 return d.Claims
5482}
5483
5484// ClaimableOf is what `addr` may take out of a dead week right now: their EARNED unit weight
5485// times the week's claim rate, or 0 once they have taken it. IT IS EXACTLY WHAT ClaimDeadWeek
5486// WILL PAY, derived the same way and off the same tree, so a front end never has to guess.
5487//
5488// IT READS earnedOf AND NOT ticketsOf — AUDIT-2026-09-28.md finding 34, and this read had to
5489// move in the same commit as ClaimDeadWeek's. A read that disagrees with the entry point it
5490// mirrors is worse than no read at all: a front end would have shown a refunded buyer a
5491// positive claimable figure and then watched their transaction abort, which is how a caller
5492// learns to distrust the whole realm. THE TWO DERIVATIONS ARE NOW ONE EXPRESSION, DUPLICATED
5493// — earnedOf times ClaimPerUnit — and every test that asserts ClaimDeadWeek's return value
5494// against this read is what keeps them equal.
5495//
5496// A BUYER WHO HOLDS ONLY BOUGHT UNITS READS ZERO HERE, AND THAT IS THE ANSWER, NOT A GAP.
5497// Their money comes back through Refund, in full, fee included. Read EarnedOfWeek alongside
5498// TicketsOfWeek to see the two kinds of weight separately.
5499func ClaimableOf(week int64, addr address) int64 {
5500 d := getDraw(week)
5501 if d == nil || d.ClaimPerUnit <= 0 {
5502 return 0
5503 }
5504 k := string(addr)
5505 if d.claimedOf.Has(k) {
5506 return 0
5507 }
5508 return treeInt64(d.earnedOf, k) * d.ClaimPerUnit
5509}
5510
5511// EarnedOfWeek is how many of `addr`'s units in a week were EARNED through duel play rather
5512// than bought — the basis a dead week's sponsorship is divided on. AUDIT-2026-09-28.md
5513// finding 34.
5514//
5515// IT EXISTS SO THE TWO KINDS OF WEIGHT ARE VISIBLE SEPARATELY. TicketsOfWeek returns the
5516// draw's basis (both kinds, indistinguishable, which is finding 15's design) and this returns
5517// the claim's. A front end showing a dead week needs both, because "you hold 412 units and
5518// your claim is on 12 of them" is the only explanation of a zero ClaimableOf that a buyer
5519// will accept.
5520func EarnedOfWeek(week int64, addr address) int64 {
5521 d := getDraw(week)
5522 if d == nil {
5523 return 0
5524 }
5525 return treeInt64(d.earnedOf, string(addr))
5526}
5527
5528// EarnedTotalOfWeek is a week's whole earned unit count — the divisor retireSponsorship used,
5529// or will use. Zero on a week whose every unit was bought, which is the state that routes a
5530// dead week's sponsorship to opsPot rather than to holders.
5531func EarnedTotalOfWeek(week int64) int64 {
5532 d := getDraw(week)
5533 if d == nil {
5534 return 0
5535 }
5536 return d.earnedTotal
5537}
5538
5539// UnclaimedHoldersOf is how many named addresses a dead week still owes a share to, and it is
5540// the other half of AUDIT-2026-09-28.md finding 37 — the audit's open question, "let an
5541// operator distinguish heldClaims AWAITING LIVE HOLDERS from heldClaims FROZEN FOR EVER".
5542//
5543// heldClaims ON ITS OWN CANNOT ANSWER THAT and neither can UnclaimedOf: a positive number
5544// means the same thing in both cases. This read turns the amount into a COUNT OF PEOPLE, so
5545// "3,600,000 ugnot owed to 24 addresses" reads differently from "3,600,000 ugnot owed to 24
5546// addresses that have not moved in a year", and an operator can at least see which weeks are
5547// settled and which are not. The DeadWeekFullyClaimed event in ClaimDeadWeek is the push
5548// version of the same fact, for a week that reaches zero.
5549//
5550// IT IS O(EARNED HOLDERS) AND THEREFORE A READ-ONLY FUNCTION AND NOTHING ELSE. It is never
5551// called from a state-changing path — that is the whole reason retireSponsorship writes a
5552// rate instead of a tree — and a caller who pays the gas to ask is paying for their own
5553// answer. COLLECT-THEN-LOOP, NEVER A NESTED avl.Iterate: the earnedOf keys are gathered in
5554// one pass and every claimedOf lookup happens after it, because nesting a second traversal
5555// inside the first's callback crashes this toolchain's VM outright (see AuditFailure).
5556//
5557// ZERO MEANS "NOTHING LEFT TO PAY" and is the answer on a live week, a resolved week, a week
5558// that never distributed anything, and a week every holder has claimed out.
5559func UnclaimedHoldersOf(week int64) int64 {
5560 d := getDraw(week)
5561 if d == nil || d.ClaimPerUnit <= 0 {
5562 return 0
5563 }
5564 keys := []string{}
5565 d.earnedOf.Iterate("", "", func(k string, _ any) bool {
5566 keys = append(keys, k)
5567 return false
5568 })
5569 n := int64(0)
5570 for _, k := range keys {
5571 if !d.claimedOf.Has(k) {
5572 n++
5573 }
5574 }
5575 return n
5576}
5577
5578// Unclaimed is the sum of every week's unclaimed dead-week shares — the aggregate
5579// heldClaims, exposed so it can be read without iterating. It is already inside the pots, so
5580// it is not a fourth balance.
5581func Unclaimed() int64 {
5582 return heldClaims
5583}
5584
5585// Sponsored is the house money in the CURRENT week's pot.
5586func Sponsored() int64 {
5587 return SponsoredOf(currentWeek())
5588}
5589
5590// CapturedRandomness returns what Capture stored for a week, or the empty
5591// string if nothing was captured. Kept out of DrawOf so DrawOf's shape stays
5592// stable for callers.
5593func CapturedRandomness(week int64) string {
5594 d := getDraw(week)
5595 if d == nil {
5596 return ""
5597 }
5598 return d.Randomness
5599}
5600
5601// OpsPot is the house's EARNED revenue this realm holds, in ugnot: the fees of weeks
5602// that actually drew, plus absorbed donations, plus any abandoned week's sponsorship
5603// that had no live week to roll into.
5604//
5605// IT IS NOT "THE ACCUMULATED HOUSE CUT" ANY MORE, which is what this comment said until
5606// 2026-09-29. A live week's fee is NOT in here — it is escrowed in that week and is
5607// still refundable. Read EscrowedCut and HeldCuts for the unearned half, and finding 25
5608// for why the two are separate numbers.
5609func OpsPot() int64 {
5610 return opsPot
5611}
5612
5613// EscrowedCut is the house fee held against one week and NOT YET EARNED, in ugnot: it
5614// becomes ops revenue if that week resolves and goes back to the buyers if it does not.
5615// Zero for a week that has resolved, been fully refunded, or never sold a ticket.
5616func EscrowedCut(week int64) int64 {
5617 d := getDraw(week)
5618 if d == nil {
5619 return 0
5620 }
5621 return d.Cut
5622}
5623
5624// HeldCuts is the total unearned house fee this realm holds across every week, in ugnot
5625// — the aggregate of EscrowedCut and the third term in the solvency equality.
5626func HeldCuts() int64 {
5627 return heldCuts
5628}
5629
5630// --- enumerating the weeks -----------------------------------------------
5631//
5632// WHY A LISTER EXISTS AT ALL. Every other read here needs a week number the
5633// caller already has, and Render is prose. A front end looking for weeks to
5634// sweep (see SweepDrawlessWeek) had no way to find them except by parsing the
5635// rendered table, which is a human-readable format with no stability promise.
5636// Same shape as r/splash/arcade/duel's OfferIDs / DuelIDs, down to the clamp:
5637// an offset and a limit in, at most `limit` week numbers out, `limit` clamped to
5638// PageLimit rather than refused. Week numbers only — DrawOf, SponsoredOf and
5639// TicketTotalOfWeek are the per-week readers, and a batched row reader would mean
5640// inventing a second serialisation of the same page for callers to parse.
5641
5642// PageLimit is the most week numbers one Weeks call returns. A larger limit is
5643// clamped to it, not refused.
5644const PageLimit int64 = 100
5645
5646// WeekCount is the number of weeks that have a record: weeks that sold a ticket
5647// or were sponsored. O(1); avl keeps its size.
5648func WeekCount() int64 {
5649 return int64(draws.Size())
5650}
5651
5652// Weeks is a window over the recorded week numbers in ASCENDING ORDER: it skips
5653// `offset` weeks and holds at most min(limit, PageLimit). Empty past the end.
5654// Negative arguments are a caller error and panic.
5655//
5656// Ascending order is week order, not merely key order, because weekKey is
5657// fixed-width and zero-padded (see keyWidth) — the same property Render relies on.
5658//
5659// A CALLER FINDS THE SWEEPABLE WEEKS by walking this and keeping those where
5660// week < CurrentWeek(), TicketTotalOfWeek(week) == 0 and SponsoredOf(week) > 0.
5661func Weeks(offset, limit int64) []int64 {
5662 if offset < 0 {
5663 panic("raffle: negative offset")
5664 }
5665 if limit < 0 {
5666 panic("raffle: negative limit")
5667 }
5668 if limit > PageLimit {
5669 limit = PageLimit
5670 }
5671 remaining := int64(draws.Size()) - offset
5672 if remaining < limit {
5673 limit = remaining
5674 }
5675 if limit <= 0 {
5676 return []int64{}
5677 }
5678 weeks := make([]int64, 0, limit)
5679 // IterateByOffset is avl's own offset-and-count traversal, O(log n + limit);
5680 // it is not a full scan followed by a slice.
5681 draws.IterateByOffset(int(offset), int(limit), func(key string, _ any) bool {
5682 weeks = append(weeks, weekOfKey(key))
5683 return false
5684 })
5685 return weeks
5686}
5687
5688// weekOfKey inverts weekKey. ParseInt reads the zero padding fine; a key that
5689// does not parse cannot have come from weekKey, so it is a corrupt tree and not
5690// a case to paper over.
5691func weekOfKey(key string) int64 {
5692 week, err := strconv.ParseInt(key, 10, 64)
5693 if err != nil {
5694 panic("raffle: malformed week key " + key)
5695 }
5696 return week
5697}
5698
5699// Render is the human-readable view. It ITERATES THE avl.Tree in key order,
5700// which is week order because weekKey is fixed-width and zero-padded, so the
5701// output is byte-identical across runs. A Go map here would randomise the order
5702// and make the rendered state unreproducible.
5703// sponsorText makes a stranger's credit line safe to put in this realm's markdown.
5704//
5705// TWO ESCAPES, NOT ONE. sanitize.InlineText handles the markdown and HTML
5706// metacharacters; the pipe is escaped separately because Render's per-week table
5707// is pipe-delimited and a label is allowed to contain '|' — 64 bytes is ample to
5708// forge a cell and fake a column. The list below is not a table today, and the
5709// pipe escape is here anyway so that moving this text into one later cannot
5710// reintroduce the hole. Same two-step, and the same reasoning, as the duel's.
5711//
5712// THE ESCAPING IS HERE AND NOT IN Sponsor DELIBERATELY: the stored value stays
5713// exactly what the sponsor typed, so every reader holds one thing rather than
5714// having to know whether their copy was pre-escaped.
5715func sponsorText(s string) string {
5716 return strings.ReplaceAll(sanitize.InlineText(s), "|", "\\|")
5717}
5718
5719// renderSponsors lists a week's credit lines — the thing a sponsorship actually
5720// buys. Empty string when there are none, so the section simply does not appear.
5721func renderSponsors(week int64) string {
5722 d := getDraw(week)
5723 if d == nil || len(d.Sponsors) == 0 {
5724 return ""
5725 }
5726 out := "\n### Sponsored by\n\n"
5727 for _, sp := range d.Sponsors {
5728 out += "- **" + sponsorText(sp.Label) + "** — " + strconv.FormatInt(sp.Amount, 10) + " ugnot\n"
5729 }
5730 out += "\nA sponsorship raises the prize and buys no entries: a sponsor cannot win this draw.\n"
5731 return out
5732}
5733
5734func Render(path string) string {
5735 i64 := strconv.FormatInt
5736
5737 out := "# SPLASH weekly raffle\n\n"
5738 out += "Tickets are " + i64(TicketPrice, 10) + " ugnot each (wrapped GNOT, 6 decimals: 1 GNOT = 1000000 ugnot).\n"
5739 out += "One ticket is " + i64(unitsPerTicket, 10) + " units of draw weight, at " + i64(EntryUnitPrice, 10) + " ugnot a unit.\n"
5740 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"
5741 out += "THE HOUSE FEE IS HELD IN ESCROW PER WEEK AND IS EARNED ONLY BY A DRAW: a week that is refunded returns the FULL ticket price.\n\n"
5742 out += "The arcade's rake is sponsored straight into this week's pot, so every duel makes the prize bigger.\n\n"
5743 out += "- current week: " + i64(currentWeek(), 10) + "\n"
5744 out += "- this week's pot: " + i64(PotSize(), 10) + " ugnot\n"
5745 out += "- of which sponsored: " + i64(Sponsored(), 10) + " ugnot\n"
5746 out += "- this week's tickets: " + i64(TicketTotal(), 10) + "\n"
5747 out += "- held across all unresolved weeks: " + i64(heldPots, 10) + " ugnot\n"
5748 out += "- house fees held in escrow, NOT yet earned and still refundable: " + i64(heldCuts, 10) + " ugnot\n"
5749 out += "- ops pot (earned): " + i64(opsPot, 10) + " ugnot\n"
5750 // THE UNCLAIMED DEAD-WEEK SHARES, SHOWN BECAUSE THEY ARE MONEY IN A DEAD WEEK'S POT AND A
5751 // READER WOULD OTHERWISE HAVE TO SUBTRACT THEM OUT BY HAND. Already inside "held across
5752 // all unresolved weeks" above, so this line is a breakdown and not a fourth term.
5753 //
5754 // THE LINE IT REPLACES WAS "sponsorship awaiting the next week to open" (finding 28's
5755 // register). Nothing awaits a week any more — finding 31 — so the question the old line
5756 // answered has no answer and the new one names the holders' claim instead.
5757 out += "- dead weeks' sponsorship owed back to their sponsors, not yet claimed: " +
5758 i64(heldSponsorRefunds, 10) + " ugnot\n"
5759 out += "- dead weeks' sponsorship returned to their own holders, not yet claimed: " +
5760 i64(heldClaims, 10) + " ugnot\n"
5761 out += "- weeks recorded: " + strconv.Itoa(draws.Size()) + "\n"
5762 out += renderSponsors(currentWeek())
5763 // THE TWO NUMBERS A BUYER OF A STUCK WEEK NEEDS. The grace window is stated
5764 // once here and the per-week table below gives the height it lands on, so a
5765 // front end can tell a buyer when their money becomes reclaimable even if the
5766 // beacon never publishes their round again. See refundGraceBlocks.
5767 out += "- current chain height: " + i64(runtime.ChainHeight(), 10) + "\n"
5768 out += "- refunds open unconditionally " + i64(refundGraceBlocks, 10) +
5769 " blocks after a week is committed (about " + i64(refundGraceDays, 10) +
5770 " days), whatever the beacon does\n"
5771
5772 if draws.Size() == 0 {
5773 out += "\nNo tickets have been sold yet.\n"
5774 return out
5775 }
5776
5777 // THE DEAD-WEEK COLUMNS ARE NEW SINCE FINDING 31 AND ARE NOT DECORATION. On an abandoned
5778 // week `sponsored` is now always 0 — retireSponsorship zeroes it — so without these two a
5779 // front end reading this table would see a week with a POT, no sponsorship and no buyer
5780 // claims and have no way to learn that the money is claimable, or by whom. "unclaimed" is
5781 // what the week still owes its holders and "per unit" is the rate to multiply their weight
5782 // by; ClaimableOf answers it per address. A pot nobody can see a claim on is how money
5783 // goes missing quietly, which is the same sentence Draw.Cut carries.
5784 out += "\n| week | tickets | pot | sponsored | unclaimed | per unit | escrowed fee | committed | round | captured | winner | abandoned | refund opens at |\n"
5785 out += "|---|---|---|---|---|---|---|---|---|---|---|---|---|\n"
5786 draws.Iterate("", "", func(_ string, value any) bool {
5787 d := value.(*Draw)
5788 committed := "no"
5789 total := d.tickets.Total()
5790 if d.Committed {
5791 committed = "yes"
5792 total = d.FrozenTotal
5793 }
5794 captured := "no"
5795 if d.Randomness != "" {
5796 captured = "yes"
5797 }
5798 winner := "unresolved"
5799 if d.Resolved {
5800 winner = string(d.Winner)
5801 }
5802 // ABANDONED IS SHOWN, and shown separately from the winner column: a
5803 // reader must be able to tell a week still waiting for its randomness
5804 // from one that is refunding and will never be drawn.
5805 abandoned := "no"
5806 if d.Abandoned {
5807 abandoned = "yes"
5808 if !d.Resolved {
5809 winner = "refunding"
5810 }
5811 }
5812 // THE HEIGHT A BUYER CAN STOP WAITING AT, per week. "n/a" on a week with
5813 // no refund clock running: uncommitted (nothing is locked yet), already
5814 // resolved (the pot has gone to its winner), or already abandoned (refunds
5815 // are open now and this number is behind us). A bare height rather than a
5816 // countdown, because Render must be byte-identical for the same state and
5817 // a countdown would also have to be recomputed by every reader anyway.
5818 refundOpens := "n/a"
5819 if d.Committed && !d.Resolved && !d.Abandoned && d.CommittedAt > 0 {
5820 refundOpens = i64(d.CommittedAt+refundGraceBlocks, 10)
5821 }
5822 out += "| " + i64(d.Week, 10) +
5823 " | " + i64(total, 10) +
5824 " | " + i64(d.Pot, 10) +
5825 " | " + i64(d.Sponsored, 10) +
5826 " | " + i64(d.Claims, 10) +
5827 " | " + i64(d.ClaimPerUnit, 10) +
5828 " | " + i64(d.Cut, 10) +
5829 " | " + committed +
5830 " | " + i64(d.Round, 10) +
5831 " | " + captured +
5832 " | " + winner +
5833 " | " + abandoned +
5834 " | " + refundOpens + " |\n"
5835 return false
5836 })
5837 return out
5838}