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 package

Overview

Package duel implements r/splash/arcade/duel: Splash Play I, the Duel (SPEC-03 v1.0, ../../../../SPEC-03-duel.md).

Two players, a matched stake from a fixed ladder, 50/50, winner takes both stakes less a 3% rake. Matching is an OPEN LOBBY: a poster leaves a stake standing and any other USER — including the poster (SPEC-03 §7.3) — may take it. (A USER: both Post and Accept require a direct user call since 2026-09-30, so no realm can be a party to a duel. requireUserCall has the reasoning, and it is this realm's own escrow that needs it.) A poster MAY instead address the offer to one opponent, in which case only that address may take it, and MAY attach a short label ("loser buys lunch"). Both are descriptive only: neither touches settlement, and the coin still decides.

THE STAKE IS WRAPPED GNOT. SPLASH launches on GNOT only — the SPLASH token economy is shelved — so this realm escrows gno.land/r/gnoland/wugnot, the GRC20 wrapper over the chain's native ugnot (6 decimals: 1 GNOT = 1e6 ugnot). It is the only token this realm touches. r/splash/token is left untouched for when the token returns; nothing here depends on it.

THE REALM BOUNDARY WAS DELIBERATELY NOTHING (SPEC-03 §2) UNTIL 2026-09-29, AND IT IS NOW EXACTLY ONE THING. The sentence that used to stand here — "it holds no authority over r/splash/arcade/raffle, r/splash/beacon or r/gnoland/wugnot: no package-path guard anywhere admits it" — is no longer true of the raffle and is corrected rather than quietly deleted.

THE ONE AUTHORITY: raffle.FundPotWithEntries admits THIS PACKAGE PATH AND NO OTHER, because playing a duel now earns the two players entries in the raffle's live week (AUDIT-2026-09-28.md finding 15, and see pendingEntries). Everything else is as it was: this is an ordinary realm holding liquid GRC20 wugnot in escrow, and the other raffle function it calls — FundPot — is still a permissionless caller-pays door any address could call with its own money.

WHAT A TOTAL COMPROMISE OF THIS REALM CAN NOW DO, restated honestly because the old sentence understated it: lose the stakes of currently-open duels, as before, AND credit raffle entries to addresses of its choosing — but only by DELIVERING THE MONEY FOR THEM, because the raffle re-derives its own ceiling from the amount it receives in the same call and refuses a batch that claims more (see raffle.ArcadeEntryUnitsBound). It cannot mint an entry, cannot move an ugnot out of the raffle, cannot reach a past week, and cannot resolve anything. The bound is what limits the authority; the guard only decides who may reach it.

THE RAKE FEEDS THE WEEKLY RAFFLE (changed 2026-09-28). It used to fund r/splash/game's yield index and prize pot; that permanent-deposit yield product is retired, SPLASH launches as the weekly ticket raffle plus this coin flip, and nothing is held beyond a week. So the players' side of every rake is sponsored into the raffle's CURRENT week's pot. See Collect.

AND PLAYING NOW EARNS ENTRIES IN THAT WEEK'S DRAW (changed 2026-09-29). The rake alone was not enough: the money arrived and the entrants did not, so a week of busy coin flipping and no ticket sales could be won whole by one 0.1 GNOT ticket bought in its final block. Every settled duel now credits its two players entries worth EXACTLY what its rake would have bought at raffle.EntryUnitPrice — the loser getting two thirds of them — so the people whose money built the pot are in the draw for it. Pricing off the rake rather than the stake is what makes that safe against two accounts flipping each other; read rakeEntryUnits, which is the security property of the feature. AUDIT-2026-09-28.md finding 15.

BUT NOT IN THE SAME TRANSACTION AS THE WINNER'S PAYOUT, AND THAT IS THE ONE STRUCTURAL CHANGE OF 2026-09-28's LATER FIX. Collect used to call raffle.FundPot directly, which ends in the raffle's own solvency check — so any panic in the raffle took the winner's payout down with it, permanently, because VoidStale refuses a resolved duel. Collect now BOOKS the players' side (pendingPot) and FlushPot delivers it, permissionlessly, whenever the raffle will take it. A RESOLVED DUEL'S PAYOUT NO LONGER DEPENDS ON ANOTHER REALM'S BOOKKEEPING. Read pendingPot for the two exits and why neither is a new money sink.

RANDOMNESS: A TWO-PARTY COMMIT-REVEAL BETWEEN THE ONLY TWO PEOPLE WHO CARE, AND SINCE 2026-09-30 THIS REALM DOES NOT IMPORT r/splash/beacon AT ALL.

Example
1Post   carries commitHash = H(commitTag ‖ secret), the poster's 32-byte secret.
2Accept carries a PLAINTEXT 32-byte nonce from the taker. No commitment, no reveal
3       from the taker, and no extra transaction — one more argument on the call
4       they already make.
5Reveal opens the commitment. outcome = H(outcomeTag ‖ secret ‖ nonce ‖ duelID),
6       last byte's parity, exactly as posterWins always read it.
7       PERMISSIONLESS: the hash is the authorisation, so anybody may settle.
8Forfeit pays the TAKER after revealWindowBlocks if the poster never opens.

WHY IT HOLDS: the poster cannot grind, because the nonce does not exist when they commit; the taker cannot steer, because they choose the nonce without the secret. No round to wait for, so settlement is available in the very next block, the window is one clock read off chain height, and no 17M-gas drand relay sits in anyone's critical path.

WHAT IT REPLACES, AND WHY A BETTER FRESHNESS CHECK WAS NOT AN OPTION. Accept used to bind Round = beacon.EstimatedLatestRoundWithin(duelMaxStaleBlocks) + duelBindOffsetRounds — 300 seconds of drand rounds past a staleness-bounded estimate of drand's head — and Resolve read that round's randomness once somebody relayed it. THAT IS BROKEN AND THERE IS A TEST PROVING IT: r/splash/beacon's TestKnownWeaknessRelayingTheRoundJustAboveTheEstimateBuysTheWholeStalenessBudgetBack shows that relaying the round JUST ABOVE the estimate resets the staleness clock to zero while advancing the estimate by exactly one round, for 0.0170 GNOT — about a tenth of a US cent. Repeat it and the estimate lags arbitrarily far behind drand's real head; once it lags past the 100-round offset, the bound round's randomness is already public on api.drand.sh and a taker computes the winner BEFORE accepting. SPEC-03 §7.1 calls exactly that "the single fatal bug of this design".

IT CANNOT BE FIXED BY A TIGHTER STALENESS BOUND, WHICH IS THE WHOLE REASON THE SOURCE OF RANDOMNESS CHANGED RATHER THAN ITS TOLERANCE. What the contract needs is an UPPER bound on drand's head ("round R is not yet signed"). A signature proves a LOWER bound ("round R exists"); the ABSENCE of a signature is not provable on chain at all, because a relayer chooses what to relay and withholding is free and invisible. So no arithmetic over relayed rounds can ever establish the property Accept needed. See r/splash/beacon/DRAND-MIGRATION.md §5b/§5c and the retired "unforgeable" claim in beacon.gno.

============================================================================ WHAT THIS DESIGN DOES NOT GIVE YOU — READ BEFORE YOU PLAY, TWO ITEMS ============================================================================

(1) THE SECRET'S ENTROPY IS UNENFORCEABLE ON CHAIN, AND commitHash IS A FREE OFFLINE VERIFICATION ORACLE. Anybody can take a published commitHash and try candidate secrets against H(commitTag ‖ secret) on their own machine, for nothing, for as long as they like. A secret drawn from anything a person could think of — a word, a date, a counter, Math.random(), a small integer padded to 32 bytes — WILL be found, and the finder then picks the nonce that makes them win, or waits for the reveal window and forfeits a duel the poster had actually won. The reveal that robs you is a VALID reveal: it passes the hash check, this realm cannot tell it from yours, and there is no event that says anything went wrong. THE PAGE MUST DRAW THE SECRET FROM A CSPRNG (crypto.getRandomValues, crypto/rand, /dev/urandom), 32 bytes, and it must be FRESH FOR EVERY OFFER. There is deliberately no on-chain entropy check: a 32-byte string is a 32-byte string, and a contract that tried to judge one would only be rejecting honest posters. checkCommitment enforces the only thing that IS checkable — the length and the encoding — and liveCommitments refuses the one reuse case a contract can see.

(2) A POSTER WITH MEMPOOL VISIBILITY HAS A FREE OPTION ON THE TAKER'S NONCE, AND THIS IS AN OPEN, UNCLOSED WEAKNESS OF THE DESIGN. The nonce travels in the clear in the Accept transaction. A poster who sees that transaction before it is committed knows the secret, the nonce and the duel ID that Accept will assign — so they can compute the outcome and, if they are about to lose, try to make the Accept fail: by cancelling the offer, or by taking their own offer (SPEC-03 §7.3 permits it) for the cost of the rake alone. Either move costs them at most 3% of the pool and saves them 50% of it. It is NOT fixed by removing Cancel — the self-match route survives that — and it is NOT fixed by mixing the accept height into the outcome, because a poster can enumerate the two or three heights their abort could land in. THE ONLY CLOSURES ARE A SECOND TAKER TRANSACTION (commit the nonce, reveal it later), WHICH THIS DESIGN DELIBERATELY REFUSES, OR AN ON-CHAIN VALUE NOBODY CAN PREDICT, WHICH IS THE BEACON THIS CHANGE JUST REMOVED. What bounds it today is that the abort must be ORDERED BEFORE an Accept that was already broadcast: a FIFO mempool puts the abort second and the taker wins. That is a property of the node software and the proposer, not of this contract, so it is recorded as an assumption and not as a defence. See TestBreak_APosterWhoSeesTheNonceEarlyHasAFreeAbort, which drives the option rather than describing it, and the addendum to AUDIT-duel-2026-09-27.md.

STORAGE IS PERMANENT AND IS RECLAIMED (SPEC-03 §7): Cancel, Collect, Forfeit and VoidStale all DELETE their record. Nothing in this realm is ever marked "settled" and kept.

Function

SweepStuckRake

func SweepStuckRake(cur realm) int64
Ask AI Claude ChatGPT

SweepStuckRake is the LAST RESORT for a booked rake the raffle will not take, and it exists so that pendingPot is not a place money can enter and never leave. It transfers the whole booked amount to the raffle's address by PLAIN wugnot.Transfer and returns it. PERMISSIONLESS, and only once the batch's YOUNGEST CLAIM has stood for pendingGraceBlocks (pendingSince, finding D-4 — it is not, and cannot be, a measure of delivery having been attempted and refused).

WHY IT CANNOT BE REFUSED, which is the only property that makes it a real exit: a plain GRC20 transfer runs no raffle code. FlushPot goes through raffle.FundPot and can therefore be refused by anything in the raffle — that is exactly the state this function is for. This one cannot be.

AND IT DROPS THE EARNED ENTRIES WITH THE MONEY, WHICH IS THE OTHER HALF OF THE COST (finding 15). The money on this path becomes the raffle's ops pot, so there is no prize left for an entry to be an entry in, and an entry with no rake behind it is a thing this realm's own invariant refuses. So players who earned entries in a batch that reached this function get nothing for them. It takes three weeks of the raffle refusing EVERY caller, the drop is in this function's event, and any player could have prevented it at any moment by flushing — for between 10,378,157 gas (a two-holder batch, measured 2026-09-30) and 351,725,503 (a FULL 128-holder batch against a week grown to 100,096 ticket indices). Read maxPendingEntryHolders for where each figure comes from and why only the first is reproducible by running this package.

THE FULL-BATCH END OF THAT RANGE IS THE ONE THAT APPLIES HERE, and citing only the cheap end was a real misstatement, corrected 2026-09-30. This paragraph is about a batch that has been accumulating for three weeks of refusals, i.e. the regime in which the batch is as large as maxPendingEntryHolders allows — so quoting a two-holder figure understated the mitigation's cost by more than thirty times in exactly the paragraph where it is dearest. (The figure said ~1.1M until 2026-09-29 and was stale by finding 15 — see maxPendingEntryHolders and AUDIT-2026-09-28.md finding 19. The argument every version of it supports is unchanged: at 11.7% of a block it is still one affordable transaction available to anybody at any height. AUDIT-duel-2026-09-27.md addendum 2, findings D-4 and D-6.)

WHERE IT LANDS, AND THE HONEST COST OF THAT. An unattributed arrival at the raffle is swept by its sweepDonations into its OPS pot, not into any week's prize — the raffle says why: crediting an unattributed arrival to a prize pot would break the identity that makes its refunds safe. So this money reaches the house rather than the players, and the 80/20 split is therefore NOT preserved on this path. That is stated plainly because it is the one place in this change where the split moves: it takes three weeks of the raffle refusing EVERY caller for it to happen, it emits its own event, and it is the alternative to the money never leaving at all.

AND THE DESTINATION IS THE RAFFLE, NOT r/splash/ops, DELIBERATELY. gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/ops does not exist — the ops constant in this file is a package-derived address with no code at it — so routing a NEW stream there would be creating exactly the "money in, no way out" shape that six of this product's findings have had. The raffle's ops pot has an exit (WithdrawOps), and whether THAT exit's destination is reachable is a pre-existing question about r/splash/ops which is the owner's and is not made worse here.

THE COST OF HAVING A WINDOW AT ALL, RECORDED RATHER THAN LEFT TO BE FOUND: after 21 days in which nobody bothered to call FlushPot — even with the raffle perfectly willing — any caller may move the booked rake to the raffle's ops pot instead of its prize pot. That is a value shift from the players to the house, it costs the caller gas and gains them nothing personally, and it is preventable by anyone at any time by simply flushing — for 10,378,157 gas at two holders and 351,725,503 at a FULL 128-holder batch, which is the end of the range that applies after three weeks of accumulation (see maxPendingEntryHolders for each figure's provenance; corrected from a bare "~8.6M" on 2026-09-30 and from "~1.1M" on 2026-09-29, findings D-4, D-6 and 19). It is accepted because the alternatives are worse: no window makes this a free diversion available on the first block, and no function at all leaves the balance with a single exit that another realm can hold shut for ever. AUDIT-2026-09-28.md finding 14.

Panics if there is nothing booked, or if the window has not elapsed.

Command

# WARNING: This command is running in an INSECURE mode.
# It is strongly recommended to use a hardware device for signing
# and avoid trusting any computer connected to the internet,
# as your private keys could be exposed.

gnokey maketx call -pkgpath "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/duel" -func "SweepStuckRake" -gas-fee 1000000ugnot -gas-wanted 1_000_000_000 -send "" -chainid "onyx-1" -remote "https://rpc.onyx.testnets.gno.land" ADDRESSgnokey query -remote "https://rpc.onyx.testnets.gno.land" auth/accounts/ADDRESS
gnokey maketx call -pkgpath "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/duel" -func "SweepStuckRake" -gas-fee 1000000ugnot -gas-wanted 1_000_000_000 -send "" -broadcast=false ADDRESS > call.tx
gnokey sign -tx-path call.tx -chainid "onyx-1" -account-number ACCOUNTNUMBER -account-sequence SEQUENCENUMBER ADDRESS
gnokey broadcast -remote "https://rpc.onyx.testnets.gno.land" call.tx