const PageLimit
PageLimit is the most IDs one OfferIDs or DuelIDs call returns. A larger limit is clamped to it, not refused.
Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.
Package duel implements r/splash/arcade/duel: Splash Play I, the Duel (SPEC-03 v1.0, ../../../../SPEC-03-duel.md).
Package duel implements r/splash/arcade/duel: Splash Play I, the Duel (SPEC-03 v1.0, ../../../../SPEC-03-duel.md).
Two players, a matched stake from a fixed ladder, 50/50, winner takes both stakes less a 3% rake. Matching is an OPEN LOBBY: a poster leaves a stake standing and any other USER — including the poster (SPEC-03 §7.3) — may take it. (A USER: both Post and Accept require a direct user call since 2026-09-30, so no realm can be a party to a duel. requireUserCall has the reasoning, and it is this realm's own escrow that needs it.) A poster MAY instead address the offer to one opponent, in which case only that address may take it, and MAY attach a short label ("loser buys lunch"). Both are descriptive only: neither touches settlement, and the coin still decides.
THE STAKE IS WRAPPED GNOT. SPLASH launches on GNOT only — the SPLASH token economy is shelved — so this realm escrows gno.land/r/gnoland/wugnot, the GRC20 wrapper over the chain's native ugnot (6 decimals: 1 GNOT = 1e6 ugnot). It is the only token this realm touches. r/splash/token is left untouched for when the token returns; nothing here depends on it.
THE REALM BOUNDARY WAS DELIBERATELY NOTHING (SPEC-03 §2) UNTIL 2026-09-29, AND IT IS NOW EXACTLY ONE THING. The sentence that used to stand here — "it holds no authority over r/splash/arcade/raffle, r/splash/beacon or r/gnoland/wugnot: no package-path guard anywhere admits it" — is no longer true of the raffle and is corrected rather than quietly deleted.
THE ONE AUTHORITY: raffle.FundPotWithEntries admits THIS PACKAGE PATH AND NO OTHER, because playing a duel now earns the two players entries in the raffle's live week (AUDIT-2026-09-28.md finding 15, and see pendingEntries). Everything else is as it was: this is an ordinary realm holding liquid GRC20 wugnot in escrow, and the other raffle function it calls — FundPot — is still a permissionless caller-pays door any address could call with its own money.
WHAT A TOTAL COMPROMISE OF THIS REALM CAN NOW DO, restated honestly because the old sentence understated it: lose the stakes of currently-open duels, as before, AND credit raffle entries to addresses of its choosing — but only by DELIVERING THE MONEY FOR THEM, because the raffle re-derives its own ceiling from the amount it receives in the same call and refuses a batch that claims more (see raffle.ArcadeEntryUnitsBound). It cannot mint an entry, cannot move an ugnot out of the raffle, cannot reach a past week, and cannot resolve anything. The bound is what limits the authority; the guard only decides who may reach it.
THE RAKE FEEDS THE WEEKLY RAFFLE (changed 2026-09-28). It used to fund r/splash/game's yield index and prize pot; that permanent-deposit yield product is retired, SPLASH launches as the weekly ticket raffle plus this coin flip, and nothing is held beyond a week. So the players' side of every rake is sponsored into the raffle's CURRENT week's pot. See Collect.
AND PLAYING NOW EARNS ENTRIES IN THAT WEEK'S DRAW (changed 2026-09-29). The rake alone was not enough: the money arrived and the entrants did not, so a week of busy coin flipping and no ticket sales could be won whole by one 0.1 GNOT ticket bought in its final block. Every settled duel now credits its two players entries worth EXACTLY what its rake would have bought at raffle.EntryUnitPrice — the loser getting two thirds of them — so the people whose money built the pot are in the draw for it. Pricing off the rake rather than the stake is what makes that safe against two accounts flipping each other; read rakeEntryUnits, which is the security property of the feature. AUDIT-2026-09-28.md finding 15.
BUT NOT IN THE SAME TRANSACTION AS THE WINNER'S PAYOUT, AND THAT IS THE ONE STRUCTURAL CHANGE OF 2026-09-28's LATER FIX. Collect used to call raffle.FundPot directly, which ends in the raffle's own solvency check — so any panic in the raffle took the winner's payout down with it, permanently, because VoidStale refuses a resolved duel. Collect now BOOKS the players' side (pendingPot) and FlushPot delivers it, permissionlessly, whenever the raffle will take it. A RESOLVED DUEL'S PAYOUT NO LONGER DEPENDS ON ANOTHER REALM'S BOOKKEEPING. Read pendingPot for the two exits and why neither is a new money sink.
RANDOMNESS: A TWO-PARTY COMMIT-REVEAL BETWEEN THE ONLY TWO PEOPLE WHO CARE, AND SINCE 2026-09-30 THIS REALM DOES NOT IMPORT r/splash/beacon AT ALL.
1Post carries commitHash = H(commitTag ‖ secret), the poster's 32-byte secret.
2Accept carries a PLAINTEXT 32-byte nonce from the taker. No commitment, no reveal
3 from the taker, and no extra transaction — one more argument on the call
4 they already make.
5Reveal opens the commitment. outcome = H(outcomeTag ‖ secret ‖ nonce ‖ duelID),
6 last byte's parity, exactly as posterWins always read it.
7 PERMISSIONLESS: the hash is the authorisation, so anybody may settle.
8Forfeit pays the TAKER after revealWindowBlocks if the poster never opens.
WHY IT HOLDS: the poster cannot grind, because the nonce does not exist when they commit; the taker cannot steer, because they choose the nonce without the secret. No round to wait for, so settlement is available in the very next block, the window is one clock read off chain height, and no 17M-gas drand relay sits in anyone's critical path.
WHAT IT REPLACES, AND WHY A BETTER FRESHNESS CHECK WAS NOT AN OPTION. Accept used to bind Round = beacon.EstimatedLatestRoundWithin(duelMaxStaleBlocks) + duelBindOffsetRounds — 300 seconds of drand rounds past a staleness-bounded estimate of drand's head — and Resolve read that round's randomness once somebody relayed it. THAT IS BROKEN AND THERE IS A TEST PROVING IT: r/splash/beacon's TestKnownWeaknessRelayingTheRoundJustAboveTheEstimateBuysTheWholeStalenessBudgetBack shows that relaying the round JUST ABOVE the estimate resets the staleness clock to zero while advancing the estimate by exactly one round, for 0.0170 GNOT — about a tenth of a US cent. Repeat it and the estimate lags arbitrarily far behind drand's real head; once it lags past the 100-round offset, the bound round's randomness is already public on api.drand.sh and a taker computes the winner BEFORE accepting. SPEC-03 §7.1 calls exactly that "the single fatal bug of this design".
IT CANNOT BE FIXED BY A TIGHTER STALENESS BOUND, WHICH IS THE WHOLE REASON THE SOURCE OF RANDOMNESS CHANGED RATHER THAN ITS TOLERANCE. What the contract needs is an UPPER bound on drand's head ("round R is not yet signed"). A signature proves a LOWER bound ("round R exists"); the ABSENCE of a signature is not provable on chain at all, because a relayer chooses what to relay and withholding is free and invisible. So no arithmetic over relayed rounds can ever establish the property Accept needed. See r/splash/beacon/DRAND-MIGRATION.md §5b/§5c and the retired "unforgeable" claim in beacon.gno.
============================================================================ WHAT THIS DESIGN DOES NOT GIVE YOU — READ BEFORE YOU PLAY, TWO ITEMS ============================================================================
(1) THE SECRET'S ENTROPY IS UNENFORCEABLE ON CHAIN, AND commitHash IS A FREE OFFLINE VERIFICATION ORACLE. Anybody can take a published commitHash and try candidate secrets against H(commitTag ‖ secret) on their own machine, for nothing, for as long as they like. A secret drawn from anything a person could think of — a word, a date, a counter, Math.random(), a small integer padded to 32 bytes — WILL be found, and the finder then picks the nonce that makes them win, or waits for the reveal window and forfeits a duel the poster had actually won. The reveal that robs you is a VALID reveal: it passes the hash check, this realm cannot tell it from yours, and there is no event that says anything went wrong. THE PAGE MUST DRAW THE SECRET FROM A CSPRNG (crypto.getRandomValues, crypto/rand, /dev/urandom), 32 bytes, and it must be FRESH FOR EVERY OFFER. There is deliberately no on-chain entropy check: a 32-byte string is a 32-byte string, and a contract that tried to judge one would only be rejecting honest posters. checkCommitment enforces the only thing that IS checkable — the length and the encoding — and liveCommitments refuses the one reuse case a contract can see.
(2) A POSTER WITH MEMPOOL VISIBILITY HAS A FREE OPTION ON THE TAKER'S NONCE, AND THIS IS AN OPEN, UNCLOSED WEAKNESS OF THE DESIGN. The nonce travels in the clear in the Accept transaction. A poster who sees that transaction before it is committed knows the secret, the nonce and the duel ID that Accept will assign — so they can compute the outcome and, if they are about to lose, try to make the Accept fail: by cancelling the offer, or by taking their own offer (SPEC-03 §7.3 permits it) for the cost of the rake alone. Either move costs them at most 3% of the pool and saves them 50% of it. It is NOT fixed by removing Cancel — the self-match route survives that — and it is NOT fixed by mixing the accept height into the outcome, because a poster can enumerate the two or three heights their abort could land in. THE ONLY CLOSURES ARE A SECOND TAKER TRANSACTION (commit the nonce, reveal it later), WHICH THIS DESIGN DELIBERATELY REFUSES, OR AN ON-CHAIN VALUE NOBODY CAN PREDICT, WHICH IS THE BEACON THIS CHANGE JUST REMOVED. What bounds it today is that the abort must be ORDERED BEFORE an Accept that was already broadcast: a FIFO mempool puts the abort second and the taker wins. That is a property of the node software and the proposer, not of this contract, so it is recorded as an assumption and not as a defence. See TestBreak_APosterWhoSeesTheNonceEarlyHasAFreeAbort, which drives the option rather than describing it, and the addendum to AUDIT-duel-2026-09-27.md.
STORAGE IS PERMANENT AND IS RECLAIMED (SPEC-03 §7): Cancel, Collect, Forfeit and VoidStale all DELETE their record. Nothing in this realm is ever marked "settled" and kept.
Accept takes a standing offer: pulls a stake equal to THE OFFER'S rung — the caller chooses nothing about the money — deletes the offer, records the taker's NONCE and creates the duel. Returns the new duel's ID.
nonce IS THE TAKER'S HALF OF THE COIN AND IT TRAVELS IN THE CLEAR: 32 bytes as 64 lowercase hex characters (checkNonce). No commitment and no reveal from the taker, and therefore NO EXTRA TRANSACTION — one more argument on the call they were already making. Read posterWins for why plaintext is safe from the taker's own side: they choose the nonce WITHOUT the secret, so every nonce they could pick is a coin they cannot evaluate, and choosing among coins you cannot see is not choosing. A CSPRNG is the right source here too, though for a different reason than the poster's: a nonce a poster could have ANTICIPATED (all zeroes, the taker's own address, a counter) lets the poster grind their secret against it at Post time, which is the one way the poster-cannot-grind property can be handed away by the other party.
THE PLAINTEXT NONCE IS ALSO THIS DESIGN'S ONE OPEN WEAKNESS, and the taker should know it at the door they walk through: a poster watching the mempool can read the nonce out of this very transaction before it is committed, compute the outcome, and try to abort the Accept by cancelling or by self-matching. Item (2) of this file's doc comment has the argument, the two moves and why neither is closed here.
AN OFFER WITH NO COMMITMENT IS REFUSED, AND THAT REFUSAL IS THE WHOLE OF REQUIREMENT 5 ON THIS SIDE. A legacy offer (created before 2026-09-30) carries no commitHash, so no secret exists that could ever open it and Reveal could never run. Accepting one would create a duel whose only exit after the window is Forfeit — which pays the TAKER — so anybody could have taken a legacy offer and been handed the poster's stake for nothing. So such an offer cannot be accepted at all; its exit is Cancel, permissionless for exactly this case.
THERE IS NO ROUND, NO BEACON AND NOTHING TO WAIT FOR. Everything this function used to do about randomness is gone: no anchor, no staleness tolerance, no offset, no assertion that the bound round is in the future. Read this file's doc comment for why that binding could not be repaired. The consequence a player sees is that settlement is available IN THE NEXT BLOCK rather than 300 seconds and one 17M-gas relay later.
The caller MAY be the poster (SPEC-03 §7.3): self-matching wins half the time and pays the rake regardless, a guaranteed 3% loss, so a guard would cost code and buy nothing. THAT REASONING NEEDED RE-CHECKING UNDER THE NEW COIN AND IT SURVIVES, in a sharper form worth writing down: a self-matcher holds the secret AND chooses the nonce, so they can grind the nonce and win with CERTAINTY — against themselves. Winner and loser are one address, the pool returns to it less the rake, and certainty about which pocket it lands in is worth nothing. It is still a guaranteed 3% loss, and it is still the case that the only party a self-match can take money from is the self-matcher. An offer ADDRESSED to an opponent is the one exception, and only by construction: then the opponent is the only address that may accept, and the poster is not it unless they named themself.
THE CALLER MUST HOWEVER BE A USER AND NOT A REALM, SINCE 2026-09-30. That is the one thing about WHO may accept that this function refuses, and requireUserCall has the argument: a realm taker that wins cannot call the winner-only Collect, so the pool would have no exit at all — the poster's stake included. It is the raffle's finding-8 reasoning (a package-derived address nothing can spend from) applied to this realm's own escrow rather than only to the raffle entries a duel earns. AUDIT-duel-2026-09-27.md addendum 2, finding D-1.
EVERY CHECK RUNS BEFORE THE PULL. The second of two Accepts on one offer in a block therefore finds no offer and fails before touching the caller's wallet: its stake is never pulled, so there is nothing to return (SPEC-03 §7.4). It cannot join, overwrite or silently succeed.
AuditFailure re-derives escrowed by iterating every record and returns the empty string when the books balance, otherwise why not. O(n) and READ-ONLY; never called from a state-changing path — checkInvariant is the O(1) guard for those. Exists so anyone can verify off-chain, and so the tests can.
AuditInvariant is AuditFailure as a bool.
Cancel returns an unaccepted offer's stake to its poster, EXACTLY and with no fee, and deletes the record. Poster only.
An accepted offer no longer exists — Accept deletes it in the same transaction that creates the duel — so "Cancel of an accepted offer" and "Cancel of an unknown offer" are the same refusal. That is what makes the Cancel-racing-Accept rule (SPEC-03 §7.5) hold by construction: whichever lands first in block order removes the offer, and the other finds nothing.
AND THAT RACE IS NO LONGER HARMLESS, WHICH IS DISCLOSED HERE RATHER THAN ONLY IN THE FILE HEADER. Under the beacon the poster could not predict the outcome, so cancelling under a pending Accept gained them nothing. Since 2026-09-30 the taker's nonce is plaintext in the Accept transaction, so a poster watching the mempool CAN compute the outcome and cancel the ones they are about to lose. It is item (2) of this file's doc comment, it is OPEN, and removing Cancel would not close it — the poster can take their own offer instead, for the rake. It is recorded at the door it is reached through.
PERMISSIONLESS FOR A LEGACY OFFER, AND ONLY FOR ONE. An offer with no commitment (Offer.CommitHash == "") can never be accepted — Accept refuses it — so the poster's stake would otherwise sit in the lobby until the poster themselves turned up, and a pre-D-1 poster may be a REALM that contains no code able to call this function. So for that one case any caller may cancel, and it is not a way to steal: the refund's destination is READ FROM THE RECORD and is the poster either way. Nothing is deployed, so no such offer exists; this is requirement 5's "the code must not contain a trap".
Collect pays the winner 2 x stake - rake, forwards the rake, and DELETES THE DUEL. Winner only.
Conservation (SPEC-03 §7.2): payout + rake == 2 x stake exactly, and the rake's two parts sum exactly to the rake — the raffle pot share takes the remainder.
THE RECORD IS DELETED BEFORE ANYTHING IS PAID. That single ordering is what makes a second Collect fail instead of double-paying (SPEC-03 §7.6): by the time the first transfer runs, there is no duel for a re-entrant or repeated call to find. It also reclaims the storage deposit (SPEC-03 §7).
AND IT BOOKS THE ENTRIES THE RAKE EARNED ITS TWO PLAYERS (finding 15). Booked here, delivered by FlushPot with the money, and credited to nobody else: the winner takes a third, the LOSER takes two thirds, and the two sum to exactly what that rake would have bought at raffle.EntryUnitPrice. Read rakeEntryUnits for why the rake and never the stake is the price base — it is what stops two accounts flipping each other for cheap entries — and bookEntries for the two reasons a party can earn nothing (they played from realm code, or the undelivered batch is full). NEITHER CAN MAKE THIS FUNCTION FAIL: a winner's collect must never depend on a raffle entry.
AN EARNED ENTRY IS NOT A COIN FLIP AND IT CARRIES A DISCLOSED, STILL-OPEN RISK THAT YOUR FLIP DOES NOT. The entry is in r/splash/arcade/raffle's weekly draw, which is settled by a drand round PINNED WHEN THE WEEK OPENED against an estimate of drand's current round derived from chain height. If that estimate drifts — slow blocks, or a beacon nobody has relayed to lately — the week's winning randomness can become publicly readable on api.drand.sh BEFORE the week's ticket sales close, and whoever reads it can buy last and size the buy to own the winning unit. THE POT THEY WOULD BE TAKING IS MOSTLY DUEL RAKE. It is bounded by the beacon's staleness refusal and by a measured margin, it is nobody's key and nobody's privilege, and it is recorded OPEN: the full disclosure, the bound and what it rests on are at rakeEntryUnits above, and the record is AUDIT-2026-09-28.md findings 4, 10, 12, 13 and 20.
WHERE THE RAKE GOES (BIBLE.md §4.15 S12): the pot share through raffle.FundPot — the raffle's named, permissionless, caller-pays door, approved for exactly its amount in the same transaction, so a panic anywhere reverts the approval with everything else — and the ops share by plain transfer to r/splash/ops.
REPOINTED AT THE RAFFLE 2026-09-28, AND THE SPLIT DID NOT MOVE. The players' side of the rake used to go through two doors, game.FundYield (5000 bps) and game.FundPrizes (the remainder, 3000 bps plus all dust). r/splash/game's permanent-deposit yield product is retired, so there is no yield to fund and the yield share is folded into the pot share rather than dropped: one call, carrying rake - opsShare, which is exactly what the two calls carried between them. The ops share is computed by the same floored formula as before, so the house takes neither more nor less than it did. Integer-division dust was the prize share's and is now the raffle pot's — it has always gone to the players' side, and still does. SPONSORSHIP BUYS NO TICKETS: the raffle records it as house money in the week's pot, attributable to no buyer, so this realm never gains a winning chance in a draw it funds.
IT FUNDS WHATEVER WEEK IS LIVE WHEN THE DUEL IS COLLECTED, which is the point — every coin flip makes this Saturday's prize bigger — and needs no coordination: FundPot creates the week's record if the week has not sold a ticket yet, and the live week is uncommitted by construction, so a settlement can never arrive at a frozen pot.
(The previous note here recorded that between cd29697 and 2026-09-28 the whole rake went by one transfer to the ops vault, because the game's doors pulled r/splash/token while this realm held wugnot. That detour is over: the players' side now reaches the players, in the one product they can play this week.)
DuelCommitment is everything the coin is made of: the poster's commitment, the taker's nonce, and the secret once it has been revealed ("" before that). All three empty for a duel that does not exist, and the commitment empty for a LEGACY duel.
IT IS WHAT MAKES A SETTLED DUEL CHECKABLE BY ANYBODY. With these three values and the duel's ID, a caller recomputes H(commitTag ‖ secret) against the commitment and H(outcomeTag ‖ secret ‖ nonce ‖ duelID) against the winner DuelOf reports, off chain, with no trust in this realm at all. The beacon version could not offer that: its randomness lived in another realm and was addressed by a round number.
DuelCount is the number of duels in flight: accepted and not yet collected or voided, resolved or not. O(1).
DuelEntryEligibility reports whether each party of a duel played from a direct user call and will therefore earn raffle entries when it is collected: poster first, taker second. Both false for a duel that does not exist.
A SEPARATE READ RATHER THAN TWO MORE ELEMENTS OF DuelOf, deliberately: DuelOf keeps the six-value shape SPEC-03 §4 published, and an arity change there is mechanical churn in every caller (the raffle's own audit records doing it three times in one file). Read Duel.PosterIsUser for what these flags do and do not affect — they have no bearing on who wins or who is paid.
DuelIDs is OfferIDs for the duels in flight.
DuelLabel is the label a duel was accepted under, "" if none or if the duel does not exist. Raw, as OfferChallenge. DuelOf keeps its six-value shape.
DuelOf reports a duel: poster, taker, rung, REVEAL DEADLINE, resolved, winner. All zero for a duel that does not exist — which, after Collect, Forfeit or VoidStale, is how a caller can see the record really was deleted (SPEC-03 §8 test 19). Plain read.
THE FOURTH VALUE IS NO LONGER A BEACON ROUND, AND IT IS REPLACED RATHER THAN REMOVED. It used to be `Round` — the drand round the duel settled against — and there is no round any more. The slot now holds THE BLOCK HEIGHT AT WHICH THE REVEAL WINDOW CLOSES, i.e. AcceptedAt + revealWindowBlocks: the height from which anybody may Forfeit this duel to its taker. That keeps the six-value shape SPEC-03 §4 published — an arity change here is mechanical churn in every caller, which the raffle's audit records doing three times in one file — and it keeps the slot's MEANING, which was always "the clock this duel settles against". It is a DERIVED value, not a stored one; a duel carries AcceptedAt and the window is a constant.
A CALLER THAT WANTS THE OLD FIELD WILL NOT FIND IT AND MUST NOT GUESS. Reading this as a round number gives a nonsense round in the tens of thousands, which is why the change is stated here, in the event log (DuelAccept's "revealBy" replaced "round"), in Render's column heading and in the addendum — a silently reinterpreted return value is the worst possible way to carry a change like this.
EntriesForRung is what one duel at `rung` earns its two players: the winner's share and the loser's, which sum to the rake-equivalent. Panics on an invalid rung index, exactly as Post would.
EntryHolderLimit is maxPendingEntryHolders, exported so a front end can say why a player's entries were dropped rather than leaving them to guess. Read maxPendingEntryHolders for what happens at the bound and why it is a drop rather than a refusal.
EntryUnitsForRake is rakeEntryUnits, readable: how many raffle entry units a rake of `rake` ugnot earns, at raffle.EntryUnitPrice. A front end can quote a rung's entries with StakeOfRung, and anybody can check the rate that makes this feature safe without reading the source. Plain arithmetic, no state.
Escrowed is the wugnot this realm holds for open offers and uncollected duels. O(1); maintained incrementally.
FlushPot delivers the whole booked rake to r/splash/arcade/raffle's CURRENT week and returns the amount delivered. PERMISSIONLESS: anyone may pay the gas to do it, and the destination is not theirs to choose.
THIS IS THE FUNCTION Collect USED TO BE THE SECOND HALF OF. The split is the whole fix: a raffle that will not accept the rake now costs the CALLER OF THIS FUNCTION a reverted transaction and nothing else. The winner has already been paid, the duel record is already gone, and pendingPot is still here for the next attempt. Nothing about a duel depends on the raffle any more.
BOOKS FIRST, CALL SECOND, and the revert is what makes that safe. pendingPot is zeroed BEFORE raffle.FundPot runs, so a re-entrant call finds nothing to send twice; and if FundPot panics, the panic aborts this whole transaction and discards the zeroing with it, so the balance is preserved exactly. (Nothing on-chain can catch that panic and continue — the same atomicity Accept and Collect already rely on.)
THE APPROVAL IS FOR EXACTLY THE AMOUNT AND IN THE SAME TRANSACTION, as it was in Collect, so a revert takes the approval with it and this realm never leaves a standing allowance behind.
IT FUNDS WHATEVER WEEK IS LIVE WHEN IT RUNS, which is a behaviour change from "whatever week was live when the duel was collected" and is recorded rather than hidden. A rake booked on Saturday and flushed on Monday enlarges Monday's week's prize. Nobody's claim moves — sponsorship buys no tickets in any week — and any player who wants their own flip to swell this week's pot can flush it themselves in the same block they collect. Panics if there is nothing booked. AND IT DELIVERS THE EARNED ENTRIES WITH THE MONEY, IN ONE CALL (finding 15). The batch is built from pendingEntries and handed to raffle.FundPotWithEntries, which funds the pot through the same FundPot and appends one ticket index per holder. One call rather than two because the money and the entrants must land in the SAME week: a delivery that could split across a week boundary would put the rake in one draw and the entrants in another, which is the defect finding 15 is about, only subtler.
THE ENTRIES FOLLOW THE MONEY'S WEEK, WHICH IS THE WEEK LIVE WHEN THE FLUSH RUNS, AND THAT IS A DELIBERATE CHOICE RATHER THAN THE ONLY ONE. A rake booked on Saturday and flushed on Monday enlarges Monday's pot and earns its entries in Monday's draw — the money and the entrants stay together, which is the property that matters, and any player who wants their own flip in THIS week's draw can flush in the same block they collect (the suite's collectAs helper does exactly that, as it already did for the money). The alternative — booking the settlement's week number and crediting THAT week — was rejected twice over: it would let this realm write ticket weight into a week that is already committed or abandoned, which is the frozen-total rule the raffle refuses Buy for (findings 2 and 10), and a week that had moved on would leave the batch permanently undeliverable, which is a "money in, no way out" shape.
Forfeit ends a duel whose poster never opened their commitment: after revealWindowBlocks the TAKER takes the pool on exactly the terms the winner of a revealed duel would have. PERMISSIONLESS, and the destination is READ FROM THE RECORD.
WHY THE TAKER, AND WHY THIS IS THE WHOLE INCENTIVE ARGUMENT FOR THE DESIGN. After Accept the outcome is already determined — the secret is fixed, the nonce is in the record — but only the poster can see it. So the poster's choice is: reveal, or withhold. Paying the taker on withholding makes those two choices IDENTICAL IN MONEY when the poster has lost (they hand over the pool either way) and STRICTLY WORSE when the poster has won (they hand over a pool they could have taken). Withholding therefore never pays, at any rung, and the only thing it buys is up to revealWindowBlocks of delay — which costs the poster the same wait. Pay the POSTER on expiry instead and the design inverts: the poster would withhold every loss and the coin would be a one-way bet.
PERMISSIONLESS, FOR THE REASON EVERY EXIT IN THIS FILE IS. The taker is the only party who gains, so the taker has the incentive — but their willingness to send a transaction must not be what stands between the pool and an exit. A bystander, a front end or a keeper may fire it; the money still goes where the record says. Requirement 3.
AND IT SETTLES THROUGH THE SAME ARITHMETIC AS Collect, WHICH IS NOT A TIDINESS POINT BUT THE MOST-REPEATED DEFECT IN THIS PROJECT. It calls the same settle() — same rake, same split, same entries, same books. The alternative shapes were considered and both of the safe ones are: the same rake AND the same entries (this), or NO rake and NO entries (the VoidStale shape). WHAT MUST NEVER SHIP IS ENTRIES WITHOUT RAKE: checkInvariant panics on `pendingEntryUnits > 0 && pendingPot <= 0`, so a forfeit that booked entries without booking the rake behind them would panic on its own exit — bricking THE ONLY EXIT this state has and stranding the pool with the books reading green. A separate copy of the payout arithmetic is exactly how that gets written, so there is no separate copy.
A LEGACY DUEL IS REFUSED HERE, AND THAT REFUSAL IS THE POINT OF REQUIREMENT 5. A duel with no commitment can never be revealed by anybody, so its expiry is not a poster's silence — it is a certainty from the moment it was accepted. Forfeiting it would hand the taker the poster's stake for nothing. VoidStale refunds both stakes instead, and this function will not touch such a record. Unreachable since Accept refuses commitment-less offers.
Panics if the duel does not exist, if it is already revealed (Collect is then the path, and the winner it names may not be the taker), if it carries no commitment, or if the window has not elapsed.
ForfeitOpensAt is the height from which anybody may Forfeit a duel to its taker: the same value DuelOf returns in its fourth slot. 0 for a duel that does not exist, and 0 for a LEGACY duel — which is never forfeitable at all and goes to VoidStale instead, so a front end that offers a forfeit button must treat 0 as "not this one".
OfferCommitment is the commitment an offer stands behind: H(commitTag ‖ secret) as 64 lowercase hex characters. "" for an offer that does not exist AND for a LEGACY offer — which a caller can tell apart with OfferOf, since a non-existent offer has a zero poster. A taker should read this before accepting: an offer with no commitment cannot be accepted at all (Accept refuses it).
A SEPARATE READ RATHER THAN A SEVENTH VALUE ON OfferOf, for the reason OfferChallenge gives: OfferOf keeps the three-value shape SPEC-03 §4 published.
OfferCount is the number of offers standing in the lobby. O(1): avl keeps its size.
OfferIDs is a window over the standing offers' IDs in ascending order: the window skips `offset` offers and holds at most min(limit, PageLimit). Empty past the end. Negative arguments are a caller error and panic.
OfferOf reports an offer: poster, rung, posted-at height. All zero for an offer that does not exist (including one that was cancelled or accepted — both delete it). Plain read. Keeps the three-value shape SPEC-03 §4 published; the challenge fields are read through OfferChallenge.
PendingEntriesOf is the raffle entry units booked for one address and not yet delivered: what a player has earned by playing and not yet had credited in the raffle. Zero once a flush has delivered them, at which point the units are the raffle's to report (raffle.TicketsOfWeek). O(log n).
PendingEntryHolders is how many distinct addresses the undelivered batch holds. O(1); avl keeps its size. maxPendingEntryHolders is the bound (EntryHolderLimit).
PendingEntryUnits is the total raffle entry units booked and not yet delivered. O(1).
PendingPot is the players' side of the rake booked and not yet delivered to the raffle. O(1) read.
PendingSince is the height SweepStuckRake's window is counted from: the height at which the booked rake last went from zero to non-zero, or at which the batch's most recent NEW HOLDER was admitted, whichever is later. 0 when nothing is booked. O(1) read. Read pendingSince for why a new holder moves it (finding D-4).
PendingSweepOpensAt is the height at which SweepStuckRake's last resort becomes available, or 0 when nothing is booked. FlushPot is available at every height.
IT MOVES LATER WHEN A NEW HOLDER JOINS THE BATCH (finding D-4), so a front end that wants to warn a player must re-read it rather than cache it — and a player who has just earned entries can see that their own claim opened the window they are measured against.
Post pulls rungs[rung] from the caller through the GRC20 allowance path and leaves it standing in the lobby. Returns the new offer's ID.
opponent, when non-zero, addresses the offer to one player: only that address may Accept it. Zero ("") leaves it open to anyone, and the offer then behaves exactly as it always did. A non-zero opponent must be a valid address, or the stake would stand for a player who can never exist and the poster's only way out would be Cancel.
label is free text for the render, at most maxLabelLen bytes (checkLabel). "" is no label. Neither argument touches settlement: the coin still decides.
commitHash IS THE POSTER'S HALF OF THE COIN AND IS NOW MANDATORY (changed 2026-09-30). It is H(commitTag ‖ secret) as 64 lowercase hex characters, for a secret of exactly 32 bytes that the poster keeps and opens at Reveal. There is no salt and no second field: read checkSecret for why one fixed-length field is the requirement and not a preference.
1THE SECRET MUST COME FROM A CSPRNG AND MUST BE FRESH FOR THIS OFFER. commitHash is a
2free offline oracle — anybody can test candidate secrets against it forever, at no
3cost — so a guessable secret is robbed SILENTLY, by a reveal this realm cannot tell
4from an honest one. There is no on-chain entropy check and there cannot be one. The
5full statement is item (1) of this file's doc comment; liveCommitments refuses the one
6reuse case a contract can see.
THE CALLER MUST BE A USER, NOT A REALM, SINCE 2026-09-30 (requireUserCall, finding D-1). A realm poster that WINS cannot call the winner-only Collect, which would leave the pool — its own stake AND its opponent's — in escrow with no exit whatever.
EVERY CHECK RUNS BEFORE THE PULL, so a failed Post moves nothing and creates nothing (SPEC-03 §8 tests 1 and 2) — and that now includes the commitment's shape and its uniqueness, so a rejected commitment never costs a stake.
Render is the realm's human-readable view: the ladder, the open lobby and the duels in flight. It ITERATES THE avl.Trees in key order, which is ID order because idKey is fixed-width, so the output is byte-identical across calls and across nodes (SPEC-v1.md §7.7). Only the empty path is meaningful; any other path is answered without echoing it.
Reveal opens the poster's commitment, names the winner, and is PERMISSIONLESS.
IT IS Resolve, AND Resolve IS GONE — MERGED INTO THIS FUNCTION RATHER THAN KEPT BESIDE IT, WHICH REQUIREMENT 4 ASKS TO BE JUSTIFIED. Resolve's entire body was: read the outcome's source, compute the winner, store it, refuse a second call. This function is that same body with a different source — an argument instead of a cross-realm read of beacon.Randomness(d.Round). Keeping both would mean two doors onto ONE state transition, and the second door would have NO SOURCE AT ALL: there is no round to read, so a surviving Resolve could only either do nothing or invent an outcome. A vestigial settlement entry point on a realm with no owner and no upgrade path is precisely the shape this project keeps finding (a dead premise that outlives the thing it depended on), so the transition has exactly one door and the door names what it needs.
THE HASH IS THE AUTHORISATION, SO THERE IS DELIBERATELY NO CALLER CHECK — REQUIREMENT 2, AND IT IS A SECURITY PROPERTY AND NOT A CONVENIENCE. Only somebody who holds the secret can pass the check below, and by the time this can be called the secret is worth nothing to anybody: the nonce is already fixed in the record, so the outcome is already determined and revealing cannot change it. Two things follow, both wanted:
1SETTLEMENT STAYS PERMISSIONLESS. A poster who has revealed to their opponent
2off-chain, or published the secret anywhere, can have the duel settled by any
3bystander — exactly as the beacon version let any bystander Resolve. Nothing about a
4duel's conclusion depends on one particular address being willing to send a
5transaction.
6
7AND NO TRUSTED SECRET-HOLDER IS CREATED. A caller check would make "the address that
8may reveal" a third role in the contract, and a poster who wanted a front end or a bot
9to settle for them would have to hand that role the secret while it still mattered.
10The secret would become a credential held off chain by a de-facto party to the duel.
11With no check, the secret is a credential for nothing.
WHAT IT REFUSES: a duel that does not exist, a duel already revealed (so no later caller and no later secret can move a winner that is already fixed — the same once-and-only-once Resolve had), a secret of the wrong length or spelling (checkSecret), and a secret whose commitment is not this duel's. Nothing is paid here: Collect pays, and deletes.
THE REVEAL WINDOW IS NOT CHECKED HERE, AND THAT IS ON PURPOSE. A late reveal is welcome as long as the pool is still there — an honest poster whose transaction was delayed keeps a duel they won, and nobody is harmed by a reveal on day two of an uncollected duel. What the window does is open Forfeit; the RACE between a late Reveal and a Forfeit is settled by block order, and both outcomes are legitimate. The suite drives that ordering both ways (reveal-after-expiry, and forfeit-then-reveal-refused).
RevealWindowBlocks is revealWindowBlocks, exported so a front end can say how long a poster has to open their commitment before the duel can be forfeited to the taker, without hardcoding the number or reading it out of Render.
RungCount is the number of rungs on the ladder.
StakeOfRung is the ladder, readable: the stake a rung index costs. Panics on an invalid index, exactly as Post would.
SweepStuckRake is the LAST RESORT for a booked rake the raffle will not take, and it exists so that pendingPot is not a place money can enter and never leave. It transfers the whole booked amount to the raffle's address by PLAIN wugnot.Transfer and returns it. PERMISSIONLESS, and only once the batch's YOUNGEST CLAIM has stood for pendingGraceBlocks (pendingSince, finding D-4 — it is not, and cannot be, a measure of delivery having been attempted and refused).
WHY IT CANNOT BE REFUSED, which is the only property that makes it a real exit: a plain GRC20 transfer runs no raffle code. FlushPot goes through raffle.FundPot and can therefore be refused by anything in the raffle — that is exactly the state this function is for. This one cannot be.
AND IT DROPS THE EARNED ENTRIES WITH THE MONEY, WHICH IS THE OTHER HALF OF THE COST (finding 15). The money on this path becomes the raffle's ops pot, so there is no prize left for an entry to be an entry in, and an entry with no rake behind it is a thing this realm's own invariant refuses. So players who earned entries in a batch that reached this function get nothing for them. It takes three weeks of the raffle refusing EVERY caller, the drop is in this function's event, and any player could have prevented it at any moment by flushing — for between 10,378,157 gas (a two-holder batch, measured 2026-09-30) and 351,725,503 (a FULL 128-holder batch against a week grown to 100,096 ticket indices). Read maxPendingEntryHolders for where each figure comes from and why only the first is reproducible by running this package.
THE FULL-BATCH END OF THAT RANGE IS THE ONE THAT APPLIES HERE, and citing only the cheap end was a real misstatement, corrected 2026-09-30. This paragraph is about a batch that has been accumulating for three weeks of refusals, i.e. the regime in which the batch is as large as maxPendingEntryHolders allows — so quoting a two-holder figure understated the mitigation's cost by more than thirty times in exactly the paragraph where it is dearest. (The figure said ~1.1M until 2026-09-29 and was stale by finding 15 — see maxPendingEntryHolders and AUDIT-2026-09-28.md finding 19. The argument every version of it supports is unchanged: at 11.7% of a block it is still one affordable transaction available to anybody at any height. AUDIT-duel-2026-09-27.md addendum 2, findings D-4 and D-6.)
WHERE IT LANDS, AND THE HONEST COST OF THAT. An unattributed arrival at the raffle is swept by its sweepDonations into its OPS pot, not into any week's prize — the raffle says why: crediting an unattributed arrival to a prize pot would break the identity that makes its refunds safe. So this money reaches the house rather than the players, and the 80/20 split is therefore NOT preserved on this path. That is stated plainly because it is the one place in this change where the split moves: it takes three weeks of the raffle refusing EVERY caller for it to happen, it emits its own event, and it is the alternative to the money never leaving at all.
AND THE DESTINATION IS THE RAFFLE, NOT r/splash/ops, DELIBERATELY. gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/ops does not exist — the ops constant in this file is a package-derived address with no code at it — so routing a NEW stream there would be creating exactly the "money in, no way out" shape that six of this product's findings have had. The raffle's ops pot has an exit (WithdrawOps), and whether THAT exit's destination is reachable is a pre-existing question about r/splash/ops which is the owner's and is not made worse here.
THE COST OF HAVING A WINDOW AT ALL, RECORDED RATHER THAN LEFT TO BE FOUND: after 21 days in which nobody bothered to call FlushPot — even with the raffle perfectly willing — any caller may move the booked rake to the raffle's ops pot instead of its prize pot. That is a value shift from the players to the house, it costs the caller gas and gains them nothing personally, and it is preventable by anyone at any time by simply flushing — for 10,378,157 gas at two holders and 351,725,503 at a FULL 128-holder batch, which is the end of the range that applies after three weeks of accumulation (see maxPendingEntryHolders for each figure's provenance; corrected from a bare "~8.6M" on 2026-09-30 and from "~1.1M" on 2026-09-29, findings D-4, D-6 and 19). It is accepted because the alternatives are worse: no window makes this a free diversion available on the first block, and no function at all leaves the balance with a single exit that another realm can hold shut for ever. AUDIT-2026-09-28.md finding 14.
Panics if there is nothing booked, or if the window has not elapsed.
VoidStale refunds BOTH STAKES IN FULL, with no rake and no entries, for a duel that CAN NEVER BE SETTLED BY ANY CALLER — which since 2026-09-30 means exactly one thing: a LEGACY duel, accepted before the commit-reveal coin, carrying no commitment. PERMISSIONLESS.
ITS PREMISE CHANGED COMPLETELY AND THE NAME IS KEPT ON PURPOSE. It was "the beacon-outage escape (SPEC-03 §6)": either party, after a 24-hour grace, when the bound round was STILL unpublished, because a stopped publisher would otherwise freeze every pending duel with both stakes locked on a contract that cannot be patched. THERE IS NO PUBLISHER AND NO ROUND ANY MORE, so that state does not exist: the poster can always reveal, and after revealWindowBlocks anybody can Forfeit. What survives is the one shape the old function was really for — a duel with no reachable settlement — and the only way to be in it now is to have been created before the change. The name is kept because it is the published exit for "this duel cannot settle, unwind it"; renaming it would churn every caller to say the same thing.
WHY IT MUST EXIST AND MUST NOT BE Forfeit, WHICH IS REQUIREMENT 5. A legacy duel's poster has no secret and never had one: no commitment was recorded, so no preimage exists and Reveal cannot run for anybody. Routing that to Forfeit would pay the TAKER the poster's stake for a silence the poster could not have broken — and worse, it would make taking a legacy offer a free stake, which is why Accept refuses those outright. Both stakes go back.
NO GRACE WINDOW, AND NO CALLER CHECK. Both were dropped, and both for the same reason: the record PROVES its own unsettleability from a field on itself (CommitHash == ""), so there is nothing to wait for and nothing for a caller check to protect. Waiting would only hold two refunds hostage to a clock; requiring a party would strand the pool if that party were a pre-D-1 realm with no code able to call anything. The destination is read from the record and is both parties, so a permissionless door cannot name a payee.
NEVER ON A REVEALED DUEL, or it would be a way for a loser to cancel a loss — so that check comes first, as it always did. Unreachable together with the commitment check (nothing with an empty commitment can be revealed), and both are stated.
NOTHING IS DEPLOYED, SO NO RECORD OF THIS SHAPE EXISTS TODAY. The lobby will be drained as a deployment step regardless. This function is here so that the code contains no trap: requirement 5 is that legacy records must not route to forfeit, and a comment saying "there are none" is not that.
1type Duel struct {
2 Poster address
3 Taker address
4 Rung int64
5
6 // Label is copied verbatim from the offer at Accept, so the render can
7 // still say what the duel was about. Descriptive only, as on Offer.
8 Label string
9
10 // CommitHash is the offer's, copied at Accept. Reveal checks a secret against it.
11 // "" is a legacy record: read Offer.CommitHash for what that means and for why such a
12 // duel goes to VoidStale and never to Forfeit.
13 //
14 // THE FIELD `Round int64` USED TO STAND HERE, and it is deleted rather than kept and
15 // ignored: it was "the beacon round this resolves against, set at Accept, strictly in
16 // the future", and both the round and the beacon are gone. A dead field that a reader
17 // could mistake for a live one is exactly how the 300-second bind margin outlived the
18 // cadence change that had already falsified it.
19 CommitHash string
20
21 // Nonce is the taker's 32 bytes, as 64 lowercase hex characters, IN THE CLEAR. It is
22 // half of the outcome and the taker chooses it freely — which is safe because they
23 // choose it WITHOUT the secret, so every nonce is a coin they cannot evaluate
24 // (posterWins). It is in the clear because a commitment from the taker would cost them
25 // a second transaction, and the whole point of this shape is that the taker's
26 // contribution rides the call they were already making.
27 //
28 // THE ONE THING THE PLAINTEXT COSTS is item (2) of this file's doc comment: it is
29 // visible in the mempool before Accept is committed, which gives a watching poster a
30 // free abort option on a nonce that loses. That is disclosed, open, and not closed by
31 // anything in this file.
32 Nonce string
33
34 // Secret is the opened commitment, "" until Reveal. STORED, NOT DISCARDED, AND THAT IS
35 // DELIBERATE: it makes the outcome re-derivable by anybody from the record alone, and
36 // it is what lets AuditFailure re-check that every resolved duel's Winner is actually
37 // the one H(outcomeTag ‖ secret ‖ nonce ‖ duelID) names — a settlement-correctness
38 // invariant the beacon version could not state, because its randomness lived in another
39 // realm. 64 bytes of deposit, reclaimed when Collect deletes the record.
40 Secret string
41
42 // AcceptedAt is the block height at Accept; the REVEAL WINDOW is counted from it
43 // (revealWindowBlocks). Not part of SPEC-03 §3's sketch — it is the only way to measure
44 // an elapsed window on a chain with no timestamp — and DuelOf keeps the six-value shape
45 // §4 published by returning the window's END in the slot the beacon round used to have.
46 AcceptedAt int64
47
48 // PosterIsUser and TakerIsUser record whether each party's OWN call — the Post
49 // for the poster, the Accept for the taker — was a direct user call. Read
50 // Offer.PosterIsUser for why: they decide only whether that party earns raffle
51 // entries at Collect (finding 15), and nothing else in this realm reads them.
52 // The poster's flag is copied from the offer, which is the only place it was
53 // knowable.
54 //
55 // BOTH ARE NECESSARILY TRUE FOR EVERY DUEL CREATED SINCE 2026-09-30: Post and
56 // Accept both require a direct user call now (requireUserCall, finding D-1), which
57 // is what gives this realm's own escrow the exit it was missing. They are retained
58 // as the enforcement point for the ENTRY half of finding 8 — see
59 // Offer.PosterIsUser for the two reasons — and DuelEntryEligibility still reports
60 // them.
61 PosterIsUser bool
62 TakerIsUser bool
63
64 Resolved bool
65 Winner address // zero until Resolved
66}Duel is an accepted offer waiting to be resolved and collected.
1type Offer struct {
2 Poster address
3 Rung int64 // index into rungs, NOT a raw amount
4 PostedAt int64 // block height, for the render only
5
6 // Opponent, when non-zero, is the ONLY address that may Accept this offer:
7 // the duel was sent to someone rather than listed. Zero means open to
8 // anyone, exactly as before the field existed. Checked in Accept, nowhere
9 // else — it has no bearing on who wins.
10 Opponent address
11
12 // Label is free text from the poster, at most maxLabelLen bytes, printable
13 // UTF-8 only (checkLabel). PURELY DESCRIPTIVE: never parsed, never used in
14 // settlement, and it reaches Render only through renderLabel.
15 Label string
16
17 // CommitHash is H(commitTag ‖ secret) as 64 lowercase hex characters: the poster's
18 // half of the coin, sealed. It is THE ONLY SOURCE OF RANDOMNESS THIS REALM HAS from
19 // the poster's side, and Reveal will not open it for anything but the one 32-byte
20 // preimage it names (checkCommitment, checkSecret, commitmentOf).
21 //
22 // EMPTY MEANS LEGACY, AND LEGACY MUST NEVER ROUTE TO FORFEIT. An offer created before
23 // 2026-09-30 has no commitment and therefore CAN NEVER BE REVEALED by anybody — so
24 // treating its expiry as a forfeit would hand the taker a pool the poster never had a
25 // chance to win. Accept REFUSES such an offer outright (so no such duel can be created
26 // from here on), Cancel is permissionless for it (so it cannot be stranded even if its
27 // poster is a pre-D-1 realm that cannot call anything), and VoidStale refunds a duel
28 // that already has one. Nothing is deployed, so no record of this shape exists; the
29 // paths exist so that the code contains no trap. Requirement 5 of the change.
30 CommitHash string
31
32 // PosterIsUser records whether the Post that created this offer was a DIRECT
33 // USER CALL, and it exists for exactly one reason: AUDIT-2026-09-28.md finding
34 // 8, carried into finding 15's earned entries.
35 //
36 // IT IS NECESSARILY TRUE FOR EVERY OFFER CREATED SINCE 2026-09-30, and it is kept
37 // anyway. This field used to be the ONLY consequence of playing from realm code —
38 // "it has NO bearing on settlement, a realm may post, accept, win and collect
39 // exactly as before" is what stood here, and finding D-1 is that leaving settlement
40 // open was the defect: a winning realm could not call the winner-only Collect, so
41 // the pool had no exit. Post now refuses a non-user call outright
42 // (requireUserCall), so nothing can write false here any more.
43 //
44 // KEPT, NOT DELETED, FOR TWO REASONS. It is the one place finding 8's property is
45 // enforced for ENTRIES — the raffle cannot check it for a bare address, and
46 // FundPotWithEntries trusts this realm to have checked — so if either gate is ever
47 // relaxed, bookEntries must not silently start crediting realms; the flag and its
48 // check are what make that failure a zero and an event rather than a stranded raffle
49 // pot. And it is a recorded fact about a call that no later reader can re-derive.
50 //
51 // WHY IT HAS TO BE RECORDED HERE AND CANNOT BE DERIVED LATER. A raffle entry is
52 // a claim on an outbound transfer (the raffle pushes a whole week's pot to the
53 // winner's address), and for a realm caller that address is PACKAGE-DERIVED:
54 // nothing can move wugnot out of it unless that realm happens to contain code
55 // that does. A realm that won a raffle week would strand the entire pot, which
56 // is finding 8 verbatim. Neither realm can test an address for EOA-ness — the
57 // predicate is about the CALL, not the address, and it is only available while
58 // the call is on the stack. So it is captured at the door and carried: Accept
59 // copies it onto the Duel, and Collect credits entries only to parties whose own
60 // call was a user call.
61 PosterIsUser bool
62}Offer is a standing stake in the lobby.