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

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