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

60.26 Kb · 1321 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
  54	"gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v2"
  55	"gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/beacon/v2"
  56	"gno.land/r/gnoland/wugnot"
  57)
  58
  59// --- the economics (SPEC-09 §5: "unchanged, deliberately") ---------------
  60//
  61// rakeBps, bpsDenom, rakePotShareBps and rakeOpsShareBps, and the rungs
  62// ladder below, are copied VERBATIM from r/splash/arcade/duel/duel.gno,
  63// which in turn cites BIBLE.md "Arcade rake" §2.6/§2.7 and SPEC-03 §5/§9.
  64// SPEC-09 §5 is explicit that nothing about the economics changes in this
  65// rewrite — only how the coin is flipped — so these numbers are re-stated,
  66// not re-derived. This slice stops at having them present and the stake
  67// escrowed: rakeOf/splitRake and the raffle entries they feed belong to
  68// Resolve, slice 2, and are not needed until a payout exists to apply them
  69// to.
  70
  71const (
  72	// rakeBps is the arcade rake: 300 bps of the pool, i.e. of 2 x stake.
  73	rakeBps  = 300
  74	bpsDenom = 10000
  75
  76	// The rake splits 60% to the current raffle pot / 40% to ops, ops
  77	// floored, dust to the pot (SPEC-09 §5).
  78	//
  79	// rakePotShareBps IS A THIRD COPY OF THE SAME NUMBER, NOT JUST A SECOND
  80	// ONE — r/splash/arcade/duel/duel.gno's identical constant is the first
  81	// copy, and r/splash/arcade/raffle/raffle.gno's arcadeRakePotShareBps
  82	// (raffle.gno:3218) is the second, restated there because "the import
  83	// only goes one way" (raffle.gno, same comment block): the raffle cannot
  84	// import either duel to read their constant, so it keeps its own. This
  85	// file is in the same position relative to the raffle, for the same
  86	// reason, so it keeps a third.
  87	//
  88	// THE RAFFLE SAYS, IN ITS OWN SOURCE, WHY A COPY LIKE THIS ONE IS
  89	// DANGEROUS ON ITS OWN: raffle.gno:3214 requires
  90	// "raffle.ArcadeEntryUnitsBound(potShare) to equal it, to the entry",
  91	// and raffle.gno:3276-3277 exports that function "so the duel's suite
  92	// can pin the two realms' arithmetic to each other" — an invitation,
  93	// not a decoration. maxEntryUnitsFor below is this realm's restatement
  94	// of that same bound (raffle.gno:3286-3291), and if this constant ever
  95	// drifts from the raffle's WITHOUT raffle.ArcadeEntryUnitsBound moving
  96	// with it — in particular if it is ever LOWERED, which loosens this
  97	// file's gross-up — checkInvariant below keeps passing (it only checks
  98	// pendingEntryUnits against THIS FILE'S OWN maxEntryUnitsFor), but every
  99	// raffle.FundPotWithEntries call FlushPot makes then finds its units (or
 100	// their total) over the RAFFLE'S bound and panics, unconditionally
 101	// (raffle.gno:3449-3455). FlushPot is therefore refused for that batch
 102	// forever, and the only door left is SweepStuckRake, which — after
 103	// pendingGraceBlocks — donates the WHOLE booked pot share (the players'
 104	// 60%) into the raffle's ops pot and drops every entry riding with it.
 105	// Money enters, the correct exit (FlushPot) closes permanently, and the
 106	// wrong one (SweepStuckRake) pays the house — and nothing in this file
 107	// would report it, because nothing here checks this number against the
 108	// raffle's own. TestMaxEntryUnitsForMatchesRafflesArcadeEntryUnitsBound
 109	// in duel_test.gno is the pin against that drift, ported from the old
 110	// duel's TestFix15_TheRafflesEntryBoundIsExactlyThisRealmsRakeEquivalent
 111	// (duel_test.gno:3338,3341) and TestFix26 (duel_test.gno:3742,3747); the
 112	// compile-time floor two lines below is the second half of that pin —
 113	// read ITS comment for what it stops that the test, being a test, can
 114	// only catch after the fact.
 115	rakePotShareBps = 6000
 116	rakeOpsShareBps = 4000
 117)
 118
 119// A negative array length is a compile error, so this line fails the BUILD
 120// if a future edit pushes the two shares past the denominator — copied from
 121// r/splash/arcade/duel/duel.gno's identical guard.
 122var _ [bpsDenom - rakePotShareBps - rakeOpsShareBps]struct{}
 123
 124// THE PLAYERS' SHARE MUST BE AT LEAST HALF THE RAKE — same idiom, same
 125// value and the same reasoning as r/splash/arcade/raffle/raffle.gno's
 126// identical guard on its own copy of this number (raffle.gno:3238, "a
 127// negative array length is a compile error"). rakePotShareBps's own comment
 128// above explains WHY a drift here is dangerous (it silently closes
 129// FlushPot and opens SweepStuckRake as the real exit); this line is HALF of
 130// what stops that drift happening in the first place — the other half is
 131// TestMaxEntryUnitsForMatchesRafflesArcadeEntryUnitsBound in
 132// duel_test.gno. The test alone is not enough: a test only runs when
 133// somebody runs it, but a build fails every time, for every editor,
 134// including one who never thinks to run this package's suite before
 135// touching this constant. Note what this guard does NOT do: the
 136// bpsDenom-sum guard just above already stops the two shares overflowing
 137// the denominator, but says nothing about EITHER share's own floor — a drop
 138// to e.g. rakePotShareBps=1000/rakeOpsShareBps=9000 still sums to bpsDenom
 139// and would sail past that guard while being exactly the dangerous drift
 140// this one exists to catch.
 141var _ [rakePotShareBps - 5_000]struct{}
 142
 143// rungs is the stake ladder, in ugnot (GNOT has 6 decimals): 10 / 50 / 200 /
 144// 1,000 GNOT. FIXED, never mutable. Copied verbatim from
 145// r/splash/arcade/duel/duel.gno; SPEC-03 §9 records these as a free dial and
 146// SPEC-09 §5 carries them forward unchanged.
 147const (
 148	rung0 = 10_000_000    // 10 GNOT
 149	rung1 = 50_000_000    // 50 GNOT
 150	rung2 = 200_000_000   // 200 GNOT
 151	rung3 = 1_000_000_000 // 1,000 GNOT
 152)
 153
 154var rungs = [...]int64{rung0, rung1, rung2, rung3}
 155
 156// maxStake is the largest stake the ladder can ever name — rung3 — used to
 157// bound poolOf's and rakeOf's inputs before any arithmetic touches them.
 158// Copied verbatim from r/splash/arcade/duel/duel.gno.
 159const maxStake = rung3
 160
 161func init() {
 162	for i := 1; i < len(rungs); i++ {
 163		if rungs[i] <= rungs[i-1] {
 164			panic("duel: the stake ladder must ascend")
 165		}
 166	}
 167}
 168
 169// opsVaultAddr is gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/ops, the
 170// operations share of the rake's destination. SAME CONSTANT, SAME DERIVATION,
 171// SAME REASONING as r/splash/arcade/duel/duel.gno's identical constant: the
 172// realm at that path need not exist yet for the address to be right, and it
 173// is a plain wugnot.Transfer destination, not a function call gated by any
 174// package-path check.
 175const opsVaultAddr address = "g1dqnxfp5zj89ggfcfzvampmhmrkjq36d2zvdyk7"
 176
 177// raffleRealmAddr is TODAY's gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle
 178// — the import this file already uses (see this file's import block; no /v2
 179// suffix exists yet in this repo). SAME LITERAL VALUE as
 180// r/splash/arcade/duel/duel.gno's identical constant, because it names the
 181// SAME DEPLOYED REALM: the raffle has not moved yet (SPEC-09 §8b — it will,
 182// to .../arcade/raffle/v2, but only at its own redeploy, which is a
 183// precondition for FlushPot ever actually succeeding and is not this slice's
 184// job). Used only as a wugnot.Transfer destination (SweepStuckRake), never as
 185// a gate: FlushPot's destination is raffle.FundPot/FundPotWithEntries, which
 186// gate on package path and need no address at all.
 187const raffleRealmAddr address = "g1eazf5ja22kxnlecqwvea4mdsqxwmmf55al80xu"
 188
 189// mulDiv, poolOf, rakeOf and splitRake are copied verbatim (arithmetic and
 190// bounds) from r/splash/arcade/duel/duel.gno — SPEC-09 §5: "nothing about the
 191// economics changes". Comments are condensed to a pointer at the old file
 192// rather than reproduced in full; read poolOf/rakeOf/splitRake there for the
 193// complete reasoning (mulDiv's 128-bit product, the floor-to-ops/dust-to-pot
 194// rule, and the two real (non-tautological) guards on splitRake).
 195
 196// mulDiv returns floor(a*b/d) via a 128-bit intermediate product: raw a*b/d
 197// on token-scale int64s wraps silently on the GnoVM (tools/muldiv_probe.gno).
 198func mulDiv(a, b, d uint64) uint64 {
 199	hi, lo := bits.Mul64(a, b)
 200	if d == 0 {
 201		panic("duel: mulDiv: division by zero")
 202	}
 203	if hi >= d {
 204		panic("duel: mulDiv: overflow")
 205	}
 206	q, _ := bits.Div64(hi, lo, d)
 207	return q
 208}
 209
 210// poolOf is 2 x stake, the whole pot of a duel, with the input bounded first.
 211func poolOf(stake int64) int64 {
 212	if stake <= 0 || stake > maxStake {
 213		panic("duel: stake out of bounds")
 214	}
 215	return 2 * stake
 216}
 217
 218// rakeOf is the rake on a pool: floor(pool x rakeBps / bpsDenom).
 219func rakeOf(pool int64) int64 {
 220	if pool <= 0 || pool > 2*maxStake {
 221		panic("duel: pool out of bounds")
 222	}
 223	return int64(mulDiv(uint64(pool), rakeBps, bpsDenom))
 224}
 225
 226// splitRake divides a rake 60/40 into the raffle pot share and the ops
 227// share. Ops is floored; the pot takes the remainder and all the dust, so
 228// potShare + opsShare == rake exactly, always.
 229func splitRake(rake int64) (potShare, opsShare int64) {
 230	if rake < 0 {
 231		panic("duel: negative rake")
 232	}
 233	opsShare = int64(mulDiv(uint64(rake), rakeOpsShareBps, bpsDenom))
 234	potShare = rake - opsShare
 235	if potShare < 0 {
 236		panic("duel: rake split does not sum to the rake: pot share is negative")
 237	}
 238	if potShare < int64(mulDiv(uint64(rake), rakePotShareBps, bpsDenom)) {
 239		panic("duel: rake split shorts the pot share")
 240	}
 241	return potShare, opsShare
 242}
 243
 244// rakeEntryUnits and splitEntries are copied verbatim (arithmetic, bounds and
 245// the three asserted invariants) from r/splash/arcade/duel/duel.gno. Read
 246// that file's identical functions for the full history (AUDIT-2026-09-28.md
 247// findings 15, 22 and 26) — condensed here to what each check enforces.
 248
 249// rakeEntryUnits is how many raffle entry units a rake of `rake` ugnot earns:
 250// exactly what that money would have bought at raffle.EntryUnitPrice.
 251// raffle.EntryUnitPrice is read as a compile-time constant of another realm,
 252// never a call, so this adds no runtime dependency on the raffle.
 253func rakeEntryUnits(rake int64) int64 {
 254	if rake < 0 {
 255		panic("duel: negative rake")
 256	}
 257	return rake / raffle.EntryUnitPrice
 258}
 259
 260// splitEntries divides a duel's earned entries between its winner and its
 261// loser: each player is guaranteed one entry whenever the total can pay for
 262// two (total >= 2), and the remainder is weighted two thirds to the loser.
 263func splitEntries(total int64) (winnerUnits, loserUnits int64) {
 264	if total < 0 {
 265		panic("duel: negative entry total")
 266	}
 267	switch {
 268	case total == 0:
 269		winnerUnits, loserUnits = 0, 0
 270	case total == 1:
 271		winnerUnits, loserUnits = 0, 1
 272	default:
 273		winnerUnits = total / 3
 274		if winnerUnits < 1 {
 275			winnerUnits = 1
 276		}
 277		loserUnits = total - winnerUnits
 278	}
 279	if winnerUnits+loserUnits != total {
 280		panic("duel: entry split does not sum to the total")
 281	}
 282	if loserUnits < winnerUnits {
 283		panic("duel: entry split shorts the loser")
 284	}
 285	if total >= 2 && (winnerUnits < 1 || loserUnits < 1) {
 286		panic("duel: entry split leaves a player with no entry out of a total that can pay both")
 287	}
 288	return winnerUnits, loserUnits
 289}
 290
 291// maxEntryUnitsFor is the most booked entry units a booked pot share of
 292// `potShare` ugnot may ever carry — the same ceiling r/splash/arcade/raffle
 293// applies at its door (raffle.ArcadeEntryUnitsBound, raffle.gno:3286-3291),
 294// computed here from this realm's own constants so checkInvariant can assert
 295// it without calling another realm. Copied verbatim from
 296// r/splash/arcade/duel/duel.gno. A THIRD COPY, same situation as
 297// rakePotShareBps above — read THAT constant's comment for what happens if
 298// this one ever disagrees with the raffle's own
 299// (raffle.gno:3449-3455/FlushPot/SweepStuckRake) — and PINNED the same way:
 300// TestMaxEntryUnitsForMatchesRafflesArcadeEntryUnitsBound in
 301// duel_test.gno asserts this function equals raffle.ArcadeEntryUnitsBound,
 302// to the entry, exactly as the old duel's
 303// TestFix15_TheRafflesEntryBoundIsExactlyThisRealmsRakeEquivalent
 304// (duel_test.gno:3338,3341,3742,3747) pins the old duel's identical copy.
 305func maxEntryUnitsFor(potShare int64) int64 {
 306	if potShare <= 0 {
 307		return 0
 308	}
 309	gross := int64(mulDiv(uint64(potShare), bpsDenom, rakePotShareBps))
 310	return gross / raffle.EntryUnitPrice
 311}
 312
 313// --- the pin (SPEC-09 §3.1 — "the only subtle part") -----------------------
 314
 315const (
 316	// pinOffsetRounds IS A SECURITY BUDGET, IN SECONDS, NOT ONLY A UX DIAL
 317	// FOR HOW LONG A PLAYER WAITS — stated plainly because only the wait
 318	// half has ever been documented here. The attack this margin prevents:
 319	// beacon.CurrentRound() derives drand's round from the BLOCK'S OWN
 320	// CLOCK (this file's package doc comment), so if that clock ever lags
 321	// drand's REAL head by more than this margin, the round Accept pins is
 322	// already signed in the real world, its randomness is fetchable from
 323	// drand's public API right now, and a taker who checks it before
 324	// calling Accept can simply decline to send that call when the outcome
 325	// is unfavourable — turning a 50/50 game into a free option. Given
 326	// bucketRounds's round-up, the reachable pin for any Accept is
 327	// [current+pinOffsetRounds+1, current+pinOffsetRounds+bucketRounds]
 328	// rounds ahead (pinRound's own doc comment derives this), so at
 329	// pinOffsetRounds=100, bucketRounds=100, drand's 3-second period, the
 330	// MINIMUM margin this design has against that lag is
 331	// (pinOffsetRounds+1) x 3s = 303s — not the 100 rounds / 5 minutes
 332	// framing below, which is the TYPICAL case, not the floor. HALVING
 333	// THIS CONSTANT FOR SNAPPIER SETTLEMENT HALVES THAT MARGIN TOO: it is
 334	// the ONLY security margin this design has against a clock-lag
 335	// foreknowledge attack, there being no other check anywhere in this
 336	// file that stands in for it (see Resolve's own doc comment on why a
 337	// beacon.Published(round) check at Accept would not help — an attacker
 338	// with foreknowledge simply would not publish early). So this constant
 339	// is tuned for BOTH properties — far enough past
 340	// beacon.CurrentRound() that it is unknowable at the moment of the pin
 341	// (drand's period is 3 seconds, so 100 rounds is 5 minutes, SPEC-09
 342	// §3.1, §9), AND the clock-skew budget above — and a future change to
 343	// it must weigh both, not just the first.
 344	pinOffsetRounds = 100
 345
 346	// bucketRounds rounds the pin UP to a shared boundary, and that single
 347	// choice does two jobs at once (SPEC-09 §3.1):
 348	//
 349	//   - EVERY DUEL ACCEPTED IN THE SAME BUCKET SHARES A ROUND, which is
 350	//     the communal settling moment the owner asked for; and
 351	//   - ONE beacon.Publish SERVES ALL OF THEM, which is what makes the gas
 352	//     affordable: a publish was measured at ~0.277 GNOT on 2026-10-07,
 353	//     and one publish per duel would cost more than the rake on even the
 354	//     smallest (10 GNOT) stake, let alone recoup it.
 355	//
 356	// 100 again, so a bucket spans the same 5 minutes as the offset
 357	// (SPEC-09 §9's proposed starting point for both).
 358	bucketRounds = 100
 359
 360	// minPinOffsetRounds is the compile-time floor pinOffsetRounds may never
 361	// be shrunk below, in this file's existing idiom (see bpsDenom's
 362	// identical negative-array-length guard above). THIS is what actually
 363	// stops pinOffsetRounds being shrunk — not the runtime assertion in
 364	// pinRound below, which cannot check a compile-time constant against a
 365	// value derived from that same constant (see pinRound's own doc comment).
 366	//
 367	// IT WAS 1, AND 1 WAS THE WRONG NUMBER. Corrected 2026-10-08 after the
 368	// re-audit: 1 is the bare MATHEMATICAL minimum for "ahead" to mean
 369	// anything, and this guard's whole purpose is to protect the SECURITY
 370	// minimum, which is a different and much larger number. At
 371	// pinOffsetRounds = 1 the margin is (1+1)*3 = 6 SECONDS against a
 372	// clock-lag foreknowledge attack, where the budget stated on
 373	// pinOffsetRounds above is 303. Every value from 1 to 99 collapses that
 374	// margin and every one of them COMPILED CLEAN — verified by setting it
 375	// to 1 and watching the build succeed.
 376	//
 377	// So the floor is now derived from the budget rather than from the
 378	// arithmetic: it IS pinOffsetRounds' current value, because that value
 379	// is the budget. Raising pinOffsetRounds is always safe and needs no
 380	// change here; lowering it below the documented margin now fails the
 381	// BUILD, which is the only guard an editor cannot skip.
 382	//
 383	// A GUARD CALIBRATED TO THE WRONG QUANTITY READS EXACTLY LIKE A GUARD.
 384	// That is the fourth time this project has met that shape in one day.
 385	minPinOffsetRounds = 100
 386)
 387
 388// A negative array length is a compile error, so this line fails the BUILD
 389// if a future edit shrinks pinOffsetRounds below minPinOffsetRounds — the
 390// same guard idiom as bpsDenom's above, applied to this file's other
 391// security-bearing constant.
 392var _ [pinOffsetRounds - minPinOffsetRounds]struct{}
 393
 394// pinRound computes the round Accept binds a duel to. It is the ONLY place
 395// this arithmetic exists (SPEC-09 §8: "one expression used twice, never two
 396// copies" — the old duel's R1/N1/T1 findings were all one deadline written
 397// as several expressions that drifted apart).
 398//
 399//	raw   = beacon.CurrentRound() + pinOffsetRounds
 400//	Round = ((raw / bucketRounds) + 1) * bucketRounds
 401//
 402// current IS READ EXACTLY ONCE, into a local, and reused for both the
 403// derivation above and the assertion below — never a second call to
 404// beacon.CurrentRound() — so the invariant is checked against the very value
 405// the pin was derived from, not a possibly-different later read.
 406//
 407// ASSERTS SPEC-09 INVARIANT 2 IN THE CONTRACT, NOT JUST IN A TEST — and this
 408// time correctly: the panic below checks the MARGIN (round must be at least
 409// current + pinOffsetRounds), not merely the SIGN (round > current). The
 410// sign alone is NOT a useful guard: bucketRounds's own round-up already
 411// makes round > current by at least 1, for ANY pinOffsetRounds including 0
 412// — so a sign-only check (this file's previous version) passes even when
 413// pinOffsetRounds has collapsed to 0 and the pin has collapsed with it to
 414// 1-100 rounds, well inside ordinary clock skew. The margin check here is
 415// STILL NOT what defends pinOffsetRounds from being set too low, though:
 416// raw embeds pinOffsetRounds directly, so round's own derivation already
 417// guarantees round >= current + pinOffsetRounds + 1 for WHATEVER
 418// pinOffsetRounds compiles in — the check below is therefore a defence
 419// against a future bug in the ROUNDING step (e.g. bucketRounds's arithmetic
 420// silently losing the "+1" that makes the round-up strict), not a defence
 421// against pinOffsetRounds itself being shrunk. THAT defence is the
 422// minPinOffsetRounds floor above, a BUILD-TIME failure — because
 423// pinOffsetRounds is a compile-time constant, and nothing checked at
 424// runtime against a value derived from that same constant can ever catch
 425// the constant itself being wrong.
 426func pinRound() int64 {
 427	current := beacon.CurrentRound()
 428	raw := current + pinOffsetRounds
 429	round := ((raw / bucketRounds) + 1) * bucketRounds
 430	if round < current+pinOffsetRounds {
 431		panic("duel: pinned round is not at least pinOffsetRounds ahead of beacon.CurrentRound() — refusing to pin")
 432	}
 433	return round
 434}
 435
 436// --- state (SPEC-09 §4) ----------------------------------------------------
 437
 438// maxLabelLen bounds an offer's label, in bytes. Copied from
 439// r/splash/arcade/duel/duel.gno.
 440const maxLabelLen = 64
 441
 442// Offer is a standing stake in the lobby (SPEC-09 §4).
 443type Offer struct {
 444	Poster   address
 445	Rung     int64 // index into rungs, not a raw amount
 446	PostedAt int64 // block height, for a future Render
 447
 448	// Opponent, when non-zero, is the only address that may Accept this
 449	// offer. Zero means open to anyone.
 450	Opponent address
 451
 452	// Label is free text from the poster, purely descriptive (checkLabel):
 453	// it never touches settlement.
 454	Label string
 455
 456	// PosterIsUser records whether the Post that created this offer was a
 457	// DIRECT user call, carried forward to Duel.PosterIsUser at Accept. See
 458	// Duel.PosterIsUser for why the flag is kept even though requireUserCall
 459	// already makes it unconditionally true today (SPEC-09 §8, the entries
 460	// half of finding 8; copied from r/splash/arcade/duel/duel.gno's
 461	// identical field).
 462	PosterIsUser bool
 463}
 464
 465// Duel is a matched pair with a round pinned to settle it (SPEC-09 §4).
 466type Duel struct {
 467	Poster, Taker address
 468	Rung          int64
 469	Label         string
 470
 471	// Round is pinned by pinRound() at Accept and NEVER rebound by
 472	// anything after — not by Resolve (slice 2), not by anything
 473	// (SPEC-09 invariant 1).
 474	Round int64
 475
 476	AcceptedAt int64 // block height
 477
 478	// PosterIsUser and TakerIsUser record whether each party's OWN call —
 479	// the Post that created the offer, the Accept that matched it — was a
 480	// DIRECT user call rather than a realm's. SPEC-09 §8 is explicit that
 481	// finding 8's ENTRIES half still needs this flag even though this
 482	// slice's requireUserCall already refuses any non-user Post or Accept
 483	// outright, which makes both fields unconditionally true today: the
 484	// same reasoning r/splash/arcade/duel/duel.gno's identical fields give
 485	// (see that file's bookEntries) — the flag is the ONE place finding 8's
 486	// property is enforced for entries specifically, so a future
 487	// relaxation of requireUserCall must fail here as a declined entry and
 488	// an event, not as a raffle entry credited to an address that cannot
 489	// spend it. Slice 1 did not add these fields; this slice does, because
 490	// Resolve is the first thing in this realm that books an entry.
 491	PosterIsUser bool
 492	TakerIsUser  bool
 493}
 494
 495var (
 496	// offers is offerID -> *Offer and duels is duelID -> *Duel. avl.Trees,
 497	// never maps: a future Render must iterate them in a deterministic
 498	// order, which a map's iteration order does not guarantee.
 499	offers = avl.NewTree()
 500	duels  = avl.NewTree()
 501
 502	// IDs start at 1 so that 0 never names a record. Separate sequences, as
 503	// in r/splash/arcade/duel.
 504	nextOfferID int64 = 1
 505	nextDuelID  int64 = 1
 506
 507	// escrowed is the wugnot this realm holds on behalf of players: one
 508	// stake per open offer, two per live duel. Maintained incrementally,
 509	// never by iteration; the realm's balance must never fall below it
 510	// (checkInvariant).
 511	escrowed int64
 512
 513	// pendingPot and pendingEntries are the players' side of every
 514	// resolved duel's rake, BOOKED AND NOT YET SENT to the raffle — the
 515	// exact pattern r/splash/arcade/duel/duel.gno's settle/FlushPot split
 516	// uses, and ported here deliberately rather than a direct call inside
 517	// Resolve. See Resolve's own doc comment for why: raffle.gno's
 518	// FundPot/FundPotWithEntries both gate on cur.Previous().PkgPath() ==
 519	// the OLD duel's literal package path, which this realm's package path
 520	// is not and can never be while the two coexist, so a call from this
 521	// file into either door panics every time, unconditionally — SPEC-09
 522	// §8a, found during slice 2, confirmed it is a certainty and not a risk.
 523	// Booking here keeps a winner's payout safe from that (and from any
 524	// other raffle-side refusal) exactly as it already protects the old
 525	// realm's Collect — "nothing about a duel depends on the raffle any
 526	// more". DELIVERING THIS BOOKED AMOUNT IS THIS SLICE'S OWN JOB: FlushPot,
 527	// below, is the permissionless delivery call slice 2 deferred (SPEC-09
 528	// §8a.4). It CANNOT be proven to succeed end-to-end by this suite —
 529	// today's deployed raffle will refuse every call this realm's package
 530	// path can produce, unconditionally, until the raffle is redeployed
 531	// naming this realm's path (SPEC-09 §8b) — so FlushPot is built and
 532	// tested for everything that does NOT require the raffle to accept, and
 533	// end-to-end delivery is unproven until that redeploy. See FlushPot's own
 534	// doc comment and the task report for the full finding.
 535	pendingPot        int64
 536	pendingEntries    = avl.NewTree()
 537	pendingEntryUnits int64
 538
 539	// pendingSince is the clock SweepStuckRake's last-resort grace window is
 540	// counted from: the chain height at which pendingPot last went from zero
 541	// to non-zero (Resolve), ADVANCED AGAIN EVERY TIME A NEW HOLDER IS
 542	// ADMITTED to the entry batch (bookEntries) — so the window always
 543	// measures the age of the YOUNGEST CLAIM in the batch, not the batch's
 544	// age. Zero when pendingPot is zero, and reset by every successful
 545	// FlushPot and by SweepStuckRake. Copied verbatim, reasoning included,
 546	// from r/splash/arcade/duel/duel.gno's identical field (that file's own
 547	// comment records why this is NOT, and cannot be, a measure of delivery
 548	// having been attempted and refused — AUDIT-duel-2026-09-27.md addendum
 549	// 2, finding D-4).
 550	pendingSince int64
 551)
 552
 553// maxPendingEntryHolders bounds the undelivered entry batch, exactly as
 554// r/splash/arcade/duel/duel.gno's identical constant: an unbounded batch
 555// could grow past what a block can iterate. Copied verbatim.
 556const maxPendingEntryHolders = 128
 557
 558// blocksPerDay and pendingGraceBlocks: SAME VALUES, SAME REASONING as
 559// r/splash/arcade/duel/duel.gno's identical constants. 21 days of blocks is
 560// the same window r/splash/arcade/raffle gives a committed week before its
 561// refunds open; pendingGraceBlocks must stay several raffle weeks long, and
 562// the raffle's week is blocks, so the two move together whatever the block
 563// time turns out to be. Copied verbatim.
 564const blocksPerDay = 26182
 565
 566const pendingGraceBlocks = blocksPerDay * 21
 567
 568// realmSelf is this realm's own address, resolved once so every escrow call
 569// names a constant rather than re-deriving it.
 570var realmSelf = unsafe.CurrentRealm().Address()
 571
 572func selfAddr() address {
 573	return realmSelf
 574}
 575
 576// idKeyWidth/idZeroPad/idKey: enough decimal digits for any non-negative
 577// int64, so avl's lexicographic key order is numeric ID order. Copied from
 578// r/splash/arcade/duel/duel.gno.
 579const idKeyWidth = 19
 580
 581const idZeroPad = "0000000000000000000"
 582
 583func idKey(id int64) string {
 584	s := strconv.FormatInt(id, 10)
 585	if len(s) < idKeyWidth {
 586		s = idZeroPad[:idKeyWidth-len(s)] + s
 587	}
 588	return s
 589}
 590
 591// getOffer returns the offer, or nil. avl's Get returns a single value, so
 592// existence is a separate Has.
 593func getOffer(id int64) *Offer {
 594	key := idKey(id)
 595	if !offers.Has(key) {
 596		return nil
 597	}
 598	return offers.Get(key).(*Offer)
 599}
 600
 601func getDuel(id int64) *Duel {
 602	key := idKey(id)
 603	if !duels.Has(key) {
 604		return nil
 605	}
 606	return duels.Get(key).(*Duel)
 607}
 608
 609// stakeOf is the only place the ladder is read. Panics on any index outside
 610// it, including negative ones.
 611func stakeOf(rung int64) int64 {
 612	if rung < 0 || rung >= int64(len(rungs)) {
 613		panic("duel: invalid rung")
 614	}
 615	return rungs[rung]
 616}
 617
 618func checkLabel(label string) {
 619	if len(label) > maxLabelLen {
 620		panic("duel: label too long")
 621	}
 622	if !utf8.ValidString(label) {
 623		panic("duel: label is not valid UTF-8")
 624	}
 625	for i := 0; i < len(label); i++ {
 626		if label[i] < 0x20 || label[i] == 0x7f {
 627			panic("duel: label contains a control character")
 628		}
 629	}
 630}
 631
 632// checkInvariant is the O(1) solvency check run at the end of every
 633// state-changing function: the realm must hold at least what it owes in
 634// escrow. ">=" and not "==", deliberately — wugnot.Transfer is
 635// permissionless, so anyone can push wugnot at this address, and an exact
 636// check would let a one-ugnot donation brick every open offer and live duel.
 637func checkInvariant() {
 638	if escrowed < 0 {
 639		panic("duel: escrowed went negative")
 640	}
 641	if pendingPot < 0 {
 642		panic("duel: pending rake went negative")
 643	}
 644	if pendingEntryUnits < 0 {
 645		panic("duel: booked raffle entries went negative")
 646	}
 647	if pendingSince < 0 {
 648		panic("duel: pending-since booking height went negative")
 649	}
 650	if pendingPot > 0 && pendingSince <= 0 {
 651		// Copied check from r/splash/arcade/duel/duel.gno: a booked rake
 652		// with no booking height is a clock SweepStuckRake cannot read.
 653		panic("duel: a rake is booked with no booking height")
 654	}
 655	if pendingPot == 0 && pendingSince != 0 {
 656		panic("duel: no rake is booked but a booking height is still set")
 657	}
 658	if pendingEntryUnits > 0 && pendingPot <= 0 {
 659		// Entries with no rake behind them could never be delivered: the
 660		// raffle prices its ceiling off the money delivered in the same
 661		// call. Copied reasoning from r/splash/arcade/duel/duel.gno.
 662		panic("duel: raffle entries are booked with no rake behind them")
 663	}
 664	if pendingEntryUnits > maxEntryUnitsFor(pendingPot) {
 665		panic("duel: booked raffle entries exceed what the booked rake would buy at the ticket price")
 666	}
 667	if pendingEntries.Size() > maxPendingEntryHolders {
 668		panic("duel: the pending entry batch is over its bound")
 669	}
 670	// ">=" against escrowed+pendingPot, not "==": wugnot.Transfer is
 671	// permissionless, so anyone can push wugnot at this address, and an
 672	// exact check would let a one-ugnot donation brick every open offer,
 673	// live duel and booked rake.
 674	if wugnot.BalanceOf(selfAddr()) < escrowed+pendingPot {
 675		panic("duel: realm holds less wugnot than it owes in escrow and booked rake")
 676	}
 677}
 678
 679// entryUnitsOf reads the booked entry units for a tree key (a holder
 680// address string), zero if absent. Copied from r/splash/arcade/duel/duel.gno.
 681func entryUnitsOf(k string) int64 {
 682	if !pendingEntries.Has(k) {
 683		return 0
 684	}
 685	return pendingEntries.Get(k).(int64)
 686}
 687
 688// bookEntries credits `holder` with `units` raffle entry units, booked in
 689// this realm for a future delivery, and returns what it actually booked —
 690// zero when the holder is not eligible or the batch is full. IT NEVER
 691// PANICS AND NEVER REFUSES ITS CALLER: a winner's Resolve must not be able
 692// to fail over a raffle entry, so every reason not to credit somebody is a
 693// zero and an event, not an abort. Copied verbatim (including both
 694// eligibility reasons and their reasoning) from
 695// r/splash/arcade/duel/duel.gno's identical function.
 696func bookEntries(holder address, units int64, isUser bool) int64 {
 697	if units <= 0 {
 698		return 0
 699	}
 700	if holder == "" {
 701		return 0
 702	}
 703	if !isUser {
 704		// SPEC-09 §8, finding 8's entries half: a realm's address is
 705		// package-derived and cannot spend an entry it won, so a party
 706		// that did not play from a direct user call earns none. See
 707		// Duel.PosterIsUser for why this is unreachable today and kept
 708		// anyway.
 709		chain.Emit("DuelEntriesDeclined",
 710			"holder", string(holder),
 711			"units", strconv.FormatInt(units, 10),
 712			"reason", "the party did not play from a direct user call")
 713		return 0
 714	}
 715	k := string(holder)
 716	if !pendingEntries.Has(k) && pendingEntries.Size() >= maxPendingEntryHolders {
 717		chain.Emit("DuelEntriesDropped",
 718			"holder", string(holder),
 719			"units", strconv.FormatInt(units, 10),
 720			"holders", strconv.Itoa(pendingEntries.Size()),
 721			"reason", "the undelivered entry batch is full")
 722		return 0
 723	}
 724	if !pendingEntries.Has(k) {
 725		// A NEW HOLDER ADVANCES SweepStuckRake'S CLOCK, so the grace window
 726		// tracks the YOUNGEST CLAIM in the batch rather than the batch's own
 727		// age — copied from r/splash/arcade/duel/duel.gno's identical check
 728		// (finding D-4). An EXISTING holder's further units inherit the
 729		// batch's clock rather than re-advancing it: admissions are bounded
 730		// by maxPendingEntryHolders, so the clock can advance at most that
 731		// many times and the window always closes; advancing on every
 732		// booking instead could let one repeat player defer the last
 733		// resort forever.
 734		pendingSince = runtime.ChainHeight()
 735	}
 736	pendingEntries.Set(k, entryUnitsOf(k)+units)
 737	pendingEntryUnits += units
 738	return units
 739}
 740
 741// --- guards carried over from r/splash/arcade/duel (SPEC-09 §8) ----------
 742
 743// requireUserCall refuses a Post or Accept that did not come directly from a
 744// user. A realm party that wins a duel would have no way to call a
 745// winner-only exit, which strands the pool (SPEC-09 §8, finding D-1). Here
 746// Resolve (slice 2) is permissionless and pays directly, which removes the
 747// settlement hazard at its root — but §8 keeps the flag on Post and Accept
 748// anyway, for the entries half of finding 8 that a later slice still needs.
 749func requireUserCall(cur realm, what string) {
 750	if !cur.Previous().IsUserCall() {
 751		panic("duel: " + what + " must be called directly by a user — a realm cannot be a party to a duel")
 752	}
 753}
 754
 755// rejectAttachedCoin panics if the caller attached native coin to a call
 756// that has no use for it. Every money-in path in this realm is the wugnot
 757// GRC20 allowance (Post, Accept), never the transaction's send envelope, so
 758// any attached ugnot would simply sit at this address forever with no code
 759// path that ever looks at it (SPEC-09 §8, ../speed/AUDIT-2026-10-07.md Y1).
 760// Call this as the FIRST STATEMENT of every crossing entrypoint.
 761//
 762// unsafe.OriginSend() reads the envelope attached at the chain root, shared
 763// by every realm in the call chain, so reading it is only sound when the
 764// immediate caller is an EOA — cur.Previous().IsUserCall() is that check.
 765// When the call did not come directly from a user, this is silently a
 766// no-op: requireUserCall is what refuses that case on Post and Accept, and
 767// Cancel has no reason to refuse a realm caller that is the offer's own
 768// poster.
 769func rejectAttachedCoin(cur realm, what string) {
 770	if !cur.Previous().IsUserCall() {
 771		return
 772	}
 773	if sent := unsafe.OriginSend(); len(sent) != 0 {
 774		panic("duel: " + what + " does not take native coin — pay through the wugnot allowance instead")
 775	}
 776}
 777
 778// --- the lifecycle, this slice: Post / Cancel / Accept (SPEC-09 §3) -------
 779
 780// Post pulls rungs[rung] from the caller through the wugnot allowance and
 781// leaves it standing in the lobby. Returns the new offer's ID.
 782//
 783// opponent, when non-zero, addresses the offer to one player: only that
 784// address may later Accept it. Zero ("") leaves it open to anyone.
 785//
 786// label is free text for a future render, at most maxLabelLen bytes (""
 787// is no label). Neither argument touches settlement: the pinned round and
 788// the beacon's randomness decide that, not this call.
 789func Post(cur realm, rung int64, opponent address, label string) int64 {
 790	rejectAttachedCoin(cur, "Post")
 791	// A PARTY TO A DUEL MUST BE AN ADDRESS THAT CAN CALL A WINNER-ONLY EXIT
 792	// once one exists (requireUserCall; SPEC-09 §8, finding D-1).
 793	requireUserCall(cur, "Post")
 794
 795	stake := stakeOf(rung)
 796	if opponent != "" && !opponent.IsValid() {
 797		panic("duel: invalid opponent address")
 798	}
 799	checkLabel(label)
 800	caller := cur.Previous().Address()
 801	if wugnot.Allowance(caller, selfAddr()) < stake {
 802		panic("duel: insufficient allowance — approve this realm for the rung's stake first")
 803	}
 804
 805	wugnot.TransferFrom(cross(cur), caller, selfAddr(), stake)
 806
 807	id := nextOfferID
 808	nextOfferID++
 809	offers.Set(idKey(id), &Offer{
 810		Poster:   caller,
 811		Rung:     rung,
 812		PostedAt: runtime.ChainHeight(),
 813		Opponent: opponent,
 814		Label:    label,
 815		// requireUserCall above already refused this call otherwise, so
 816		// this is unconditionally true today — recorded rather than
 817		// assumed. See Offer.PosterIsUser for why it is kept.
 818		PosterIsUser: cur.Previous().IsUserCall(),
 819	})
 820	escrowed += stake
 821
 822	chain.Emit("DuelPost",
 823		"offer", strconv.FormatInt(id, 10),
 824		"poster", string(caller),
 825		"rung", strconv.FormatInt(rung, 10),
 826		"stake", strconv.FormatInt(stake, 10),
 827		"opponent", string(opponent),
 828		"label", label)
 829	checkInvariant()
 830	return id
 831}
 832
 833// Cancel returns an unaccepted offer's stake to its poster, exactly and with
 834// no fee, and deletes the record. Poster only.
 835//
 836// An accepted offer no longer exists — Accept deletes it in the same
 837// transaction that creates the duel — so "Cancel of an accepted offer" and
 838// "Cancel of an unknown offer" are the same refusal: whichever of Cancel or
 839// Accept lands first in block order removes the offer, and the other finds
 840// nothing. THE DESTINATION IS READ FROM THE RECORD, never a caller-supplied
 841// argument (SPEC-09 §8): a canceller can only ever recover the poster's own
 842// stake to the poster's own address.
 843func Cancel(cur realm, offerID int64) {
 844	rejectAttachedCoin(cur, "Cancel")
 845	o := getOffer(offerID)
 846	if o == nil {
 847		panic("duel: no such offer")
 848	}
 849	caller := cur.Previous().Address()
 850	if caller != o.Poster {
 851		panic("duel: only the poster may cancel")
 852	}
 853	stake := stakeOf(o.Rung)
 854
 855	// Delete before paying, so no path exists on which the offer is both
 856	// refunded and still standing.
 857	offers.Remove(idKey(offerID))
 858	escrowed -= stake
 859
 860	wugnot.Transfer(cross(cur), o.Poster, stake)
 861
 862	chain.Emit("DuelCancel",
 863		"offer", strconv.FormatInt(offerID, 10),
 864		"poster", string(o.Poster),
 865		"stake", strconv.FormatInt(stake, 10))
 866	checkInvariant()
 867}
 868
 869// Accept takes a standing offer: pulls a stake equal to THE OFFER'S rung —
 870// the caller chooses nothing about the money — deletes the offer, pins the
 871// settlement round and creates the duel. Returns the new duel's ID.
 872//
 873// THE TAKER CHOOSES THE MOMENT OF Accept AND THEREFORE THE BUCKET, AND THAT
 874// IS SAFE — STATED RATHER THAN ASSUMED (SPEC-09 §3.1, invariant 3): every
 875// bucket reachable from "now" lies in the future, so no choice among them
 876// reveals anything about the randomness it will eventually be settled with.
 877// The poster has no influence over the round at all: it is pinned HERE, at
 878// Accept, and the poster's own call happened earlier, before this duel or
 879// its bucket existed to choose.
 880func Accept(cur realm, offerID int64) int64 {
 881	rejectAttachedCoin(cur, "Accept")
 882	// A PARTY TO A DUEL MUST BE AN ADDRESS THAT CAN CALL A WINNER-ONLY EXIT
 883	// once one exists (requireUserCall; SPEC-09 §8, finding D-1).
 884	requireUserCall(cur, "Accept")
 885
 886	o := getOffer(offerID)
 887	if o == nil {
 888		panic("duel: no such offer")
 889	}
 890	caller := cur.Previous().Address()
 891	if o.Opponent != "" && caller != o.Opponent {
 892		// Addressed to someone else. Checked before the allowance so the
 893		// refusal never depends on what the wrong player has approved.
 894		panic("duel: this offer is addressed to another player")
 895	}
 896	stake := stakeOf(o.Rung)
 897	if wugnot.Allowance(caller, selfAddr()) < stake {
 898		panic("duel: insufficient allowance — approve this realm for the rung's stake first")
 899	}
 900
 901	// The offer is deleted before the pull, the same ordering as Cancel:
 902	// the record goes first, the money moves second, so a re-entrant Accept
 903	// on this offer finds nothing whatever the token does.
 904	offers.Remove(idKey(offerID))
 905
 906	wugnot.TransferFrom(cross(cur), caller, selfAddr(), stake)
 907
 908	round := pinRound()
 909
 910	id := nextDuelID
 911	nextDuelID++
 912	duels.Set(idKey(id), &Duel{
 913		Poster:     o.Poster,
 914		Taker:      caller,
 915		Rung:       o.Rung,
 916		Label:      o.Label,
 917		Round:      round,
 918		AcceptedAt: runtime.ChainHeight(),
 919		// Carried from the offer, not re-derived: PosterIsUser describes
 920		// the POSTER'S OWN Post call, which happened earlier and is not
 921		// this call's business to recompute. TakerIsUser is this call's
 922		// own IsUserCall(), unconditionally true today because
 923		// requireUserCall above already gates it — see Duel.PosterIsUser.
 924		PosterIsUser: o.PosterIsUser,
 925		TakerIsUser:  cur.Previous().IsUserCall(),
 926	})
 927	escrowed += stake
 928
 929	chain.Emit("DuelAccept",
 930		"offer", strconv.FormatInt(offerID, 10),
 931		"duel", strconv.FormatInt(id, 10),
 932		"poster", string(o.Poster),
 933		"taker", string(caller),
 934		"rung", strconv.FormatInt(o.Rung, 10),
 935		"round", strconv.FormatInt(round, 10))
 936	checkInvariant()
 937	return id
 938}
 939
 940// --- the outcome (SPEC-09 §3.2) -------------------------------------------
 941
 942// outcomeTag is copied BYTE FOR BYTE from r/splash/arcade/duel/duel.gno's
 943// identical constant. THE OLD REALM'S commitTag AND takerCommitTag ARE GONE
 944// WITH THE COMMIT-REVEAL DESIGN THEY EXISTED TO SEAL (SPEC-09 §1, §4: no
 945// Secret, no Nonce, no TakerCommit here at all) — there is only ONE domain
 946// tag in this file, so the prefix-freeness machinery the old file needed to
 947// keep three tags from colliding (checkTagsPrefixFree and its init, guarding
 948// commitTag/outcomeTag/takerCommitTag pairwise) has nothing left to guard.
 949// Its absence here is deliberate, not an oversight: domain separation across
 950// a single tag is not a thing that needs checking.
 951const outcomeTag = "splash/duel/v1/result:"
 952
 953// outcome computes a published duel's winner (SPEC-09 §3.2):
 954//
 955//	sum        = sha256(outcomeTag ‖ randomness ‖ duelID)
 956//	posterWins = sum[len(sum)-1] % 2 == 0
 957//
 958// duelID, AS ITS DECIMAL STRING (strconv.FormatInt, exactly as the old
 959// realm's posterWins suffixed its own duelID), IS MIXED IN, AND THAT IS
 960// LOAD-BEARING. bucketRounds (SPEC-09 §3.1) means MANY DUELS SHARE ONE
 961// Round and therefore one randomness — pinRound's whole point is that one
 962// beacon.Publish serves a whole bucket. Without duelID in this preimage,
 963// every duel in a bucket would hash to the identical sum and therefore
 964// resolve IDENTICALLY: exploitable (the first duel to resolve leaks every
 965// other duel in its bucket's outcome before they are even looked at) and
 966// absurd to watch (a communal settling moment where everyone flips the same
 967// side of the same coin). Mixing in duelID gives each duel its own
 968// independent coin despite sharing randomness with its whole bucket.
 969func outcome(duelID int64, randomness string) (posterWins bool) {
 970	sum := sha256.Sum256([]byte(outcomeTag + randomness + strconv.FormatInt(duelID, 10)))
 971	return sum[len(sum)-1]%2 == 0
 972}
 973
 974// --- Resolve, this slice (SPEC-09 §3) -------------------------------------
 975
 976// Resolve computes a duel's winner from the published beacon round, pays and
 977// deletes the record — ALL IN ONE CALL. PERMISSIONLESS: there is no Collect
 978// in this design (SPEC-09 §3), so a winner never has to come back, and
 979// anybody — a party, a bystander, a keeper — may call this once the pinned
 980// round is on chain. The caller supplies nothing but duelID: the winner, the
 981// payout and the entries are all non-discretionary functions of stored
 982// state and the beacon's own randomness (invariant 4).
 983//
 984// ORDER OF OPERATIONS, SPEC-09'S OWN: reject attached coin first, as every
 985// entrypoint; look up the duel, refusing an unknown one; refuse if the
 986// pinned round is not yet published; THEN compute the outcome, pay, book
 987// entries, delete, emit.
 988//
 989// THE PAYOUT IS PORTED FROM r/splash/arcade/duel/duel.gno's settle, NOT
 990// REDESIGNED (SPEC-09 §8, this task's own instruction): same pool/rake
 991// arithmetic, same 60/40 split with ops floored and dust to the pot, same
 992// splitEntries/rakeEntryUnits weighting, same conservation assertion. THE
 993// ONE DEPARTURE, AND IT IS DELIBERATE RATHER THAN AN OMISSION, IS WHERE THE
 994// POT SHARE AND ITS ENTRIES LAND. The old settle's own comment says it
 995// plainly — "BOOKED, NOT SENT, AND NOT ONE LINE OF RAFFLE CODE RUNS IN THIS
 996// TRANSACTION" — and books into pendingPot/pendingEntries for a SEPARATE,
 997// later FlushPot to deliver, specifically so a raffle-side refusal can never
 998// take a winner's payout down with it. This file ports exactly that half of
 999// settle (see pendingPot's own doc comment above) rather than a direct,
1000// same-transaction call to raffle.FundPotWithEntries, because such a call
1001// would not merely risk the winner's payout here — IT WOULD PANIC EVERY
1002// SINGLE TIME, UNCONDITIONALLY: raffle.gno's FundPot and FundPotWithEntries
1003// both gate on cur.Previous().PkgPath() == the literal string
1004// "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/duel/v2" — the
1005// OLD realm's own package path — and this realm's module is
1006// ".../arcade/duel/v2" (SPEC-09 §8b, set in slice 3's gnomod.toml), a
1007// different string, so that check fails on every call for as long as the
1008// raffle's compile-time constant names the old path. A direct call here
1009// would make Resolve permanently unable to pay any duel whose rake is
1010// positive (every rung's is), which is every duel — the opposite of SPEC-09
1011// invariant 7 ("a duel can never be stuck by inaction"). ACTUALLY DELIVERING
1012// THE BOOKED POT IS FlushPot, BELOW (THIS SLICE, SPEC-09 §8a.4) — permissionless,
1013// separate from this call, and provably safe for a winner's payout even
1014// while the raffle refuses it. Nothing is lost in the meantime: the booked
1015// wugnot sits in this realm's own balance, accounted for by pendingPot in
1016// checkInvariant, exactly as it would between settle and FlushPot in the
1017// old realm today.
1018func Resolve(cur realm, duelID int64) {
1019	rejectAttachedCoin(cur, "Resolve")
1020
1021	d := getDuel(duelID)
1022	if d == nil {
1023		panic("duel: no such duel")
1024	}
1025
1026	// THE STATE AN HONEST PLAYER ACTUALLY HITS WHILE WAITING OUT THE ~5
1027	// MINUTES until drand signs the pinned round — so this reads as a
1028	// status with an instruction, not a dead end. ANYBODY may publish it;
1029	// it is not this realm's job to, and naming the round lets the caller
1030	// (or the front end on their behalf) go do exactly that and come back.
1031	if !beacon.Published(d.Round) {
1032		panic("duel: round " + strconv.FormatInt(d.Round, 10) +
1033			" is not published yet — anybody may call beacon.Publish for it once drand has signed it, then call Resolve again")
1034	}
1035
1036	randomness := beacon.Randomness(d.Round)
1037	posterWins := outcome(duelID, randomness)
1038
1039	winner, loser := d.Taker, d.Poster
1040	winnerIsUser, loserIsUser := d.TakerIsUser, d.PosterIsUser
1041	if posterWins {
1042		winner, loser = d.Poster, d.Taker
1043		winnerIsUser, loserIsUser = d.PosterIsUser, d.TakerIsUser
1044	}
1045
1046	stake := stakeOf(d.Rung)
1047	pool := poolOf(stake)
1048	rake := rakeOf(pool)
1049	payout := pool - rake
1050	potShare, opsShare := splitRake(rake)
1051	if payout+potShare+opsShare != pool {
1052		// A real check, not a tautology: payout and the two shares are
1053		// each computed from the pool by their own path above, and this
1054		// is the first place all three meet (SPEC-09 invariant 5).
1055		panic("duel: conservation violated — payout plus rake is not the pool")
1056	}
1057	winnerUnits, loserUnits := splitEntries(rakeEntryUnits(rake))
1058
1059	// Delete before paying, the same ordering Cancel and Accept already
1060	// use: no path exists on which this duel is both paid and still
1061	// resolvable. THE DESTINATION IS NEVER A CALLER-SUPPLIED ARGUMENT
1062	// (SPEC-09 §8) — winner is read from the record via outcome(), not
1063	// from anything the caller of Resolve passed in.
1064	duels.Remove(idKey(duelID))
1065	escrowed -= pool
1066
1067	wugnot.Transfer(cross(cur), winner, payout)
1068	if potShare > 0 {
1069		// Booked, not sent — see this function's own doc comment above for
1070		// why. The entries that rake bought are booked in the same breath,
1071		// inside this branch deliberately: an entry must never exist
1072		// without the money that pays for it (checkInvariant asserts this
1073		// too).
1074		if pendingPot == 0 {
1075			// pendingSince starts the grace clock the instant a batch goes
1076			// from nothing owed to something owed — copied from
1077			// r/splash/arcade/duel/duel.gno's identical check in settle.
1078			pendingSince = runtime.ChainHeight()
1079		}
1080		pendingPot += potShare
1081		winnerUnits = bookEntries(winner, winnerUnits, winnerIsUser)
1082		loserUnits = bookEntries(loser, loserUnits, loserIsUser)
1083	} else {
1084		// No rake to pay for entries, so there are none. Unreachable on
1085		// today's ladder (every rung's rake is positive).
1086		winnerUnits, loserUnits = 0, 0
1087	}
1088	if opsShare > 0 {
1089		wugnot.Transfer(cross(cur), opsVaultAddr, opsShare)
1090	}
1091
1092	chain.Emit("DuelResolve",
1093		"duel", strconv.FormatInt(duelID, 10),
1094		"round", strconv.FormatInt(d.Round, 10),
1095		"winner", string(winner),
1096		"loser", string(loser),
1097		"payout", strconv.FormatInt(payout, 10),
1098		"rake", strconv.FormatInt(rake, 10),
1099		"rafflePotBooked", strconv.FormatInt(potShare, 10),
1100		"ops", strconv.FormatInt(opsShare, 10),
1101		"pendingPot", strconv.FormatInt(pendingPot, 10),
1102		"winnerEntries", strconv.FormatInt(winnerUnits, 10),
1103		"loserEntries", strconv.FormatInt(loserUnits, 10),
1104		"pendingEntries", strconv.FormatInt(pendingEntryUnits, 10))
1105	checkInvariant()
1106}
1107
1108// --- FlushPot and SweepStuckRake, this slice (SPEC-09 §8a.4) --------------
1109
1110// FlushPot delivers the whole booked rake — pendingPot and the entries
1111// earned alongside it — to r/splash/arcade/raffle's CURRENT week, through
1112// raffle.FundPotWithEntries (or raffle.FundPot when nobody earned an
1113// entry), and returns the amount actually delivered. PERMISSIONLESS: anyone
1114// may pay the gas to do it, and the destination is not theirs to choose.
1115// Ported from r/splash/arcade/duel/duel.gno's FlushPot, same shape and same
1116// reasoning: a resolved duel's payout must never depend on another realm's
1117// bookkeeping (see pendingPot's own doc comment above), so delivery is a
1118// SEPARATE, later, permissionless call whose only failure mode costs the
1119// CALLER OF THIS FUNCTION a reverted transaction — never a duel's winner,
1120// who has already been paid and whose record is already gone.
1121//
1122// A NO-OP, NOT A PANIC, WHEN NOTHING IS BOOKED — the FIRST of two deliberate
1123// departures from the old realm's identical function, which panics in this
1124// case. A permissionless call anybody (in practice: a keeper, on a
1125// schedule) may make at any time is better served by a harmless zero than
1126// by forcing every caller to read PendingPot first just to avoid an abort.
1127//
1128// THE SECOND DEPARTURE: pendingPot AND THE REST OF THE BOOKING ARE ZEROED
1129// ONLY AFTER raffle.FundPot/FundPotWithEntries HAS RETURNED WITHOUT
1130// PANICKING — not before, the old realm's own ordering. ON A REAL CHAIN
1131// THIS ORDERING CHOICE DOES NOT MATTER: a panic reverts the WHOLE
1132// transaction, so whether the zeroing happened before or after the
1133// cross-realm call, it is discarded along with everything else the call
1134// did, and the old realm's "before" ordering is exactly as revert-safe as
1135// this file's "after" ordering. RE-ENTRANCY THROUGH THE CROSS-REALM CALL
1136// ITSELF IS SEPARATELY UNREACHABLE, ARCHITECTURALLY, NOT BY CONVENTION:
1137// this file imports raffle, so raffle cannot import this file back without
1138// a compile-time import cycle, and nothing else in raffle's own code calls
1139// into this realm — there is no path by which raffle.FundPot/
1140// FundPotWithEntries could call back into FlushPot mid-execution regardless
1141// of ordering. A mutation check (reorder this file to zero
1142// first, the old realm's way) DOES make
1143// TestFlushPotRefusedDeliveryLeavesBookingIntactAndRetryable fail — but
1144// that result is a property of gno test's HARNESS, not of the chain:
1145// uassert.AbortsContains recovers the panic inside the same test process
1146// without re-running the whole message atomically, so a package-level
1147// write made before the panic (the zeroing) survives the recover when a
1148// real chain's revert would have discarded it. The harness's recover is
1149// not a state rollback, and the old realm's comment overstated what the
1150// mutation test proves by citing it as if it were one. The ordering below
1151// is kept anyway — zeroing after costs nothing and makes the retry property
1152// visible in THIS function's own control flow rather than resting on a
1153// chain-level guarantee this suite cannot exercise — but the reason to keep
1154// it is suite-observability, not a chain safety property the old ordering
1155// lacked.
1156//
1157// THE RULE THIS FILE ACTUALLY FOLLOWS, SO THE TWO ORDERINGS IN THIS SECTION
1158// DO NOT READ AS A CONTRADICTION: every OTHER state change in this file
1159// (Cancel's escrowed -=, Accept's offers.Remove, Resolve's duels.Remove)
1160// mutates bookkeeping BEFORE the paying wugnot.Transfer, on the general
1161// principle that a retry or a re-entrant call should find nothing rather
1162// than something stale. SweepStuckRake, below, follows that same general
1163// rule: its wugnot.Transfer is a plain, ungated call that this function's
1164// own prior checks (and checkInvariant's balance assertion) already make
1165// as close to unconditional as this realm can establish, so there is
1166// nothing to protect by delaying the zeroing. FlushPot is the ONE
1167// deliberate exception to the general rule, and the reason is specific to
1168// it: its cross-realm call is, TODAY, KNOWN to panic unconditionally (the
1169// raffle's package-path gate, documented below), so zeroing after is what
1170// keeps the retry property visible inside this function's own control flow
1171// for the one call in this file whose failure is not hypothetical.
1172//
1173// END-TO-END DELIVERY IS UNPROVEN BY THIS SUITE, AND THAT IS STATED HERE
1174// RATHER THAN DISCOVERED LATER (SPEC-09 §8a — found during slice 2 and a
1175// certainty, not a risk). raffle.gno's FundPot and FundPotWithEntries both
1176// gate on cur.Previous().PkgPath() being EXACTLY
1177// "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/duel/v2" — the
1178// OLD realm's own path, never this realm's ".../arcade/duel/v2" — so EVERY
1179// CALL THIS FUNCTION MAKES TO THE RAFFLE TODAY PANICS, UNCONDITIONALLY, and
1180// that panic reverts this whole call with pendingPot intact (the retry
1181// property above, independently of the zero-after ordering, since nothing
1182// here has executed the zeroing yet when the panic happens). This is §8a's
1183// forced deploy order working as designed, not a defect to chase here: the
1184// raffle must be redeployed FIRST, naming this realm's path, before this
1185// function's happy path can be exercised for real — and it will be, for
1186// the first time, at that deploy, not in this suite. What this suite proves
1187// instead, and all it honestly can: the booking is correct, a refused
1188// delivery loses nothing and remains retryable, the books balance, this
1189// function is permissionless, and it refuses attached coin.
1190func FlushPot(cur realm) int64 {
1191	rejectAttachedCoin(cur, "FlushPot")
1192	amount := pendingPot
1193	if amount <= 0 {
1194		return 0
1195	}
1196
1197	// THE BATCH IS COLLECTED BEFORE ANY CROSS-REALM CALL, AND NEVER NEST ONE
1198	// avl.Iterate INSIDE ANOTHER — copied warning from
1199	// r/splash/arcade/duel/duel.gno's FlushPot: the raffle's own FundPot
1200	// iterates its own trees, and a second traversal starting inside the
1201	// first's callback crashes this toolchain's VM with a Go-level nil
1202	// dereference that no gno panic recovers from. So the walk finishes
1203	// here, fully, before the raffle runs at all.
1204	holders := make([]address, 0, pendingEntries.Size())
1205	units := make([]int64, 0, pendingEntries.Size())
1206	pendingEntries.Iterate("", "", func(key string, value any) bool {
1207		holders = append(holders, address(key))
1208		units = append(units, value.(int64))
1209		return false
1210	})
1211	entryUnits := pendingEntryUnits
1212
1213	wugnot.Approve(cross(cur), raffleRealmAddr, amount)
1214	if len(holders) > 0 {
1215		raffle.FundPotWithEntries(cross(cur), amount, holders, units)
1216	} else {
1217		// NO ELIGIBLE HOLDERS, A REAL STATE AND NOT A DEGENERATE ONE: every
1218		// party of every settled duel played from realm code, or the rakes
1219		// were too small to buy one whole entry. The money must still go,
1220		// and the raffle's entry door refuses an empty batch on purpose, so
1221		// this is the plain door.
1222		raffle.FundPot(cross(cur), amount)
1223	}
1224
1225	// ONLY NOW, WITH THE CROSS-REALM CALL HAVING RETURNED WITHOUT PANICKING
1226	// (see this function's own doc comment for why this ordering, not the
1227	// old realm's), IS THE BOOKING CLEARED.
1228	pendingPot = 0
1229	pendingSince = 0
1230	pendingEntries = avl.NewTree()
1231	pendingEntryUnits = 0
1232
1233	chain.Emit("DuelFlushPot",
1234		"amount", strconv.FormatInt(amount, 10),
1235		"destination", string(raffleRealmAddr),
1236		"entryHolders", strconv.Itoa(len(holders)),
1237		"entryUnits", strconv.FormatInt(entryUnits, 10))
1238	checkInvariant()
1239	return amount
1240}
1241
1242// SweepStuckRake is the LAST RESORT for a booked rake the raffle will not
1243// take, so that pendingPot is never a place money can enter and never
1244// leave. It transfers the WHOLE booked amount to the raffle's address by a
1245// PLAIN wugnot.Transfer — no raffle code runs, so nothing in the raffle can
1246// refuse it — and returns the amount moved. PERMISSIONLESS, and only once
1247// the batch's YOUNGEST CLAIM (pendingSince) has stood for
1248// pendingGraceBlocks. Ported from r/splash/arcade/duel/duel.gno's
1249// SweepStuckRake — same mechanism, same grace window, same dropped-entries
1250// cost, same reasoning; read that function's own doc comment for the full
1251// argument, including the gas figures for a full 128-holder batch. This
1252// comment restates only what is specific to deciding to port it here at
1253// all.
1254//
1255// WHY PORT A GRACE-WINDOW LAST RESORT WHEN §8a's REFUSAL IS A CERTAINTY,
1256// NOT THE OCCASIONAL TROUBLE THE OLD REALM'S VERSION IS A LAST RESORT FOR —
1257// this is the one judgment call this slice makes and states plainly rather
1258// than assuming. The invariant SweepStuckRake protects ("a place money can
1259// enter and never leave") does not care WHY FlushPot's door is shut, only
1260// THAT it might stay shut indefinitely. Today it is shut by construction —
1261// the raffle's duelRealmPkgPath names the old realm, not this one — and it
1262// reopens only when the raffle is redeployed naming this realm's path
1263// (SPEC-09 §8b). Resolve keeps booking pendingPot regardless of whether
1264// that redeploy ever happens, is delayed, or the swap this directory is
1265// scaffolding for is abandoned; Resolve must not, and does not, know or
1266// care. Without this function that booking would have exactly one exit
1267// (FlushPot), and that exit is the one this entire slice's own doc comment
1268// says is unproven. A grace-windowed, gate-bypassing last resort is cheap
1269// insurance against that exit never opening, and the mechanism — a plain
1270// transfer that runs no raffle code — does not depend on knowing why the
1271// gate is shut, only on giving the booking a second way out that the gate
1272// cannot touch.
1273//
1274// THE BOOKED ENTRIES ARE DROPPED ON THIS PATH, exactly as in the old
1275// realm's SweepStuckRake and for the identical reason: the money lands in
1276// the raffle's ops pot via sweepDonations, not a prize pot, so there is no
1277// draw left for an entry to be an entry IN — and an entry with no rake
1278// behind it is exactly what checkInvariant refuses. The 60/40 split is
1279// NOT preserved on this path (it all becomes the house's ops pot); that is
1280// the honest cost of a window existing at all, and it is preventable by
1281// anyone, at any time, by simply calling FlushPot instead.
1282//
1283// Panics if there is nothing booked, or if the window has not elapsed.
1284func SweepStuckRake(cur realm) int64 {
1285	rejectAttachedCoin(cur, "SweepStuckRake")
1286	amount := pendingPot
1287	if amount <= 0 {
1288		panic("duel: no rake is waiting to be delivered to the raffle")
1289	}
1290	if pendingSince <= 0 {
1291		// Unreachable: pendingSince is set in the same statement group that
1292		// makes pendingPot non-zero (Resolve, bookEntries). Loud rather than
1293		// treating an unset clock as elapsed.
1294		panic("duel: the pending rake has no booking height")
1295	}
1296	if runtime.ChainHeight()-pendingSince < pendingGraceBlocks {
1297		panic("duel: nothing in this batch is old enough for the last resort — call FlushPot instead")
1298	}
1299
1300	// THE BOOKED ENTRIES DIE HERE, discarded in O(1) by replacing the tree
1301	// rather than walking and removing — this function is the LAST RESORT
1302	// for a booked rake nobody could deliver, so its own cost must not scale
1303	// with the batch that could not be delivered.
1304	sweptEntryUnits := pendingEntryUnits
1305	sweptEntryHolders := pendingEntries.Size()
1306
1307	pendingPot = 0
1308	pendingSince = 0
1309	pendingEntries = avl.NewTree()
1310	pendingEntryUnits = 0
1311
1312	wugnot.Transfer(cross(cur), raffleRealmAddr, amount)
1313
1314	chain.Emit("DuelSweepStuckRake",
1315		"amount", strconv.FormatInt(amount, 10),
1316		"destination", string(raffleRealmAddr),
1317		"droppedEntryHolders", strconv.Itoa(sweptEntryHolders),
1318		"droppedEntryUnits", strconv.FormatInt(sweptEntryUnits, 10))
1319	checkInvariant()
1320	return amount
1321}