duel.gno
75.14 Kb · 1727 lines
1// Package duel implements the first slice of Splash Play I, the Duel,
2// settled from r/splash/beacon instead of a two-party commit-reveal
3// (SPEC-09-duel-beacon.md v1.0, ../../../../SPEC-09-duel-beacon.md).
4//
5// TEMPORARY NAME. "duel" is scratch space, built beside the LIVE
6// r/splash/arcade/duel so that realm keeps working while this one is proven.
7// The two are swapped in one commit once this one is ready, and the deploy
8// path is decided then (SPEC-09 is silent on it on purpose). Nothing here
9// should be linked to, or depended on, from outside this directory.
10//
11// THIS SLICE IS Post, Cancel, Accept AND THE ROUND PIN. No Resolve — that is
12// slice 2 (SPEC-09 §3 describes the whole lifecycle; the part after Accept is
13// not built yet). A duel created here therefore has no payout path: it
14// exists so the pin itself, SPEC-09's "only subtle part" (§3.1), can be built
15// and proved on its own before anything is wired to money leaving the realm.
16//
17// WHY COMMIT-REVEAL IS GONE RATHER THAN CARRIED OVER (SPEC-09 §1). The old
18// duel moved OFF the beacon on 2026-09-30 because Accept needed an UPPER
19// bound on drand's head ("round R is not signed yet"), and no arithmetic over
20// a RELAYED round could ever establish one: a signature proves a round
21// EXISTS (a lower bound), and the absence of one is not provable on chain,
22// because withholding a relay is free and invisible. beacon.CurrentRound(),
23// added 2026-10-08, derives drand's round from the BLOCK'S OWN CLOCK instead
24// — no relayer in the path — so that upper bound now exists, and the whole
25// reason commit-reveal's complexity existed is gone with it. What carries
26// over is not the lifecycle but four audit findings about THIS REALM'S OWN
27// escrow and money handling that were never specific to commit-reveal
28// (SPEC-09 §8): rejectAttachedCoin and requireUserCall below, no transfer
29// destination ever being a caller-supplied argument, and (for later slices)
30// one clock/one derived value and no sentinel field standing in for a height.
31//
32// THE STAKE IS WRAPPED GNOT, exactly as in r/splash/arcade/duel: this realm
33// escrows gno.land/r/gnoland/wugnot and touches no other token.
34//
35// NO Resolved, NO Winner, NO Secret, NO Nonce, NO NonceRevealedAt, NO
36// TakerCommit, AND NO DEADLINE OF ANY KIND IN THIS SLICE'S STATE (SPEC-09 §4,
37// §8). Resolve (slice 2) pays and deletes a duel in one call, so a resolved
38// duel will never exist to be recorded; there is nothing here for a sentinel
39// "never happened yet" value to be confused with a real height.
40package duel
41
42import (
43 "crypto/sha256"
44 "math/bits"
45 "strconv"
46 "unicode/utf8"
47
48 "chain"
49 "chain/runtime"
50 "chain/runtime/unsafe"
51
52 "gno.land/p/nt/avl/v0"
53 "gno.land/p/nt/markdown/sanitize/v0"
54
55 "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4"
56 "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/beacon/v2"
57 "gno.land/r/gnoland/wugnot"
58)
59
60// --- the economics (SPEC-09 §5: "unchanged, deliberately") ---------------
61//
62// rakeBps, bpsDenom, rakePotShareBps and rakeOpsShareBps, and the rungs
63// ladder below, are copied VERBATIM from r/splash/arcade/duel/duel.gno,
64// which in turn cites BIBLE.md "Arcade rake" §2.6/§2.7 and SPEC-03 §5/§9.
65// SPEC-09 §5 is explicit that nothing about the economics changes in this
66// rewrite — only how the coin is flipped — so these numbers are re-stated,
67// not re-derived. This slice stops at having them present and the stake
68// escrowed: rakeOf/splitRake and the raffle entries they feed belong to
69// Resolve, slice 2, and are not needed until a payout exists to apply them
70// to.
71
72const (
73 // rakeBps is the arcade rake: 300 bps of the pool, i.e. of 2 x stake.
74 rakeBps = 300
75 bpsDenom = 10000
76
77 // The rake splits 60% to the current raffle pot / 40% to ops, ops
78 // floored, dust to the pot (SPEC-09 §5).
79 //
80 // rakePotShareBps IS A THIRD COPY OF THE SAME NUMBER, NOT JUST A SECOND
81 // ONE — r/splash/arcade/duel/duel.gno's identical constant is the first
82 // copy, and r/splash/arcade/raffle/raffle.gno's arcadeRakePotShareBps
83 // (raffle.gno:3218) is the second, restated there because "the import
84 // only goes one way" (raffle.gno, same comment block): the raffle cannot
85 // import either duel to read their constant, so it keeps its own. This
86 // file is in the same position relative to the raffle, for the same
87 // reason, so it keeps a third.
88 //
89 // THE RAFFLE SAYS, IN ITS OWN SOURCE, WHY A COPY LIKE THIS ONE IS
90 // DANGEROUS ON ITS OWN: raffle.gno:3214 requires
91 // "raffle.ArcadeEntryUnitsBound(potShare) to equal it, to the entry",
92 // and raffle.gno:3276-3277 exports that function "so the duel's suite
93 // can pin the two realms' arithmetic to each other" — an invitation,
94 // not a decoration. maxEntryUnitsFor below is this realm's restatement
95 // of that same bound (raffle.gno:3286-3291), and if this constant ever
96 // drifts from the raffle's WITHOUT raffle.ArcadeEntryUnitsBound moving
97 // with it — in particular if it is ever LOWERED, which loosens this
98 // file's gross-up — checkInvariant below keeps passing (it only checks
99 // pendingEntryUnits against THIS FILE'S OWN maxEntryUnitsFor), but every
100 // raffle.FundPotWithEntries call FlushPot makes then finds its units (or
101 // their total) over the RAFFLE'S bound and panics, unconditionally
102 // (raffle.gno:3449-3455). FlushPot is therefore refused for that batch
103 // forever, and the only door left is SweepStuckRake, which — after
104 // pendingGraceBlocks — donates the WHOLE booked pot share (the players'
105 // 60%) into the raffle's ops pot and drops every entry riding with it.
106 // Money enters, the correct exit (FlushPot) closes permanently, and the
107 // wrong one (SweepStuckRake) pays the house — and nothing in this file
108 // would report it, because nothing here checks this number against the
109 // raffle's own. TestMaxEntryUnitsForMatchesRafflesArcadeEntryUnitsBound
110 // in duel_test.gno is the pin against that drift, ported from the old
111 // duel's TestFix15_TheRafflesEntryBoundIsExactlyThisRealmsRakeEquivalent
112 // (duel_test.gno:3338,3341) and TestFix26 (duel_test.gno:3742,3747); the
113 // compile-time floor two lines below is the second half of that pin —
114 // read ITS comment for what it stops that the test, being a test, can
115 // only catch after the fact.
116 rakePotShareBps = 6000
117 rakeOpsShareBps = 4000
118)
119
120// A negative array length is a compile error, so this line fails the BUILD
121// if a future edit pushes the two shares past the denominator — copied from
122// r/splash/arcade/duel/duel.gno's identical guard.
123var _ [bpsDenom - rakePotShareBps - rakeOpsShareBps]struct{}
124
125// THE PLAYERS' SHARE MUST BE AT LEAST HALF THE RAKE — same idiom, same
126// value and the same reasoning as r/splash/arcade/raffle/raffle.gno's
127// identical guard on its own copy of this number (raffle.gno:3238, "a
128// negative array length is a compile error"). rakePotShareBps's own comment
129// above explains WHY a drift here is dangerous (it silently closes
130// FlushPot and opens SweepStuckRake as the real exit); this line is HALF of
131// what stops that drift happening in the first place — the other half is
132// TestMaxEntryUnitsForMatchesRafflesArcadeEntryUnitsBound in
133// duel_test.gno. The test alone is not enough: a test only runs when
134// somebody runs it, but a build fails every time, for every editor,
135// including one who never thinks to run this package's suite before
136// touching this constant. Note what this guard does NOT do: the
137// bpsDenom-sum guard just above already stops the two shares overflowing
138// the denominator, but says nothing about EITHER share's own floor — a drop
139// to e.g. rakePotShareBps=1000/rakeOpsShareBps=9000 still sums to bpsDenom
140// and would sail past that guard while being exactly the dangerous drift
141// this one exists to catch.
142var _ [rakePotShareBps - 5_000]struct{}
143
144// rungs is the stake ladder, in ugnot (GNOT has 6 decimals): 10 / 50 / 200 /
145// 1,000 GNOT. FIXED, never mutable. Copied verbatim from
146// r/splash/arcade/duel/duel.gno; SPEC-03 §9 records these as a free dial and
147// SPEC-09 §5 carries them forward unchanged.
148const (
149 rung0 = 10_000_000 // 10 GNOT
150 rung1 = 50_000_000 // 50 GNOT
151 rung2 = 200_000_000 // 200 GNOT
152 rung3 = 1_000_000_000 // 1,000 GNOT
153)
154
155var rungs = [...]int64{rung0, rung1, rung2, rung3}
156
157// maxStake is the largest stake the ladder can ever name — rung3 — used to
158// bound poolOf's and rakeOf's inputs before any arithmetic touches them.
159// Copied verbatim from r/splash/arcade/duel/duel.gno.
160const maxStake = rung3
161
162func init() {
163 for i := 1; i < len(rungs); i++ {
164 if rungs[i] <= rungs[i-1] {
165 panic("duel: the stake ladder must ascend")
166 }
167 }
168}
169
170// opsVaultAddr is gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/ops, the
171// operations share of the rake's destination. SAME CONSTANT, SAME DERIVATION,
172// SAME REASONING as r/splash/arcade/duel/duel.gno's identical constant: the
173// realm at that path need not exist yet for the address to be right, and it
174// is a plain wugnot.Transfer destination, not a function call gated by any
175// package-path check.
176const opsVaultAddr address = "g1dqnxfp5zj89ggfcfzvampmhmrkjq36d2zvdyk7"
177
178// raffleRealmAddr is the address of the raffle this file IMPORTS, and the two
179// must move together or FlushPot silently approves the wrong account.
180//
181// THE COMMENT HERE USED TO SAY "no /v2 suffix exists yet in this repo" and
182// promised the raffle would move "at its own redeploy". It has now moved
183// twice, to /v3 and then /v4, and this constant did not follow it the first
184// time anybody looked. That is the whole hazard of an address DERIVED FROM A
185// PACKAGE PATH: the import above changes with a one-line edit and this literal
186// does not, and nothing about the result looks wrong until a cross-realm call
187// fails on an allowance that was granted to a realm nobody is using.
188//
189// Caught on 2026-10-08 by the duel's own FlushPot test failing with
190// "insufficient allowance" the moment the import went to /v4 — which is the
191// test doing exactly its job. tools/raffle_addr_test.go derives this from the
192// raffle's gnomod.toml and fails if the two disagree; believe that guard over
193// this comment.
194//
195// Used as a wugnot.Transfer destination (SweepStuckRake) and as the Approve
196// target in FlushPot. Never as a gate: the raffle's entry doors gate on
197// PACKAGE PATH, not on an address.
198const raffleRealmAddr address = "g1lmd9dxwefss54vtf80jscnds6a7k4ec86873c3"
199
200// mulDiv, poolOf, rakeOf and splitRake are copied verbatim (arithmetic and
201// bounds) from r/splash/arcade/duel/duel.gno — SPEC-09 §5: "nothing about the
202// economics changes". Comments are condensed to a pointer at the old file
203// rather than reproduced in full; read poolOf/rakeOf/splitRake there for the
204// complete reasoning (mulDiv's 128-bit product, the floor-to-ops/dust-to-pot
205// rule, and the two real (non-tautological) guards on splitRake).
206
207// mulDiv returns floor(a*b/d) via a 128-bit intermediate product: raw a*b/d
208// on token-scale int64s wraps silently on the GnoVM (tools/muldiv_probe.gno).
209func mulDiv(a, b, d uint64) uint64 {
210 hi, lo := bits.Mul64(a, b)
211 if d == 0 {
212 panic("duel: mulDiv: division by zero")
213 }
214 if hi >= d {
215 panic("duel: mulDiv: overflow")
216 }
217 q, _ := bits.Div64(hi, lo, d)
218 return q
219}
220
221// poolOf is 2 x stake, the whole pot of a duel, with the input bounded first.
222func poolOf(stake int64) int64 {
223 if stake <= 0 || stake > maxStake {
224 panic("duel: stake out of bounds")
225 }
226 return 2 * stake
227}
228
229// rakeOf is the rake on a pool: floor(pool x rakeBps / bpsDenom).
230func rakeOf(pool int64) int64 {
231 if pool <= 0 || pool > 2*maxStake {
232 panic("duel: pool out of bounds")
233 }
234 return int64(mulDiv(uint64(pool), rakeBps, bpsDenom))
235}
236
237// splitRake divides a rake 60/40 into the raffle pot share and the ops
238// share. Ops is floored; the pot takes the remainder and all the dust, so
239// potShare + opsShare == rake exactly, always.
240func splitRake(rake int64) (potShare, opsShare int64) {
241 if rake < 0 {
242 panic("duel: negative rake")
243 }
244 opsShare = int64(mulDiv(uint64(rake), rakeOpsShareBps, bpsDenom))
245 potShare = rake - opsShare
246 if potShare < 0 {
247 panic("duel: rake split does not sum to the rake: pot share is negative")
248 }
249 if potShare < int64(mulDiv(uint64(rake), rakePotShareBps, bpsDenom)) {
250 panic("duel: rake split shorts the pot share")
251 }
252 return potShare, opsShare
253}
254
255// rakeEntryUnits and splitEntries are copied verbatim (arithmetic, bounds and
256// the three asserted invariants) from r/splash/arcade/duel/duel.gno. Read
257// that file's identical functions for the full history (AUDIT-2026-09-28.md
258// findings 15, 22 and 26) — condensed here to what each check enforces.
259
260// rakeEntryUnits is how many raffle entry units a rake of `rake` ugnot earns:
261// exactly what that money would have bought at raffle.EntryUnitPrice.
262// raffle.EntryUnitPrice is read as a compile-time constant of another realm,
263// never a call, so this adds no runtime dependency on the raffle.
264func rakeEntryUnits(rake int64) int64 {
265 if rake < 0 {
266 panic("duel: negative rake")
267 }
268 return rake / raffle.EntryUnitPrice
269}
270
271// splitEntries divides a duel's earned entries between its winner and its
272// loser: each player is guaranteed one entry whenever the total can pay for
273// two (total >= 2), and the remainder is weighted two thirds to the loser.
274func splitEntries(total int64) (winnerUnits, loserUnits int64) {
275 if total < 0 {
276 panic("duel: negative entry total")
277 }
278 switch {
279 case total == 0:
280 winnerUnits, loserUnits = 0, 0
281 case total == 1:
282 winnerUnits, loserUnits = 0, 1
283 default:
284 winnerUnits = total / 3
285 if winnerUnits < 1 {
286 winnerUnits = 1
287 }
288 loserUnits = total - winnerUnits
289 }
290 if winnerUnits+loserUnits != total {
291 panic("duel: entry split does not sum to the total")
292 }
293 if loserUnits < winnerUnits {
294 panic("duel: entry split shorts the loser")
295 }
296 if total >= 2 && (winnerUnits < 1 || loserUnits < 1) {
297 panic("duel: entry split leaves a player with no entry out of a total that can pay both")
298 }
299 return winnerUnits, loserUnits
300}
301
302// maxEntryUnitsFor is the most booked entry units a booked pot share of
303// `potShare` ugnot may ever carry — the same ceiling r/splash/arcade/raffle
304// applies at its door (raffle.ArcadeEntryUnitsBound, raffle.gno:3286-3291),
305// computed here from this realm's own constants so checkInvariant can assert
306// it without calling another realm. Copied verbatim from
307// r/splash/arcade/duel/duel.gno. A THIRD COPY, same situation as
308// rakePotShareBps above — read THAT constant's comment for what happens if
309// this one ever disagrees with the raffle's own
310// (raffle.gno:3449-3455/FlushPot/SweepStuckRake) — and PINNED the same way:
311// TestMaxEntryUnitsForMatchesRafflesArcadeEntryUnitsBound in
312// duel_test.gno asserts this function equals raffle.ArcadeEntryUnitsBound,
313// to the entry, exactly as the old duel's
314// TestFix15_TheRafflesEntryBoundIsExactlyThisRealmsRakeEquivalent
315// (duel_test.gno:3338,3341,3742,3747) pins the old duel's identical copy.
316func maxEntryUnitsFor(potShare int64) int64 {
317 if potShare <= 0 {
318 return 0
319 }
320 gross := int64(mulDiv(uint64(potShare), bpsDenom, rakePotShareBps))
321 return gross / raffle.EntryUnitPrice
322}
323
324// --- the pin (SPEC-09 §3.1 — "the only subtle part") -----------------------
325
326const (
327 // pinOffsetRounds IS A SECURITY BUDGET, IN SECONDS, NOT ONLY A UX DIAL
328 // FOR HOW LONG A PLAYER WAITS — stated plainly because only the wait
329 // half has ever been documented here. The attack this margin prevents:
330 // beacon.CurrentRound() derives drand's round from the BLOCK'S OWN
331 // CLOCK (this file's package doc comment), so if that clock ever lags
332 // drand's REAL head by more than this margin, the round Accept pins is
333 // already signed in the real world, its randomness is fetchable from
334 // drand's public API right now, and a taker who checks it before
335 // calling Accept can simply decline to send that call when the outcome
336 // is unfavourable — turning a 50/50 game into a free option. Given
337 // bucketRounds's round-up, the reachable pin for any Accept is
338 // [current+pinOffsetRounds+1, current+pinOffsetRounds+bucketRounds]
339 // rounds ahead (pinRound's own doc comment derives this), so at
340 // pinOffsetRounds=100, bucketRounds=100, drand's 3-second period, the
341 // MINIMUM margin this design has against that lag is
342 // (pinOffsetRounds+1) x 3s = 303s — not the 100 rounds / 5 minutes
343 // framing below, which is the TYPICAL case, not the floor. HALVING
344 // THIS CONSTANT FOR SNAPPIER SETTLEMENT HALVES THAT MARGIN TOO: it is
345 // the ONLY security margin this design has against a clock-lag
346 // foreknowledge attack, there being no other check anywhere in this
347 // file that stands in for it (see Resolve's own doc comment on why a
348 // beacon.Published(round) check at Accept would not help — an attacker
349 // with foreknowledge simply would not publish early). So this constant
350 // is tuned for BOTH properties — far enough past
351 // beacon.CurrentRound() that it is unknowable at the moment of the pin
352 // (drand's period is 3 seconds, so 100 rounds is 5 minutes, SPEC-09
353 // §3.1, §9), AND the clock-skew budget above — and a future change to
354 // it must weigh both, not just the first.
355 pinOffsetRounds = 100
356
357 // bucketRounds rounds the pin UP to a shared boundary, and that single
358 // choice does two jobs at once (SPEC-09 §3.1):
359 //
360 // - EVERY DUEL ACCEPTED IN THE SAME BUCKET SHARES A ROUND, which is
361 // the communal settling moment the owner asked for; and
362 // - ONE beacon.Publish SERVES ALL OF THEM, which is what makes the gas
363 // affordable: a publish was measured at ~0.277 GNOT on 2026-10-07,
364 // and one publish per duel would cost more than the rake on even the
365 // smallest (10 GNOT) stake, let alone recoup it.
366 //
367 // 100 again, so a bucket spans the same 5 minutes as the offset
368 // (SPEC-09 §9's proposed starting point for both).
369 bucketRounds = 100
370
371 // minPinOffsetRounds is the compile-time floor pinOffsetRounds may never
372 // be shrunk below, in this file's existing idiom (see bpsDenom's
373 // identical negative-array-length guard above). THIS is what actually
374 // stops pinOffsetRounds being shrunk — not the runtime assertion in
375 // pinRound below, which cannot check a compile-time constant against a
376 // value derived from that same constant (see pinRound's own doc comment).
377 //
378 // IT WAS 1, AND 1 WAS THE WRONG NUMBER. Corrected 2026-10-08 after the
379 // re-audit: 1 is the bare MATHEMATICAL minimum for "ahead" to mean
380 // anything, and this guard's whole purpose is to protect the SECURITY
381 // minimum, which is a different and much larger number. At
382 // pinOffsetRounds = 1 the margin is (1+1)*3 = 6 SECONDS against a
383 // clock-lag foreknowledge attack, where the budget stated on
384 // pinOffsetRounds above is 303. Every value from 1 to 99 collapses that
385 // margin and every one of them COMPILED CLEAN — verified by setting it
386 // to 1 and watching the build succeed.
387 //
388 // So the floor is now derived from the budget rather than from the
389 // arithmetic: it IS pinOffsetRounds' current value, because that value
390 // is the budget. Raising pinOffsetRounds is always safe and needs no
391 // change here; lowering it below the documented margin now fails the
392 // BUILD, which is the only guard an editor cannot skip.
393 //
394 // A GUARD CALIBRATED TO THE WRONG QUANTITY READS EXACTLY LIKE A GUARD.
395 // That is the fourth time this project has met that shape in one day.
396 minPinOffsetRounds = 100
397)
398
399// A negative array length is a compile error, so this line fails the BUILD
400// if a future edit shrinks pinOffsetRounds below minPinOffsetRounds — the
401// same guard idiom as bpsDenom's above, applied to this file's other
402// security-bearing constant.
403var _ [pinOffsetRounds - minPinOffsetRounds]struct{}
404
405// pinRound computes the round Accept binds a duel to. It is the ONLY place
406// this arithmetic exists (SPEC-09 §8: "one expression used twice, never two
407// copies" — the old duel's R1/N1/T1 findings were all one deadline written
408// as several expressions that drifted apart).
409//
410// raw = beacon.CurrentRound() + pinOffsetRounds
411// Round = ((raw / bucketRounds) + 1) * bucketRounds
412//
413// current IS READ EXACTLY ONCE, into a local, and reused for both the
414// derivation above and the assertion below — never a second call to
415// beacon.CurrentRound() — so the invariant is checked against the very value
416// the pin was derived from, not a possibly-different later read.
417//
418// ASSERTS SPEC-09 INVARIANT 2 IN THE CONTRACT, NOT JUST IN A TEST — and this
419// time correctly: the panic below checks the MARGIN (round must be at least
420// current + pinOffsetRounds), not merely the SIGN (round > current). The
421// sign alone is NOT a useful guard: bucketRounds's own round-up already
422// makes round > current by at least 1, for ANY pinOffsetRounds including 0
423// — so a sign-only check (this file's previous version) passes even when
424// pinOffsetRounds has collapsed to 0 and the pin has collapsed with it to
425// 1-100 rounds, well inside ordinary clock skew. The margin check here is
426// STILL NOT what defends pinOffsetRounds from being set too low, though:
427// raw embeds pinOffsetRounds directly, so round's own derivation already
428// guarantees round >= current + pinOffsetRounds + 1 for WHATEVER
429// pinOffsetRounds compiles in — the check below is therefore a defence
430// against a future bug in the ROUNDING step (e.g. bucketRounds's arithmetic
431// silently losing the "+1" that makes the round-up strict), not a defence
432// against pinOffsetRounds itself being shrunk. THAT defence is the
433// minPinOffsetRounds floor above, a BUILD-TIME failure — because
434// pinOffsetRounds is a compile-time constant, and nothing checked at
435// runtime against a value derived from that same constant can ever catch
436// the constant itself being wrong.
437func pinRound() int64 {
438 current := beacon.CurrentRound()
439 round := pinnedRoundFor(current)
440 if round < current+pinOffsetRounds {
441 panic("duel: pinned round is not at least pinOffsetRounds ahead of beacon.CurrentRound() — refusing to pin")
442 }
443 return round
444}
445
446// pinnedRoundFor is the pin arithmetic, and the ONLY copy of it.
447//
448// It was inlined in pinRound until RoundForAcceptAt needed the same answer for
449// the front end. Two copies of this expression would be the same defect as a
450// JavaScript copy, just closer together and harder to spot: a front end
451// promising "you have 4 minutes to join this flip" against arithmetic that no
452// longer matches what Accept does would be lying to a player about money. One
453// function, both callers.
454func pinnedRoundFor(current int64) int64 {
455 raw := current + pinOffsetRounds
456 return ((raw / bucketRounds) + 1) * bucketRounds
457}
458
459// --- state (SPEC-09 §4) ----------------------------------------------------
460
461// maxLabelLen bounds an offer's label, in bytes. Copied from
462// r/splash/arcade/duel/duel.gno.
463const maxLabelLen = 64
464
465// Offer is a standing stake in the lobby (SPEC-09 §4).
466type Offer struct {
467 Poster address
468 Rung int64 // index into rungs, not a raw amount
469 PostedAt int64 // block height, for a future Render
470
471 // Opponent, when non-zero, is the only address that may Accept this
472 // offer. Zero means open to anyone.
473 Opponent address
474
475 // Label is free text from the poster, purely descriptive (checkLabel):
476 // it never touches settlement.
477 Label string
478
479 // PosterIsUser records whether the Post that created this offer was a
480 // DIRECT user call, carried forward to Duel.PosterIsUser at Accept. See
481 // Duel.PosterIsUser for why the flag is kept even though requireUserCall
482 // already makes it unconditionally true today (SPEC-09 §8, the entries
483 // half of finding 8; copied from r/splash/arcade/duel/duel.gno's
484 // identical field).
485 PosterIsUser bool
486}
487
488// Duel is a matched pair with a round pinned to settle it (SPEC-09 §4).
489type Duel struct {
490 Poster, Taker address
491 Rung int64
492 Label string
493
494 // Round is pinned by pinRound() at Accept and NEVER rebound by
495 // anything after — not by Resolve (slice 2), not by anything
496 // (SPEC-09 invariant 1).
497 Round int64
498
499 AcceptedAt int64 // block height
500
501 // PosterIsUser and TakerIsUser record whether each party's OWN call —
502 // the Post that created the offer, the Accept that matched it — was a
503 // DIRECT user call rather than a realm's. SPEC-09 §8 is explicit that
504 // finding 8's ENTRIES half still needs this flag even though this
505 // slice's requireUserCall already refuses any non-user Post or Accept
506 // outright, which makes both fields unconditionally true today: the
507 // same reasoning r/splash/arcade/duel/duel.gno's identical fields give
508 // (see that file's bookEntries) — the flag is the ONE place finding 8's
509 // property is enforced for entries specifically, so a future
510 // relaxation of requireUserCall must fail here as a declined entry and
511 // an event, not as a raffle entry credited to an address that cannot
512 // spend it. Slice 1 did not add these fields; this slice does, because
513 // Resolve is the first thing in this realm that books an entry.
514 PosterIsUser bool
515 TakerIsUser bool
516}
517
518var (
519 // offers is offerID -> *Offer and duels is duelID -> *Duel. avl.Trees,
520 // never maps: a future Render must iterate them in a deterministic
521 // order, which a map's iteration order does not guarantee.
522 offers = avl.NewTree()
523 duels = avl.NewTree()
524
525 // IDs start at 1 so that 0 never names a record. Separate sequences, as
526 // in r/splash/arcade/duel.
527 nextOfferID int64 = 1
528 nextDuelID int64 = 1
529
530 // escrowed is the wugnot this realm holds on behalf of players: one
531 // stake per open offer, two per live duel. Maintained incrementally,
532 // never by iteration; the realm's balance must never fall below it
533 // (checkInvariant).
534 escrowed int64
535
536 // pendingPot and pendingEntries are the players' side of every
537 // resolved duel's rake, BOOKED AND NOT YET SENT to the raffle — the
538 // exact pattern r/splash/arcade/duel/duel.gno's settle/FlushPot split
539 // uses, and ported here deliberately rather than a direct call inside
540 // Resolve. See Resolve's own doc comment for why: raffle.gno's
541 // FundPot/FundPotWithEntries both gate on cur.Previous().PkgPath() ==
542 // the OLD duel's literal package path, which this realm's package path
543 // is not and can never be while the two coexist, so a call from this
544 // file into either door panics every time, unconditionally — SPEC-09
545 // §8a, found during slice 2, confirmed it is a certainty and not a risk.
546 // Booking here keeps a winner's payout safe from that (and from any
547 // other raffle-side refusal) exactly as it already protects the old
548 // realm's Collect — "nothing about a duel depends on the raffle any
549 // more". DELIVERING THIS BOOKED AMOUNT IS THIS SLICE'S OWN JOB: FlushPot,
550 // below, is the permissionless delivery call slice 2 deferred (SPEC-09
551 // §8a.4). It CANNOT be proven to succeed end-to-end by this suite —
552 // today's deployed raffle will refuse every call this realm's package
553 // path can produce, unconditionally, until the raffle is redeployed
554 // naming this realm's path (SPEC-09 §8b) — so FlushPot is built and
555 // tested for everything that does NOT require the raffle to accept, and
556 // end-to-end delivery is unproven until that redeploy. See FlushPot's own
557 // doc comment and the task report for the full finding.
558 pendingPot int64
559 pendingEntries = avl.NewTree()
560 pendingEntryUnits int64
561
562 // pendingSince is the clock SweepStuckRake's last-resort grace window is
563 // counted from: the chain height at which pendingPot last went from zero
564 // to non-zero (Resolve), ADVANCED AGAIN EVERY TIME A NEW HOLDER IS
565 // ADMITTED to the entry batch (bookEntries) — so the window always
566 // measures the age of the YOUNGEST CLAIM in the batch, not the batch's
567 // age. Zero when pendingPot is zero, and reset by every successful
568 // FlushPot and by SweepStuckRake. Copied verbatim, reasoning included,
569 // from r/splash/arcade/duel/duel.gno's identical field (that file's own
570 // comment records why this is NOT, and cannot be, a measure of delivery
571 // having been attempted and refused — AUDIT-duel-2026-09-27.md addendum
572 // 2, finding D-4).
573 pendingSince int64
574)
575
576// maxPendingEntryHolders bounds the undelivered entry batch, exactly as
577// r/splash/arcade/duel/duel.gno's identical constant: an unbounded batch
578// could grow past what a block can iterate. Copied verbatim.
579const maxPendingEntryHolders = 128
580
581// blocksPerDay and pendingGraceBlocks: SAME VALUES, SAME REASONING as
582// r/splash/arcade/duel/duel.gno's identical constants. 21 days of blocks is
583// the same window r/splash/arcade/raffle gives a committed week before its
584// refunds open; pendingGraceBlocks must stay several raffle weeks long, and
585// the raffle's week is blocks, so the two move together whatever the block
586// time turns out to be. Copied verbatim.
587const blocksPerDay = 26182
588
589const pendingGraceBlocks = blocksPerDay * 21
590
591// realmSelf is this realm's own address, resolved once so every escrow call
592// names a constant rather than re-deriving it.
593var realmSelf = unsafe.CurrentRealm().Address()
594
595func selfAddr() address {
596 return realmSelf
597}
598
599// idKeyWidth/idZeroPad/idKey: enough decimal digits for any non-negative
600// int64, so avl's lexicographic key order is numeric ID order. Copied from
601// r/splash/arcade/duel/duel.gno.
602const idKeyWidth = 19
603
604const idZeroPad = "0000000000000000000"
605
606func idKey(id int64) string {
607 s := strconv.FormatInt(id, 10)
608 if len(s) < idKeyWidth {
609 s = idZeroPad[:idKeyWidth-len(s)] + s
610 }
611 return s
612}
613
614// getOffer returns the offer, or nil. avl's Get returns a single value, so
615// existence is a separate Has.
616func getOffer(id int64) *Offer {
617 key := idKey(id)
618 if !offers.Has(key) {
619 return nil
620 }
621 return offers.Get(key).(*Offer)
622}
623
624func getDuel(id int64) *Duel {
625 key := idKey(id)
626 if !duels.Has(key) {
627 return nil
628 }
629 return duels.Get(key).(*Duel)
630}
631
632// stakeOf is the only place the ladder is read. Panics on any index outside
633// it, including negative ones.
634func stakeOf(rung int64) int64 {
635 if rung < 0 || rung >= int64(len(rungs)) {
636 panic("duel: invalid rung")
637 }
638 return rungs[rung]
639}
640
641func checkLabel(label string) {
642 if len(label) > maxLabelLen {
643 panic("duel: label too long")
644 }
645 if !utf8.ValidString(label) {
646 panic("duel: label is not valid UTF-8")
647 }
648 for i := 0; i < len(label); i++ {
649 if label[i] < 0x20 || label[i] == 0x7f {
650 panic("duel: label contains a control character")
651 }
652 }
653}
654
655// checkInvariant is the O(1) solvency check run at the end of every
656// state-changing function: the realm must hold at least what it owes in
657// escrow. ">=" and not "==", deliberately — wugnot.Transfer is
658// permissionless, so anyone can push wugnot at this address, and an exact
659// check would let a one-ugnot donation brick every open offer and live duel.
660func checkInvariant() {
661 if escrowed < 0 {
662 panic("duel: escrowed went negative")
663 }
664 if pendingPot < 0 {
665 panic("duel: pending rake went negative")
666 }
667 if pendingEntryUnits < 0 {
668 panic("duel: booked raffle entries went negative")
669 }
670 if pendingSince < 0 {
671 panic("duel: pending-since booking height went negative")
672 }
673 if pendingPot > 0 && pendingSince <= 0 {
674 // Copied check from r/splash/arcade/duel/duel.gno: a booked rake
675 // with no booking height is a clock SweepStuckRake cannot read.
676 panic("duel: a rake is booked with no booking height")
677 }
678 if pendingPot == 0 && pendingSince != 0 {
679 panic("duel: no rake is booked but a booking height is still set")
680 }
681 if pendingEntryUnits > 0 && pendingPot <= 0 {
682 // Entries with no rake behind them could never be delivered: the
683 // raffle prices its ceiling off the money delivered in the same
684 // call. Copied reasoning from r/splash/arcade/duel/duel.gno.
685 panic("duel: raffle entries are booked with no rake behind them")
686 }
687 if pendingEntryUnits > maxEntryUnitsFor(pendingPot) {
688 panic("duel: booked raffle entries exceed what the booked rake would buy at the ticket price")
689 }
690 if pendingEntries.Size() > maxPendingEntryHolders {
691 panic("duel: the pending entry batch is over its bound")
692 }
693 // ">=" against escrowed+pendingPot, not "==": wugnot.Transfer is
694 // permissionless, so anyone can push wugnot at this address, and an
695 // exact check would let a one-ugnot donation brick every open offer,
696 // live duel and booked rake.
697 if wugnot.BalanceOf(selfAddr()) < escrowed+pendingPot {
698 panic("duel: realm holds less wugnot than it owes in escrow and booked rake")
699 }
700}
701
702// entryUnitsOf reads the booked entry units for a tree key (a holder
703// address string), zero if absent. Copied from r/splash/arcade/duel/duel.gno.
704func entryUnitsOf(k string) int64 {
705 if !pendingEntries.Has(k) {
706 return 0
707 }
708 return pendingEntries.Get(k).(int64)
709}
710
711// bookEntries credits `holder` with `units` raffle entry units, booked in
712// this realm for a future delivery, and returns what it actually booked —
713// zero when the holder is not eligible or the batch is full. IT NEVER
714// PANICS AND NEVER REFUSES ITS CALLER: a winner's Resolve must not be able
715// to fail over a raffle entry, so every reason not to credit somebody is a
716// zero and an event, not an abort. Copied verbatim (including both
717// eligibility reasons and their reasoning) from
718// r/splash/arcade/duel/duel.gno's identical function.
719func bookEntries(holder address, units int64, isUser bool) int64 {
720 if units <= 0 {
721 return 0
722 }
723 if holder == "" {
724 return 0
725 }
726 if !isUser {
727 // SPEC-09 §8, finding 8's entries half: a realm's address is
728 // package-derived and cannot spend an entry it won, so a party
729 // that did not play from a direct user call earns none. See
730 // Duel.PosterIsUser for why this is unreachable today and kept
731 // anyway.
732 chain.Emit("DuelEntriesDeclined",
733 "holder", string(holder),
734 "units", strconv.FormatInt(units, 10),
735 "reason", "the party did not play from a direct user call")
736 return 0
737 }
738 k := string(holder)
739 if !pendingEntries.Has(k) && pendingEntries.Size() >= maxPendingEntryHolders {
740 chain.Emit("DuelEntriesDropped",
741 "holder", string(holder),
742 "units", strconv.FormatInt(units, 10),
743 "holders", strconv.Itoa(pendingEntries.Size()),
744 "reason", "the undelivered entry batch is full")
745 return 0
746 }
747 if !pendingEntries.Has(k) {
748 // A NEW HOLDER ADVANCES SweepStuckRake'S CLOCK, so the grace window
749 // tracks the YOUNGEST CLAIM in the batch rather than the batch's own
750 // age — copied from r/splash/arcade/duel/duel.gno's identical check
751 // (finding D-4). An EXISTING holder's further units inherit the
752 // batch's clock rather than re-advancing it: admissions are bounded
753 // by maxPendingEntryHolders, so the clock can advance at most that
754 // many times and the window always closes; advancing on every
755 // booking instead could let one repeat player defer the last
756 // resort forever.
757 pendingSince = runtime.ChainHeight()
758 }
759 pendingEntries.Set(k, entryUnitsOf(k)+units)
760 pendingEntryUnits += units
761 return units
762}
763
764// --- guards carried over from r/splash/arcade/duel (SPEC-09 §8) ----------
765
766// requireUserCall refuses a Post or Accept that did not come directly from a
767// user. A realm party that wins a duel would have no way to call a
768// winner-only exit, which strands the pool (SPEC-09 §8, finding D-1). Here
769// Resolve (slice 2) is permissionless and pays directly, which removes the
770// settlement hazard at its root — but §8 keeps the flag on Post and Accept
771// anyway, for the entries half of finding 8 that a later slice still needs.
772func requireUserCall(cur realm, what string) {
773 if !cur.Previous().IsUserCall() {
774 panic("duel: " + what + " must be called directly by a user — a realm cannot be a party to a duel")
775 }
776}
777
778// rejectAttachedCoin panics if the caller attached native coin to a call
779// that has no use for it. Every money-in path in this realm is the wugnot
780// GRC20 allowance (Post, Accept), never the transaction's send envelope, so
781// any attached ugnot would simply sit at this address forever with no code
782// path that ever looks at it (SPEC-09 §8, ../speed/AUDIT-2026-10-07.md Y1).
783// Call this as the FIRST STATEMENT of every crossing entrypoint.
784//
785// unsafe.OriginSend() reads the envelope attached at the chain root, shared
786// by every realm in the call chain, so reading it is only sound when the
787// immediate caller is an EOA — cur.Previous().IsUserCall() is that check.
788// When the call did not come directly from a user, this is silently a
789// no-op: requireUserCall is what refuses that case on Post and Accept, and
790// Cancel has no reason to refuse a realm caller that is the offer's own
791// poster.
792func rejectAttachedCoin(cur realm, what string) {
793 if !cur.Previous().IsUserCall() {
794 return
795 }
796 if sent := unsafe.OriginSend(); len(sent) != 0 {
797 panic("duel: " + what + " does not take native coin — pay through the wugnot allowance instead")
798 }
799}
800
801// --- the lifecycle, this slice: Post / Cancel / Accept (SPEC-09 §3) -------
802
803// Post pulls rungs[rung] from the caller through the wugnot allowance and
804// leaves it standing in the lobby. Returns the new offer's ID.
805//
806// opponent, when non-zero, addresses the offer to one player: only that
807// address may later Accept it. Zero ("") leaves it open to anyone.
808//
809// label is free text for a future render, at most maxLabelLen bytes (""
810// is no label). Neither argument touches settlement: the pinned round and
811// the beacon's randomness decide that, not this call.
812func Post(cur realm, rung int64, opponent address, label string) int64 {
813 rejectAttachedCoin(cur, "Post")
814 // A PARTY TO A DUEL MUST BE AN ADDRESS THAT CAN CALL A WINNER-ONLY EXIT
815 // once one exists (requireUserCall; SPEC-09 §8, finding D-1).
816 requireUserCall(cur, "Post")
817
818 stake := stakeOf(rung)
819 if opponent != "" && !opponent.IsValid() {
820 panic("duel: invalid opponent address")
821 }
822 checkLabel(label)
823 caller := cur.Previous().Address()
824 if wugnot.Allowance(caller, selfAddr()) < stake {
825 panic("duel: insufficient allowance — approve this realm for the rung's stake first")
826 }
827
828 wugnot.TransferFrom(cross(cur), caller, selfAddr(), stake)
829
830 id := nextOfferID
831 nextOfferID++
832 offers.Set(idKey(id), &Offer{
833 Poster: caller,
834 Rung: rung,
835 PostedAt: runtime.ChainHeight(),
836 Opponent: opponent,
837 Label: label,
838 // requireUserCall above already refused this call otherwise, so
839 // this is unconditionally true today — recorded rather than
840 // assumed. See Offer.PosterIsUser for why it is kept.
841 PosterIsUser: cur.Previous().IsUserCall(),
842 })
843 escrowed += stake
844
845 chain.Emit("DuelPost",
846 "offer", strconv.FormatInt(id, 10),
847 "poster", string(caller),
848 "rung", strconv.FormatInt(rung, 10),
849 "stake", strconv.FormatInt(stake, 10),
850 "opponent", string(opponent),
851 "label", label)
852 checkInvariant()
853 return id
854}
855
856// Cancel returns an unaccepted offer's stake to its poster, exactly and with
857// no fee, and deletes the record. Poster only.
858//
859// An accepted offer no longer exists — Accept deletes it in the same
860// transaction that creates the duel — so "Cancel of an accepted offer" and
861// "Cancel of an unknown offer" are the same refusal: whichever of Cancel or
862// Accept lands first in block order removes the offer, and the other finds
863// nothing. THE DESTINATION IS READ FROM THE RECORD, never a caller-supplied
864// argument (SPEC-09 §8): a canceller can only ever recover the poster's own
865// stake to the poster's own address.
866func Cancel(cur realm, offerID int64) {
867 rejectAttachedCoin(cur, "Cancel")
868 o := getOffer(offerID)
869 if o == nil {
870 panic("duel: no such offer")
871 }
872 caller := cur.Previous().Address()
873 if caller != o.Poster {
874 panic("duel: only the poster may cancel")
875 }
876 stake := stakeOf(o.Rung)
877
878 // Delete before paying, so no path exists on which the offer is both
879 // refunded and still standing.
880 offers.Remove(idKey(offerID))
881 escrowed -= stake
882
883 wugnot.Transfer(cross(cur), o.Poster, stake)
884
885 chain.Emit("DuelCancel",
886 "offer", strconv.FormatInt(offerID, 10),
887 "poster", string(o.Poster),
888 "stake", strconv.FormatInt(stake, 10))
889 checkInvariant()
890}
891
892// Accept takes a standing offer: pulls a stake equal to THE OFFER'S rung —
893// the caller chooses nothing about the money — deletes the offer, pins the
894// settlement round and creates the duel. Returns the new duel's ID.
895//
896// THE TAKER CHOOSES THE MOMENT OF Accept AND THEREFORE THE BUCKET, AND THAT
897// IS SAFE — STATED RATHER THAN ASSUMED (SPEC-09 §3.1, invariant 3): every
898// bucket reachable from "now" lies in the future, so no choice among them
899// reveals anything about the randomness it will eventually be settled with.
900// The poster has no influence over the round at all: it is pinned HERE, at
901// Accept, and the poster's own call happened earlier, before this duel or
902// its bucket existed to choose.
903func Accept(cur realm, offerID int64) int64 {
904 rejectAttachedCoin(cur, "Accept")
905 // A PARTY TO A DUEL MUST BE AN ADDRESS THAT CAN CALL A WINNER-ONLY EXIT
906 // once one exists (requireUserCall; SPEC-09 §8, finding D-1).
907 requireUserCall(cur, "Accept")
908
909 o := getOffer(offerID)
910 if o == nil {
911 panic("duel: no such offer")
912 }
913 caller := cur.Previous().Address()
914 if o.Opponent != "" && caller != o.Opponent {
915 // Addressed to someone else. Checked before the allowance so the
916 // refusal never depends on what the wrong player has approved.
917 panic("duel: this offer is addressed to another player")
918 }
919 stake := stakeOf(o.Rung)
920 if wugnot.Allowance(caller, selfAddr()) < stake {
921 panic("duel: insufficient allowance — approve this realm for the rung's stake first")
922 }
923
924 // The offer is deleted before the pull, the same ordering as Cancel:
925 // the record goes first, the money moves second, so a re-entrant Accept
926 // on this offer finds nothing whatever the token does.
927 offers.Remove(idKey(offerID))
928
929 wugnot.TransferFrom(cross(cur), caller, selfAddr(), stake)
930
931 round := pinRound()
932
933 id := nextDuelID
934 nextDuelID++
935 duels.Set(idKey(id), &Duel{
936 Poster: o.Poster,
937 Taker: caller,
938 Rung: o.Rung,
939 Label: o.Label,
940 Round: round,
941 AcceptedAt: runtime.ChainHeight(),
942 // Carried from the offer, not re-derived: PosterIsUser describes
943 // the POSTER'S OWN Post call, which happened earlier and is not
944 // this call's business to recompute. TakerIsUser is this call's
945 // own IsUserCall(), unconditionally true today because
946 // requireUserCall above already gates it — see Duel.PosterIsUser.
947 PosterIsUser: o.PosterIsUser,
948 TakerIsUser: cur.Previous().IsUserCall(),
949 })
950 escrowed += stake
951
952 chain.Emit("DuelAccept",
953 "offer", strconv.FormatInt(offerID, 10),
954 "duel", strconv.FormatInt(id, 10),
955 "poster", string(o.Poster),
956 "taker", string(caller),
957 "rung", strconv.FormatInt(o.Rung, 10),
958 "round", strconv.FormatInt(round, 10))
959 checkInvariant()
960 return id
961}
962
963// --- the outcome (SPEC-09 §3.2) -------------------------------------------
964
965// outcomeTag is copied BYTE FOR BYTE from r/splash/arcade/duel/duel.gno's
966// identical constant. THE OLD REALM'S commitTag AND takerCommitTag ARE GONE
967// WITH THE COMMIT-REVEAL DESIGN THEY EXISTED TO SEAL (SPEC-09 §1, §4: no
968// Secret, no Nonce, no TakerCommit here at all) — there is only ONE domain
969// tag in this file, so the prefix-freeness machinery the old file needed to
970// keep three tags from colliding (checkTagsPrefixFree and its init, guarding
971// commitTag/outcomeTag/takerCommitTag pairwise) has nothing left to guard.
972// Its absence here is deliberate, not an oversight: domain separation across
973// a single tag is not a thing that needs checking.
974const outcomeTag = "splash/duel/v1/result:"
975
976// outcome computes a published duel's winner (SPEC-09 §3.2):
977//
978// sum = sha256(outcomeTag ‖ randomness ‖ duelID)
979// posterWins = sum[len(sum)-1] % 2 == 0
980//
981// duelID, AS ITS DECIMAL STRING (strconv.FormatInt, exactly as the old
982// realm's posterWins suffixed its own duelID), IS MIXED IN, AND THAT IS
983// LOAD-BEARING. bucketRounds (SPEC-09 §3.1) means MANY DUELS SHARE ONE
984// Round and therefore one randomness — pinRound's whole point is that one
985// beacon.Publish serves a whole bucket. Without duelID in this preimage,
986// every duel in a bucket would hash to the identical sum and therefore
987// resolve IDENTICALLY: exploitable (the first duel to resolve leaks every
988// other duel in its bucket's outcome before they are even looked at) and
989// absurd to watch (a communal settling moment where everyone flips the same
990// side of the same coin). Mixing in duelID gives each duel its own
991// independent coin despite sharing randomness with its whole bucket.
992func outcome(duelID int64, randomness string) (posterWins bool) {
993 sum := sha256.Sum256([]byte(outcomeTag + randomness + strconv.FormatInt(duelID, 10)))
994 return sum[len(sum)-1]%2 == 0
995}
996
997// --- Resolve, this slice (SPEC-09 §3) -------------------------------------
998
999// Resolve computes a duel's winner from the published beacon round, pays and
1000// deletes the record — ALL IN ONE CALL. PERMISSIONLESS: there is no Collect
1001// in this design (SPEC-09 §3), so a winner never has to come back, and
1002// anybody — a party, a bystander, a keeper — may call this once the pinned
1003// round is on chain. The caller supplies nothing but duelID: the winner, the
1004// payout and the entries are all non-discretionary functions of stored
1005// state and the beacon's own randomness (invariant 4).
1006//
1007// ORDER OF OPERATIONS, SPEC-09'S OWN: reject attached coin first, as every
1008// entrypoint; look up the duel, refusing an unknown one; refuse if the
1009// pinned round is not yet published; THEN compute the outcome, pay, book
1010// entries, delete, emit.
1011//
1012// THE PAYOUT IS PORTED FROM r/splash/arcade/duel/duel.gno's settle, NOT
1013// REDESIGNED (SPEC-09 §8, this task's own instruction): same pool/rake
1014// arithmetic, same 60/40 split with ops floored and dust to the pot, same
1015// splitEntries/rakeEntryUnits weighting, same conservation assertion. THE
1016// ONE DEPARTURE, AND IT IS DELIBERATE RATHER THAN AN OMISSION, IS WHERE THE
1017// POT SHARE AND ITS ENTRIES LAND. The old settle's own comment says it
1018// plainly — "BOOKED, NOT SENT, AND NOT ONE LINE OF RAFFLE CODE RUNS IN THIS
1019// TRANSACTION" — and books into pendingPot/pendingEntries for a SEPARATE,
1020// later FlushPot to deliver, specifically so a raffle-side refusal can never
1021// take a winner's payout down with it. This file ports exactly that half of
1022// settle (see pendingPot's own doc comment above) rather than a direct,
1023// same-transaction call to raffle.FundPotWithEntries, because such a call
1024// would not merely risk the winner's payout here — IT WOULD PANIC EVERY
1025// SINGLE TIME, UNCONDITIONALLY: raffle.gno's FundPot and FundPotWithEntries
1026// both gate on cur.Previous().PkgPath() == the literal string
1027// "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/duel/v3" — the
1028// OLD realm's own package path — and this realm's module is
1029// ".../arcade/duel/v2" (SPEC-09 §8b, set in slice 3's gnomod.toml), a
1030// different string, so that check fails on every call for as long as the
1031// raffle's compile-time constant names the old path. A direct call here
1032// would make Resolve permanently unable to pay any duel whose rake is
1033// positive (every rung's is), which is every duel — the opposite of SPEC-09
1034// invariant 7 ("a duel can never be stuck by inaction"). ACTUALLY DELIVERING
1035// THE BOOKED POT IS FlushPot, BELOW (THIS SLICE, SPEC-09 §8a.4) — permissionless,
1036// separate from this call, and provably safe for a winner's payout even
1037// while the raffle refuses it. Nothing is lost in the meantime: the booked
1038// wugnot sits in this realm's own balance, accounted for by pendingPot in
1039// checkInvariant, exactly as it would between settle and FlushPot in the
1040// old realm today.
1041func Resolve(cur realm, duelID int64) {
1042 rejectAttachedCoin(cur, "Resolve")
1043
1044 d := getDuel(duelID)
1045 if d == nil {
1046 panic("duel: no such duel")
1047 }
1048
1049 // THE STATE AN HONEST PLAYER ACTUALLY HITS WHILE WAITING OUT THE ~5
1050 // MINUTES until drand signs the pinned round — so this reads as a
1051 // status with an instruction, not a dead end. ANYBODY may publish it;
1052 // it is not this realm's job to, and naming the round lets the caller
1053 // (or the front end on their behalf) go do exactly that and come back.
1054 if !beacon.Published(d.Round) {
1055 panic("duel: round " + strconv.FormatInt(d.Round, 10) +
1056 " is not published yet — anybody may call beacon.Publish for it once drand has signed it, then call Resolve again")
1057 }
1058
1059 randomness := beacon.Randomness(d.Round)
1060 posterWins := outcome(duelID, randomness)
1061
1062 winner, loser := d.Taker, d.Poster
1063 winnerIsUser, loserIsUser := d.TakerIsUser, d.PosterIsUser
1064 if posterWins {
1065 winner, loser = d.Poster, d.Taker
1066 winnerIsUser, loserIsUser = d.PosterIsUser, d.TakerIsUser
1067 }
1068
1069 stake := stakeOf(d.Rung)
1070 pool := poolOf(stake)
1071 rake := rakeOf(pool)
1072 payout := pool - rake
1073 potShare, opsShare := splitRake(rake)
1074 if payout+potShare+opsShare != pool {
1075 // A real check, not a tautology: payout and the two shares are
1076 // each computed from the pool by their own path above, and this
1077 // is the first place all three meet (SPEC-09 invariant 5).
1078 panic("duel: conservation violated — payout plus rake is not the pool")
1079 }
1080 winnerUnits, loserUnits := splitEntries(rakeEntryUnits(rake))
1081
1082 // Delete before paying, the same ordering Cancel and Accept already
1083 // use: no path exists on which this duel is both paid and still
1084 // resolvable. THE DESTINATION IS NEVER A CALLER-SUPPLIED ARGUMENT
1085 // (SPEC-09 §8) — winner is read from the record via outcome(), not
1086 // from anything the caller of Resolve passed in.
1087 duels.Remove(idKey(duelID))
1088 escrowed -= pool
1089
1090 wugnot.Transfer(cross(cur), winner, payout)
1091 if potShare > 0 {
1092 // Booked, not sent — see this function's own doc comment above for
1093 // why. The entries that rake bought are booked in the same breath,
1094 // inside this branch deliberately: an entry must never exist
1095 // without the money that pays for it (checkInvariant asserts this
1096 // too).
1097 if pendingPot == 0 {
1098 // pendingSince starts the grace clock the instant a batch goes
1099 // from nothing owed to something owed — copied from
1100 // r/splash/arcade/duel/duel.gno's identical check in settle.
1101 pendingSince = runtime.ChainHeight()
1102 }
1103 pendingPot += potShare
1104 winnerUnits = bookEntries(winner, winnerUnits, winnerIsUser)
1105 loserUnits = bookEntries(loser, loserUnits, loserIsUser)
1106 } else {
1107 // No rake to pay for entries, so there are none. Unreachable on
1108 // today's ladder (every rung's rake is positive).
1109 winnerUnits, loserUnits = 0, 0
1110 }
1111 if opsShare > 0 {
1112 wugnot.Transfer(cross(cur), opsVaultAddr, opsShare)
1113 }
1114
1115 recordResult(duelID, d.Round, winner, loser, d.Rung)
1116
1117 chain.Emit("DuelResolve",
1118 "duel", strconv.FormatInt(duelID, 10),
1119 "round", strconv.FormatInt(d.Round, 10),
1120 "winner", string(winner),
1121 "loser", string(loser),
1122 "payout", strconv.FormatInt(payout, 10),
1123 "rake", strconv.FormatInt(rake, 10),
1124 "rafflePotBooked", strconv.FormatInt(potShare, 10),
1125 "ops", strconv.FormatInt(opsShare, 10),
1126 "pendingPot", strconv.FormatInt(pendingPot, 10),
1127 "winnerEntries", strconv.FormatInt(winnerUnits, 10),
1128 "loserEntries", strconv.FormatInt(loserUnits, 10),
1129 "pendingEntries", strconv.FormatInt(pendingEntryUnits, 10))
1130 checkInvariant()
1131}
1132
1133// --- FlushPot and SweepStuckRake, this slice (SPEC-09 §8a.4) --------------
1134
1135// FlushPot delivers the whole booked rake — pendingPot and the entries
1136// earned alongside it — to r/splash/arcade/raffle's CURRENT week, through
1137// raffle.FundPotWithEntries (or raffle.FundPot when nobody earned an
1138// entry), and returns the amount actually delivered. PERMISSIONLESS: anyone
1139// may pay the gas to do it, and the destination is not theirs to choose.
1140// Ported from r/splash/arcade/duel/duel.gno's FlushPot, same shape and same
1141// reasoning: a resolved duel's payout must never depend on another realm's
1142// bookkeeping (see pendingPot's own doc comment above), so delivery is a
1143// SEPARATE, later, permissionless call whose only failure mode costs the
1144// CALLER OF THIS FUNCTION a reverted transaction — never a duel's winner,
1145// who has already been paid and whose record is already gone.
1146//
1147// A NO-OP, NOT A PANIC, WHEN NOTHING IS BOOKED — the FIRST of two deliberate
1148// departures from the old realm's identical function, which panics in this
1149// case. A permissionless call anybody (in practice: a keeper, on a
1150// schedule) may make at any time is better served by a harmless zero than
1151// by forcing every caller to read PendingPot first just to avoid an abort.
1152//
1153// THE SECOND DEPARTURE: pendingPot AND THE REST OF THE BOOKING ARE ZEROED
1154// ONLY AFTER raffle.FundPot/FundPotWithEntries HAS RETURNED WITHOUT
1155// PANICKING — not before, the old realm's own ordering. ON A REAL CHAIN
1156// THIS ORDERING CHOICE DOES NOT MATTER: a panic reverts the WHOLE
1157// transaction, so whether the zeroing happened before or after the
1158// cross-realm call, it is discarded along with everything else the call
1159// did, and the old realm's "before" ordering is exactly as revert-safe as
1160// this file's "after" ordering. RE-ENTRANCY THROUGH THE CROSS-REALM CALL
1161// ITSELF IS SEPARATELY UNREACHABLE, ARCHITECTURALLY, NOT BY CONVENTION:
1162// this file imports raffle, so raffle cannot import this file back without
1163// a compile-time import cycle, and nothing else in raffle's own code calls
1164// into this realm — there is no path by which raffle.FundPot/
1165// FundPotWithEntries could call back into FlushPot mid-execution regardless
1166// of ordering. A mutation check (reorder this file to zero
1167// first, the old realm's way) DOES make
1168// TestFlushPotRefusedDeliveryLeavesBookingIntactAndRetryable fail — but
1169// that result is a property of gno test's HARNESS, not of the chain:
1170// uassert.AbortsContains recovers the panic inside the same test process
1171// without re-running the whole message atomically, so a package-level
1172// write made before the panic (the zeroing) survives the recover when a
1173// real chain's revert would have discarded it. The harness's recover is
1174// not a state rollback, and the old realm's comment overstated what the
1175// mutation test proves by citing it as if it were one. The ordering below
1176// is kept anyway — zeroing after costs nothing and makes the retry property
1177// visible in THIS function's own control flow rather than resting on a
1178// chain-level guarantee this suite cannot exercise — but the reason to keep
1179// it is suite-observability, not a chain safety property the old ordering
1180// lacked.
1181//
1182// THE RULE THIS FILE ACTUALLY FOLLOWS, SO THE TWO ORDERINGS IN THIS SECTION
1183// DO NOT READ AS A CONTRADICTION: every OTHER state change in this file
1184// (Cancel's escrowed -=, Accept's offers.Remove, Resolve's duels.Remove)
1185// mutates bookkeeping BEFORE the paying wugnot.Transfer, on the general
1186// principle that a retry or a re-entrant call should find nothing rather
1187// than something stale. SweepStuckRake, below, follows that same general
1188// rule: its wugnot.Transfer is a plain, ungated call that this function's
1189// own prior checks (and checkInvariant's balance assertion) already make
1190// as close to unconditional as this realm can establish, so there is
1191// nothing to protect by delaying the zeroing. FlushPot is the ONE
1192// deliberate exception to the general rule, and the reason is specific to
1193// it: its cross-realm call is, TODAY, KNOWN to panic unconditionally (the
1194// raffle's package-path gate, documented below), so zeroing after is what
1195// keeps the retry property visible inside this function's own control flow
1196// for the one call in this file whose failure is not hypothetical.
1197//
1198// END-TO-END DELIVERY IS UNPROVEN BY THIS SUITE, AND THAT IS STATED HERE
1199// RATHER THAN DISCOVERED LATER (SPEC-09 §8a — found during slice 2 and a
1200// certainty, not a risk). raffle.gno's FundPot and FundPotWithEntries both
1201// gate on cur.Previous().PkgPath() being EXACTLY
1202// "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/duel/v3" — the
1203// OLD realm's own path, never this realm's ".../arcade/duel/v2" — so EVERY
1204// CALL THIS FUNCTION MAKES TO THE RAFFLE TODAY PANICS, UNCONDITIONALLY, and
1205// that panic reverts this whole call with pendingPot intact (the retry
1206// property above, independently of the zero-after ordering, since nothing
1207// here has executed the zeroing yet when the panic happens). This is §8a's
1208// forced deploy order working as designed, not a defect to chase here: the
1209// raffle must be redeployed FIRST, naming this realm's path, before this
1210// function's happy path can be exercised for real — and it will be, for
1211// the first time, at that deploy, not in this suite. What this suite proves
1212// instead, and all it honestly can: the booking is correct, a refused
1213// delivery loses nothing and remains retryable, the books balance, this
1214// function is permissionless, and it refuses attached coin.
1215func FlushPot(cur realm) int64 {
1216 rejectAttachedCoin(cur, "FlushPot")
1217 amount := pendingPot
1218 if amount <= 0 {
1219 return 0
1220 }
1221
1222 // THE BATCH IS COLLECTED BEFORE ANY CROSS-REALM CALL, AND NEVER NEST ONE
1223 // avl.Iterate INSIDE ANOTHER — copied warning from
1224 // r/splash/arcade/duel/duel.gno's FlushPot: the raffle's own FundPot
1225 // iterates its own trees, and a second traversal starting inside the
1226 // first's callback crashes this toolchain's VM with a Go-level nil
1227 // dereference that no gno panic recovers from. So the walk finishes
1228 // here, fully, before the raffle runs at all.
1229 holders := make([]address, 0, pendingEntries.Size())
1230 units := make([]int64, 0, pendingEntries.Size())
1231 pendingEntries.Iterate("", "", func(key string, value any) bool {
1232 holders = append(holders, address(key))
1233 units = append(units, value.(int64))
1234 return false
1235 })
1236 entryUnits := pendingEntryUnits
1237
1238 wugnot.Approve(cross(cur), raffleRealmAddr, amount)
1239 if len(holders) > 0 {
1240 raffle.FundPotWithEntries(cross(cur), amount, holders, units)
1241 } else {
1242 // NO ELIGIBLE HOLDERS, A REAL STATE AND NOT A DEGENERATE ONE: every
1243 // party of every settled duel played from realm code, or the rakes
1244 // were too small to buy one whole entry. The money must still go,
1245 // and the raffle's entry door refuses an empty batch on purpose, so
1246 // this is the plain door.
1247 raffle.FundPot(cross(cur), amount)
1248 }
1249
1250 // ONLY NOW, WITH THE CROSS-REALM CALL HAVING RETURNED WITHOUT PANICKING
1251 // (see this function's own doc comment for why this ordering, not the
1252 // old realm's), IS THE BOOKING CLEARED.
1253 pendingPot = 0
1254 pendingSince = 0
1255 pendingEntries = avl.NewTree()
1256 pendingEntryUnits = 0
1257
1258 chain.Emit("DuelFlushPot",
1259 "amount", strconv.FormatInt(amount, 10),
1260 "destination", string(raffleRealmAddr),
1261 "entryHolders", strconv.Itoa(len(holders)),
1262 "entryUnits", strconv.FormatInt(entryUnits, 10))
1263 checkInvariant()
1264 return amount
1265}
1266
1267// SweepStuckRake is the LAST RESORT for a booked rake the raffle will not
1268// take, so that pendingPot is never a place money can enter and never
1269// leave. It transfers the WHOLE booked amount to the raffle's address by a
1270// PLAIN wugnot.Transfer — no raffle code runs, so nothing in the raffle can
1271// refuse it — and returns the amount moved. PERMISSIONLESS, and only once
1272// the batch's YOUNGEST CLAIM (pendingSince) has stood for
1273// pendingGraceBlocks. Ported from r/splash/arcade/duel/duel.gno's
1274// SweepStuckRake — same mechanism, same grace window, same dropped-entries
1275// cost, same reasoning; read that function's own doc comment for the full
1276// argument, including the gas figures for a full 128-holder batch. This
1277// comment restates only what is specific to deciding to port it here at
1278// all.
1279//
1280// WHY PORT A GRACE-WINDOW LAST RESORT WHEN §8a's REFUSAL IS A CERTAINTY,
1281// NOT THE OCCASIONAL TROUBLE THE OLD REALM'S VERSION IS A LAST RESORT FOR —
1282// this is the one judgment call this slice makes and states plainly rather
1283// than assuming. The invariant SweepStuckRake protects ("a place money can
1284// enter and never leave") does not care WHY FlushPot's door is shut, only
1285// THAT it might stay shut indefinitely. Today it is shut by construction —
1286// the raffle's duelRealmPkgPath names the old realm, not this one — and it
1287// reopens only when the raffle is redeployed naming this realm's path
1288// (SPEC-09 §8b). Resolve keeps booking pendingPot regardless of whether
1289// that redeploy ever happens, is delayed, or the swap this directory is
1290// scaffolding for is abandoned; Resolve must not, and does not, know or
1291// care. Without this function that booking would have exactly one exit
1292// (FlushPot), and that exit is the one this entire slice's own doc comment
1293// says is unproven. A grace-windowed, gate-bypassing last resort is cheap
1294// insurance against that exit never opening, and the mechanism — a plain
1295// transfer that runs no raffle code — does not depend on knowing why the
1296// gate is shut, only on giving the booking a second way out that the gate
1297// cannot touch.
1298//
1299// THE BOOKED ENTRIES ARE DROPPED ON THIS PATH, exactly as in the old
1300// realm's SweepStuckRake and for the identical reason: the money lands in
1301// the raffle's ops pot via sweepDonations, not a prize pot, so there is no
1302// draw left for an entry to be an entry IN — and an entry with no rake
1303// behind it is exactly what checkInvariant refuses. The 60/40 split is
1304// NOT preserved on this path (it all becomes the house's ops pot); that is
1305// the honest cost of a window existing at all, and it is preventable by
1306// anyone, at any time, by simply calling FlushPot instead.
1307//
1308// Panics if there is nothing booked, or if the window has not elapsed.
1309func SweepStuckRake(cur realm) int64 {
1310 rejectAttachedCoin(cur, "SweepStuckRake")
1311 amount := pendingPot
1312 if amount <= 0 {
1313 panic("duel: no rake is waiting to be delivered to the raffle")
1314 }
1315 if pendingSince <= 0 {
1316 // Unreachable: pendingSince is set in the same statement group that
1317 // makes pendingPot non-zero (Resolve, bookEntries). Loud rather than
1318 // treating an unset clock as elapsed.
1319 panic("duel: the pending rake has no booking height")
1320 }
1321 if runtime.ChainHeight()-pendingSince < pendingGraceBlocks {
1322 panic("duel: nothing in this batch is old enough for the last resort — call FlushPot instead")
1323 }
1324
1325 // THE BOOKED ENTRIES DIE HERE, discarded in O(1) by replacing the tree
1326 // rather than walking and removing — this function is the LAST RESORT
1327 // for a booked rake nobody could deliver, so its own cost must not scale
1328 // with the batch that could not be delivered.
1329 sweptEntryUnits := pendingEntryUnits
1330 sweptEntryHolders := pendingEntries.Size()
1331
1332 pendingPot = 0
1333 pendingSince = 0
1334 pendingEntries = avl.NewTree()
1335 pendingEntryUnits = 0
1336
1337 wugnot.Transfer(cross(cur), raffleRealmAddr, amount)
1338
1339 chain.Emit("DuelSweepStuckRake",
1340 "amount", strconv.FormatInt(amount, 10),
1341 "destination", string(raffleRealmAddr),
1342 "droppedEntryHolders", strconv.Itoa(sweptEntryHolders),
1343 "droppedEntryUnits", strconv.FormatInt(sweptEntryUnits, 10))
1344 checkInvariant()
1345 return amount
1346}
1347
1348// --- the read surface ------------------------------------------------------
1349//
1350// ADDED 2026-10-08, AFTER THE REALM WAS ALREADY DEPLOYED WITHOUT IT. The first
1351// v2 deployment exported exactly six functions -- Post, Cancel, Accept,
1352// Resolve, FlushPot, SweepStuckRake -- every one an action and not one a
1353// question. The realm was playable and completely unreadable: no board, no
1354// standing offers, no live duels, and above all no way to show the countdown
1355// that is the whole point of settling from a future beacon round. The audit
1356// raised it as an open question and it was not acted on before deploy.
1357//
1358// NONE OF THESE TAKE `cur realm`, SO NONE NEEDS rejectAttachedCoin. That guard
1359// exists because a crossing entrypoint can have native coin attached to it and
1360// this realm has no way to spend ugnot; a plain function is not a transaction
1361// and cannot carry an envelope. Every function below is a pure read: no writes,
1362// no lazy initialisation, no caching into state.
1363
1364// pageLimit caps every list this realm returns. UNBOUNDED READS ARE A DENIAL
1365// SURFACE, not merely untidy: offers and duels grow without limit, and a caller
1366// asking for all of them on a busy arcade gets a response no node wants to
1367// build. Callers page.
1368const pageLimit = 100
1369
1370// PinOffsetRounds and BucketRounds expose the pin arithmetic, so a front end
1371// can work out WHICH FLIP a new acceptance would land in -- "4 minutes left to
1372// get into this one" -- without keeping a second copy of either number.
1373//
1374// THAT SECOND COPY IS THE WHOLE REASON THESE EXIST. The constants are a
1375// security budget (read pinOffsetRounds' comment), and a JavaScript duplicate
1376// of a security budget is one that drifts silently the moment anyone retunes
1377// it. The front end asks the realm instead. Same reasoning and same shape as
1378// arcade/speed's PinOffsetRounds.
1379//
1380// They are reads of compile-time constants: no state, no caller, nothing to
1381// guard.
1382func PinOffsetRounds() int64 { return pinOffsetRounds }
1383func BucketRounds() int64 { return bucketRounds }
1384
1385// RoundForAcceptAt is THE question a front end actually has, answered by the
1386// realm that owns the answer: if a duel were accepted while the beacon reads
1387// `currentRound`, which round would it be pinned to?
1388//
1389// It is the exact expression Accept uses, factored out so there is ONE copy of
1390// it and both callers share it -- see pinnedRoundFor below. A front end can
1391// therefore show a truthful join deadline: the answer changes at a bucket
1392// boundary, and the moment it changes is the moment the current flip closes to
1393// newcomers.
1394func RoundForAcceptAt(currentRound int64) int64 { return pinnedRoundFor(currentRound) }
1395
1396// RungCount and StakeOfRung expose the ladder so a front end need not hardcode
1397// it. Same names and shapes as the superseded realm, deliberately, so the
1398// existing front end's reads.js keeps working.
1399func RungCount() int64 { return int64(len(rungs)) }
1400
1401func StakeOfRung(rung int64) int64 { return stakeOf(rung) }
1402
1403// Escrowed is the players' money this realm is holding: every standing offer's
1404// stake plus both stakes of every live duel. It does NOT include pendingPot,
1405// which is the raffle's share already earned and awaiting delivery -- read
1406// PendingPot for that. Two separate numbers because they are two separate
1407// claims, and summing them would hide which is which.
1408func Escrowed() int64 { return escrowed }
1409
1410func OfferCount() int64 { return int64(offers.Size()) }
1411
1412func DuelCount() int64 { return int64(duels.Size()) }
1413
1414// OfferIDs and DuelIDs page through the boards in key order. offset and limit
1415// are CLAMPED rather than validated: a caller asking for a million gets
1416// pageLimit, and one asking past the end gets an empty slice. A reader that
1417// panics on a bad page is a reader a front end has to guard against.
1418func OfferIDs(offset, limit int64) []int64 { return idsOf(offers, offset, limit) }
1419
1420func DuelIDs(offset, limit int64) []int64 { return idsOf(duels, offset, limit) }
1421
1422func idsOf(t *avl.Tree, offset, limit int64) []int64 {
1423 if offset < 0 {
1424 offset = 0
1425 }
1426 if limit <= 0 || limit > pageLimit {
1427 limit = pageLimit
1428 }
1429 out := []int64{}
1430 var seen int64
1431 t.Iterate("", "", func(k string, v any) bool {
1432 if seen < offset {
1433 seen++
1434 return false
1435 }
1436 if int64(len(out)) >= limit {
1437 return true
1438 }
1439 id, err := strconv.ParseInt(k, 10, 64)
1440 if err == nil {
1441 out = append(out, id)
1442 }
1443 seen++
1444 return false
1445 })
1446 return out
1447}
1448
1449// OfferOf reports a standing offer: poster, rung, posted-at height. All zero
1450// for an id that is not on the table -- the same "zero means absent" shape the
1451// superseded realm used, so a front end can tell absent from present without a
1452// second call.
1453func OfferOf(id int64) (address, int64, int64) {
1454 o := getOffer(id)
1455 if o == nil {
1456 return "", 0, 0
1457 }
1458 return o.Poster, o.Rung, o.PostedAt
1459}
1460
1461// OfferChallenge reports who an offer is aimed at and what its poster said.
1462// A zero opponent means open to anyone.
1463func OfferChallenge(id int64) (address, string) {
1464 o := getOffer(id)
1465 if o == nil {
1466 return "", ""
1467 }
1468 return o.Opponent, o.Label
1469}
1470
1471// DuelOf reports a live duel: poster, taker, rung, THE PINNED ROUND, and the
1472// height it was accepted at.
1473//
1474// THE ROUND IS THE IMPORTANT ONE AND IS WHY THIS SHAPE DIFFERS FROM THE
1475// SUPERSEDED REALM'S. That realm returned a reveal deadline, a resolved flag
1476// and a winner, because its duels sat in state until somebody opened them.
1477// Here Resolve pays and DELETES in the same call, so a duel in this tree is by
1478// definition unsettled and there is no winner to report. What a caller wants
1479// instead is the round -- pair it with beacon.TimeOfRound(round) and you have
1480// the countdown.
1481func DuelOf(id int64) (address, address, int64, int64, int64) {
1482 d := getDuel(id)
1483 if d == nil {
1484 return "", "", 0, 0, 0
1485 }
1486 return d.Poster, d.Taker, d.Rung, d.Round, d.AcceptedAt
1487}
1488
1489func DuelLabel(id int64) string {
1490 d := getDuel(id)
1491 if d == nil {
1492 return ""
1493 }
1494 return d.Label
1495}
1496
1497// DuelRound is DuelOf's fourth value on its own, because it is the one a
1498// countdown polls and a caller should not have to decode a five-tuple to get
1499// it. 0 for an unknown or already-settled duel.
1500func DuelRound(id int64) int64 {
1501 d := getDuel(id)
1502 if d == nil {
1503 return 0
1504 }
1505 return d.Round
1506}
1507
1508// PendingPot is the raffle's share of settled duels' rake, earned and booked
1509// but not yet delivered. FlushPot's own doc comment told callers to read this
1510// and IT DID NOT EXIST -- a comment pointing at a function nobody wrote, which
1511// is the shape this project has met repeatedly today.
1512func PendingPot() int64 { return pendingPot }
1513
1514// PendingEntryUnits and PendingEntryHolders are what FlushPot will deliver
1515// alongside the pot. A keeper reads all three to decide whether calling is
1516// worth the gas.
1517func PendingEntryUnits() int64 { return pendingEntryUnits }
1518
1519func PendingEntryHolders() int64 { return int64(pendingEntries.Size()) }
1520
1521// PendingSince is the height the current booking started accruing, or 0 when
1522// nothing is booked. It is SweepStuckRake's grace clock, exposed so the wait
1523// is visible rather than inferred.
1524func PendingSince() int64 { return pendingSince }
1525
1526// --- recent results, a bounded ring ----------------------------------------
1527//
1528// WHY THIS EXISTS. Resolve pays and DELETES the duel in the same call, so a
1529// settled duel leaves no state whatsoever. That is right for storage and wrong
1530// for the product: this design's whole point is that MANY DUELS SHARE A ROUND
1531// AND SETTLE TOGETHER, and a front end that showed nothing a second later would
1532// throw away the one moment the design was built to create.
1533//
1534// WHY IT IS BOUNDED, AND WHAT THAT COSTS. A realm cannot be pruned and nothing
1535// here can delete on a caller's behalf, so an unbounded history is a storage
1536// leak that grows for ever with no exit -- the exact shape this project forbids
1537// for money and forbids here for the same reason. So the ring keeps the last
1538// resultRingSize settlements and overwrites the oldest.
1539//
1540// WHAT IS LOST: everything older falls off and is recoverable ONLY from the
1541// chain's event log, which DuelResolve writes in full. That is a real loss and
1542// is stated rather than buried -- a reader must not mistake this for the
1543// complete history.
1544const resultRingSize = 32
1545
1546type Result struct {
1547 DuelID int64
1548 Round int64
1549 Winner address
1550 Loser address
1551 Rung int64
1552 Height int64
1553}
1554
1555var (
1556 results []Result // len is capped at resultRingSize
1557 resultNext int // write cursor; wraps
1558 resultSeen int64 // total ever recorded, including those overwritten
1559)
1560
1561// recordResult is called by Resolve immediately before it emits, with the
1562// values it has already computed. It never panics and never allocates beyond
1563// the ring: once full it overwrites in place.
1564func recordResult(duelID, round int64, winner, loser address, rung int64) {
1565 r := Result{
1566 DuelID: duelID,
1567 Round: round,
1568 Winner: winner,
1569 Loser: loser,
1570 Rung: rung,
1571 Height: runtime.ChainHeight(),
1572 }
1573 resultSeen++
1574 if len(results) < resultRingSize {
1575 results = append(results, r)
1576 resultNext = len(results) % resultRingSize
1577 return
1578 }
1579 results[resultNext] = r
1580 resultNext = (resultNext + 1) % resultRingSize
1581}
1582
1583// ResultCount is how many settlements the ring currently holds, which is at
1584// most resultRingSize. Read ResultsSeen for how many there have ever been.
1585func ResultCount() int64 { return int64(len(results)) }
1586
1587// ResultsSeen is every settlement this realm has ever made, including those the
1588// ring has overwritten. The gap between this and ResultCount is exactly what a
1589// caller must go to the event log for.
1590func ResultsSeen() int64 { return resultSeen }
1591
1592// ResultRingSize is the bound, exported so a front end can say "last 32"
1593// without hardcoding a number that might change.
1594func ResultRingSize() int64 { return resultRingSize }
1595
1596// RecentResults returns up to `limit` settlements, MOST RECENT FIRST. limit is
1597// clamped to the ring's size.
1598func RecentResults(limit int64) []Result {
1599 n := int64(len(results))
1600 if n == 0 {
1601 return []Result{}
1602 }
1603 if limit <= 0 || limit > n {
1604 limit = n
1605 }
1606 out := make([]Result, 0, limit)
1607 // resultNext points at the oldest slot once full, so walk backwards from
1608 // the most recently written one.
1609 i := resultNext - 1
1610 if i < 0 {
1611 i = len(results) - 1
1612 }
1613 for c := int64(0); c < limit; c++ {
1614 out = append(out, results[i])
1615 i--
1616 if i < 0 {
1617 i = len(results) - 1
1618 }
1619 }
1620 return out
1621}
1622
1623// --- Render ----------------------------------------------------------------
1624//
1625// THE PAGE gnoweb SHOWS. The first v2 deployment had none, so anybody who went
1626// looking at this realm directly saw nothing at all.
1627//
1628// IT MUST NEVER PANIC, WHATEVER path IS. A panicking Render does not fail
1629// quietly on one request -- it takes the realm's whole gnoweb page down, for
1630// everyone, permanently, with no way to fix it because a realm cannot be
1631// upgraded. So this function ignores `path` entirely, reads only, and every
1632// loop below is over a bounded collection.
1633//
1634// EVERY PIECE OF CALLER TEXT GOES THROUGH cell(). A Label is free text from a
1635// poster, and this is a markdown document: an unescaped label can forge table
1636// rows, inject links, or impersonate the house. sanitize.InlineText handles the
1637// markdown; the pipe escaping is ours, because a bare | ends a table cell.
1638func cell(s string) string {
1639 out := sanitize.InlineText(s)
1640 esc := ""
1641 for _, r := range out {
1642 if r == '|' {
1643 esc += "\\|"
1644 continue
1645 }
1646 esc += string(r)
1647 }
1648 return esc
1649}
1650
1651func short(a address) string {
1652 s := string(a)
1653 if len(s) <= 12 {
1654 return s
1655 }
1656 return s[:8] + "…" + s[len(s)-4:]
1657}
1658
1659func Render(path string) string {
1660 out := "# SPLASH — the Duel\n\n"
1661 out += "Two stakes, one coin, settled from a public randomness round that does not exist yet " +
1662 "when you commit. Neither player can steer it and neither has to come back.\n\n"
1663
1664 out += "## The ladder\n\n| rung | stake |\n|---|---|\n"
1665 for i := range rungs {
1666 out += "| " + strconv.Itoa(i) + " | " + strconv.FormatInt(rungs[i], 10) + " |\n"
1667 }
1668
1669 out += "\n## On the table\n\n"
1670 if offers.Size() == 0 {
1671 out += "_Nothing standing._\n"
1672 } else {
1673 out += "| # | poster | stake | called out | said |\n|---|---|---|---|---|\n"
1674 offers.Iterate("", "", func(k string, v any) bool {
1675 o, ok := v.(*Offer)
1676 if !ok {
1677 return false
1678 }
1679 target := "anyone"
1680 if o.Opponent != "" {
1681 target = short(o.Opponent)
1682 }
1683 out += "| " + cell(k) + " | " + short(o.Poster) + " | " +
1684 strconv.FormatInt(stakeOf(o.Rung), 10) + " | " + target + " | " + cell(o.Label) + " |\n"
1685 return false
1686 })
1687 }
1688
1689 out += "\n## In the air\n\n"
1690 if duels.Size() == 0 {
1691 out += "_No duel is waiting on a round._\n"
1692 } else {
1693 out += "Each of these is already locked. The round named below decides it, and nobody " +
1694 "knows that round's value yet.\n\n"
1695 out += "| # | poster | taker | stake | decided by round |\n|---|---|---|---|---|\n"
1696 duels.Iterate("", "", func(k string, v any) bool {
1697 d, ok := v.(*Duel)
1698 if !ok {
1699 return false
1700 }
1701 out += "| " + cell(k) + " | " + short(d.Poster) + " | " + short(d.Taker) + " | " +
1702 strconv.FormatInt(stakeOf(d.Rung), 10) + " | " +
1703 strconv.FormatInt(d.Round, 10) + " |\n"
1704 return false
1705 })
1706 }
1707
1708 out += "\n## Settled lately\n\n"
1709 if len(results) == 0 {
1710 out += "_Nothing yet._\n"
1711 } else {
1712 out += "The last " + strconv.FormatInt(int64(len(results)), 10) + " of " +
1713 strconv.FormatInt(resultSeen, 10) + " ever settled. Older ones are in the chain's " +
1714 "event log, not here — this list is deliberately bounded.\n\n"
1715 out += "| # | won | lost | stake | round |\n|---|---|---|---|---|\n"
1716 for _, r := range RecentResults(int64(len(results))) {
1717 out += "| " + strconv.FormatInt(r.DuelID, 10) + " | " + short(r.Winner) + " | " +
1718 short(r.Loser) + " | " + strconv.FormatInt(stakeOf(r.Rung), 10) + " | " +
1719 strconv.FormatInt(r.Round, 10) + " |\n"
1720 }
1721 }
1722
1723 out += "\n## The books\n\n"
1724 out += "- players' money held: " + strconv.FormatInt(escrowed, 10) + "\n"
1725 out += "- raffle's share booked, awaiting delivery: " + strconv.FormatInt(pendingPot, 10) + "\n"
1726 return out
1727}