duel.gno
179.29 Kb · 3359 lines
1// Package duel implements r/splash/arcade/duel: Splash Play I, the Duel
2// (SPEC-03 v1.0, ../../../../SPEC-03-duel.md).
3//
4// Two players, a matched stake from a fixed ladder, 50/50, winner takes both
5// stakes less a 3% rake. Matching is an OPEN LOBBY: a poster leaves a stake
6// standing and any other USER — including the poster (SPEC-03 §7.3) — may
7// take it. (A USER: both Post and Accept require a direct user call since
8// 2026-09-30, so no realm can be a party to a duel. requireUserCall has the
9// reasoning, and it is this realm's own escrow that needs it.)
10// A poster MAY instead address the offer to one opponent, in which
11// case only that address may take it, and MAY attach a short label ("loser
12// buys lunch"). Both are descriptive only: neither touches settlement, and the
13// coin still decides.
14//
15// THE STAKE IS WRAPPED GNOT. SPLASH launches on GNOT only — the SPLASH token
16// economy is shelved — so this realm escrows gno.land/r/gnoland/wugnot, the
17// GRC20 wrapper over the chain's native ugnot (6 decimals: 1 GNOT = 1e6
18// ugnot). It is the only token this realm touches. r/splash/token is left
19// untouched for when the token returns; nothing here depends on it.
20//
21// THE REALM BOUNDARY WAS DELIBERATELY NOTHING (SPEC-03 §2) UNTIL 2026-09-29, AND IT
22// IS NOW EXACTLY ONE THING. The sentence that used to stand here — "it holds no
23// authority over r/splash/arcade/raffle, r/splash/beacon or r/gnoland/wugnot: no
24// package-path guard anywhere admits it" — is no longer true of the raffle and is
25// corrected rather than quietly deleted.
26//
27// THE ONE AUTHORITY: raffle.FundPotWithEntries admits THIS PACKAGE PATH AND NO
28// OTHER, because playing a duel now earns the two players entries in the raffle's
29// live week (AUDIT-2026-09-28.md finding 15, and see pendingEntries). Everything
30// else is as it was: this is an ordinary realm holding liquid GRC20 wugnot in
31// escrow, and the other raffle function it calls — FundPot — is still a
32// permissionless caller-pays door any address could call with its own money.
33//
34// WHAT A TOTAL COMPROMISE OF THIS REALM CAN NOW DO, restated honestly because the
35// old sentence understated it: lose the stakes of currently-open duels, as before,
36// AND credit raffle entries to addresses of its choosing — but only by DELIVERING
37// THE MONEY FOR THEM, because the raffle re-derives its own ceiling from the amount
38// it receives in the same call and refuses a batch that claims more (see
39// raffle.ArcadeEntryUnitsBound). It cannot mint an entry, cannot move an ugnot out
40// of the raffle, cannot reach a past week, and cannot resolve anything. The bound
41// is what limits the authority; the guard only decides who may reach it.
42//
43// THE RAKE FEEDS THE WEEKLY RAFFLE (changed 2026-09-28). It used to fund
44// r/splash/game's yield index and prize pot; that permanent-deposit yield product
45// is retired, SPLASH launches as the weekly ticket raffle plus this coin flip, and
46// nothing is held beyond a week. So the players' side of every rake is sponsored
47// into the raffle's CURRENT week's pot. See Collect.
48//
49// AND PLAYING NOW EARNS ENTRIES IN THAT WEEK'S DRAW (changed 2026-09-29). The rake
50// alone was not enough: the money arrived and the entrants did not, so a week of
51// busy coin flipping and no ticket sales could be won whole by one 0.1 GNOT ticket
52// bought in its final block. Every settled duel now credits its two players entries
53// worth EXACTLY what its rake would have bought at raffle.EntryUnitPrice — the loser
54// getting two thirds of them — so the people whose money built the pot are in the
55// draw for it. Pricing off the rake rather than the stake is what makes that safe
56// against two accounts flipping each other; read rakeEntryUnits, which is the
57// security property of the feature. AUDIT-2026-09-28.md finding 15.
58//
59// BUT NOT IN THE SAME TRANSACTION AS THE WINNER'S PAYOUT, AND THAT IS THE ONE
60// STRUCTURAL CHANGE OF 2026-09-28's LATER FIX. Collect used to call raffle.FundPot
61// directly, which ends in the raffle's own solvency check — so any panic in the raffle
62// took the winner's payout down with it, permanently, because VoidStale refuses a
63// resolved duel. Collect now BOOKS the players' side (pendingPot) and FlushPot delivers
64// it, permissionlessly, whenever the raffle will take it. A RESOLVED DUEL'S PAYOUT NO
65// LONGER DEPENDS ON ANOTHER REALM'S BOOKKEEPING. Read pendingPot for the two exits and
66// why neither is a new money sink.
67//
68// RANDOMNESS: A TWO-PARTY COMMIT-REVEAL BETWEEN THE ONLY TWO PEOPLE WHO CARE, AND SINCE
69// 2026-09-30 THIS REALM DOES NOT IMPORT r/splash/beacon AT ALL.
70//
71// Post carries commitHash = H(commitTag ‖ secret), the poster's 32-byte secret.
72// Accept carries a PLAINTEXT 32-byte nonce from the taker. No commitment, no reveal
73// from the taker, and no extra transaction — one more argument on the call
74// they already make.
75// Reveal opens the commitment. outcome = H(outcomeTag ‖ secret ‖ nonce ‖ duelID),
76// last byte's parity, exactly as posterWins always read it.
77// PERMISSIONLESS: the hash is the authorisation, so anybody may settle.
78// Forfeit pays the TAKER after revealWindowBlocks if the poster never opens.
79//
80// WHY IT HOLDS: the poster cannot grind, because the nonce does not exist when they
81// commit; the taker cannot steer, because they choose the nonce without the secret. No
82// round to wait for, so settlement is available in the very next block, the window is one
83// clock read off chain height, and no 17M-gas drand relay sits in anyone's critical path.
84//
85// WHAT IT REPLACES, AND WHY A BETTER FRESHNESS CHECK WAS NOT AN OPTION. Accept used to
86// bind Round = beacon.EstimatedLatestRoundWithin(duelMaxStaleBlocks) +
87// duelBindOffsetRounds — 300 seconds of drand rounds past a staleness-bounded estimate of
88// drand's head — and Resolve read that round's randomness once somebody relayed it. THAT
89// IS BROKEN AND THERE IS A TEST PROVING IT:
90// r/splash/beacon's TestKnownWeaknessRelayingTheRoundJustAboveTheEstimateBuysTheWholeStalenessBudgetBack
91// shows that relaying the round JUST ABOVE the estimate resets the staleness clock to zero
92// while advancing the estimate by exactly one round, for 0.0170 GNOT — about a tenth of a
93// US cent. Repeat it and the estimate lags arbitrarily far behind drand's real head; once
94// it lags past the 100-round offset, the bound round's randomness is already public on
95// api.drand.sh and a taker computes the winner BEFORE accepting. SPEC-03 §7.1 calls
96// exactly that "the single fatal bug of this design".
97//
98// IT CANNOT BE FIXED BY A TIGHTER STALENESS BOUND, WHICH IS THE WHOLE REASON THE SOURCE
99// OF RANDOMNESS CHANGED RATHER THAN ITS TOLERANCE. What the contract needs is an UPPER
100// bound on drand's head ("round R is not yet signed"). A signature proves a LOWER bound
101// ("round R exists"); the ABSENCE of a signature is not provable on chain at all, because
102// a relayer chooses what to relay and withholding is free and invisible. So no arithmetic
103// over relayed rounds can ever establish the property Accept needed. See
104// r/splash/beacon/DRAND-MIGRATION.md §5b/§5c and the retired "unforgeable" claim in
105// beacon.gno.
106//
107// ============================================================================
108// WHAT THIS DESIGN DOES NOT GIVE YOU — READ BEFORE YOU PLAY, TWO ITEMS
109// ============================================================================
110//
111// (1) THE SECRET'S ENTROPY IS UNENFORCEABLE ON CHAIN, AND commitHash IS A FREE OFFLINE
112// VERIFICATION ORACLE. Anybody can take a published commitHash and try candidate secrets
113// against H(commitTag ‖ secret) on their own machine, for nothing, for as long as they
114// like. A secret drawn from anything a person could think of — a word, a date, a counter,
115// Math.random(), a small integer padded to 32 bytes — WILL be found, and the finder then
116// picks the nonce that makes them win, or waits for the reveal window and forfeits a duel
117// the poster had actually won. The reveal that robs you is a VALID reveal: it passes the
118// hash check, this realm cannot tell it from yours, and there is no event that says
119// anything went wrong. THE PAGE MUST DRAW THE SECRET FROM A CSPRNG
120// (crypto.getRandomValues, crypto/rand, /dev/urandom), 32 bytes, and it must be FRESH FOR
121// EVERY OFFER. There is deliberately no on-chain entropy check: a 32-byte string is a
122// 32-byte string, and a contract that tried to judge one would only be rejecting honest
123// posters. checkCommitment enforces the only thing that IS checkable — the length and the
124// encoding — and liveCommitments refuses the one reuse case a contract can see.
125//
126// (2) A POSTER WITH MEMPOOL VISIBILITY HAS A FREE OPTION ON THE TAKER'S NONCE, AND THIS
127// IS AN OPEN, UNCLOSED WEAKNESS OF THE DESIGN. The nonce travels in the clear in the
128// Accept transaction. A poster who sees that transaction before it is committed knows the
129// secret, the nonce and the duel ID that Accept will assign — so they can compute the
130// outcome and, if they are about to lose, try to make the Accept fail: by cancelling the
131// offer, or by taking their own offer (SPEC-03 §7.3 permits it) for the cost of the rake
132// alone. Either move costs them at most 3% of the pool and saves them 50% of it. It is
133// NOT fixed by removing Cancel — the self-match route survives that — and it is NOT fixed
134// by mixing the accept height into the outcome, because a poster can enumerate the two or
135// three heights their abort could land in. THE ONLY CLOSURES ARE A SECOND TAKER
136// TRANSACTION (commit the nonce, reveal it later), WHICH THIS DESIGN DELIBERATELY REFUSES,
137// OR AN ON-CHAIN VALUE NOBODY CAN PREDICT, WHICH IS THE BEACON THIS CHANGE JUST REMOVED.
138// What bounds it today is that the abort must be ORDERED BEFORE an Accept that was already
139// broadcast: a FIFO mempool puts the abort second and the taker wins. That is a property
140// of the node software and the proposer, not of this contract, so it is recorded as an
141// assumption and not as a defence. See TestBreak_APosterWhoSeesTheNonceEarlyHasAFreeAbort,
142// which drives the option rather than describing it, and the addendum to
143// AUDIT-duel-2026-09-27.md.
144//
145// STORAGE IS PERMANENT AND IS RECLAIMED (SPEC-03 §7): Cancel, Collect, Forfeit
146// and VoidStale all DELETE their record. Nothing in this realm is ever marked
147// "settled" and kept.
148package duel
149
150import (
151 "crypto/sha256"
152 "encoding/hex"
153 "math/bits"
154 "strconv"
155 "strings"
156 "unicode/utf8"
157
158 "chain"
159 "chain/runtime"
160 "chain/runtime/unsafe"
161
162 "gno.land/p/nt/avl/v0"
163 "gno.land/p/nt/markdown/sanitize/v0"
164
165 "gno.land/r/gnoland/wugnot"
166 "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle"
167)
168
169// --- the economics (BIBLE.md §2.6, normative) ----------------------------
170
171const (
172 // rakeBps is the arcade rake: 300 bps of the pool, i.e. of 2 x stake
173 // (BIBLE.md "Arcade rake"; SPEC-03 §5's "mulDiv(2 x stake, 3, 100)" is the
174 // same number written over 100).
175 rakeBps = 300
176 bpsDenom = 10000
177
178 // The rake splits 60% to THIS WEEK'S RAFFLE POT / 40% to operations, DUST TO
179 // THE POT (BIBLE.md §2.7, the remainder rule binding on every split). The ops
180 // share is floored; the pot share is whatever is left, so the two parts always
181 // sum EXACTLY to the rake and no unit can vanish.
182 //
183 // IT WAS 50 YIELD / 30 PRIZES / 20 OPS, and the 50 and the 30 are now ONE
184 // NUMBER, not two doors. r/splash/game's permanent-deposit yield product is
185 // retired — SPLASH launches as the weekly ticket raffle and this coin flip,
186 // with nothing held beyond a week — so the yield share had nowhere left to go
187 // and is folded into the pot share rather than dropped. THE HOUSE/PLAYER SPLIT
188 // IS THEREFORE UNCHANGED: ops is still floor(rake x 2000 / 10000) computed the
189 // same way, and the players' side is still everything else,
190 // 5000 + 3000 = 8000 bps plus every unit of integer-division dust. What
191 // changed is only WHERE the players' side lands: one FundPot call into
192 // r/splash/arcade/raffle's current week instead of game.FundYield plus
193 // game.FundPrizes.
194 // THE SPLIT MOVED 80/20 -> 60/40 ON 2026-09-29, AT THE OWNER'S INSTRUCTION, AND
195 // THE TOTAL RAKE DID NOT MOVE. rakeBps is still 300 and nothing about what a
196 // player pays or is paid changed: a duel's winner still takes pool - rake, so THE
197 // PLAYER'S EDGE AGAINST THE HOUSE IS EXACTLY WHAT IT WAS. What moved is only
198 // which side of the house the 300 bps lands on, and
199 // TestFix24_ThePlayersEdgeAgainstTheHouseIsUnchangedByTheRakeSplit asserts that
200 // against the arithmetic rather than against a constant.
201 //
202 // TWO THINGS ELSE MOVE WITH IT AND BOTH ARE RE-DERIVED RATHER THAN LEFT STALE:
203 // the raffle's arcadeRakePotShareBps restates this number at its door and is
204 // changed in the same commit (a drift either refuses honest batches or reopens
205 // finding 15), and maxEntryUnitsFor's gross-up is 10000/6000 rather than
206 // 10000/8000. AUDIT-2026-09-28.md finding 24.
207 rakePotShareBps = 6000
208 rakeOpsShareBps = 4000
209 // pot = rake - ops: the remainder, and all integer-division dust. The
210 // rakePotShareBps constant above is the pot's FLOOR share, asserted against in
211 // splitRake; the pot is never paid less than it and takes the dust on top.
212)
213
214// A NEGATIVE ARRAY LENGTH IS A COMPILE ERROR, so this line fails the BUILD if a
215// future edit pushes the two named shares past the denominator — the case in
216// which the pot remainder would go negative and Collect would silently pay out
217// more than the rake. r/splash/game's Claim carries the same guard at runtime;
218// here it is caught before the code can run at all. (Zero elements as the shares
219// stand, which is ideal: the GnoVM does materialise the array — see init below —
220// so this guard must never be given a large length.)
221var _ [bpsDenom - rakePotShareBps - rakeOpsShareBps]struct{}
222
223// AND THE POT'S SHARE MUST BE BOUNDED FROM ABOVE AS WELL, WHICH IT WAS NOT UNTIL
224// 2026-09-30. This is AUDIT-duel-2026-09-27.md addendum 2, finding D-2, and it is the
225// missing half of rakeEntryUnits' security argument rather than a tidiness guard.
226//
227// THE ARGUMENT IT PROTECTS, RESTATED WITH ITS REAL MARGIN. rakeEntryUnits claims that
228// "priced off the rake, FLIPPING IS NEVER CHEAPER THAN BUYING". Read as weight per ugnot
229// SPENT, that claim holds with ZERO MARGIN at three of the four rungs: a rake of
230// EntryUnitPrice buys exactly one unit, and so does a ticket price of EntryUnitPrice. The
231// STRICT inequality lives in one place only — the PRIZE MONEY each route puts behind one
232// unit of weight:
233//
234// BOUGHT: EntryUnitPrice x (bpsDenom - raffle.HouseCutBps) / bpsDenom = 237,500 ugnot
235// FLIPPED: EntryUnitPrice x rakePotShareBps / bpsDenom = 150,000 ugnot
236//
237// A flipped unit is backed by less prize money than a bought one, so self-dealing is
238// weakly WORSE than buying and the feature is safe. THE ONLY THING SEPARATING THOSE TWO
239// NUMBERS IS rakePotShareBps = 6000 AGAINST raffle.HouseCutBps = 500. Raise the pot's
240// share of the rake past bpsDenom - HouseCutBps = 9500 bps and the inequality FLIPS:
241// flipping strictly dominates buying, nobody buys a ticket again, and one funded account
242// owns every draw — which is exactly the defect rakeEntryUnits exists to prevent, arrived
243// at by editing a number this file calls a free dial.
244//
245// THE EXISTING GUARDS CANNOT CATCH IT, and that is why this line is not redundant:
246//
247// - the guard above bounds rakePotShareBps + rakeOpsShareBps by bpsDenom, i.e. it only
248// refuses a split that does not sum. 9600/400 sums perfectly.
249// - r/splash/arcade/raffle's `var _ [arcadeRakePotShareBps - 5_000]struct{}` bounds the
250// restated copy FROM BELOW ONLY — it refuses a pot share under 5000 bps, because the
251// raffle's worry is the opposite one (too small a gross-up refuses honest batches).
252// Nothing anywhere bounded it from above.
253// - TestFix15_TheRafflesEntryBoundIsExactlyThisRealmsRakeEquivalent pins the two realms'
254// copies to EACH OTHER, so moving both in step keeps it green.
255//
256// IT CAN FAIL, AND BOTH CONSTANTS ARE REAL TERMS IN IT (finding 11's requirement):
257// raffle.HouseCutBps is the raffle's, rakePotShareBps is this realm's, neither cancels,
258// and both sides are bps of the same denominator reached by two independent product
259// decisions. 3499 elements as the constants stand — the bps of headroom the pot's share
260// has before it would overtake a ticket's — which is small enough for the GnoVM to
261// materialise (see the ladder note at init for why token-scale guards are forbidden; this
262// one is a bps count). The mutation that proves it is in the addendum: set
263// rakePotShareBps to 9600 and the build stops.
264var _ [bpsDenom - raffle.HouseCutBps - rakePotShareBps - 1]struct{}
265
266// rungs is the stake ladder, in ugnot (GNOT has 6 decimals):
267// 10 / 50 / 200 / 1,000 GNOT. FIXED AT DEPLOY, NEVER MUTABLE — there is no
268// function anywhere in this file that writes to it (SPEC-03 §3/§4).
269//
270// WHY THESE NUMBERS. The previous ladder (100 / 500 / 2,500 / 10,000) was
271// chosen for a SPLASH token priced quite differently; read in GNOT at roughly
272// $0.08 it ran $8 to $785 a side, which is a casino. This ladder is roughly
273// $0.79 / $3.93 / $15.70 / $78.50 a side — "go on then" to "I mean it" — which
274// is an arcade. SPEC-03 §9 records the exact rung values as a free dial.
275const (
276 rung0 = 10_000_000 // 10 GNOT
277 rung1 = 50_000_000 // 50 GNOT
278 rung2 = 200_000_000 // 200 GNOT
279 rung3 = 1_000_000_000 // 1,000 GNOT
280
281 // maxStake bounds every arithmetic input in this realm: it is the top rung,
282 // and the largest number that can ever be pulled into escrow in one call.
283 //
284 // THE WIDEST INTERMEDIATE IN THIS FILE IS 2 x maxStake x bpsDenom, re-derived
285 // for this ladder: the pool is at most 2 x maxStake = 2e9, rakeOf multiplies
286 // that pool by rakeBps (300) <= bpsDenom (1e4), so the product is at most
287 // 2e9 x 1e4 = 2e13; splitRake multiplies a rake (< the pool) by a share in
288 // bps (<= 1e4), which is smaller again. 2e13 is five orders of magnitude
289 // below an int64's 9.2e18 (and 2e9 alone is below int32's 2.1e9 by a hair,
290 // which is irrelevant here: every quantity is int64). None of that is what
291 // keeps the arithmetic safe — the spec's rule is to never multiply
292 // token-scale int64s directly, and mulDiv below is used regardless, so a
293 // future rung big enough to matter cannot reintroduce a wrap (SPEC-v1.md
294 // §7.9; commit 3622284 "mulDiv alone only moves the wrap": BOUND THE INPUT,
295 // then use mulDiv). poolOf and rakeOf check against it at runtime, and
296 // init below refuses deployment if the headroom ever stops holding.
297 maxStake = rung3
298
299 // maxStakeHeadroom is the largest maxStake for which 2 x maxStake x
300 // bpsDenom stays below 2^62, a quarter of int64's range: 2.3e14 ugnot,
301 // i.e. 230 million GNOT a side. Cannot be an array-length guard — see the
302 // ladder-ascends note below for why token-scale guards must not be.
303 maxStakeHeadroom = (1 << 62) / (2 * bpsDenom)
304)
305
306var rungs = [...]int64{rung0, rung1, rung2, rung3}
307
308// The ladder must ascend, or a "higher" rung could be the cheaper one.
309//
310// NOT AN ARRAY-LENGTH GUARD, unlike the two bps checks in this file: the GnoVM
311// materialises a [N]struct{} declaration as N elements, so a guard of the form
312// [rung1 - rung0 - 1]struct{} would allocate four hundred million of them at
313// init and exhaust memory (it did, on the first test run). Small guards are
314// fine; token-scale ones are not. A failing init refuses the deployment, which
315// is the next best thing to refusing the build.
316func init() {
317 for i := 1; i < len(rungs); i++ {
318 if rungs[i] <= rungs[i-1] {
319 panic("duel: the stake ladder must ascend")
320 }
321 }
322 if rungs[len(rungs)-1] != maxStake {
323 panic("duel: maxStake must be the top rung")
324 }
325 if maxStake > maxStakeHeadroom {
326 panic("duel: 2 x maxStake x bpsDenom would not fit an int64 with headroom")
327 }
328 // THE TWO DOMAIN TAGS MUST DIFFER (see commitTag): if they were the same string, a
329 // commitment would be an outcome and the two uses of sha256 in this file would stop
330 // being independent functions. Checked here rather than at build time because a string
331 // comparison is not an integer constant expression and so cannot be an array length.
332 if commitTag == outcomeTag {
333 panic("duel: the commitment and outcome domain tags must differ")
334 }
335}
336
337// --- the reveal window (SPEC-03 §6, re-shaped 2026-09-30) ------------------
338//
339// ONE CLOCK, AND BOTH ENDS OF IT ARE CHAIN HEIGHT. This block used to hold FOUR coupled
340// numbers — voidGraceBlocks, duelBindOffsetRounds, duelMaxStaleBlocks and
341// minVoidGraceMultiple — three of which existed only to keep a drand round binding honest
342// against a beacon estimate. The beacon is gone from this realm, so they are gone with it:
343//
344// duelBindOffsetRounds DELETED. Nothing binds a round any more.
345// duelMaxStaleBlocks DELETED. Accept reads no beacon, so it has no staleness to bound.
346// minVoidGraceMultiple DELETED. It bounded the grace against the wait for a bound round.
347// voidGraceBlocks RENAMED revealWindowBlocks, same 24 hours, different meaning.
348//
349// AND THE TWO BUILD GUARDS THAT STOOD HERE ARE DELETED RATHER THAN RESCALED, WHICH IS
350// REQUIRED BY THIS PROJECT'S FINDING 11 DOCTRINE AND NOT MERELY TIDY. They were
351//
352// var _ [duelBindOffsetRounds*beacon.PeriodSeconds/2 - duelMaxStaleBlocks*33/100 - 1]struct{}
353// var _ [voidGraceBlocks*33/10/(duelBindOffsetRounds*beacon.PeriodSeconds) - minVoidGraceMultiple]struct{}
354//
355// and BOTH were legitimate because each had SECONDS ON BOTH SIDES FROM DIFFERENT CLOCKS:
356// the chain's block time on one side, drand's period on the other. That is exactly what
357// made them able to fail. With drand's clock removed there is only one clock left, so any
358// guard written between two windows here would have the block time on both sides, it would
359// cancel, and the guard COULD NOT FAIL — which AUDIT-2026-09-28.md finding 11 says is
360// worse than no guard at all, because it looks like protection. So there is DELIBERATELY
361// NO BUILD GUARD ON revealWindowBlocks, and the relation it would have asserted is stated
362// in prose below instead, exactly as pendingGraceBlocks already does for its own window.
363const (
364 // blocksPerDay mirrors r/splash/game's constant of the same name:
365 // ECONOMICS.md §7a-bis's measured ~3.3s block time. A different package
366 // cannot reference game's unexported constant, so it is restated here.
367 blocksPerDay = 26182
368
369 // revealWindowBlocks is how long after Accept the poster has to open their
370 // commitment before ANYBODY may Forfeit the duel to the taker: 24 hours, the same
371 // number voidGraceBlocks carried, now measuring something a player controls.
372 //
373 // THE RELATION IT HAS TO HOLD, IN PROSE BECAUSE A GUARD FOR IT COULD NOT FAIL (see
374 // the block comment above). Two bounds, pulling in opposite directions:
375 //
376 // FROM BELOW: it must comfortably exceed the time an honest poster needs to get one
377 // transaction on chain — including a wallet prompt, a congested block or two, and a
378 // person who accepted a challenge and then walked away from the keyboard. A poster
379 // who is LATE ON A DUEL THEY WON LOSES THE WHOLE POOL to the taker, so this end of
380 // the window is the one with money on it. 24 hours is four orders of magnitude above
381 // one block and is not a number anybody has to tune.
382 //
383 // FROM ABOVE: it is how long a losing poster can make the winning taker wait. The
384 // wait is the ONLY thing withholding buys them — Forfeit pays the taker the same
385 // pool - rake that Reveal plus Collect would have paid, so a withheld secret costs
386 // the poster exactly what losing costs them and gains them nothing but the delay.
387 // 24 hours of griefable delay against an outcome that does not change is the accepted
388 // trade, and it is the identical exposure the old voidGraceBlocks carried.
389 //
390 // BOTH ENDS ARE MEASURED IN BLOCKS, AND THAT IS UNCHANGED AND NOW UNCONTROVERSIAL.
391 // The old comment here had to argue the point at length, because "a day of missed
392 // beacon rounds" cannot be counted during the very outage the window exists for. There
393 // is no outage to reason about any more: chain/runtime.ChainHeight() is the only clock
394 // a realm can read on this toolchain (no block timestamp exists — see game.gno's
395 // blocksPerDay comment) and it is now the only clock this window needs.
396 revealWindowBlocks = blocksPerDay
397)
398
399// --- rake destinations ----------------------------------------------------
400//
401// BOTH CONSTANTS BELOW ARE DUPLICATES OF r/splash/exchange's, and for the same
402// reason: a realm's address is a pure function of its package path
403// (gnolang.DerivePkgCryptoAddr), the constants in the exchange are unexported,
404// and this is a different package. tools/duel_addr_test.go re-derives both
405// from their package paths on every `go test ./tools`, fails on a mismatch, and
406// fails on any address constant in this file it has not been told about — the
407// guard the exchange's R3 finding (a label hash shipped as a live destination)
408// showed to be necessary.
409const (
410 // raffleRealmAddr — gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle. The pot share of the rake
411 // goes here, through raffle.FundPot: A NAMED DOOR, not a bare transfer.
412 // SPEC-03 §2 says "an ordinary GRC20 transfer", but a bare transfer into the
413 // raffle is swept to its OPS pot, not into any week's prize (the raffle's
414 // sweepDonations says why: crediting an unattributed arrival to a prize pot
415 // would break the identity that makes its refunds safe). So the share must go
416 // through the door that records it as sponsorship of the current week.
417 //
418 // IT REPLACED gameRealmAddr ON 2026-09-28. The yield and prize shares used to
419 // go to gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/game via game.FundYield and game.FundPrizes; that
420 // permanent-deposit yield product is retired and nothing in SPLASH holds money
421 // beyond a week, so both shares now arrive as one sponsorship of THIS WEEK'S
422 // raffle pot. Every coin flip makes Saturday's prize bigger: that is the
423 // product. Derivation-checked by tools/duel_addr_test.go.
424 //
425 // This is also the SPENDER the approval in Collect names, so it must be the
426 // address the raffle's own TransferFrom runs as — which is what the derivation
427 // check guarantees.
428 raffleRealmAddr address = "g1whdkh0jr9c07cewdrsed6fnwptkvdvrzypyfah"
429
430 // opsVaultAddr — gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/ops. The operations share of the rake.
431 // Same constant, same derivation and same reasoning as r/splash/exchange's
432 // opsVaultAddr: the realm at that path need not exist yet for the address
433 // to be right.
434 opsVaultAddr address = "g1dqnxfp5zj89ggfcfzvampmhmrkjq36d2zvdyk7"
435)
436
437// --- state (SPEC-03 §3) ---------------------------------------------------
438
439// maxLabelLen bounds an offer's label, in BYTES (len of the string, not
440// runes). 64 is room for "loser buys lunch, and the dessert" with change, and
441// small enough that the storage deposit a label costs — 100 ugnot/byte until
442// the record is deleted — is a rounding error on the smallest stake.
443const maxLabelLen = 64
444
445// Offer is a standing stake in the lobby.
446type Offer struct {
447 Poster address
448 Rung int64 // index into rungs, NOT a raw amount
449 PostedAt int64 // block height, for the render only
450
451 // Opponent, when non-zero, is the ONLY address that may Accept this offer:
452 // the duel was sent to someone rather than listed. Zero means open to
453 // anyone, exactly as before the field existed. Checked in Accept, nowhere
454 // else — it has no bearing on who wins.
455 Opponent address
456
457 // Label is free text from the poster, at most maxLabelLen bytes, printable
458 // UTF-8 only (checkLabel). PURELY DESCRIPTIVE: never parsed, never used in
459 // settlement, and it reaches Render only through renderLabel.
460 Label string
461
462 // CommitHash is H(commitTag ‖ secret) as 64 lowercase hex characters: the poster's
463 // half of the coin, sealed. It is THE ONLY SOURCE OF RANDOMNESS THIS REALM HAS from
464 // the poster's side, and Reveal will not open it for anything but the one 32-byte
465 // preimage it names (checkCommitment, checkSecret, commitmentOf).
466 //
467 // EMPTY MEANS LEGACY, AND LEGACY MUST NEVER ROUTE TO FORFEIT. An offer created before
468 // 2026-09-30 has no commitment and therefore CAN NEVER BE REVEALED by anybody — so
469 // treating its expiry as a forfeit would hand the taker a pool the poster never had a
470 // chance to win. Accept REFUSES such an offer outright (so no such duel can be created
471 // from here on), Cancel is permissionless for it (so it cannot be stranded even if its
472 // poster is a pre-D-1 realm that cannot call anything), and VoidStale refunds a duel
473 // that already has one. Nothing is deployed, so no record of this shape exists; the
474 // paths exist so that the code contains no trap. Requirement 5 of the change.
475 CommitHash string
476
477 // PosterIsUser records whether the Post that created this offer was a DIRECT
478 // USER CALL, and it exists for exactly one reason: AUDIT-2026-09-28.md finding
479 // 8, carried into finding 15's earned entries.
480 //
481 // IT IS NECESSARILY TRUE FOR EVERY OFFER CREATED SINCE 2026-09-30, and it is kept
482 // anyway. This field used to be the ONLY consequence of playing from realm code —
483 // "it has NO bearing on settlement, a realm may post, accept, win and collect
484 // exactly as before" is what stood here, and finding D-1 is that leaving settlement
485 // open was the defect: a winning realm could not call the winner-only Collect, so
486 // the pool had no exit. Post now refuses a non-user call outright
487 // (requireUserCall), so nothing can write false here any more.
488 //
489 // KEPT, NOT DELETED, FOR TWO REASONS. It is the one place finding 8's property is
490 // enforced for ENTRIES — the raffle cannot check it for a bare address, and
491 // FundPotWithEntries trusts this realm to have checked — so if either gate is ever
492 // relaxed, bookEntries must not silently start crediting realms; the flag and its
493 // check are what make that failure a zero and an event rather than a stranded raffle
494 // pot. And it is a recorded fact about a call that no later reader can re-derive.
495 //
496 // WHY IT HAS TO BE RECORDED HERE AND CANNOT BE DERIVED LATER. A raffle entry is
497 // a claim on an outbound transfer (the raffle pushes a whole week's pot to the
498 // winner's address), and for a realm caller that address is PACKAGE-DERIVED:
499 // nothing can move wugnot out of it unless that realm happens to contain code
500 // that does. A realm that won a raffle week would strand the entire pot, which
501 // is finding 8 verbatim. Neither realm can test an address for EOA-ness — the
502 // predicate is about the CALL, not the address, and it is only available while
503 // the call is on the stack. So it is captured at the door and carried: Accept
504 // copies it onto the Duel, and Collect credits entries only to parties whose own
505 // call was a user call.
506 PosterIsUser bool
507}
508
509// Duel is an accepted offer waiting to be resolved and collected.
510type Duel struct {
511 Poster address
512 Taker address
513 Rung int64
514
515 // Label is copied verbatim from the offer at Accept, so the render can
516 // still say what the duel was about. Descriptive only, as on Offer.
517 Label string
518
519 // CommitHash is the offer's, copied at Accept. Reveal checks a secret against it.
520 // "" is a legacy record: read Offer.CommitHash for what that means and for why such a
521 // duel goes to VoidStale and never to Forfeit.
522 //
523 // THE FIELD `Round int64` USED TO STAND HERE, and it is deleted rather than kept and
524 // ignored: it was "the beacon round this resolves against, set at Accept, strictly in
525 // the future", and both the round and the beacon are gone. A dead field that a reader
526 // could mistake for a live one is exactly how the 300-second bind margin outlived the
527 // cadence change that had already falsified it.
528 CommitHash string
529
530 // Nonce is the taker's 32 bytes, as 64 lowercase hex characters, IN THE CLEAR. It is
531 // half of the outcome and the taker chooses it freely — which is safe because they
532 // choose it WITHOUT the secret, so every nonce is a coin they cannot evaluate
533 // (posterWins). It is in the clear because a commitment from the taker would cost them
534 // a second transaction, and the whole point of this shape is that the taker's
535 // contribution rides the call they were already making.
536 //
537 // THE ONE THING THE PLAINTEXT COSTS is item (2) of this file's doc comment: it is
538 // visible in the mempool before Accept is committed, which gives a watching poster a
539 // free abort option on a nonce that loses. That is disclosed, open, and not closed by
540 // anything in this file.
541 Nonce string
542
543 // Secret is the opened commitment, "" until Reveal. STORED, NOT DISCARDED, AND THAT IS
544 // DELIBERATE: it makes the outcome re-derivable by anybody from the record alone, and
545 // it is what lets AuditFailure re-check that every resolved duel's Winner is actually
546 // the one H(outcomeTag ‖ secret ‖ nonce ‖ duelID) names — a settlement-correctness
547 // invariant the beacon version could not state, because its randomness lived in another
548 // realm. 64 bytes of deposit, reclaimed when Collect deletes the record.
549 Secret string
550
551 // AcceptedAt is the block height at Accept; the REVEAL WINDOW is counted from it
552 // (revealWindowBlocks). Not part of SPEC-03 §3's sketch — it is the only way to measure
553 // an elapsed window on a chain with no timestamp — and DuelOf keeps the six-value shape
554 // §4 published by returning the window's END in the slot the beacon round used to have.
555 AcceptedAt int64
556
557 // PosterIsUser and TakerIsUser record whether each party's OWN call — the Post
558 // for the poster, the Accept for the taker — was a direct user call. Read
559 // Offer.PosterIsUser for why: they decide only whether that party earns raffle
560 // entries at Collect (finding 15), and nothing else in this realm reads them.
561 // The poster's flag is copied from the offer, which is the only place it was
562 // knowable.
563 //
564 // BOTH ARE NECESSARILY TRUE FOR EVERY DUEL CREATED SINCE 2026-09-30: Post and
565 // Accept both require a direct user call now (requireUserCall, finding D-1), which
566 // is what gives this realm's own escrow the exit it was missing. They are retained
567 // as the enforcement point for the ENTRY half of finding 8 — see
568 // Offer.PosterIsUser for the two reasons — and DuelEntryEligibility still reports
569 // them.
570 PosterIsUser bool
571 TakerIsUser bool
572
573 Resolved bool
574 Winner address // zero until Resolved
575}
576
577var (
578 // offers is offerID -> *Offer and duels is duelID -> *Duel. avl.Trees,
579 // NEVER MAPS: Render iterates them, and a map's iteration order differs
580 // between nodes (SPEC-v1.md §7.7).
581 offers = avl.NewTree()
582 duels = avl.NewTree()
583
584 // IDs start at 1 so that 0 never names a record. Offer and duel IDs are
585 // separate sequences, as in SPEC-03 §3.
586 nextOfferID int64 = 1
587 nextDuelID int64 = 1
588
589 // liveCommitments is the set of (poster, commitHash) pairs currently held by a LIVE
590 // record — one standing offer or one uncollected duel each. Post refuses a pair already
591 // in it; Cancel, Collect, Forfeit and VoidStale release it with the record.
592 //
593 // WHY IT EXISTS: IT IS THE ONE REUSE A CONTRACT CAN SEE, AND REUSE BREAKS THE TAKER
594 // PROPERTY OUTRIGHT. "The taker cannot steer" holds because they choose the nonce
595 // WITHOUT the secret. A poster who posts four offers under ONE secret destroys that for
596 // three of them: the first Reveal publishes the secret on chain, and the taker of any
597 // still-live sibling can then pick the nonce that wins, or simply wait out the window
598 // and Forfeit a duel the poster would have won. That is not a subtle edge — it is the
599 // exact loss of the property, available to anybody reading the block log, and it is the
600 // shape a front end falls into naturally by generating one secret per session instead
601 // of one per offer.
602 //
603 // KEYED BY POSTER AND HASH, NOT BY HASH ALONE, AND THAT IS THE WHOLE DIFFERENCE BETWEEN
604 // A GUARD AND A GRIEF. A bare-hash set would let anybody watch the mempool, copy a
605 // commitHash out of a pending Post and land their own Post first, so the victim's
606 // transaction fails and they must generate a fresh secret to get on the board at all —
607 // a free denial of service on the front door. Keyed by poster, the copier's entry
608 // collides with nothing of the victim's, and the copier has only arranged to be robbed
609 // themselves when the victim reveals. The protection is unharmed, because only the
610 // poster knows the secret, so only the poster can reuse one.
611 //
612 // WHAT IT DOES NOT DO, STATED SO IT IS NOT MISTAKEN FOR MORE THAN IT IS. It is a LIVE
613 // set, not a history: once a duel is collected its pair is released, so the same poster
614 // may post the same commitment again — with a secret that is by then public — and be
615 // robbed. Keeping every commitment ever seen would be unbounded storage on a contract
616 // with no owner and no prune, which is a worse defect than the one it would close. The
617 // residue is the same class as the entropy problem above and has the same answer: the
618 // page draws a FRESH 32 bytes from a CSPRNG for every offer.
619 //
620 // A LEAKED ENTRY IS HARMLESS AND AN OVER-EAGER RELEASE IS HARMLESS, which is why this
621 // index is not on the critical path of any exit. An entry left behind can only refuse a
622 // future Post of one exact pair the poster should not be reusing anyway; a released one
623 // can only re-admit a duplicate. Neither can strand money, and AuditFailure re-derives
624 // the whole set from the records so drift in either direction is visible.
625 liveCommitments = avl.NewTree()
626
627 // escrowed is the wugnot this realm holds on behalf of players: one stake
628 // per open offer, two per uncollected duel. Maintained incrementally, never
629 // by iteration, and re-derived by AuditFailure. The realm's balance must
630 // never fall below it (checkInvariant).
631 escrowed int64
632
633 // pendingPot is the players' side of the rake that has been BOOKED by Collect and
634 // not yet delivered to r/splash/arcade/raffle. FlushPot delivers it.
635 //
636 // WHY IT EXISTS: A RESOLVED DUEL'S PAYOUT MUST NOT DEPEND ON ANOTHER REALM'S
637 // BOOKKEEPING. Until today Collect called raffle.FundPot in the SAME TRANSACTION as
638 // the winner's payout, and FundPot ends in the raffle's checkInvariant. So ANY panic
639 // anywhere in the raffle — an accounting deficit, a committed or abandoned current
640 // week, a beacon too stale to pin a new week's round since the staleness bound landed
641 // — took the winner's payout down with it. And VoidStale refuses a RESOLVED duel, so
642 // there was no second exit: the pool sat in escrow, unreachable by every path in
643 // either realm, on two contracts with no owner and no upgrade path. THAT WAS
644 // PRE-EXISTING, not created by the staleness bound: a raffle accounting deficit
645 // already bricked every resolved duel's settlement.
646 //
647 // SO Collect NO LONGER CALLS THE RAFFLE AT ALL. It books here and returns. There is
648 // no way to make a cross-realm call conditional on it succeeding — revive()/recover()
649 // does not work across a realm boundary on this toolchain and would be the wrong
650 // mechanism if it did — so the coupling is removed rather than caught.
651 //
652 // IT IS NOT A NEW MONEY SINK, AND THAT IS THE FIRST THING TO CHECK ABOUT IT, because
653 // six of the fourteen findings on this product so far have been "money in, no way
654 // out". Two exits, both permissionless:
655 //
656 // - FlushPot, any time, by anyone: the whole balance goes to the raffle's current
657 // week through FundPot, exactly as Collect used to send it. If the raffle refuses,
658 // the transaction reverts and THIS BALANCE IS UNTOUCHED — the caller of FlushPot
659 // wastes their gas, and no duel winner is affected. That is the entire point.
660 // - SweepStuckRake, after pendingGraceBlocks with no successful flush: a plain
661 // wugnot.Transfer to the raffle's address, which calls no raffle code and so
662 // cannot be refused by it. The raffle's sweepDonations absorbs it into its ops
663 // pot, and WithdrawOps takes it from there. See SweepStuckRake for why the
664 // destination is the raffle and not r/splash/ops.
665 //
666 // AND IT IS IN checkInvariant AND AuditFailure. The realm must hold escrowed +
667 // pendingPot, not just escrowed; a bug that booked rake it did not hold would
668 // otherwise be invisible.
669 pendingPot int64
670
671 // pendingSince is the clock SweepStuckRake's last-resort window is counted from: the
672 // chain height at which pendingPot last went from zero to non-zero, ADVANCED AGAIN
673 // EVERY TIME A NEW HOLDER IS ADMITTED TO THE ENTRY BATCH (bookEntries). Zero when
674 // pendingPot is zero, and reset by every successful flush.
675 //
676 // IT MEASURES THE AGE OF THE YOUNGEST CLAIM IN THE BATCH. It does NOT measure "how
677 // long has delivery been failing", which is what this comment claimed until
678 // 2026-09-30 and what SweepStuckRake's justification is still written in terms of —
679 // AUDIT-duel-2026-09-27.md addendum 2, finding D-4. THE CLAIM WAS NEVER TRUE:
680 // nothing here records a FAILED flush and nothing requires anybody to have attempted
681 // one, so a raffle that would have accepted every delivery all along is
682 // indistinguishable from one that refused them all. What the window has always
683 // actually measured is elapsed time.
684 //
685 // THE DISCLOSED CONSEQUENCE OF THAT IS ACCEPTED AND IS RECORDED AT
686 // pendingGraceBlocks: 21 days in which nobody bothers to call FlushPot lets any
687 // caller divert the players' side of the rake to the house, even with the raffle
688 // perfectly willing. That is a cost of having a window at all.
689 //
690 // THE UNDISCLOSED ONE WAS THE FINDING, AND IT IS WHY THE CLOCK NOW ADVANCES. A batch
691 // opened at height H accumulates up to maxPendingEntryHolders holders under a clock
692 // started at H. So a player who collected on day 20 of the window had their earned
693 // entries destroyed — SweepStuckRake discards the whole batch — by a window that
694 // OPENED BEFORE THEY PLAYED, with no notice, no event they could have seen in time,
695 // and no action available to them but paying up to 351,725,503 gas to flush a batch
696 // they had just joined. Advancing on admission means every holder gets the whole
697 // window measured from their own claim.
698 //
699 // WHY THIS AND NOT THE OTHER TWO CANDIDATES. Gating the sweep on recorded evidence of
700 // a FAILED flush is the honest reading of the justification, but a failed flush
701 // REVERTS — including any write that recorded it — so recording one is not possible
702 // without a caller who reports their own failure, which nothing can compel and
703 // anybody can fake in either direction. Merely documenting that a late entrant
704 // inherits the clock leaves the harm in place. Advancing on admission is bounded (at
705 // most maxPendingEntryHolders advances, so the window always closes — see bookEntries)
706 // and moves the sweep only in the players' favour, since the sweep is the path that
707 // takes their money to the house.
708 pendingSince int64
709
710 // pendingEntries is string(address) -> int64 raffle entry units earned by that
711 // address and not yet delivered, and pendingEntryUnits is its running sum.
712 // FlushPot delivers both together with pendingPot.
713 //
714 // WHY PLAYING EARNS ENTRIES AT ALL: AUDIT-2026-09-28.md finding 15. The rake has
715 // funded the raffle's current week since 2026-09-28, but playing bought NO
716 // TICKETS — so a week in which the coin flip was busy and nobody bought a raffle
717 // ticket could be won outright by a single 0.1 GNOT ticket bought in its final
718 // block. The money arrived and the entrants did not. Now both arrive together:
719 // every settled duel credits its two players entries worth exactly what its rake
720 // would have bought at raffle.EntryUnitPrice. Read rakeEntryUnits for the pricing
721 // rule, which is the security property, and splitEntries for the consolation
722 // weighting.
723 //
724 // THEY RIDE THE SAME MECHANISM AS THE MONEY, AND THAT IS NOT NEGOTIABLE.
725 // Collect books here and calls no raffle code; FlushPot delivers. Finding 14 is
726 // the reason: a panic inside the raffle used to strand every resolved duel's pool
727 // permanently, and entries must never reintroduce that coupling. A raffle that
728 // refuses the batch costs the flusher a reverted transaction and no winner
729 // anything.
730 //
731 // AGGREGATED BY ADDRESS, NOT LISTED BY DUEL, for two reasons. A player who
732 // settles three duels between two flushes delivers as ONE index in the raffle's
733 // ticket tree rather than three, which is cheaper for everybody and is
734 // indistinguishable in the draw (a fenwick index's weight is what decides, not
735 // how many indices an address holds). And the batch FlushPot has to build is
736 // bounded by the number of distinct PLAYERS since the last flush rather than by
737 // the number of duels, which is what maxPendingEntryHolders bounds.
738 pendingEntries = avl.NewTree()
739 pendingEntryUnits int64
740)
741
742// maxPendingEntryHolders bounds the batch FlushPot builds, and therefore bounds
743// FlushPot's gas.
744//
745// IT EXISTS BECAUSE THE ONE THING THAT MUST NEVER HAPPEN IS AN UNDELIVERABLE
746// BATCH. Every distinct player who collects between two flushes adds a holder, and
747// FlushPot pays to append each one in the raffle. With no bound, a long spell of
748// the raffle refusing every flush — see FundPot's refusals — could grow the batch
749// until delivering it cost more than a block, at which point the booked rake's
750// primary exit would be gas-bricked and only SweepStuckRake's last resort would be
751// left. That is the "money in, no way out" shape six of this product's findings
752// have had, so it is bounded rather than hoped about.
753//
754// WHAT HAPPENS AT THE BOUND IS A DROPPED ENTRY, NEVER A FAILED Collect. A winner
755// must always be able to collect (finding 14), so Collect cannot start refusing
756// because a queue is full. A player who would be the (n+1)th distinct holder
757// therefore earns nothing for that duel and an event says so. It costs any caller
758// ONE FLUSH to prevent that for everybody, permanently — and in
759// normal operation the batch is emptied by the next flush, which anybody may make
760// at any height and which the players themselves have every reason to make.
761//
762// WHAT THAT FLUSH COSTS IS A RANGE AND NOT A FIGURE, corrected 2026-09-30
763// (AUDIT-duel-2026-09-27.md addendum 2, finding D-4). This paragraph said "~8.6M gas",
764// which is A TWO-HOLDER FIGURE — and this paragraph is about the moment the batch is
765// FULL, which is the one regime in which that number is more than thirty times too small.
766// The two ends of the range, each with its provenance, because they are not both
767// reproducible the same way:
768//
769// TWO HOLDERS: 10,378,157 gas, 0.35% of a 3e9 block. MEASURED 2026-09-30 on
770// /home/johnny/go/bin/gno as GasCollectAndFlush minus GasCollectSetupAndCollect in
771// zz_gas_test.gno, stable across repeated runs. NOTE THAT FILE'S TABLE STILL RECORDS
772// 8,638,590 FOR THE SAME DIFFERENCE AND NO LONGER REPRODUCES — it is stale by ~1.7M
773// and is owed a re-measurement, which is finding D-6 and is NOT fixed here.
774//
775// A FULL 128-HOLDER BATCH against a live week grown to 100,096 ticket indices:
776// 351,725,503 gas, 11.7% of a block. AN OUT-OF-SUITE FIGURE BY CONSTRUCTION, and it
777// must be read as one: seeding 100,096 indices takes ~7 minutes of wall clock, so it
778// was measured with throwaway probes in a scratch tree and CANNOT be reproduced by
779// running this package. zz_gas_test.gno records the method. The in-suite
780// TestGasFlushAFullBatch exercises the same 128 holders against a far smaller index.
781//
782// THE ARGUMENT IS UNCHANGED AND THE HONEST VERSION IS STILL CHEAP: even at the full-batch
783// end it is one transaction, well inside a block, available to anybody at any height. But
784// a reader deciding whether the mitigation is realistic must be given the number that
785// applies in the state the mitigation is for, not the cheapest one in the file.
786//
787// THE FIGURE SAID "~1.1M" UNTIL 2026-09-29 AND WAS STALE BY FINDING 15, which put two
788// fenwick appends and their events inside every FlushPot. The ARGUMENT is unchanged —
789// preventing a dropped entry for everybody costs one caller one affordable transaction —
790// but this number has now been wrong three times running, in three different ways, which
791// is itself the lesson: A MEASURED FIGURE IN A COMMENT DECAYS, and the only ones worth
792// quoting are the ones a reader can re-derive from a named test. AUDIT-2026-09-28.md
793// finding 19; AUDIT-duel-2026-09-27.md addendum 2, findings D-4 and D-6.
794//
795// 128 IS PICKED, NOT DERIVED, AND WHAT MAKES IT SAFE IS THAT BOTH SIDES OF IT ARE
796// MEASURED: zz_gas_test.gno pins the cost of a full batch's delivery, so a future
797// edit that raises this constant has to re-measure rather than reason. It is far
798// above the number of distinct players a quiet arcade sees between two flushes and
799// far below anything that threatens a block.
800//
801// AND THE BOUND WAS RE-MEASURED IN THE REGIME IT PROTECTS, WHICH IS THE POINT OF
802// FINDING 19. This constant caps the HOLDER dimension at 128 and says nothing about the
803// other dimension the cost depends on: THE SIZE OF THE RAFFLE WEEK'S TICKET INDEX,
804// which grows on its own with no code edit at all. The earlier full-batch figure was
805// taken against a week grown to only a few hundred entries. Measured against a live week
806// seeded to 100,096 indices on this toolchain, a full 128-holder FlushPot costs
807// 351,725,503 gas — 11.7 % of a 3e9 block, against 8.8 % at a few hundred. The
808// tree-dependent half grew by about a third rather than doubling, the bound holds with
809// more than eight times the headroom it needs, and 128 IS THEREFORE KEPT. The
810// measurement, including how the index was seeded, is in zz_gas_test.gno.
811const maxPendingEntryHolders = 128
812
813// realmSelf is this realm's own address, resolved ONCE at package
814// initialisation, for the same reason game.gno does it: under the test
815// harness unsafe.CurrentRealm() follows the origin caller in a non-crossing
816// frame, and a realm's own address is a constant for its whole life.
817var realmSelf = unsafe.CurrentRealm().Address()
818
819func selfAddr() address {
820 return realmSelf
821}
822
823// idKeyWidth is enough decimal digits for any non-negative int64, so that
824// avl's lexicographic key order is numeric ID order and Render lists records
825// as 1, 2, ... 10 rather than 1, 10, 2 — the same convention as
826// r/splash/beacon's roundKey and r/splash/game's ticketIndexKey.
827const idKeyWidth = 19
828
829const idZeroPad = "0000000000000000000"
830
831func idKey(id int64) string {
832 s := strconv.FormatInt(id, 10)
833 if len(s) < idKeyWidth {
834 s = idZeroPad[:idKeyWidth-len(s)] + s
835 }
836 return s
837}
838
839// getOffer returns the offer, or nil. avl's Get returns a single value, so
840// existence is a separate Has.
841func getOffer(id int64) *Offer {
842 key := idKey(id)
843 if !offers.Has(key) {
844 return nil
845 }
846 return offers.Get(key).(*Offer)
847}
848
849func getDuel(id int64) *Duel {
850 key := idKey(id)
851 if !duels.Has(key) {
852 return nil
853 }
854 return duels.Get(key).(*Duel)
855}
856
857// stakeOf is the only place the ladder is read. Panics on any index outside
858// it, including negative ones.
859func stakeOf(rung int64) int64 {
860 if rung < 0 || rung >= int64(len(rungs)) {
861 panic("duel: invalid rung")
862 }
863 return rungs[rung]
864}
865
866// mulDiv returns floor(a*b/d) via a 128-bit intermediate product, exactly as
867// r/splash/game and r/splash/exchange do it. Raw a*b/d on token-scale int64s
868// wraps silently on the GnoVM (tools/muldiv_probe.gno).
869func mulDiv(a, b, d uint64) uint64 {
870 hi, lo := bits.Mul64(a, b)
871 if d == 0 {
872 panic("duel: mulDiv: division by zero")
873 }
874 if hi >= d {
875 panic("duel: mulDiv: overflow")
876 }
877 q, _ := bits.Div64(hi, lo, d)
878 return q
879}
880
881// poolOf is 2 x stake, the whole pot of a duel, with the input bounded first
882// (SPEC-v1.md §7.9; commit 3622284's lesson).
883func poolOf(stake int64) int64 {
884 if stake <= 0 || stake > maxStake {
885 panic("duel: stake out of bounds")
886 }
887 return 2 * stake
888}
889
890// rakeOf is the rake on a pool: floor(pool x 300 / 10000).
891func rakeOf(pool int64) int64 {
892 if pool <= 0 || pool > 2*maxStake {
893 panic("duel: pool out of bounds")
894 }
895 return int64(mulDiv(uint64(pool), rakeBps, bpsDenom))
896}
897
898// splitRake divides a rake 60 / 40 into the raffle pot share and the ops share.
899// Ops is floored; THE POT TAKES THE REMAINDER AND ALL THE DUST, so
900// potShare + opsShare == rake exactly, always (BIBLE.md §2.7).
901//
902// THE ARITHMETIC IS UNCHANGED FROM THE OLD THREE-WAY SPLIT, and that is the
903// point. There, ops was floor(rake x 2000 / 10000) and prizes was the remainder
904// after yield and ops; so yield + prizes was already exactly rake - ops, dust
905// included. Folding yield into the pot share therefore moves no money between
906// the house and the players — it only sends one number where two used to go.
907//
908// Both checks below are REAL guards, not tautologies (see r/splash/game's Claim
909// for the history). The compile-time array above makes the negative case
910// unreachable today; they stay here so a runtime reader sees the property
911// asserted where the money moves, and so that a pot share quietly shorted below
912// its floor share would fail loudly rather than dribble into ops.
913func splitRake(rake int64) (potShare, opsShare int64) {
914 if rake < 0 {
915 panic("duel: negative rake")
916 }
917 opsShare = int64(mulDiv(uint64(rake), rakeOpsShareBps, bpsDenom))
918 potShare = rake - opsShare
919 if potShare < 0 {
920 panic("duel: rake split does not sum to the rake: pot share is negative")
921 }
922 if potShare < int64(mulDiv(uint64(rake), rakePotShareBps, bpsDenom)) {
923 panic("duel: rake split shorts the pot share")
924 }
925 return potShare, opsShare
926}
927
928// --- the raffle entries a duel earns (AUDIT-2026-09-28.md finding 15) -------
929//
930// THE DEFECT, IN THE OWNER'S WORDS: "so people could be gambling money on coinflip
931// all week and some asshole with a 1cent ticket can win the pot?" Yes. The rake fed
932// the week's raffle pot and playing bought no tickets, so a week of busy coin
933// flipping and no ticket sales was a large pot with a ticket total of zero — takeable
934// whole by one 0.1 GNOT ticket in its final block. The fix puts the players who paid
935// for the pot into the draw for it, which addresses the cause instead of gating the
936// symptom: no minimum, no refusal, no rule about when a ticket may be bought.
937
938// rakeEntryUnits is how many raffle entry units a rake of `rake` ugnot earns:
939// EXACTLY WHAT THAT MONEY WOULD HAVE BOUGHT AT raffle.EntryUnitPrice.
940//
941// IT WAS raffle.TicketPrice UNTIL 2026-09-29 AND THAT IS AUDIT-2026-09-28.md FINDING
942// 22 — the general lesson being that TWO INDEPENDENT PRODUCT PARAMETERS SHARING ONE
943// CONSTANT WILL EVENTUALLY BE GIVEN VALUES THAT CANCEL EACH OTHER OUT. The raffle's
944// ticket price went to 10 GNOT, and against a 10 GNOT divisor this ladder's rakes —
945// 0.6 / 3 / 12 / 60 GNOT — earn 0 / 0 / 1 / 6 entries. THREE OF THE FOUR RUNGS WOULD
946// HAVE EARNED NOTHING, silently, with the build green and every existing test that
947// asserted the RATE rather than a count still passing. The entry unit is now its own
948// constant at 0.25 GNOT, one ticket is 40 of them, and the rungs earn 2 / 12 / 48 /
949// 240. TestFix22_EveryRungEarnsAtLeastOneEntryAndTheCountsAreExact is the regression.
950//
951// THIS FUNCTION IS THE SECURITY PROPERTY OF THE WHOLE FEATURE AND THE REASON IT IS
952// PRICED OFF THE RAKE AND NEVER OFF THE STAKE.
953//
954// A POSTER MAY ADDRESS AN OFFER TO ONE OPPONENT (see Post), so two accounts
955// under one hand can flip each other for as long as they like and the ONLY real
956// cost of doing so is the rake — the stakes go round in a circle. Weight entries
957// by the stake or the pool and that is free money: a rung-0 duel is a 20 GNOT
958// pool on a 0.6 GNOT rake, so pool-weighting would pay 80 units of weight
959// (20 GNOT / EntryUnitPrice) for 2 units' worth of cost. ENTRIES AT A
960// THIRTY-THIRD OF FACE VALUE. Nobody would buy a ticket again and one funded
961// account would own every draw, for ever, on a contract with no owner and no
962// pause.
963//
964// PRICED OFF THE RAKE, FLIPPING IS NEVER CHEAPER THAN BUYING AND SELF-DEALING
965// GAINS NOTHING. The self-dealer's net cost of a rung-1 flip is the 3000000
966// ugnot rake and it earns 12 units: 250000 ugnot each, which is
967// raffle.EntryUnitPrice to the ugnot. It is in fact weakly WORSE than buying, on
968// two counts since 2026-09-29: only 60% of a rake reaches the pot against 95% of
969// a ticket price — the same price per unit, into a smaller prize — and at rung 0
970// the floor discards 0.4 of a unit, so that rung pays 300000 a unit, 1.2x face
971// value. BOTH ERRORS RUN THE SAFE WAY. duel_test.gno asserts that rate against
972// raffle.EntryUnitPrice rather than against a hardcoded 2, and
973// r/splash/arcade/raffle enforces the same ceiling at its door from the money it
974// actually receives (raffle.ArcadeEntryUnitsBound), so neither side has to trust
975// the other's arithmetic.
976//
977// THE FLOOR IS DELIBERATE: a rake that cannot buy one whole entry earns none. On
978// today's ladder rungs 1, 2 and 3 are exact multiples and RUNG 0 IS NOT — 600000 /
979// 250000 is 2.4, floored to 2 — so the floor now discards 0.4 of a unit at the bottom
980// rung where it used to discard nothing at any rung. That is a fractional unit of
981// weight the flipper paid for and does not get; it is the safe direction (it can only
982// ever make flipping dearer than buying, never cheaper) and
983// TestExtra_LadderAndRakeConstants pins the exact counts.
984//
985// AND MIND WHAT THE FLOOR DOES TO splitEntries AT RUNG 0 — IT ONCE MADE THAT RUNG
986// DEGENERATE AND THE OWNER HAS HAD IT FIXED. Two units split one-third / two-thirds is
987// 0 to the winner and 2 to the loser, so a rung-0 winner used to earn NO ENTRIES AT
988// ALL. THE CAUSE WAS THIS FLOOR MEETING A VERY SMALL TOTAL, NOT THE CONSOLATION
989// WEIGHTING: a third of 2 is 0 however you weight it. Since 2026-09-29 splitEntries
990// guarantees each player one entry whenever the total is at least 2, so rung 0 is 1 / 1
991// and every other rung is untouched. The TOTAL this function returns did not move by a
992// unit at any rung, which is the only quantity an attacker could care about. Read
993// splitEntries for the guarantee and its three boundaries.
994//
995// raffle.EntryUnitPrice IS A CONSTANT OF ANOTHER REALM AND THAT IS ON PURPOSE. It is a
996// compile-time const, not a call, so reading it adds no runtime dependency ON THE RAFFLE
997// to any path in this file — which matters most in checkInvariant, which runs at the end
998// of Collect and must never be able to fail for a reason IN THE RAFFLE (finding 14).
999// "in the raffle" and not "in another realm", corrected 2026-09-30: checkInvariant does
1000// read another realm, namely r/gnoland/wugnot's ledger for the escrow balance, and it
1001// always has. See checkInvariant, where that is now stated exactly.
1002//
1003// ============================================================================
1004// DISCLOSURE: WHAT AN EARNED ENTRY IS SETTLED BY, AND THE ONE KNOWN OPEN RISK IN IT
1005// ============================================================================
1006//
1007// READ THIS IF YOU PLAY THE DUEL. A coin flip here is decided by the two players' own
1008// commit-reveal (posterWins), with the caveats in this file's doc comment and NO DEPENDENCE
1009// ON A BEACON AT ALL since 2026-09-30. But the ENTRIES this function
1010// prices are entries in r/splash/arcade/raffle's WEEKLY DRAW, and that draw IS settled by a
1011// beacon round, and it has a
1012// known, open, recorded weakness of its own. It is disclosed here
1013//
1014// AND THE SENTENCE THIS REPLACES SAID THE OPPOSITE OF THE TRUTH, WHICH IS WHY IT IS QUOTED
1015// RATHER THAN DELETED: "a coin flip here is decided by a beacon round bound when the duel is
1016// accepted, AND THAT PART IS FINE". It was not fine. The binding's freshness signal was
1017// forgeable for about a tenth of a US cent, which is why the coin was replaced and why this
1018// disclosure now has a matching pair of caveats at the top of the file. A reassurance in a
1019// comment is worth exactly as much as the test behind it.
1020// because a duel player never has to read the raffle's source, and since finding 15
1021// duel players are the LARGEST CLAIMANT CLASS in that weekly pot — people who opted
1022// into a coin flip, not a raffle.
1023//
1024// THE RISK, IN ONE SENTENCE: anybody who can learn a week's winning randomness BEFORE
1025// that week's ticket sales close can buy last, size the buy so that
1026// randomness mod FrozenTotal lands on a unit they own, and take the pot — a pot that
1027// since finding 15 is mostly duel rake. This is AUDIT-2026-09-28.md finding 4, narrowed
1028// by finding 10 and recorded OPEN there.
1029//
1030// WHY EARNED ENTRIES DO NOT MAKE IT BETTER OR WORSE, BUT CHANGE WHO PAYS FOR IT. Earned
1031// entries raise a week's ticket total, and the attack's cost is set by the attacker's
1032// own last purchase against that total — so a bigger total costs them more tickets, but
1033// they were always going to buy the number of tickets the arithmetic needed. IT DOES
1034// NOT RAISE THE ATTACK'S COST IN ANY MEANINGFUL SENSE. What it changes is the loser:
1035// before, the pot was buyers' money and a rigged draw robbed raffle buyers; now the pot
1036// is mostly rake and a rigged draw robs duel players.
1037//
1038// THE FORM THE RISK TAKES TODAY, STATED ACCURATELY RATHER THAN AS IT WAS FIRST WRITTEN.
1039// Finding 4 was written when r/splash/beacon verified a key THIS PROJECT HELD, which
1040// made the operator able to compute any future round's randomness at will. THAT IS NO
1041// LONGER THE CASE and the entry in the audit file predates the change: the beacon now
1042// verifies drand evmnet under a DKG threshold group key that nobody here holds, so no
1043// single party can sign a future round and nobody can read a future round's randomness.
1044// The finding is nevertheless still recorded OPEN, and it is open for a narrower reason
1045// that is worth stating plainly:
1046//
1047// THE RAFFLE PINS ITS WEEK'S ROUND AGAINST AN ESTIMATE OF DRAND'S CURRENT ROUND,
1048// DERIVED FROM CHAIN HEIGHT (r/splash/beacon's EstimatedLatestRound). If this chain's
1049// blocks run slower than the 3.29998 s the estimate assumes, or if nobody has relayed
1050// a round recently, the pinned round drifts EARLIER relative to real time — and a
1051// round whose time has come is PUBLIC on api.drand.sh, readable by anybody, for free.
1052// Drift far enough and the week's winning randomness is readable before its sales
1053// close, which is exactly the last-buyer attack above with no key and no privilege
1054// required.
1055//
1056// IT IS BOUNDED, AND THE BOUND IS MEASURED RATHER THAN ASSUMED: beacon's
1057// maxEstimateStaleBlocks refuses to open a week against an anchor more than one day of
1058// blocks old, and AUDIT-2026-09-28.md finding 13 computes the remaining margin as ~24
1059// hours at nominal block time and ~7.2 hours at the slow edge of the stated ±10 % band,
1060// against ~2.4 hours of admitted estimation error. So it holds today at the measured
1061// constants. WHAT IT RESTS ON IS blocksPerDay = 26182 BEING RIGHT ABOUT THE REAL CHAIN,
1062// which finding 12 records as a DEPLOYMENT-BLOCKING MEASUREMENT the owner must make.
1063//
1064// NOTHING IN THIS REALM CLOSES IT AND NOTHING HERE SHOULD BE READ AS TRYING TO. It is
1065// the raffle's draw, it is the owner's decision (an externally-timed pin, or a VRF),
1066// and the only thing this comment does is make sure a duel player or a reviewer meets
1067// it here — because the money at risk is theirs. See Collect's doc for the pointer and
1068// AUDIT-2026-09-28.md findings 4, 10, 12, 13 and 20 for the full record.
1069func rakeEntryUnits(rake int64) int64 {
1070 if rake < 0 {
1071 panic("duel: negative rake")
1072 }
1073 return rake / raffle.EntryUnitPrice
1074}
1075
1076// splitEntries divides a duel's earned entries between its winner and its loser:
1077// EACH PLAYER IS GUARANTEED ONE ENTRY WHENEVER THE TOTAL CAN PAY FOR TWO, AND THE
1078// REMAINDER IS WEIGHTED TWO THIRDS TO THE LOSER.
1079//
1080// THE FLOOR OF ONE IS THE OWNER'S 2026-09-29 CHANGE AND IT IS THE ONLY THING THAT
1081// MOVED. Before it, the rule was the bare floored third — winner = total/3, loser the
1082// rest — and at the bottom rung that produced a DEGENERATE result: a 10 GNOT flip
1083// earns 2 units (600000 ugnot of rake over a 250000 ugnot entry unit, floored), a
1084// third of 2 floors to 0, AND THE WINNER OF A DUEL EARNED NOTHING. The weighting was
1085// never wrong; a floored share of a very small total is. So the small total is
1086// handled where it arises instead of by reweighting anything: one unit each first,
1087// then the old arithmetic on what is left.
1088//
1089// THE CONSOLATION WEIGHTING IS A PRODUCT DECISION AND IT COSTS NOTHING, which is
1090// why it is safe, and the same sentence is why the floor is safe. The TOTAL is fixed
1091// by rakeEntryUnits and is the only quantity an attacker could care about; how it is
1092// divided between two parties who may be the same person creates no edge whatever. A
1093// self-dealer holding both accounts gets the same total either way, which is exactly
1094// what the self-dealing test asserts. NOTHING BELOW RAISES A TOTAL BY ONE UNIT, so
1095// the money-backed ceiling this realm checks in maxEntryUnitsFor and the raffle
1096// checks again in ArcadeEntryUnitsBound is untouched at every rung.
1097//
1098// THE NUMBERS IT PRODUCES on today's ladder. ONLY RUNG 0 MOVED:
1099//
1100// rung 0: rake 600000 -> 2 units -> 1 winner / 1 loser <- WAS 0 / 2
1101// rung 1: rake 3000000 -> 12 units -> 4 winner / 8 loser
1102// rung 2: rake 12000000 -> 48 units -> 16 winner / 32 loser
1103// rung 3: rake 60000000 -> 240 units -> 80 winner / 160 loser
1104//
1105// Rungs 1, 2 and 3 are unchanged because their floored third is already at least one,
1106// so the floor never binds: max(1, total/3) == total/3 for every total >= 3. The
1107// floor can therefore only ever be visible at total 2, which is the one place the
1108// defect was. TestFix26_EachPlayerIsGuaranteedAnEntryAndTheRungCountsAreExact pins
1109// every row of the table above and pins the totals as unchanged.
1110//
1111// THE THREE BOUNDARIES, CHOSEN EXPLICITLY RATHER THAN FALLING OUT OF THE ARITHMETIC:
1112//
1113// total 0 -> 0 / 0. NEITHER PLAYER CAN GET ANYTHING AND THAT MUST NOT PANIC. It is
1114// what a sub-unit rake produces — a rake under raffle.EntryUnitPrice floors to no
1115// units at all — and it is reachable from Collect's `potShare > 0` branch on any
1116// ladder whose bottom rung is small enough. There is nothing to guarantee here: a
1117// guarantee of one entry each out of a total of zero would have to invent the
1118// entries, which is exactly the ceiling violation this whole feature is priced to
1119// prevent.
1120//
1121// total 1 -> 0 / 1, THE LOSER TAKES IT. One unit cannot be split two ways and
1122// something must decide; the loser gets it for consistency with the consolation
1123// intent that shapes the whole function, which is the same reason the remainder
1124// goes to the loser at every other total. Unreachable on today's ladder (the
1125// smallest rung earns 2) and therefore stated and tested rather than left to be
1126// discovered by a future rung.
1127//
1128// total >= 2 -> at least 1 each. This is the guarantee.
1129//
1130// THE WINNER'S SHARE IS THE FLOORED ONE AND THE LOSER TAKES THE REMAINDER, in the
1131// same spirit as splitRake giving the pot the dust: the asymmetry must never round
1132// against the party it is meant to favour, and the two shares must sum to the total
1133// EXACTLY or the ceiling the raffle enforces would be met by a total this realm did
1134// not intend. So loser >= winner always, and winner + loser == total for every total
1135// including 0, 1 and 2.
1136//
1137// ONE PREVIOUSLY TRUE CLAIM IS NOW FALSE AND IS RETIRED RATHER THAN QUIETLY DROPPED.
1138// This comment used to say "strictly greater for any total >= 1". AT TOTAL 2 THE TWO
1139// SHARES ARE NOW EQUAL, 1 and 1, which is the whole point of the change: a two-unit
1140// pot split "strictly in the loser's favour" is 0 and 2, and 0 is the bug. The
1141// invariant that survives — and that the raffle's ceiling and this realm's own
1142// checkInvariant actually rest on — is loser >= winner, which the panic below
1143// enforces. Strictness still holds for every total >= 3.
1144func splitEntries(total int64) (winnerUnits, loserUnits int64) {
1145 if total < 0 {
1146 panic("duel: negative entry total")
1147 }
1148 switch {
1149 case total == 0:
1150 // Nothing to divide. Not an error: see the boundary note above.
1151 winnerUnits, loserUnits = 0, 0
1152 case total == 1:
1153 // Indivisible. To the loser, deliberately.
1154 winnerUnits, loserUnits = 0, 1
1155 default:
1156 // THE GUARANTEE. One each, then the old weighting on the remainder — which
1157 // is what max(1, total/3) is: for every total >= 3 the floored third already
1158 // exceeds one and this line changes nothing at all.
1159 winnerUnits = total / 3
1160 if winnerUnits < 1 {
1161 winnerUnits = 1
1162 }
1163 loserUnits = total - winnerUnits
1164 }
1165 if winnerUnits+loserUnits != total {
1166 // A real check, not a tautology at the level that matters: it is the one
1167 // place the two shares and the total meet, and the raffle's ceiling is
1168 // stated against the total. IT IS ALSO THE LINE THAT CATCHES A FLOOR THAT
1169 // INVENTS AN ENTRY — the one way this function could ever break the ceiling
1170 // is by returning a pair that sums to more than it was given.
1171 panic("duel: entry split does not sum to the total")
1172 }
1173 if loserUnits < winnerUnits {
1174 panic("duel: entry split shorts the loser")
1175 }
1176 if total >= 2 && (winnerUnits < 1 || loserUnits < 1) {
1177 // THE GUARANTEE, ASSERTED AT THE ONE PLACE IT IS PRODUCED. A total of two or
1178 // more can pay both players, so leaving either on zero is the defect this
1179 // change exists to remove, and a future edit to the arithmetic above should
1180 // fail here rather than ship a duel whose winner earns nothing.
1181 panic("duel: entry split leaves a player with no entry out of a total that can pay both")
1182 }
1183 return winnerUnits, loserUnits
1184}
1185
1186// maxEntryUnitsFor is the most booked entry units a booked pot share of `potShare`
1187// ugnot may ever carry — the SAME ceiling r/splash/arcade/raffle applies at its
1188// door, computed here from THIS realm's own constants so that checkInvariant can
1189// assert it without calling another realm.
1190//
1191// potShare >= rake * rakePotShareBps / bpsDenom, so rake <= potShare * bpsDenom /
1192// rakePotShareBps, and an entry costs raffle.EntryUnitPrice of rake. It is an
1193// inequality and not an equality because a party who played from realm code earns
1194// nothing (see Collect) and because the floor in rakeEntryUnits can discard a
1195// remainder — which since 2026-09-29 it actually does at rung 0 — so this realm may
1196// always book FEWER entries than the ceiling, never more.
1197//
1198// BOTH OF ITS TWO CONSTANTS MOVED ON 2026-09-29 and both had to: the gross-up is now
1199// 10000/6000 (finding 24's rake split) and the divisor is now the ENTRY UNIT and not
1200// the ticket price (finding 22). Getting either wrong in isolation is visible — too
1201// small a ceiling refuses honest batches here in checkInvariant, too large a one lets
1202// the raffle's own door refuse them — and
1203// TestFix15_TheRafflesEntryBoundIsExactlyThisRealmsRakeEquivalent pins this function
1204// against the raffle's copy to the entry, at every rung.
1205func maxEntryUnitsFor(potShare int64) int64 {
1206 if potShare <= 0 {
1207 return 0
1208 }
1209 gross := int64(mulDiv(uint64(potShare), bpsDenom, rakePotShareBps))
1210 return gross / raffle.EntryUnitPrice
1211}
1212
1213// --- the coin: one commitment, one plaintext nonce -------------------------
1214
1215// hexLen32 is how long a 32-byte value is in hex: 64 characters. The secret, the nonce
1216// and the commitment hash are all exactly this long and all lowercase hex, checked at
1217// BOTH ENDS — checkCommitment and checkNonce at the doors, checkSecret again at Reveal.
1218//
1219// IT IS THE SAME 64 randomnessHexLen USED TO BE, AND THE NAME CHANGED ON PURPOSE. That
1220// constant was "the length of a beacon randomness string (as in raffle.gno)", and this
1221// realm reads no beacon; a length named after a thing it no longer touches is how a
1222// stale premise survives a change. The number is unchanged because sha256 is unchanged.
1223const hexLen32 = 64
1224
1225// THE TWO DOMAIN TAGS, AND THEY MUST DIFFER. commitTag seals the poster's secret;
1226// outcomeTag turns it plus the taker's nonce into a coin. If one string could be hashed
1227// under both tags then a commitment would BE an outcome — a poster could publish, as
1228// their commitment, the very digest whose parity they want, and the two uses of sha256
1229// in this file would stop being independent functions.
1230//
1231// EQUAL LENGTH AND DIFFERENT CONTENT, deliberately: equal so that neither tag can ever be
1232// a prefix of the other followed by the start of a field, different so that domain
1233// separation is real. Both are compile-time constants of this file and are reachable by no
1234// caller, so nothing an attacker supplies can move the boundary between tag and payload.
1235//
1236// THE EQUAL LENGTH IS A BUILD GUARD AND THE DIFFERENCE IS AN init GUARD, and the split is
1237// forced rather than chosen: an array length must be an integer constant expression, and a
1238// string comparison is not one on this toolchain. So the length relation — the one that IS
1239// integer arithmetic over two independent constants — fails the BUILD, and the inequality
1240// fails the DEPLOYMENT, which this file already establishes at the ladder as the next best
1241// thing (see init). Both can fail: neither constant cancels, and they were chosen
1242// independently.
1243const (
1244 commitTag = "splash/duel/v1/commit:"
1245 outcomeTag = "splash/duel/v1/result:"
1246)
1247
1248var _ [len(commitTag) - len(outcomeTag)]struct{}
1249
1250// isLowerHex32 reports whether s is exactly 64 characters of LOWERCASE hex.
1251//
1252// LOWERCASE IS REQUIRED AND IS NOT FUSSINESS: it makes the encoding of a 32-byte value
1253// CANONICAL, so one secret has one spelling. Admitting both cases would mean a secret has
1254// up to 2^64 spellings, each hashing to a different commitment — harmless for the
1255// commitment (any one of them is a fine commitment) but it turns "the length is checked, so
1256// the opening is unique" into an argument a reader has to redo. One spelling, one check.
1257func isLowerHex32(s string) bool {
1258 if len(s) != hexLen32 {
1259 return false
1260 }
1261 for i := 0; i < len(s); i++ {
1262 c := s[i]
1263 if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') {
1264 continue
1265 }
1266 return false
1267 }
1268 return true
1269}
1270
1271// checkCommitment validates a commitHash at Post: 64 lowercase hex characters, no
1272// exceptions and no empty string. "" is reserved for LEGACY records (Offer.CommitHash) and
1273// must never be creatable, or a poster could post a duel that nobody can ever reveal and
1274// that Forfeit deliberately refuses to settle.
1275func checkCommitment(commitHash string) {
1276 if commitHash == "" {
1277 panic("duel: a commitment is required — post H(commitTag ‖ secret) as 64 lowercase hex characters")
1278 }
1279 if !isLowerHex32(commitHash) {
1280 panic("duel: the commitment must be exactly 64 lowercase hex characters (sha256, hex-encoded)")
1281 }
1282}
1283
1284// checkNonce validates the taker's nonce at Accept: 64 lowercase hex characters, i.e.
1285// exactly 32 bytes. Checked HERE and not only at settlement because the nonce is half the
1286// coin: a short or empty one would shrink the taker's contribution to the outcome, and a
1287// zero-length nonce would make every duel a function of the secret alone — which the
1288// poster knows.
1289func checkNonce(nonce string) {
1290 if !isLowerHex32(nonce) {
1291 panic("duel: the nonce must be exactly 64 lowercase hex characters (32 bytes)")
1292 }
1293}
1294
1295// checkSecret validates a revealed secret: 64 lowercase hex characters.
1296//
1297// THIS IS THE OTHER END OF THE LENGTH CHECK AND IT IS THE LOAD-BEARING ONE — REQUIREMENT 1
1298// OF THIS CHANGE, AND THE ONE THAT KILLED AN EARLIER PROPOSAL OUTRIGHT. The rejected shape
1299// was commitHash = H(secret ‖ salt) with both fields variable length. Two variable-length
1300// fields concatenated have NO UNIQUE SPLIT: a preimage of length L admits L+1 different
1301// (secret, salt) readings, every one of which passes the hash check, and the poster simply
1302// announces whichever reading wins. Not a bias — CERTAINTY, for free, with no grinding and
1303// no collision.
1304//
1305// ONE FIELD, FIXED LENGTH, ENFORCED AT BOTH ENDS is what removes it: the preimage of
1306// commitTag ‖ secret is commitTag's 22 known bytes followed by exactly 64 more, so there
1307// is exactly one place it can be cut and exactly one secret that opens a commitment.
1308// posterWins' own comment has warned about this since the beacon version ("without it the
1309// split point would be attackable") and it is now enforced rather than remarked on.
1310func checkSecret(secret string) {
1311 if !isLowerHex32(secret) {
1312 panic("duel: the secret must be exactly 64 lowercase hex characters (32 bytes)")
1313 }
1314}
1315
1316// commitmentOf is H(commitTag ‖ secret), hex-encoded lowercase: what a poster publishes at
1317// Post and what Reveal recomputes. The secret is validated first, so this function is never
1318// given a variable-length preimage (checkSecret).
1319func commitmentOf(secret string) string {
1320 checkSecret(secret)
1321 sum := sha256.Sum256([]byte(commitTag + secret))
1322 return hex.EncodeToString(sum[:])
1323}
1324
1325// posterWins is the whole winner rule, in one place (SPEC-03 §5):
1326//
1327// h = sha256(outcomeTag ‖ secret ‖ nonce ‖ duelID)
1328// winner = Poster if h is even else Taker
1329//
1330// "h is even" is the parity of h read as a big-endian integer, i.e. of its last byte —
1331// unchanged from the beacon version, which read exactly the same bit of exactly the same
1332// hash function. WHAT CHANGED IS ONLY WHERE THE UNPREDICTABILITY COMES FROM: it was one
1333// value published by a third party (a drand round) and is now TWO values contributed by
1334// the two people with money on the table.
1335//
1336// WHY NEITHER PARTY CAN CHOOSE THE ANSWER, which is the entire security of this realm:
1337//
1338// THE POSTER CANNOT GRIND. They fix secret at Post, when `nonce` does not exist and
1339// `duelID` has not been assigned. Every candidate secret they might try is a commitment
1340// to an outcome they cannot evaluate, because two of the three inputs are still in the
1341// future. Grinding requires being able to score a candidate, and there is nothing to
1342// score against.
1343//
1344// THE TAKER CANNOT STEER. They choose `nonce` freely, with `duelID` and `commitHash`
1345// both known — and without `secret`. Knowing H(commitTag ‖ secret) tells them nothing
1346// about H(outcomeTag ‖ secret ‖ nonce ‖ duelID) short of inverting sha256, so every
1347// nonce they could pick is a coin whose face they cannot see. Choosing among coins you
1348// cannot evaluate is not choosing.
1349//
1350// AND NEITHER CAN CHANGE THEIR MIND. The poster is bound by collision resistance (one
1351// secret opens one commitment — checkSecret) and the taker's nonce is written into the
1352// record at Accept, before any secret exists on chain.
1353//
1354// MIXING IN THE DUEL ID IS STILL MANDATORY (SPEC-v1.md §7.2), for a reason that survived
1355// the change of source: a poster may stand several offers at once, and without the ID two
1356// duels that happened to share a secret and a nonce would share an outcome. The
1357// concatenation is UNAMBIGUOUS because secret and nonce are each exactly hexLen32 bytes and
1358// the only variable-length field, the decimal ID, is LAST — nothing follows it, so nothing
1359// can be shifted into it.
1360//
1361// Factored out, as before, so the tests can derive a duel's outcome independently of
1362// Reveal — and so that the poster-cannot-grind and taker-cannot-steer tests can drive the
1363// function directly over thousands of candidates instead of asserting a property in a
1364// comment.
1365func posterWins(secret, nonce string, duelID int64) bool {
1366 checkSecret(secret)
1367 checkNonce(nonce)
1368 sum := sha256.Sum256([]byte(outcomeTag + secret + nonce + strconv.FormatInt(duelID, 10)))
1369 return sum[len(sum)-1]%2 == 0
1370}
1371
1372// commitKey is liveCommitments' key: the poster and their commitment, joined by a
1373// separator that cannot occur in either half. An address is bech32 (no "|") and a
1374// commitment is lowercase hex (no "|"), so the two halves cannot be confused for one
1375// another whatever the lengths — the same unique-split discipline as the hash preimages
1376// above, applied to a tree key.
1377func commitKey(poster address, commitHash string) string {
1378 return string(poster) + "|" + commitHash
1379}
1380
1381// requireFreshCommitment refuses a (poster, commitHash) pair that a live record already
1382// holds. CHECKED BEFORE THE STAKE IS PULLED, so a refused Post moves nothing.
1383func requireFreshCommitment(poster address, commitHash string) {
1384 if liveCommitments.Has(commitKey(poster, commitHash)) {
1385 panic("duel: you already have a live offer or duel under this commitment — " +
1386 "reusing one secret across records lets the taker of the second steer it once the first is revealed; " +
1387 "draw a fresh 32 bytes from a CSPRNG")
1388 }
1389}
1390
1391// holdCommitment records the pair. WRITTEN AFTER THE PULL, BESIDE THE RECORD IT BELONGS TO,
1392// and the split from the check above is deliberate: a write made before a pull that then
1393// fails is discarded on chain (the panic aborts the transaction) but is KEPT under `gno
1394// test`, where uassert's Aborts* helpers catch the abort with revive() and "state mutations
1395// remain even after revive()" — see duel_test.gno's F2 note. So a check-then-write pair
1396// straddling the pull would leave this suite with a held commitment for an offer that does
1397// not exist, and AuditFailure would rightly refuse the books. Checking early and writing late
1398// gives the same refusal with no window in which the index can disagree with the records.
1399func holdCommitment(poster address, commitHash string) {
1400 liveCommitments.Set(commitKey(poster, commitHash), true)
1401}
1402
1403// releaseCommitment frees a pair when its record dies. Silent on a pair that is not held:
1404// a legacy record never held one, and a release that finds nothing must not be able to
1405// stop an exit (see liveCommitments on why a leak and an over-eager release are both
1406// harmless).
1407func releaseCommitment(poster address, commitHash string) {
1408 if commitHash == "" {
1409 return
1410 }
1411 liveCommitments.Remove(commitKey(poster, commitHash))
1412}
1413
1414// checkLabel is the ONLY validation a label gets, and it runs before any
1415// pull. Bounded in bytes, valid UTF-8, no control characters (which rules out
1416// newlines and carriage returns, so the stored text is always one printable
1417// line). Everything else — markdown, HTML, pipes — is legal to STORE and is
1418// neutralised at render time by renderLabel, the same discipline every
1419// address in this realm already goes through.
1420func checkLabel(label string) {
1421 if len(label) > maxLabelLen {
1422 panic("duel: label too long")
1423 }
1424 if !utf8.ValidString(label) {
1425 panic("duel: label is not valid UTF-8")
1426 }
1427 for i := 0; i < len(label); i++ {
1428 if label[i] < 0x20 || label[i] == 0x7f {
1429 panic("duel: label contains a control character")
1430 }
1431 }
1432}
1433
1434// checkInvariant is the O(1) solvency check run at the end of every
1435// state-changing function: the realm must hold at least what it has in
1436// escrow. ">=" and not "==", deliberately — wugnot.Transfer is permissionless,
1437// so anyone can push wugnot at this address, and an exact check would let a
1438// one-ugnot donation brick every duel. Such a donation is simply stranded:
1439// SPEC-03 §4 forbids any function that pays anyone other than a duel's own
1440// winner, so there is no sweep and no admin drain by design.
1441// pendingPot IS PART OF WHAT THIS REALM OWES, and it is checked here for the same reason
1442// escrowed is: a booked rake this realm does not hold is money promised to the raffle out
1443// of players' stakes. Still ">=" and not "==" — a donation is still stranded, by design.
1444// THE BOOKED ENTRIES ARE CHECKED HERE TOO, AND THE RATE CHECK IS THE IMPORTANT ONE.
1445// Entries are not money, so they are not part of what the realm holds — but the rate
1446// at which this realm hands them out IS the security property of finding 15, and an
1447// O(1) statement of it belongs on every mutator's exit. If a future edit ever books
1448// entries worth more than the rake that paid for them, this is the line that fails,
1449// and it fails BEFORE the raffle's own door would refuse the batch — which matters,
1450// because the raffle refusing means a stuck flush and this means a refused Collect
1451// with nothing written.
1452//
1453// IT CANNOT FAIL FOR A REASON IN r/splash/arcade/raffle — AND THAT, NOT "no other realm",
1454// IS THE PROPERTY FINDING 14 ACTUALLY NEEDS. Until 2026-09-30 these lines claimed this
1455// function "reads no other realm at runtime … so it cannot fail for a reason outside this
1456// file", AND THAT WAS FALSE ON ITS FACE: the last check below calls
1457// wugnot.BalanceOf(selfAddr()), a live cross-realm read of r/gnoland/wugnot's ledger. The
1458// claim is corrected rather than deleted because the narrower one is true and is the one
1459// finding 14 rests on:
1460//
1461// NO LINE OF RAFFLE CODE RUNS ON THIS PATH AND NO RAFFLE STATE IS READ.
1462// raffle.EntryUnitPrice is a compile-time const, not a call, and maxEntryUnitsFor is
1463// this realm's own arithmetic over this realm's own constants. Finding 14 is about a
1464// panic in the RAFFLE taking a winner's payout down with it — Collect used to end in
1465// the raffle's own checkInvariant — and that is what this independence buys.
1466//
1467// THE wugnot READ IS NOT A DEPENDENCY THIS FUNCTION COULD BE WITHOUT, which is why it is
1468// disclosed rather than removed: the escrow being checked IS a wugnot balance, so there is
1469// no version of this check that does not read the token. It is also not a new exposure on
1470// any mutator's exit — every path that reaches here has already moved wugnot in the same
1471// transaction (Post pulls, Collect pays), so a token that refused to answer would have
1472// aborted before this line. AUDIT-duel-2026-09-27.md addendum 2, finding D-3.
1473func checkInvariant() {
1474 if escrowed < 0 {
1475 panic("duel: escrow negative")
1476 }
1477 if pendingPot < 0 {
1478 panic("duel: pending rake negative")
1479 }
1480 if pendingEntryUnits < 0 {
1481 panic("duel: booked raffle entries negative")
1482 }
1483 if pendingEntryUnits > 0 && pendingPot <= 0 {
1484 // Entries with no rake behind them would be entries this realm cannot pay
1485 // for: the raffle prices its ceiling off the money delivered in the same
1486 // call, so a batch in this state could never be delivered at all.
1487 panic("duel: raffle entries are booked with no rake behind them")
1488 }
1489 if pendingEntryUnits > maxEntryUnitsFor(pendingPot) {
1490 panic("duel: booked raffle entries exceed what the booked rake would buy at the ticket price")
1491 }
1492 if pendingEntries.Size() > maxPendingEntryHolders {
1493 panic("duel: the pending entry batch is over its bound")
1494 }
1495 // THE LIVE COMMITMENT SET CANNOT HOLD MORE PAIRS THAN THERE ARE RECORDS THAT COULD HOLD
1496 // ONE, which is the O(1) half of the re-derivation AuditFailure does in full. Offers and
1497 // duels each hold at most one pair, so the set can never be larger than their sum; a set
1498 // that grew past it would mean a release was missed, and a missed release quietly refuses
1499 // an honest poster's future Post. Cheap, and it can fail.
1500 if int64(liveCommitments.Size()) > int64(offers.Size())+int64(duels.Size()) {
1501 panic("duel: more live commitments are held than there are records to hold them")
1502 }
1503 if wugnot.BalanceOf(selfAddr()) < escrowed+pendingPot {
1504 panic("duel: escrow invariant violated — the realm holds less than it owes")
1505 }
1506}
1507
1508// --- API (SPEC-03 §4) -----------------------------------------------------
1509
1510// requireUserCall refuses a Post or an Accept that did not come DIRECTLY FROM A USER.
1511// It is the only gate in this file that turns a caller away for what it IS rather than
1512// for what it owns or owes, and it was added on 2026-09-30 by
1513// AUDIT-duel-2026-09-27.md addendum 2, finding D-1.
1514//
1515// THIS IS THE RAFFLE'S FINDING-8 REASONING APPLIED TO THIS REALM'S OWN ESCROW, RATHER
1516// THAN ONLY TO ITS ENTRIES, AND THAT IS THE WHOLE POINT OF THE CHANGE. r/splash/arcade/raffle
1517// refuses realms outright in Buy because a ticket is a claim on an OUTBOUND TRANSFER and a
1518// realm's address is PACKAGE-DERIVED: nothing can move wugnot out of it unless that realm
1519// happens to contain code that does, so a realm that won a week would strand the whole pot
1520// in an address nobody can spend from. This realm imported that reasoning in 2026-09-29's
1521// finding 15 and applied it to EXACTLY ONE THING — whether a party earns raffle entries
1522// (PosterIsUser, TakerIsUser, bookEntries) — while leaving its own escrow wide open:
1523//
1524// A REALM COULD POST, ACCEPT, WIN AND COLLECT. Collect is WINNER-ONLY, and VoidStale
1525// refuses a resolved duel. So a winning realm that contains no code calling Collect
1526// left 2 x stake sitting in this realm's escrow WITH NO EXIT AT ALL — not a delayed
1527// exit, not a dear exit, none. Every path out of a resolved duel's pool goes through
1528// Collect, and only the winner may walk it.
1529//
1530// AND THE BOOKS STAYED GREEN OVER IT, which is what made it invisible. The duel record is
1531// still there and still counted, so `escrowed` still matches it, AuditFailure still returns
1532// "" and checkInvariant still passes. The money is accounted for exactly; it is simply
1533// unreachable. That is the "money in, no way out" shape SIX of this product's findings have
1534// had, and this one was the seventh.
1535//
1536// WHY BOTH DOORS AND NOT ONLY Accept — THE ASYMMETRY THAT MAKES A ONE-SIDED GATE USELESS.
1537// Gating Accept alone leaves the identical defect reachable from the other side, because
1538// the losing party's identity is decided by a coin flip AFTER both stakes are escrowed:
1539//
1540// realm posts, user accepts, THE REALM WINS -> Collect is the realm's alone to call,
1541// and the pool is stranded. The user's stake is stranded with it.
1542//
1543// A one-sided gate would therefore have halved the probability of the defect and fixed
1544// none of it — and it would have stranded the money of the party this realm CAN vouch for,
1545// which is worse than the symmetric case. Both parties must be addresses that can call
1546// Collect, so both doors that make a party are gated.
1547//
1548// AND NEITHER OF THE OTHER TWO EXITS IS A WAY OUT OF IT, RE-CHECKED AGAINST THE COMMIT-REVEAL
1549// COIN ON 2026-09-30 BECAUSE THE OLD SENTENCE'S PREMISE IS GONE. It read "VoidStale is not a
1550// way out either: it refuses a resolved duel, and a duel whose round is published cannot be
1551// voided at all", and there are no rounds now. The argument survives in a different shape and
1552// is stronger, not weaker:
1553//
1554// VoidStale now refuses EVERY duel that carries a commitment, so it cannot release a
1555// realm's pool at all — not before the window, not after it, not ever.
1556//
1557// Forfeit releases a pool without the winner's cooperation, but only while the duel is
1558// UNREVEALED, and it pays THE TAKER. So it rescues exactly one of the two stranded cases
1559// (a realm poster who never reveals) and not the case the gate is for: once the coin is
1560// revealed and a REALM is the winner, Collect is the realm's alone to call and Forfeit
1561// refuses a revealed duel. The gate is still the only thing standing between this realm
1562// and a pool nothing in the universe can close.
1563//
1564// IsUserCall AND NOT IsUser, for the reason raffle.Buy states at length: IsUser also admits
1565// a `maketx run` ephemeral realm, which is code executing under a user's signature and not
1566// an EOA — its address is package-derived too, and it ceases to exist when the transaction
1567// ends. The predicate is about THE CALL, not the address, and it is only answerable while
1568// the call is on the stack.
1569//
1570// WHAT IS LOST, STATED PLAINLY BECAUSE IT IS A REAL CAPABILITY REMOVAL. No realm can play a
1571// duel any more: no vault, no strategy contract, no "flip on my behalf" wrapper, not even
1572// one that does contain a Collect call. This realm cannot tell those apart from the ones
1573// that do not — the raffle's Buy comment makes the same point ("this realm cannot tell which
1574// realms those are and must not try") — so it refuses them all at the door rather than
1575// stranding a pool at settlement time. THE ALTERNATIVE WAS NOT "realms can play safely", it
1576// was "a realm can create an escrow position that nothing in the universe can close", and
1577// that is not a trade. A realm that wants exposure can hold wugnot and let its user play.
1578//
1579// FUNCTIONS THIS DOES NOT GATE, AND WHY EACH IS RIGHT TO BE OPEN.
1580//
1581// Cancel and Collect are restricted to a party of the record, and after this change every
1582// such party is an address whose own call was a user call — so gating them again would be
1583// redundant on every reachable record and would BRICK any record created before this
1584// change if one existed. (Cancel has one permissionless case, a LEGACY offer, and that is
1585// the whole reason it is permissionless: a pre-D-1 poster may be a realm that cannot call
1586// anything, and the refund's destination is read from the record.)
1587//
1588// Reveal, Forfeit, VoidStale, FlushPot and SweepStuckRake are permissionless keeper work
1589// that pays nobody the caller names. Refusing realms there would remove the bystanders the
1590// whole design relies on — and for Reveal it would be worse than useless, because the hash
1591// IS the authorisation and a caller check would turn the secret into an off-chain
1592// credential held by a de-facto third party to the duel. Read Reveal.
1593func requireUserCall(cur realm, what string) {
1594 if !cur.Previous().IsUserCall() {
1595 panic("duel: " + what + " must be called directly by a user — a realm cannot be a party to a duel, because Collect is winner-only and a package-derived address may have no way to call it")
1596 }
1597}
1598
1599// rejectAttachedCoin panics if the caller attached native coin to a call that
1600// has no use for it. Every money-in path in this realm is the wugnot GRC20
1601// allowance (Post, Accept), never the transaction's send envelope, so any
1602// attached ugnot would simply sit in this realm forever with no code path
1603// that ever looks at it — AUDIT-2026-10-07.md Y1. Call this as the first
1604// statement of every crossing entrypoint so the transaction reverts and the
1605// sender keeps their coin instead.
1606//
1607// unsafe.OriginSend() reads the envelope attached at the CHAIN ROOT, and that
1608// envelope is shared by every realm in the call chain: an intermediate realm
1609// can consume it, or have already consumed it, before this call is ever
1610// reached. Reading it is only sound when the immediate caller is an EOA, so
1611// that whatever it describes was paid to THIS realm and nothing else first.
1612// cur.Previous().IsUserCall() is that check (IsUser() is not — it also
1613// accepts `maketx run` ephemeral realms, which can pre-consume the envelope);
1614// see r/splash/exchange/exchange.gno's Buy/Seed comment for the fuller
1615// argument, which this mirrors rather than contradicts. WHEN THE CALL DID NOT
1616// COME DIRECTLY FROM A USER — which, per requireUserCall's comment just
1617// above, is a deliberately open door on Cancel/Collect (legacy case),
1618// Reveal, Forfeit, VoidStale, FlushPot and SweepStuckRake — THIS FUNCTION
1619// DOES NOT LOOK AT THE ENVELOPE AT ALL: it cannot be attributed to this call
1620// either way, so there is nothing sound to enforce, and guessing would be
1621// worse than staying silent. It does not reopen any of those doors; it is
1622// simply inert on them.
1623//
1624// THE OVERWHELMINGLY COMMON CASE — a plain call with nothing attached — is
1625// cheap: one stack-walk for IsUserCall, then unsafe.OriginSend() always
1626// allocates (even a len-0 slice) and len(sent) == 0 is a single comparison.
1627// No iteration, no second cross-realm call.
1628func rejectAttachedCoin(cur realm, what string) {
1629 if !cur.Previous().IsUserCall() {
1630 return
1631 }
1632 if sent := unsafe.OriginSend(); len(sent) != 0 {
1633 panic("duel: " + what + " does not take native coin — pay through the wugnot allowance instead")
1634 }
1635}
1636
1637// Post pulls rungs[rung] from the caller through the GRC20 allowance path and
1638// leaves it standing in the lobby. Returns the new offer's ID.
1639//
1640// opponent, when non-zero, addresses the offer to one player: only that
1641// address may Accept it. Zero ("") leaves it open to anyone, and the offer
1642// then behaves exactly as it always did. A non-zero opponent must be a valid
1643// address, or the stake would stand for a player who can never exist and the
1644// poster's only way out would be Cancel.
1645//
1646// label is free text for the render, at most maxLabelLen bytes (checkLabel).
1647// "" is no label. Neither argument touches settlement: the coin still decides.
1648//
1649// commitHash IS THE POSTER'S HALF OF THE COIN AND IS NOW MANDATORY (changed 2026-09-30).
1650// It is H(commitTag ‖ secret) as 64 lowercase hex characters, for a secret of exactly 32
1651// bytes that the poster keeps and opens at Reveal. There is no salt and no second field:
1652// read checkSecret for why one fixed-length field is the requirement and not a preference.
1653//
1654// THE SECRET MUST COME FROM A CSPRNG AND MUST BE FRESH FOR THIS OFFER. commitHash is a
1655// free offline oracle — anybody can test candidate secrets against it forever, at no
1656// cost — so a guessable secret is robbed SILENTLY, by a reveal this realm cannot tell
1657// from an honest one. There is no on-chain entropy check and there cannot be one. The
1658// full statement is item (1) of this file's doc comment; liveCommitments refuses the one
1659// reuse case a contract can see.
1660//
1661// THE CALLER MUST BE A USER, NOT A REALM, SINCE 2026-09-30 (requireUserCall, finding
1662// D-1). A realm poster that WINS cannot call the winner-only Collect, which would leave
1663// the pool — its own stake AND its opponent's — in escrow with no exit whatever.
1664//
1665// EVERY CHECK RUNS BEFORE THE PULL, so a failed Post moves nothing and creates
1666// nothing (SPEC-03 §8 tests 1 and 2) — and that now includes the commitment's shape and
1667// its uniqueness, so a rejected commitment never costs a stake.
1668func Post(cur realm, rung int64, opponent address, label string, commitHash string) int64 {
1669 rejectAttachedCoin(cur, "Post")
1670 // A PARTY TO A DUEL MUST BE AN ADDRESS THAT CAN CALL Collect. See requireUserCall:
1671 // this is the raffle's finding-8 reasoning applied to THIS REALM'S OWN ESCROW, and
1672 // Post needs it for the same reason Accept does — a realm poster who WINS cannot be
1673 // paid, and the stake it strands is its opponent's as much as its own.
1674 requireUserCall(cur, "Post")
1675
1676 stake := stakeOf(rung)
1677 if opponent != "" && !opponent.IsValid() {
1678 panic("duel: invalid opponent address")
1679 }
1680 checkLabel(label)
1681 checkCommitment(commitHash)
1682 caller := cur.Previous().Address()
1683 if wugnot.Allowance(caller, selfAddr()) < stake {
1684 panic("duel: insufficient allowance — approve this realm for the rung's stake first")
1685 }
1686
1687 // BEFORE THE PULL, like every other check here: a reused commitment must cost the
1688 // poster a reverted transaction and not a stake standing in the lobby. The matching
1689 // WRITE is after the pull, beside the record — see holdCommitment for why the pair is
1690 // split around it.
1691 requireFreshCommitment(caller, commitHash)
1692
1693 wugnot.TransferFrom(cross(cur), caller, selfAddr(), stake)
1694
1695 id := nextOfferID
1696 nextOfferID++
1697 holdCommitment(caller, commitHash)
1698 offers.Set(idKey(id), &Offer{
1699 Poster: caller,
1700 Rung: rung,
1701 PostedAt: runtime.ChainHeight(),
1702 Opponent: opponent,
1703 Label: label,
1704 CommitHash: commitHash,
1705 // CAPTURED HERE BECAUSE IT IS ONLY KNOWABLE HERE. Necessarily true since
1706 // 2026-09-30 — requireUserCall above has already refused every other case — and
1707 // recorded rather than assumed. See Offer.PosterIsUser for why it is kept.
1708 PosterIsUser: cur.Previous().IsUserCall(),
1709 })
1710 escrowed += stake
1711
1712 chain.Emit("DuelPost",
1713 "offer", strconv.FormatInt(id, 10),
1714 "poster", string(caller),
1715 "rung", strconv.FormatInt(rung, 10),
1716 "stake", strconv.FormatInt(stake, 10),
1717 "opponent", string(opponent),
1718 "label", label,
1719 // THE COMMITMENT IS IN THE LOG BECAUSE THAT IS WHAT MAKES THE FLIP AUDITABLE FROM
1720 // OUTSIDE. A reader with the block log alone has the commitment from here, the nonce
1721 // from DuelAccept and the secret from DuelReveal, which is every input posterWins
1722 // takes — so anybody can re-derive any settled duel's winner without reading state.
1723 "commit", commitHash)
1724 checkInvariant()
1725 return id
1726}
1727
1728// Cancel returns an unaccepted offer's stake to its poster, EXACTLY and with no
1729// fee, and deletes the record. Poster only.
1730//
1731// An accepted offer no longer exists — Accept deletes it in the same
1732// transaction that creates the duel — so "Cancel of an accepted offer" and
1733// "Cancel of an unknown offer" are the same refusal. That is what makes the
1734// Cancel-racing-Accept rule (SPEC-03 §7.5) hold by construction: whichever
1735// lands first in block order removes the offer, and the other finds nothing.
1736//
1737// AND THAT RACE IS NO LONGER HARMLESS, WHICH IS DISCLOSED HERE RATHER THAN ONLY IN THE
1738// FILE HEADER. Under the beacon the poster could not predict the outcome, so cancelling
1739// under a pending Accept gained them nothing. Since 2026-09-30 the taker's nonce is
1740// plaintext in the Accept transaction, so a poster watching the mempool CAN compute the
1741// outcome and cancel the ones they are about to lose. It is item (2) of this file's doc
1742// comment, it is OPEN, and removing Cancel would not close it — the poster can take their
1743// own offer instead, for the rake. It is recorded at the door it is reached through.
1744//
1745// PERMISSIONLESS FOR A LEGACY OFFER, AND ONLY FOR ONE. An offer with no commitment
1746// (Offer.CommitHash == "") can never be accepted — Accept refuses it — so the poster's
1747// stake would otherwise sit in the lobby until the poster themselves turned up, and a
1748// pre-D-1 poster may be a REALM that contains no code able to call this function. So for
1749// that one case any caller may cancel, and it is not a way to steal: the refund's
1750// destination is READ FROM THE RECORD and is the poster either way. Nothing is deployed, so
1751// no such offer exists; this is requirement 5's "the code must not contain a trap".
1752func Cancel(cur realm, offerID int64) {
1753 rejectAttachedCoin(cur, "Cancel")
1754 o := getOffer(offerID)
1755 if o == nil {
1756 panic("duel: no such offer")
1757 }
1758 caller := cur.Previous().Address()
1759 if caller != o.Poster && o.CommitHash != "" {
1760 panic("duel: only the poster may cancel")
1761 }
1762 stake := stakeOf(o.Rung)
1763
1764 // Delete BEFORE paying, so no path exists on which the offer is both
1765 // refunded and still standing. The commitment is released with the record it belonged
1766 // to, so the poster may stand a new offer under the same secret — which they should not
1767 // do, but which is theirs to decide once nothing live depends on it.
1768 offers.Remove(idKey(offerID))
1769 releaseCommitment(o.Poster, o.CommitHash)
1770 escrowed -= stake
1771
1772 wugnot.Transfer(cross(cur), o.Poster, stake)
1773
1774 chain.Emit("DuelCancel",
1775 "offer", strconv.FormatInt(offerID, 10),
1776 "poster", string(o.Poster),
1777 "stake", strconv.FormatInt(stake, 10))
1778 checkInvariant()
1779}
1780
1781// Accept takes a standing offer: pulls a stake equal to THE OFFER'S rung — the
1782// caller chooses nothing about the money — deletes the offer, records the taker's NONCE
1783// and creates the duel. Returns the new duel's ID.
1784//
1785// nonce IS THE TAKER'S HALF OF THE COIN AND IT TRAVELS IN THE CLEAR: 32 bytes as 64
1786// lowercase hex characters (checkNonce). No commitment and no reveal from the taker, and
1787// therefore NO EXTRA TRANSACTION — one more argument on the call they were already making.
1788// Read posterWins for why plaintext is safe from the taker's own side: they choose the
1789// nonce WITHOUT the secret, so every nonce they could pick is a coin they cannot evaluate,
1790// and choosing among coins you cannot see is not choosing. A CSPRNG is the right source
1791// here too, though for a different reason than the poster's: a nonce a poster could have
1792// ANTICIPATED (all zeroes, the taker's own address, a counter) lets the poster grind their
1793// secret against it at Post time, which is the one way the poster-cannot-grind property
1794// can be handed away by the other party.
1795//
1796// THE PLAINTEXT NONCE IS ALSO THIS DESIGN'S ONE OPEN WEAKNESS, and the taker should know
1797// it at the door they walk through: a poster watching the mempool can read the nonce out of
1798// this very transaction before it is committed, compute the outcome, and try to abort the
1799// Accept by cancelling or by self-matching. Item (2) of this file's doc comment has the
1800// argument, the two moves and why neither is closed here.
1801//
1802// AN OFFER WITH NO COMMITMENT IS REFUSED, AND THAT REFUSAL IS THE WHOLE OF REQUIREMENT 5 ON
1803// THIS SIDE. A legacy offer (created before 2026-09-30) carries no commitHash, so no secret
1804// exists that could ever open it and Reveal could never run. Accepting one would create a
1805// duel whose only exit after the window is Forfeit — which pays the TAKER — so anybody could
1806// have taken a legacy offer and been handed the poster's stake for nothing. So such an offer
1807// cannot be accepted at all; its exit is Cancel, permissionless for exactly this case.
1808//
1809// THERE IS NO ROUND, NO BEACON AND NOTHING TO WAIT FOR. Everything this function used to do
1810// about randomness is gone: no anchor, no staleness tolerance, no offset, no assertion that
1811// the bound round is in the future. Read this file's doc comment for why that binding could
1812// not be repaired. The consequence a player sees is that settlement is available IN THE NEXT
1813// BLOCK rather than 300 seconds and one 17M-gas relay later.
1814//
1815// The caller MAY be the poster (SPEC-03 §7.3): self-matching wins half the time and pays
1816// the rake regardless, a guaranteed 3% loss, so a guard would cost code and buy nothing.
1817// THAT REASONING NEEDED RE-CHECKING UNDER THE NEW COIN AND IT SURVIVES, in a sharper form
1818// worth writing down: a self-matcher holds the secret AND chooses the nonce, so they can
1819// grind the nonce and win with CERTAINTY — against themselves. Winner and loser are one
1820// address, the pool returns to it less the rake, and certainty about which pocket it lands
1821// in is worth nothing. It is still a guaranteed 3% loss, and it is still the case that the
1822// only party a self-match can take money from is the self-matcher.
1823// An offer ADDRESSED to an opponent is the one exception, and only by construction: then the
1824// opponent is the only address that may accept, and the poster is not it unless they named
1825// themself.
1826//
1827// THE CALLER MUST HOWEVER BE A USER AND NOT A REALM, SINCE 2026-09-30. That is the one
1828// thing about WHO may accept that this function refuses, and requireUserCall has the
1829// argument: a realm taker that wins cannot call the winner-only Collect, so the pool
1830// would have no exit at all — the poster's stake included. It is the raffle's finding-8
1831// reasoning (a package-derived address nothing can spend from) applied to this realm's
1832// own escrow rather than only to the raffle entries a duel earns. AUDIT-duel-2026-09-27.md
1833// addendum 2, finding D-1.
1834//
1835// EVERY CHECK RUNS BEFORE THE PULL. The second of two Accepts on one offer in
1836// a block therefore finds no offer and fails before touching the caller's
1837// wallet: its stake is never pulled, so there is nothing to return (SPEC-03
1838// §7.4). It cannot join, overwrite or silently succeed.
1839func Accept(cur realm, offerID int64, nonce string) int64 {
1840 rejectAttachedCoin(cur, "Accept")
1841 // A PARTY TO A DUEL MUST BE AN ADDRESS THAT CAN CALL Collect (requireUserCall).
1842 // Checked before the offer is even looked up: nothing about which offer was named
1843 // changes the answer, and the refusal must not depend on the lobby's contents.
1844 requireUserCall(cur, "Accept")
1845
1846 // AND THE NONCE'S SHAPE IS CHECKED BEFORE THE LOOKUP FOR THE SAME REASON: a malformed
1847 // nonce is a malformed call whatever the lobby holds, and this is the "validated at both
1848 // ends" half that belongs at the door (checkNonce; checkSecret is the other end).
1849 checkNonce(nonce)
1850
1851 o := getOffer(offerID)
1852 if o == nil {
1853 panic("duel: no such offer")
1854 }
1855 if o.CommitHash == "" {
1856 // LEGACY, AND IT MUST NOT BECOME A DUEL. See this function's doc comment: no secret
1857 // can open an absent commitment, so such a duel could only ever end in Forfeit, which
1858 // pays the taker. Requirement 5.
1859 panic("duel: this offer predates the commit-reveal coin and carries no commitment, so it can never be revealed — it can only be cancelled")
1860 }
1861 caller := cur.Previous().Address()
1862 if o.Opponent != "" && caller != o.Opponent {
1863 // Addressed to someone else. Checked before the allowance so the
1864 // refusal never depends on what the wrong player has approved.
1865 panic("duel: this offer is addressed to another player")
1866 }
1867 stake := stakeOf(o.Rung)
1868 if wugnot.Allowance(caller, selfAddr()) < stake {
1869 panic("duel: insufficient allowance — approve this realm for the rung's stake first")
1870 }
1871
1872 // THE OFFER IS DELETED BEFORE THE PULL, the same ordering as Cancel,
1873 // Collect, Forfeit and VoidStale: the record goes first, the money moves second.
1874 // Until AUDIT-duel-2026-09-27 F2 this function pulled first and deleted
1875 // after, which was safe only because wugnot's TransferFrom has no hook or
1876 // callback through which Accept could be re-entered — a load-bearing
1877 // assumption about ANOTHER realm that was stated nowhere. With the removal
1878 // first, a re-entrant Accept on this offer finds nothing whatever the token
1879 // does, and the file has one ordering rule instead of one-and-an-exception.
1880 //
1881 // THE COMMITMENT IS NOT RELEASED HERE, and that is the point of it being keyed by the
1882 // PAIR and not by the record: the offer dies but the same commitment is now held by the
1883 // duel, continuously, so the poster cannot stand a second offer under the same secret
1884 // while this duel is live.
1885 //
1886 // WHAT THIS ORDERING RELIES ON, stated because it is load-bearing: a pull
1887 // that fails panics across a realm boundary, which aborts the transaction
1888 // and discards the removal with it — the same atomicity Collect relies on
1889 // when it deletes before it pays. Nothing on-chain can catch that panic and
1890 // continue (revive() exists only under `gno test`, and even there it keeps
1891 // pre-abort writes — see duel_test.gno's F2 note), so no path exists on
1892 // which the offer is gone and the taker's stake was not pulled.
1893 offers.Remove(idKey(offerID))
1894
1895 wugnot.TransferFrom(cross(cur), caller, selfAddr(), stake)
1896
1897 id := nextDuelID
1898 nextDuelID++
1899 duels.Set(idKey(id), &Duel{
1900 Poster: o.Poster,
1901 Taker: caller,
1902 Rung: o.Rung,
1903 Label: o.Label,
1904 CommitHash: o.CommitHash,
1905 Nonce: nonce,
1906 AcceptedAt: runtime.ChainHeight(),
1907 // CARRIED, NOT RE-DERIVED: the poster's call is long gone by now, so its
1908 // flag can only come from the offer. The taker's is this very call. Neither
1909 // gates anything here — see Duel.PosterIsUser.
1910 PosterIsUser: o.PosterIsUser,
1911 TakerIsUser: cur.Previous().IsUserCall(),
1912 })
1913 // The poster's stake was already in escrow; only the taker's arrives now.
1914 escrowed += stake
1915
1916 chain.Emit("DuelAccept",
1917 "offer", strconv.FormatInt(offerID, 10),
1918 "duel", strconv.FormatInt(id, 10),
1919 "poster", string(o.Poster),
1920 "taker", string(caller),
1921 "rung", strconv.FormatInt(o.Rung, 10),
1922 // THE NONCE AND THE DEADLINE, so the block log alone carries both the second input
1923 // to posterWins and the height at which anybody may Forfeit. "round" used to stand
1924 // in the first of these slots; it is not renamed, it is replaced, because there is
1925 // no round.
1926 "nonce", nonce,
1927 "commit", o.CommitHash,
1928 "revealBy", strconv.FormatInt(runtime.ChainHeight()+revealWindowBlocks, 10))
1929 checkInvariant()
1930 return id
1931}
1932
1933// Reveal opens the poster's commitment, names the winner, and is PERMISSIONLESS.
1934//
1935// IT IS Resolve, AND Resolve IS GONE — MERGED INTO THIS FUNCTION RATHER THAN KEPT BESIDE
1936// IT, WHICH REQUIREMENT 4 ASKS TO BE JUSTIFIED. Resolve's entire body was: read the
1937// outcome's source, compute the winner, store it, refuse a second call. This function is
1938// that same body with a different source — an argument instead of a cross-realm read of
1939// beacon.Randomness(d.Round). Keeping both would mean two doors onto ONE state transition,
1940// and the second door would have NO SOURCE AT ALL: there is no round to read, so a
1941// surviving Resolve could only either do nothing or invent an outcome. A vestigial
1942// settlement entry point on a realm with no owner and no upgrade path is precisely the
1943// shape this project keeps finding (a dead premise that outlives the thing it depended on),
1944// so the transition has exactly one door and the door names what it needs.
1945//
1946// THE HASH IS THE AUTHORISATION, SO THERE IS DELIBERATELY NO CALLER CHECK — REQUIREMENT 2,
1947// AND IT IS A SECURITY PROPERTY AND NOT A CONVENIENCE. Only somebody who holds the secret
1948// can pass the check below, and by the time this can be called the secret is worth nothing
1949// to anybody: the nonce is already fixed in the record, so the outcome is already
1950// determined and revealing cannot change it. Two things follow, both wanted:
1951//
1952// SETTLEMENT STAYS PERMISSIONLESS. A poster who has revealed to their opponent
1953// off-chain, or published the secret anywhere, can have the duel settled by any
1954// bystander — exactly as the beacon version let any bystander Resolve. Nothing about a
1955// duel's conclusion depends on one particular address being willing to send a
1956// transaction.
1957//
1958// AND NO TRUSTED SECRET-HOLDER IS CREATED. A caller check would make "the address that
1959// may reveal" a third role in the contract, and a poster who wanted a front end or a bot
1960// to settle for them would have to hand that role the secret while it still mattered.
1961// The secret would become a credential held off chain by a de-facto party to the duel.
1962// With no check, the secret is a credential for nothing.
1963//
1964// WHAT IT REFUSES: a duel that does not exist, a duel already revealed (so no later caller
1965// and no later secret can move a winner that is already fixed — the same once-and-only-once
1966// Resolve had), a secret of the wrong length or spelling (checkSecret), and a secret whose
1967// commitment is not this duel's. Nothing is paid here: Collect pays, and deletes.
1968//
1969// THE REVEAL WINDOW IS NOT CHECKED HERE, AND THAT IS ON PURPOSE. A late reveal is welcome
1970// as long as the pool is still there — an honest poster whose transaction was delayed keeps
1971// a duel they won, and nobody is harmed by a reveal on day two of an uncollected duel. What
1972// the window does is open Forfeit; the RACE between a late Reveal and a Forfeit is settled
1973// by block order, and both outcomes are legitimate. The suite drives that ordering both
1974// ways (reveal-after-expiry, and forfeit-then-reveal-refused).
1975func Reveal(cur realm, duelID int64, secret string) {
1976 rejectAttachedCoin(cur, "Reveal")
1977 d := getDuel(duelID)
1978 if d == nil {
1979 panic("duel: no such duel")
1980 }
1981 if d.Resolved {
1982 panic("duel: already revealed")
1983 }
1984 if d.CommitHash == "" {
1985 // LEGACY: no commitment exists, so no secret can open it. Said explicitly rather
1986 // than left to fail the comparison below, because the right next call is VoidStale
1987 // and a reader deserves to be told so. Unreachable for any duel created since
1988 // 2026-09-30 — Accept refuses a commitment-less offer. Requirement 5.
1989 panic("duel: this duel predates the commit-reveal coin and has no commitment to open — void it instead")
1990 }
1991 // LENGTH AND ENCODING FIRST (checkSecret), WHICH IS REQUIREMENT 1'S OTHER END. The
1992 // commitment was checked at Post and the secret is checked here, so the preimage
1993 // commitTag ‖ secret is fixed-length at both moments and has exactly one split. Without
1994 // this, a 63- or 65-character "secret" would be a different preimage of a hash nobody
1995 // committed to — which is harmless on its own, but it is the door through which a
1996 // variable-length opening walks back in.
1997 if commitmentOf(secret) != d.CommitHash {
1998 panic("duel: the secret does not open this duel's commitment")
1999 }
2000
2001 winner := d.Taker
2002 if posterWins(secret, d.Nonce, duelID) {
2003 winner = d.Poster
2004 }
2005 d.Resolved = true
2006 d.Winner = winner
2007 // STORED SO THE OUTCOME IS RE-DERIVABLE FROM THE RECORD ALONE, and so that AuditFailure
2008 // can re-check every resolved duel's winner against the hash rather than taking the
2009 // stored winner's word for it. See Duel.Secret.
2010 d.Secret = secret
2011
2012 chain.Emit("DuelReveal",
2013 "duel", strconv.FormatInt(duelID, 10),
2014 "winner", string(winner),
2015 // EVERY INPUT TO posterWins IS NOW IN THE LOG: the commitment from DuelPost, the
2016 // nonce from DuelAccept, and the secret here. Anybody can recompute the parity and
2017 // check this realm against itself without reading a single storage slot.
2018 "secret", secret,
2019 "nonce", d.Nonce,
2020 "commit", d.CommitHash)
2021 checkInvariant()
2022}
2023
2024// Collect pays the winner 2 x stake - rake, forwards the rake, and DELETES THE
2025// DUEL. Winner only.
2026//
2027// Conservation (SPEC-03 §7.2): payout + rake == 2 x stake exactly, and the
2028// rake's two parts sum exactly to the rake — the raffle pot share takes the
2029// remainder.
2030//
2031// THE RECORD IS DELETED BEFORE ANYTHING IS PAID. That single ordering is what
2032// makes a second Collect fail instead of double-paying (SPEC-03 §7.6): by the
2033// time the first transfer runs, there is no duel for a re-entrant or repeated
2034// call to find. It also reclaims the storage deposit (SPEC-03 §7).
2035//
2036// AND IT BOOKS THE ENTRIES THE RAKE EARNED ITS TWO PLAYERS (finding 15). Booked here,
2037// delivered by FlushPot with the money, and credited to nobody else: the winner takes a
2038// third, the LOSER takes two thirds, and the two sum to exactly what that rake would
2039// have bought at raffle.EntryUnitPrice. Read rakeEntryUnits for why the rake and never the
2040// stake is the price base — it is what stops two accounts flipping each other for cheap
2041// entries — and bookEntries for the two reasons a party can earn nothing (they played
2042// from realm code, or the undelivered batch is full). NEITHER CAN MAKE THIS FUNCTION
2043// FAIL: a winner's collect must never depend on a raffle entry.
2044//
2045// AN EARNED ENTRY IS NOT A COIN FLIP AND IT CARRIES A DISCLOSED, STILL-OPEN RISK THAT
2046// YOUR FLIP DOES NOT. The entry is in r/splash/arcade/raffle's weekly draw, which is
2047// settled by a drand round PINNED WHEN THE WEEK OPENED against an estimate of drand's
2048// current round derived from chain height. If that estimate drifts — slow blocks, or a
2049// beacon nobody has relayed to lately — the week's winning randomness can become
2050// publicly readable on api.drand.sh BEFORE the week's ticket sales close, and whoever
2051// reads it can buy last and size the buy to own the winning unit. THE POT THEY WOULD BE
2052// TAKING IS MOSTLY DUEL RAKE. It is bounded by the beacon's staleness refusal and by a
2053// measured margin, it is nobody's key and nobody's privilege, and it is recorded OPEN:
2054// the full disclosure, the bound and what it rests on are at rakeEntryUnits above, and
2055// the record is AUDIT-2026-09-28.md findings 4, 10, 12, 13 and 20.
2056//
2057// WHERE THE RAKE GOES (BIBLE.md §4.15 S12): the pot share through
2058// raffle.FundPot — the raffle's named, permissionless, caller-pays door, approved
2059// for exactly its amount in the same transaction, so a panic anywhere reverts the
2060// approval with everything else — and the ops share by plain transfer to
2061// r/splash/ops.
2062//
2063// REPOINTED AT THE RAFFLE 2026-09-28, AND THE SPLIT DID NOT MOVE. The players'
2064// side of the rake used to go through two doors, game.FundYield (5000 bps) and
2065// game.FundPrizes (the remainder, 3000 bps plus all dust). r/splash/game's
2066// permanent-deposit yield product is retired, so there is no yield to fund and
2067// the yield share is folded into the pot share rather than dropped: one call,
2068// carrying rake - opsShare, which is exactly what the two calls carried between
2069// them. The ops share is computed by the same floored formula as before, so the
2070// house takes neither more nor less than it did. Integer-division dust was the
2071// prize share's and is now the raffle pot's — it has always gone to the players'
2072// side, and still does. SPONSORSHIP BUYS NO TICKETS: the raffle records it as
2073// house money in the week's pot, attributable to no buyer, so this realm never
2074// gains a winning chance in a draw it funds.
2075//
2076// IT FUNDS WHATEVER WEEK IS LIVE WHEN THE DUEL IS COLLECTED, which is the point —
2077// every coin flip makes this Saturday's prize bigger — and needs no coordination:
2078// FundPot creates the week's record if the week has not sold a ticket yet, and the
2079// live week is uncommitted by construction, so a settlement can never arrive at a
2080// frozen pot.
2081//
2082// (The previous note here recorded that between cd29697 and 2026-09-28 the whole
2083// rake went by one transfer to the ops vault, because the game's doors pulled
2084// r/splash/token while this realm held wugnot. That detour is over: the players'
2085// side now reaches the players, in the one product they can play this week.)
2086func Collect(cur realm, duelID int64) {
2087 rejectAttachedCoin(cur, "Collect")
2088 d := getDuel(duelID)
2089 if d == nil {
2090 panic("duel: no such duel")
2091 }
2092 if !d.Resolved {
2093 panic("duel: not revealed yet — anybody holding the poster's secret may Reveal it, and after the reveal window anybody may Forfeit it")
2094 }
2095 caller := cur.Previous().Address()
2096 if caller != d.Winner {
2097 panic("duel: only the winner may collect")
2098 }
2099 settle(cur, duelID, d, d.Winner, "DuelCollect")
2100}
2101
2102// Forfeit ends a duel whose poster never opened their commitment: after revealWindowBlocks
2103// the TAKER takes the pool on exactly the terms the winner of a revealed duel would have.
2104// PERMISSIONLESS, and the destination is READ FROM THE RECORD.
2105//
2106// WHY THE TAKER, AND WHY THIS IS THE WHOLE INCENTIVE ARGUMENT FOR THE DESIGN. After Accept
2107// the outcome is already determined — the secret is fixed, the nonce is in the record — but
2108// only the poster can see it. So the poster's choice is: reveal, or withhold. Paying the
2109// taker on withholding makes those two choices IDENTICAL IN MONEY when the poster has lost
2110// (they hand over the pool either way) and STRICTLY WORSE when the poster has won (they hand
2111// over a pool they could have taken). Withholding therefore never pays, at any rung, and
2112// the only thing it buys is up to revealWindowBlocks of delay — which costs the poster the
2113// same wait. Pay the POSTER on expiry instead and the design inverts: the poster would
2114// withhold every loss and the coin would be a one-way bet.
2115//
2116// PERMISSIONLESS, FOR THE REASON EVERY EXIT IN THIS FILE IS. The taker is the only party
2117// who gains, so the taker has the incentive — but their willingness to send a transaction
2118// must not be what stands between the pool and an exit. A bystander, a front end or a
2119// keeper may fire it; the money still goes where the record says. Requirement 3.
2120//
2121// AND IT SETTLES THROUGH THE SAME ARITHMETIC AS Collect, WHICH IS NOT A TIDINESS POINT BUT
2122// THE MOST-REPEATED DEFECT IN THIS PROJECT. It calls the same settle() — same rake, same
2123// split, same entries, same books. The alternative shapes were considered and both of the
2124// safe ones are: the same rake AND the same entries (this), or NO rake and NO entries (the
2125// VoidStale shape). WHAT MUST NEVER SHIP IS ENTRIES WITHOUT RAKE: checkInvariant panics on
2126// `pendingEntryUnits > 0 && pendingPot <= 0`, so a forfeit that booked entries without
2127// booking the rake behind them would panic on its own exit — bricking THE ONLY EXIT this
2128// state has and stranding the pool with the books reading green. A separate copy of the
2129// payout arithmetic is exactly how that gets written, so there is no separate copy.
2130//
2131// A LEGACY DUEL IS REFUSED HERE, AND THAT REFUSAL IS THE POINT OF REQUIREMENT 5. A duel with
2132// no commitment can never be revealed by anybody, so its expiry is not a poster's silence —
2133// it is a certainty from the moment it was accepted. Forfeiting it would hand the taker the
2134// poster's stake for nothing. VoidStale refunds both stakes instead, and this function will
2135// not touch such a record. Unreachable since Accept refuses commitment-less offers.
2136//
2137// Panics if the duel does not exist, if it is already revealed (Collect is then the path, and
2138// the winner it names may not be the taker), if it carries no commitment, or if the window
2139// has not elapsed.
2140func Forfeit(cur realm, duelID int64) {
2141 rejectAttachedCoin(cur, "Forfeit")
2142 d := getDuel(duelID)
2143 if d == nil {
2144 panic("duel: no such duel")
2145 }
2146 if d.Resolved {
2147 panic("duel: already revealed — the winner collects, and a forfeit cannot take that away")
2148 }
2149 if d.CommitHash == "" {
2150 panic("duel: this duel predates the commit-reveal coin and could never have been revealed, so its expiry is not a forfeit — void it instead")
2151 }
2152 if runtime.ChainHeight()-d.AcceptedAt < revealWindowBlocks {
2153 panic("duel: the reveal window has not elapsed")
2154 }
2155 // THE DESTINATION IS THE RECORD'S, NOT THE CALLER'S. Read from the Duel and passed in,
2156 // so a permissionless door cannot be a way to name a payee.
2157 settle(cur, duelID, d, d.Taker, "DuelForfeit")
2158}
2159
2160// settle is THE ONE payout path: it pays `winner` pool - rake, books the players' side of
2161// the rake and the raffle entries it earned, pays the ops share, deletes the record and
2162// emits `event`. Collect and Forfeit are both one caller check and a call to this.
2163//
2164// ONE COPY, DELIBERATELY, AND THE REASON IS REQUIREMENT 3. Two exits that pay the same pool
2165// on the same terms must not have two copies of the rake-and-entry arithmetic, because the
2166// failure mode of a drifted copy is not a wrong number — it is a booked entry with no
2167// booked rake behind it, which checkInvariant refuses on the way out of the very function
2168// that created it. That panic would brick the exit and strand the pool while `escrowed`,
2169// AuditFailure and checkInvariant all still read green over it. Six of this product's
2170// findings have had the "money in, no way out" shape; this is the cheapest possible way to
2171// not add a seventh.
2172//
2173// `winner` IS AN ARGUMENT AND NOT READ FROM THE RECORD, because the two callers learn it
2174// differently: Collect reads d.Winner, which Reveal fixed from the coin, and Forfeit uses
2175// d.Taker, which the poster's silence fixed. Both are values of the record; neither is the
2176// caller's to choose. It is asserted below that the winner is a party to the duel, which is
2177// the property that actually matters and is cheap to state where the money moves.
2178func settle(cur realm, duelID int64, d *Duel, winner address, event string) {
2179 if winner != d.Poster && winner != d.Taker {
2180 // Unreachable from either caller; stated because this is the line between "the pool
2181 // goes to a party of this duel" and "the pool goes somewhere", and it is the only
2182 // place both callers' choices meet.
2183 panic("duel: the payee is not a party to this duel")
2184 }
2185
2186 stake := stakeOf(d.Rung)
2187 pool := poolOf(stake)
2188 rake := rakeOf(pool)
2189 payout := pool - rake
2190 potShare, opsShare := splitRake(rake)
2191 if payout+potShare+opsShare != pool {
2192 // A real check, not a tautology: payout and the two shares are each
2193 // computed from the pool by their own path above, and this is the
2194 // first place all three meet.
2195 panic("duel: conservation violated — payout plus rake is not the pool")
2196 }
2197
2198 // WHO LOST, AND WHICH OF THE TWO PARTIES MAY EARN ENTRIES. Both are read from the
2199 // record before it is deleted. A self-matched duel (poster == taker, SPEC-03 §7.3)
2200 // makes the winner and the loser the same address, which is correct and is the
2201 // self-dealing case the pricing rule is built for: the two shares aggregate to the
2202 // same total under one key, so one hand flipping itself earns exactly the total the
2203 // rake bought and not a unit more.
2204 loser := d.Taker
2205 winnerIsUser, loserIsUser := d.PosterIsUser, d.TakerIsUser
2206 if winner != d.Poster {
2207 loser = d.Poster
2208 winnerIsUser, loserIsUser = d.TakerIsUser, d.PosterIsUser
2209 }
2210 winnerUnits, loserUnits := splitEntries(rakeEntryUnits(rake))
2211
2212 duels.Remove(idKey(duelID))
2213 releaseCommitment(d.Poster, d.CommitHash)
2214 escrowed -= pool
2215
2216 wugnot.Transfer(cross(cur), winner, payout)
2217 if potShare > 0 {
2218 // BOOKED, NOT SENT, AND NOT ONE LINE OF RAFFLE CODE RUNS IN THIS TRANSACTION.
2219 // Read pendingPot: this used to be an Approve plus a raffle.FundPot here, and
2220 // FundPot ends in the raffle's checkInvariant, so any panic in the raffle took
2221 // this winner's payout with it — permanently, because the void path refuses a
2222 // settled duel. FlushPot delivers this to the raffle's current week later, and
2223 // anybody may call it.
2224 if pendingPot == 0 {
2225 pendingSince = runtime.ChainHeight()
2226 }
2227 pendingPot += potShare
2228
2229 // AND THE ENTRIES THAT RAKE BOUGHT, BOOKED IN THE SAME BREATH AND INSIDE THIS
2230 // BRANCH DELIBERATELY. Entries must never exist without the money that pays for
2231 // them: the raffle prices its ceiling off the amount delivered in the same call,
2232 // so a booked entry with no booked rake behind it could never be delivered at
2233 // all. Being inside `potShare > 0` makes that structural rather than asserted —
2234 // and checkInvariant asserts it anyway. IT IS ALSO WHY FORFEIT SHARES THIS CODE
2235 // RATHER THAN HAVING ITS OWN: requirement 3, and the note on this function.
2236 //
2237 // STILL NOT ONE LINE OF RAFFLE CODE RUNS IN THIS TRANSACTION. bookEntries writes
2238 // this realm's own tree; FlushPot is what talks to the raffle. Finding 14.
2239 winnerUnits = bookEntries(winner, winnerUnits, winnerIsUser)
2240 loserUnits = bookEntries(loser, loserUnits, loserIsUser)
2241 } else {
2242 // No rake to pay for entries, so there are none. Unreachable on today's
2243 // ladder (every rung's rake is positive), and stated rather than left to the
2244 // reader to work out from the branch above.
2245 winnerUnits, loserUnits = 0, 0
2246 }
2247 if opsShare > 0 {
2248 wugnot.Transfer(cross(cur), opsVaultAddr, opsShare)
2249 }
2250
2251 // ONE EVENT SHAPE FOR BOTH EXITS, WITH THE NAME AS THE ONLY DIFFERENCE, so an indexer
2252 // reads a forfeit with the code it already has for a collect and a reader can see at a
2253 // glance that the money moved identically. THE SPLIT DID NOT MOVE: potShare is the same
2254 // number computed the same way, still the players' side of the rake in its entirety,
2255 // dust included; "rafflePot" is the amount destined for the pot and "pending" says it
2256 // has not gone yet.
2257 chain.Emit(event,
2258 "duel", strconv.FormatInt(duelID, 10),
2259 "winner", string(winner),
2260 "payout", strconv.FormatInt(payout, 10),
2261 "rake", strconv.FormatInt(rake, 10),
2262 "rafflePot", strconv.FormatInt(potShare, 10),
2263 "ops", strconv.FormatInt(opsShare, 10),
2264 "pending", strconv.FormatInt(pendingPot, 10),
2265 // THE ENTRIES EACH PARTY ACTUALLY EARNED, after any decline or drop, so the
2266 // block log alone shows both the rate and the consolation weighting. The loser
2267 // is named because it is the only place in this realm that records who lost.
2268 "loser", string(loser),
2269 "winnerEntries", strconv.FormatInt(winnerUnits, 10),
2270 "loserEntries", strconv.FormatInt(loserUnits, 10),
2271 "pendingEntries", strconv.FormatInt(pendingEntryUnits, 10))
2272 checkInvariant()
2273}
2274
2275// bookEntries credits `holder` with `units` raffle entry units, booked in this realm
2276// for FlushPot to deliver, and returns what it actually booked — which is zero when
2277// the holder is not eligible or the batch is full. IT NEVER PANICS AND NEVER
2278// REFUSES ITS CALLER: a winner's Collect must not be able to fail over a raffle
2279// entry (AUDIT-2026-09-28.md finding 14), so every reason not to credit somebody is
2280// a zero and an event, not an abort.
2281//
2282// TWO REASONS A PARTY EARNS NOTHING, both deliberate and both visible in the log:
2283//
2284// - THE PARTY DID NOT PLAY FROM A USER CALL. A raffle entry is a claim on the
2285// raffle pushing a whole week's pot to that address, and a realm's address is
2286// package-derived — nothing can spend from it unless that realm contains code
2287// that does, so a realm winning a raffle week would strand the entire pot
2288// (AUDIT-2026-09-28.md finding 8, which is why raffle.Buy refuses realms
2289// outright). The raffle cannot check this for a bare address, so it is checked
2290// here, where the call was. The check is IsUserCall and not IsUser for the reason
2291// the raffle's Buy states: a `maketx run` ephemeral realm's address is
2292// package-derived too.
2293//
2294// THIS BRANCH IS UNREACHABLE FROM Post AND Accept SINCE 2026-09-30 AND IS KEPT
2295// DELIBERATELY. The sentence that stood here — "a realm may still post, accept, win
2296// and collect; it simply earns no entries" — described the state finding D-1 closed:
2297// settlement was open to realms, so a winning realm stranded 2 x stake in this
2298// realm's escrow with no exit, because Collect is winner-only. Both doors now
2299// require a direct user call (requireUserCall), so every party of every duel is
2300// eligible and `isUser` arrives true. The check stays because it is the ONLY place
2301// finding 8's property is enforced for entries — the raffle's FundPotWithEntries
2302// admits this package path and trusts it to have checked — so a future relaxation
2303// of either gate, or a future caller of bookEntries that is not Collect, must fail
2304// as a dropped entry and an event rather than as a raffle pot pushed to an address
2305// that cannot spend it. A guard that has become unreachable by a change elsewhere is
2306// cheap; re-deriving why it was needed is not.
2307// - THE BATCH IS FULL. See maxPendingEntryHolders: an unbounded batch could grow
2308// past what a block can deliver, which would brick the booked rake's primary
2309// exit. Any caller can empty the batch at any time by flushing.
2310func bookEntries(holder address, units int64, isUser bool) int64 {
2311 if units <= 0 {
2312 return 0
2313 }
2314 if holder == "" {
2315 // Unreachable: every party came from cur.Previous().Address(). Silent zero
2316 // rather than a panic, for the reason in this function's doc comment.
2317 return 0
2318 }
2319 if !isUser {
2320 chain.Emit("DuelEntriesDeclined",
2321 "holder", string(holder),
2322 "units", strconv.FormatInt(units, 10),
2323 "reason", "the party did not play from a direct user call")
2324 return 0
2325 }
2326 k := string(holder)
2327 if !pendingEntries.Has(k) && pendingEntries.Size() >= maxPendingEntryHolders {
2328 chain.Emit("DuelEntriesDropped",
2329 "holder", string(holder),
2330 "units", strconv.FormatInt(units, 10),
2331 "holders", strconv.Itoa(pendingEntries.Size()),
2332 "reason", "the undelivered entry batch is full — call FlushPot")
2333 return 0
2334 }
2335 if !pendingEntries.Has(k) {
2336 // A NEW HOLDER ADVANCES SweepStuckRake'S CLOCK, so that the window tracks THE
2337 // YOUNGEST CLAIM IN THE BATCH and not the batch's age. AUDIT-duel-2026-09-27.md
2338 // addendum 2, finding D-4 — read pendingSince for the defect this closes, which is
2339 // that a player who settled on day 20 of a 21-day window had their entries
2340 // destroyed by a clock that started before they played.
2341 //
2342 // IT IS THE ADMISSION OF A HOLDER AND NOT EVERY BOOKING, AND THAT IS THE WHOLE
2343 // REASON THE SWEEP STAYS REACHABLE. An existing holder's further units inherit the
2344 // batch's clock. If every booking advanced it, ONE repeat player settling a duel
2345 // every twenty days could defer the last resort for ever — and the last resort is
2346 // what stops pendingPot being a place money enters and never leaves, so an
2347 // indefinitely deferrable one is not an exit. Admissions are bounded by
2348 // maxPendingEntryHolders, so the clock can be advanced AT MOST 128 TIMES and the
2349 // window always closes.
2350 //
2351 // THE RESIDUAL, STATED RATHER THAN LEFT TO BE FOUND: a returning holder's
2352 // additional units do inherit an older clock, so they can still be swept sooner
2353 // than 21 days after they were earned. That is the deliberate limit of this fix —
2354 // the alternative is the unbounded deferral above — and it is much the smaller
2355 // case, because such a player is already in the batch and has already had every
2356 // opportunity to flush it themselves.
2357 pendingSince = runtime.ChainHeight()
2358 }
2359 pendingEntries.Set(k, entryUnitsOf(k)+units)
2360 pendingEntryUnits += units
2361 return units
2362}
2363
2364// entryUnitsOf reads the booked entry units for a tree key, zero if absent. avl's
2365// Get returns a single value, so existence is a separate Has.
2366func entryUnitsOf(k string) int64 {
2367 if !pendingEntries.Has(k) {
2368 return 0
2369 }
2370 return pendingEntries.Get(k).(int64)
2371}
2372
2373// --- delivering the players' side of the rake ------------------------------
2374
2375// pendingGraceBlocks is how long the YOUNGEST CLAIM in the booked batch must have stood
2376// before SweepStuckRake's last resort opens: 21 days of blocks, the same window
2377// r/splash/arcade/raffle gives a committed week before its refunds open.
2378//
2379// "THE YOUNGEST CLAIM", NOT "DELIVERY HAS BEEN FAILING", corrected 2026-09-30 by finding
2380// D-4. Nothing in this realm can observe a failed flush — a failed flush reverts, taking
2381// any record of itself with it — so no window here can measure what the paragraph below
2382// once said it measured. Read pendingSince for what it does measure and for the two
2383// consequences, one accepted and one closed.
2384//
2385// WHY A WINDOW AT ALL: without one, SweepStuckRake would be a free way for anyone to
2386// divert the players' side of every rake out of the prize pot and into the raffle's ops
2387// pot. The window is what makes it a last resort rather than a choice.
2388//
2389// WHY 21 DAYS AND NOT LESS: every refusal FundPot can raise except a raffle accounting
2390// deficit is transient, and the longest of them is bounded by a raffle WEEK — a committed
2391// or abandoned current week (both unreachable for the live week by construction, see
2392// FundPot) and a beacon nobody has relayed to (clearable by any caller with one Publish).
2393// Three weeks is comfortably longer than one, so the sweep cannot fire on a transient
2394// refusal that a single FlushPot would have cleared.
2395//
2396// THERE IS DELIBERATELY NO SECONDS-ON-BOTH-SIDES BUILD GUARD ON THIS ONE, and that is a
2397// considered omission rather than the defect AUDIT-2026-09-28.md finding 11 is about. What
2398// this window has to outlast is a raffle week, which is itself a BLOCK count in the
2399// raffle (raffleWeekDays * blocksPerDay) — so both sides would come from the same clock,
2400// the block time would cancel, and the guard could not fail. A guard that cannot fail is
2401// worse than none, because it looks like protection. The relation is stated here instead:
2402// pendingGraceBlocks must stay several raffle weeks long, and the raffle's week is
2403// blocks, so the two move together whatever the block time turns out to be.
2404const pendingGraceBlocks = blocksPerDay * 21
2405
2406// FlushPot delivers the whole booked rake to r/splash/arcade/raffle's CURRENT week and
2407// returns the amount delivered. PERMISSIONLESS: anyone may pay the gas to do it, and the
2408// destination is not theirs to choose.
2409//
2410// THIS IS THE FUNCTION Collect USED TO BE THE SECOND HALF OF. The split is the whole fix:
2411// a raffle that will not accept the rake now costs the CALLER OF THIS FUNCTION a reverted
2412// transaction and nothing else. The winner has already been paid, the duel record is
2413// already gone, and pendingPot is still here for the next attempt. Nothing about a duel
2414// depends on the raffle any more.
2415//
2416// BOOKS FIRST, CALL SECOND, and the revert is what makes that safe. pendingPot is zeroed
2417// BEFORE raffle.FundPot runs, so a re-entrant call finds nothing to send twice; and if
2418// FundPot panics, the panic aborts this whole transaction and discards the zeroing with
2419// it, so the balance is preserved exactly. (Nothing on-chain can catch that panic and
2420// continue — the same atomicity Accept and Collect already rely on.)
2421//
2422// THE APPROVAL IS FOR EXACTLY THE AMOUNT AND IN THE SAME TRANSACTION, as it was in
2423// Collect, so a revert takes the approval with it and this realm never leaves a standing
2424// allowance behind.
2425//
2426// IT FUNDS WHATEVER WEEK IS LIVE WHEN IT RUNS, which is a behaviour change from "whatever
2427// week was live when the duel was collected" and is recorded rather than hidden. A rake
2428// booked on Saturday and flushed on Monday enlarges Monday's week's prize. Nobody's claim
2429// moves — sponsorship buys no tickets in any week — and any player who wants their own
2430// flip to swell this week's pot can flush it themselves in the same block they collect.
2431// Panics if there is nothing booked.
2432// AND IT DELIVERS THE EARNED ENTRIES WITH THE MONEY, IN ONE CALL (finding 15). The
2433// batch is built from pendingEntries and handed to raffle.FundPotWithEntries, which
2434// funds the pot through the same FundPot and appends one ticket index per holder.
2435// One call rather than two because the money and the entrants must land in the SAME
2436// week: a delivery that could split across a week boundary would put the rake in one
2437// draw and the entrants in another, which is the defect finding 15 is about, only
2438// subtler.
2439//
2440// THE ENTRIES FOLLOW THE MONEY'S WEEK, WHICH IS THE WEEK LIVE WHEN THE FLUSH RUNS,
2441// AND THAT IS A DELIBERATE CHOICE RATHER THAN THE ONLY ONE. A rake booked on Saturday
2442// and flushed on Monday enlarges Monday's pot and earns its entries in Monday's draw —
2443// the money and the entrants stay together, which is the property that matters, and
2444// any player who wants their own flip in THIS week's draw can flush in the same block
2445// they collect (the suite's collectAs helper does exactly that, as it already did for
2446// the money). The alternative — booking the settlement's week number and crediting
2447// THAT week — was rejected twice over: it would let this realm write ticket weight
2448// into a week that is already committed or abandoned, which is the frozen-total rule
2449// the raffle refuses Buy for (findings 2 and 10), and a week that had moved on would
2450// leave the batch permanently undeliverable, which is a "money in, no way out" shape.
2451func FlushPot(cur realm) int64 {
2452 rejectAttachedCoin(cur, "FlushPot")
2453 amount := pendingPot
2454 if amount <= 0 {
2455 panic("duel: no rake is waiting to be delivered to the raffle")
2456 }
2457
2458 // THE BATCH IS COLLECTED BEFORE ANY CROSS-REALM CALL, and that ordering is not a
2459 // style choice: NEVER NEST ONE avl.Iterate INSIDE ANOTHER. The raffle's FundPot
2460 // iterates its own trees (sweepDonations, and its invariant reads), and this
2461 // toolchain's VM crashes with a Go-level nil dereference — not a gno panic, so
2462 // nothing catches it — if a second traversal starts inside the first's callback.
2463 // So the walk finishes, and only then does the raffle run.
2464 holders := make([]address, 0, pendingEntries.Size())
2465 units := make([]int64, 0, pendingEntries.Size())
2466 pendingEntries.Iterate("", "", func(key string, value any) bool {
2467 holders = append(holders, address(key))
2468 units = append(units, value.(int64))
2469 return false
2470 })
2471 entryUnits := pendingEntryUnits
2472
2473 // BOOKS FIRST, CALL SECOND, for the entries exactly as for the money: a
2474 // re-entrant call finds an empty batch, and a panic in the raffle aborts this
2475 // whole transaction and discards every one of these writes with it, so the batch
2476 // is preserved exactly for the next attempt.
2477 pendingPot = 0
2478 pendingSince = 0
2479 pendingEntries = avl.NewTree()
2480 pendingEntryUnits = 0
2481
2482 wugnot.Approve(cross(cur), raffleRealmAddr, amount)
2483 if len(holders) > 0 {
2484 // The raffle re-derives the entry ceiling from `amount` itself and refuses a
2485 // batch that claims more than that money could have bought at its own ticket
2486 // price, so neither realm depends on the other's arithmetic being right.
2487 raffle.FundPotWithEntries(cross(cur), amount, holders, units)
2488 } else {
2489 // NO ELIGIBLE HOLDERS, WHICH IS A REAL STATE AND NOT A DEGENERATE ONE: both
2490 // parties of every settled duel played from realm code, or the rakes were too
2491 // small to buy one whole entry. The money must still go, and the raffle's
2492 // entry door refuses an empty batch on purpose, so this is the plain door.
2493 raffle.FundPot(cross(cur), amount)
2494 }
2495
2496 chain.Emit("DuelFlushPot",
2497 "amount", strconv.FormatInt(amount, 10),
2498 "destination", string(raffleRealmAddr),
2499 "entryHolders", strconv.Itoa(len(holders)),
2500 "entryUnits", strconv.FormatInt(entryUnits, 10))
2501 checkInvariant()
2502 return amount
2503}
2504
2505// SweepStuckRake is the LAST RESORT for a booked rake the raffle will not take, and it
2506// exists so that pendingPot is not a place money can enter and never leave. It transfers
2507// the whole booked amount to the raffle's address by PLAIN wugnot.Transfer and returns it.
2508// PERMISSIONLESS, and only once the batch's YOUNGEST CLAIM has stood for
2509// pendingGraceBlocks (pendingSince, finding D-4 — it is not, and cannot be, a measure of
2510// delivery having been attempted and refused).
2511//
2512// WHY IT CANNOT BE REFUSED, which is the only property that makes it a real exit: a plain
2513// GRC20 transfer runs no raffle code. FlushPot goes through raffle.FundPot and can
2514// therefore be refused by anything in the raffle — that is exactly the state this
2515// function is for. This one cannot be.
2516//
2517// AND IT DROPS THE EARNED ENTRIES WITH THE MONEY, WHICH IS THE OTHER HALF OF THE COST
2518// (finding 15). The money on this path becomes the raffle's ops pot, so there is no
2519// prize left for an entry to be an entry in, and an entry with no rake behind it is a
2520// thing this realm's own invariant refuses. So players who earned entries in a batch
2521// that reached this function get nothing for them. It takes three weeks of the raffle
2522// refusing EVERY caller, the drop is in this function's event, and any player could have
2523// prevented it at any moment by flushing — for between 10,378,157 gas (a two-holder batch,
2524// measured 2026-09-30) and 351,725,503 (a FULL 128-holder batch against a week grown to
2525// 100,096 ticket indices). Read maxPendingEntryHolders for where each figure comes from and
2526// why only the first is reproducible by running this package.
2527//
2528// THE FULL-BATCH END OF THAT RANGE IS THE ONE THAT APPLIES HERE, and citing only the
2529// cheap end was a real misstatement, corrected 2026-09-30. This paragraph is about a
2530// batch that has been accumulating for three weeks of refusals, i.e. the regime in which
2531// the batch is as large as maxPendingEntryHolders allows — so quoting a two-holder figure
2532// understated the mitigation's cost by more than thirty times in exactly the paragraph
2533// where it is dearest. (The figure said ~1.1M until 2026-09-29 and was stale by finding 15
2534// — see maxPendingEntryHolders and AUDIT-2026-09-28.md finding 19. The argument every
2535// version of it supports is unchanged: at 11.7% of a block it is still one affordable
2536// transaction available to anybody at any height. AUDIT-duel-2026-09-27.md addendum 2,
2537// findings D-4 and D-6.)
2538//
2539// WHERE IT LANDS, AND THE HONEST COST OF THAT. An unattributed arrival at the raffle is
2540// swept by its sweepDonations into its OPS pot, not into any week's prize — the raffle
2541// says why: crediting an unattributed arrival to a prize pot would break the identity
2542// that makes its refunds safe. So this money reaches the house rather than the players,
2543// and the 80/20 split is therefore NOT preserved on this path. That is stated plainly
2544// because it is the one place in this change where the split moves: it takes three weeks
2545// of the raffle refusing EVERY caller for it to happen, it emits its own event, and it
2546// is the alternative to the money never leaving at all.
2547//
2548// AND THE DESTINATION IS THE RAFFLE, NOT r/splash/ops, DELIBERATELY. gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/ops
2549// does not exist — the ops constant in this file is a package-derived address with no code
2550// at it — so routing a NEW stream there would be creating exactly the "money in, no way
2551// out" shape that six of this product's findings have had. The raffle's ops pot has an
2552// exit (WithdrawOps), and whether THAT exit's destination is reachable is a pre-existing
2553// question about r/splash/ops which is the owner's and is not made worse here.
2554//
2555// THE COST OF HAVING A WINDOW AT ALL, RECORDED RATHER THAN LEFT TO BE FOUND: after 21 days
2556// in which nobody bothered to call FlushPot — even with the raffle perfectly willing — any
2557// caller may move the booked rake to the raffle's ops pot instead of its prize pot. That is
2558// a value shift from the players to the house, it costs the caller gas and gains them
2559// nothing personally, and it is preventable by anyone at any time by simply flushing —
2560// for 10,378,157 gas at two holders and 351,725,503 at a FULL 128-holder batch, which is
2561// the end of the range that applies after three weeks of accumulation (see
2562// maxPendingEntryHolders for each figure's provenance; corrected from a bare "~8.6M" on
2563// 2026-09-30 and from "~1.1M" on
2564// 2026-09-29, findings D-4, D-6 and 19). It is accepted because the alternatives are worse: no window makes this a free
2565// diversion available on the first block, and no function at all leaves the balance with a
2566// single exit that another realm can hold shut for ever. AUDIT-2026-09-28.md finding 14.
2567//
2568// Panics if there is nothing booked, or if the window has not elapsed.
2569func SweepStuckRake(cur realm) int64 {
2570 rejectAttachedCoin(cur, "SweepStuckRake")
2571 amount := pendingPot
2572 if amount <= 0 {
2573 panic("duel: no rake is waiting to be delivered to the raffle")
2574 }
2575 if pendingSince <= 0 {
2576 // Unreachable: pendingSince is set in the same statement group that makes
2577 // pendingPot non-zero. Loud rather than treating an unset clock as elapsed.
2578 panic("duel: the pending rake has no booking height")
2579 }
2580 if runtime.ChainHeight()-pendingSince < pendingGraceBlocks {
2581 // THE MESSAGE NAMES WHAT IS ACTUALLY MEASURED, since 2026-09-30. It used to say
2582 // "the raffle has not been refusing this rake long enough", which asserted a fact
2583 // about the raffle that this realm has no way to know — see pendingSince, finding
2584 // D-4. What is measured is how long the youngest claim in the batch has stood.
2585 panic("duel: nothing in this batch is old enough for the last resort — call FlushPot instead")
2586 }
2587
2588 // THE BOOKED ENTRIES DIE HERE, AND THAT IS THE SECOND HALF OF THIS FUNCTION'S
2589 // HONEST COST (finding 15). The money on this path lands in the raffle's OPS pot
2590 // rather than a prize pot, so there is no draw left for an entry to be an entry
2591 // IN — and an entry with no rake behind it is exactly what checkInvariant refuses,
2592 // because the raffle would price its ceiling off money that never arrived. So the
2593 // batch is discarded with the same event that reports the sweep.
2594 //
2595 // DISCARDED IN O(1) BY REPLACING THE TREE, never by walking and removing. This
2596 // function is the LAST RESORT for a booked rake nobody could deliver, so its cost
2597 // must not scale with the batch that could not be delivered — a sweep that could
2598 // itself run out of gas would leave no exit at all, which is the whole shape this
2599 // function exists to prevent.
2600 sweptEntryUnits := pendingEntryUnits
2601 sweptEntryHolders := pendingEntries.Size()
2602
2603 pendingPot = 0
2604 pendingSince = 0
2605 pendingEntries = avl.NewTree()
2606 pendingEntryUnits = 0
2607
2608 wugnot.Transfer(cross(cur), raffleRealmAddr, amount)
2609
2610 chain.Emit("DuelSweepStuckRake",
2611 "amount", strconv.FormatInt(amount, 10),
2612 "destination", string(raffleRealmAddr),
2613 "droppedEntryHolders", strconv.Itoa(sweptEntryHolders),
2614 "droppedEntryUnits", strconv.FormatInt(sweptEntryUnits, 10))
2615 checkInvariant()
2616 return amount
2617}
2618
2619// PendingPot is the players' side of the rake booked and not yet delivered to the raffle.
2620// O(1) read.
2621func PendingPot() int64 { return pendingPot }
2622
2623// PendingSince is the height SweepStuckRake's window is counted from: the height at which
2624// the booked rake last went from zero to non-zero, or at which the batch's most recent NEW
2625// HOLDER was admitted, whichever is later. 0 when nothing is booked. O(1) read. Read
2626// pendingSince for why a new holder moves it (finding D-4).
2627func PendingSince() int64 { return pendingSince }
2628
2629// PendingSweepOpensAt is the height at which SweepStuckRake's last resort becomes
2630// available, or 0 when nothing is booked. FlushPot is available at every height.
2631//
2632// IT MOVES LATER WHEN A NEW HOLDER JOINS THE BATCH (finding D-4), so a front end that
2633// wants to warn a player must re-read it rather than cache it — and a player who has just
2634// earned entries can see that their own claim opened the window they are measured against.
2635func PendingSweepOpensAt() int64 {
2636 if pendingSince <= 0 {
2637 return 0
2638 }
2639 return pendingSince + pendingGraceBlocks
2640}
2641
2642// --- the raffle entries a duel earned (finding 15), readable ----------------
2643
2644// PendingEntryUnits is the total raffle entry units booked and not yet delivered.
2645// O(1).
2646func PendingEntryUnits() int64 { return pendingEntryUnits }
2647
2648// PendingEntryHolders is how many distinct addresses the undelivered batch holds.
2649// O(1); avl keeps its size. maxPendingEntryHolders is the bound (EntryHolderLimit).
2650func PendingEntryHolders() int64 { return int64(pendingEntries.Size()) }
2651
2652// PendingEntriesOf is the raffle entry units booked for one address and not yet
2653// delivered: what a player has earned by playing and not yet had credited in the
2654// raffle. Zero once a flush has delivered them, at which point the units are the
2655// raffle's to report (raffle.TicketsOfWeek). O(log n).
2656func PendingEntriesOf(holder address) int64 {
2657 return entryUnitsOf(string(holder))
2658}
2659
2660// EntryHolderLimit is maxPendingEntryHolders, exported so a front end can say why a
2661// player's entries were dropped rather than leaving them to guess. Read
2662// maxPendingEntryHolders for what happens at the bound and why it is a drop rather
2663// than a refusal.
2664func EntryHolderLimit() int64 { return maxPendingEntryHolders }
2665
2666// EntryUnitsForRake is rakeEntryUnits, readable: how many raffle entry units a rake
2667// of `rake` ugnot earns, at raffle.EntryUnitPrice. A front end can quote a rung's
2668// entries with StakeOfRung, and anybody can check the rate that makes this feature
2669// safe without reading the source. Plain arithmetic, no state.
2670func EntryUnitsForRake(rake int64) int64 {
2671 if rake <= 0 {
2672 return 0
2673 }
2674 return rakeEntryUnits(rake)
2675}
2676
2677// EntriesForRung is what one duel at `rung` earns its two players: the winner's
2678// share and the loser's, which sum to the rake-equivalent. Panics on an invalid rung
2679// index, exactly as Post would.
2680func EntriesForRung(rung int64) (winnerUnits, loserUnits int64) {
2681 return splitEntries(rakeEntryUnits(rakeOf(poolOf(stakeOf(rung)))))
2682}
2683
2684// DuelEntryEligibility reports whether each party of a duel played from a direct
2685// user call and will therefore earn raffle entries when it is collected: poster
2686// first, taker second. Both false for a duel that does not exist.
2687//
2688// A SEPARATE READ RATHER THAN TWO MORE ELEMENTS OF DuelOf, deliberately: DuelOf
2689// keeps the six-value shape SPEC-03 §4 published, and an arity change there is
2690// mechanical churn in every caller (the raffle's own audit records doing it three
2691// times in one file). Read Duel.PosterIsUser for what these flags do and do not
2692// affect — they have no bearing on who wins or who is paid.
2693func DuelEntryEligibility(duelID int64) (bool, bool) {
2694 d := getDuel(duelID)
2695 if d == nil {
2696 return false, false
2697 }
2698 return d.PosterIsUser, d.TakerIsUser
2699}
2700
2701// VoidStale refunds BOTH STAKES IN FULL, with no rake and no entries, for a duel that CAN
2702// NEVER BE SETTLED BY ANY CALLER — which since 2026-09-30 means exactly one thing: a LEGACY
2703// duel, accepted before the commit-reveal coin, carrying no commitment. PERMISSIONLESS.
2704//
2705// ITS PREMISE CHANGED COMPLETELY AND THE NAME IS KEPT ON PURPOSE. It was "the beacon-outage
2706// escape (SPEC-03 §6)": either party, after a 24-hour grace, when the bound round was STILL
2707// unpublished, because a stopped publisher would otherwise freeze every pending duel with
2708// both stakes locked on a contract that cannot be patched. THERE IS NO PUBLISHER AND NO
2709// ROUND ANY MORE, so that state does not exist: the poster can always reveal, and after
2710// revealWindowBlocks anybody can Forfeit. What survives is the one shape the old function
2711// was really for — a duel with no reachable settlement — and the only way to be in it now is
2712// to have been created before the change. The name is kept because it is the published exit
2713// for "this duel cannot settle, unwind it"; renaming it would churn every caller to say the
2714// same thing.
2715//
2716// WHY IT MUST EXIST AND MUST NOT BE Forfeit, WHICH IS REQUIREMENT 5. A legacy duel's poster
2717// has no secret and never had one: no commitment was recorded, so no preimage exists and
2718// Reveal cannot run for anybody. Routing that to Forfeit would pay the TAKER the poster's
2719// stake for a silence the poster could not have broken — and worse, it would make taking a
2720// legacy offer a free stake, which is why Accept refuses those outright. Both stakes go back.
2721//
2722// NO GRACE WINDOW, AND NO CALLER CHECK. Both were dropped, and both for the same reason:
2723// the record PROVES its own unsettleability from a field on itself (CommitHash == ""), so
2724// there is nothing to wait for and nothing for a caller check to protect. Waiting would only
2725// hold two refunds hostage to a clock; requiring a party would strand the pool if that party
2726// were a pre-D-1 realm with no code able to call anything. The destination is read from the
2727// record and is both parties, so a permissionless door cannot name a payee.
2728//
2729// NEVER ON A REVEALED DUEL, or it would be a way for a loser to cancel a loss — so that
2730// check comes first, as it always did. Unreachable together with the commitment check
2731// (nothing with an empty commitment can be revealed), and both are stated.
2732//
2733// NOTHING IS DEPLOYED, SO NO RECORD OF THIS SHAPE EXISTS TODAY. The lobby will be drained as
2734// a deployment step regardless. This function is here so that the code contains no trap:
2735// requirement 5 is that legacy records must not route to forfeit, and a comment saying "there
2736// are none" is not that.
2737func VoidStale(cur realm, duelID int64) {
2738 rejectAttachedCoin(cur, "VoidStale")
2739 d := getDuel(duelID)
2740 if d == nil {
2741 panic("duel: no such duel")
2742 }
2743 if d.Resolved {
2744 panic("duel: already revealed — a revealed duel cannot be voided")
2745 }
2746 if d.CommitHash != "" {
2747 panic("duel: this duel has a commitment and can be settled — Reveal it with the poster's secret, or Forfeit it to the taker once the reveal window has elapsed")
2748 }
2749
2750 stake := stakeOf(d.Rung)
2751 pool := poolOf(stake)
2752
2753 // Delete before paying, as everywhere else. releaseCommitment is called for symmetry
2754 // with the other exits and is a no-op here by construction (the commitment is empty);
2755 // it is called anyway so that no exit in this file is the one that forgets.
2756 duels.Remove(idKey(duelID))
2757 releaseCommitment(d.Poster, d.CommitHash)
2758 escrowed -= pool
2759
2760 // Two transfers even when poster == taker: "both stakes return" stated as
2761 // written, and the sum is the pool either way.
2762 wugnot.Transfer(cross(cur), d.Poster, stake)
2763 wugnot.Transfer(cross(cur), d.Taker, stake)
2764
2765 // NO RAKE AND NO ENTRIES ON THIS PATH, which is the OTHER arithmetic requirement 3
2766 // permits, and it is consistent: nothing is booked, so nothing is owed, and
2767 // checkInvariant's "entries with no rake behind them" case cannot arise. It is also the
2768 // right product answer — a duel that never had a coin to flip should cost its parties
2769 // nothing.
2770 chain.Emit("DuelVoid",
2771 "duel", strconv.FormatInt(duelID, 10),
2772 "poster", string(d.Poster),
2773 "taker", string(d.Taker),
2774 "stake", strconv.FormatInt(stake, 10),
2775 "reason", "no commitment: this duel predates the commit-reveal coin and could never be revealed")
2776 checkInvariant()
2777}
2778
2779// --- reads -----------------------------------------------------------------
2780
2781// OfferOf reports an offer: poster, rung, posted-at height. All zero for an
2782// offer that does not exist (including one that was cancelled or accepted —
2783// both delete it). Plain read. Keeps the three-value shape SPEC-03 §4
2784// published; the challenge fields are read through OfferChallenge.
2785func OfferOf(offerID int64) (address, int64, int64) {
2786 o := getOffer(offerID)
2787 if o == nil {
2788 return address(""), 0, 0
2789 }
2790 return o.Poster, o.Rung, o.PostedAt
2791}
2792
2793// OfferChallenge reports the part of an offer that makes it a challenge: the
2794// opponent it is addressed to ("" if open to anyone) and its label ("" if
2795// none). Both zero for an offer that does not exist. Plain read; the label is
2796// returned RAW, as stored — it is only Render that must escape it.
2797func OfferChallenge(offerID int64) (address, string) {
2798 o := getOffer(offerID)
2799 if o == nil {
2800 return address(""), ""
2801 }
2802 return o.Opponent, o.Label
2803}
2804
2805// OfferCommitment is the commitment an offer stands behind: H(commitTag ‖ secret) as 64
2806// lowercase hex characters. "" for an offer that does not exist AND for a LEGACY offer —
2807// which a caller can tell apart with OfferOf, since a non-existent offer has a zero poster.
2808// A taker should read this before accepting: an offer with no commitment cannot be accepted
2809// at all (Accept refuses it).
2810//
2811// A SEPARATE READ RATHER THAN A SEVENTH VALUE ON OfferOf, for the reason OfferChallenge
2812// gives: OfferOf keeps the three-value shape SPEC-03 §4 published.
2813func OfferCommitment(offerID int64) string {
2814 o := getOffer(offerID)
2815 if o == nil {
2816 return ""
2817 }
2818 return o.CommitHash
2819}
2820
2821// DuelLabel is the label a duel was accepted under, "" if none or if the duel
2822// does not exist. Raw, as OfferChallenge. DuelOf keeps its six-value shape.
2823func DuelLabel(duelID int64) string {
2824 d := getDuel(duelID)
2825 if d == nil {
2826 return ""
2827 }
2828 return d.Label
2829}
2830
2831// DuelCommitment is everything the coin is made of: the poster's commitment, the taker's
2832// nonce, and the secret once it has been revealed ("" before that). All three empty for a
2833// duel that does not exist, and the commitment empty for a LEGACY duel.
2834//
2835// IT IS WHAT MAKES A SETTLED DUEL CHECKABLE BY ANYBODY. With these three values and the
2836// duel's ID, a caller recomputes H(commitTag ‖ secret) against the commitment and
2837// H(outcomeTag ‖ secret ‖ nonce ‖ duelID) against the winner DuelOf reports, off chain, with
2838// no trust in this realm at all. The beacon version could not offer that: its randomness
2839// lived in another realm and was addressed by a round number.
2840func DuelCommitment(duelID int64) (commitHash, nonce, secret string) {
2841 d := getDuel(duelID)
2842 if d == nil {
2843 return "", "", ""
2844 }
2845 return d.CommitHash, d.Nonce, d.Secret
2846}
2847
2848// DuelOf reports a duel: poster, taker, rung, REVEAL DEADLINE, resolved, winner. All
2849// zero for a duel that does not exist — which, after Collect, Forfeit or VoidStale, is
2850// how a caller can see the record really was deleted (SPEC-03 §8 test 19).
2851// Plain read.
2852//
2853// THE FOURTH VALUE IS NO LONGER A BEACON ROUND, AND IT IS REPLACED RATHER THAN REMOVED.
2854// It used to be `Round` — the drand round the duel settled against — and there is no round
2855// any more. The slot now holds THE BLOCK HEIGHT AT WHICH THE REVEAL WINDOW CLOSES, i.e.
2856// AcceptedAt + revealWindowBlocks: the height from which anybody may Forfeit this duel to
2857// its taker. That keeps the six-value shape SPEC-03 §4 published — an arity change here is
2858// mechanical churn in every caller, which the raffle's audit records doing three times in
2859// one file — and it keeps the slot's MEANING, which was always "the clock this duel settles
2860// against". It is a DERIVED value, not a stored one; a duel carries AcceptedAt and the
2861// window is a constant.
2862//
2863// A CALLER THAT WANTS THE OLD FIELD WILL NOT FIND IT AND MUST NOT GUESS. Reading this as a
2864// round number gives a nonsense round in the tens of thousands, which is why the change is
2865// stated here, in the event log (DuelAccept's "revealBy" replaced "round"), in Render's
2866// column heading and in the addendum — a silently reinterpreted return value is the worst
2867// possible way to carry a change like this.
2868func DuelOf(duelID int64) (address, address, int64, int64, bool, address) {
2869 d := getDuel(duelID)
2870 if d == nil {
2871 return address(""), address(""), 0, 0, false, address("")
2872 }
2873 return d.Poster, d.Taker, d.Rung, d.AcceptedAt + revealWindowBlocks, d.Resolved, d.Winner
2874}
2875
2876// RevealWindowBlocks is revealWindowBlocks, exported so a front end can say how long a
2877// poster has to open their commitment before the duel can be forfeited to the taker, without
2878// hardcoding the number or reading it out of Render.
2879func RevealWindowBlocks() int64 { return revealWindowBlocks }
2880
2881// ForfeitOpensAt is the height from which anybody may Forfeit a duel to its taker: the same
2882// value DuelOf returns in its fourth slot. 0 for a duel that does not exist, and 0 for a
2883// LEGACY duel — which is never forfeitable at all and goes to VoidStale instead, so a front
2884// end that offers a forfeit button must treat 0 as "not this one".
2885func ForfeitOpensAt(duelID int64) int64 {
2886 d := getDuel(duelID)
2887 if d == nil || d.CommitHash == "" {
2888 return 0
2889 }
2890 return d.AcceptedAt + revealWindowBlocks
2891}
2892
2893// --- listers ---------------------------------------------------------------
2894//
2895// OfferOf and DuelOf answer "what is record N", and until these existed
2896// nothing answered "which N exist": a caller had to parse Render's markdown to
2897// find out what was on the board, which made a human-readable display into a
2898// load-bearing API. These four functions are the contract for enumeration.
2899// Plain reads, all of them: no state, no writes, nothing touched in
2900// settlement or escrow.
2901//
2902// THE IDS COME FROM THE TREES, NOT FROM A COUNTER. Post, Cancel, Accept,
2903// Collect and VoidStale each add to or delete from `offers` or `duels`, so a
2904// walk of the tree IS the set of live records, by construction; a separate
2905// list or count could only drift from it. And because idKey is fixed-width,
2906// avl's in-order walk is ascending ID order for free (SPEC-v1.md §7.7: the
2907// same order Render prints, on every node).
2908//
2909// BOUNDED, because the trees are not. Render lists everything, which is right
2910// for a page a human reads, but a query that returned every ID forever would
2911// grow in gas and payload with the lobby. So a call is a window: `offset`
2912// records in, at most `limit` IDs out, and `limit` is clamped to PageLimit.
2913// OfferCount and DuelCount are the totals a caller pages against. IDs only —
2914// the per-record readers above already exist, and a batched row reader would
2915// mean inventing a second serialisation for the same page to parse.
2916
2917// PageLimit is the most IDs one OfferIDs or DuelIDs call returns. A larger
2918// limit is clamped to it, not refused.
2919const PageLimit int64 = 100
2920
2921// OfferCount is the number of offers standing in the lobby. O(1): avl keeps
2922// its size.
2923func OfferCount() int64 {
2924 return int64(offers.Size())
2925}
2926
2927// DuelCount is the number of duels in flight: accepted and not yet collected
2928// or voided, resolved or not. O(1).
2929func DuelCount() int64 {
2930 return int64(duels.Size())
2931}
2932
2933// OfferIDs is a window over the standing offers' IDs in ascending order: the
2934// window skips `offset` offers and holds at most min(limit, PageLimit). Empty
2935// past the end. Negative arguments are a caller error and panic.
2936func OfferIDs(offset, limit int64) []int64 {
2937 return idsOf(offers, offset, limit)
2938}
2939
2940// DuelIDs is OfferIDs for the duels in flight.
2941func DuelIDs(offset, limit int64) []int64 {
2942 return idsOf(duels, offset, limit)
2943}
2944
2945// idsOf is the one window walk both listers share. IterateByOffset is avl's
2946// own offset-and-count traversal, O(log n + limit); it is not a full scan
2947// followed by a slice.
2948func idsOf(tree *avl.Tree, offset, limit int64) []int64 {
2949 if offset < 0 {
2950 panic("duel: negative offset")
2951 }
2952 if limit < 0 {
2953 panic("duel: negative limit")
2954 }
2955 if limit > PageLimit {
2956 limit = PageLimit
2957 }
2958 remaining := int64(tree.Size()) - offset
2959 if remaining < limit {
2960 limit = remaining
2961 }
2962 if limit <= 0 {
2963 return []int64{}
2964 }
2965 ids := make([]int64, 0, limit)
2966 tree.IterateByOffset(int(offset), int(limit), func(key string, _ any) bool {
2967 ids = append(ids, idOfKey(key))
2968 return false
2969 })
2970 return ids
2971}
2972
2973// idOfKey inverts idKey. A key that does not parse cannot have come from
2974// idKey, so it is a corrupt tree and not a case to paper over.
2975func idOfKey(key string) int64 {
2976 id, err := strconv.ParseInt(key, 10, 64)
2977 if err != nil {
2978 panic("duel: malformed id key " + key)
2979 }
2980 return id
2981}
2982
2983// StakeOfRung is the ladder, readable: the stake a rung index costs. Panics
2984// on an invalid index, exactly as Post would.
2985func StakeOfRung(rung int64) int64 {
2986 return stakeOf(rung)
2987}
2988
2989// RungCount is the number of rungs on the ladder.
2990func RungCount() int64 {
2991 return int64(len(rungs))
2992}
2993
2994// Escrowed is the wugnot this realm holds for open offers and uncollected
2995// duels. O(1); maintained incrementally.
2996func Escrowed() int64 {
2997 return escrowed
2998}
2999
3000// AuditFailure re-derives escrowed by iterating every record and returns the
3001// empty string when the books balance, otherwise why not. O(n) and READ-ONLY;
3002// never called from a state-changing path — checkInvariant is the O(1) guard
3003// for those. Exists so anyone can verify off-chain, and so the tests can.
3004func AuditFailure() string {
3005 i64 := func(v int64) string { return strconv.FormatInt(v, 10) }
3006
3007 sum := int64(0)
3008 failure := ""
3009 // heldCommitments counts the records that SHOULD be holding a pair in liveCommitments,
3010 // so the set can be re-derived from the records rather than trusted. Legacy records hold
3011 // none and are not counted.
3012 heldCommitments := 0
3013 offers.Iterate("", "", func(key string, value any) bool {
3014 o := value.(*Offer)
3015 if o.Rung < 0 || o.Rung >= int64(len(rungs)) {
3016 failure = "offer " + key + " has an invalid rung"
3017 return true
3018 }
3019 if len(o.Label) > maxLabelLen {
3020 failure = "offer " + key + " has a label over the bound"
3021 return true
3022 }
3023 if o.CommitHash != "" {
3024 if !isLowerHex32(o.CommitHash) {
3025 failure = "offer " + key + " has a malformed commitment"
3026 return true
3027 }
3028 if !liveCommitments.Has(commitKey(o.Poster, o.CommitHash)) {
3029 failure = "offer " + key + " holds a commitment that is not in the live set"
3030 return true
3031 }
3032 heldCommitments++
3033 }
3034 sum += rungs[o.Rung]
3035 return false
3036 })
3037 if failure != "" {
3038 return failure
3039 }
3040 duels.Iterate("", "", func(key string, value any) bool {
3041 d := value.(*Duel)
3042 if d.Rung < 0 || d.Rung >= int64(len(rungs)) {
3043 failure = "duel " + key + " has an invalid rung"
3044 return true
3045 }
3046 if d.Resolved && d.Winner == "" {
3047 failure = "duel " + key + " is resolved but names no winner"
3048 return true
3049 }
3050 if !d.Resolved && d.Winner != "" {
3051 failure = "duel " + key + " is unresolved but already names a winner"
3052 return true
3053 }
3054 if d.Resolved && d.Winner != d.Poster && d.Winner != d.Taker {
3055 failure = "duel " + key + " names a winner who is not a party to it"
3056 return true
3057 }
3058 if len(d.Label) > maxLabelLen {
3059 failure = "duel " + key + " has a label over the bound"
3060 return true
3061 }
3062 if d.CommitHash != "" {
3063 if !isLowerHex32(d.CommitHash) {
3064 failure = "duel " + key + " has a malformed commitment"
3065 return true
3066 }
3067 if !isLowerHex32(d.Nonce) {
3068 failure = "duel " + key + " has a malformed nonce"
3069 return true
3070 }
3071 if !liveCommitments.Has(commitKey(d.Poster, d.CommitHash)) {
3072 failure = "duel " + key + " holds a commitment that is not in the live set"
3073 return true
3074 }
3075 heldCommitments++
3076 }
3077 // THE SETTLEMENT ITSELF IS RE-DERIVED, WHICH THE BEACON VERSION COULD NOT DO. A
3078 // resolved duel carries the secret that opened it, so the whole coin can be
3079 // recomputed from the record alone: the secret must open the commitment, and the
3080 // winner must be the one the outcome hash names. Nothing here trusts the stored
3081 // Winner — that is the point. It is the one invariant in this file that checks the
3082 // realm's PURPOSE rather than its books, and it is only possible because Reveal
3083 // stores the secret (Duel.Secret).
3084 if d.Resolved {
3085 if d.CommitHash == "" {
3086 failure = "duel " + key + " is resolved but carries no commitment, so its outcome cannot be re-derived"
3087 return true
3088 }
3089 if !isLowerHex32(d.Secret) {
3090 failure = "duel " + key + " is resolved but its revealed secret is malformed"
3091 return true
3092 }
3093 if commitmentOf(d.Secret) != d.CommitHash {
3094 failure = "duel " + key + " is resolved with a secret that does not open its commitment"
3095 return true
3096 }
3097 // EXCEPT FOR A SELF-MATCHED DUEL, WHERE THE CHECK IS NOT EXPRESSIBLE AND SAYING
3098 // SO IS BETTER THAN A FALSE FAILURE. When poster == taker (SPEC-03 §7.3) the two
3099 // parties are one address, so `d.Winner == d.Poster` is true whichever side the
3100 // coin came down on and no comparison against the parity can distinguish them.
3101 // The winner-is-a-party check above already covers everything that could go
3102 // wrong with such a record, and the money is identical either way.
3103 if d.Poster != d.Taker {
3104 wantPoster := posterWins(d.Secret, d.Nonce, idOfKey(key))
3105 if wantPoster != (d.Winner == d.Poster) {
3106 failure = "duel " + key + " names a winner the outcome hash does not"
3107 return true
3108 }
3109 }
3110 } else if d.Secret != "" {
3111 failure = "duel " + key + " is unrevealed but already holds a secret"
3112 return true
3113 }
3114 sum += 2 * rungs[d.Rung]
3115 return false
3116 })
3117 if failure != "" {
3118 return failure
3119 }
3120 // THE LIVE COMMITMENT SET IS RE-DERIVED, NOT TRUSTED, exactly as `escrowed` and
3121 // pendingEntryUnits are. Every live record's pair was checked present above, so equal
3122 // sizes means the set is EXACTLY the records' pairs: no leak (which would silently refuse
3123 // an honest poster's next Post) and no premature release (which would re-admit the reuse
3124 // the set exists to refuse).
3125 if liveCommitments.Size() != heldCommitments {
3126 return "the live commitment set holds " + strconv.Itoa(liveCommitments.Size()) +
3127 " pairs but the records account for " + strconv.Itoa(heldCommitments)
3128 }
3129 if sum != escrowed {
3130 return "records hold " + i64(sum) + " in stakes but escrowed is " + i64(escrowed)
3131 }
3132 if pendingPot < 0 {
3133 return "the pending rake is negative: " + i64(pendingPot)
3134 }
3135 // THE PENDING RAKE IS PART OF WHAT THIS REALM OWES, so the balance test includes it.
3136 // A booked rake the realm does not hold would be a promise to the raffle funded out of
3137 // players' stakes, and it must not be able to hide behind the escrow figure.
3138 if pendingPot > 0 && pendingSince <= 0 {
3139 return "a rake of " + i64(pendingPot) + " is booked with no booking height"
3140 }
3141 if pendingPot == 0 && pendingSince != 0 {
3142 return "no rake is booked but the booking height is " + i64(pendingSince)
3143 }
3144 // THE BOOKED ENTRIES, RE-DERIVED BY ITERATION exactly as `escrowed` is, because
3145 // pendingEntryUnits is maintained incrementally and a counter that nothing
3146 // re-derives is a counter that can drift. The rate is checked against the booked
3147 // rake for the reason checkInvariant states: the rate IS finding 15's security
3148 // property, and a batch this realm could not pay for is a batch the raffle would
3149 // refuse — a stuck flush rather than a loud failure here.
3150 entrySum := int64(0)
3151 entryFailure := ""
3152 pendingEntries.Iterate("", "", func(key string, value any) bool {
3153 u := value.(int64)
3154 if u <= 0 {
3155 entryFailure = "pending entries for " + key + " are not positive: " + i64(u)
3156 return true
3157 }
3158 if !address(key).IsValid() {
3159 entryFailure = "pending entries are booked for " + key + ", which is not a valid address"
3160 return true
3161 }
3162 entrySum += u
3163 return false
3164 })
3165 if entryFailure != "" {
3166 return entryFailure
3167 }
3168 if entrySum != pendingEntryUnits {
3169 return "the pending entry tree holds " + i64(entrySum) +
3170 " units but pendingEntryUnits is " + i64(pendingEntryUnits)
3171 }
3172 if pendingEntries.Size() > maxPendingEntryHolders {
3173 return "the pending entry batch holds " + strconv.Itoa(pendingEntries.Size()) +
3174 " holders, over the bound of " + strconv.Itoa(maxPendingEntryHolders)
3175 }
3176 if pendingEntryUnits > 0 && pendingPot <= 0 {
3177 return "raffle entries are booked (" + i64(pendingEntryUnits) + " units) with no rake behind them"
3178 }
3179 if max := maxEntryUnitsFor(pendingPot); pendingEntryUnits > max {
3180 return "the booked raffle entries (" + i64(pendingEntryUnits) +
3181 " units) exceed what the booked rake of " + i64(pendingPot) +
3182 " would buy at the ticket price (" + i64(max) + " units)"
3183 }
3184 bal := wugnot.BalanceOf(selfAddr())
3185 if bal < escrowed+pendingPot {
3186 return "the realm holds " + i64(bal) + " but owes " + i64(escrowed) +
3187 " in escrow plus " + i64(pendingPot) + " in booked rake"
3188 }
3189 return ""
3190}
3191
3192// AuditInvariant is AuditFailure as a bool.
3193func AuditInvariant() bool {
3194 return AuditFailure() == ""
3195}
3196
3197// --- render ----------------------------------------------------------------
3198
3199// renderCell is the ONLY way user-originated text reaches Render's output, and
3200// everything below that prints an address or a label goes through it. The rule
3201// (SPEC-v1.md §7.6) is that any user-originated text reaching Render is
3202// sanitised, not that it is assumed clean. sanitize.InlineText escapes
3203// markdown and HTML metacharacters and folds any line break to a space; the
3204// pipe is escaped on top of that because every such value here is printed
3205// inside a markdown table cell, where a raw pipe would break the row. (This is
3206// what sanitize.TableCell does too; it is spelled out here so the escaping is
3207// visible at the one place it happens.) Applied exactly once per value: the
3208// escapers are not idempotent.
3209func renderCell(s string) string {
3210 return strings.ReplaceAll(sanitize.InlineText(s), "|", "\\|")
3211}
3212
3213// renderAddr renders an address. Every address this realm stores came from
3214// cur.Previous().Address() or passed IsValid and is bech32 — but it is
3215// sanitised anyway, for the reason renderCell gives.
3216func renderAddr(a address) string {
3217 return renderCell(string(a))
3218}
3219
3220// renderLabel renders a label, which IS arbitrary user text (within checkLabel's
3221// bounds) and is the one place in this realm where the sanitiser is not a
3222// formality. "-" for no label, so the column is never visually empty.
3223func renderLabel(label string) string {
3224 if label == "" {
3225 return "-"
3226 }
3227 return renderCell(label)
3228}
3229
3230// renderOpponent renders an offer's opponent column: "anyone" for an open
3231// offer, the address otherwise.
3232func renderOpponent(a address) string {
3233 if a == "" {
3234 return "anyone"
3235 }
3236 return renderAddr(a)
3237}
3238
3239// Render is the realm's human-readable view: the ladder, the open lobby and
3240// the duels in flight. It ITERATES THE avl.Trees in key order, which is ID
3241// order because idKey is fixed-width, so the output is byte-identical across
3242// calls and across nodes (SPEC-v1.md §7.7). Only the empty path is meaningful;
3243// any other path is answered without echoing it.
3244func Render(path string) string {
3245 i64 := strconv.FormatInt
3246
3247 out := "# Splash Play: the Duel\n\n"
3248 if path != "" {
3249 return out + "Unknown path.\n"
3250 }
3251
3252 out += "Two players, a matched stake in GNOT, 50/50, winner takes both less a 3% rake.\n\n"
3253 out += "The coin is a two-party commit-reveal: the poster posts H(tag|secret) with their stake, " +
3254 "the taker sends a 32-byte nonce with theirs, and the winner is the parity of " +
3255 "H(tag|secret|nonce|duelID). The poster cannot grind it (the nonce does not exist yet) " +
3256 "and the taker cannot steer it (they choose the nonce without the secret). " +
3257 "THE SECRET MUST COME FROM A CSPRNG AND MUST BE FRESH FOR EVERY OFFER: the commitment is a " +
3258 "free offline oracle, so a guessable secret is robbed silently by a reveal nobody can tell " +
3259 "from an honest one.\n\n"
3260 out += "- stakes are wrapped GNOT (r/gnoland/wugnot); amounts below are in ugnot, 1 GNOT = 1000000 ugnot\n"
3261 out += "- reveal window: " + i64(revealWindowBlocks, 10) +
3262 " blocks (~24h) after Accept; after that anybody may Forfeit the duel to the taker\n"
3263 out += "- rake: " + i64(rakeBps, 10) + " bps of the pool, split 60% into this week's raffle pot / 40% ops\n"
3264 out += "- escrowed: " + i64(escrowed, 10) + " ugnot\n"
3265 out += "- raffle rake booked and awaiting delivery: " + i64(pendingPot, 10) +
3266 " ugnot (anyone may call FlushPot)\n"
3267 out += "- raffle entries earned and awaiting delivery: " + i64(pendingEntryUnits, 10) +
3268 " units for " + strconv.Itoa(pendingEntries.Size()) + " players (limit " +
3269 i64(maxPendingEntryHolders, 10) + ")\n"
3270 out += "- playing earns entries in this week's raffle draw: at least one each, the rest weighted two thirds to the loser, priced at " +
3271 i64(raffle.EntryUnitPrice, 10) + " ugnot of rake per entry\n"
3272 out += "- open offers: " + strconv.Itoa(offers.Size()) + "\n"
3273 out += "- duels in flight: " + strconv.Itoa(duels.Size()) + "\n"
3274
3275 // THE LADDER CARRIES THE ENTRY COLUMNS, because "what do I get for this rung" is
3276 // now two questions and a player should not have to do the arithmetic. Appended
3277 // columns, so a row is still findable by its leading rung and stake.
3278 out += "\n## Ladder\n\n| rung | stake (ugnot) | stake (GNOT) | raffle entries: winner | loser |\n|---|---|---|---|---|\n"
3279 for i := 0; i < len(rungs); i++ {
3280 w, l := splitEntries(rakeEntryUnits(rakeOf(poolOf(rungs[i]))))
3281 out += "| " + strconv.Itoa(i) + " | " + i64(rungs[i], 10) + " | " + i64(rungs[i]/1_000_000, 10) +
3282 " | " + i64(w, 10) + " | " + i64(l, 10) + " |\n"
3283 }
3284
3285 // THE UNDELIVERED ENTRY BATCH, and it sits between the ladder and the lobby
3286 // deliberately: the lobby and duel tables are parsed by marker in the suite and
3287 // nothing may come between them. Iterated in avl key order, which is address
3288 // order, so the output is byte-identical across calls and across nodes.
3289 if pendingEntries.Size() > 0 {
3290 out += "\n## Raffle entries earned and awaiting delivery\n\n"
3291 out += "Anyone may deliver these to this week's draw with FlushPot.\n\n"
3292 out += "| player | entry units |\n|---|---|\n"
3293 pendingEntries.Iterate("", "", func(key string, value any) bool {
3294 out += "| " + renderAddr(address(key)) + " | " + i64(value.(int64), 10) + " |\n"
3295 return false
3296 })
3297 }
3298
3299 // Column order in both tables keeps "| id | poster |" (and "| taker |")
3300 // leading, so a row is still findable by its parties; the challenge
3301 // columns are appended after them.
3302 out += "\n## Lobby\n\n"
3303 if offers.Size() == 0 {
3304 out += "No open offers.\n"
3305 } else {
3306 out += "| offer | poster | rung | stake | posted at | opponent | label |\n|---|---|---|---|---|---|---|\n"
3307 offers.Iterate("", "", func(key string, value any) bool {
3308 o := value.(*Offer)
3309 out += "| " + strings.TrimLeft(key, "0") +
3310 " | " + renderAddr(o.Poster) +
3311 " | " + i64(o.Rung, 10) +
3312 " | " + i64(rungs[o.Rung], 10) +
3313 " | " + i64(o.PostedAt, 10) +
3314 " | " + renderOpponent(o.Opponent) +
3315 " | " + renderLabel(o.Label) + " |\n"
3316 return false
3317 })
3318 }
3319
3320 out += "\n## Duels\n\n"
3321 if duels.Size() == 0 {
3322 out += "No duels in flight.\n"
3323 } else {
3324 // THE "round" COLUMN IS REPLACED BY "reveal by", NOT RENAMED IN PLACE WITH THE SAME
3325 // NUMBER UNDERNEATH: there is no round, and the height printed here is the block from
3326 // which anybody may Forfeit. Same reasoning as DuelOf's fourth value.
3327 out += "| duel | poster | taker | stake | reveal by | status | winner | label |\n|---|---|---|---|---|---|---|---|\n"
3328 duels.Iterate("", "", func(key string, value any) bool {
3329 d := value.(*Duel)
3330 status := "awaiting the poster's reveal"
3331 winner := "-"
3332 switch {
3333 case d.Resolved:
3334 status = "revealed, awaiting collect"
3335 winner = renderAddr(d.Winner)
3336 case d.CommitHash == "":
3337 // LEGACY: unrevealable and unforfeitable, and the page must say which exit it
3338 // has rather than showing a deadline that means nothing.
3339 status = "no commitment (legacy): void it"
3340 case runtime.ChainHeight()-d.AcceptedAt >= revealWindowBlocks:
3341 status = "reveal window elapsed: anybody may forfeit it to the taker"
3342 }
3343 revealBy := "-"
3344 if d.CommitHash != "" {
3345 revealBy = i64(d.AcceptedAt+revealWindowBlocks, 10)
3346 }
3347 out += "| " + strings.TrimLeft(key, "0") +
3348 " | " + renderAddr(d.Poster) +
3349 " | " + renderAddr(d.Taker) +
3350 " | " + i64(rungs[d.Rung], 10) +
3351 " | " + revealBy +
3352 " | " + status +
3353 " | " + winner +
3354 " | " + renderLabel(d.Label) + " |\n"
3355 return false
3356 })
3357 }
3358 return out
3359}