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.

raffle/v4 package

Overview

Package raffle is the SPLASH weekly ticket raffle.

WHAT THIS REPLACES, AND WHY IT IS A SEPARATE REALM.

r/splash/game is a permanent-deposit yield product: you deposit GNOT, you can never withdraw, your position size IS your ticket count, and winning the weekly draw ADDS the pot to your locked position rather than paying your wallet. Its raffle had to live inside that realm because the draw mutated three pieces of that realm's state atomically (see the long comment at the top of r/splash/game/raffle.gno).

This realm sells TICKETS, not positions. A ticket costs TicketPrice and buys exactly unitsPerTicket units of one week's draw — one ticket was one unit until 2026-09-29, and EntryUnitPrice says why the two numbers had to come apart; the pot pays out to the winner's WALLET; and nothing a buyer paid is held beyond the week they bought into. Because there is no position, no yield index and no permanent deposit, the draw needs nothing from r/splash/game and this realm holds no authority over it, over r/splash/beacon, or over r/gnoland/wugnot.

THERE IS EXACTLY ONE PACKAGE-PATH GUARD IN THIS FILE, AND UNTIL 2026-09-29 THERE WERE NONE. The sentence that used to be here — "there is no package-path guard anywhere in this file and nothing for one to protect" — is no longer true and is not quietly deleted. FundPotWithEntries admits r/splash/arcade/duel and nobody else, because duel play now EARNS entries in the live week's draw (AUDIT-2026-09-28.md finding 15). Read that function in full before touching it: it is the only privileged door in this product, it is the shape r/splash/game declined to add five times, and what keeps it small is that everything it can do is bounded by the money delivered through it in the same call. Every OTHER entry point here is still permissionless and can still only do the one thing the week's state machine allows next.

THE MECHANICS ARE r/splash/game/raffle.gno's, DELIBERATELY UNCHANGED: the week number derived from the global clock, the Commit/Capture/Resolve three-phase cycle, and above all the FROZEN-TOTAL property. Read Commit below before changing anything: freezing the ticket total is what stops a buyer from waiting for the randomness to publish and then buying exactly the winning ticket index, and that attack was live in an earlier version of the old realm.

ONE MECHANIC IS NOT THE OLD REALM'S ANY MORE: WHICH ROUND DECIDES A WEEK. The old realm — and this one until 2026-09-28 — bound Round = beacon.Latest() + 1 inside Commit, which handed the round choice to whoever called Commit, AFTER the week's ticket set was already frozen and public. The publisher's key can sign any round at will and beacon.Publish accepts rounds in any order, so that was a deterministic, risk-free win for the key holder. The round is now PINNED WHEN THE WEEK'S RECORD IS CREATED — before the first ticket of that week exists — and is never written again. See getOrCreateDraw and pinOffsetRounds.

THAT DOES NOT MAKE THE DRAW TRUSTLESS, and nothing in this file should be read as claiming it does. The beacon's key holder still knows every round's randomness in advance, so they can still rig a week by choosing the MODULUS — buying last, just before the week closes, to move FrozenTotal and the unit ranges. Pinning removes the round-selection freedom, which was the cheap, deniable and exact version of the attack; the residual one leaves an on-chain trace as a suspicious late purchase. THE ARCHITECTURAL PROBLEM REMAINS OPEN and is the owner's decision: closing it needs an externally-controlled beacon or a VRF. See AUDIT-2026-09-28.md findings 4 and 10.

THE ARCADE FEEDS THIS POT, AND SINCE 2026-09-29 IT ALSO FILLS THE DRAW. r/splash/arcade/duel sends its rake here, so every coin flip makes this week's prize bigger — that is what makes the duel and the raffle one product instead of two contracts. The rake itself still buys no tickets and no winning chance: it is tracked separately as Draw.Sponsored, because the refund safety property is the identity Pot == sum(spends) + Sponsored and house money belongs to no buyer. Read Draw.Sponsored and FundPot together before changing either.

WHAT CHANGED IS THAT THE PLAYERS WHO PAID THAT RAKE NOW ARRIVE WITH IT. Until 2026-09-29 the money came and the entrants did not, so a week of busy coin flipping and no ticket sales could be won outright by one cheap ticket bought in its final block — the whole pot, to somebody who had contributed none of it. FundPotWithEntries is the fix: the duel delivers the same rake and, in the same call, the two players of each settled duel are credited entries worth EXACTLY what that rake would have bought at EntryUnitPrice. The money and the entrants now arrive together. Earned entries carry ticket weight and NO spendOf record, which is consistent with every identity in this file and is spelled out at FundPotWithEntries. AUDIT-2026-09-28.md finding 15.

WHAT IS NEW, and could not have existed in the old design: Refund. There, the pot was accrued protocol fees and nobody's stake was ever at risk, so an unresolvable draw could simply wait forever. Here the pot is buyers' own money, so a week nobody settles must have a way back out. SINCE THE DRAND MIGRATION THAT IS A LIVENESS EXIT AND NOT A LOSS EXIT — no round is ever unobtainable, so what Refund guards against is nobody bothering to relay one. See Refund.

Functions

ArcadeEntryUnitsBound

func ArcadeEntryUnitsBound(amount int64) int64
Ask AI Claude ChatGPT

arcadeEntryUnitsBound is the MOST entry units a delivery of `amount` ugnot of duel rake pot share may ever carry, and it is the security property of finding 15 stated in one line of arithmetic at the door.

THE DERIVATION. The duel's rake splits potShare = rake - floor(rake * 4000 / 10000), so potShare >= rake * 6000/10000 and therefore

Example
1rake <= amount * arcadeBpsDenom / arcadeRakePotShareBps

for a delivery of amount = potShare. Rule 1 prices an entry at ONE EntryUnitPrice of rake, so the unit ceiling is that gross-up divided by EntryUnitPrice. At rung 0: amount 360000 -> gross 600000 -> 2 units, which is EXACTLY what the duel credits, so this bound is tight rather than generous and one unit of over-issuance fails it.

IT IS PRICED PER ENTRY UNIT AND NOT PER TICKET, SINCE 2026-09-29, AND THAT IS FINDING 22. Divided by a 10 GNOT TicketPrice this ceiling would be 0 at rung 0, 0 at rung 1 and 1 at rung 2 — it would refuse the duel's own honest batch at three of the four rungs, and a refused batch is a FlushPot that can never land, i.e. rake that reaches this realm's prize pot never at all. The ceiling and the duel's own rakeEntryUnits have to be denominated in the same unit or one of them is wrong; the unit is EntryUnitPrice.

AND NOTE WHAT IT IS *NOT*: floor(amount / EntryUnitPrice), i.e. the units the DELIVERED money alone would buy. That is 1 unit at rung 0 (360000 / 250000), and it would refuse the honest 2-unit batch for exactly the reason the gross-up exists — this realm receives the players' share and prices entries off the whole rake. It is alternative 2 from the block above duelRealmPkgPath, rejected there because it cannot express the owner's pricing rule.

IT NEVER REFUSES AN HONEST BATCH, INCLUDING AN AGGREGATED ONE. FlushPot delivers the sum of several duels' pot shares against the sum of their entries. Each duel's rake_i <= potShare_i * 1.25 exactly, sum(rake_i) is an integer no greater than amount * 1.25, and floor() of a real bound is still >= any integer below it — so the aggregate bound is never smaller than the aggregate claim. Dust can only ever make the bound looser, never tighter.

EXPORTED so the duel's suite can pin the two realms' arithmetic to each other (see arcadeRakePotShareBps), and so anybody can check the rate off-chain. A plain read: no state, no authority, nothing it can be called for.

Param

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.ArcadeEntryUnitsBound()"

Result

AuditFailure

func AuditFailure() string
Ask AI Claude ChatGPT

AuditFailure re-derives the invariant BY ITERATION and reports the empty string when the books balance, otherwise which check failed and by how much. Never called from a state-changing path — checkInvariant is the O(1) guard for that — and exists so anyone can verify off-chain, and so a test can assert it after every operation.

IT MUST BE ROBUST AGAINST ANYONE WHO CAN SEND THIS REALM TOKENS, which is everyone: wugnot.Transfer needs no permission. So the balance test below is a one-sided one — a shortfall is a violation, an unswept surplus is not. See the comment on that check.

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.AuditFailure()"

Result

AuditInvariant

func AuditInvariant() bool
Ask AI Claude ChatGPT

AuditInvariant is AuditFailure reduced to a bool.

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.AuditInvariant()"

Result

Buy

func Buy(cur realm, tickets int64)
Ask AI Claude ChatGPT

Buy sells `tickets` tickets in the CURRENT week to the caller.

Cost is tickets * TicketPrice, pulled from the caller with the GRC20 allowance path, so the caller wraps native ugnot with wugnot.Deposit and approves this realm first. HouseCutBps of the cost is ESCROWED AGAINST THIS WEEK in Draw.Cut; the remainder — and all integer-division dust, since the cut is floored — joins this week's prize pot.

IT SAID "goes to the ops pot" UNTIL NOW AND THAT HAS BEEN FALSE SINCE 2026-09-29. A sale banks nothing for the house: the fee is unearned until the week actually draws, Resolve is what moves it to opsPot, and a week that fails hands it back as part of each buyer's gross refund. The distinction is the whole of finding 25 — "we earn when the game runs, not when it fails" — and a comment that says the money is already the house's describes a realm that cannot refund in full. AUDIT-2026-09-28.md finding 29.

EVERY CHECK RUNS BEFORE THE PULL, and the week record is created only after it, so a failed Buy moves nothing and creates nothing.

Each purchase APPENDS its own ticket index rather than growing an existing one. That is not a style choice: it is the property Commit depends on, and the reason is spelled out there.

THE WEEK'S FIRST BUY PINS THE WEEK'S BEACON ROUND, as a side effect of creating the record (getOrCreateDraw). That is deliberate and is the only moment the round is ever chosen: read pinOffsetRounds. Two consequences a caller can see. First, a sale needs the beacon to have published at least one round ever, or it is refused with the beacon's own "no round published yet" — which is a refused sale rather than money accepted into a week that could never be drawn. Second, the buyer who opens a week does not get to pick anything useful: the round is fixed a week and more ahead of the moment the ticket set closes, so at the instant it is pinned there is no ticket set for it to be advantageous against.

Panics if tickets is not positive, if the cost overflows int64, if the allowance is short, or if THIS WEEK IS ALREADY COMMITTED — the last of which is the frozen-total rule, not a convenience check. Once a week's total is frozen, no further ticket may join it.

THAT LAST REFUSAL IS NOW UNREACHABLE, and is kept deliberately. Commit only accepts a week that is already over, and Buy only ever sells into currentWeek(), so the live week cannot be in a committed state — which is exactly why a commit can no longer shut a week's sales down early. The check stays as a second lock on the frozen-total rule: if the week's identity ever stops being "the one the clock is on", this is the line that still refuses to grow a frozen ticket set.

ONLY A USER MAY BUY, AND THE REASON IS THE PAYOUT, NOT THE PURCHASE. A ticket is a claim on two transfers this realm will later push OUT: Resolve's whole pot to the winner's address, and Refund's spend back to the buyer's. Both go to cur.Previous().Address(), which for a realm caller is that realm's package-derived address — an address whose only "key" is its package path, so nothing can move wugnot out of it unless that realm happens to contain code that does. A realm with no such code that won a draw would have the entire week's pot transferred into an address it can never spend from: money that has left this realm's books (heldPots drops, the invariant stays green) and is unreachable by anybody, for ever. This realm cannot tell which realms those are and must not try, so it refuses them all at the door instead of stranding a pot at settlement time.

cur.Previous().IsUserCall() IS THE CHECK, COPIED FROM r/splash/exchange:378 — AND IsUser() IS NOT. IsUser() also accepts a `maketx run` ephemeral realm, which is code executing under a user's signature and NOT an EOA: its address is still package-derived, so it can win a pot and then cease to exist with the money in it. The exchange states the same distinction for the same word, in the context of a send envelope rather than a payout; the failure mode differs but the property being asked for — "the thing in front of me is a wallet somebody holds a private key for" — is identical.

FundPot IS DELIBERATELY NOT GUARDED THIS WAY, AND MUST NOT BE "MADE CONSISTENT". r/splash/arcade/duel calls it from realm code — from FlushPot, once per batch of collected duels; it was from Collect itself until AUDIT-2026-09-28.md finding 14 — so adding this check there would break the arcade's rake, the thing that makes the duel and the raffle one product. It is safe there precisely because sponsorship buys nothing: no ticket unit, no winning chance, no refundable claim (see FundPot and Draw.Sponsored), so a realm that funds a pot can never be the address a payout is pushed to, and the defect above has no way to arise. The asymmetry is the point: the guard belongs on the entry point that creates a claim on an outbound transfer, not on the one that only ever pays in.

AND FundPotWithEntries DOES CREATE SUCH A CLAIM, WHICH IS WHY IT IS THE ONE GUARDED FUNCTION IN THIS FILE — WITH A DIFFERENT GUARD, FOR A DIFFERENT REASON. It appends ticket weight for addresses the CALLER names, so this finding's defect would arise there twice over if it were open: any caller could credit a realm address that can never spend a pot, and could do it for somebody else's benefit. IsUserCall cannot be the check there, because the only legitimate caller IS a realm. So that function admits exactly one package path, and the finding-8 property — every holder of an earned entry is an address a payout can actually reach — is enforced where it is knowable: r/splash/arcade/duel records whether each party's own Post or Accept was a direct user call and credits only those. A REALM CANNOT PLAY A DUEL AT ALL SINCE 2026-09-30, and the sentence that stood here — "a realm may still play duels; it simply earns no raffle entries by doing so" — is corrected rather than deleted because the reason it stopped being true is this finding travelling one realm further. r/splash/arcade/duel found that leaving SETTLEMENT open to realms had the same defect in its own escrow: its Collect is winner-only, so a winning realm with no code calling it stranded both stakes with no exit. Post and Accept there now require a direct user call. The duel still records each party's flag and still credits entries only to eligible parties — nothing this realm relies on has weakened, and the property below is now enforced twice over. AUDIT-duel-2026-09-27.md addendum 2, finding D-1.

Param

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/raffle/v4" -func "Buy" -args $'' -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/raffle/v4" -func "Buy" -args $'' -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
  

Capture

func Capture(cur realm, week int64)
Ask AI Claude ChatGPT

Capture copies a committed week's beacon randomness into the draw.

IT IS NO LONGER A SAFETY MECHANISM, AND SAYING SO IS THE POINT OF THIS COMMENT. What it used to say: "the beacon keeps signatures for beacon.PruneWindow = 2016 rounds, about seven days at a five-minute cadence; this raffle is WEEKLY, so a draw settled late can find its round already pruned and the pot would have no way to name a winner. Capture preserves the value while it is still there." Every clause of that is now false. r/splash/beacon has no retention window and no Prune, drand serves every round for ever from at least three public relays, and Publish is permissionless — SO THERE IS NOTHING TO PRESERVE THE VALUE FROM. There is no capture window either; the old paragraph computed one, and it does not exist.

WHAT IT STILL DOES, which is why it is kept rather than removed:

  • it copies a public value into the draw's own state, so Resolve needs no beacon read later and the settled draw is self-contained;
  • it makes the value visible through CapturedRandomness and the Capture event before the week is settled, which a front end and an auditor can both use.

A CONVENIENCE, then. Removing it would delete an exported realm function and a large block of correct, tested behaviour in order to retire a comment; retiring the comment is enough. Nothing in this realm's safety now depends on anyone calling it — see resolvable()'s removal and Refund's gate.

PERMISSIONLESS, deliberately. Its only effect is to store a value that is already public — the round's randomness, readable by anyone the moment it is published. Gating it on an operator would buy nothing and would put a liveness dependency for the whole pot back onto one key.

WHAT IS STORED IS THE 64-CHARACTER HEX RANDOMNESS — sha256 of the signature, a hash, not the signature itself — and it is NOT re-verified here. The pairing check happened once already inside beacon.Publish on the way in; redoing it would be expensive duplicated work on an input that is already known good, and the beacon has no route by which a published round's randomness can change afterwards.

A SECOND Capture NEVER OVERWRITES THE FIRST, and is a silent no-op rather than a panic: two callers racing to perform a permissionless public good should not make one of them fail.

Capturing before the round is published PANICS. There is nothing to preserve yet, and storing anything else — least of all some already-published value — would hand the draw a randomness for a round nobody pinned, which is the exact attack the pin exists to prevent.

COMMIT DOES NOT CAPTURE, AND MUST NOT START. Since the round is pinned at record creation, a week's round may well be published by the time it is committed, so a capture there would often succeed — and it would put a beacon read back inside the one function whose job is to choose nothing. Capture staying separate and permissionless is what keeps the commit path free of any beacon dependency at all; see Commit's note on why there is no publication check there either.

Param

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/raffle/v4" -func "Capture" -args $'' -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/raffle/v4" -func "Capture" -args $'' -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
  

CapturedRandomness

func CapturedRandomness(week int64) string
Ask AI Claude ChatGPT

CapturedRandomness returns what Capture stored for a week, or the empty string if nothing was captured. Kept out of DrawOf so DrawOf's shape stays stable for callers.

Param

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.CapturedRandomness()"

Result

ClaimDeadWeek

func ClaimDeadWeek(cur realm, week int64) int64
Ask AI Claude ChatGPT

Param

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/raffle/v4" -func "ClaimDeadWeek" -args $'' -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/raffle/v4" -func "ClaimDeadWeek" -args $'' -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
  

ClaimPerUnitOf

func ClaimPerUnitOf(week int64) int64
Ask AI Claude ChatGPT

ClaimPerUnitOf is the ugnot each ticket UNIT of a week may reclaim from the sponsorship that week's draw never paid out, or 0 for any week that is not abandoned. Read Draw.ClaimPerUnit and retireSponsorship.

ZERO ON AN ABANDONED WEEK IS MEANINGFUL AND IS NOT AN ERROR: it means either that the week held no sponsorship at all, or that the sponsorship was smaller than the week's unit count, so no unit could be given a whole ugnot and the whole amount was sub-unit dust. The DeadWeekSponsorshipDistributed and DeadWeekSponsorshipToOps events distinguish the cases.

Param

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.ClaimPerUnitOf()"

Result

ClaimSponsorRefund

func ClaimSponsorRefund(cur realm, week int64) int64
Ask AI Claude ChatGPT

ClaimDeadWeek pays the caller their pro-rata share of the sponsorship that a DEAD week's draw never got to pay out, and returns the amount. PERMISSIONLESS, self-service only — a caller can only ever claim their own share — and it is the EXIT for the claim term retireSponsorship creates. AUDIT-2026-09-28.md finding 31.

WHY IT IS A NEW ENTRY POINT AT ALL, WHICH IS THE ONE QUESTION WORTH ANSWERING HERE. The cheaper design is to fold each holder's share into their spendOf and let the existing, already-audited Refund be the exit — no new function, no new term, no new tree. THAT DOES NOT WORK, AND THE ARITHMETIC IS WHY RATHER THAN A JUDGEMENT CALL:

Example
 1Refund re-derives the house fee it must give back as
 2    cutPart = floor(gross * HouseCutBps / bpsDenom)
 3and decrements d.Cut by it. That derivation is EXACT — see cutPerTicket's build guard —
 4only because a spendOf is always a whole number of ticket prices, so its floor is
 5precisely the sum of the cuts those purchases booked.
 6
 7A sponsorship share booked NO CUT. Add a share s to a buyer whose gross was
 8g = n * TicketPrice and Refund derives floor((g+s)/20) = n*500_000 + floor(s/20), while
 9d.Cut only ever received n*500_000. d.Cut goes NEGATIVE by floor(s/20) and Refund's own
10"refund would overdraw the week's escrowed fee" panic fires — so the buyer cannot
11reclaim their own ticket money at all. Worse in the pure-earned case: a holder with no
12purchase gets gross s against a week whose Cut is 0, and floor(s/20) is stolen from
13nothing. THE FIX FOR A FUND-STRANDING DEFECT WOULD HAVE CREATED ONE.
14
15The identity itself survives — Refund removes `amount` from both sides whatever the
16rounding — which is exactly what makes this dangerous: checkInvariant and AuditFailure
17stay green right up to the panic. It is the Cut term, not the identity, that breaks.

SO THE CLAIM IS A SEPARATE TERM WITH ITS OWN EXIT, AND IT CARRIES NO FEE BY CONSTRUCTION: sponsorship never paid a house cut on the way in and this pays none on the way out. The term appears in checkInvariant (heldClaims) and in AuditFailure (per week, and in the aggregate, and re-derived against claimedOf by deadWeekClaimFailure), which is the price of not reusing Refund and is paid in full.

IT HAS NO CLOCK, NO BEACON READ AND NO REFUSABLE DEPENDENCY, deliberately. By the time a claim exists the week is already abandoned — every gate that had to be waited out (Refund's grace window, or SweepDrawlessWeek's) was waited out before retireSponsorship ran — so a second wait would only be a second thing that could go wrong on a payout path. Compare ReclaimStrandedCarry, which this replaces: that needed a beacon test AND a three-week long stop because it was trying to find a destination. There is nothing to find.

IT IS O(log N) AND ALL-OR-NOTHING PER HOLDER. The share is derived from the rate and the holder's unit weight at call time rather than stored per holder at abandonment, which is what keeps the abandonment transition constant-time (read retireSponsorship). The weight cannot have changed since: an abandoned week is always a past week, and Buy and FundPotWithEntries only ever touch currentWeek(). One claimedOf entry therefore settles a holder for ever, and a second call finds it and refuses.

A REFUNDED BUYER MAY STILL CLAIM, AND THAT IS CORRECT, NOT A LOOPHOLE. Refund returns the gross they paid for their tickets; this returns their share of money that was never theirs to pay and never anybody's to win. The two are different money with different owners, and the week's identity keeps them apart — spendOf on one side, Claims on the other. A holder with earned entries and no purchase has a claim here and nothing to refund, which is the whole point of finding 15's players being paid.

BOOKS FIRST, TRANSFER SECOND, as in Resolve, Refund and WithdrawOps.

Panics if there is no record for the week, if that week never distributed anything, if the caller holds no units in it, or if the caller has already claimed. ClaimSponsorRefund returns a sponsor's payment from a week that died without drawing. It pays the GROSS — the house's 5% included — because the fee was only ever earned by a draw actually running, and this draw never ran.

THE OWNER'S RULING, 2026-09-30: a sponsor's money is "either returned to sponsor or rolls over until a draw happens". Rolling forward is what AUDIT-2026-09-28.md findings 28 and 31 removed — the caller chose which week it landed in by choosing when to call, and could aim it at a draw they already dominated. So it is returned, and it is PULLED rather than pushed: paying up to maxSponsorsPerWeek addresses inside retireSponsorship would put those transfers on the critical path of a buyer's own Refund, where one failing recipient could block the week.

IT DOES NOT COMPETE WITH ClaimDeadWeek. That pays earned-entry holders out of the duel's OWNERLESS rake; this pays named sponsors out of money that never entered that pool, because retireSponsorship carves it out first. The two draw on different halves of the same pot and neither can reach the other's.

Param

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/raffle/v4" -func "ClaimSponsorRefund" -args $'' -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/raffle/v4" -func "ClaimSponsorRefund" -args $'' -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
  

ClaimableOf

func ClaimableOf(week int64, addr address) int64
Ask AI Claude ChatGPT

ClaimableOf is what `addr` may take out of a dead week right now: their EARNED unit weight times the week's claim rate, or 0 once they have taken it. IT IS EXACTLY WHAT ClaimDeadWeek WILL PAY, derived the same way and off the same tree, so a front end never has to guess.

IT READS earnedOf AND NOT ticketsOf — AUDIT-2026-09-28.md finding 34, and this read had to move in the same commit as ClaimDeadWeek's. A read that disagrees with the entry point it mirrors is worse than no read at all: a front end would have shown a refunded buyer a positive claimable figure and then watched their transaction abort, which is how a caller learns to distrust the whole realm. THE TWO DERIVATIONS ARE NOW ONE EXPRESSION, DUPLICATED — earnedOf times ClaimPerUnit — and every test that asserts ClaimDeadWeek's return value against this read is what keeps them equal.

A BUYER WHO HOLDS ONLY BOUGHT UNITS READS ZERO HERE, AND THAT IS THE ANSWER, NOT A GAP. Their money comes back through Refund, in full, fee included. Read EarnedOfWeek alongside TicketsOfWeek to see the two kinds of weight separately.

Params

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.ClaimableOf(,)"

Result

Commit

func Commit(cur realm, week int64) int64
Ask AI Claude ChatGPT

Commit commits an ALREADY-FINISHED week to a future beacon round. Returns that round.

THE WEEK IS A PARAMETER AND IT MUST BE OVER — week < currentWeek(). Both halves of that sentence are load-bearing, and each fixes a defect the first version of this function had:

  • IT TAKES A WEEK, so any past week can be committed at any time, by anyone. Before, Commit could only ever act on currentWeek(), which meant a week whose clock ran out before anybody called it could never be committed — and because Resolve and Refund both require Committed, that week's whole pot was unreachable forever while heldPots went on counting it, so the invariant and the audit both stayed green over money nobody could ever get back. A stranded week is now always recoverable: commit it, then resolve it, or refund it if its round was lost.

  • THE LIVE WEEK CAN NEVER BE COMMITTED, so a commit can no longer cut a week's sales short. Before, anyone could Buy(1) and immediately Commit: that made them the sole owner of a FrozenTotal of 1 — the guaranteed winner — and, because Buy refuses a committed week, shut the week's sales for its remaining days. It cost the attacker the 5% cut on one ticket and was repeatable every week with no admin and no pause to stop it; the front-running variant landed a commit just ahead of somebody's large Buy and made their purchase panic. Refusing week >= currentWeek() closes all of that at the root rather than with a minimum-tickets or minimum-elapsed heuristic: the week being sold into is simply not a committable week.

PERMISSIONLESS: there is no keeper and no admin. Anyone who wants the draw to happen — every ticket holder, for a start — can call it, once the week is up.

It freezes two numbers, and CHOOSES NOTHING:

  • FrozenTotal = the week's final ticket total. The week is over and Buy only ever sells into currentWeek(), so this total can no longer grow BY CONSTRUCTION; it is still assigned explicitly, because Resolve reads this field and not the live total.
  • Pot, which stops growing for the same reason (and Buy also refuses a committed week, which is now a second, unreachable-by-design lock).
  • AND IT USES Draw.Round EXACTLY AS PINNED, which was written when this week's record was created and is not this function's business. See below.

THE SECURITY RULE, AND THE ONLY REASON THIS IS THREE PHASES: the ticket set must be frozen BEFORE the randomness is knowable. If the total were read at resolve time, anybody who saw the published round could compute the winning unit and then buy precisely enough tickets to own it — which turns a raffle into a purchase.

THIS FUNCTION NO LONGER BINDS THE ROUND, AND THAT IS THE 2026-09-28 FIX. It used to set Round = beacon.Latest() + 1 here, which looked like the conservative choice — the round is unpublished at commit time, so its randomness "does not exist yet" — and was in fact the operator's cheapest attack on the product: the beacon's key holder knows every round's randomness in advance, and Commit ran AFTER the week's ticket set was frozen and public, so they could enumerate candidate rounds, Publish until Latest()+1 named them, and commit. The round is now pinned at record creation instead; read pinOffsetRounds for the full account, including what remains open.

THERE IS DELIBERATELY NO "THE ROUND MUST STILL BE IN THE FUTURE" CHECK HERE, and the old `round <= latest` wrap check is gone with it. A reader who expects one has the fix backwards. A week is committed at least a week after its round was pinned, so by now the pinned round MAY LEGITIMATELY ALREADY BE PUBLISHED — that is the ordinary case for a week whose record opened early, and it is harmless precisely because nobody chose it: the round was fixed before a single ticket of that week had been sold. Requiring it to be unpublished would refuse the honest case and, worse, would reintroduce round selection through the back door, since the only way to satisfy such a check would be to recompute the round here. The wrap case the old check guarded is now handled where the arithmetic happens, at maxPinnableLatest.

WHY FREEZING THE TOTAL ALSO FREEZES WHO OWNS UNIT k — the part the old realm got wrong first time, and the reason Buy appends:

  • Every purchase APPENDS A NEW ticket index (appendTickets). No index's value is ever increased afterwards, and fenwick.Add is not called from this realm at all.
  • An appended index therefore starts at a prefix equal to the whole total at the moment it was appended, so anything appended after Commit lies at or beyond FrozenTotal — outside the frozen range by construction, not by convention.
  • And because no earlier index's value changes, no earlier prefix moves: the owner of every unit below FrozenTotal is fixed from the instant that unit was sold.

WITH ONE INDEX PER BUYER INSTEAD, a further purchase would grow that buyer's existing value, widening their range IN PLACE, from its start, inside the frozen range, and pushing every later buyer along. The earliest buyer (prefix 0 forever) could then wait for the round to publish, compute k = randomness mod FrozenTotal, buy until their own value exceeded k, and call Resolve to take the pot: a free option on the whole pot. That exploit was real in r/splash/game and its fix is what this realm inherits. Buy's refusal after commit closes the same door a second time.

A WEEK'S TICKET TOTAL MAY BE ENTIRELY EARNED AND THIS FUNCTION DOES NOT CARE. Since finding 15 a week can reach this point with tickets and NO spend records at all — every entry credited to a duel player through FundPotWithEntries, not one ticket bought. total > 0 is satisfied, the week commits, and Resolve pays the winner a pot made purely of the rake those players paid. That is the intent, and it is why the zero-ticket refusal below is a check on the TICKET INDEX and not on the spend records: a drawless week is one nobody entered, not one nobody paid this realm directly.

Panics if the week is not over yet, if it is already committed, or if it has no tickets at all (there is NO ROLLOVER, so a drawless week must never be allowed to exist). It no longer reads the beacon at all: the "no round has ever been published" refusal moved to the pin, which is to say to Buy and FundPot, where a sale is refused rather than accepted into a week that could never be drawn.

Param

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/raffle/v4" -func "Commit" -args $'' -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/raffle/v4" -func "Commit" -args $'' -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
  

CurrentWeek

func CurrentWeek() int64
Ask AI Claude ChatGPT

CurrentWeek is the week tickets are being sold into right now.

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.CurrentWeek()"

Result

CurrentWeekEndHeight

func CurrentWeekEndHeight() int64
Ask AI Claude ChatGPT

CurrentWeekEndHeight is the chain height at which the LIVE week ends: the one number a countdown wants. WeekEndHeight(CurrentWeek()), read in a single call.

IT IS ONE CALL ON PURPOSE. Composing the two exported reads off chain means two queries at two different chain heights, and a week that turns over between them yields a boundary for a week that has already ended — a countdown that jumps backwards by a whole week exactly at the moment it matters most. Read here, both halves see one height.

A BLOCK HEIGHT, NOT A TIME, for the reasons on WeekEndHeight.

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.CurrentWeekEndHeight()"

Result

DrawOf

func DrawOf(week int64) (bool, int64, int64, int64, bool, address, bool, int64, int64)
Ask AI Claude ChatGPT

DrawOf reports a week's record: committed, frozen ticket total, beacon round, pot, resolved, winner, abandoned, sponsored. All zero for a week that never sold a ticket.

ABANDONED IS PART OF THIS TUPLE rather than a separate read because it is not a detail: it is the difference between a week that is waiting for its randomness and a week that is paying its buyers back and will never have a winner. A front end that showed "unresolved" for both would be lying to a buyer about whether to expect a draw or to go and claim a refund.

SPONSORED IS PART OF IT for a related reason: pot minus sponsored is what the week's buyers put in and could reclaim, and sponsored is what the arcade added on top. A buyer looking at a pot deserves to know which part of it is a claim of theirs and which is house money that will never be refunded.

COMMITTED-AT IS THE LAST ELEMENT, and it is here so a front end can compute the one number a buyer of a stuck week actually wants: CommittedAt + refundGraceBlocks, the height at which their money becomes reclaimable whatever the beacon does. RefundOpensAt does that arithmetic; this returns the raw height so a caller can also show how long the week has been waiting. Zero on an uncommitted week, which is exactly the "no refund clock is running yet" case.

Param

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.DrawOf()"

Result

EarnedOfWeek

func EarnedOfWeek(week int64, addr address) int64
Ask AI Claude ChatGPT

EarnedOfWeek is how many of `addr`'s units in a week were EARNED through duel play rather than bought — the basis a dead week's sponsorship is divided on. AUDIT-2026-09-28.md finding 34.

IT EXISTS SO THE TWO KINDS OF WEIGHT ARE VISIBLE SEPARATELY. TicketsOfWeek returns the draw's basis (both kinds, indistinguishable, which is finding 15's design) and this returns the claim's. A front end showing a dead week needs both, because "you hold 412 units and your claim is on 12 of them" is the only explanation of a zero ClaimableOf that a buyer will accept.

Params

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.EarnedOfWeek(,)"

Result

EarnedTotalOfWeek

func EarnedTotalOfWeek(week int64) int64
Ask AI Claude ChatGPT

EarnedTotalOfWeek is a week's whole earned unit count — the divisor retireSponsorship used, or will use. Zero on a week whose every unit was bought, which is the state that routes a dead week's sponsorship to opsPot rather than to holders.

Param

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.EarnedTotalOfWeek()"

Result

EscrowedCut

func EscrowedCut(week int64) int64
Ask AI Claude ChatGPT

EscrowedCut is the house fee held against one week and NOT YET EARNED, in ugnot: it becomes ops revenue if that week resolves and goes back to the buyers if it does not. Zero for a week that has resolved, been fully refunded, or never sold a ticket.

Param

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.EscrowedCut()"

Result

FundPot

func FundPot(cur realm, amount int64)
Ask AI Claude ChatGPT

FundPot adds house money to the CURRENT week's prize pot without buying a single ticket. PERMISSIONLESS and CALLER-PAYS: the funder pulls from their own allowance, exactly as Buy does, so this realm gains no authority over anybody and any address can call it with its own money.

IT IS REALM-CALLABLE ON PURPOSE AND HAS NO IsUserCall GUARD. Do not add one for consistency with Buy. r/splash/arcade/duel calls this from realm code on every Collect — that is the arcade's rake, and gating it on an EOA would break the one mechanism that makes the duel and the raffle a single product. Buy's guard exists because a ticket is a claim on an outbound transfer (Resolve's pot, Refund's spend) and a realm's package-derived address may have no way to spend what arrives there; sponsorship creates no such claim at all — no ticket unit, no winning chance, no refundable record, see below — so a funder can never be the address a payout is pushed to and the defect Buy's guard closes cannot arise here. The asymmetry is deliberate and is stated at both ends.

WHY IT EXISTS. r/splash/arcade/duel sends its rake here: every coin flip makes this week's raffle prize bigger, which is what makes the two contracts one product rather than two features. It used to fund r/splash/game's yield and prize pots; that product is retired, and nothing in SPLASH holds money beyond a week any more.

IT CAN OPEN A WEEK, AND THEREFORE CAN PIN THAT WEEK'S BEACON ROUND. When the duel's rake arrives before anybody has bought a raffle ticket, this is the call that creates the week's record — and getOrCreateDraw pins Draw.Round there, once, for whichever entry point got there first (read pinOffsetRounds). A later Buy in the same week finds the record and never re-pins. The same beacon prerequisites as Buy therefore apply here, and there are now TWO: with no round ever published, and with no relay inside beacon.MaxEstimateStaleBlocks that raised the beacon's anchor, EstimatedLatestRound panics and THE SPONSORSHIP IS REFUSED.

AND THAT REFUSAL IS NO LONGER A CROSS-REALM HAZARD, WHICH IS WHY THE STALENESS BOUND COULD BE ADOPTED AT ALL. It used to be: r/splash/arcade/duel's Collect called this function IN THE SAME TRANSACTION as the winner's payout, so a refusal here reverted that payout — permanently, since VoidStale refuses a resolved duel. Collect now BOOKS its rake and duel.FlushPot delivers it, so a refusal here costs the flusher a reverted transaction and the booked balance is still there for the next attempt. Any caller can clear the refusal with one Publish of drand's current round. AUDIT-2026-09-28.md findings 12 and 14.

SPONSORSHIP BUYS NOTHING. d.tickets, d.ownerOf, d.ticketsOf and spendOf are all left alone: the funder gets no ticket unit, no winning chance, and no refundable claim. Only d.Sponsored and d.Pot move. A funder who never bought a ticket therefore cannot win the week they funded — ownerOfUnit can only ever name an address that appears in d.ownerOf, and nothing here writes to it.

THAT IS STILL TRUE OF THIS FUNCTION AFTER FINDING 15, AND IT IS THE REASON THE EARNED-ENTRY DOOR IS A SEPARATE FUNCTION RATHER THAN A PARAMETER HERE. FundPotWithEntries appends ticket weight for named holders and calls this function for the money; this function appends nothing for anybody, and every caller of it — including a realm, including the duel when it has no eligible holders to credit — still buys exactly nothing. Do not add holders to this signature: the guard that makes the other door safe would then have to be here, on the permissionless path the arcade's rake depends on.

WHY THE SEPARATE FIELD RATHER THAN JUST d.Pot += amount: read Draw.Sponsored. The one-line version is that d.Pot == sum(spendOf) is what makes Refund provably unable to overdraw, house money belongs to no buyer, and so the identity becomes d.Pot == sum(spendOf) + d.Sponsored rather than being quietly abandoned.

IT FUNDS THE LIVE WEEK ONLY, AND THERE IS NO COMMITTED-WEEK RACE HERE. Commit accepts only a week that is already over (week < currentWeek()), so the week currentWeek() names is uncommitted BY CONSTRUCTION — no concurrent commit can freeze it under a funder's feet, and money can never land in a pot whose winner is already being drawn. The refusals below are therefore defensive rather than reachable, and are kept for the same reason Buy keeps its own committed-week check: if the week this funds ever stops being "the one the clock is on", these are the lines that still refuse.

A SPONSORED WEEK WITH NO TICKETS IS NOT A DRAWABLE WEEK. Creating the week's record here is the same act Buy performs, and it does not make the week committable: Commit still panics on a zero ticket total, because there is no rollover and a drawless week must not exist. Such a week's sponsorship sits in the pot until somebody buys a ticket into it (the week is live, so they can) — and if the week ends with nobody having bought one, SweepDrawlessWeek registers that sponsorship for the next week to open (it names no destination itself — finding 28 — and it waits out a grace window first). THAT FUNCTION IS WHAT KEEPS THIS ONE SAFE: without it, funding a week that never sold a ticket stranded the money in a Draw that could never be committed, resolved or refunded. Refusing to create the record instead would mean the duel's rake bounced whenever it arrived before the week's first ticket sale, which would make settling a duel depend on somebody else's purchase.

Panics if amount is not positive, if the allowance is short, or — defensively — if the target week is already committed or has been abandoned into refund (there is nothing left to fund: its buyers are taking their money back).

Param

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/raffle/v4" -func "FundPot" -args $'' -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/raffle/v4" -func "FundPot" -args $'' -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
  

FundPotWithEntries

func FundPotWithEntries(cur realm, amount int64, holders []address, units []int64) int64
Ask AI Claude ChatGPT

FundPotWithEntries is FundPot plus the earned entries that rake bought, in ONE call, and it is callable only by r/splash/arcade/duel. Returns the total entry units credited.

READ THE BLOCK ABOVE duelRealmPkgPath FIRST: it has the defect, the three rules, why this door is privileged and why the bound rather than the guard is what makes it safe.

WHY ONE CALL AND NOT TWO. The money and the entries must land in the SAME week and the same transaction, or the pot identity and the ticket index could disagree about which week the duel's rake belonged to. A caller cannot deliver one without the other, cannot deliver entries worth more than the money, and cannot choose the week — currentWeek() decides, exactly as FundPot's does.

THE MONEY PATH IS FundPot'S, UNCHANGED AND NOT COPIED. This function calls FundPot directly (a non-crossing call within this realm, so cur.Previous() is still the duel and the pull is still caller-pays out of the duel's own allowance). Every refusal FundPot makes — non-positive amount, a committed or abandoned week, a short allowance, a beacon too stale to pin a new week's round — therefore applies here identically, and no second version of the sponsorship bookkeeping exists to drift from the first.

WHAT AN EARNED ENTRY IS, EXACTLY: one unit appended to this week's ticket index with NO spendOf record.

Example
 1APPENDED, NEVER INSERTED OR ADJUSTED. appendTickets is the same function Buy
 2uses, so an earned entry is a new index whose prefix starts at the whole
 3total at the moment it was appended. That is the frozen-ownership property
 4Commit's argument rests on (finding 10) and the reason fenwick rejects
 5negative deltas by design. Nothing here touches an existing index.
 6
 7AND NO spendOf RECORD, WHICH IS CORRECT AND IS NOT AN OVERSIGHT. The holder
 8paid no ticket money to this realm; what they paid was the duel's rake, and
 9that money is in this week's pot as Sponsored. So:
10
11  - Pot == sum(spendOf) + Sponsored is UNAFFECTED. Entries carry no money of
12    their own; the money term is FundPot's, and it is counted once.
13  - Refund pays spendOf only, so a holder of nothing but earned entries
14    reclaims nothing, which is right: they have no ticket money here to
15    reclaim, and their rake is in Sponsored.
16  - On an abandoned week THE SPONSORSHIP GOES TO THE EARNED-ENTRY HOLDERS, pro
17    rata, as a claim on that same week, and buyers reclaim their own gross. The
18    sentence that stood here was PRECISELY INVERTED and is kept because a reader
19    who has read this file before will be carrying it: "On an abandoned week
20    Sponsored goes to opsPot and buyers reclaim their spend; EARNED-ENTRY HOLDERS
21    GET NOTHING. Also right, and it is the same money-belongs-to-no-buyer rule
22    Refund already applies." Earned-entry holders are the INTENDED BENEFICIARIES —
23    the rake is theirs and they can recover it by no other route — and it is
24    BUYERS who get nothing from it, having contributed nothing to it and having
25    their whole ticket price back. AUDIT-2026-09-28.md findings 16, 31 and 34; read
26    retireSponsorship and ClaimDeadWeek. Only a week with NO earned holder at all
27    sends this money to opsPot.
28  - A WEEK CAN NOW HAVE TICKETS AND ZERO spendOf — every entry earned, none
29    bought. Commit requires tickets > 0 and gets it, Resolve pays the winner
30    the sponsorship, and that is precisely the intent: the flippers draw for
31    the pot their own rake built. SWEEPING SUCH A WEEK IS ITS SHAPE 2 AND FIRES ON
32    EXACTLY IT, once committed and once the grace window has passed with no draw.
33    The retired claim, which was wrong from the day finding 17 was implemented:
34    "SweepDrawlessWeek requires ZERO tickets and therefore correctly will not fire
35    on such a week — its sponsorship is a real prize now, not stranded money." The
36    ZERO-TICKET requirement is shape 1 only. Finding 17 added shape 2 for this very
37    week, because Resolve was otherwise its ONLY exit and no clock could rescue it —
38    and shape 2 then retires the sponsorship to the earned holders rather than
39    stranding it. Read SweepDrawlessWeek's two shapes.
40  - Finding 3's one-way Abandoned flag still covers the fact that refunds
41    cannot retire tickets. An earned entry is as un-retirable as a bought
42    one and is safe for the same reason: no draw over an abandoned week's
43    ticket set will ever happen.

THE HOLDERS ARE THE DUEL'S TO NAME AND THIS REALM DOES NOT SECOND-GUESS THEM, with one exception it cannot check and one it can. It cannot check that a holder is an EOA — there is no such predicate for a bare address on this toolchain — so THE DUEL credits only parties whose own Post or Accept was a direct user call, which is where finding 8's reasoning has to live for earned entries. It can and does check that every holder is a valid non-empty address and every unit count is positive, so a malformed batch fails loudly instead of appending an index nobody owns.

Panics if the caller is not the duel realm, if the two slices disagree in length, if either is empty, if any unit count is not positive, if any holder is invalid, or if the batch claims more units than the delivered money could have bought.

Params

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/raffle/v4" -func "FundPotWithEntries" -args $'' -args $'' -args $'' -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/raffle/v4" -func "FundPotWithEntries" -args $'' -args $'' -args $'' -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
  

HeldCuts

func HeldCuts() int64
Ask AI Claude ChatGPT

HeldCuts is the total unearned house fee this realm holds across every week, in ugnot — the aggregate of EscrowedCut and the third term in the solvency equality.

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.HeldCuts()"

Result

OpsPot

func OpsPot() int64
Ask AI Claude ChatGPT

OpsPot is the house's EARNED revenue this realm holds, in ugnot: the fees of weeks that actually drew, plus absorbed donations, plus any abandoned week's sponsorship that had no live week to roll into.

IT IS NOT "THE ACCUMULATED HOUSE CUT" ANY MORE, which is what this comment said until 2026-09-29. A live week's fee is NOT in here — it is escrowed in that week and is still refundable. Read EscrowedCut and HeldCuts for the unearned half, and finding 25 for why the two are separate numbers.

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.OpsPot()"

Result

PotSize

func PotSize() int64
Ask AI Claude ChatGPT

PotSize is the CURRENT week's prize pot in ugnot: ticket sales after the house cut, PLUS whatever FundPot has sponsored into it (Sponsored, below). It is the whole amount the winner takes.

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.PotSize()"

Result

Refund

func Refund(cur realm, week int64)
Ask AI Claude ChatGPT

Refund returns the caller's own ticket money for a week whose draw can never be resolved. PERMISSIONLESS, and self-service only: a caller can only ever reclaim their own record.

THIS IS THE SAFETY VALVE r/splash/game COULD NOT HAVE. There, the pot was accrued protocol fees, nobody had staked anything, and an unresolved draw could simply wait forever with no one out of pocket — so a void path would have been the only route by which a prize could move without a winner, and was deliberately omitted. Here the pot is the buyers' own money, and without this function a week nobody settles would lose every ticket bought into it.

ITS JUSTIFICATION CHANGED COMPLETELY IN THE DRAND MIGRATION, AND THE OLD ONE IS WRITTEN OUT HERE BECAUSE A READER WILL COME LOOKING FOR IT. It used to be: "the committed beacon round was published and PRUNED with nobody calling Capture, so no winner can ever be named". That was a claim about a beacon that forgot things. r/splash/beacon does not forget, drand's API serves every round for ever, and Publish is permissionless — so no draw is ever unresolvable, and the precondition this function was built on no longer exists (see resolvable()'s removal, above).

WHAT IT IS FOR NOW IS LIVENESS, NOT LOSS. A week's pot must not be hostage to whether anybody spends ~17.0M gas relaying one drand round. That is the only failure left, and it is unbounded in time, so the exit is a CLOCK: refunds open refundGraceBlocks (21 days) after Commit and not before.

THE PRECONDITION IS THEREFORE NO LONGER THE COMPLEMENT OF Resolve's — the two now OVERLAP for ever once the window opens, and that is fine and is handled by Abandoned. There is no state in which Resolve is impossible, so a refund gate that waited for one would never open at all.

AND THE EXIT IS ONE-WAY, WHICHEVER WAY IT GOES. Refund and Resolve are permanently mutually exclusive per week: the first refund that pays out sets Abandoned, after which Resolve and Capture refuse that week forever and every later refund proceeds without consulting the beacon again. THE ONE-WAY EXIT MATTERS MORE NOW, NOT LESS: it used to protect against a round being published late or re-published after a prune, and it now carries the whole burden, because a pinned round can be relayed by anyone at any time for ever — so a week in refund would otherwise be re-resolvable indefinitely. Without it, a week could take refunds from some buyers and then be resolved over the original frozen ticket set, paying the remaining pot — those other buyers' money — to a winner who may already have been refunded.

AND THE ESCROWED FEE FOLLOWS THE SAME PRINCIPLE AS THE SPONSORSHIP, BY A DIFFERENT ROUTE — BUT THE SPONSORSHIP DOES NOT ROLL ANYWHERE AND HAS NOT SINCE 2026-09-29. The retired sentence, ~240 lines above a transition that does nothing of the kind:

Example
1"An abandoned week's Sponsored ROLLS FORWARD TO THE LIVE WEEK (finding 16), because it
2belongs to no buyer and there is nobody to hand it back to."

BOTH HALVES WERE WRONG BY THE TIME IT WAS WRITTEN. Rolling forward to the live week was AUDIT-2026-09-28.md finding 28 — the live week is whichever week the permissionless caller chose to fire in, so the caller picked the destination by picking the moment — and the premise "there is nobody to hand it back to" is false whenever a duel was played in the week: THE EARNED-ENTRY HOLDERS ARE EXACTLY THE PEOPLE WHOSE RAKE IT IS (findings 31 and 34). The sponsorship becomes a per-unit claim on THIS week, payable to the holders of its EARNED entries, and it never changes weeks.

SO THE TWO AMOUNTS NOW ANSWER THE SAME QUESTION THE SAME WAY, AND THE PARALLEL IS TIGHTER THAN IT WAS. An abandoned week's Cut does not roll anywhere: it belongs to specific buyers, so it goes BACK to them, one gross refund at a time, and the abandonment transition below deliberately does not touch d.Cut at all. The sponsorship likewise goes back to the people it came from, in place. We did not run a draw, so we keep neither — and the only money that reaches the house is a sponsorship with no player behind it at all.

A BUYER WHO NEVER CALLS THIS LEAVES THEIR OWN GROSS SITTING IN THE WEEK, fee part included, and that is not a new stranded-money path: there is no deadline on a refund, Abandoned is one-way so nothing else can take it, and the only address that can ever reclaim it is theirs. It is the same "unclaimed, not unreachable" state the prize half has always had.

AN EARNED ENTRY RECLAIMS NOTHING *HERE*, AND THAT IS CORRECT RATHER THAN A GAP — BUT IT RECLAIMS EVERYTHING THROUGH ClaimDeadWeek. Finding 15 lets duel play earn ticket weight with no spendOf record (FundPotWithEntries), and this function pays spendOf and nothing else — so a holder of only earned entries is refused with "nothing to reclaim for that week". They paid this realm no ticket money to get back: what they paid was the duel's rake, and the abandonment transition below turns that rake into THEIR claim on this same week (AUDIT-2026-09-28.md findings 31 and 34).

THE TWO SENTENCES THAT FOLLOWED WERE BOTH FALSE AND ARE KEPT BECAUSE EACH WAS LOAD-BEARING FOR A DIFFERENT READER:

Example
1"A week whose entries are ALL earned therefore refunds nobody and MOVES ITS WHOLE POT TO
2opsPot ON THE FIRST REFUND ATTEMPT — except that there can be no first refund attempt,
3because no caller has a spend record, so such a week simply stays unrefundable and
4RESOLVE REMAINS ITS ONLY EXIT."

The first clause contradicted itself in its own sentence (a refund attempt that cannot happen cannot move anything) and named a destination that is deleted. The second was true when it was written and was FIXED BY finding 17 in the same audit: a pure-earned week has a SECOND exit, SweepDrawlessWeek's shape 2, gated on the same grace window this function uses, and that exit retires the sponsorship to the earned holders. Leaving "Resolve remains its only exit" standing is how a reader concludes that the stranding finding 17 closed is still open — or, worse, re-derives finding 17 as a new idea and implements it a second time.

WHAT IS TRUE: such a week refunds nobody through this function, its draw is Resolve, and if no draw happens for the whole grace window then SweepDrawlessWeek marks it abandoned and its sponsorship becomes its earned holders' claim. It strands nothing by either route.

EACH BUYER RECLAIMS EXACTLY THEIR OWN RECORDED SPEND, ONCE. The record is zeroed before the transfer, so a second call finds nothing and panics; and because Pot == sum(spends) + Sponsored for an unresolved week — with the sponsorship term taken out of the pot the moment the week is abandoned, see below — the refunds can never total more than the pot holds.

SPONSORSHIP IS NOT REFUNDABLE AND IS NOT LEFT BEHIND. House money added by FundPot (the duel's rake, for one) belongs to no buyer, so nobody can claim it here; the first refund that pays out ROLLS IT FORWARD INTO THE LIVE WEEK'S POT — exactly as SweepDrawlessWeek does for a week nobody entered — instead of leaving it stranded in a pot whose every claim has been zeroed. It went to opsPot until 2026-09-29, and the two paths disagreeing about the same money was AUDIT-2026-09-28.md finding 16. The code and the reasoning are at the transition below.

THE HOUSE FEE *IS* REFUNDED, SINCE 2026-09-29, AND THE PARAGRAPH THAT USED TO BE HERE SAID THE OPPOSITE. It read: "What a buyer gets back is their post-cut contribution — the part that was ever in the prize pot — not the gross ticket price. The 5% was taken at sale time and is already in the ops pot; clawing it back would mean the ops pot could go negative, or that this realm had to hold every cut in escrow for a week against the small chance of a pruned round." IT IS KEPT VERBATIM BECAUSE IT NAMES THE COST OF THE FIX AND THE FIX PAID IT: the realm now DOES hold every fee in escrow per week (Draw.Cut), which is the second option that sentence dismissed, and the ops pot cannot go negative because nothing credits it until a draw succeeds.

WHAT A BUYER GETS BACK IS THEREFORE THE WHOLE TICKET PRICE. The owner's rule is that WE EARN WHEN THE GAME RUNS, NOT WHEN IT FAILS, and a refunded week is a week that did not run: the fee was a charge for a draw that never happened. This is a REDUCTION IN HOUSE REVENUE in the failure case, which needs no permission and is the safe direction — the same shape as finding 16 sending an abandoned week's sponsorship back to players rather than to the house.

THE SPLIT IS IN THE BODY, and it is where the only rounding risk in the change lives; read it there. AUDIT-2026-09-28.md finding 25.

A WEEK THAT WAS NEVER COMMITTED IS NOT REFUNDABLE HERE, and needs no separate path — but only because Commit TAKES THE WEEK AS AN ARGUMENT and any week that is over can be committed by anyone at any time. So a buyer holding a stranded week's tickets names that week to Commit themselves, and then either Resolves it (the normal outcome) or refunds it here if its round was lost. Before Commit took that argument this paragraph was simply false: a caller could not name the week, Commit could only act on the live one, and a week the clock had passed was unreachable by every path in this file — committed never, resolved never, refunded never, and counted in heldPots forever.

Param

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/raffle/v4" -func "Refund" -args $'' -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/raffle/v4" -func "Refund" -args $'' -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
  

RefundGraceBlocks

func RefundGraceBlocks() int64
Ask AI Claude ChatGPT

RefundGraceBlocks is refundGraceBlocks, EXPORTED so a front end can state the window without hardcoding it — the same reason TicketPrice is exported. Read refundGraceBlocks for what it means and how it was derived.

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.RefundGraceBlocks()"

Result

RefundOpensAt

func RefundOpensAt(week int64) int64
Ask AI Claude ChatGPT

RefundOpensAt is the chain height from which Refund will pay this week's buyers REGARDLESS OF THE BEACON: CommittedAt + refundGraceBlocks.

ZERO MEANS "THERE IS NO FUTURE HEIGHT TO WAIT FOR", which covers three unrelated states and a caller must not read any of them as "refundable now": no record for the week, not committed yet (nothing is locked up), already resolved (the pot has gone to its winner), or already abandoned (refunds are open this instant and the height is behind us). Render draws the same distinction, as "n/a".

IT IS NOW AN EXACT HEIGHT, NOT A CEILING, and the change is worth noting because the comment used to hedge. It said refunds "may open much earlier through the beacon-derived half of Refund's gate — the moment the committed round falls outside beacon.PruneWindow unresolved". THAT HALF OF THE GATE NO LONGER EXISTS (see resolvable()'s removal), so this height is the only one at which a refund opens, and a front end can show it as a fact rather than as a worst case.

Param

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.RefundOpensAt()"

Result

Render

func Render(path string) string
Ask AI Claude ChatGPT

Param

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.Render()"

Result

Resolve

func Resolve(cur realm, week int64)
Ask AI Claude ChatGPT

Resolve picks the week's winner and PAYS THEIR WALLET. PERMISSIONLESS.

THIS IS THE ONE PLACE THIS REALM DIVERGES FROM r/splash/game ON PURPOSE. There, the pot was credited to the winner's locked position, which meant the prize could never be spent and the winner's stake grew instead. Here the whole pot leaves this realm in a single wugnot.Transfer to the winner's address. No internal balance is credited, there is nothing left to claim afterwards, and no buyer's money is held past the week they bought into.

winner = ownerOfUnit(randomness mod FrozenTotal). FrozenTotal, NOT the live ticket total: recomputing it here would reopen the buy-the-winning-unit attack described on Commit.

THE POT PAYS OUT UNTAXED, AND THE FEE IS TAKEN HERE — NOT AT SALE TIME. This paragraph read "The house cut was already taken on the way in, at sale time; taxing the payout as well would be charging twice for the same ticket" until 2026-09-29, and the first half stopped being true that day: since finding 25 a sale ESCROWS the fee in Draw.Cut and this function is what earns it, moving the week's whole escrow to opsPot in a separate step from the prize. The conclusion is unchanged and so is the money — the winner still receives Pot and nothing is deducted from it here — but a reader looking for where the house is paid must look at this function and not at Buy. AUDIT-2026-09-28.md finding 29.

RANDOMNESS SOURCE: a captured value if there is one, otherwise the beacon directly — so the happy path needs no extra transaction.

THERE IS ONE FAILURE MESSAGE, AND THERE USED TO BE TWO. The pair was "not published yet" (wait and retry; the draw is fine) and "published and is now pruned, and nobody captured it" (the signature is gone, retrying will never help, Refund is the way out), and keeping them distinguishable was load-bearing because Refund's precondition WAS that distinction. Since the drand migration the second state cannot occur: no round is ever unobtainable, so "retrying will never help" is never true. Collapsing the two was therefore required, not a simplification — a realm that still said it would be steering buyers into refunds they do not need. Anyone who sees the remaining message can relay the round themselves.

THERE IS NO HEIGHT GATE HERE, AND THERE MUST NEVER BE ONE. refundGraceBlocks does not appear in this function and CommittedAt is not read: the grace window is a FLOOR ON BUYER ACCESS, NOT A DEADLINE ON THE DRAW. It only ever opens Refund; it never closes Resolve.

WHY THAT ASYMMETRY IS THE RIGHT ONE. The defect the grace window closes is that a stalled beacon left the buyers with no way to reach their own money. It is not that a late draw is illegitimate — an honest publication that arrives a fortnight after commit, before anybody has refunded, should still pay the winner it always would have, and the FrozenTotal it draws over is exactly the set it was frozen with. Adding a height deadline here would create a second class of dead week — past the grace, published, and resolvable by nobody — which is the very shape of defect this window exists to remove.

WHAT DOES CLOSE THIS FUNCTION IS Abandoned, as before, and that is enough. Once the grace window has opened, Resolve and Refund are both available and the first one called wins: a refund sets Abandoned and this function refuses for ever, a resolve pays the winner and Refund's Resolved check refuses for ever. Neither ordering can pay the same ugnot twice, which is the property that matters. (The fairness cost of that race is recorded in AUDIT-2026-09-28.md finding 7: past the grace window, on a week whose round HAS since been published, a buyer who can see they did not win can refund rather than lose. It is bounded by the window being twice the beacon's entire retention — the state only exists after an outage — and closing it properly means a two-phase refund, which is a separate change.)

Param

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/raffle/v4" -func "Resolve" -args $'' -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/raffle/v4" -func "Resolve" -args $'' -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
  

SpendOf

func SpendOf(addr address) int64
Ask AI Claude ChatGPT

SpendOf is addr's refundable contribution to the CURRENT week in ugnot: THE GROSS TICKET PRICE THEY PAID, house fee included, which is exactly what Refund returns.

THIS COMMENT SAID THE OPPOSITE UNTIL NOW AND THE VALUE NEVER CHANGED. It read: "what they paid MINUS the house cut, which is what Refund would return. It is not the gross ticket price they paid." That was true until 2026-09-29, when the house fee stopped being banked at sale time and started being escrowed per week in Draw.Cut (AUDIT-2026-09-28.md finding 25): spendOf became the GROSS in the same change, and Refund returns the whole ticket price. The function was correct and its documentation was not — THE WORST SHAPE OF ALL FOR AN EXPORTED, FRONT-END-FACING READ, because a caller who believed it would display a refund quote 5% short and nothing would ever fail. Corrected rather than deleted, because the old sentence is the one a reader may have in their head. AUDIT-2026-09-28.md finding 29.

Param

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.SpendOf()"

Result

SpendOfWeek

func SpendOfWeek(week int64, addr address) int64
Ask AI Claude ChatGPT

SpendOfWeek is addr's remaining refundable contribution to a given week: their GROSS ticket spend, fee included, which is what Refund pays them. Zero once they have been refunded. Read SpendOf for the claim this pair used to make and why it is retired.

Params

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.SpendOfWeek(,)"

Result

Sponsor

func Sponsor(cur realm, amount int64, label string)
Ask AI Claude ChatGPT

Sponsor puts money into this week's prize and buys a credit line beside it. The sponsor gets no entries and cannot win: that is what separates a sponsorship from a ticket.

THE PUBLIC DOOR, WHERE FundPot IS THE DUEL'S. They were one function until 2026-09-30 and splitting them is the whole reason the rules below can exist. FundPot carries the duel's rake, in whatever amount has accumulated — often far less than MinSponsorship, and already charged its ops share on the duel's side. Putting a floor or a second fee on that path would strand small flushes and double-charge money that has already paid. So the duel keeps its own unpriced door and this one is for people buying advertising.

WHAT HAPPENS ON A WEEK THAT NEVER DRAWS, because a sponsor should be able to find it out before paying: the money stays with this week for as long as the week can still be drawn, which is indefinitely — Resolve has no deadline and the round stays publishable. A week only ends without drawing when a TICKET BUYER reclaims their own stake after the grace window, and at that moment this sponsorship, the house's cut of it included, is divided among the addresses holding units in this week. It does not roll forward to a future week: that was removed by AUDIT-2026-09-28.md findings 28 and 31, because the caller chose the destination by choosing when to call.

Params

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/raffle/v4" -func "Sponsor" -args $'' -args $'' -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/raffle/v4" -func "Sponsor" -args $'' -args $'' -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
  

SponsorCutOf

func SponsorCutOf(week int64) int64
Ask AI Claude ChatGPT

SponsorCutOf is how much of a week's escrowed fee came from sponsorships rather than ticket sales. Zero on a week that has retired its sponsorship, which is what makes "the fee found its way out" checkable from outside.

Param

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.SponsorCutOf()"

Result

SponsorRefundableFor

func SponsorRefundableFor(week int64, addr address) int64
Ask AI Claude ChatGPT

SponsorRefundableFor is what a dead week owes ONE sponsor, and zero once they have pulled it. Without it a sponsor has to fetch SponsorsOf, sum their own rows by hand, and still cannot tell whether they already claimed — the tree that records that is unexported. 2026-09-30 audit, Y3.

Params

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.SponsorRefundableFor(,)"

Result

SponsorRefundableOf

func SponsorRefundableOf(week int64) int64
Ask AI Claude ChatGPT

SponsorRefundableOf is what a dead week still owes its sponsors.

Param

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.SponsorRefundableOf()"

Result

Sponsored

func Sponsored() int64
Ask AI Claude ChatGPT

Sponsored is the house money in the CURRENT week's pot.

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.Sponsored()"

Result

SponsoredOf

func SponsoredOf(week int64) int64
Ask AI Claude ChatGPT

Param

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.SponsoredOf()"

Result

SweepDrawlessWeek

func SweepDrawlessWeek(cur realm, week int64)
Ask AI Claude ChatGPT

SweepDrawlessWeek rolls a FINISHED week's sponsorship forward into the live week's pot, when nobody ever bought a ticket in it. PERMISSIONLESS, like Commit.

THERE ARE TWO SWEEPABLE SHAPES SINCE 2026-09-29 AND THE SECOND HAS A CLOCK ON IT: a week with NO TICKETS AT ALL (the original drawless week, sweepable as soon as it is over) and a week with TICKETS BUT NO SPEND — every entry earned by duel play — which is sweepable only once it is committed and refundGraceElapsed has gone true, and which is marked Abandoned when it is. The full reasoning, including why "zero spend" alone would have been a fund-destroying gate, is in the body above the shape test. AUDIT-2026-09-28.md finding 17.

FINDING 15 MADE THIS FUNCTION MUCH HARDER TO REACH AND IT IS DELIBERATELY KEPT. A week of duel rake used to end with sponsorship and a zero ticket total, which is exactly the state this function exists for. Now the duel's deliveries bring ENTRIES with them (FundPotWithEntries), so such a week normally has tickets, is a real draw, and this function correctly refuses it — its sponsorship is a prize somebody can win rather than money nobody can reach. The states that still land here are real and small: a rake too small to buy one whole entry (rake < EntryUnitPrice), a duel both of whose parties played from realm code and so earned nothing, and any plain FundPot sponsorship from a caller that is not the duel. DO NOT DELETE IT ON THE GROUNDS THAT THE COMMON CASE IS GONE: every one of those states still strands money without it, and that was the sixth of this product's "money in, no way out" findings.

THE DEFECT IT CLOSES. FundPot credits the live week and creates that week's record if it does not exist yet, so a week in which duels were collected but nobody ever bought a raffle ticket ends the week holding sponsorship in a Draw with a zero ticket total. Commit rightly refuses that week (no tickets, no unit to draw, no owner to pay), Resolve and Refund both require Committed, and FundPot only ever funds the live week — so before this function that sponsorship was unreachable by EVERY path in this realm, permanently, while heldPots went on counting it and checkInvariant and AuditFailure stayed green over money that was gone. That is the same class of defect as the 2026-09-28 audit's first and most serious finding, and it is not an exotic state: a quiet raffle week with an active coin flip is an ordinary Tuesday.

WHERE THE MONEY GOES, AND WHY NOT opsPot, AND WHY IT DOES NOT GO ANYWHERE AT ALL. It goes BACK TO THE PEOPLE WHO HOLD UNITS IN THIS WEEK, pro rata — and for shape 1, which has no unit holders whatsoever, it goes to opsPot because there is provably nobody else. Read retireSponsorship; the branch this function takes is exactly `pureEarned` and is stated again at the call site. This money is the PLAYERS' side of the duel rake (see r/splash/arcade/duel's Collect: the ops share went to r/splash/ops already, and what arrives here is deliberately the rest), so on shape 2 it lands back on the duel players who generated it.

TWO SENTENCES THIS REPLACES, IN ORDER, BECAUSE THE SEQUENCE IS THE LESSON. First: "So it rolls forward to the live week's pot" — the live week being whichever week the caller chose to fire in (finding 28). Then: "It marks the week and registers it (markCarryPending); the next week record to be created absorbs it at its own birth... to a week nobody in this transaction can name" — except that the caller who opens that week is the caller who calls Buy, so they could name it after all, and be its only entrant (finding 31). Both sentences described a destination. There is none now.

AND REFUND NOW AGREES, WHICH IT DID NOT UNTIL 2026-09-29. This paragraph used to end "Contrast Refund, which moves an abandoned week's sponsorship to opsPot: there the week HAD entrants and they are being paid back individually, so there is no prize left for the sponsorship to enlarge." THE CONTRAST WAS THE DEFECT. Two functions in one file moved the same field's money to opposite destinations, and since earned entries exist an abandoned week's "entrants" are mostly duel players with no spendOf to be paid back at all — so the rationale did not hold either. One ticket plus the grace window turned the whole argument into a cheap transfer of a week of rake to the house. AUDIT-2026-09-28.md finding 16. Refund's transition now retires the sponsorship exactly as this function does, and retireSponsorship is the shared step — a step with no destination in it.

THIS FUNCTION STILL DIFFERS FROM REFUND IN ONE WAY, AND MUST, BUT THE DIFFERENCE IS NOW A CLOCK RATHER THAN A DESTINATION. It may REFUSE — the live-week rule, the shape tests, and the grace window each shape must wait out — because a dead week's sponsorship has no claimant and no deadline, so a refusal costs nobody anything and any caller may retry for ever. Refund may not refuse, because a buyer is waiting; and since neither function looks for a destination any more, there is no longer any state in which Refund COULD be refused for want of one. That is strictly stronger than both fallbacks it replaced — the immediate opsPot bank of finding 16 and ReclaimStrandedCarry's three-week long stop of finding 28 — and both of them are now deleted rather than relocated, because a claim on the week the money is already in cannot fail to find anything.

THE DESIGN TENSION, RESOLVED EXPLICITLY SO IT IS NOT "FIXED" BACK. This realm's stated principle is that THERE IS NO ROLLOVER, so a drawless week must not exist — and Commit still enforces exactly that, unchanged. That principle is about TICKET HOLDERS' pots: it stops a week's prize being deferred away from the people who entered that week and paid for it. Rolling SPONSORSHIP forward from a week that had no entrants whatsoever does not violate it, because there was nobody to defer anything away from — no ticket was sold, so no buyer's expectation of this week's prize exists to be broken. Do not "restore consistency" by deleting this function or by letting Commit accept a zero total; the second would reopen the drawless-draw hole, and the first would reopen the stranded-money hole.

NO WUGNOT MOVES HERE, and a reader looking for the missing Transfer should stop looking. The tokens are already inside this realm — FundPot pulled them in when the dead week was sponsored — so this is PURE BOOKKEEPING between two weeks of the same realm. The realm's wugnot balance is identical before and after; heldPots and heldSponsored each lose and regain exactly the same amount, which is why checkInvariant's equality still holds at the end.

Panics if the week is not over yet (a live week may still sell a ticket, which would make it a real draw), if there is no record for it, if anybody BOUGHT a ticket in it, if it holds no sponsorship, if it is resolved or already abandoned, if its entries are all earned and its draw can still happen (uncommitted, or inside the grace window), or — defensively — if a ticketless week is somehow committed.

Param

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/raffle/v4" -func "SweepDrawlessWeek" -args $'' -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/raffle/v4" -func "SweepDrawlessWeek" -args $'' -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
  

TicketTotal

func TicketTotal() int64
Ask AI Claude ChatGPT

TicketTotal is the CURRENT week's ticket units sold.

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.TicketTotal()"

Result

TicketTotalOfWeek

func TicketTotalOfWeek(week int64) int64
Ask AI Claude ChatGPT

TicketTotalOfWeek is a given week's ticket units sold: the LIVE total, not FrozenTotal, so it is readable before a commit and is zero for a week that never sold a ticket.

IT IS THE READ THAT MAKES A DRAWLESS WEEK VISIBLE. DrawOf reports FrozenTotal, which is zero on any uncommitted week, so a caller cannot use it to tell "sold nothing" from "sold plenty and was never committed". Sweepability turns on exactly that distinction: a past week is sweepable when this is zero and SponsoredOf is positive. See SweepDrawlessWeek.

Param

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.TicketTotalOfWeek()"

Result

TicketsOf

func TicketsOf(addr address) int64
Ask AI Claude ChatGPT

TicketsOf is addr's ticket units in the CURRENT week.

Param

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.TicketsOf()"

Result

TicketsOfWeek

func TicketsOfWeek(week int64, addr address) int64
Ask AI Claude ChatGPT

TicketsOfWeek is addr's ticket units in a given week, so a front end can still show a buyer their past weeks once the clock has moved on.

Params

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.TicketsOfWeek(,)"

Result

Unclaimed

func Unclaimed() int64
Ask AI Claude ChatGPT

Unclaimed is the sum of every week's unclaimed dead-week shares — the aggregate heldClaims, exposed so it can be read without iterating. It is already inside the pots, so it is not a fourth balance.

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.Unclaimed()"

Result

UnclaimedHoldersOf

func UnclaimedHoldersOf(week int64) int64
Ask AI Claude ChatGPT

UnclaimedHoldersOf is how many named addresses a dead week still owes a share to, and it is the other half of AUDIT-2026-09-28.md finding 37 — the audit's open question, "let an operator distinguish heldClaims AWAITING LIVE HOLDERS from heldClaims FROZEN FOR EVER".

heldClaims ON ITS OWN CANNOT ANSWER THAT and neither can UnclaimedOf: a positive number means the same thing in both cases. This read turns the amount into a COUNT OF PEOPLE, so "3,600,000 ugnot owed to 24 addresses" reads differently from "3,600,000 ugnot owed to 24 addresses that have not moved in a year", and an operator can at least see which weeks are settled and which are not. The DeadWeekFullyClaimed event in ClaimDeadWeek is the push version of the same fact, for a week that reaches zero.

IT IS O(EARNED HOLDERS) AND THEREFORE A READ-ONLY FUNCTION AND NOTHING ELSE. It is never called from a state-changing path — that is the whole reason retireSponsorship writes a rate instead of a tree — and a caller who pays the gas to ask is paying for their own answer. COLLECT-THEN-LOOP, NEVER A NESTED avl.Iterate: the earnedOf keys are gathered in one pass and every claimedOf lookup happens after it, because nesting a second traversal inside the first's callback crashes this toolchain's VM outright (see AuditFailure).

ZERO MEANS "NOTHING LEFT TO PAY" and is the answer on a live week, a resolved week, a week that never distributed anything, and a week every holder has claimed out.

Param

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.UnclaimedHoldersOf()"

Result

UnclaimedOf

func UnclaimedOf(week int64) int64
Ask AI Claude ChatGPT

UnclaimedOf is how much of a dead week's distributed sponsorship its holders have not taken yet — Draw.Claims. It is PART OF THAT WEEK'S POT, so PotSizeOf-style reads already include it; this read exists so an operator can see how much of a dead week's money is still sitting there, and so AuditFailure's heldClaims re-derivation can be read alongside it.

Param

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.UnclaimedOf()"

Result

WeekCount

func WeekCount() int64
Ask AI Claude ChatGPT

WeekCount is the number of weeks that have a record: weeks that sold a ticket or were sponsored. O(1); avl keeps its size.

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.WeekCount()"

Result

WeekEndHeight

func WeekEndHeight(week int64) int64
Ask AI Claude ChatGPT

WeekEndHeight is weekEndHeight, EXPORTED: the chain height at which a given week stops being the live one. A week is live for heights [WeekEndHeight(week-1), WeekEndHeight(week)) — the returned height is the FIRST height of week+1, NOT the last height of week, so at exactly this height CurrentWeek already reads week+1. A countdown that showed "1 block left" here would be off by one.

IT IS A BLOCK HEIGHT AND DELIBERATELY NOT A TIME. This realm's week is raffleWeekDays*blocksPerDay blocks, and blocksPerDay = 26182 encodes an ASSUMED 86400/26182 = 3.29998 s per block (read blocksPerDay for how much that assumption is allowed to be wrong). The chain does not actually run at that rate: 3.306 s measured on gno.land mainnet and 3.376 s on onyx-1. So a caller wanting a wall-clock countdown must multiply by a block time IT chooses and own the error; this realm will not return seconds or "time remaining", because that would bake one clock's rounding into every front end that reads it and make the contract the source of a number it cannot know. Nothing on chain converts blocks to seconds and neither does this.

WHY THIS AND NOT AN EXPORTED launchHeight: it is the stronger primitive, not the weaker one. raffleWeekDays and blocksPerDay are both unexported, so a LaunchHeight() alone could not be turned into a boundary without a caller hardcoding 7*26182 and re-implementing the +1 above. This read goes the other way: launchHeight is WeekEndHeight(0) - (WeekEndHeight(1) - WeekEndHeight(0)) and the week's length is WeekEndHeight(1) - WeekEndHeight(0), so one function answers every question the constant would have, with the off-by-one computed once, here, on chain.

NEGATIVE OR FAR-FUTURE week IS NOT REFUSED and returns the arithmetic for it, exactly as weekEndHeight does — this is a pure function of week and launchHeight with no record lookup, so there is no "no such week" state to report. WeekEndHeight(-1) is launchHeight, the height this realm's week 0 began at, which is a useful answer rather than an error.

Param

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.WeekEndHeight()"

Result

Weeks

func Weeks(offset, limit int64) []int64
Ask AI Claude ChatGPT

Weeks is a window over the recorded week numbers in ASCENDING ORDER: it skips `offset` weeks and holds at most min(limit, PageLimit). Empty past the end. Negative arguments are a caller error and panic.

Ascending order is week order, not merely key order, because weekKey is fixed-width and zero-padded (see keyWidth) — the same property Render relies on.

A CALLER FINDS THE SWEEPABLE WEEKS by walking this and keeping those where week < CurrentWeek(), TicketTotalOfWeek(week) == 0 and SponsoredOf(week) > 0.

Params

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.Weeks(,)"

Result

WithdrawOps

func WithdrawOps(cur realm) int64
Ask AI Claude ChatGPT

WithdrawOps pushes the whole ops pot to opsVaultAddr and returns the amount moved. PERMISSIONLESS.

THE DEFECT IT CLOSES, found 2026-09-28 by the engineer implementing that day's fifth fix rather than by the audit: opsPot only ever increased. It was written in three places — Buy's house cut (now Resolve's released escrow, finding 25), sweepDonations' absorbed arrivals, and Refund's move of an abandoned week's sponsorship — and read by nothing but the invariant and the reads. There was no outflow anywhere in this file, so three separate streams of money became permanently unreachable by every path in the realm, THE PRODUCT'S ENTIRE REVENUE AMONG THEM. checkInvariant and AuditFailure both stayed green over it, because both count opsPot as money the realm holds and neither has an opinion about whether anybody can ever reach it. That is the same class of defect as the unreachable week (the audit's first finding) and the stranded drawless-week sponsorship (SweepDrawlessWeek).

PERMISSIONLESS, AND THAT IS SAFE PRECISELY BECAUSE THE DESTINATION IS FIXED AT COMPILE TIME. A reader will look for a caller check here and there is none, on purpose: opsVaultAddr is a const, so the caller does not choose where the money goes, cannot name themselves, and cannot name a week's pot. All they can do is push the house's money to the house — and paying the gas to do somebody else's bookkeeping is a favour, not an attack. Gating it on an operator key would buy nothing and would put a liveness dependency for the realm's revenue onto one key, exactly as it would for Commit, Capture and Resolve. This is r/splash/exchange's SettleOps shape, and the property that makes it sound is the one tools/raffle_addr_test.go exists to keep true.

IT NEVER REACHES A PLAYER'S MONEY. No Draw is touched: not Pot, not Sponsored, not spendOf, not a ticket. The only book that moves is opsPot, which by construction holds nothing any buyer can claim — the fee in it was EARNED BY A DRAW THAT ACTUALLY RAN (Resolve releases the week's escrow; a week that fails refunds it with the gross, finding 25), a donation belongs to whoever chose to give it away, and a dead week's sponsorship was never attributable to a buyer at all.

THE CITATION THAT USED TO STAND HERE IS RETIRED: this paragraph said "the cut was taken at sale time (see Refund's 'THE HOUSE CUT IS NOT REFUNDED')", and there is no such section in Refund any more — finding 25 deleted the behaviour and Refund records the retired paragraph verbatim in its place. A citation that outlives the thing it cites is how a reader ends up trusting a rule the code stopped following. AUDIT-2026-09-28.md finding 29. NOTE THAT THE THIRD STREAM IS NOW ALMOST CLOSED: since 2026-09-29 a dead week's sponsorship goes forward to a future week's prize and reaches this counter only when no week can be opened at all (Refund's transition, and AUDIT-2026-09-28.md finding 16). That is a deliberate reduction in house revenue and it is the safe direction.

AND SINCE 2026-09-29 IT CANNOT REACH AN UNEARNED FEE EITHER, which is the point of finding 25 from this function's side. The house cut used to arrive in opsPot the instant a ticket was sold, so this call could push out money for draws that had not happened yet — and the buyers of a week that then failed got 95% back while the missing 5% had already left the realm. The fee now sits in Draw.Cut until Resolve releases it, heldCuts is a separate term in checkInvariant, and this function does not mention either. A caller who withdraws every block still cannot touch a live week's escrow.

sweepDonations RUNS FIRST, as in every other mutator, so a donation that arrived since the last state change is included in this withdrawal rather than left behind for the next one.

BOOKS FIRST, TRANSFER SECOND, exactly as Resolve and Refund do it: opsPot is zeroed BEFORE the wugnot leaves, so a re-entrant call — which wugnot's Transfer cannot cause today, having no hook or callback, but a future GRC20 might — finds an empty pot and panics rather than a balance it can take twice.

THE INVARIANT IS EXACT ACROSS IT, not merely preserved one-sidedly: checkInvariant asserts heldPots + opsPot == balance, and this call reduces both sides by the same amount. heldPots and heldSponsored are deliberately untouched.

Panics if there is nothing to withdraw.

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/raffle/v4" -func "WithdrawOps" -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/raffle/v4" -func "WithdrawOps" -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
  

SponsorsOf

func SponsorsOf(week int64) []Sponsorship
Ask AI Claude ChatGPT

SponsoredOf is the house money in a given week's pot: what FundPot added, and therefore the part of the pot no buyer can ever reclaim. Zero for a week that was never sponsored.

THE SECOND HALF OF THIS COMMENT WAS FALSE, WAS CORRECTED, AND IS NOW TRUE AGAIN FOR A DIFFERENT REASON. It originally said "and zero again once an abandoned week's sponsorship has been moved to the ops pot"; that became wrong when the money started going forward to a future week (finding 16) and wrong twice over when it stopped moving at the moment of abandonment at all (finding 28).

SINCE FINDING 31 THIS IS ZERO ON EVERY ABANDONED WEEK, and that is a guarantee rather than an accident: retireSponsorship zeroes the field in the same statement group that sets Abandoned, and AuditFailure reports any week where the two disagree. What the money became is a per-unit claim on the SAME week — read ClaimPerUnitOf, UnclaimedOf and ClaimableOf, and ClaimDeadWeek for the exit. A front end showing an abandoned week should show those three, not this one. SponsorsOf is the credit lines a week carries, in the order they were bought.

A COPY, NOT THE SLICE. Returning d.Sponsors itself would hand a caller a reference into this realm's own storage; the copy is the same discipline as every other read here returning values rather than pointers.

Param

Command

gnokey query vm/qeval -remote "https://rpc.onyx.testnets.gno.land" -data "gno.land/r/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/arcade/raffle/v4.SponsorsOf()"

Result