Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}