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