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 state

Back to all declarations

TicketPrice

int64

Value

10000000

EntryUnitPrice

int64

Value

250000

unitsPerTicket

int64

Value

40

HouseCutBps

int64

Value

500

bpsDenom

int64

Value

10000

cutPerTicket

int64

Value

500000

MinSponsorship

int64

Value

1000000000

maxSponsorLabelLen

untyped bigint

Value

(64 <untyped> bigint)

maxSponsorsPerWeek

untyped bigint

Value

(16 <untyped> bigint)

opsVaultAddr

.uverse.address

Value

<gnolang.StringValue>

blocksPerDay

untyped bigint

blocksPerDay is the block-count day unit, copied from r/splash/game (which documents the choice): this toolchain exposes no block-timestamp getter, so the clock is chain height. NOTHING IN THIS REALM IS A TIMESTAMP — a week is a sequence number derived from the global clock, and no ticket, spend or draw is dated.

Value

(26182 <untyped> bigint)

raffleWeekDays

untyped bigint

raffleWeekDays is the draw period in those day units. A FREE DIAL, as in r/splash/game: referenced from exactly one place (currentWeek), and nothing else in the file assumes seven of anything.

Value

(7 <untyped> bigint)

randomnessHexLen

untyped bigint

randomnessHexLen is the length of a beacon randomness string: sha256, hex-encoded, 64 characters.

Value

(64 <untyped> bigint)

beaconRoundsPerDay

untyped bigint

\--- the beacon's cadence ------------------------------------------------ beaconRoundsPerDay IS THE ONE CADENCE ASSUMPTION THIS FILE MAKES, and the round pin below is a duration expressed through it. IT IS NO LONGER A NUMBER TYPED INTO THIS FILE, and that change is the single most important line in the drand migration. It used to read \`const beaconRoundsPerDay = 288\` — one round every five minutes, the cadence of the beacon SPLASH itself ran. r/splash/beacon now verifies drand evmnet, whose period is THREE SECONDS, so the true figure is 86400/3 = 28800 and every round-denominated window in this file was wrong by a factor of 100 the moment the key changed. Taking the number from the beacon means the two can never disagree again. WHY 100x WRONG WAS NOT MERELY UNTIDY — IT SILENTLY RE-OPENED AUDIT-2026-09-28.md FINDING 10. pinOffsetRounds is derived from this constant and exists to make the week's pinned round fall AFTER ticket sales close. At 288 the offset measured 1.92 hours of real time instead of 8 days, so the winning randomness would have been published on a public API days before the week ended and the last purchaser of the week could buy the winning unit outright. AND THE BUILD GUARD DID NOT CARE: the old \`var \_ \[pinOffsetRounds - raffleWeekDays\*beaconRoundsPerDay - 1]struct{}\` compares rounds to rounds, this constant appears on both sides and cancels, so the assertion stayed satisfied while measuring a hundredth of the real time. AN ASSERTION THAT CANNOT FAIL IS NOT AN ASSERTION. It is replaced below by one that compares SECONDS to SECONDS, drawing the two sides from different clocks so that no single wrong constant can cancel itself out. THE OTHER CLOCK, unchanged: a block is ~3.3s, so a day is blocksPerDay = 26182 blocks (ECONOMICS.md §7a-bis's measured block time, the constant r/splash/game and r/splash/arcade/duel also use). NOTE THAT THE BLOCK-DENOMINATED CONSTANTS DID NOT MOVE IN THE MIGRATION AND THE ROUND-DENOMINATED ONES ALL MOVED BY 100x — half this file's durations were right and half were wrong, which is exactly why the error was invisible. There is no block-timestamp getter here, so a realm cannot measure either clock at runtime; both are assumptions, and they are written down rather than implied.

Value

(28800 <untyped> bigint)

pinMarginDays

untyped bigint

\--- pinning the week's beacon round ------------------------------------- THE ROUND A WEEK IS DRAWN ON IS FIXED WHEN THE WEEK'S RECORD IS CREATED, never when it is committed. getOrCreateDraw writes Draw.Round exactly once and nothing else in this file ever writes it again. THE DEFECT THAT FORCED THIS, and it was the operator's cheapest attack on the whole product. Commit used to bind Round = beacon.Latest() + 1. The beacon's publisher BLS key is compiled in with no rotation path and the signed message is derived ONLY from the chain id and the round number (see r/splash/beacon), so the key holder can produce a valid signature for ANY round at will and therefore knows every round's randomness in advance, permanently. beacon.Publish applies no sequence check and no window check, and latestRound only ever rises. So the key holder could: buy one ticket during the week; wait for the week to close, which froze the ticket set into something public and immutable; enumerate candidate rounds off-chain computing ownerOfUnit(d, randomness(r) mod FrozenTotal) for each; Publish rounds until beacon.Latest()+1 was a round that named them; and then Commit. A DETERMINISTIC, RISK-FREE WIN THAT LOOKED EXACTLY LIKE A NORMAL SETTLEMENT. Note the irony, so it is not repeated: requiring Commit to take a FINISHED week (the 2026-09-28 griefing fix) made this strictly worse, because freezing the ticket set before the round was chosen turned an available attack into an exact one. WHAT PINNING AT CREATION ACHIEVES. The round is fixed before the week's first ticket exists, so nobody — key holder or not — can move the draw onto a round that suits the ticket set, because there is no ticket set yet. The committer has no round choice left at all: Commit reads d.Round and cannot compute one. WHAT IT DOES NOT ACHIEVE, STATED PLAINLY BECAUSE OVERSTATING IT WOULD BE WORSE THAN NOT DOING IT. It does NOT stop the key holder rigging the draw. They still know randomness(d.Round) from the moment the week opens, so they can still choose the MODULUS: be the final purchaser before the week closes, sizing the buy so that randomness mod FrozenTotal lands on a unit they own. That is a strictly worse attack for them — it costs real tickets and leaves an on-chain trace as a suspicious late buy — but it is not closed here and this realm cannot close it. THE ARCHITECTURAL PROBLEM (the project holding the beacon key) REMAINS OPEN AND IS THE OWNER'S DECISION: an externally-controlled beacon or a VRF. See AUDIT-2026-09-28.md findings 4 and 10. THE DERIVATION OF THE OFFSET, which must not be a picked number: THE PROPERTY REQUIRED is that the pinned round falls AFTER that week's ticket sales close, so that in honest operation the round's randomness does not exist until the ticket set is already frozen. A week's record can be created at any point inside the week it belongs to (Buy and FundPot both act on currentWeek()), and the worst case is the FIRST BLOCK of that week: the whole week — raffleWeekDays days — still has to elapse before sales close. SO THE OFFSET MUST EXCEED ONE WEEK OF ROUNDS: raffleWeekDays * beaconRoundsPerDay = 7 * 28800 = 201600 rounds. One day of margin on top absorbs ordinary cadence jitter and the estimation error described below, giving pinOffsetDays = 8 and pinOffsetRounds = 230400. THE COST OF THE MARGIN IS PAID IN WAITING, WHICH IS WHY IT IS ONE DAY AND NOT SEVEN. A week whose record is created late in the week pins a round up to pinOffsetDays after the week closes, so Resolve may have to wait that long after Commit. That wait is the top half of the honest cycle the refund grace window has to outlast; see refundGraceDays, where this constant appears in the derivation. WHY THE ROUND NUMBER, NOT A HEIGHT: rounds are a beacon counter with no on-chain mapping to chain height, so the only anchor a realm has is what the beacon reports at pin time. That is also why pinning requires the beacon to have published at least once — see getOrCreateDraw. AND WHY THE ANCHOR IS beacon.EstimatedLatestRound() AND NOT beacon.Latest(). THIS IS A DRAND-MIGRATION CHANGE AND IT IS SECURITY-CRITICAL. Under the old SPLASH beacon, Latest() was approximately the current round by assumption: the publisher was this project, emitting every five minutes, so "highest published" and "now" were within one period of each other. r/splash/beacon is now an ON-DEMAND RECORD of drand — nothing relays routinely, because nothing needs to — so Latest() is whatever round somebody last had a reason to relay. That is plausibly last week's pinned round, and after a quiet spell, months old. Pinning Latest() + pinOffsetRounds against a stale anchor measures the offset from the past, so the pinned round lands EARLIER in real time than intended. Let the anchor be stale by Δ rounds: the pin lands at now − 3Δ seconds + 8 days, and sales close at most 7 days from now, so as soon as 3Δ > 1 day — Δ > 28800 rounds, ONE DAY OF STALENESS — the pinned round's randomness is public before sales close. THAT IS FINDING 10'S LAST-BUYER ATTACK, VERBATIM, and no guard in this file can see it, because the file has no way to tell a fresh anchor from a stale one. beacon.EstimatedLatestRound() corrects for exactly that: it adds the blocks elapsed since the anchor round was relayed, converted at the two cadences, using a clock no relayer controls. It equals Latest() when nothing is stale, so this is a strict improvement at no liveness cost. THE RESIDUAL ERROR, AND THE FIGURE IN THIS COMMENT WAS WRONG BY A FACTOR OF NINE UNTIL TODAY. It read "a day of staleness at 10% block drift is ~14 minutes and does not matter", and so did DRAND-MIGRATION.md §5b/§9.4(a), AUDIT-2026-09-28.md §A2 and the duel's audit addendum. Derived rather than copied: A = 86400/blocksPerDay = 3.29998 s the block time this file and the beacon assume B = the chain's real average block time S = blocks since the beacon's anchor was last raised error = S*(A − B)/PeriodSeconds rounds = S*(A − B) SECONDS of drand time which in terms of elapsed real time T = S\*B is T\*(A/B − 1), i.e. |T\*(1 − A/B)|: ONE DAY of staleness at 10% drift: 86400*(1 − 3.29998/3.63) = 7854 s = 2.2 HOURS (or 2.4 hours in the fast direction, B = 3.0 s) ONE YEAR at the same 10%: ~36 days — which was RIGHT, and that is what localises the slip: 2.2 hours x 365 is 33 days, so the year figure was computed correctly and the day figure was not. THE SLIP WAS A DOUBLE DIVISION BY THE PERIOD: the formula "staleness x (real block time − 3.3s) / 3s" yields ROUNDS when staleness is in seconds, and the ~14-minute number comes from reading that as seconds and dividing by 3 again. 7854/9 = 873 s = 14.5 minutes. See AUDIT-2026-09-28.md finding 13. AND THERE IS A SECOND TERM THE OLD FRAMING OMITTED ENTIRELY: THE WEEK LENGTHENS AND THE PIN DOES NOT. A week here is a BLOCK count and pinOffsetRounds is a ROUND count, so the pin margin is 691200 − raffleWeekDays\*blocksPerDay\*B seconds: B = 3.29998 (nominal) margin = 86400 s = 24 hours B = 3.63 (10% slow) margin = 25916 s = 7.2 hours — BEFORE any staleness at all B = 3.7714 margin = 0 — gone, at a 14.3% drift in one constant So finding 10's defence rests on a 14% tolerance in blocksPerDay, and the staleness error eats into what is left of it. Both terms are now asserted at compile time by the second guard below pinOffsetRounds. IT IS NO LONGER LEFT TO THE OWNER, AND THAT IS THE CHANGE OF 2026-09-28's LATER FIX. Bounding the error means refusing to open a week against a beacon nobody has relayed to recently, and the recorded reason for not doing it was cross-realm: getOrCreateDraw is reached from FundPot, FundPot was called inside r/splash/arcade/duel's Collect, and a refusing FundPot therefore stranded every later duel winner. THAT COUPLING IS GONE — Collect books its rake and FlushPot delivers it, so a refusing FundPot now costs the flusher a reverted transaction and nothing else — and the bound is in r/splash/beacon itself, where a fifth consumer cannot forget it. See beacon.maxEstimateStaleBlocks, and AUDIT-2026-09-28.md findings 12 and 14.

Value

(1 <untyped> bigint)

pinOffsetDays

untyped bigint

\--- pinning the week's beacon round ------------------------------------- THE ROUND A WEEK IS DRAWN ON IS FIXED WHEN THE WEEK'S RECORD IS CREATED, never when it is committed. getOrCreateDraw writes Draw.Round exactly once and nothing else in this file ever writes it again. THE DEFECT THAT FORCED THIS, and it was the operator's cheapest attack on the whole product. Commit used to bind Round = beacon.Latest() + 1. The beacon's publisher BLS key is compiled in with no rotation path and the signed message is derived ONLY from the chain id and the round number (see r/splash/beacon), so the key holder can produce a valid signature for ANY round at will and therefore knows every round's randomness in advance, permanently. beacon.Publish applies no sequence check and no window check, and latestRound only ever rises. So the key holder could: buy one ticket during the week; wait for the week to close, which froze the ticket set into something public and immutable; enumerate candidate rounds off-chain computing ownerOfUnit(d, randomness(r) mod FrozenTotal) for each; Publish rounds until beacon.Latest()+1 was a round that named them; and then Commit. A DETERMINISTIC, RISK-FREE WIN THAT LOOKED EXACTLY LIKE A NORMAL SETTLEMENT. Note the irony, so it is not repeated: requiring Commit to take a FINISHED week (the 2026-09-28 griefing fix) made this strictly worse, because freezing the ticket set before the round was chosen turned an available attack into an exact one. WHAT PINNING AT CREATION ACHIEVES. The round is fixed before the week's first ticket exists, so nobody — key holder or not — can move the draw onto a round that suits the ticket set, because there is no ticket set yet. The committer has no round choice left at all: Commit reads d.Round and cannot compute one. WHAT IT DOES NOT ACHIEVE, STATED PLAINLY BECAUSE OVERSTATING IT WOULD BE WORSE THAN NOT DOING IT. It does NOT stop the key holder rigging the draw. They still know randomness(d.Round) from the moment the week opens, so they can still choose the MODULUS: be the final purchaser before the week closes, sizing the buy so that randomness mod FrozenTotal lands on a unit they own. That is a strictly worse attack for them — it costs real tickets and leaves an on-chain trace as a suspicious late buy — but it is not closed here and this realm cannot close it. THE ARCHITECTURAL PROBLEM (the project holding the beacon key) REMAINS OPEN AND IS THE OWNER'S DECISION: an externally-controlled beacon or a VRF. See AUDIT-2026-09-28.md findings 4 and 10. THE DERIVATION OF THE OFFSET, which must not be a picked number: THE PROPERTY REQUIRED is that the pinned round falls AFTER that week's ticket sales close, so that in honest operation the round's randomness does not exist until the ticket set is already frozen. A week's record can be created at any point inside the week it belongs to (Buy and FundPot both act on currentWeek()), and the worst case is the FIRST BLOCK of that week: the whole week — raffleWeekDays days — still has to elapse before sales close. SO THE OFFSET MUST EXCEED ONE WEEK OF ROUNDS: raffleWeekDays * beaconRoundsPerDay = 7 * 28800 = 201600 rounds. One day of margin on top absorbs ordinary cadence jitter and the estimation error described below, giving pinOffsetDays = 8 and pinOffsetRounds = 230400. THE COST OF THE MARGIN IS PAID IN WAITING, WHICH IS WHY IT IS ONE DAY AND NOT SEVEN. A week whose record is created late in the week pins a round up to pinOffsetDays after the week closes, so Resolve may have to wait that long after Commit. That wait is the top half of the honest cycle the refund grace window has to outlast; see refundGraceDays, where this constant appears in the derivation. WHY THE ROUND NUMBER, NOT A HEIGHT: rounds are a beacon counter with no on-chain mapping to chain height, so the only anchor a realm has is what the beacon reports at pin time. That is also why pinning requires the beacon to have published at least once — see getOrCreateDraw. AND WHY THE ANCHOR IS beacon.EstimatedLatestRound() AND NOT beacon.Latest(). THIS IS A DRAND-MIGRATION CHANGE AND IT IS SECURITY-CRITICAL. Under the old SPLASH beacon, Latest() was approximately the current round by assumption: the publisher was this project, emitting every five minutes, so "highest published" and "now" were within one period of each other. r/splash/beacon is now an ON-DEMAND RECORD of drand — nothing relays routinely, because nothing needs to — so Latest() is whatever round somebody last had a reason to relay. That is plausibly last week's pinned round, and after a quiet spell, months old. Pinning Latest() + pinOffsetRounds against a stale anchor measures the offset from the past, so the pinned round lands EARLIER in real time than intended. Let the anchor be stale by Δ rounds: the pin lands at now − 3Δ seconds + 8 days, and sales close at most 7 days from now, so as soon as 3Δ > 1 day — Δ > 28800 rounds, ONE DAY OF STALENESS — the pinned round's randomness is public before sales close. THAT IS FINDING 10'S LAST-BUYER ATTACK, VERBATIM, and no guard in this file can see it, because the file has no way to tell a fresh anchor from a stale one. beacon.EstimatedLatestRound() corrects for exactly that: it adds the blocks elapsed since the anchor round was relayed, converted at the two cadences, using a clock no relayer controls. It equals Latest() when nothing is stale, so this is a strict improvement at no liveness cost. THE RESIDUAL ERROR, AND THE FIGURE IN THIS COMMENT WAS WRONG BY A FACTOR OF NINE UNTIL TODAY. It read "a day of staleness at 10% block drift is ~14 minutes and does not matter", and so did DRAND-MIGRATION.md §5b/§9.4(a), AUDIT-2026-09-28.md §A2 and the duel's audit addendum. Derived rather than copied: A = 86400/blocksPerDay = 3.29998 s the block time this file and the beacon assume B = the chain's real average block time S = blocks since the beacon's anchor was last raised error = S*(A − B)/PeriodSeconds rounds = S*(A − B) SECONDS of drand time which in terms of elapsed real time T = S\*B is T\*(A/B − 1), i.e. |T\*(1 − A/B)|: ONE DAY of staleness at 10% drift: 86400*(1 − 3.29998/3.63) = 7854 s = 2.2 HOURS (or 2.4 hours in the fast direction, B = 3.0 s) ONE YEAR at the same 10%: ~36 days — which was RIGHT, and that is what localises the slip: 2.2 hours x 365 is 33 days, so the year figure was computed correctly and the day figure was not. THE SLIP WAS A DOUBLE DIVISION BY THE PERIOD: the formula "staleness x (real block time − 3.3s) / 3s" yields ROUNDS when staleness is in seconds, and the ~14-minute number comes from reading that as seconds and dividing by 3 again. 7854/9 = 873 s = 14.5 minutes. See AUDIT-2026-09-28.md finding 13. AND THERE IS A SECOND TERM THE OLD FRAMING OMITTED ENTIRELY: THE WEEK LENGTHENS AND THE PIN DOES NOT. A week here is a BLOCK count and pinOffsetRounds is a ROUND count, so the pin margin is 691200 − raffleWeekDays\*blocksPerDay\*B seconds: B = 3.29998 (nominal) margin = 86400 s = 24 hours B = 3.63 (10% slow) margin = 25916 s = 7.2 hours — BEFORE any staleness at all B = 3.7714 margin = 0 — gone, at a 14.3% drift in one constant So finding 10's defence rests on a 14% tolerance in blocksPerDay, and the staleness error eats into what is left of it. Both terms are now asserted at compile time by the second guard below pinOffsetRounds. IT IS NO LONGER LEFT TO THE OWNER, AND THAT IS THE CHANGE OF 2026-09-28's LATER FIX. Bounding the error means refusing to open a week against a beacon nobody has relayed to recently, and the recorded reason for not doing it was cross-realm: getOrCreateDraw is reached from FundPot, FundPot was called inside r/splash/arcade/duel's Collect, and a refusing FundPot therefore stranded every later duel winner. THAT COUPLING IS GONE — Collect books its rake and FlushPot delivers it, so a refusing FundPot now costs the flusher a reverted transaction and nothing else — and the bound is in r/splash/beacon itself, where a fifth consumer cannot forget it. See beacon.maxEstimateStaleBlocks, and AUDIT-2026-09-28.md findings 12 and 14.

Value

(8 <untyped> bigint)

pinOffsetRounds

untyped bigint

\--- pinning the week's beacon round ------------------------------------- THE ROUND A WEEK IS DRAWN ON IS FIXED WHEN THE WEEK'S RECORD IS CREATED, never when it is committed. getOrCreateDraw writes Draw.Round exactly once and nothing else in this file ever writes it again. THE DEFECT THAT FORCED THIS, and it was the operator's cheapest attack on the whole product. Commit used to bind Round = beacon.Latest() + 1. The beacon's publisher BLS key is compiled in with no rotation path and the signed message is derived ONLY from the chain id and the round number (see r/splash/beacon), so the key holder can produce a valid signature for ANY round at will and therefore knows every round's randomness in advance, permanently. beacon.Publish applies no sequence check and no window check, and latestRound only ever rises. So the key holder could: buy one ticket during the week; wait for the week to close, which froze the ticket set into something public and immutable; enumerate candidate rounds off-chain computing ownerOfUnit(d, randomness(r) mod FrozenTotal) for each; Publish rounds until beacon.Latest()+1 was a round that named them; and then Commit. A DETERMINISTIC, RISK-FREE WIN THAT LOOKED EXACTLY LIKE A NORMAL SETTLEMENT. Note the irony, so it is not repeated: requiring Commit to take a FINISHED week (the 2026-09-28 griefing fix) made this strictly worse, because freezing the ticket set before the round was chosen turned an available attack into an exact one. WHAT PINNING AT CREATION ACHIEVES. The round is fixed before the week's first ticket exists, so nobody — key holder or not — can move the draw onto a round that suits the ticket set, because there is no ticket set yet. The committer has no round choice left at all: Commit reads d.Round and cannot compute one. WHAT IT DOES NOT ACHIEVE, STATED PLAINLY BECAUSE OVERSTATING IT WOULD BE WORSE THAN NOT DOING IT. It does NOT stop the key holder rigging the draw. They still know randomness(d.Round) from the moment the week opens, so they can still choose the MODULUS: be the final purchaser before the week closes, sizing the buy so that randomness mod FrozenTotal lands on a unit they own. That is a strictly worse attack for them — it costs real tickets and leaves an on-chain trace as a suspicious late buy — but it is not closed here and this realm cannot close it. THE ARCHITECTURAL PROBLEM (the project holding the beacon key) REMAINS OPEN AND IS THE OWNER'S DECISION: an externally-controlled beacon or a VRF. See AUDIT-2026-09-28.md findings 4 and 10. THE DERIVATION OF THE OFFSET, which must not be a picked number: THE PROPERTY REQUIRED is that the pinned round falls AFTER that week's ticket sales close, so that in honest operation the round's randomness does not exist until the ticket set is already frozen. A week's record can be created at any point inside the week it belongs to (Buy and FundPot both act on currentWeek()), and the worst case is the FIRST BLOCK of that week: the whole week — raffleWeekDays days — still has to elapse before sales close. SO THE OFFSET MUST EXCEED ONE WEEK OF ROUNDS: raffleWeekDays * beaconRoundsPerDay = 7 * 28800 = 201600 rounds. One day of margin on top absorbs ordinary cadence jitter and the estimation error described below, giving pinOffsetDays = 8 and pinOffsetRounds = 230400. THE COST OF THE MARGIN IS PAID IN WAITING, WHICH IS WHY IT IS ONE DAY AND NOT SEVEN. A week whose record is created late in the week pins a round up to pinOffsetDays after the week closes, so Resolve may have to wait that long after Commit. That wait is the top half of the honest cycle the refund grace window has to outlast; see refundGraceDays, where this constant appears in the derivation. WHY THE ROUND NUMBER, NOT A HEIGHT: rounds are a beacon counter with no on-chain mapping to chain height, so the only anchor a realm has is what the beacon reports at pin time. That is also why pinning requires the beacon to have published at least once — see getOrCreateDraw. AND WHY THE ANCHOR IS beacon.EstimatedLatestRound() AND NOT beacon.Latest(). THIS IS A DRAND-MIGRATION CHANGE AND IT IS SECURITY-CRITICAL. Under the old SPLASH beacon, Latest() was approximately the current round by assumption: the publisher was this project, emitting every five minutes, so "highest published" and "now" were within one period of each other. r/splash/beacon is now an ON-DEMAND RECORD of drand — nothing relays routinely, because nothing needs to — so Latest() is whatever round somebody last had a reason to relay. That is plausibly last week's pinned round, and after a quiet spell, months old. Pinning Latest() + pinOffsetRounds against a stale anchor measures the offset from the past, so the pinned round lands EARLIER in real time than intended. Let the anchor be stale by Δ rounds: the pin lands at now − 3Δ seconds + 8 days, and sales close at most 7 days from now, so as soon as 3Δ > 1 day — Δ > 28800 rounds, ONE DAY OF STALENESS — the pinned round's randomness is public before sales close. THAT IS FINDING 10'S LAST-BUYER ATTACK, VERBATIM, and no guard in this file can see it, because the file has no way to tell a fresh anchor from a stale one. beacon.EstimatedLatestRound() corrects for exactly that: it adds the blocks elapsed since the anchor round was relayed, converted at the two cadences, using a clock no relayer controls. It equals Latest() when nothing is stale, so this is a strict improvement at no liveness cost. THE RESIDUAL ERROR, AND THE FIGURE IN THIS COMMENT WAS WRONG BY A FACTOR OF NINE UNTIL TODAY. It read "a day of staleness at 10% block drift is ~14 minutes and does not matter", and so did DRAND-MIGRATION.md §5b/§9.4(a), AUDIT-2026-09-28.md §A2 and the duel's audit addendum. Derived rather than copied: A = 86400/blocksPerDay = 3.29998 s the block time this file and the beacon assume B = the chain's real average block time S = blocks since the beacon's anchor was last raised error = S*(A − B)/PeriodSeconds rounds = S*(A − B) SECONDS of drand time which in terms of elapsed real time T = S\*B is T\*(A/B − 1), i.e. |T\*(1 − A/B)|: ONE DAY of staleness at 10% drift: 86400*(1 − 3.29998/3.63) = 7854 s = 2.2 HOURS (or 2.4 hours in the fast direction, B = 3.0 s) ONE YEAR at the same 10%: ~36 days — which was RIGHT, and that is what localises the slip: 2.2 hours x 365 is 33 days, so the year figure was computed correctly and the day figure was not. THE SLIP WAS A DOUBLE DIVISION BY THE PERIOD: the formula "staleness x (real block time − 3.3s) / 3s" yields ROUNDS when staleness is in seconds, and the ~14-minute number comes from reading that as seconds and dividing by 3 again. 7854/9 = 873 s = 14.5 minutes. See AUDIT-2026-09-28.md finding 13. AND THERE IS A SECOND TERM THE OLD FRAMING OMITTED ENTIRELY: THE WEEK LENGTHENS AND THE PIN DOES NOT. A week here is a BLOCK count and pinOffsetRounds is a ROUND count, so the pin margin is 691200 − raffleWeekDays\*blocksPerDay\*B seconds: B = 3.29998 (nominal) margin = 86400 s = 24 hours B = 3.63 (10% slow) margin = 25916 s = 7.2 hours — BEFORE any staleness at all B = 3.7714 margin = 0 — gone, at a 14.3% drift in one constant So finding 10's defence rests on a 14% tolerance in blocksPerDay, and the staleness error eats into what is left of it. Both terms are now asserted at compile time by the second guard below pinOffsetRounds. IT IS NO LONGER LEFT TO THE OWNER, AND THAT IS THE CHANGE OF 2026-09-28's LATER FIX. Bounding the error means refusing to open a week against a beacon nobody has relayed to recently, and the recorded reason for not doing it was cross-realm: getOrCreateDraw is reached from FundPot, FundPot was called inside r/splash/arcade/duel's Collect, and a refusing FundPot therefore stranded every later duel winner. THAT COUPLING IS GONE — Collect books its rake and FlushPot delivers it, so a refusing FundPot now costs the flusher a reverted transaction and nothing else — and the bound is in r/splash/beacon itself, where a fifth consumer cannot forget it. See beacon.maxEstimateStaleBlocks, and AUDIT-2026-09-28.md findings 12 and 14.

Value

(230400 <untyped> bigint)

maxPinnableLatest

int64

maxPinnableLatest is the highest anchor round from which a round can still be pinned without the int64 addition wrapping. A wrapped pin would be a negative round — a round nobody can ever publish. getOrCreateDraw refuses instead, loudly. SINCE THE DRAND MIGRATION THIS IS BELT-AND-BRACES ON AN ADDITION, NOT A LIVE GRIEFING SURFACE, and the change is worth stating because the paragraph it replaces was one of the longest in this file. It used to be reachable by a deliberate act: the SPLASH beacon's key holder could sign ANY round, including one near the int64 maximum, and one Publish moved latestRound there for ever — after which no new week could be opened in this realm again, because Buy and FundPot both refuse a live week with no record. That reached across a realm boundary, since r/splash/arcade/duel's Collect CALLED FundPot DIRECTLY at the time, so every later duel winner would have been unable to collect; the recorded decision was that choosing between refusing and pinning a clamped unpublishable round is the owner's, not this file's. (THAT COUPLING IS GONE — Collect books its rake and duel.FlushPot delivers it, so a refusing FundPot now costs the flusher a reverted transaction and no winner anything. AUDIT-2026-09-28.md finding 14. The sentence is kept because the cross-realm reasoning it records is what that finding is about.) DRAND CANNOT SIGN A ROUND WHOSE TIME HAS NOT COME, so latestRound is now bounded above by drand's real head: ~2.1e7, gaining ~1.05e7 a year, reaching 9.2e18 in about 880 billion years. The trigger is gone and the cross-realm decision is moot. This constant stays because the arithmetic still must not wrap, and because a future change of beacon must not silently inherit the assumption that it cannot. (The same act was the operator-triggered half of AUDIT-2026-09-28.md finding 7 — one far-future Publish moving latest past the prune window and abandoning every pending week at once. That is dead too, for the same reason. The STALLED-beacon half of finding 7 is very much alive and the height gate below is still the only answer to it.)

Value

9223372036854545407

refundGraceDays

untyped bigint

\--- the refund grace window --------------------------------------------- refundGraceBlocks IS A BEACON-INDEPENDENT FLOOR ON BUYER ACCESS, and it is the only thing in this file that lets a committed week's pot out when the beacon simply STOPS. Read Refund and resolvable() with it. THE DEFECT IT CLOSES, and the lesson that goes with it: r/splash/arcade/duel had already solved exactly this problem and this realm had not. The duel's VoidStale gates on runtime.ChainHeight() - d.AcceptedAt >= voidGraceBlocks — CHAIN HEIGHT, NOT BEACON STATE — and its comment says in as many words that without it "a stopped publisher freezes every pending duel with both stakes locked, permanently, on a contract that cannot be patched". That sentence described this realm verbatim until this constant existed. A week's round is pinned at A + pinOffsetRounds, where A is the beacon's anchor round at record creation (it was latest+1, bound in Commit, when this window was first written; the pin moves the round further out, which only widens the trap below). Freeze the beacon at that same A and BOTH exits refuse forever: Resolve panics "beacon round not published yet", and Refund panicked "still resolvable" because resolvable() reduced to A+pinOffsetRounds > A-PruneWindow, which is true for ever. The week's whole pot — the buyers' own money — was then unreachable by every path in the file, while heldPots went on counting it so checkInvariant and AuditFailure both stayed green. Not committing was no defence either: Refund requires Committed, so the only way out of a stranded week is through the very act that sets the trap. AND SINCE THE DRAND MIGRATION THIS GATE IS NOT A SECOND OPENER, IT IS THE ONLY ONE. resolvable() is gone (see Refund), because the state it detected — "the round came and went and its signature is destroyed" — cannot occur against a beacon that never prunes and an API that serves every round for ever. What replaced that failure mode is a pure liveness failure: nobody has bothered to relay the round. A beacon-state gate can never detect that, because there is nothing about the beacon's state that distinguishes "not relayed yet" from "never will be". A CLOCK CAN. That is precisely what this constant is. THE OPERATOR-TRIGGERED VARIANT OF THE SAME ROOT IS NOW DEAD, AND THE PARAGRAPH IS KEPT BECAUSE ITS LESSON IS NOT. Under the SPLASH key, beacon.Publish applied no sequence check and latestRound only ever rose, so ONE Publish of a far-future round moved latest by more than beacon.PruneWindow in a single transaction — flipping resolvable() false for every pending committed week at once and forcing mass abandonment. drand cannot sign a round whose time has not come, so latestRound is bounded by drand's real head and nobody can jump it. The STALLED-beacon variant above is untouched and is now the ONLY thing standing between a quiet week and a trapped pot — and it got LARGER, because with an on-demand record nobody relays unless they want something (see below). THE DERIVATION, because the number must not be arbitrary. THE CADENCE ASSUMPTION THIS RESTS ON is beaconRoundsPerDay and blocksPerDay — stated in full at beaconRoundsPerDay, above. This window is a duration expressed in the only clock a realm can read here, so if either assumption moves materially it must be re-derived. THE UPPER BOUND ON AN HONEST CYCLE USED TO HAVE TWO TERMS AND NOW HAS ONE AND A HALF, and the change is a drand-migration change. It is measured from Commit, because Commit is what CommittedAt records and therefore what this window counts from: 1. WAITING FOR THE PINNED ROUND TO BE SIGNED: up to pinOffsetRounds = 230400 rounds, which at three seconds is 8 days. The worst case is a week whose record was created in its final block — the pin is then measured from almost exactly the moment the week closes, so the round lands the whole offset after Commit. (A week created early pins a round that is already signed by Commit, which is fine and is the point: nobody CHOSE it.) 2. WAITING FOR SOMEBODY TO RELAY IT. This term used to be beacon.PruneWindow — a further 7 days, after which the signature was GONE, resolvable() went false on its own, and refunds opened through the beacon-derived half of Refund's gate. THAT TERM NO LONGER EXISTS AND NEITHER DOES ITS DEADLINE. Every drand round is served for ever by the public API and r/splash/beacon neither prunes nor forgets, so a pinned round is obtainable indefinitely — and correspondingly nothing FORCES anyone to obtain it. The second term is therefore UNBOUNDED, and the height gate is not one of two independent openers any more, IT IS THE ONLY ONE. See resolvable's removal and Refund's gate. SO THE BOUNDED PART OF THE HONEST CYCLE IS pinOffsetDays = 8 DAYS from Commit, and the height gate MUST be strictly longer than that: a shorter one would open refunds on a week whose pinned round has not even been signed yet, let alone failed to resolve. The 13 days of slack above it are now the RELAY ALLOWANCE — how long this realm is willing to wait for somebody to spend ~17.0M gas relaying one round — and that is what the margin is for, so it says so. 21 DAYS IS KEPT RATHER THAN RECOMPUTED, on purpose. The bound it has to clear fell from 15 days to 8 when the retention term disappeared, so 21 is now more generous than strictly required. Shortening it would be a change to buyers' exposure made as a side effect of a beacon migration, and the direction is not obviously right: the second term is unbounded now, so the case for a long window is stronger, not weaker.

Value

(21 <untyped> bigint)

refundGraceBlocks

untyped bigint

\--- the refund grace window --------------------------------------------- refundGraceBlocks IS A BEACON-INDEPENDENT FLOOR ON BUYER ACCESS, and it is the only thing in this file that lets a committed week's pot out when the beacon simply STOPS. Read Refund and resolvable() with it. THE DEFECT IT CLOSES, and the lesson that goes with it: r/splash/arcade/duel had already solved exactly this problem and this realm had not. The duel's VoidStale gates on runtime.ChainHeight() - d.AcceptedAt >= voidGraceBlocks — CHAIN HEIGHT, NOT BEACON STATE — and its comment says in as many words that without it "a stopped publisher freezes every pending duel with both stakes locked, permanently, on a contract that cannot be patched". That sentence described this realm verbatim until this constant existed. A week's round is pinned at A + pinOffsetRounds, where A is the beacon's anchor round at record creation (it was latest+1, bound in Commit, when this window was first written; the pin moves the round further out, which only widens the trap below). Freeze the beacon at that same A and BOTH exits refuse forever: Resolve panics "beacon round not published yet", and Refund panicked "still resolvable" because resolvable() reduced to A+pinOffsetRounds > A-PruneWindow, which is true for ever. The week's whole pot — the buyers' own money — was then unreachable by every path in the file, while heldPots went on counting it so checkInvariant and AuditFailure both stayed green. Not committing was no defence either: Refund requires Committed, so the only way out of a stranded week is through the very act that sets the trap. AND SINCE THE DRAND MIGRATION THIS GATE IS NOT A SECOND OPENER, IT IS THE ONLY ONE. resolvable() is gone (see Refund), because the state it detected — "the round came and went and its signature is destroyed" — cannot occur against a beacon that never prunes and an API that serves every round for ever. What replaced that failure mode is a pure liveness failure: nobody has bothered to relay the round. A beacon-state gate can never detect that, because there is nothing about the beacon's state that distinguishes "not relayed yet" from "never will be". A CLOCK CAN. That is precisely what this constant is. THE OPERATOR-TRIGGERED VARIANT OF THE SAME ROOT IS NOW DEAD, AND THE PARAGRAPH IS KEPT BECAUSE ITS LESSON IS NOT. Under the SPLASH key, beacon.Publish applied no sequence check and latestRound only ever rose, so ONE Publish of a far-future round moved latest by more than beacon.PruneWindow in a single transaction — flipping resolvable() false for every pending committed week at once and forcing mass abandonment. drand cannot sign a round whose time has not come, so latestRound is bounded by drand's real head and nobody can jump it. The STALLED-beacon variant above is untouched and is now the ONLY thing standing between a quiet week and a trapped pot — and it got LARGER, because with an on-demand record nobody relays unless they want something (see below). THE DERIVATION, because the number must not be arbitrary. THE CADENCE ASSUMPTION THIS RESTS ON is beaconRoundsPerDay and blocksPerDay — stated in full at beaconRoundsPerDay, above. This window is a duration expressed in the only clock a realm can read here, so if either assumption moves materially it must be re-derived. THE UPPER BOUND ON AN HONEST CYCLE USED TO HAVE TWO TERMS AND NOW HAS ONE AND A HALF, and the change is a drand-migration change. It is measured from Commit, because Commit is what CommittedAt records and therefore what this window counts from: 1. WAITING FOR THE PINNED ROUND TO BE SIGNED: up to pinOffsetRounds = 230400 rounds, which at three seconds is 8 days. The worst case is a week whose record was created in its final block — the pin is then measured from almost exactly the moment the week closes, so the round lands the whole offset after Commit. (A week created early pins a round that is already signed by Commit, which is fine and is the point: nobody CHOSE it.) 2. WAITING FOR SOMEBODY TO RELAY IT. This term used to be beacon.PruneWindow — a further 7 days, after which the signature was GONE, resolvable() went false on its own, and refunds opened through the beacon-derived half of Refund's gate. THAT TERM NO LONGER EXISTS AND NEITHER DOES ITS DEADLINE. Every drand round is served for ever by the public API and r/splash/beacon neither prunes nor forgets, so a pinned round is obtainable indefinitely — and correspondingly nothing FORCES anyone to obtain it. The second term is therefore UNBOUNDED, and the height gate is not one of two independent openers any more, IT IS THE ONLY ONE. See resolvable's removal and Refund's gate. SO THE BOUNDED PART OF THE HONEST CYCLE IS pinOffsetDays = 8 DAYS from Commit, and the height gate MUST be strictly longer than that: a shorter one would open refunds on a week whose pinned round has not even been signed yet, let alone failed to resolve. The 13 days of slack above it are now the RELAY ALLOWANCE — how long this realm is willing to wait for somebody to spend ~17.0M gas relaying one round — and that is what the margin is for, so it says so. 21 DAYS IS KEPT RATHER THAN RECOMPUTED, on purpose. The bound it has to clear fell from 15 days to 8 when the retention term disappeared, so 21 is now more generous than strictly required. Shortening it would be a change to buyers' exposure made as a side effect of a beacon migration, and the direction is not obviously right: the second term is unbounded now, so the case for a long window is stronger, not weaker.

Value

(549822 <untyped> bigint)

Sponsorship

type

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.

Value

v2.Sponsorship

Draw

type

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

Value

v2.Draw

opsPot

int64

Value

0

heldPots

int64

Value

0

heldCuts

int64

Value

0

heldSponsored

int64

Value

0

heldClaims

int64

Value

0

heldSponsorRefunds

int64

Value

0

launchHeight

int64

Value

254839

realmSelf

.uverse.address

realmSelf is this realm's own address, resolved ONCE at package initialisation and never recomputed. WHY NOT CALL unsafe.CurrentRealm() PER USE: CurrentRealm resolves against whatever frame is executing. Inside a crossing entrypoint that is this realm, which is correct — but Render and the audit reads are deliberately NOT crossing functions (anyone must be able to read them), so when they are called directly the "current realm" is the caller and BalanceOf(selfAddr()) would silently read the CALLER'S wallet. r/splash/game learned this the hard way; the fix is to resolve the address once, since it is constant for the life of the realm.

Value

<gnolang.StringValue>

keyWidth

untyped bigint

keyWidth is enough decimal digits for any non-negative int64 (max is 9223372036854775807, 19 digits). Keys are fixed-width and zero-padded so that avl.Tree's lexicographic order matches numeric order — the same convention as r/splash/beacon's roundKey, and the reason Render lists weeks as 1, 2, ... 10 rather than 1, 10, 2.

Value

(19 <untyped> bigint)

zeroPad

untyped string

Value

"0000000000000000000"

currentDay

func() int64

currentDay is the single global clock this realm reads — never a per-ticket one. See blocksPerDay.

Open
OID
0a69c1…966c:20
currentDay details

Inspect func

currentWeek

func() int64

currentWeek is the week tickets are being sold into right now.

Open
OID
0a69c1…966c:21
currentWeek details

Inspect func

weekEndHeight

func(week int64) int64

weekEndHeight is the chain height at which a week stops being the live one — the first height of week+1. Derived from the same two constants and the same launchHeight currentWeek reads, so the two clocks can never disagree. IT EXISTS FOR ONE GATE: the ticketless sweep shape, which has no CommittedAt to count from (such a week can never be committed) and therefore cannot use refundGraceElapsed. Read SweepDrawlessWeek's shape-1 clock for why that gate is there at all — in one line, a sweep that is free and instant the moment a week ends is a sweep that can be repeated week after week, and finding 28's chain was built out of exactly that repeat. NO OVERFLOW: week here is always a week this realm has a record for, so it is bounded by the chain's own height divided by a week of blocks.

Open
OID
0a69c1…966c:22
weekEndHeight details

Inspect func

mulDiv

func(a uint64, b uint64, d uint64) uint64

mulDiv returns floor(a\*b/d) via a 128-bit intermediate product. Raw a\*b/d on token-scale quantities wraps silently on the GnoVM (see ../../../../tools/muldiv\_probe.gno) instead of panicking, so every split in this realm goes through here.

Open
OID
0a69c1…966c:23
mulDiv details

Inspect func

maxInt64

uint64

maxInt64 as a uint64, for the overflow guard below.

Value

9223372036854775807

ticketCost

func(tickets int64) int64

ticketCost is tickets \* TicketPrice, GUARDED AGAINST int64 OVERFLOW. A raw multiplication here is the classic money bug: on the GnoVM an int64 product wraps silently, so a caller passing a large enough ticket count would buy an enormous number of ticket units for a small — or negative — cost. The product is therefore taken in 128 bits (the same bits.Mul64 path as mulDiv) and REJECTED unless it fits in an int64: the high word must be zero AND the low word must be within int64 range, because hi == 0 alone still admits values above 2^63-1 that would land negative.

Open
OID
0a69c1…966c:24
ticketCost details

Inspect func

hexDigit

func(c uint8) (uint64, bool)

hexDigit decodes one lowercase-or-uppercase hex character.

Open
OID
0a69c1…966c:25
hexDigit details

Inspect func

randomnessMod

func(randomness string, m int64) int64

randomnessMod reduces a 256-bit hex randomness string modulo m, which gives the draw's winning ticket unit. Verbatim in behaviour from r/splash/game/raffle.gno, for the reasons stated there: The randomness is 256 bits; an int64 holds 63. Any truncation (taking the first or last 16 hex digits, say) throws away most of the beacon's entropy and does it in a way an attacker can reason about. math/big does not exist on this toolchain, so the reduction is schoolbook: fold four bits at a time, reducing as we go. The intermediate r\*16 + digit does NOT fit in 64 bits when m is near the int64 maximum, so it goes through the 128-bit path rather than wrapping. \`hi %= um\` before the divide is required by bits.Div64 (which panics unless the high word is smaller than the divisor) and is sound because hi\*2^64 == (hi mod m)\*2^64 (mod m).

Open
OID
0a69c1…966c:26
randomnessMod details

Inspect func

getDraw

func(week int64) *v2.Draw

getDraw returns the draw for week, or nil. avl's Get returns a SINGLE value and existence is a separate Has, so the check comes first — otherwise a missing week type-asserts nil and panics with avl's message rather than this realm's.

Open
OID
0a69c1…966c:27
getDraw details

Inspect func

getOrCreateDraw

func(week int64) *v2.Draw

getOrCreateDraw returns the week's draw, creating an empty one if needed. Called only AFTER a sale's money has moved, so a rejected Buy never leaves an empty record behind — which matters, because "this week has no tickets" is one of Commit's refusals and an empty husk must not look like a live week. THIS IS WHERE THE WEEK'S BEACON ROUND IS PINNED, AND IT IS THE ONLY WRITER OF Draw.Round IN THIS FILE. It is here, rather than in Buy and FundPot separately, so that the pin happens EXACTLY ONCE per week whichever of them opens the week — a second pin would be a second choice, and choice is the whole defect. Read pinOffsetRounds for the attack this closes, for the derivation of the offset, and for the part of the problem it deliberately does not close. IT NEEDS THE BEACON TO HAVE PUBLISHED AT LEAST ONCE, because beacon.Latest() is the only anchor a realm has on the round counter. On a chain where the beacon has never published a round, beacon.Latest() panics with its own message and the sale or sponsorship is REFUSED. That is the safe direction and is strictly better than what it replaces: before the pin, such a Buy succeeded, and the week could then never be committed (Commit's own beacon.Latest() panicked), never resolved and never refunded — Refund requires Committed — so the money entered a week no path in the realm could ever empty. Refusing the sale keeps the money in the buyer's wallet instead. The refusal happens after the wugnot pull inside Buy, which is harmless: a panic reverts the whole transaction, exactly as checkInvariant's panics already rely on.

Open
OID
0a69c1…966c:28
getOrCreateDraw details

Inspect func

foldSponsorCut

func(d *v2.Draw)

retireSponsorship turns a dead week's sponsorship into a claim held BY THE PEOPLE WHO EARNED ENTRIES IN THAT SAME WEEK. It is the whole of AUDIT-2026-09-28.md finding 31's fix and the ONE statement that is worth making about it is that IT HAS NO DESTINATION: nothing here takes a week as a parameter, reads currentWeek(), reads the beacon, or looks at any Draw but the one it is given. WHY THAT IS THE FIX RATHER THAN A BETTER-CHOSEN DESTINATION. Three designs in three days each moved this money somewhere: to opsPot (finding 16 — the house was the party best placed to trigger the move and the party that received it), to currentWeek() (finding 28 — a destination evaluated at call time is a destination the caller chooses), and into the next week to be created (finding 31 — the caller creates that week, with one Buy, and can be its only entrant). THE COMMON FACTOR IS NOT WHICH DESTINATION WAS PICKED, IT IS THAT THERE WAS ONE. This money came from the duel players whose rake it is; giving it back to them, pro rata, in the week it is already in, leaves nothing for anybody to aim. AND "THEM" IS THE EARNED-ENTRY HOLDERS, NOT EVERY UNIT HOLDER — AUDIT-2026-09-28.md finding 34. THE SENTENCE ABOVE SAID THE WRONG THING FOR ONE DAY and the code divided on d.tickets.Total() to match it. The retired text, because it is the defect stated out loud: "This money came from the duel players whose rake it is AND FROM THE BUYERS WHO BOUGHT INTO THIS WEEK; giving it back to them, pro rata, in the week it is already in, leaves nothing for anybody to aim." THE TWO UNIT KINDS DID NOT CONTRIBUTE EQUALLY TO THE THING BEING DIVIDED, so "pro rata" over both of them divided it on the wrong basis: An EARNED unit PUT 150,000 ugnot INTO Sponsored — EntryUnitPrice scaled by the duel's players' share, arcadeRakePotShareBps/arcadeBpsDenom — and IT IS the sponsorship. Its holder can recover that money by no other route at all, because an earned entry has no spendOf record and Refund pays spendOf and nothing else. A BOUGHT unit PUT NOTHING INTO Sponsored. It put 250,000 ugnot into Pot and Cut, and its holder recovers THAT IN FULL through Refund — the whole gross, fee included (finding 25). SO A REFUNDED BUYER ENDS AN ABANDONED WEEK WITH ZERO NET STAKE AND, UNDER THE OLD BASIS, A POSITIVE CLAIM ON THE DUEL PLAYERS' RAKE. They keep their units deliberately — fenwick refuses negative deltas by design, so a ticket cannot be retired once sold (see Refund) — so the refund returns every ugnot they paid AND leaves their share of somebody else's money standing. With equal counts of the two kinds, each duel player recovered HALF of what their own rake contributed and a buyer took the other half for nothing. Paying those players is finding 31's entire stated purpose, so this was not a rounding error in that fix: it was that fix missing its target. THE DRAW'S BASIS IS UNTOUCHED AND MUST STAY SO. tickets.Total(), ticketsOf, ownerOf and FrozenTotal still count both kinds identically, because a unit is a unit in the draw whoever paid for it — that indistinguishability is finding 15's design and the earned entry's whole point. Only the CLAIM has a second basis. Read Draw.earnedOf. PURE BOOKKEEPING: NO WUGNOT MOVES. The tokens have been inside this realm since FundPot pulled them in. Sponsored falls, Claims rises by the same amount less the dust, and the dust is reclassified from a pot to opsPot — both of which the realm already holds. IT IS O(1) AND THAT IS LOAD-BEARING, NOT AN OPTIMISATION. Its main caller is Refund's abandonment transition, and Refund MUST NOT FAIL: a buyer is waiting for their own money, so a path that can be made too expensive to execute is a "money in, no way out" defect wearing a gas limit. The obvious implementation — walk earnedOf and add each holder's share to a tree — is O(number of earned holders), and that set is unbounded (every duel played in the week appends two through FundPotWithEntries). One rate is written instead, and ClaimDeadWeek derives a holder's share from it in O(log N). FINDING 34 DID NOT COST THAT PROPERTY AND COULD NOT BE ALLOWED TO. Changing the basis from tickets.Total() to earnedTotal is ONE FIELD READ FOR ANOTHER: both are O(1) counters maintained incrementally by their single writer (fenwick's total, and appendEarnedTickets respectively). This function reads no tree, iterates nothing, and its statement count is unchanged. The ten-holder gas twins exist so that a reintroduced loop shows up as a number rather than as a review comment. THE TWO CASES ARE NAMED HERE AND AT EVERY CALL SITE, and the branch between them is earnedTotal and nothing else — IT WAS tickets.Total() UNTIL finding 34: - THERE ARE EARNED HOLDERS (earnedTotal > 0). The money is theirs, and theirs alone: every ugnot except the sub-unit dust becomes claimable in proportion to EARNED weight. A buyer's bought weight is worth nothing here, because it contributed nothing here and Refund gives it back in full. - THERE ARE NO EARNED HOLDERS AT ALL (earnedTotal == 0). This is reachable and real, and it is now the WIDER branch of the two: a plain FundPot from any caller that is not the duel (into a week nobody entered OR into a week full of buyers), a duel flush whose rake was below one entry unit, or a duel both of whose parties played from realm code. That money PROVABLY HAS NO PLAYER BEHIND IT — no address earned a unit of this week — so it goes to opsPot, the destination nobody can choose, and nobody is wronged. Note what this branch does NOT do: it does not hand plain house money to the week's buyers, who paid nothing towards it and get their own gross back regardless. A WEEK WITH EARNED HOLDERS CAN NEVER FALL INTO THIS BRANCH: earnedTotal is monotonically non-decreasing (appendEarnedTickets only ever adds, and nothing in this file decrements or removes an earnedOf entry), so a week that has ever had one earned unit has a positive earnedTotal for ever. THE DUST, EXPLICITLY, BECAUSE THE IDENTITY MUST HOLD EXACTLY AND NOT APPROXIMATELY. perUnit is floor(Sponsored / earnedTotal), so the shares sum to perUnit \* earnedTotal, which is Sponsored - dust where 0 \<= dust \< earnedTotal. That dust has NO UNIT TO ATTACH TO: it is strictly smaller than one ugnot per earned unit, so there is no holder it could be given a share of without giving some holder more per unit than another. IT GOES TO opsPot IN THIS SAME STATEMENT and is reported in the event, so it is never left sitting in a pot with no claim on it and it CANNOT ACCUMULATE — there is exactly one retirement per week, for ever (Abandoned is one-way and this function refuses a second run). THE BOUND IMPROVED BY TWO ORDERS OF MAGNITUDE AS A SIDE EFFECT OF finding 34, AND THE ARITHMETIC IS WORTH WRITING OUT BECAUSE THE OLD BOUND WAS STATED AGAINST THE WRONG QUANTITY. It was "bounded above by the week's unit count in ugnot: 40 ugnot per ticket-equivalent against a 10,000,000 ugnot ticket, so at most four parts per million of a week's SALES" — a fraction of the sales, which have nothing to do with the sponsorship being divided. As a fraction of the money actually at stake it was unbounded: 1,000 tickets sold (40,000 bought units) and one rung-1 duel's rake of 1,800,000 ugnot (12 earned units). Old divisor 40,012: perUnit = 44, distributed 1,760,528, DUST 39,472 — 2.2% of the players' rake to the house. New divisor 12: perUnit = 150,000, distributed 1,800,000, DUST ZERO. THE NEW BOUND IS A FRACTION OF THE SPONSORSHIP ITSELF AND IS INDEPENDENT OF TICKET SALES: dust < earnedTotal, and arcadeEntryUnitsBound caps every delivery at units <= floor(floor(amount * arcadeBpsDenom / arcadeRakePotShareBps) / EntryUnitPrice) <= amount * (10000/6000) / 250000 = amount / 150,000, which sums over deliveries to earnedTotal <= Sponsored / 150,000. So dust < Sponsored / 150,000: STRICTLY UNDER SEVEN PARTS PER MILLION of the amount being retired, against 2.2% of it in the worked example above. AND THE SAME INEQUALITY MAKES perUnit >= 150,000 WHENEVER earnedTotal > 0, since Sponsored >= 150,000 \* earnedTotal. So the "the whole amount was sub-unit dust" state — perUnit == 0 on a week that HAS holders — IS NO LONGER REACHABLE through the duel. THE CODE FOR IT IS KEPT ANYWAY AND IS NOT CONVERTED INTO AN ASSERTION, deliberately, for the reason that governs this whole function: a panic here is a panic inside Refund. Plain FundPot money can be any size and arcadeEntryUnitsBound is a constant somebody may retune, so an assertion would trade a bounded, already-handled rounding case for a buyer who cannot reclaim their own ticket money. Handle it, do not assert it. ClaimDeadWeek's "no dead-week share to claim" names the state for a caller either way. THE TWO ALTERNATIVES AND WHY NOT. A LARGEST-REMAINDER OR RUNNING-CUMULATIVE PASS distributes the dust exactly and leaves nothing for the house, but it is the O(holders) loop this function exists to avoid. GIVING THE DUST TO THE FIRST CLAIMANT keeps it with the players but makes it a RACE for a caller-chosen benefit, which is the exact shape of the three defects above, at a tiny scale — and this realm has now been wrong twice by accepting a small choosable benefit. opsPot is the destination nobody can choose. Panics, defensively, if the week is not in the state its two callers guarantee. foldSponsorCut turns a week's escrowed SPONSOR fee back into sponsorship. IDEMPOTENT, AND CALLED FROM BOTH EXITS A DEAD WEEK HAS. retireSponsorship runs it for the Refund path; SweepDrawlessWeek runs it BEFORE its own assertions, because one of those assertions is "a swept week holds no escrowed fee" and a sponsorship is the one way a fee can be present with no ticket sale behind it. Leaving the fold until after that check was a REAL DEFECT and not a tidiness one: the assertion fired, the sweep was the only exit a ticketless week has, and the whole week's pot — including unrelated duel rake — was stranded with AuditFailure reporting green over it. Found by the 2026-09-30 audit, R1. IT MOVES BETWEEN TWO TRACKED TOTALS AND CREATES NOTHING: heldCuts falls by exactly what heldPots rises by, so heldPots + heldCuts + opsPot is untouched.

Open
OID
0a69c1…966c:29
foldSponsorCut details

Inspect func

retireSponsorship

func(d *v2.Draw)
Open
OID
0a69c1…966c:30
retireSponsorship details

Inspect func

weekHasNoSpend

func(d *v2.Draw) bool

weekHasNoSpend reports whether NOBODY EVER BOUGHT A TICKET in this week — that is, whether sum(spendOf) is zero. It is the test SweepDrawlessWeek uses to tell money that belongs to nobody from money that belongs to buyers. IT IS AN ITERATION AND NOT A COUNTER, on purpose: a per-week spend total would be a fourth aggregate to keep in step with heldPots, heldSponsored and the per-week identity, and this is not on a hot path (SweepDrawlessWeek runs at most once per week, ever). AuditFailure already re-derives the same sum the same way. IT STOPS AT THE FIRST NON-ZERO ENTRY rather than summing, so it cannot overflow and costs one lookup on the common case. A ZERO ENTRY IS NOT A CLAIM, and that distinction is why this reads the values instead of asking Size(). Refund ZEROES a spend record rather than removing it (d.spendOf.Set(k, 0)), so a week that has taken refunds has entries whose values are zero — but such a week is Abandoned and SweepDrawlessWeek refuses it before asking this, so the two can never be confused. NEVER CALL IT FROM INSIDE ANOTHER avl.Iterate: nesting a second traversal inside the first's callback crashes this toolchain's VM outright (see AuditFailure).

Open
OID
0a69c1…966c:31
weekHasNoSpend details

Inspect func

treeInt64

func(t *v0.Tree, key string) int64
Open
OID
0a69c1…966c:32
treeInt64 details

Inspect func

appendTickets

func(d *v2.Draw, addr .uverse.address, units int64)

appendTickets records a purchase of units by addr by APPENDING A NEW INDEX to this week's ticket index. It never touches an existing index, and that is the whole point — see Commit's frozen-ownership argument.

Open
OID
0a69c1…966c:33
appendTickets details

Inspect func

appendEarnedTickets

func(d *v2.Draw, addr .uverse.address, units int64)

appendEarnedTickets is appendTickets PLUS the record that these units were EARNED rather than bought. It is the only writer of earnedOf and earnedTotal, and FundPotWithEntries is its only caller. AUDIT-2026-09-28.md finding 34. IT WRAPS appendTickets RATHER THAN SITTING BESIDE IT, AND THAT IS THE WHOLE GUARANTEE. sum(earnedOf) == earnedTotal has to hold identically, not approximately, because retireSponsorship divides a real wugnot balance by the counter and ClaimDeadWeek pays out against the tree — a drift of one unit between them is a week whose claims sum to more or less than its Claims. Two sibling calls in a caller's loop can drift the moment somebody adds an early \`continue\`; one function that cannot append the weight without recording the basis cannot. The three writes are one statement group from one \`units\` argument, and nothing in this file ever decrements or removes an entry of either. THERE IS DELIBERATELY NO \`earned bool\` PARAMETER ON appendTickets INSTEAD. Buy's call would then have to pass \`false\`, which is a place a future edit can pass \`true\` by accident and hand a buyer a claim on the duel's rake — the exact defect finding 34 is about. The bought path cannot name this function at all.

Open
OID
0a69c1…966c:34
appendEarnedTickets details

Inspect func

ownerOfUnit

func(d *v2.Draw, k int64) .uverse.address
Open
OID
0a69c1…966c:35
ownerOfUnit details

Inspect func

rejectAttachedCoin

func(what string)

rejectAttachedCoin panics if the caller attached native coin to a call that has no use for it. Every money-in path in this realm is the wugnot GRC20 allowance (Buy, Sponsor, FundPot, FundPotWithEntries), never the transaction's send envelope, so any attached ugnot would simply sit in this realm forever with no code path that ever looks at it — AUDIT-2026-10-07.md Y1. Call this as the first statement of every crossing entrypoint so the transaction reverts and the sender keeps their coin instead. unsafe.OriginSend() reads the envelope attached at the CHAIN ROOT, and that envelope is shared by every realm in the call chain: an intermediate realm can consume it, or have already consumed it, before this call is ever reached. Reading it is only sound when the immediate caller is an EOA, so that whatever it describes was paid to THIS realm and nothing else first. cur.Previous().IsUserCall() is that check (IsUser() is not — it also accepts \`maketx run\` ephemeral realms, which can pre-consume the envelope); see r/splash/exchange/exchange.gno's Buy/Seed comment for the fuller argument, which this mirrors rather than contradicts. WHEN THE CALL DID NOT COME DIRECTLY FROM A USER — which is every call FundPot and FundPotWithEntries ever accept, since both require cur.Previous().PkgPath() to be the duel's — THIS FUNCTION DOES NOT LOOK AT THE ENVELOPE AT ALL: it cannot be attributed to this call either way, so there is nothing sound to enforce, and guessing would be worse than staying silent. THE OVERWHELMINGLY COMMON CASE — a plain call with nothing attached — is cheap: one stack-walk for IsUserCall, then unsafe.OriginSend() always allocates (even a len-0 slice) and len(sent) == 0 is a single comparison. No iteration, no second cross-realm call.

Open
OID
0a69c1…966c:36
rejectAttachedCoin details

Inspect func

sweepDonations

func()

sweepDonations accounts for any wugnot that has arrived without going through Buy. IT HAS TO EXIST. wugnot.Transfer is permissionless: any address may push wugnot at this realm with no memo, no callback and no way for this realm to learn who sent it or why. Without a sweep, one unsolicited transfer would fail checkInvariant for every later caller and brick the realm. IT GOES TO opsPot, NOT TO THE CURRENT WEEK'S POT, which is the opposite of r/splash/game's choice and for a reason specific to this design: here Pot == sum(this week's spend records) is a load-bearing identity — it is what makes Refund unable to pay out more than was put in — and a donation credited to a pot would break it, silently converting somebody's gift into refundable balance that nobody has a claim on. The ops pot has no such identity to violate, and a donation there enlarges nothing it should not. IT EMITS A Donation EVENT FOR EVERY NON-ZERO ABSORPTION, and that is not bookkeeping decoration — it is the only record that the stream exists. Nothing else in this realm distinguishes a swept arrival from the house cut: both land in opsPot, both are withdrawn by WithdrawOps, and opsPot is a single number. So without this event an operator reading the chain cannot tell revenue from gifts, cannot see a donation arrive at all, and — the reason it matters — cannot see the masking case below. WHAT THE INVARIANT'S EQUALITY IS ACTUALLY WORTH, RECORDED HERE SO THE NEXT READER DOES NOT OVERTRUST IT. Suppose heldPots OVERCOUNTS by D because of a bug in this file — the realm believes it owes D more than it holds. Suppose donations totalling X > D have arrived. This function computes extra = bal - (heldPots + opsPot), which is X - D, absorbs it into opsPot, and afterwards heldPots + opsPot == bal EXACTLY. checkInvariant then passes, and so does AuditFailure's one-sided balance test. THE DEFICIT HAS NOT BEEN DETECTED, IT HAS BEEN PAID FOR OUT OF A STRANGER'S MONEY, and both solvency checks report green over it. The equality in checkInvariant is therefore weaker than it looks: it says "the books add up after absorbing whatever arrived", not "the books were already right". This event is what makes the absorption visible so the case can be noticed off-chain — a Donation for an amount no donor sent is the signature of exactly that state. FIXING THAT PROPERLY IS OUT OF SCOPE HERE, deliberately. It needs a separate counter for absorbed donations so the invariant can subtract them and state the pre-donation equality — a change to what the invariant MEANS, in a realm where every mutator asserts it, and therefore a separately-audited one. This function's destination is unchanged: donations still go to opsPot, not to any week's pot, for the reason above.

Open
OID
0a69c1…966c:37
sweepDonations details

Inspect func

absorbDonation

func() int64

absorbDonation is sweepDonations without the event: it credits opsPot with any unattributed arrival and RETURNS THE AMOUNT IT ABSORBED, zero if there was nothing. Everything said about the destination and the masking case is on sweepDonations. IT IS SPLIT OUT FOR ONE REASON, AND IT IS A TESTABILITY REASON STATED PLAINLY: this toolchain gives a Gno test no way to read emitted events, so the only statements about the Donation event that can be asserted on-chain are the value it would carry and whether it fires at all. Both are exactly this return value, and raffle\_test.gno asserts it on both sides — a real arrival absorbed to the ugnot, and an empty sweep returning zero so the caller above emits nothing. The Emit is then a one-line function of a tested quantity rather than an untested branch. Do not inline it back without putting the event under test some other way.

Open
OID
0a69c1…966c:38
absorbDonation details

Inspect func

checkInvariant

func()

checkInvariant is this realm's solvency statement: the wugnot it holds is exactly the sum of every unresolved week's pot plus the ops pot, and nothing else. O(1), using the incrementally-maintained heldPots; AuditFailure re-derives the same thing by iteration. CALLED AT THE END OF EVERY STATE-CHANGING FUNCTION. An equality, not a ">=": sweepDonations runs first in each of them, so unattributed arrivals are already accounted for and any remaining gap is a bug in this file. THE SPONSORSHIP HALF OF THE POT IDENTITY IS CHECKED HERE ONLY IN ITS O(1) FORM. The full statement is per week — Pot + Cut == sum(spendOf) + Sponsored — and verifying it directly would mean iterating every buyer of every week on every call, which is exactly the cost this function exists to avoid. What is affordable, and is asserted, is that the aggregate sponsorship is a non-negative part of the pots that hold it: more sponsorship than pots would mean the buyers' collective claim had gone negative somewhere. AuditFailure re-derives the per-week identity ONE WEEK AT A TIME (finding 27 — it used to do it summed over the unresolved weeks, which two weeks with compensating errors passed through silently).

Open
OID
0a69c1…966c:39
checkInvariant details

Inspect func

AuditFailure

func() string

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.

Open
OID
0a69c1…966c:40
AuditFailure details

Inspect func

deadWeekClaimFailure

func(d *v2.Draw, i64 func(...)) string

deadWeekClaimFailure re-derives everything finding 31's fix asserts about ONE week's dead-money state, and returns the empty string when that week is consistent. Factored out of AuditFailure so the per-week loop stays readable, and called for EVERY week rather than only the unresolved ones — two of the five statements below are about a resolved week. THE FIVE STATEMENTS, and each one is a state that would mean money the realm holds has no claimant or two claimants: 1. AN ABANDONED WEEK HOLDS NO SPONSORSHIP. This is the one that replaces the whole carry register and it is the strongest of the five: retireSponsorship runs in the same statement group that sets Abandoned, so a non-zero Sponsored on an abandoned week means money sitting in a pot whose draw can never happen and whose buyers' claims are being zeroed one by one — the exact "money in, no way out" shape this realm's first finding was about, and the shape the register only NAMED rather than removed. 2. A CLAIM RATE IMPLIES ABANDONMENT. A live week can still sell tickets and still resolve, so a claim rate on one would be a claim against a prize somebody is about to win. 3. A RESOLVED WEEK HOLDS NO CLAIMS AND NO RATE. Resolved and Abandoned are mutually exclusive, so this is implied by (2) — and it is stated anyway because it is the statement that the winner was not paid a pot somebody else still has a claim on. 4. THE UNCLAIMED TOTAL MATCHES THE RATE AND THE PAYMENTS. Claims must equal ClaimPerUnit \* earnedTotal minus everything claimedOf records as paid. This is the independent re-derivation of the O(1) counter: ClaimDeadWeek writes both sides and a half-applied payment is its most likely bug. 5. EVERY PAYMENT MATCHES ITS HOLDER'S EARNED WEIGHT. Each claimedOf entry must be exactly that holder's EARNED units times the rate. Without this, (4) would still pass if one holder had been overpaid and another underpaid by the same amount — the same cancelling-pair blindness finding 27 was about, one level down. 6. THE EARNED BASIS IS INTERNALLY CONSISTENT — AUDIT-2026-09-28.md finding 34, and it is checked on EVERY week including live ones, before any of the five above. Two statements: sum(earnedOf) == earnedTotal, and earnedTotal \<= tickets.Total(). WHY IT IS RE-DERIVED AT ALL, GIVEN THAT ONE FUNCTION WRITES BOTH. Because the whole exactness argument of finding 31's fix — that every possible claim sums to exactly Claims — now rests on it. retireSponsorship divides a real wugnot balance by the COUNTER and ClaimDeadWeek pays out against the TREE; if the two ever disagreed by one unit, the claims would sum to more or less than Claims and (4) would be the only thing that noticed, one abandonment too late. The old design got this identity free because sum(ticketsOf) == tickets.Total() was maintained by fenwick itself. The earned pair is maintained by appendEarnedTickets instead, so the equivalent statement is made here rather than assumed from the type. THE SECOND HALF IS THE STRUCTURAL ONE: an earned unit is appended through appendTickets in the same statement group, so it is always also a ticket unit. An earnedTotal larger than the week's ticket total would mean weight claimed that the draw never saw. COLLECT-THEN-LOOP, NEVER A NESTED avl.Iterate. The claimedOf keys are gathered in one pass and every ticketsOf lookup happens after it, because nesting a second traversal inside the first's callback crashes this toolchain's VM outright (a Go-level nil dereference, not a gno panic). Its caller has already finished the draws traversal before calling, for the same reason.

Open
OID
0a69c1…966c:41
deadWeekClaimFailure details

Inspect func

AuditInvariant

func() bool

AuditInvariant is AuditFailure reduced to a bool.

Open
OID
0a69c1…966c:42
AuditInvariant details

Inspect func

Buy

func(tickets int64)

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.

Open
OID
0a69c1…966c:43
Buy details

Inspect func

FundPot

func(amount int64)

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).

Open
OID
0a69c1…966c:44
FundPot details

Inspect func

Sponsor

func(amount int64, label string)

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.

Open
OID
0a69c1…966c:45
Sponsor details

Inspect func

checkSponsorLabel

func(label string)

checkSponsorLabel refuses anything that is not plain, bounded, single-line text. Same shape as the duel's checkLabel, and for the same reason: this is a stranger's text and it is about to be stored for ever. IT DOES NOT SANITISE. Escaping belongs at the point of rendering, where the target syntax is known — storing a pre-escaped string would mean the stored value and the value the sponsor typed were different things, and every later reader would have to know which one they held.

Open
OID
0a69c1…966c:46
checkSponsorLabel details

Inspect func

duelRealmPkgPath

untyped string

\--- the arcade's earned entries ----------------------------------------- THE DEFECT THIS CLOSES (AUDIT-2026-09-28.md finding 15), in the owner's words: "so people could be gambling money on coinflip all week and some asshole with a 1cent ticket can win the pot?" Yes. The duel's rake flowed in here as sponsorship and PLAYING THE DUEL BOUGHT NOTHING, so a week with a busy coin flip and no ticket sales had a large pot, a ticket total of zero, and could be taken in its entirety by one 0.1 GNOT ticket bought in its final block. The money and the entrants came from different places. THE FIX IS THE OWNER'S OWN AND IT ADDRESSES THE CAUSE RATHER THAN GATING THE SYMPTOM. Duel players earn entries in the live week's draw, so the people whose money built the pot are in the draw for it and a lone late ticket is one entry among many. Nothing is refused, no minimum is imposed and no buyer is gated. RULE 1, AND IT IS THE ONE THAT MAKES THE FEATURE SAFE: ENTRIES ARE PRICED OFF THE RAKE PAID, NEVER OFF THE STAKE. The duel lets a poster address an offer to one opponent, so two accounts under one hand can flip each other all day and the only real cost of doing it is the rake. WEIGHT ENTRIES BY STAKE AND THAT IS FREE MONEY: a 10 GNOT rung is a 20 GNOT pool on a 0.6 GNOT rake, so stake-weighting would hand out 80 units of weight (20 GNOT / EntryUnitPrice) for 0.6 GNOT of cost — entries at a THIRTY-THIRD of face value. Nobody would ever buy a ticket again and one funded account would own every draw. PRICING ENTRIES AT EXACTLY WHAT THE RAKE WOULD HAVE BOUGHT AT EntryUnitPrice MAKES FLIPPING AND BUYING IDENTICAL VALUE. A self-dealer's net cost for a rung-1 flip is the 3000000 ugnot rake and they earn 12 units: 250000 ugnot a unit, which is EntryUnitPrice exactly. Self-dealing therefore gains nothing — and is in fact weakly WORSE than buying, on two counts since 2026-09-29. Only 60% of a rake reaches the pot against 95% of a ticket price, so the flipper pays the same per unit into a smaller prize; and at rung 0 the floor bites (600000 / 250000 = 2.4 units, floored to 2), so that rung pays 300000 a unit — 1.2x face value. BOTH ERRORS RUN THE SAFE WAY: flipping can be dearer than buying and can never be cheaper. THAT INEQUALITY IS THE SECURITY PROPERTY OF THIS WHOLE FEATURE, it is what the bound below enforces at the door, and raffle_test.gno and duel_test.gno both assert the rate against EntryUnitPrice rather than against a hardcoded entry count. IT WAS TicketPrice ON BOTH SIDES OF THAT SENTENCE UNTIL 2026-09-29, when a 10 GNOT ticket made three of the four rungs earn nothing at all. The entry unit exists so that the ticket price and the draw's granularity can move independently; read EntryUnitPrice and AUDIT-2026-09-28.md finding 22. RULE 2: BOTH PLAYERS EARN, AND THE LOSER NEVER EARNS LESS. The split is the duel's to compute (at least one entry each, the remainder two thirds to the loser — see its splitEntries) and this realm neither knows nor cares which holder was which. What this realm enforces is the TOTAL, because the total is the only part that could be an attack: an uneven split of a fixed total costs nothing and creates no edge. "AND THE LOSER EARNS MORE" IS WHAT THIS SAID UNTIL 2026-09-29, and it is corrected rather than left: at the duel's bottom rung the two shares are now EQUAL, one each out of a two-unit total, because a floored third of 2 gave the WINNER ZERO. AUDIT-2026-09-28.md finding 26. Nothing this realm enforces moved — the total per rung is the same number it was, which is exactly why the change needed no permission from this side of the door. RULE 3: IT TRAVELS THE SAFE ROUTE. duel.Collect books; duel.FlushPot delivers and is the only caller of the function below. Collect still calls NO raffle code at all, which is AUDIT-2026-09-28.md finding 14 and must stay true: a panic in here — including this function's own refusals — costs the flusher a reverted transaction and can never stop a duel winner collecting.

Value

"gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/duel/v2"

arcadeRakePotShareBps

int64

\--- the arcade's earned entries ----------------------------------------- THE DEFECT THIS CLOSES (AUDIT-2026-09-28.md finding 15), in the owner's words: "so people could be gambling money on coinflip all week and some asshole with a 1cent ticket can win the pot?" Yes. The duel's rake flowed in here as sponsorship and PLAYING THE DUEL BOUGHT NOTHING, so a week with a busy coin flip and no ticket sales had a large pot, a ticket total of zero, and could be taken in its entirety by one 0.1 GNOT ticket bought in its final block. The money and the entrants came from different places. THE FIX IS THE OWNER'S OWN AND IT ADDRESSES THE CAUSE RATHER THAN GATING THE SYMPTOM. Duel players earn entries in the live week's draw, so the people whose money built the pot are in the draw for it and a lone late ticket is one entry among many. Nothing is refused, no minimum is imposed and no buyer is gated. RULE 1, AND IT IS THE ONE THAT MAKES THE FEATURE SAFE: ENTRIES ARE PRICED OFF THE RAKE PAID, NEVER OFF THE STAKE. The duel lets a poster address an offer to one opponent, so two accounts under one hand can flip each other all day and the only real cost of doing it is the rake. WEIGHT ENTRIES BY STAKE AND THAT IS FREE MONEY: a 10 GNOT rung is a 20 GNOT pool on a 0.6 GNOT rake, so stake-weighting would hand out 80 units of weight (20 GNOT / EntryUnitPrice) for 0.6 GNOT of cost — entries at a THIRTY-THIRD of face value. Nobody would ever buy a ticket again and one funded account would own every draw. PRICING ENTRIES AT EXACTLY WHAT THE RAKE WOULD HAVE BOUGHT AT EntryUnitPrice MAKES FLIPPING AND BUYING IDENTICAL VALUE. A self-dealer's net cost for a rung-1 flip is the 3000000 ugnot rake and they earn 12 units: 250000 ugnot a unit, which is EntryUnitPrice exactly. Self-dealing therefore gains nothing — and is in fact weakly WORSE than buying, on two counts since 2026-09-29. Only 60% of a rake reaches the pot against 95% of a ticket price, so the flipper pays the same per unit into a smaller prize; and at rung 0 the floor bites (600000 / 250000 = 2.4 units, floored to 2), so that rung pays 300000 a unit — 1.2x face value. BOTH ERRORS RUN THE SAFE WAY: flipping can be dearer than buying and can never be cheaper. THAT INEQUALITY IS THE SECURITY PROPERTY OF THIS WHOLE FEATURE, it is what the bound below enforces at the door, and raffle_test.gno and duel_test.gno both assert the rate against EntryUnitPrice rather than against a hardcoded entry count. IT WAS TicketPrice ON BOTH SIDES OF THAT SENTENCE UNTIL 2026-09-29, when a 10 GNOT ticket made three of the four rungs earn nothing at all. The entry unit exists so that the ticket price and the draw's granularity can move independently; read EntryUnitPrice and AUDIT-2026-09-28.md finding 22. RULE 2: BOTH PLAYERS EARN, AND THE LOSER NEVER EARNS LESS. The split is the duel's to compute (at least one entry each, the remainder two thirds to the loser — see its splitEntries) and this realm neither knows nor cares which holder was which. What this realm enforces is the TOTAL, because the total is the only part that could be an attack: an uneven split of a fixed total costs nothing and creates no edge. "AND THE LOSER EARNS MORE" IS WHAT THIS SAID UNTIL 2026-09-29, and it is corrected rather than left: at the duel's bottom rung the two shares are now EQUAL, one each out of a two-unit total, because a floored third of 2 gave the WINNER ZERO. AUDIT-2026-09-28.md finding 26. Nothing this realm enforces moved — the total per rung is the same number it was, which is exactly why the change needed no permission from this side of the door. RULE 3: IT TRAVELS THE SAFE ROUTE. duel.Collect books; duel.FlushPot delivers and is the only caller of the function below. Collect still calls NO raffle code at all, which is AUDIT-2026-09-28.md finding 14 and must stay true: a panic in here — including this function's own refusals — costs the flusher a reverted transaction and can never stop a duel winner collecting.

Value

6000

arcadeBpsDenom

int64

\--- the arcade's earned entries ----------------------------------------- THE DEFECT THIS CLOSES (AUDIT-2026-09-28.md finding 15), in the owner's words: "so people could be gambling money on coinflip all week and some asshole with a 1cent ticket can win the pot?" Yes. The duel's rake flowed in here as sponsorship and PLAYING THE DUEL BOUGHT NOTHING, so a week with a busy coin flip and no ticket sales had a large pot, a ticket total of zero, and could be taken in its entirety by one 0.1 GNOT ticket bought in its final block. The money and the entrants came from different places. THE FIX IS THE OWNER'S OWN AND IT ADDRESSES THE CAUSE RATHER THAN GATING THE SYMPTOM. Duel players earn entries in the live week's draw, so the people whose money built the pot are in the draw for it and a lone late ticket is one entry among many. Nothing is refused, no minimum is imposed and no buyer is gated. RULE 1, AND IT IS THE ONE THAT MAKES THE FEATURE SAFE: ENTRIES ARE PRICED OFF THE RAKE PAID, NEVER OFF THE STAKE. The duel lets a poster address an offer to one opponent, so two accounts under one hand can flip each other all day and the only real cost of doing it is the rake. WEIGHT ENTRIES BY STAKE AND THAT IS FREE MONEY: a 10 GNOT rung is a 20 GNOT pool on a 0.6 GNOT rake, so stake-weighting would hand out 80 units of weight (20 GNOT / EntryUnitPrice) for 0.6 GNOT of cost — entries at a THIRTY-THIRD of face value. Nobody would ever buy a ticket again and one funded account would own every draw. PRICING ENTRIES AT EXACTLY WHAT THE RAKE WOULD HAVE BOUGHT AT EntryUnitPrice MAKES FLIPPING AND BUYING IDENTICAL VALUE. A self-dealer's net cost for a rung-1 flip is the 3000000 ugnot rake and they earn 12 units: 250000 ugnot a unit, which is EntryUnitPrice exactly. Self-dealing therefore gains nothing — and is in fact weakly WORSE than buying, on two counts since 2026-09-29. Only 60% of a rake reaches the pot against 95% of a ticket price, so the flipper pays the same per unit into a smaller prize; and at rung 0 the floor bites (600000 / 250000 = 2.4 units, floored to 2), so that rung pays 300000 a unit — 1.2x face value. BOTH ERRORS RUN THE SAFE WAY: flipping can be dearer than buying and can never be cheaper. THAT INEQUALITY IS THE SECURITY PROPERTY OF THIS WHOLE FEATURE, it is what the bound below enforces at the door, and raffle_test.gno and duel_test.gno both assert the rate against EntryUnitPrice rather than against a hardcoded entry count. IT WAS TicketPrice ON BOTH SIDES OF THAT SENTENCE UNTIL 2026-09-29, when a 10 GNOT ticket made three of the four rungs earn nothing at all. The entry unit exists so that the ticket price and the draw's granularity can move independently; read EntryUnitPrice and AUDIT-2026-09-28.md finding 22. RULE 2: BOTH PLAYERS EARN, AND THE LOSER NEVER EARNS LESS. The split is the duel's to compute (at least one entry each, the remainder two thirds to the loser — see its splitEntries) and this realm neither knows nor cares which holder was which. What this realm enforces is the TOTAL, because the total is the only part that could be an attack: an uneven split of a fixed total costs nothing and creates no edge. "AND THE LOSER EARNS MORE" IS WHAT THIS SAID UNTIL 2026-09-29, and it is corrected rather than left: at the duel's bottom rung the two shares are now EQUAL, one each out of a two-unit total, because a floored third of 2 gave the WINNER ZERO. AUDIT-2026-09-28.md finding 26. Nothing this realm enforces moved — the total per rung is the same number it was, which is exactly why the change needed no permission from this side of the door. RULE 3: IT TRAVELS THE SAFE ROUTE. duel.Collect books; duel.FlushPot delivers and is the only caller of the function below. Collect still calls NO raffle code at all, which is AUDIT-2026-09-28.md finding 14 and must stay true: a panic in here — including this function's own refusals — costs the flusher a reverted transaction and can never stop a duel winner collecting.

Value

10000

ArcadeEntryUnitsBound

func(amount int64) int64

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 rake <= 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.

Open
OID
0a69c1…966c:47
ArcadeEntryUnitsBound details

Inspect func

arcadeEntryUnitsBound

func(amount int64) int64
Open
OID
0a69c1…966c:48
arcadeEntryUnitsBound details

Inspect func

FundPotWithEntries

func(amount int64, holders [].uverse.address, units []int64) int64

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

Open
OID
0a69c1…966c:49
FundPotWithEntries details

Inspect func

Commit

func(week int64) int64

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.

Open
OID
0a69c1…966c:50
Commit details

Inspect func

Capture

func(week int64)

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.

Open
OID
0a69c1…966c:51
Capture details

Inspect func

Resolve

func(week int64)

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.)

Open
OID
0a69c1…966c:52
Resolve details

Inspect func

refundGraceElapsed

func(d *v2.Draw) bool

refundGraceElapsed reports whether refundGraceBlocks have passed since Commit froze this week. IT READS NO BEACON STATE AT ALL, and that is its entire purpose: it is the one condition in this file that a stopped, lost or hostile publisher cannot hold false. See refundGraceBlocks for the derivation and for the two ways the beacon otherwise traps a pot forever. IT IS MONOTONE, unlike resolvable(): chain height only rises and CommittedAt never moves once set, so this goes true exactly once and stays true. Refund therefore never needs Abandoned to protect it from this half of the gate — only from resolvable(). A ZERO CommittedAt RETURNS false, and false is the safe direction. It is unreachable through the entry points: Refund tests Committed first, and Commit sets both fields in the same statement group. If it ever were reachable — a week committed by some future code path that forgot this field — withholding the extra escape hatch is a liveness cost, whereas granting it would open refunds on a live draw at any height past refundGraceBlocks.

Open
OID
0a69c1…966c:53
refundGraceElapsed details

Inspect func

Refund

func(week int64)

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: "An abandoned week's Sponsored ROLLS FORWARD TO THE LIVE WEEK (finding 16), because it belongs 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: "A week whose entries are ALL earned therefore refunds nobody and MOVES ITS WHOLE POT TO opsPot ON THE FIRST REFUND ATTEMPT — except that there can be no first refund attempt, because no caller has a spend record, so such a week simply stays unrefundable and RESOLVE 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.

Open
OID
0a69c1…966c:54
Refund details

Inspect func

ClaimSponsorRefund

func(week int64) int64

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

Open
OID
0a69c1…966c:55
ClaimSponsorRefund details

Inspect func

SponsorRefundableFor

func(week int64, addr .uverse.address) int64

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.

Open
OID
0a69c1…966c:56
SponsorRefundableFor details

Inspect func

SponsorRefundableOf

func(week int64) int64

SponsorRefundableOf is what a dead week still owes its sponsors.

Open
OID
0a69c1…966c:57
SponsorRefundableOf details

Inspect func

ClaimDeadWeek

func(week int64) int64
Open
OID
0a69c1…966c:58
ClaimDeadWeek details

Inspect func

SweepDrawlessWeek

func(week int64)

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.

Open
OID
0a69c1…966c:59
SweepDrawlessWeek details

Inspect func

WithdrawOps

func() int64

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.

Open
OID
0a69c1…966c:60
WithdrawOps details

Inspect func

PotSize

func() int64

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.

Open
OID
0a69c1…966c:61
PotSize details

Inspect func

TicketTotal

func() int64

TicketTotal is the CURRENT week's ticket units sold.

Open
OID
0a69c1…966c:62
TicketTotal details

Inspect func

TicketTotalOfWeek

func(week int64) int64

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.

Open
OID
0a69c1…966c:63
TicketTotalOfWeek details

Inspect func

TicketsOf

func(addr .uverse.address) int64

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

Open
OID
0a69c1…966c:64
TicketsOf details

Inspect func

SpendOf

func(addr .uverse.address) int64

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.

Open
OID
0a69c1…966c:65
SpendOf details

Inspect func

TicketsOfWeek

func(week int64, addr .uverse.address) int64

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.

Open
OID
0a69c1…966c:66
TicketsOfWeek details

Inspect func

SpendOfWeek

func(week int64, addr .uverse.address) int64

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.

Open
OID
0a69c1…966c:67
SpendOfWeek details

Inspect func

CurrentWeek

func() int64

CurrentWeek is the week tickets are being sold into right now.

Open
OID
0a69c1…966c:68
CurrentWeek details

Inspect func

WeekEndHeight

func(week int64) int64

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.

Open
OID
0a69c1…966c:69
WeekEndHeight details

Inspect func

CurrentWeekEndHeight

func() int64

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.

Open
OID
0a69c1…966c:70
CurrentWeekEndHeight details

Inspect func

DrawOf

func(week int64) (bool, int64, int64, int64, bool, .uverse.address, bool, int64, int64)

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.

Open
OID
0a69c1…966c:71
DrawOf details

Inspect func

RefundGraceBlocks

func() int64

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.

Open
OID
0a69c1…966c:72
RefundGraceBlocks details

Inspect func

RefundOpensAt

func(week int64) int64

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.

Open
OID
0a69c1…966c:73
RefundOpensAt details

Inspect func

SponsorsOf

func(week int64) []v2.Sponsorship

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.

Open
OID
0a69c1…966c:74
SponsorsOf details

Inspect func

SponsorCutOf

func(week int64) int64

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.

Open
OID
0a69c1…966c:75
SponsorCutOf details

Inspect func

SponsoredOf

func(week int64) int64
Open
OID
0a69c1…966c:76
SponsoredOf details

Inspect func

ClaimPerUnitOf

func(week int64) int64

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.

Open
OID
0a69c1…966c:77
ClaimPerUnitOf details

Inspect func

UnclaimedOf

func(week int64) int64

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.

Open
OID
0a69c1…966c:78
UnclaimedOf details

Inspect func

ClaimableOf

func(week int64, addr .uverse.address) int64

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.

Open
OID
0a69c1…966c:79
ClaimableOf details

Inspect func

EarnedOfWeek

func(week int64, addr .uverse.address) int64

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.

Open
OID
0a69c1…966c:80
EarnedOfWeek details

Inspect func

EarnedTotalOfWeek

func(week int64) int64

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.

Open
OID
0a69c1…966c:81
EarnedTotalOfWeek details

Inspect func

UnclaimedHoldersOf

func(week int64) int64

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.

Open
OID
0a69c1…966c:82
UnclaimedHoldersOf details

Inspect func

Unclaimed

func() int64

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.

Open
OID
0a69c1…966c:83
Unclaimed details

Inspect func

Sponsored

func() int64

Sponsored is the house money in the CURRENT week's pot.

Open
OID
0a69c1…966c:84
Sponsored details

Inspect func

CapturedRandomness

func(week int64) string

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.

Open
OID
0a69c1…966c:85
CapturedRandomness details

Inspect func

OpsPot

func() int64

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.

Open
OID
0a69c1…966c:86
OpsPot details

Inspect func

EscrowedCut

func(week int64) int64

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.

Open
OID
0a69c1…966c:87
EscrowedCut details

Inspect func

HeldCuts

func() int64

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.

Open
OID
0a69c1…966c:88
HeldCuts details

Inspect func

PageLimit

int64

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

Value

100

WeekCount

func() int64

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

Open
OID
0a69c1…966c:89
WeekCount details

Inspect func

Weeks

func(offset int64, limit int64) []int64

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.

Open
OID
0a69c1…966c:90
Weeks details

Inspect func

weekOfKey

func(key string) int64

weekOfKey inverts weekKey. ParseInt reads the zero padding fine; a key that does not parse cannot have come from weekKey, so it is a corrupt tree and not a case to paper over.

Open
OID
0a69c1…966c:91
weekOfKey details

Inspect func

sponsorText

func(s string) string

Render is the human-readable view. It ITERATES THE avl.Tree in key order, which is week order because weekKey is fixed-width and zero-padded, so the output is byte-identical across runs. A Go map here would randomise the order and make the rendered state unreproducible. sponsorText makes a stranger's credit line safe to put in this realm's markdown. TWO ESCAPES, NOT ONE. sanitize.InlineText handles the markdown and HTML metacharacters; the pipe is escaped separately because Render's per-week table is pipe-delimited and a label is allowed to contain '|' — 64 bytes is ample to forge a cell and fake a column. The list below is not a table today, and the pipe escape is here anyway so that moving this text into one later cannot reintroduce the hole. Same two-step, and the same reasoning, as the duel's. THE ESCAPING IS HERE AND NOT IN Sponsor DELIBERATELY: the stored value stays exactly what the sponsor typed, so every reader holds one thing rather than having to know whether their copy was pre-escaped.

Open
OID
0a69c1…966c:92
sponsorText details

Inspect func

renderSponsors

func(week int64) string

renderSponsors lists a week's credit lines — the thing a sponsorship actually buys. Empty string when there are none, so the section simply does not appear.

Open
OID
0a69c1…966c:93
renderSponsors details

Inspect func
TicketPrice : int64 =10000000
EntryUnitPrice : int64 =250000
unitsPerTicket : int64 =40
HouseCutBps : int64 =500
bpsDenom : int64 =10000
cutPerTicket : int64 =500000
MinSponsorship : int64 =1000000000
maxSponsorLabelLen : untyped bigint =(64 <untyped> bigint)
maxSponsorsPerWeek : untyped bigint =(16 <untyped> bigint)
opsVaultAddr : .uverse.address =<gnolang.StringValue>
blocksPerDay : untyped bigint =(26182 <untyped> bigint)
raffleWeekDays : untyped bigint =(7 <untyped> bigint)
randomnessHexLen : untyped bigint =(64 <untyped> bigint)
beaconRoundsPerDay : untyped bigint =(28800 <untyped> bigint)
pinMarginDays : untyped bigint =(1 <untyped> bigint)
pinOffsetDays : untyped bigint =(8 <untyped> bigint)
pinOffsetRounds : untyped bigint =(230400 <untyped> bigint)
maxPinnableLatest : int64 =9223372036854545407
refundGraceDays : untyped bigint =(21 <untyped> bigint)
refundGraceBlocks : untyped bigint =(549822 <untyped> bigint)
Sponsorship : type =v2.Sponsorship
Draw : type =v2.Draw
draws : *v0.Tree Inspect
opsPot : int64 =0
heldPots : int64 =0
heldCuts : int64 =0
heldSponsored : int64 =0
heldClaims : int64 =0
heldSponsorRefunds : int64 =0
launchHeight : int64 =254839
init.30 : func() Inspect
realmSelf : .uverse.address =<gnolang.StringValue>
selfAddr : func() .uverse.address Inspect
keyWidth : untyped bigint =(19 <untyped> bigint)
zeroPad : untyped string ="0000000000000000000"
pad : func(s string) string Inspect
weekKey : func(week int64) string Inspect
indexKey : func(i int) string Inspect
currentDay : func() int64 Inspect
currentWeek : func() int64 Inspect
weekEndHeight : func(week int64) int64 Inspect
mulDiv : func(a uint64, b uint64, d uint64) uint64 Inspect
maxInt64 : uint64 =9223372036854775807
ticketCost : func(tickets int64) int64 Inspect
hexDigit : func(c uint8) (uint64, bool) Inspect
randomnessMod : func(randomness string, m int64) int64 Inspect
getDraw : func(week int64) *v2.Draw Inspect
getOrCreateDraw : func(week int64) *v2.Draw Inspect
foldSponsorCut : func(d *v2.Draw) Inspect
retireSponsorship : func(d *v2.Draw) Inspect
weekHasNoSpend : func(d *v2.Draw) bool Inspect
treeInt64 : func(t *v0.Tree, key string) int64 Inspect
appendTickets : func(d *v2.Draw, addr .uverse.address, units int64) Inspect
appendEarnedTickets : func(d *v2.Draw, addr .uverse.address, units int64) Inspect
ownerOfUnit : func(d *v2.Draw, k int64) .uverse.address Inspect
rejectAttachedCoin : func(what string) Inspect
sweepDonations : func() Inspect
absorbDonation : func() int64 Inspect
checkInvariant : func() Inspect
AuditFailure : func() string Inspect
deadWeekClaimFailure : func(d *v2.Draw, i64 func(...)) string Inspect
AuditInvariant : func() bool Inspect
Buy : func(tickets int64) Inspect
FundPot : func(amount int64) Inspect
Sponsor : func(amount int64, label string) Inspect
checkSponsorLabel : func(label string) Inspect
duelRealmPkgPath : untyped string ="gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/duel/v2"
arcadeRakePotShareBps : int64 =6000
arcadeBpsDenom : int64 =10000
ArcadeEntryUnitsBound : func(amount int64) int64 Inspect
arcadeEntryUnitsBound : func(amount int64) int64 Inspect
FundPotWithEntries : func(amount int64, holders [].uverse.address, units []int64) int64 Inspect
Commit : func(week int64) int64 Inspect
Capture : func(week int64) Inspect
Resolve : func(week int64) Inspect
refundGraceElapsed : func(d *v2.Draw) bool Inspect
Refund : func(week int64) Inspect
ClaimSponsorRefund : func(week int64) int64 Inspect
SponsorRefundableFor : func(week int64, addr .uverse.address) int64 Inspect
SponsorRefundableOf : func(week int64) int64 Inspect
ClaimDeadWeek : func(week int64) int64 Inspect
SweepDrawlessWeek : func(week int64) Inspect
WithdrawOps : func() int64 Inspect
PotSize : func() int64 Inspect
TicketTotal : func() int64 Inspect
TicketTotalOfWeek : func(week int64) int64 Inspect
TicketsOf : func(addr .uverse.address) int64 Inspect
SpendOf : func(addr .uverse.address) int64 Inspect
TicketsOfWeek : func(week int64, addr .uverse.address) int64 Inspect
SpendOfWeek : func(week int64, addr .uverse.address) int64 Inspect
CurrentWeek : func() int64 Inspect
WeekEndHeight : func(week int64) int64 Inspect
CurrentWeekEndHeight : func() int64 Inspect
DrawOf : func(week int64) (bool, int64, int64, int64, bool, .uverse.address, bool, int64, int64) Inspect
RefundGraceBlocks : func() int64 Inspect
RefundOpensAt : func(week int64) int64 Inspect
SponsorsOf : func(week int64) []v2.Sponsorship Inspect
SponsorCutOf : func(week int64) int64 Inspect
SponsoredOf : func(week int64) int64 Inspect
ClaimPerUnitOf : func(week int64) int64 Inspect
UnclaimedOf : func(week int64) int64 Inspect
ClaimableOf : func(week int64, addr .uverse.address) int64 Inspect
EarnedOfWeek : func(week int64, addr .uverse.address) int64 Inspect
EarnedTotalOfWeek : func(week int64) int64 Inspect
UnclaimedHoldersOf : func(week int64) int64 Inspect
Unclaimed : func() int64 Inspect
Sponsored : func() int64 Inspect
CapturedRandomness : func(week int64) string Inspect
OpsPot : func() int64 Inspect
EscrowedCut : func(week int64) int64 Inspect
HeldCuts : func() int64 Inspect
PageLimit : int64 =100
WeekCount : func() int64 Inspect
Weeks : func(offset int64, limit int64) []int64 Inspect
weekOfKey : func(key string) int64 Inspect
sponsorText : func(s string) string Inspect
renderSponsors : func(week int64) string Inspect
Render : func(path string) string Inspect