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}