Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

v2 source realm

Package raffle is the SPLASH weekly ticket raffle.

Overview

Package raffle is the SPLASH weekly ticket raffle.

WHAT THIS REPLACES, AND WHY IT IS A SEPARATE REALM.

r/splash/game is a permanent-deposit yield product: you deposit GNOT, you can never withdraw, your position size IS your ticket count, and winning the weekly draw ADDS the pot to your locked position rather than paying your wallet. Its raffle had to live inside that realm because the draw mutated three pieces of that realm's state atomically (see the long comment at the top of r/splash/game/raffle.gno).

This realm sells TICKETS, not positions. A ticket costs TicketPrice and buys exactly unitsPerTicket units of one week's draw — one ticket was one unit until 2026-09-29, and EntryUnitPrice says why the two numbers had to come apart; the pot pays out to the winner's WALLET; and nothing a buyer paid is held beyond the week they bought into. Because there is no position, no yield index and no permanent deposit, the draw needs nothing from r/splash/game and this realm holds no authority over it, over r/splash/beacon, or over r/gnoland/wugnot.

THERE IS EXACTLY ONE PACKAGE-PATH GUARD IN THIS FILE, AND UNTIL 2026-09-29 THERE WERE NONE. The sentence that used to be here — "there is no package-path guard anywhere in this file and nothing for one to protect" — is no longer true and is not quietly deleted. FundPotWithEntries admits r/splash/arcade/duel and nobody else, because duel play now EARNS entries in the live week's draw (AUDIT-2026-09-28.md finding 15). Read that function in full before touching it: it is the only privileged door in this product, it is the shape r/splash/game declined to add five times, and what keeps it small is that everything it can do is bounded by the money delivered through it in the same call. Every OTHER entry point here is still permissionless and can still only do the one thing the week's state machine allows next.

THE MECHANICS ARE r/splash/game/raffle.gno's, DELIBERATELY UNCHANGED: the week number derived from the global clock, the Commit/Capture/Resolve three-phase cycle, and above all the FROZEN-TOTAL property. Read Commit below before changing anything: freezing the ticket total is what stops a buyer from waiting for the randomness to publish and then buying exactly the winning ticket index, and that attack was live in an earlier version of the old realm.

ONE MECHANIC IS NOT THE OLD REALM'S ANY MORE: WHICH ROUND DECIDES A WEEK. The old realm — and this one until 2026-09-28 — bound Round = beacon.Latest() + 1 inside Commit, which handed the round choice to whoever called Commit, AFTER the week's ticket set was already frozen and public. The publisher's key can sign any round at will and beacon.Publish accepts rounds in any order, so that was a deterministic, risk-free win for the key holder. The round is now PINNED WHEN THE WEEK'S RECORD IS CREATED — before the first ticket of that week exists — and is never written again. See getOrCreateDraw and pinOffsetRounds.

THAT DOES NOT MAKE THE DRAW TRUSTLESS, and nothing in this file should be read as claiming it does. The beacon's key holder still knows every round's randomness in advance, so they can still rig a week by choosing the MODULUS — buying last, just before the week closes, to move FrozenTotal and the unit ranges. Pinning removes the round-selection freedom, which was the cheap, deniable and exact version of the attack; the residual one leaves an on-chain trace as a suspicious late purchase. THE ARCHITECTURAL PROBLEM REMAINS OPEN and is the owner's decision: closing it needs an externally-controlled beacon or a VRF. See AUDIT-2026-09-28.md findings 4 and 10.

THE ARCADE FEEDS THIS POT, AND SINCE 2026-09-29 IT ALSO FILLS THE DRAW. r/splash/arcade/duel sends its rake here, so every coin flip makes this week's prize bigger — that is what makes the duel and the raffle one product instead of two contracts. The rake itself still buys no tickets and no winning chance: it is tracked separately as Draw.Sponsored, because the refund safety property is the identity Pot == sum(spends) + Sponsored and house money belongs to no buyer. Read Draw.Sponsored and FundPot together before changing either.

WHAT CHANGED IS THAT THE PLAYERS WHO PAID THAT RAKE NOW ARRIVE WITH IT. Until 2026-09-29 the money came and the entrants did not, so a week of busy coin flipping and no ticket sales could be won outright by one cheap ticket bought in its final block — the whole pot, to somebody who had contributed none of it. FundPotWithEntries is the fix: the duel delivers the same rake and, in the same call, the two players of each settled duel are credited entries worth EXACTLY what that rake would have bought at EntryUnitPrice. The money and the entrants now arrive together. Earned entries carry ticket weight and NO spendOf record, which is consistent with every identity in this file and is spelled out at FundPotWithEntries. AUDIT-2026-09-28.md finding 15.

WHAT IS NEW, and could not have existed in the old design: Refund. There, the pot was accrued protocol fees and nobody's stake was ever at risk, so an unresolvable draw could simply wait forever. Here the pot is buyers' own money, so a week nobody settles must have a way back out. SINCE THE DRAND MIGRATION THAT IS A LIVENESS EXIT AND NOT A LOSS EXIT — no round is ever unobtainable, so what Refund guards against is nobody bothering to relay one. See Refund.

Constants 2

const TicketPrice, EntryUnitPrice, unitsPerTicket, HouseCutBps, bpsDenom, cutPerTicket, MinSponsorship, maxSponsorLabelLen, maxSponsorsPerWeek

  1const (
  2	// TicketPrice is the cost of one ticket in ugnot: 10 GNOT. Money here is
  3	// wrapped GNOT (r/gnoland/wugnot), a GRC20 with 6 decimals, so 1 GNOT is
  4	// 1_000_000 ugnot and every amount in this file is an int64 count of ugnot.
  5	//
  6	// EXPORTED because a front end must be able to quote the price without
  7	// hardcoding it, and because a buyer's cost is tickets * this and nothing
  8	// else — there is no dynamic pricing, no discount tier and no bonding curve.
  9	//
 10	// IT IS NO LONGER THE GRANULARITY OF THE DRAW, AND THAT SEPARATION IS THE WHOLE
 11	// POINT OF EntryUnitPrice BELOW. Until 2026-09-29 this one constant was both "what
 12	// a ticket costs" and "what one unit of winning weight is worth", and raising it
 13	// from 0.1 to 10 GNOT would have silently deleted the earned-entry feature for
 14	// three of the duel's four rungs. AUDIT-2026-09-28.md finding 22.
 15	TicketPrice int64 = 10_000_000
 16
 17	// EntryUnitPrice is the value of ONE UNIT OF WEIGHT IN THE DRAW: 0.25 GNOT.
 18	//
 19	// IT IS THE SECOND, INDEPENDENT PRODUCT PARAMETER THAT USED TO SHARE TicketPrice'S
 20	// CONSTANT, and the numbers that forced them apart are worth keeping here because
 21	// they are what a future edit will recreate:
 22	//
 23	//	The duel's players-side rake per rung is 0.36 / 1.80 / 7.20 / 36.00 GNOT
 24	//	(after the 60/40 split), off rakes of 0.6 / 3 / 12 / 60 GNOT. An earned entry
 25	//	is priced at what the RAKE would have bought (r/splash/arcade/duel's
 26	//	rakeEntryUnits — that equality is the security property of the whole feature).
 27	//	Price an entry at a 10 GNOT TICKET and rungs 0, 1 and 2 — 0.6, 3 and 12 GNOT
 28	//	of rake — earn 0, 0 and 1 entries. THREE OF THE FOUR RUNGS EARN NOTHING, and
 29	//	finding 15's fix, which exists so that the people whose rake built the pot are
 30	//	in the draw for it, dies for everyone but whales.
 31	//
 32	// So the two numbers are separated: a TICKET is a price, an ENTRY UNIT is a
 33	// granularity, and one ticket is simply worth unitsPerTicket of them. Everything
 34	// downstream of appendTickets — the fenwick index, ownerOfUnit, randomnessMod,
 35	// FrozenTotal — has always worked in arbitrary units of weight and needed no
 36	// change at all; what had to change is every place that turned money into weight
 37	// or weight into money.
 38	//
 39	// EXPORTED for the same reason TicketPrice is: r/splash/arcade/duel prices its
 40	// earned entries against it, and a front end must be able to quote "one ticket is
 41	// 40 entries" without hardcoding either number.
 42	EntryUnitPrice int64 = 250_000
 43
 44	// unitsPerTicket is how many units of draw weight one ticket buys: 40.
 45	//
 46	// DERIVED, NEVER WRITTEN OUT, and the build guard below refuses any pair for which
 47	// it is not exact — see there for why dust in the weight domain would be
 48	// unrecoverable rather than merely untidy.
 49	unitsPerTicket = TicketPrice / EntryUnitPrice
 50
 51	// HouseCutBps is the share of every ticket sale that is the house's fee rather
 52	// than prize money: 500 bps, 5%.
 53	//
 54	// IT IS ESCROWED PER WEEK AND EARNED ONLY BY A DRAW, since 2026-09-29. Buy no
 55	// longer credits opsPot; it credits Draw.Cut, and that money reaches opsPot only
 56	// on a successful Resolve. A week that never draws refunds it as part of each
 57	// buyer's GROSS ticket price. AUDIT-2026-09-28.md finding 25.
 58	//
 59	// EXPORTED ON 2026-09-30, AND FOR A REASON THAT IS NOT A FRONT END'S CONVENIENCE.
 60	// r/splash/arcade/duel's earned entries are safe only while flipping is never CHEAPER
 61	// than buying, and the two prices meet in the amount of PRIZE MONEY each route puts
 62	// behind one unit of draw weight: a bought unit delivers
 63	// EntryUnitPrice x (bpsDenom - HouseCutBps) / bpsDenom, a flipped one
 64	// EntryUnitPrice x rakePotShareBps / bpsDenom. So THIS CONSTANT IS ONE OF THE TWO
 65	// TERMS OF THE DUEL'S SECURITY ARGUMENT, and the duel now carries a build guard that
 66	// refuses a rake split which would invert it (see its guard beside rakePotShareBps,
 67	// and AUDIT-duel-2026-09-27.md addendum 2, finding D-2). A guard cannot be written
 68	// against a constant it cannot name.
 69	//
 70	// IT IS A COMPILE-TIME CONST AND NOT A FUNCTION, DELIBERATELY, exactly as TicketPrice
 71	// and EntryUnitPrice are: the duel reads it in an array length and in comments, so it
 72	// must add NO runtime dependency on this realm to any path over there — which is the
 73	// property finding 14 rests on (see the duel's checkInvariant).
 74	HouseCutBps int64 = 500
 75	bpsDenom    int64 = 10_000
 76
 77	// cutPerTicket is the house fee on ONE ticket: 500_000 ugnot.
 78	//
 79	// IT EXISTS FOR THE BUILD GUARD, NOT FOR THE ARITHMETIC. Buy still computes its
 80	// cut with mulDiv over the whole cost, exactly as it always did. What this
 81	// constant and its guard establish is that the cut is EXACTLY PROPORTIONAL to the
 82	// number of tickets — no flooring anywhere — which is precisely the property
 83	// Refund's per-buyer split needs in order to be exact. Read the guard.
 84	cutPerTicket = TicketPrice * HouseCutBps / bpsDenom
 85
 86	// MinSponsorship is the floor on one Sponsor call: ten tickets' worth.
 87	//
 88	// IT IS A PRODUCT RULE, NOT A SAFETY ONE — the owner's, 2026-09-30: "it should be
 89	// a meaningful set amount, should be a threshold for sponsorship". A credit line
 90	// beside the prize is advertising, and advertising bought for dust is spam. The
 91	// figure is a hundred tickets so it reads against a number a player already
 92	// knows, rather than being a new denomination to learn.
 93	//
 94	// IT WAS TEN TICKETS FOR ABOUT AN HOUR AND THAT WAS TOO CHEAP IN REAL MONEY.
 95	// GNOT was $0.074 on 2026-09-30, so ten tickets is ~$7.40 — the price of putting
 96	// your name beside a prize for a week, which is not a price. The owner: "it's too
 97	// cheap, needs to be 10x at least". A hundred tickets is ~$74.
 98	//
 99	// AND IT IS A GNOT FIGURE, SO ITS REAL VALUE MOVES. Anyone revisiting this should
100	// price it in fiat on the day rather than trusting the number: at a 10x in GNOT
101	// this floor becomes ~$740 without anybody deciding that.
102	//
103	// NOTE WHAT IT DOES NOT GATE. FundPot — the duel's door — has no floor and must
104	// not gain one: a flush carries whatever rake has accumulated, frequently far less
105	// than this, and a floor there would strand it. The two doors are separate
106	// precisely so this constant can exist.
107	MinSponsorship int64 = 100 * TicketPrice
108
109	// maxSponsorLabelLen bounds the credit line, in BYTES rather than runes, because
110	// a byte bound is what makes the storage cost predictable. Same figure and same
111	// reasoning as the duel's label.
112	maxSponsorLabelLen = 64
113
114	// maxSponsorsPerWeek bounds the list Render walks and the storage one week can be
115	// made to hold. At MinSponsorship each slot costs a sponsor 100 GNOT, so filling a
116	// week costs 1,600 GNOT — the bound is belt-and-braces over a price that already
117	// makes flooding expensive, and it is here so the cost of a full week is a fixed
118	// number rather than whatever an attacker's budget happens to be.
119	maxSponsorsPerWeek = 16
120)
source

const PageLimit

1const PageLimit int64 = 100
source

PageLimit is the most week numbers one Weeks call returns. A larger limit is clamped to it, not refused.

Functions 48

func ArcadeEntryUnitsBound

Action
1func ArcadeEntryUnitsBound(amount int64) int64
source

arcadeEntryUnitsBound is the MOST entry units a delivery of `amount` ugnot of duel rake pot share may ever carry, and it is the security property of finding 15 stated in one line of arithmetic at the door.

THE DERIVATION. The duel's rake splits potShare = rake - floor(rake * 4000 / 10000), so potShare >= rake * 6000/10000 and therefore

Example
1rake <= amount * arcadeBpsDenom / arcadeRakePotShareBps

for a delivery of amount = potShare. Rule 1 prices an entry at ONE EntryUnitPrice of rake, so the unit ceiling is that gross-up divided by EntryUnitPrice. At rung 0: amount 360000 -> gross 600000 -> 2 units, which is EXACTLY what the duel credits, so this bound is tight rather than generous and one unit of over-issuance fails it.

IT IS PRICED PER ENTRY UNIT AND NOT PER TICKET, SINCE 2026-09-29, AND THAT IS FINDING 22. Divided by a 10 GNOT TicketPrice this ceiling would be 0 at rung 0, 0 at rung 1 and 1 at rung 2 — it would refuse the duel's own honest batch at three of the four rungs, and a refused batch is a FlushPot that can never land, i.e. rake that reaches this realm's prize pot never at all. The ceiling and the duel's own rakeEntryUnits have to be denominated in the same unit or one of them is wrong; the unit is EntryUnitPrice.

AND NOTE WHAT IT IS *NOT*: floor(amount / EntryUnitPrice), i.e. the units the DELIVERED money alone would buy. That is 1 unit at rung 0 (360000 / 250000), and it would refuse the honest 2-unit batch for exactly the reason the gross-up exists — this realm receives the players' share and prices entries off the whole rake. It is alternative 2 from the block above duelRealmPkgPath, rejected there because it cannot express the owner's pricing rule.

IT NEVER REFUSES AN HONEST BATCH, INCLUDING AN AGGREGATED ONE. FlushPot delivers the sum of several duels' pot shares against the sum of their entries. Each duel's rake_i <= potShare_i * 1.25 exactly, sum(rake_i) is an integer no greater than amount * 1.25, and floor() of a real bound is still >= any integer below it — so the aggregate bound is never smaller than the aggregate claim. Dust can only ever make the bound looser, never tighter.

EXPORTED so the duel's suite can pin the two realms' arithmetic to each other (see arcadeRakePotShareBps), and so anybody can check the rate off-chain. A plain read: no state, no authority, nothing it can be called for.

func AuditFailure

Action
1func AuditFailure() string
source

AuditFailure re-derives the invariant BY ITERATION and reports the empty string when the books balance, otherwise which check failed and by how much. Never called from a state-changing path — checkInvariant is the O(1) guard for that — and exists so anyone can verify off-chain, and so a test can assert it after every operation.

IT MUST BE ROBUST AGAINST ANYONE WHO CAN SEND THIS REALM TOKENS, which is everyone: wugnot.Transfer needs no permission. So the balance test below is a one-sided one — a shortfall is a violation, an unswept surplus is not. See the comment on that check.

func Buy

crossing Action
1func Buy(cur realm, tickets int64)
source

Buy sells `tickets` tickets in the CURRENT week to the caller.

Cost is tickets * TicketPrice, pulled from the caller with the GRC20 allowance path, so the caller wraps native ugnot with wugnot.Deposit and approves this realm first. HouseCutBps of the cost is ESCROWED AGAINST THIS WEEK in Draw.Cut; the remainder — and all integer-division dust, since the cut is floored — joins this week's prize pot.

IT SAID "goes to the ops pot" UNTIL NOW AND THAT HAS BEEN FALSE SINCE 2026-09-29. A sale banks nothing for the house: the fee is unearned until the week actually draws, Resolve is what moves it to opsPot, and a week that fails hands it back as part of each buyer's gross refund. The distinction is the whole of finding 25 — "we earn when the game runs, not when it fails" — and a comment that says the money is already the house's describes a realm that cannot refund in full. AUDIT-2026-09-28.md finding 29.

EVERY CHECK RUNS BEFORE THE PULL, and the week record is created only after it, so a failed Buy moves nothing and creates nothing.

Each purchase APPENDS its own ticket index rather than growing an existing one. That is not a style choice: it is the property Commit depends on, and the reason is spelled out there.

THE WEEK'S FIRST BUY PINS THE WEEK'S BEACON ROUND, as a side effect of creating the record (getOrCreateDraw). That is deliberate and is the only moment the round is ever chosen: read pinOffsetRounds. Two consequences a caller can see. First, a sale needs the beacon to have published at least one round ever, or it is refused with the beacon's own "no round published yet" — which is a refused sale rather than money accepted into a week that could never be drawn. Second, the buyer who opens a week does not get to pick anything useful: the round is fixed a week and more ahead of the moment the ticket set closes, so at the instant it is pinned there is no ticket set for it to be advantageous against.

Panics if tickets is not positive, if the cost overflows int64, if the allowance is short, or if THIS WEEK IS ALREADY COMMITTED — the last of which is the frozen-total rule, not a convenience check. Once a week's total is frozen, no further ticket may join it.

THAT LAST REFUSAL IS NOW UNREACHABLE, and is kept deliberately. Commit only accepts a week that is already over, and Buy only ever sells into currentWeek(), so the live week cannot be in a committed state — which is exactly why a commit can no longer shut a week's sales down early. The check stays as a second lock on the frozen-total rule: if the week's identity ever stops being "the one the clock is on", this is the line that still refuses to grow a frozen ticket set.

ONLY A USER MAY BUY, AND THE REASON IS THE PAYOUT, NOT THE PURCHASE. A ticket is a claim on two transfers this realm will later push OUT: Resolve's whole pot to the winner's address, and Refund's spend back to the buyer's. Both go to cur.Previous().Address(), which for a realm caller is that realm's package-derived address — an address whose only "key" is its package path, so nothing can move wugnot out of it unless that realm happens to contain code that does. A realm with no such code that won a draw would have the entire week's pot transferred into an address it can never spend from: money that has left this realm's books (heldPots drops, the invariant stays green) and is unreachable by anybody, for ever. This realm cannot tell which realms those are and must not try, so it refuses them all at the door instead of stranding a pot at settlement time.

cur.Previous().IsUserCall() IS THE CHECK, COPIED FROM r/splash/exchange:378 — AND IsUser() IS NOT. IsUser() also accepts a `maketx run` ephemeral realm, which is code executing under a user's signature and NOT an EOA: its address is still package-derived, so it can win a pot and then cease to exist with the money in it. The exchange states the same distinction for the same word, in the context of a send envelope rather than a payout; the failure mode differs but the property being asked for — "the thing in front of me is a wallet somebody holds a private key for" — is identical.

FundPot IS DELIBERATELY NOT GUARDED THIS WAY, AND MUST NOT BE "MADE CONSISTENT". r/splash/arcade/duel calls it from realm code — from FlushPot, once per batch of collected duels; it was from Collect itself until AUDIT-2026-09-28.md finding 14 — so adding this check there would break the arcade's rake, the thing that makes the duel and the raffle one product. It is safe there precisely because sponsorship buys nothing: no ticket unit, no winning chance, no refundable claim (see FundPot and Draw.Sponsored), so a realm that funds a pot can never be the address a payout is pushed to, and the defect above has no way to arise. The asymmetry is the point: the guard belongs on the entry point that creates a claim on an outbound transfer, not on the one that only ever pays in.

AND FundPotWithEntries DOES CREATE SUCH A CLAIM, WHICH IS WHY IT IS THE ONE GUARDED FUNCTION IN THIS FILE — WITH A DIFFERENT GUARD, FOR A DIFFERENT REASON. It appends ticket weight for addresses the CALLER names, so this finding's defect would arise there twice over if it were open: any caller could credit a realm address that can never spend a pot, and could do it for somebody else's benefit. IsUserCall cannot be the check there, because the only legitimate caller IS a realm. So that function admits exactly one package path, and the finding-8 property — every holder of an earned entry is an address a payout can actually reach — is enforced where it is knowable: r/splash/arcade/duel records whether each party's own Post or Accept was a direct user call and credits only those. A REALM CANNOT PLAY A DUEL AT ALL SINCE 2026-09-30, and the sentence that stood here — "a realm may still play duels; it simply earns no raffle entries by doing so" — is corrected rather than deleted because the reason it stopped being true is this finding travelling one realm further. r/splash/arcade/duel found that leaving SETTLEMENT open to realms had the same defect in its own escrow: its Collect is winner-only, so a winning realm with no code calling it stranded both stakes with no exit. Post and Accept there now require a direct user call. The duel still records each party's flag and still credits entries only to eligible parties — nothing this realm relies on has weakened, and the property below is now enforced twice over. AUDIT-duel-2026-09-27.md addendum 2, finding D-1.

func Capture

crossing Action
1func Capture(cur realm, week int64)
source

Capture copies a committed week's beacon randomness into the draw.

IT IS NO LONGER A SAFETY MECHANISM, AND SAYING SO IS THE POINT OF THIS COMMENT. What it used to say: "the beacon keeps signatures for beacon.PruneWindow = 2016 rounds, about seven days at a five-minute cadence; this raffle is WEEKLY, so a draw settled late can find its round already pruned and the pot would have no way to name a winner. Capture preserves the value while it is still there." Every clause of that is now false. r/splash/beacon has no retention window and no Prune, drand serves every round for ever from at least three public relays, and Publish is permissionless — SO THERE IS NOTHING TO PRESERVE THE VALUE FROM. There is no capture window either; the old paragraph computed one, and it does not exist.

WHAT IT STILL DOES, which is why it is kept rather than removed:

  • it copies a public value into the draw's own state, so Resolve needs no beacon read later and the settled draw is self-contained;
  • it makes the value visible through CapturedRandomness and the Capture event before the week is settled, which a front end and an auditor can both use.

A CONVENIENCE, then. Removing it would delete an exported realm function and a large block of correct, tested behaviour in order to retire a comment; retiring the comment is enough. Nothing in this realm's safety now depends on anyone calling it — see resolvable()'s removal and Refund's gate.

PERMISSIONLESS, deliberately. Its only effect is to store a value that is already public — the round's randomness, readable by anyone the moment it is published. Gating it on an operator would buy nothing and would put a liveness dependency for the whole pot back onto one key.

WHAT IS STORED IS THE 64-CHARACTER HEX RANDOMNESS — sha256 of the signature, a hash, not the signature itself — and it is NOT re-verified here. The pairing check happened once already inside beacon.Publish on the way in; redoing it would be expensive duplicated work on an input that is already known good, and the beacon has no route by which a published round's randomness can change afterwards.

A SECOND Capture NEVER OVERWRITES THE FIRST, and is a silent no-op rather than a panic: two callers racing to perform a permissionless public good should not make one of them fail.

Capturing before the round is published PANICS. There is nothing to preserve yet, and storing anything else — least of all some already-published value — would hand the draw a randomness for a round nobody pinned, which is the exact attack the pin exists to prevent.

COMMIT DOES NOT CAPTURE, AND MUST NOT START. Since the round is pinned at record creation, a week's round may well be published by the time it is committed, so a capture there would often succeed — and it would put a beacon read back inside the one function whose job is to choose nothing. Capture staying separate and permissionless is what keeps the commit path free of any beacon dependency at all; see Commit's note on why there is no publication check there either.

func CapturedRandomness

Action
1func CapturedRandomness(week int64) string
source

CapturedRandomness returns what Capture stored for a week, or the empty string if nothing was captured. Kept out of DrawOf so DrawOf's shape stays stable for callers.

func ClaimPerUnitOf

Action
1func ClaimPerUnitOf(week int64) int64
source

ClaimPerUnitOf is the ugnot each ticket UNIT of a week may reclaim from the sponsorship that week's draw never paid out, or 0 for any week that is not abandoned. Read Draw.ClaimPerUnit and retireSponsorship.

ZERO ON AN ABANDONED WEEK IS MEANINGFUL AND IS NOT AN ERROR: it means either that the week held no sponsorship at all, or that the sponsorship was smaller than the week's unit count, so no unit could be given a whole ugnot and the whole amount was sub-unit dust. The DeadWeekSponsorshipDistributed and DeadWeekSponsorshipToOps events distinguish the cases.

func ClaimSponsorRefund

crossing Action
1func ClaimSponsorRefund(cur realm, week int64) int64
source

ClaimDeadWeek pays the caller their pro-rata share of the sponsorship that a DEAD week's draw never got to pay out, and returns the amount. PERMISSIONLESS, self-service only — a caller can only ever claim their own share — and it is the EXIT for the claim term retireSponsorship creates. AUDIT-2026-09-28.md finding 31.

WHY IT IS A NEW ENTRY POINT AT ALL, WHICH IS THE ONE QUESTION WORTH ANSWERING HERE. The cheaper design is to fold each holder's share into their spendOf and let the existing, already-audited Refund be the exit — no new function, no new term, no new tree. THAT DOES NOT WORK, AND THE ARITHMETIC IS WHY RATHER THAN A JUDGEMENT CALL:

Example
 1Refund re-derives the house fee it must give back as
 2    cutPart = floor(gross * HouseCutBps / bpsDenom)
 3and decrements d.Cut by it. That derivation is EXACT — see cutPerTicket's build guard —
 4only because a spendOf is always a whole number of ticket prices, so its floor is
 5precisely the sum of the cuts those purchases booked.
 6
 7A sponsorship share booked NO CUT. Add a share s to a buyer whose gross was
 8g = n * TicketPrice and Refund derives floor((g+s)/20) = n*500_000 + floor(s/20), while
 9d.Cut only ever received n*500_000. d.Cut goes NEGATIVE by floor(s/20) and Refund's own
10"refund would overdraw the week's escrowed fee" panic fires — so the buyer cannot
11reclaim their own ticket money at all. Worse in the pure-earned case: a holder with no
12purchase gets gross s against a week whose Cut is 0, and floor(s/20) is stolen from
13nothing. THE FIX FOR A FUND-STRANDING DEFECT WOULD HAVE CREATED ONE.
14
15The identity itself survives — Refund removes `amount` from both sides whatever the
16rounding — which is exactly what makes this dangerous: checkInvariant and AuditFailure
17stay green right up to the panic. It is the Cut term, not the identity, that breaks.

SO THE CLAIM IS A SEPARATE TERM WITH ITS OWN EXIT, AND IT CARRIES NO FEE BY CONSTRUCTION: sponsorship never paid a house cut on the way in and this pays none on the way out. The term appears in checkInvariant (heldClaims) and in AuditFailure (per week, and in the aggregate, and re-derived against claimedOf by deadWeekClaimFailure), which is the price of not reusing Refund and is paid in full.

IT HAS NO CLOCK, NO BEACON READ AND NO REFUSABLE DEPENDENCY, deliberately. By the time a claim exists the week is already abandoned — every gate that had to be waited out (Refund's grace window, or SweepDrawlessWeek's) was waited out before retireSponsorship ran — so a second wait would only be a second thing that could go wrong on a payout path. Compare ReclaimStrandedCarry, which this replaces: that needed a beacon test AND a three-week long stop because it was trying to find a destination. There is nothing to find.

IT IS O(log N) AND ALL-OR-NOTHING PER HOLDER. The share is derived from the rate and the holder's unit weight at call time rather than stored per holder at abandonment, which is what keeps the abandonment transition constant-time (read retireSponsorship). The weight cannot have changed since: an abandoned week is always a past week, and Buy and FundPotWithEntries only ever touch currentWeek(). One claimedOf entry therefore settles a holder for ever, and a second call finds it and refuses.

A REFUNDED BUYER MAY STILL CLAIM, AND THAT IS CORRECT, NOT A LOOPHOLE. Refund returns the gross they paid for their tickets; this returns their share of money that was never theirs to pay and never anybody's to win. The two are different money with different owners, and the week's identity keeps them apart — spendOf on one side, Claims on the other. A holder with earned entries and no purchase has a claim here and nothing to refund, which is the whole point of finding 15's players being paid.

BOOKS FIRST, TRANSFER SECOND, as in Resolve, Refund and WithdrawOps.

Panics if there is no record for the week, if that week never distributed anything, if the caller holds no units in it, or if the caller has already claimed. ClaimSponsorRefund returns a sponsor's payment from a week that died without drawing. It pays the GROSS — the house's 5% included — because the fee was only ever earned by a draw actually running, and this draw never ran.

THE OWNER'S RULING, 2026-09-30: a sponsor's money is "either returned to sponsor or rolls over until a draw happens". Rolling forward is what AUDIT-2026-09-28.md findings 28 and 31 removed — the caller chose which week it landed in by choosing when to call, and could aim it at a draw they already dominated. So it is returned, and it is PULLED rather than pushed: paying up to maxSponsorsPerWeek addresses inside retireSponsorship would put those transfers on the critical path of a buyer's own Refund, where one failing recipient could block the week.

IT DOES NOT COMPETE WITH ClaimDeadWeek. That pays earned-entry holders out of the duel's OWNERLESS rake; this pays named sponsors out of money that never entered that pool, because retireSponsorship carves it out first. The two draw on different halves of the same pot and neither can reach the other's.

func ClaimableOf

Action
1func ClaimableOf(week int64, addr address) int64
source

ClaimableOf is what `addr` may take out of a dead week right now: their EARNED unit weight times the week's claim rate, or 0 once they have taken it. IT IS EXACTLY WHAT ClaimDeadWeek WILL PAY, derived the same way and off the same tree, so a front end never has to guess.

IT READS earnedOf AND NOT ticketsOf — AUDIT-2026-09-28.md finding 34, and this read had to move in the same commit as ClaimDeadWeek's. A read that disagrees with the entry point it mirrors is worse than no read at all: a front end would have shown a refunded buyer a positive claimable figure and then watched their transaction abort, which is how a caller learns to distrust the whole realm. THE TWO DERIVATIONS ARE NOW ONE EXPRESSION, DUPLICATED — earnedOf times ClaimPerUnit — and every test that asserts ClaimDeadWeek's return value against this read is what keeps them equal.

A BUYER WHO HOLDS ONLY BOUGHT UNITS READS ZERO HERE, AND THAT IS THE ANSWER, NOT A GAP. Their money comes back through Refund, in full, fee included. Read EarnedOfWeek alongside TicketsOfWeek to see the two kinds of weight separately.

func Commit

crossing Action
1func Commit(cur realm, week int64) int64
source

Commit commits an ALREADY-FINISHED week to a future beacon round. Returns that round.

THE WEEK IS A PARAMETER AND IT MUST BE OVER — week < currentWeek(). Both halves of that sentence are load-bearing, and each fixes a defect the first version of this function had:

  • IT TAKES A WEEK, so any past week can be committed at any time, by anyone. Before, Commit could only ever act on currentWeek(), which meant a week whose clock ran out before anybody called it could never be committed — and because Resolve and Refund both require Committed, that week's whole pot was unreachable forever while heldPots went on counting it, so the invariant and the audit both stayed green over money nobody could ever get back. A stranded week is now always recoverable: commit it, then resolve it, or refund it if its round was lost.

  • THE LIVE WEEK CAN NEVER BE COMMITTED, so a commit can no longer cut a week's sales short. Before, anyone could Buy(1) and immediately Commit: that made them the sole owner of a FrozenTotal of 1 — the guaranteed winner — and, because Buy refuses a committed week, shut the week's sales for its remaining days. It cost the attacker the 5% cut on one ticket and was repeatable every week with no admin and no pause to stop it; the front-running variant landed a commit just ahead of somebody's large Buy and made their purchase panic. Refusing week >= currentWeek() closes all of that at the root rather than with a minimum-tickets or minimum-elapsed heuristic: the week being sold into is simply not a committable week.

PERMISSIONLESS: there is no keeper and no admin. Anyone who wants the draw to happen — every ticket holder, for a start — can call it, once the week is up.

It freezes two numbers, and CHOOSES NOTHING:

  • FrozenTotal = the week's final ticket total. The week is over and Buy only ever sells into currentWeek(), so this total can no longer grow BY CONSTRUCTION; it is still assigned explicitly, because Resolve reads this field and not the live total.
  • Pot, which stops growing for the same reason (and Buy also refuses a committed week, which is now a second, unreachable-by-design lock).
  • AND IT USES Draw.Round EXACTLY AS PINNED, which was written when this week's record was created and is not this function's business. See below.

THE SECURITY RULE, AND THE ONLY REASON THIS IS THREE PHASES: the ticket set must be frozen BEFORE the randomness is knowable. If the total were read at resolve time, anybody who saw the published round could compute the winning unit and then buy precisely enough tickets to own it — which turns a raffle into a purchase.

THIS FUNCTION NO LONGER BINDS THE ROUND, AND THAT IS THE 2026-09-28 FIX. It used to set Round = beacon.Latest() + 1 here, which looked like the conservative choice — the round is unpublished at commit time, so its randomness "does not exist yet" — and was in fact the operator's cheapest attack on the product: the beacon's key holder knows every round's randomness in advance, and Commit ran AFTER the week's ticket set was frozen and public, so they could enumerate candidate rounds, Publish until Latest()+1 named them, and commit. The round is now pinned at record creation instead; read pinOffsetRounds for the full account, including what remains open.

THERE IS DELIBERATELY NO "THE ROUND MUST STILL BE IN THE FUTURE" CHECK HERE, and the old `round <= latest` wrap check is gone with it. A reader who expects one has the fix backwards. A week is committed at least a week after its round was pinned, so by now the pinned round MAY LEGITIMATELY ALREADY BE PUBLISHED — that is the ordinary case for a week whose record opened early, and it is harmless precisely because nobody chose it: the round was fixed before a single ticket of that week had been sold. Requiring it to be unpublished would refuse the honest case and, worse, would reintroduce round selection through the back door, since the only way to satisfy such a check would be to recompute the round here. The wrap case the old check guarded is now handled where the arithmetic happens, at maxPinnableLatest.

WHY FREEZING THE TOTAL ALSO FREEZES WHO OWNS UNIT k — the part the old realm got wrong first time, and the reason Buy appends:

  • Every purchase APPENDS A NEW ticket index (appendTickets). No index's value is ever increased afterwards, and fenwick.Add is not called from this realm at all.
  • An appended index therefore starts at a prefix equal to the whole total at the moment it was appended, so anything appended after Commit lies at or beyond FrozenTotal — outside the frozen range by construction, not by convention.
  • And because no earlier index's value changes, no earlier prefix moves: the owner of every unit below FrozenTotal is fixed from the instant that unit was sold.

WITH ONE INDEX PER BUYER INSTEAD, a further purchase would grow that buyer's existing value, widening their range IN PLACE, from its start, inside the frozen range, and pushing every later buyer along. The earliest buyer (prefix 0 forever) could then wait for the round to publish, compute k = randomness mod FrozenTotal, buy until their own value exceeded k, and call Resolve to take the pot: a free option on the whole pot. That exploit was real in r/splash/game and its fix is what this realm inherits. Buy's refusal after commit closes the same door a second time.

A WEEK'S TICKET TOTAL MAY BE ENTIRELY EARNED AND THIS FUNCTION DOES NOT CARE. Since finding 15 a week can reach this point with tickets and NO spend records at all — every entry credited to a duel player through FundPotWithEntries, not one ticket bought. total > 0 is satisfied, the week commits, and Resolve pays the winner a pot made purely of the rake those players paid. That is the intent, and it is why the zero-ticket refusal below is a check on the TICKET INDEX and not on the spend records: a drawless week is one nobody entered, not one nobody paid this realm directly.

Panics if the week is not over yet, if it is already committed, or if it has no tickets at all (there is NO ROLLOVER, so a drawless week must never be allowed to exist). It no longer reads the beacon at all: the "no round has ever been published" refusal moved to the pin, which is to say to Buy and FundPot, where a sale is refused rather than accepted into a week that could never be drawn.

func CurrentWeekEndHeight

Action
1func CurrentWeekEndHeight() int64
source

CurrentWeekEndHeight is the chain height at which the LIVE week ends: the one number a countdown wants. WeekEndHeight(CurrentWeek()), read in a single call.

IT IS ONE CALL ON PURPOSE. Composing the two exported reads off chain means two queries at two different chain heights, and a week that turns over between them yields a boundary for a week that has already ended — a countdown that jumps backwards by a whole week exactly at the moment it matters most. Read here, both halves see one height.

A BLOCK HEIGHT, NOT A TIME, for the reasons on WeekEndHeight.

func DrawOf

Action
1func DrawOf(week int64) (bool, int64, int64, int64, bool, address, bool, int64, int64)
source

DrawOf reports a week's record: committed, frozen ticket total, beacon round, pot, resolved, winner, abandoned, sponsored. All zero for a week that never sold a ticket.

ABANDONED IS PART OF THIS TUPLE rather than a separate read because it is not a detail: it is the difference between a week that is waiting for its randomness and a week that is paying its buyers back and will never have a winner. A front end that showed "unresolved" for both would be lying to a buyer about whether to expect a draw or to go and claim a refund.

SPONSORED IS PART OF IT for a related reason: pot minus sponsored is what the week's buyers put in and could reclaim, and sponsored is what the arcade added on top. A buyer looking at a pot deserves to know which part of it is a claim of theirs and which is house money that will never be refunded.

COMMITTED-AT IS THE LAST ELEMENT, and it is here so a front end can compute the one number a buyer of a stuck week actually wants: CommittedAt + refundGraceBlocks, the height at which their money becomes reclaimable whatever the beacon does. RefundOpensAt does that arithmetic; this returns the raw height so a caller can also show how long the week has been waiting. Zero on an uncommitted week, which is exactly the "no refund clock is running yet" case.

func EarnedOfWeek

Action
1func EarnedOfWeek(week int64, addr address) int64
source

EarnedOfWeek is how many of `addr`'s units in a week were EARNED through duel play rather than bought — the basis a dead week's sponsorship is divided on. AUDIT-2026-09-28.md finding 34.

IT EXISTS SO THE TWO KINDS OF WEIGHT ARE VISIBLE SEPARATELY. TicketsOfWeek returns the draw's basis (both kinds, indistinguishable, which is finding 15's design) and this returns the claim's. A front end showing a dead week needs both, because "you hold 412 units and your claim is on 12 of them" is the only explanation of a zero ClaimableOf that a buyer will accept.

func EarnedTotalOfWeek

Action
1func EarnedTotalOfWeek(week int64) int64
source

EarnedTotalOfWeek is a week's whole earned unit count — the divisor retireSponsorship used, or will use. Zero on a week whose every unit was bought, which is the state that routes a dead week's sponsorship to opsPot rather than to holders.

func EscrowedCut

Action
1func EscrowedCut(week int64) int64
source

EscrowedCut is the house fee held against one week and NOT YET EARNED, in ugnot: it becomes ops revenue if that week resolves and goes back to the buyers if it does not. Zero for a week that has resolved, been fully refunded, or never sold a ticket.

func FundPot

crossing Action
1func FundPot(cur realm, amount int64)
source

FundPot adds house money to the CURRENT week's prize pot without buying a single ticket. PERMISSIONLESS and CALLER-PAYS: the funder pulls from their own allowance, exactly as Buy does, so this realm gains no authority over anybody and any address can call it with its own money.

IT IS REALM-CALLABLE ON PURPOSE AND HAS NO IsUserCall GUARD. Do not add one for consistency with Buy. r/splash/arcade/duel calls this from realm code on every Collect — that is the arcade's rake, and gating it on an EOA would break the one mechanism that makes the duel and the raffle a single product. Buy's guard exists because a ticket is a claim on an outbound transfer (Resolve's pot, Refund's spend) and a realm's package-derived address may have no way to spend what arrives there; sponsorship creates no such claim at all — no ticket unit, no winning chance, no refundable record, see below — so a funder can never be the address a payout is pushed to and the defect Buy's guard closes cannot arise here. The asymmetry is deliberate and is stated at both ends.

WHY IT EXISTS. r/splash/arcade/duel sends its rake here: every coin flip makes this week's raffle prize bigger, which is what makes the two contracts one product rather than two features. It used to fund r/splash/game's yield and prize pots; that product is retired, and nothing in SPLASH holds money beyond a week any more.

IT CAN OPEN A WEEK, AND THEREFORE CAN PIN THAT WEEK'S BEACON ROUND. When the duel's rake arrives before anybody has bought a raffle ticket, this is the call that creates the week's record — and getOrCreateDraw pins Draw.Round there, once, for whichever entry point got there first (read pinOffsetRounds). A later Buy in the same week finds the record and never re-pins. The same beacon prerequisites as Buy therefore apply here, and there are now TWO: with no round ever published, and with no relay inside beacon.MaxEstimateStaleBlocks that raised the beacon's anchor, EstimatedLatestRound panics and THE SPONSORSHIP IS REFUSED.

AND THAT REFUSAL IS NO LONGER A CROSS-REALM HAZARD, WHICH IS WHY THE STALENESS BOUND COULD BE ADOPTED AT ALL. It used to be: r/splash/arcade/duel's Collect called this function IN THE SAME TRANSACTION as the winner's payout, so a refusal here reverted that payout — permanently, since VoidStale refuses a resolved duel. Collect now BOOKS its rake and duel.FlushPot delivers it, so a refusal here costs the flusher a reverted transaction and the booked balance is still there for the next attempt. Any caller can clear the refusal with one Publish of drand's current round. AUDIT-2026-09-28.md findings 12 and 14.

SPONSORSHIP BUYS NOTHING. d.tickets, d.ownerOf, d.ticketsOf and spendOf are all left alone: the funder gets no ticket unit, no winning chance, and no refundable claim. Only d.Sponsored and d.Pot move. A funder who never bought a ticket therefore cannot win the week they funded — ownerOfUnit can only ever name an address that appears in d.ownerOf, and nothing here writes to it.

THAT IS STILL TRUE OF THIS FUNCTION AFTER FINDING 15, AND IT IS THE REASON THE EARNED-ENTRY DOOR IS A SEPARATE FUNCTION RATHER THAN A PARAMETER HERE. FundPotWithEntries appends ticket weight for named holders and calls this function for the money; this function appends nothing for anybody, and every caller of it — including a realm, including the duel when it has no eligible holders to credit — still buys exactly nothing. Do not add holders to this signature: the guard that makes the other door safe would then have to be here, on the permissionless path the arcade's rake depends on.

WHY THE SEPARATE FIELD RATHER THAN JUST d.Pot += amount: read Draw.Sponsored. The one-line version is that d.Pot == sum(spendOf) is what makes Refund provably unable to overdraw, house money belongs to no buyer, and so the identity becomes d.Pot == sum(spendOf) + d.Sponsored rather than being quietly abandoned.

IT FUNDS THE LIVE WEEK ONLY, AND THERE IS NO COMMITTED-WEEK RACE HERE. Commit accepts only a week that is already over (week < currentWeek()), so the week currentWeek() names is uncommitted BY CONSTRUCTION — no concurrent commit can freeze it under a funder's feet, and money can never land in a pot whose winner is already being drawn. The refusals below are therefore defensive rather than reachable, and are kept for the same reason Buy keeps its own committed-week check: if the week this funds ever stops being "the one the clock is on", these are the lines that still refuse.

A SPONSORED WEEK WITH NO TICKETS IS NOT A DRAWABLE WEEK. Creating the week's record here is the same act Buy performs, and it does not make the week committable: Commit still panics on a zero ticket total, because there is no rollover and a drawless week must not exist. Such a week's sponsorship sits in the pot until somebody buys a ticket into it (the week is live, so they can) — and if the week ends with nobody having bought one, SweepDrawlessWeek registers that sponsorship for the next week to open (it names no destination itself — finding 28 — and it waits out a grace window first). THAT FUNCTION IS WHAT KEEPS THIS ONE SAFE: without it, funding a week that never sold a ticket stranded the money in a Draw that could never be committed, resolved or refunded. Refusing to create the record instead would mean the duel's rake bounced whenever it arrived before the week's first ticket sale, which would make settling a duel depend on somebody else's purchase.

Panics if amount is not positive, if the allowance is short, or — defensively — if the target week is already committed or has been abandoned into refund (there is nothing left to fund: its buyers are taking their money back).

func FundPotWithEntries

crossing Action
1func FundPotWithEntries(cur realm, amount int64, holders []address, units []int64) int64
source

FundPotWithEntries is FundPot plus the earned entries that rake bought, in ONE call, and it is callable only by r/splash/arcade/duel. Returns the total entry units credited.

READ THE BLOCK ABOVE duelRealmPkgPath FIRST: it has the defect, the three rules, why this door is privileged and why the bound rather than the guard is what makes it safe.

WHY ONE CALL AND NOT TWO. The money and the entries must land in the SAME week and the same transaction, or the pot identity and the ticket index could disagree about which week the duel's rake belonged to. A caller cannot deliver one without the other, cannot deliver entries worth more than the money, and cannot choose the week — currentWeek() decides, exactly as FundPot's does.

THE MONEY PATH IS FundPot'S, UNCHANGED AND NOT COPIED. This function calls FundPot directly (a non-crossing call within this realm, so cur.Previous() is still the duel and the pull is still caller-pays out of the duel's own allowance). Every refusal FundPot makes — non-positive amount, a committed or abandoned week, a short allowance, a beacon too stale to pin a new week's round — therefore applies here identically, and no second version of the sponsorship bookkeeping exists to drift from the first.

WHAT AN EARNED ENTRY IS, EXACTLY: one unit appended to this week's ticket index with NO spendOf record.

Example
 1APPENDED, NEVER INSERTED OR ADJUSTED. appendTickets is the same function Buy
 2uses, so an earned entry is a new index whose prefix starts at the whole
 3total at the moment it was appended. That is the frozen-ownership property
 4Commit's argument rests on (finding 10) and the reason fenwick rejects
 5negative deltas by design. Nothing here touches an existing index.
 6
 7AND NO spendOf RECORD, WHICH IS CORRECT AND IS NOT AN OVERSIGHT. The holder
 8paid no ticket money to this realm; what they paid was the duel's rake, and
 9that money is in this week's pot as Sponsored. So:
10
11  - Pot == sum(spendOf) + Sponsored is UNAFFECTED. Entries carry no money of
12    their own; the money term is FundPot's, and it is counted once.
13  - Refund pays spendOf only, so a holder of nothing but earned entries
14    reclaims nothing, which is right: they have no ticket money here to
15    reclaim, and their rake is in Sponsored.
16  - On an abandoned week THE SPONSORSHIP GOES TO THE EARNED-ENTRY HOLDERS, pro
17    rata, as a claim on that same week, and buyers reclaim their own gross. The
18    sentence that stood here was PRECISELY INVERTED and is kept because a reader
19    who has read this file before will be carrying it: "On an abandoned week
20    Sponsored goes to opsPot and buyers reclaim their spend; EARNED-ENTRY HOLDERS
21    GET NOTHING. Also right, and it is the same money-belongs-to-no-buyer rule
22    Refund already applies." Earned-entry holders are the INTENDED BENEFICIARIES —
23    the rake is theirs and they can recover it by no other route — and it is
24    BUYERS who get nothing from it, having contributed nothing to it and having
25    their whole ticket price back. AUDIT-2026-09-28.md findings 16, 31 and 34; read
26    retireSponsorship and ClaimDeadWeek. Only a week with NO earned holder at all
27    sends this money to opsPot.
28  - A WEEK CAN NOW HAVE TICKETS AND ZERO spendOf — every entry earned, none
29    bought. Commit requires tickets > 0 and gets it, Resolve pays the winner
30    the sponsorship, and that is precisely the intent: the flippers draw for
31    the pot their own rake built. SWEEPING SUCH A WEEK IS ITS SHAPE 2 AND FIRES ON
32    EXACTLY IT, once committed and once the grace window has passed with no draw.
33    The retired claim, which was wrong from the day finding 17 was implemented:
34    "SweepDrawlessWeek requires ZERO tickets and therefore correctly will not fire
35    on such a week — its sponsorship is a real prize now, not stranded money." The
36    ZERO-TICKET requirement is shape 1 only. Finding 17 added shape 2 for this very
37    week, because Resolve was otherwise its ONLY exit and no clock could rescue it —
38    and shape 2 then retires the sponsorship to the earned holders rather than
39    stranding it. Read SweepDrawlessWeek's two shapes.
40  - Finding 3's one-way Abandoned flag still covers the fact that refunds
41    cannot retire tickets. An earned entry is as un-retirable as a bought
42    one and is safe for the same reason: no draw over an abandoned week's
43    ticket set will ever happen.

THE HOLDERS ARE THE DUEL'S TO NAME AND THIS REALM DOES NOT SECOND-GUESS THEM, with one exception it cannot check and one it can. It cannot check that a holder is an EOA — there is no such predicate for a bare address on this toolchain — so THE DUEL credits only parties whose own Post or Accept was a direct user call, which is where finding 8's reasoning has to live for earned entries. It can and does check that every holder is a valid non-empty address and every unit count is positive, so a malformed batch fails loudly instead of appending an index nobody owns.

Panics if the caller is not the duel realm, if the two slices disagree in length, if either is empty, if any unit count is not positive, if any holder is invalid, or if the batch claims more units than the delivered money could have bought.

func HeldCuts

Action
1func HeldCuts() int64
source

HeldCuts is the total unearned house fee this realm holds across every week, in ugnot — the aggregate of EscrowedCut and the third term in the solvency equality.

func OpsPot

Action
1func OpsPot() int64
source

OpsPot is the house's EARNED revenue this realm holds, in ugnot: the fees of weeks that actually drew, plus absorbed donations, plus any abandoned week's sponsorship that had no live week to roll into.

IT IS NOT "THE ACCUMULATED HOUSE CUT" ANY MORE, which is what this comment said until 2026-09-29. A live week's fee is NOT in here — it is escrowed in that week and is still refundable. Read EscrowedCut and HeldCuts for the unearned half, and finding 25 for why the two are separate numbers.

func PotSize

Action
1func PotSize() int64
source

PotSize is the CURRENT week's prize pot in ugnot: ticket sales after the house cut, PLUS whatever FundPot has sponsored into it (Sponsored, below). It is the whole amount the winner takes.

func Refund

crossing Action
1func Refund(cur realm, week int64)
source

Refund returns the caller's own ticket money for a week whose draw can never be resolved. PERMISSIONLESS, and self-service only: a caller can only ever reclaim their own record.

THIS IS THE SAFETY VALVE r/splash/game COULD NOT HAVE. There, the pot was accrued protocol fees, nobody had staked anything, and an unresolved draw could simply wait forever with no one out of pocket — so a void path would have been the only route by which a prize could move without a winner, and was deliberately omitted. Here the pot is the buyers' own money, and without this function a week nobody settles would lose every ticket bought into it.

ITS JUSTIFICATION CHANGED COMPLETELY IN THE DRAND MIGRATION, AND THE OLD ONE IS WRITTEN OUT HERE BECAUSE A READER WILL COME LOOKING FOR IT. It used to be: "the committed beacon round was published and PRUNED with nobody calling Capture, so no winner can ever be named". That was a claim about a beacon that forgot things. r/splash/beacon does not forget, drand's API serves every round for ever, and Publish is permissionless — so no draw is ever unresolvable, and the precondition this function was built on no longer exists (see resolvable()'s removal, above).

WHAT IT IS FOR NOW IS LIVENESS, NOT LOSS. A week's pot must not be hostage to whether anybody spends ~17.0M gas relaying one drand round. That is the only failure left, and it is unbounded in time, so the exit is a CLOCK: refunds open refundGraceBlocks (21 days) after Commit and not before.

THE PRECONDITION IS THEREFORE NO LONGER THE COMPLEMENT OF Resolve's — the two now OVERLAP for ever once the window opens, and that is fine and is handled by Abandoned. There is no state in which Resolve is impossible, so a refund gate that waited for one would never open at all.

AND THE EXIT IS ONE-WAY, WHICHEVER WAY IT GOES. Refund and Resolve are permanently mutually exclusive per week: the first refund that pays out sets Abandoned, after which Resolve and Capture refuse that week forever and every later refund proceeds without consulting the beacon again. THE ONE-WAY EXIT MATTERS MORE NOW, NOT LESS: it used to protect against a round being published late or re-published after a prune, and it now carries the whole burden, because a pinned round can be relayed by anyone at any time for ever — so a week in refund would otherwise be re-resolvable indefinitely. Without it, a week could take refunds from some buyers and then be resolved over the original frozen ticket set, paying the remaining pot — those other buyers' money — to a winner who may already have been refunded.

AND THE ESCROWED FEE FOLLOWS THE SAME PRINCIPLE AS THE SPONSORSHIP, BY A DIFFERENT ROUTE — BUT THE SPONSORSHIP DOES NOT ROLL ANYWHERE AND HAS NOT SINCE 2026-09-29. The retired sentence, ~240 lines above a transition that does nothing of the kind:

Example
1"An abandoned week's Sponsored ROLLS FORWARD TO THE LIVE WEEK (finding 16), because it
2belongs to no buyer and there is nobody to hand it back to."

BOTH HALVES WERE WRONG BY THE TIME IT WAS WRITTEN. Rolling forward to the live week was AUDIT-2026-09-28.md finding 28 — the live week is whichever week the permissionless caller chose to fire in, so the caller picked the destination by picking the moment — and the premise "there is nobody to hand it back to" is false whenever a duel was played in the week: THE EARNED-ENTRY HOLDERS ARE EXACTLY THE PEOPLE WHOSE RAKE IT IS (findings 31 and 34). The sponsorship becomes a per-unit claim on THIS week, payable to the holders of its EARNED entries, and it never changes weeks.

SO THE TWO AMOUNTS NOW ANSWER THE SAME QUESTION THE SAME WAY, AND THE PARALLEL IS TIGHTER THAN IT WAS. An abandoned week's Cut does not roll anywhere: it belongs to specific buyers, so it goes BACK to them, one gross refund at a time, and the abandonment transition below deliberately does not touch d.Cut at all. The sponsorship likewise goes back to the people it came from, in place. We did not run a draw, so we keep neither — and the only money that reaches the house is a sponsorship with no player behind it at all.

A BUYER WHO NEVER CALLS THIS LEAVES THEIR OWN GROSS SITTING IN THE WEEK, fee part included, and that is not a new stranded-money path: there is no deadline on a refund, Abandoned is one-way so nothing else can take it, and the only address that can ever reclaim it is theirs. It is the same "unclaimed, not unreachable" state the prize half has always had.

AN EARNED ENTRY RECLAIMS NOTHING *HERE*, AND THAT IS CORRECT RATHER THAN A GAP — BUT IT RECLAIMS EVERYTHING THROUGH ClaimDeadWeek. Finding 15 lets duel play earn ticket weight with no spendOf record (FundPotWithEntries), and this function pays spendOf and nothing else — so a holder of only earned entries is refused with "nothing to reclaim for that week". They paid this realm no ticket money to get back: what they paid was the duel's rake, and the abandonment transition below turns that rake into THEIR claim on this same week (AUDIT-2026-09-28.md findings 31 and 34).

THE TWO SENTENCES THAT FOLLOWED WERE BOTH FALSE AND ARE KEPT BECAUSE EACH WAS LOAD-BEARING FOR A DIFFERENT READER:

Example
1"A week whose entries are ALL earned therefore refunds nobody and MOVES ITS WHOLE POT TO
2opsPot ON THE FIRST REFUND ATTEMPT — except that there can be no first refund attempt,
3because no caller has a spend record, so such a week simply stays unrefundable and
4RESOLVE REMAINS ITS ONLY EXIT."

The first clause contradicted itself in its own sentence (a refund attempt that cannot happen cannot move anything) and named a destination that is deleted. The second was true when it was written and was FIXED BY finding 17 in the same audit: a pure-earned week has a SECOND exit, SweepDrawlessWeek's shape 2, gated on the same grace window this function uses, and that exit retires the sponsorship to the earned holders. Leaving "Resolve remains its only exit" standing is how a reader concludes that the stranding finding 17 closed is still open — or, worse, re-derives finding 17 as a new idea and implements it a second time.

WHAT IS TRUE: such a week refunds nobody through this function, its draw is Resolve, and if no draw happens for the whole grace window then SweepDrawlessWeek marks it abandoned and its sponsorship becomes its earned holders' claim. It strands nothing by either route.

EACH BUYER RECLAIMS EXACTLY THEIR OWN RECORDED SPEND, ONCE. The record is zeroed before the transfer, so a second call finds nothing and panics; and because Pot == sum(spends) + Sponsored for an unresolved week — with the sponsorship term taken out of the pot the moment the week is abandoned, see below — the refunds can never total more than the pot holds.

SPONSORSHIP IS NOT REFUNDABLE AND IS NOT LEFT BEHIND. House money added by FundPot (the duel's rake, for one) belongs to no buyer, so nobody can claim it here; the first refund that pays out ROLLS IT FORWARD INTO THE LIVE WEEK'S POT — exactly as SweepDrawlessWeek does for a week nobody entered — instead of leaving it stranded in a pot whose every claim has been zeroed. It went to opsPot until 2026-09-29, and the two paths disagreeing about the same money was AUDIT-2026-09-28.md finding 16. The code and the reasoning are at the transition below.

THE HOUSE FEE *IS* REFUNDED, SINCE 2026-09-29, AND THE PARAGRAPH THAT USED TO BE HERE SAID THE OPPOSITE. It read: "What a buyer gets back is their post-cut contribution — the part that was ever in the prize pot — not the gross ticket price. The 5% was taken at sale time and is already in the ops pot; clawing it back would mean the ops pot could go negative, or that this realm had to hold every cut in escrow for a week against the small chance of a pruned round." IT IS KEPT VERBATIM BECAUSE IT NAMES THE COST OF THE FIX AND THE FIX PAID IT: the realm now DOES hold every fee in escrow per week (Draw.Cut), which is the second option that sentence dismissed, and the ops pot cannot go negative because nothing credits it until a draw succeeds.

WHAT A BUYER GETS BACK IS THEREFORE THE WHOLE TICKET PRICE. The owner's rule is that WE EARN WHEN THE GAME RUNS, NOT WHEN IT FAILS, and a refunded week is a week that did not run: the fee was a charge for a draw that never happened. This is a REDUCTION IN HOUSE REVENUE in the failure case, which needs no permission and is the safe direction — the same shape as finding 16 sending an abandoned week's sponsorship back to players rather than to the house.

THE SPLIT IS IN THE BODY, and it is where the only rounding risk in the change lives; read it there. AUDIT-2026-09-28.md finding 25.

A WEEK THAT WAS NEVER COMMITTED IS NOT REFUNDABLE HERE, and needs no separate path — but only because Commit TAKES THE WEEK AS AN ARGUMENT and any week that is over can be committed by anyone at any time. So a buyer holding a stranded week's tickets names that week to Commit themselves, and then either Resolves it (the normal outcome) or refunds it here if its round was lost. Before Commit took that argument this paragraph was simply false: a caller could not name the week, Commit could only act on the live one, and a week the clock had passed was unreachable by every path in this file — committed never, resolved never, refunded never, and counted in heldPots forever.

func RefundGraceBlocks

Action
1func RefundGraceBlocks() int64
source

RefundGraceBlocks is refundGraceBlocks, EXPORTED so a front end can state the window without hardcoding it — the same reason TicketPrice is exported. Read refundGraceBlocks for what it means and how it was derived.

func RefundOpensAt

Action
1func RefundOpensAt(week int64) int64
source

RefundOpensAt is the chain height from which Refund will pay this week's buyers REGARDLESS OF THE BEACON: CommittedAt + refundGraceBlocks.

ZERO MEANS "THERE IS NO FUTURE HEIGHT TO WAIT FOR", which covers three unrelated states and a caller must not read any of them as "refundable now": no record for the week, not committed yet (nothing is locked up), already resolved (the pot has gone to its winner), or already abandoned (refunds are open this instant and the height is behind us). Render draws the same distinction, as "n/a".

IT IS NOW AN EXACT HEIGHT, NOT A CEILING, and the change is worth noting because the comment used to hedge. It said refunds "may open much earlier through the beacon-derived half of Refund's gate — the moment the committed round falls outside beacon.PruneWindow unresolved". THAT HALF OF THE GATE NO LONGER EXISTS (see resolvable()'s removal), so this height is the only one at which a refund opens, and a front end can show it as a fact rather than as a worst case.

func Resolve

crossing Action
1func Resolve(cur realm, week int64)
source

Resolve picks the week's winner and PAYS THEIR WALLET. PERMISSIONLESS.

THIS IS THE ONE PLACE THIS REALM DIVERGES FROM r/splash/game ON PURPOSE. There, the pot was credited to the winner's locked position, which meant the prize could never be spent and the winner's stake grew instead. Here the whole pot leaves this realm in a single wugnot.Transfer to the winner's address. No internal balance is credited, there is nothing left to claim afterwards, and no buyer's money is held past the week they bought into.

winner = ownerOfUnit(randomness mod FrozenTotal). FrozenTotal, NOT the live ticket total: recomputing it here would reopen the buy-the-winning-unit attack described on Commit.

THE POT PAYS OUT UNTAXED, AND THE FEE IS TAKEN HERE — NOT AT SALE TIME. This paragraph read "The house cut was already taken on the way in, at sale time; taxing the payout as well would be charging twice for the same ticket" until 2026-09-29, and the first half stopped being true that day: since finding 25 a sale ESCROWS the fee in Draw.Cut and this function is what earns it, moving the week's whole escrow to opsPot in a separate step from the prize. The conclusion is unchanged and so is the money — the winner still receives Pot and nothing is deducted from it here — but a reader looking for where the house is paid must look at this function and not at Buy. AUDIT-2026-09-28.md finding 29.

RANDOMNESS SOURCE: a captured value if there is one, otherwise the beacon directly — so the happy path needs no extra transaction.

THERE IS ONE FAILURE MESSAGE, AND THERE USED TO BE TWO. The pair was "not published yet" (wait and retry; the draw is fine) and "published and is now pruned, and nobody captured it" (the signature is gone, retrying will never help, Refund is the way out), and keeping them distinguishable was load-bearing because Refund's precondition WAS that distinction. Since the drand migration the second state cannot occur: no round is ever unobtainable, so "retrying will never help" is never true. Collapsing the two was therefore required, not a simplification — a realm that still said it would be steering buyers into refunds they do not need. Anyone who sees the remaining message can relay the round themselves.

THERE IS NO HEIGHT GATE HERE, AND THERE MUST NEVER BE ONE. refundGraceBlocks does not appear in this function and CommittedAt is not read: the grace window is a FLOOR ON BUYER ACCESS, NOT A DEADLINE ON THE DRAW. It only ever opens Refund; it never closes Resolve.

WHY THAT ASYMMETRY IS THE RIGHT ONE. The defect the grace window closes is that a stalled beacon left the buyers with no way to reach their own money. It is not that a late draw is illegitimate — an honest publication that arrives a fortnight after commit, before anybody has refunded, should still pay the winner it always would have, and the FrozenTotal it draws over is exactly the set it was frozen with. Adding a height deadline here would create a second class of dead week — past the grace, published, and resolvable by nobody — which is the very shape of defect this window exists to remove.

WHAT DOES CLOSE THIS FUNCTION IS Abandoned, as before, and that is enough. Once the grace window has opened, Resolve and Refund are both available and the first one called wins: a refund sets Abandoned and this function refuses for ever, a resolve pays the winner and Refund's Resolved check refuses for ever. Neither ordering can pay the same ugnot twice, which is the property that matters. (The fairness cost of that race is recorded in AUDIT-2026-09-28.md finding 7: past the grace window, on a week whose round HAS since been published, a buyer who can see they did not win can refund rather than lose. It is bounded by the window being twice the beacon's entire retention — the state only exists after an outage — and closing it properly means a two-phase refund, which is a separate change.)

func SpendOf

Action
1func SpendOf(addr address) int64
source

SpendOf is addr's refundable contribution to the CURRENT week in ugnot: THE GROSS TICKET PRICE THEY PAID, house fee included, which is exactly what Refund returns.

THIS COMMENT SAID THE OPPOSITE UNTIL NOW AND THE VALUE NEVER CHANGED. It read: "what they paid MINUS the house cut, which is what Refund would return. It is not the gross ticket price they paid." That was true until 2026-09-29, when the house fee stopped being banked at sale time and started being escrowed per week in Draw.Cut (AUDIT-2026-09-28.md finding 25): spendOf became the GROSS in the same change, and Refund returns the whole ticket price. The function was correct and its documentation was not — THE WORST SHAPE OF ALL FOR AN EXPORTED, FRONT-END-FACING READ, because a caller who believed it would display a refund quote 5% short and nothing would ever fail. Corrected rather than deleted, because the old sentence is the one a reader may have in their head. AUDIT-2026-09-28.md finding 29.

func SpendOfWeek

Action
1func SpendOfWeek(week int64, addr address) int64
source

SpendOfWeek is addr's remaining refundable contribution to a given week: their GROSS ticket spend, fee included, which is what Refund pays them. Zero once they have been refunded. Read SpendOf for the claim this pair used to make and why it is retired.

func Sponsor

crossing Action
1func Sponsor(cur realm, amount int64, label string)
source

Sponsor puts money into this week's prize and buys a credit line beside it. The sponsor gets no entries and cannot win: that is what separates a sponsorship from a ticket.

THE PUBLIC DOOR, WHERE FundPot IS THE DUEL'S. They were one function until 2026-09-30 and splitting them is the whole reason the rules below can exist. FundPot carries the duel's rake, in whatever amount has accumulated — often far less than MinSponsorship, and already charged its ops share on the duel's side. Putting a floor or a second fee on that path would strand small flushes and double-charge money that has already paid. So the duel keeps its own unpriced door and this one is for people buying advertising.

WHAT HAPPENS ON A WEEK THAT NEVER DRAWS, because a sponsor should be able to find it out before paying: the money stays with this week for as long as the week can still be drawn, which is indefinitely — Resolve has no deadline and the round stays publishable. A week only ends without drawing when a TICKET BUYER reclaims their own stake after the grace window, and at that moment this sponsorship, the house's cut of it included, is divided among the addresses holding units in this week. It does not roll forward to a future week: that was removed by AUDIT-2026-09-28.md findings 28 and 31, because the caller chose the destination by choosing when to call.

func SponsorCutOf

Action
1func SponsorCutOf(week int64) int64
source

SponsorCutOf is how much of a week's escrowed fee came from sponsorships rather than ticket sales. Zero on a week that has retired its sponsorship, which is what makes "the fee found its way out" checkable from outside.

func SponsorRefundableFor

Action
1func SponsorRefundableFor(week int64, addr address) int64
source

SponsorRefundableFor is what a dead week owes ONE sponsor, and zero once they have pulled it. Without it a sponsor has to fetch SponsorsOf, sum their own rows by hand, and still cannot tell whether they already claimed — the tree that records that is unexported. 2026-09-30 audit, Y3.

func SweepDrawlessWeek

crossing Action
1func SweepDrawlessWeek(cur realm, week int64)
source

SweepDrawlessWeek rolls a FINISHED week's sponsorship forward into the live week's pot, when nobody ever bought a ticket in it. PERMISSIONLESS, like Commit.

THERE ARE TWO SWEEPABLE SHAPES SINCE 2026-09-29 AND THE SECOND HAS A CLOCK ON IT: a week with NO TICKETS AT ALL (the original drawless week, sweepable as soon as it is over) and a week with TICKETS BUT NO SPEND — every entry earned by duel play — which is sweepable only once it is committed and refundGraceElapsed has gone true, and which is marked Abandoned when it is. The full reasoning, including why "zero spend" alone would have been a fund-destroying gate, is in the body above the shape test. AUDIT-2026-09-28.md finding 17.

FINDING 15 MADE THIS FUNCTION MUCH HARDER TO REACH AND IT IS DELIBERATELY KEPT. A week of duel rake used to end with sponsorship and a zero ticket total, which is exactly the state this function exists for. Now the duel's deliveries bring ENTRIES with them (FundPotWithEntries), so such a week normally has tickets, is a real draw, and this function correctly refuses it — its sponsorship is a prize somebody can win rather than money nobody can reach. The states that still land here are real and small: a rake too small to buy one whole entry (rake < EntryUnitPrice), a duel both of whose parties played from realm code and so earned nothing, and any plain FundPot sponsorship from a caller that is not the duel. DO NOT DELETE IT ON THE GROUNDS THAT THE COMMON CASE IS GONE: every one of those states still strands money without it, and that was the sixth of this product's "money in, no way out" findings.

THE DEFECT IT CLOSES. FundPot credits the live week and creates that week's record if it does not exist yet, so a week in which duels were collected but nobody ever bought a raffle ticket ends the week holding sponsorship in a Draw with a zero ticket total. Commit rightly refuses that week (no tickets, no unit to draw, no owner to pay), Resolve and Refund both require Committed, and FundPot only ever funds the live week — so before this function that sponsorship was unreachable by EVERY path in this realm, permanently, while heldPots went on counting it and checkInvariant and AuditFailure stayed green over money that was gone. That is the same class of defect as the 2026-09-28 audit's first and most serious finding, and it is not an exotic state: a quiet raffle week with an active coin flip is an ordinary Tuesday.

WHERE THE MONEY GOES, AND WHY NOT opsPot, AND WHY IT DOES NOT GO ANYWHERE AT ALL. It goes BACK TO THE PEOPLE WHO HOLD UNITS IN THIS WEEK, pro rata — and for shape 1, which has no unit holders whatsoever, it goes to opsPot because there is provably nobody else. Read retireSponsorship; the branch this function takes is exactly `pureEarned` and is stated again at the call site. This money is the PLAYERS' side of the duel rake (see r/splash/arcade/duel's Collect: the ops share went to r/splash/ops already, and what arrives here is deliberately the rest), so on shape 2 it lands back on the duel players who generated it.

TWO SENTENCES THIS REPLACES, IN ORDER, BECAUSE THE SEQUENCE IS THE LESSON. First: "So it rolls forward to the live week's pot" — the live week being whichever week the caller chose to fire in (finding 28). Then: "It marks the week and registers it (markCarryPending); the next week record to be created absorbs it at its own birth... to a week nobody in this transaction can name" — except that the caller who opens that week is the caller who calls Buy, so they could name it after all, and be its only entrant (finding 31). Both sentences described a destination. There is none now.

AND REFUND NOW AGREES, WHICH IT DID NOT UNTIL 2026-09-29. This paragraph used to end "Contrast Refund, which moves an abandoned week's sponsorship to opsPot: there the week HAD entrants and they are being paid back individually, so there is no prize left for the sponsorship to enlarge." THE CONTRAST WAS THE DEFECT. Two functions in one file moved the same field's money to opposite destinations, and since earned entries exist an abandoned week's "entrants" are mostly duel players with no spendOf to be paid back at all — so the rationale did not hold either. One ticket plus the grace window turned the whole argument into a cheap transfer of a week of rake to the house. AUDIT-2026-09-28.md finding 16. Refund's transition now retires the sponsorship exactly as this function does, and retireSponsorship is the shared step — a step with no destination in it.

THIS FUNCTION STILL DIFFERS FROM REFUND IN ONE WAY, AND MUST, BUT THE DIFFERENCE IS NOW A CLOCK RATHER THAN A DESTINATION. It may REFUSE — the live-week rule, the shape tests, and the grace window each shape must wait out — because a dead week's sponsorship has no claimant and no deadline, so a refusal costs nobody anything and any caller may retry for ever. Refund may not refuse, because a buyer is waiting; and since neither function looks for a destination any more, there is no longer any state in which Refund COULD be refused for want of one. That is strictly stronger than both fallbacks it replaced — the immediate opsPot bank of finding 16 and ReclaimStrandedCarry's three-week long stop of finding 28 — and both of them are now deleted rather than relocated, because a claim on the week the money is already in cannot fail to find anything.

THE DESIGN TENSION, RESOLVED EXPLICITLY SO IT IS NOT "FIXED" BACK. This realm's stated principle is that THERE IS NO ROLLOVER, so a drawless week must not exist — and Commit still enforces exactly that, unchanged. That principle is about TICKET HOLDERS' pots: it stops a week's prize being deferred away from the people who entered that week and paid for it. Rolling SPONSORSHIP forward from a week that had no entrants whatsoever does not violate it, because there was nobody to defer anything away from — no ticket was sold, so no buyer's expectation of this week's prize exists to be broken. Do not "restore consistency" by deleting this function or by letting Commit accept a zero total; the second would reopen the drawless-draw hole, and the first would reopen the stranded-money hole.

NO WUGNOT MOVES HERE, and a reader looking for the missing Transfer should stop looking. The tokens are already inside this realm — FundPot pulled them in when the dead week was sponsored — so this is PURE BOOKKEEPING between two weeks of the same realm. The realm's wugnot balance is identical before and after; heldPots and heldSponsored each lose and regain exactly the same amount, which is why checkInvariant's equality still holds at the end.

Panics if the week is not over yet (a live week may still sell a ticket, which would make it a real draw), if there is no record for it, if anybody BOUGHT a ticket in it, if it holds no sponsorship, if it is resolved or already abandoned, if its entries are all earned and its draw can still happen (uncommitted, or inside the grace window), or — defensively — if a ticketless week is somehow committed.

func TicketTotalOfWeek

Action
1func TicketTotalOfWeek(week int64) int64
source

TicketTotalOfWeek is a given week's ticket units sold: the LIVE total, not FrozenTotal, so it is readable before a commit and is zero for a week that never sold a ticket.

IT IS THE READ THAT MAKES A DRAWLESS WEEK VISIBLE. DrawOf reports FrozenTotal, which is zero on any uncommitted week, so a caller cannot use it to tell "sold nothing" from "sold plenty and was never committed". Sweepability turns on exactly that distinction: a past week is sweepable when this is zero and SponsoredOf is positive. See SweepDrawlessWeek.

func TicketsOf

Action
1func TicketsOf(addr address) int64
source

TicketsOf is addr's ticket units in the CURRENT week.

func TicketsOfWeek

Action
1func TicketsOfWeek(week int64, addr address) int64
source

TicketsOfWeek is addr's ticket units in a given week, so a front end can still show a buyer their past weeks once the clock has moved on.

func Unclaimed

Action
1func Unclaimed() int64
source

Unclaimed is the sum of every week's unclaimed dead-week shares — the aggregate heldClaims, exposed so it can be read without iterating. It is already inside the pots, so it is not a fourth balance.

func UnclaimedHoldersOf

Action
1func UnclaimedHoldersOf(week int64) int64
source

UnclaimedHoldersOf is how many named addresses a dead week still owes a share to, and it is the other half of AUDIT-2026-09-28.md finding 37 — the audit's open question, "let an operator distinguish heldClaims AWAITING LIVE HOLDERS from heldClaims FROZEN FOR EVER".

heldClaims ON ITS OWN CANNOT ANSWER THAT and neither can UnclaimedOf: a positive number means the same thing in both cases. This read turns the amount into a COUNT OF PEOPLE, so "3,600,000 ugnot owed to 24 addresses" reads differently from "3,600,000 ugnot owed to 24 addresses that have not moved in a year", and an operator can at least see which weeks are settled and which are not. The DeadWeekFullyClaimed event in ClaimDeadWeek is the push version of the same fact, for a week that reaches zero.

IT IS O(EARNED HOLDERS) AND THEREFORE A READ-ONLY FUNCTION AND NOTHING ELSE. It is never called from a state-changing path — that is the whole reason retireSponsorship writes a rate instead of a tree — and a caller who pays the gas to ask is paying for their own answer. COLLECT-THEN-LOOP, NEVER A NESTED avl.Iterate: the earnedOf keys are gathered in one pass and every claimedOf lookup happens after it, because nesting a second traversal inside the first's callback crashes this toolchain's VM outright (see AuditFailure).

ZERO MEANS "NOTHING LEFT TO PAY" and is the answer on a live week, a resolved week, a week that never distributed anything, and a week every holder has claimed out.

func UnclaimedOf

Action
1func UnclaimedOf(week int64) int64
source

UnclaimedOf is how much of a dead week's distributed sponsorship its holders have not taken yet — Draw.Claims. It is PART OF THAT WEEK'S POT, so PotSizeOf-style reads already include it; this read exists so an operator can see how much of a dead week's money is still sitting there, and so AuditFailure's heldClaims re-derivation can be read alongside it.

func WeekCount

Action
1func WeekCount() int64
source

WeekCount is the number of weeks that have a record: weeks that sold a ticket or were sponsored. O(1); avl keeps its size.

func WeekEndHeight

Action
1func WeekEndHeight(week int64) int64
source

WeekEndHeight is weekEndHeight, EXPORTED: the chain height at which a given week stops being the live one. A week is live for heights [WeekEndHeight(week-1), WeekEndHeight(week)) — the returned height is the FIRST height of week+1, NOT the last height of week, so at exactly this height CurrentWeek already reads week+1. A countdown that showed "1 block left" here would be off by one.

IT IS A BLOCK HEIGHT AND DELIBERATELY NOT A TIME. This realm's week is raffleWeekDays*blocksPerDay blocks, and blocksPerDay = 26182 encodes an ASSUMED 86400/26182 = 3.29998 s per block (read blocksPerDay for how much that assumption is allowed to be wrong). The chain does not actually run at that rate: 3.306 s measured on gno.land mainnet and 3.376 s on onyx-1. So a caller wanting a wall-clock countdown must multiply by a block time IT chooses and own the error; this realm will not return seconds or "time remaining", because that would bake one clock's rounding into every front end that reads it and make the contract the source of a number it cannot know. Nothing on chain converts blocks to seconds and neither does this.

WHY THIS AND NOT AN EXPORTED launchHeight: it is the stronger primitive, not the weaker one. raffleWeekDays and blocksPerDay are both unexported, so a LaunchHeight() alone could not be turned into a boundary without a caller hardcoding 7*26182 and re-implementing the +1 above. This read goes the other way: launchHeight is WeekEndHeight(0) - (WeekEndHeight(1) - WeekEndHeight(0)) and the week's length is WeekEndHeight(1) - WeekEndHeight(0), so one function answers every question the constant would have, with the off-by-one computed once, here, on chain.

NEGATIVE OR FAR-FUTURE week IS NOT REFUSED and returns the arithmetic for it, exactly as weekEndHeight does — this is a pure function of week and launchHeight with no record lookup, so there is no "no such week" state to report. WeekEndHeight(-1) is launchHeight, the height this realm's week 0 began at, which is a useful answer rather than an error.

func Weeks

Action
1func Weeks(offset, limit int64) []int64
source

Weeks is a window over the recorded week numbers in ASCENDING ORDER: it skips `offset` weeks and holds at most min(limit, PageLimit). Empty past the end. Negative arguments are a caller error and panic.

Ascending order is week order, not merely key order, because weekKey is fixed-width and zero-padded (see keyWidth) — the same property Render relies on.

A CALLER FINDS THE SWEEPABLE WEEKS by walking this and keeping those where week < CurrentWeek(), TicketTotalOfWeek(week) == 0 and SponsoredOf(week) > 0.

func WithdrawOps

crossing Action
1func WithdrawOps(cur realm) int64
source

WithdrawOps pushes the whole ops pot to opsVaultAddr and returns the amount moved. PERMISSIONLESS.

THE DEFECT IT CLOSES, found 2026-09-28 by the engineer implementing that day's fifth fix rather than by the audit: opsPot only ever increased. It was written in three places — Buy's house cut (now Resolve's released escrow, finding 25), sweepDonations' absorbed arrivals, and Refund's move of an abandoned week's sponsorship — and read by nothing but the invariant and the reads. There was no outflow anywhere in this file, so three separate streams of money became permanently unreachable by every path in the realm, THE PRODUCT'S ENTIRE REVENUE AMONG THEM. checkInvariant and AuditFailure both stayed green over it, because both count opsPot as money the realm holds and neither has an opinion about whether anybody can ever reach it. That is the same class of defect as the unreachable week (the audit's first finding) and the stranded drawless-week sponsorship (SweepDrawlessWeek).

PERMISSIONLESS, AND THAT IS SAFE PRECISELY BECAUSE THE DESTINATION IS FIXED AT COMPILE TIME. A reader will look for a caller check here and there is none, on purpose: opsVaultAddr is a const, so the caller does not choose where the money goes, cannot name themselves, and cannot name a week's pot. All they can do is push the house's money to the house — and paying the gas to do somebody else's bookkeeping is a favour, not an attack. Gating it on an operator key would buy nothing and would put a liveness dependency for the realm's revenue onto one key, exactly as it would for Commit, Capture and Resolve. This is r/splash/exchange's SettleOps shape, and the property that makes it sound is the one tools/raffle_addr_test.go exists to keep true.

IT NEVER REACHES A PLAYER'S MONEY. No Draw is touched: not Pot, not Sponsored, not spendOf, not a ticket. The only book that moves is opsPot, which by construction holds nothing any buyer can claim — the fee in it was EARNED BY A DRAW THAT ACTUALLY RAN (Resolve releases the week's escrow; a week that fails refunds it with the gross, finding 25), a donation belongs to whoever chose to give it away, and a dead week's sponsorship was never attributable to a buyer at all.

THE CITATION THAT USED TO STAND HERE IS RETIRED: this paragraph said "the cut was taken at sale time (see Refund's 'THE HOUSE CUT IS NOT REFUNDED')", and there is no such section in Refund any more — finding 25 deleted the behaviour and Refund records the retired paragraph verbatim in its place. A citation that outlives the thing it cites is how a reader ends up trusting a rule the code stopped following. AUDIT-2026-09-28.md finding 29. NOTE THAT THE THIRD STREAM IS NOW ALMOST CLOSED: since 2026-09-29 a dead week's sponsorship goes forward to a future week's prize and reaches this counter only when no week can be opened at all (Refund's transition, and AUDIT-2026-09-28.md finding 16). That is a deliberate reduction in house revenue and it is the safe direction.

AND SINCE 2026-09-29 IT CANNOT REACH AN UNEARNED FEE EITHER, which is the point of finding 25 from this function's side. The house cut used to arrive in opsPot the instant a ticket was sold, so this call could push out money for draws that had not happened yet — and the buyers of a week that then failed got 95% back while the missing 5% had already left the realm. The fee now sits in Draw.Cut until Resolve releases it, heldCuts is a separate term in checkInvariant, and this function does not mention either. A caller who withdraws every block still cannot touch a live week's escrow.

sweepDonations RUNS FIRST, as in every other mutator, so a donation that arrived since the last state change is included in this withdrawal rather than left behind for the next one.

BOOKS FIRST, TRANSFER SECOND, exactly as Resolve and Refund do it: opsPot is zeroed BEFORE the wugnot leaves, so a re-entrant call — which wugnot's Transfer cannot cause today, having no hook or callback, but a future GRC20 might — finds an empty pot and panics rather than a balance it can take twice.

THE INVARIANT IS EXACT ACROSS IT, not merely preserved one-sidedly: checkInvariant asserts heldPots + opsPot == balance, and this call reduces both sides by the same amount. heldPots and heldSponsored are deliberately untouched.

Panics if there is nothing to withdraw.

func SponsorsOf

Action
1func SponsorsOf(week int64) []Sponsorship
source

SponsoredOf is the house money in a given week's pot: what FundPot added, and therefore the part of the pot no buyer can ever reclaim. Zero for a week that was never sponsored.

THE SECOND HALF OF THIS COMMENT WAS FALSE, WAS CORRECTED, AND IS NOW TRUE AGAIN FOR A DIFFERENT REASON. It originally said "and zero again once an abandoned week's sponsorship has been moved to the ops pot"; that became wrong when the money started going forward to a future week (finding 16) and wrong twice over when it stopped moving at the moment of abandonment at all (finding 28).

SINCE FINDING 31 THIS IS ZERO ON EVERY ABANDONED WEEK, and that is a guarantee rather than an accident: retireSponsorship zeroes the field in the same statement group that sets Abandoned, and AuditFailure reports any week where the two disagree. What the money became is a per-unit claim on the SAME week — read ClaimPerUnitOf, UnclaimedOf and ClaimableOf, and ClaimDeadWeek for the exit. A front end showing an abandoned week should show those three, not this one. SponsorsOf is the credit lines a week carries, in the order they were bought.

A COPY, NOT THE SLICE. Returning d.Sponsors itself would hand a caller a reference into this realm's own storage; the copy is the same discipline as every other read here returning values rather than pointers.

Types 2

type Draw

struct
  1type Draw struct {
  2	Week int64
  3
  4	// Pot is the prize pot in ugnot: the sum of every buyer's contribution
  5	// AFTER the house cut, PLUS any house money sponsored into this week
  6	// (see Sponsored). Zeroed by Resolve when it pays the winner, and
  7	// decremented by Refund as each buyer reclaims their share.
  8	//
  9	// THE IDENTITY IS NOW Pot + Cut == sum(spendOf) + Sponsored, and it is what makes
 10	// Refund unable to overdraw. It was Pot == sum(spendOf) + Sponsored until
 11	// 2026-09-29, when spendOf started recording the buyer's GROSS payment and the
 12	// house's fee started being escrowed in Cut instead of banked at sale time. Read
 13	// Cut, and AUDIT-2026-09-28.md finding 25.
 14	Pot int64
 15
 16	// Cut is THE HOUSE'S FEE FOR THIS WEEK, HELD IN ESCROW AND NOT YET EARNED: the sum
 17	// of HouseCutBps of every ticket sold into this week. In ugnot, like everything
 18	// else here.
 19	//
 20	// IT IS NOT PART OF Pot AND THE WINNER MUST NEVER WIN IT. Resolve pays Pot to the
 21	// winner and moves Cut to opsPot, in that order, as two separate amounts. If the
 22	// fee were folded into Pot the winner would be paid the house's money; if it were
 23	// in opsPot from the moment of sale — which is what happened until 2026-09-29 —
 24	// then a week that never drew would refund only 95% of what its buyers paid.
 25	//
 26	// THE RULE IT IMPLEMENTS IS THE OWNER'S: WE EARN WHEN THE GAME RUNS, NOT WHEN IT
 27	// FAILS. So the fee has exactly two destinations and no third:
 28	//
 29	//   - A SUCCESSFUL Resolve moves it to opsPot. The draw happened; we ran it.
 30	//   - ANY OTHER OUTCOME REFUNDS IT. Refund returns each buyer's GROSS spend and
 31	//     decrements this field by exactly the part of that gross which came here in
 32	//     the first place (see Refund's split, and cutPerTicket's build guard for why
 33	//     that re-derivation is exact rather than approximately right). An ABANDONED
 34	//     week therefore hands the fee back with the prize money, which is the same
 35	//     principle finding 16 applied to Sponsored: we did not run a draw, so we do
 36	//     not keep the fee.
 37	//
 38	// AND IT IS A TERM IN BOTH SOLVENCY STATEMENTS, not a note on the side. The realm
 39	// holds this wugnot, so checkInvariant is heldPots + heldCuts + opsPot == balance
 40	// and AuditFailure re-derives both new terms by iteration. A field the realm holds
 41	// money for and the invariant does not mention is the shape of a hole.
 42	//
 43	// A SWEPT OR DRAWLESS WEEK HAS Cut == 0 ONLY AFTER foldSponsorCut RUNS, AND IT USED
 44	// TO SAY 'BY CONSTRUCTION'. That was true until Sponsor gave a fee a second way in,
 45	// with no ticket sale behind it, and the stale sentence is exactly what produced the
 46	// 2026-09-30 audit's RED finding: SweepDrawlessWeek asserted the old claim and
 47	// stranded the whole week. The original reasoning, kept because it is still why the
 48	// assertion is worth making, because both sweepable
 49	// shapes require sum(spendOf) == 0 and a fee can only arrive with a ticket sale.
 50	// SweepDrawlessWeek asserts that rather than assuming it.
 51	Cut int64
 52
 53	// Sponsored is the part of Pot that came from FundPot rather than from a
 54	// ticket sale: house or rake money, ATTRIBUTABLE TO NO BUYER. It buys no
 55	// tickets and no winning chance; it only makes the prize bigger.
 56	//
 57	// THE MONEY IS STILL ATTRIBUTABLE TO NO BUYER AFTER FINDING 15, AND THAT IS
 58	// WORTH BEING PRECISE ABOUT. Duel play now earns ENTRIES as well as sending
 59	// rake (FundPotWithEntries), so the same transaction that raises this field
 60	// also appends ticket weight for the two players. The two are still separate
 61	// facts: the entries are weight with no spendOf record, this field is money
 62	// with no owner, and neither is a claim on the other. The funder — the duel
 63	// realm — still gets nothing at all, so a realm that funds a pot still cannot
 64	// be the address a payout is pushed to.
 65	//
 66	// IT IS A SEPARATE FIELD BECAUSE THE REFUND SAFETY PROPERTY IS AN IDENTITY,
 67	// NOT AN INEQUALITY. Every ugnot of a week's pot must be attributable either
 68	// to a buyer who can reclaim exactly their own contribution, or to this
 69	// field. Adding sponsorship straight into Pot with nothing recording it
 70	// would leave Pot > sum(spends) — refunds could then no longer be shown to
 71	// be unable to overdraw, and the books could not tell house money from a
 72	// buyer's claim. So the invariant is Pot == sum(spendOf) + Sponsored, and
 73	// both checkInvariant (through heldSponsored) and AuditFailure (by
 74	// iteration) are written in those terms.
 75	//
 76	// IT IS NEVER LEFT BEHIND IN A POT NOBODY CAN REACH, and there are two such
 77	// pots. On an ABANDONED week (see Refund) nobody can reclaim it, so leaving it in Pot
 78	// while every spend is zeroed and nothing could ever move it would strand it forever.
 79	// On a finished week that NEVER SOLD A TICKET (see SweepDrawlessWeek) that week can
 80	// never be committed, so it can never be resolved or refunded either, and the money
 81	// would be just as stranded. Both are the same class of defect as the unreachable week
 82	// the 2026-09-28 audit's first finding was about.
 83	//
 84	// WHAT REACHES IT IN BOTH CASES IS retireSponsorship, SINCE 2026-09-29 (SECOND
 85	// REVISION — finding 31). This field is zeroed AT THE MOMENT THE WEEK IS ABANDONED and
 86	// the money is turned into A PER-UNIT CLAIM ON THIS SAME WEEK (ClaimPerUnit, Claims),
 87	// payable to the people who hold units IN THIS WEEK. It never changes weeks, never
 88	// waits in a register, and has no destination anybody can name. So `Sponsored > 0 on an
 89	// abandoned week` is now a STATE THAT CANNOT EXIST, and AuditFailure says so.
 90	//
 91	// THE DESTINATION USED TO DISAGREE WITH ITSELF, THEN AGREED, THEN BECAME CHOOSABLE,
 92	// THEN BECAME A REGISTER, AND IS NOW GONE. The full sequence, because it is the whole
 93	// lesson:
 94	//
 95	//   - Until 2026-09-29 the abandoned-week path sent this money to opsPot while the
 96	//     drawless-week path rolled it forward, over the same field, in the same file
 97	//     (AUDIT-2026-09-28.md finding 16: that asymmetry is a finding, not untidiness).
 98	//   - Both then rolled it into currentWeek() — the week live at the moment a
 99	//     permissionless caller fired, which let the caller pick the destination by picking
100	//     the moment (finding 28).
101	//   - Then a REGISTER (carryPending) held it until the next week record pulled it in at
102	//     its own birth. That was meant to make the destination unchoosable. It did not:
103	//     a caller could BUY ONE TICKET IN THE FINAL BLOCK OF A QUIET WEEK, which both
104	//     created that week's record and absorbed every pending carry into a week whose
105	//     entire ticket set was their own — 100% capture for the price of the house cut
106	//     (finding 31). The "destination is young" property the design rested on was
107	//     asserted in a test and never enforced in the code.
108	//   - Now there is NO DESTINATION AT ALL. The money goes back to the addresses that
109	//     hold units in the week it was already in.
110	Sponsored int64
111
112	// SponsorCut is HOW MUCH OF d.Cut CAME FROM SPONSORS rather than from ticket
113	// sales, and it exists for exactly one reason: so that fee has a way out of an
114	// abandoned week.
115	//
116	// THE DEFECT IT PREVENTS, STATED PLAINLY BECAUSE IT IS INVISIBLE OTHERWISE. Refund
117	// is what retires a buyer's share of d.Cut, and it does so by RE-DERIVING the fee
118	// from that buyer's own spendOf. A sponsor has no spendOf — sponsorship buys no
119	// units, that is its definition — so no Refund call will ever reach a sponsor's
120	// fee. Left in d.Cut it would be money that entered the realm and could not leave,
121	// and checkInvariant would go on balancing happily the whole time, because a
122	// balanced invariant is not evidence of a reachable exit.
123	//
124	// AND FOLDING SPONSORSHIP INTO spendOf TO REUSE Refund IS NOT THE ALTERNATIVE. That
125	// re-derivation is exact ONLY because a spendOf is a whole number of TicketPrice;
126	// an arbitrary sponsorship is not, so d.Cut would go negative and a BUYER's own
127	// reclaim would start panicking. That arithmetic is AUDIT-2026-09-28.md finding 31.
128	//
129	// SO IT LEAVES BY THE DOOR THE MONEY IT WAS TAKEN FROM LEAVES BY: retireSponsorship
130	// folds it back into Sponsored on the abandonment transition, and it is divided
131	// among that week's unit holders with the rest. On a week that DOES draw it is not
132	// folded and goes to ops with every other fee, which is the owner's rule for fees
133	// generally — earned only if the draw actually runs.
134	SponsorCut int64
135
136	// SponsorPaid is the GROSS total of every Sponsor call on this week, and it is
137	// what separates money with an owner from money without one.
138	//
139	// d.Sponsored MIXES TWO SOURCES AND ONLY ONE OF THEM HAS A PAYER. FundPot — the
140	// duel's door — puts ownerless rake there, money attributable to no address.
141	// Sponsor puts a named person's payment there. Until 2026-09-30 an abandoned week
142	// treated both the same and divided them among unit holders, or sent them to the
143	// house when there were no earned units. The owner ruled that out: a sponsor's
144	// money is "either returned to sponsor or rolls over until a draw happens", and
145	// rolling forward is what AUDIT-2026-09-28.md findings 28 and 31 removed, because
146	// the caller picked the destination week by picking when to call. So it is
147	// returned, and this is the figure that says how much of the pot is owed back.
148	SponsorPaid int64
149
150	// SponsorRefundable is what is left of SponsorPaid once the week has died and
151	// before its sponsors have pulled it back. It is still sitting in d.Pot.
152	SponsorRefundable int64
153
154	// sponsorRefundedOf marks the addresses that have already pulled their refund, so
155	// a second call pays nothing rather than paying twice.
156	sponsorRefundedOf *avl.Tree
157
158	// Sponsors is the credit line each sponsorship bought, in the order they were
159	// bought, bounded by maxSponsorsPerWeek.
160	//
161	// THE TEXT IS A STRANGER'S AND IT IS PERMANENT. This is the first untrusted text
162	// this realm has ever stored. NOTHING ESCAPES IT TODAY, and an earlier version of
163	// this sentence claimed both ends did. checkSponsorLabel deliberately does not
164	// sanitise — escaping belongs where the target syntax is known — and Render does
165	// not yet emit this field at all.
166	//
167	// WHOEVER ADDS THAT DISPLAY MUST ESCAPE IT. Render builds a PIPE-DELIMITED
168	// MARKDOWN TABLE; the label may legally hold '|', '[', ']', '<', '>' and
169	// backticks, and 64 bytes is ample to forge extra cells and fake a column. Use the
170	// duel's approach: sanitize.InlineText, plus escaping '|'. Flagged by the
171	// 2026-09-30 audit, finding Y3.
172	//
173	// Escaping cannot strip CONTENT either way, and nothing can delete it
174	// once written — a later realm version does not erase what an earlier one wrote.
175	// The owner was told exactly that on 2026-09-30 and accepted it: "whatever, we can
176	// always redeploy with 2.0 or whatever, it's a game".
177	Sponsors []Sponsorship
178
179	// ClaimPerUnit is the UGNOT EACH *EARNED* ENTRY UNIT OF THIS WEEK MAY RECLAIM from the
180	// sponsorship this week's draw never got to pay out. ZERO ON EVERY LIVE WEEK and
181	// written exactly once, by retireSponsorship, at the moment the week is abandoned.
182	//
183	// "EARNED" IS LOAD-BEARING AND THIS COMMENT SAID "EACH TICKET UNIT" UNTIL
184	// AUDIT-2026-09-28.md finding 34 — so did the code. A BOUGHT unit contributed nothing to
185	// the sponsorship and recovers its own money in full through Refund; an earned unit IS the
186	// sponsorship, at 150,000 ugnot, and has no other route home. Dividing on both kinds paid
187	// a refunded buyer out of the duel players' rake. Read Draw.earnedOf and
188	// retireSponsorship.
189	//
190	// IT IS THE WHOLE OF finding 31's FIX AND IT IS A RATE, NOT A BALANCE. Writing one
191	// number instead of one tree entry per holder is what keeps the abandonment transition
192	// O(1): the transition happens inside Refund, which MUST NOT FAIL (a buyer is waiting),
193	// and a loop over an unbounded holder set is a gas limit waiting to make a week's
194	// refunds impossible — which would be a fresh "money in, no way out" path created by
195	// the fix. A holder's claim is derived on demand in ClaimDeadWeek as
196	// earnedOf[holder] * this, which is O(log N) and cannot be starved.
197	//
198	// IT IS A WHOLE NUMBER OF UGNOT PER EARNED UNIT, so the shares sum to
199	// ClaimPerUnit * earnedTotal EXACTLY and never to more than the sponsorship. The
200	// remainder — strictly less than the week's EARNED unit count, in ugnot, which is under
201	// seven parts per million of the sponsorship — is dust with no
202	// unit to attach to; retireSponsorship moves it to opsPot in the same statement and
203	// records it in the event, so it is never left in a pot with no claim on it. Read that
204	// function for the arithmetic, for the improved bound, and for why the alternatives (a
205	// largest-remainder pass, or first-claimant-takes-the-dust) were rejected.
206	//
207	// NON-ZERO IMPLIES Abandoned, always, and AuditFailure re-derives that. An unabandoned
208	// week can still sell tickets and still resolve, so a claim rate on one would be a
209	// claim on a prize somebody is about to win.
210	ClaimPerUnit int64
211
212	// Claims is the UNCLAIMED part of what retireSponsorship distributed: it starts at
213	// ClaimPerUnit * earnedTotal and falls by each holder's share as ClaimDeadWeek pays
214	// it. IT IS PART OF Pot, exactly as Sponsored was, so no wugnot moved when it came into
215	// existence and the realm's balance did not change.
216	//
217	// THE DIVISOR IN THAT PRODUCT WAS tickets.Total() UNTIL AUDIT-2026-09-28.md finding 34,
218	// and the sum of every possible claim is EXACTLY this number under either basis — that was
219	// never the defect. What was wrong is WHO the claimants were: bought units took a share of
220	// money they had not contributed. Read Draw.earnedOf.
221	//
222	// IT IS A TERM IN BOTH SOLVENCY STATEMENTS. The per-week identity is
223	//
224	//	Pot + Cut == sum(spendOf) + Sponsored + Claims
225	//
226	// and the global one gains heldClaims. A field the realm holds money for and the
227	// invariant does not mention is the shape of a hole — the same sentence Cut carries,
228	// for the same reason.
229	Claims int64
230
231	// claimedOf is string(address) -> the amount that holder has already taken out of
232	// Claims. AN ENTRY MEANS "PAID", so ClaimDeadWeek is all-or-nothing per holder and can
233	// never pay twice: a holder's EARNED unit count cannot change after abandonment (an
234	// abandoned week is always a past week, and FundPotWithEntries — the only writer of
235	// earnedOf — only ever touches currentWeek()), so their share is a constant and one entry
236	// settles it for ever.
237	//
238	// THE FROZEN-BASIS PROPERTY IS ENFORCED BY THREE PANICS AND NOT BY THIS SENTENCE, which
239	// is the difference between this and finding 31's second attempt — that one rested on a
240	// property asserted in a test and never enforced in code. The three, named so a future
241	// reader knows they are load-bearing and not hygiene:
242	//
243	//  1. FundPot's refusal of a committed or abandoned week. It is the money path
244	//     FundPotWithEntries calls before it appends anything, so no earned unit can ever
245	//     reach a week that is already dead.
246	//  2. retireSponsorship's "only an abandoned, unresolved week's sponsorship may be
247	//     retired". The rate cannot be written before the week's exit is one-way.
248	//  3. retireSponsorship's "that week's sponsorship has already been retired". The rate
249	//     cannot be written twice, so it cannot be re-derived against a basis that has moved.
250	//
251	// TestFix34_ThreePanicsEnforceTheFrozenBasis names all three and drives each one.
252	claimedOf *avl.Tree
253
254	Committed   bool
255	FrozenTotal int64 // ticket units at commit. NEVER recomputed at resolve.
256
257	// Round is the beacon round this week is drawn on. WRITTEN EXACTLY ONCE, by
258	// getOrCreateDraw, at the moment this record is created — which is before the
259	// week's first ticket exists — and never written again by anything, Commit
260	// included. Read pinOffsetRounds for why, and for what that does and does not
261	// protect against; the one-sentence version is that the committer used to
262	// choose this number after the ticket set was already frozen and public, which
263	// handed the beacon's key holder a deterministic win.
264	Round int64
265
266	// CommittedAt is the CHAIN HEIGHT at which Commit froze this week, and it is
267	// the only clock the refund grace window reads. Zero on an uncommitted week.
268	//
269	// IT IS STORED, AND IT CANNOT BE DERIVED FROM ANYTHING ELSE. The week number
270	// comes from the same global clock, but a week may be committed at any time
271	// after it ends (Commit takes the week as an argument precisely so a stranded
272	// week is always recoverable), so "when was this week committed" is not a
273	// function of which week it is. Round cannot stand in for it either: Round is
274	// beacon state, and the whole point of this field is to give Refund a gate the
275	// beacon cannot hold shut. See refundGraceBlocks.
276	CommittedAt int64
277
278	// Randomness is the round's randomness, copied in by Capture. Empty means "not
279	// captured"; Resolve then reads the beacon directly. See Capture — and note that
280	// since the drand migration this field is a convenience, not a rescue: the
281	// beacon cannot lose a round, so nothing is preserved by copying it here.
282	Randomness string
283
284	Resolved bool
285	Winner   address
286
287	// Abandoned is set by the FIRST successful Refund for this week and is
288	// never cleared by anything. It makes the week's exit one-way: once one
289	// buyer has taken their money back, this week can never be resolved, and
290	// every later refund proceeds without re-asking whether the draw looks
291	// resolvable again.
292	//
293	// IT CANNOT BE DERIVED FROM THE BEACON, which is why it is stored — AND THE
294	// DRAND MIGRATION MADE THAT MORE TRUE, NOT LESS. The old reason was that
295	// resolvable() was not monotone: Publish had no window check and Prune removed a
296	// round without moving latestRound, so a pruned round's Published() went back to
297	// false and the same round could be published again, arbitrarily late. There is no
298	// Prune now, so Published() IS monotone — but resolvable() is gone entirely and
299	// a pinned round can be relayed by ANYONE, from drand, AT ANY TIME, FOR EVER. So a
300	// week in refund would otherwise stay resolvable indefinitely, which is worse than
301	// a window that occasionally reopened. Without this flag, anyone could let refunds
302	// open on a
303	// week, wait for some buyers to reclaim, then re-publish or late-publish the
304	// round and Resolve — drawing over the ORIGINAL frozen range, so a buyer who
305	// had already been refunded could win and collect what was left in the pot:
306	// the money of the buyers who had not reclaimed yet. Someone holding the
307	// signature did strictly better still, computing the winner first and then
308	// choosing Refund if it was not them and publish-and-Resolve if it was. Pot
309	// == sum(spends) held throughout, so neither checkInvariant nor AuditFailure
310	// saw anything — the identity is sound, it is just blind to WHO is paid.
311	Abandoned bool
312
313	// tickets is the weighted ticket index for this week. ONE INDEX PER
314	// PURCHASE is appended and no existing index's value is ever changed —
315	// that immutability is the whole frozen-ownership argument on Commit.
316	//
317	// SINCE FINDING 15 A PURCHASE IS NOT THE ONLY THING THAT APPENDS ONE: an
318	// earned entry credited through FundPotWithEntries appends exactly as a
319	// purchase does, through the same appendTickets, and is indistinguishable
320	// here by design. What distinguishes the two is spendOf, which an earned
321	// entry does not touch. Nothing may ever INSERT or ADJUST an index — fenwick
322	// refuses negative deltas by design and Commit's argument depends on it.
323	tickets *fenwick.Tree
324
325	// ownerOf is index -> string(address), the reverse lookup that turns a
326	// drawn unit back into a winner. MANY INDICES MAP TO THE SAME ADDRESS and
327	// that is expected: one entry per purchase.
328	ownerOf *avl.Tree
329
330	// ticketsOf is string(address) -> int64 ticket units, a read-side running
331	// total so TicketsOf is O(log N) and never has to walk the index. It is
332	// NOT read by the draw: Select and ownerOf decide the winner.
333	//
334	// IT IS NOT THE DEAD-WEEK CLAIM BASIS AND IT USED TO BE — AUDIT-2026-09-28.md finding
335	// 34. It counts BOUGHT and EARNED units together, which is exactly right for the draw
336	// (a unit is a unit, whoever paid for it, and that indistinguishability is finding 15's
337	// whole design) and exactly wrong for dividing up a dead week's sponsorship. Read
338	// earnedOf below for the arithmetic.
339	ticketsOf *avl.Tree
340
341	// earnedOf is string(address) -> int64 EARNED entry units: the subset of ticketsOf that
342	// was credited by FundPotWithEntries rather than bought with Buy. IT IS THE BASIS ON
343	// WHICH A DEAD WEEK'S SPONSORSHIP IS DIVIDED, and that is the whole of
344	// AUDIT-2026-09-28.md finding 34.
345	//
346	// WHY THE DRAW AND THE CLAIM NEED DIFFERENT BASES, WHICH IS THE ONE THING WORTH
347	// UNDERSTANDING HERE. Both kinds of unit are identical in the draw and must stay so.
348	// They are NOT identical in what they contributed to the thing a dead week divides:
349	//
350	//	An EARNED unit put EntryUnitPrice * arcadeRakePotShareBps / arcadeBpsDenom =
351	//	150,000 ugnot INTO Sponsored — it IS the sponsorship — and its holder can recover
352	//	that money by no other route, because an earned entry has no spendOf record and
353	//	Refund pays spendOf and nothing else.
354	//
355	//	A BOUGHT unit put NOTHING into Sponsored. It put TicketPrice / unitsPerTicket =
356	//	250,000 ugnot into Pot and Cut, and its holder recovers THAT IN FULL through Refund
357	//	— the whole gross, fee included, since finding 25.
358	//
359	// SO A REFUNDED BUYER ON AN ABANDONED WEEK ENDS WITH ZERO NET STAKE. Their money came
360	// back entirely and they KEEP THEIR UNITS, deliberately (fenwick rejects negative deltas
361	// by design, so a ticket cannot be retired once sold — see Refund). Dividing the
362	// sponsorship on ticketsOf therefore handed that buyer a positive claim on the duel
363	// players' rake for nothing at all: with equal counts of the two kinds, each player
364	// recovered HALF of what their own rake contributed and a refunded buyer took the other
365	// half. Paying those players is finding 31's entire stated purpose, so that was not a
366	// rounding error in the fix, it was the fix missing its target.
367	//
368	// sum(earnedOf) == earnedTotal IDENTICALLY, and it is guaranteed the same way
369	// sum(ticketsOf) == tickets.Total() is: appendEarnedTickets is the ONLY writer of either,
370	// it writes both in one statement group from the same `units` argument, and nothing in
371	// this file ever decrements or removes an entry. There is no second writer to drift from
372	// the first. AuditFailure re-derives the sum by iteration anyway
373	// (deadWeekClaimFailure), because an identity nothing re-derives is an identity nobody
374	// finds out about.
375	earnedOf *avl.Tree
376
377	// earnedTotal is the sum of earnedOf — the O(1) counter that keeps the abandonment
378	// transition constant-time. IT IS NOT tickets.Total() AND THE GAP IS THE BOUGHT UNITS:
379	// 0 <= earnedTotal <= tickets.Total(), with equality exactly on a week where no ticket
380	// was ever bought (the PURE-EARNED shape SweepDrawlessWeek calls shape 2).
381	//
382	// A SEPARATE COUNTER RATHER THAN AN ITERATION, for the same reason ClaimPerUnit is a rate
383	// rather than a tree of balances: retireSponsorship runs inside Refund, Refund MUST NOT
384	// FAIL, and a week's earned-holder set is unbounded (two more holders for every duel
385	// played in the week). Reading a field is O(1); summing a tree is a gas ceiling on a
386	// payout path. Read retireSponsorship.
387	//
388	// ZERO IS A REAL AND REACHABLE VALUE ON A WEEK WITH SPONSORSHIP: plain FundPot money from
389	// any caller that is not the duel, a duel rake too small to buy one whole entry unit, or
390	// a duel both of whose parties played from realm code. That money has no player behind
391	// it, so retireSponsorship's no-claimant branch sends it to opsPot — the destination
392	// nobody can choose. A week that has ever had an earned holder can never fall into that
393	// branch, because this counter only ever increases.
394	earnedTotal int64
395
396	// spendOf is string(address) -> int64, the buyer's GROSS payment for this week's
397	// tickets — the whole ticket price, house fee included — which is exactly what
398	// Refund returns.
399	//
400	// IT WAS THE POST-CUT CONTRIBUTION UNTIL 2026-09-29 and the old comment said "the
401	// house cut is not in here and is never refunded". BOTH HALVES OF THAT ARE NOW
402	// FALSE and the sentence is corrected rather than removed, because it is the one a
403	// reader will have in their head. The fee is escrowed in Cut for the week rather
404	// than banked at sale time, so it IS part of what a buyer can reclaim; what a
405	// refund returns is the gross, and the week's Pot and Cut are each decremented by
406	// the part of that gross they received. AUDIT-2026-09-28.md finding 25.
407	//
408	// IT IS NOT A TICKET COUNT AND SINCE FINDING 15 THE DIFFERENCE IS REACHABLE.
409	// An earned entry (FundPotWithEntries) adds ticket weight and NOTHING here,
410	// so an address can hold tickets in a week with a zero spend, and a whole
411	// week can have tickets and no spend records at all. That is intended: the
412	// money behind those entries is in Sponsored, which no refund can return.
413	// Refund reads this tree and only this tree.
414	spendOf *avl.Tree
415}
source

Draw is one week's raffle: the pot, the ticket index that decides it, and the beacon round it is committed to.

type Sponsorship

struct
1type Sponsorship struct {
2	By     address
3	Label  string
4	Amount int64
5}
source

Sponsorship is one credit line beside a week's prize: who paid, what they asked to be called, and what they put in.

Amount IS THE GROSS, the figure the sponsor actually parted with, not the net that reached the pot. A credit that understated what someone paid would be a strange thing to sell them, and the house's share of it is derivable from HouseCutBps by anybody who cares.

Dependencies

Source Files 2