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.

beacon.gno

44.72 Kb · 851 lines
  1// Package beacon is SPLASH's on-chain record of the drand evmnet randomness
  2// beacon.
  3//
  4// WHAT CHANGED, AND WHY IT IS NOT A REFACTOR (2026-09-28). This realm used to
  5// verify signatures from a beacon SPLASH itself ran: one compiled-in BLS key, held
  6// by this project, publishing a round every five minutes. The key holder could
  7// sign ANY round at will and therefore knew every future round's randomness
  8// permanently, which is r/splash/arcade/raffle/AUDIT-2026-09-28.md findings 4 and
  9// 10 and was the single worst property of the product. It now verifies drand
 10// evmnet, a threshold beacon this project does not control. Two consequences run
 11// through every comment below:
 12//
 13//   - NOBODY CAN SIGN A FUTURE ROUND. Round r's signature does not exist before
 14//     its time, and no party below the DKG threshold can make it exist. That is
 15//     what the games' round bindings are for and it is now true rather than
 16//     assumed.
 17//   - NOBODY CAN DESTROY A PAST ROUND. Every round from 1 to the head is served
 18//     forever by at least three independent relays, and Publish is
 19//     permissionless, so anything deleted here is re-creatable by anyone. "The
 20//     randomness is lost" stopped being a reachable state, which is why Prune,
 21//     PruneWindow and the raffle's resolvable() are gone -- see DRAND-MIGRATION.md
 22//     section 3.
 23//
 24// THIS REALM IS NOT A MIRROR OF DRAND AND MUST NEVER BECOME ONE. At a three-second
 25// cadence, keeping up would mean one Publish every three seconds at 16,970,375 gas
 26// each: 0.62% of EVERY BLOCK on the chain, for ever, for data that is read about
 27// fifty times a year (DRAND-MIGRATION.md sections 2 and 9.5 have the measured
 28// arithmetic; 9.5 also records that this said 2.4% until 2026-09-30, off by the same
 29// 4.4x as every other gas figure derived from the pre-optimisation VerifyDrand, and
 30// that at 0.62% the STORAGE cost rather than the gas share is what rules mirroring out).
 31// It is an ON-DEMAND RECORD: a round is stored when something on this chain has a
 32// reason to look at it, and nothing else is stored at all.
 33//
 34// There is no owner, no pause and no upgrade path. Once a round is published its
 35// randomness is fixed for ever.
 36package beacon
 37
 38import (
 39	"chain"
 40	"chain/runtime"
 41	"crypto/sha256"
 42	"encoding/hex"
 43	"strconv"
 44	"time"
 45
 46	"gno.land/p/nt/avl/v0"
 47	"gno.land/p/g1r4aw43kagaaquhegxw6xr346h9f2qu2ezf0jar/bls254"
 48)
 49
 50// --- the chain being verified --------------------------------------------
 51
 52// ChainHash, PeriodSeconds and RoundsPerDay are re-exported from
 53// p/splash/bls254, which holds the ONE copy of drand evmnet's identity. They are
 54// re-exported rather than restated so that a realm needing the cadence does not
 55// have to import a crypto package to get it, and so that there is still exactly
 56// one place the numbers are written down.
 57const (
 58	// ChainHash names the drand chain these signatures belong to.
 59	ChainHash = bls254.DrandEvmnetChainHash
 60
 61	// PeriodSeconds is drand evmnet's cadence: one round every three seconds.
 62	//
 63	// ANY REALM EXPRESSING A DURATION IN ROUNDS MUST MULTIPLY BY THIS AND COMPARE
 64	// AGAINST SECONDS. A round count is not a duration. The five-minute beacon
 65	// this replaces made every round-denominated window in the repository 100x
 66	// too short at a stroke, and a guard comparing rounds to rounds cannot see
 67	// it: the cadence appears on both sides and cancels. See
 68	// DRAND-MIGRATION.md section 6, and r/splash/arcade/raffle's seconds guard,
 69	// which is the one assertion in the system that would have caught it.
 70	PeriodSeconds = bls254.DrandEvmnetPeriodSeconds
 71
 72	// RoundsPerDay is 86400 / PeriodSeconds = 28800. Exported because the raffle
 73	// and the duel both need it and both used to restate it as 288 -- the same
 74	// number in two files, wrong in both.
 75	RoundsPerDay = 86400 / PeriodSeconds
 76
 77	// drandGenesisTime is round 1's unix timestamp: round r is signed at
 78	// drandGenesisTime + PeriodSeconds*(r-1). Re-exported from p/splash/bls254
 79	// for the same reason as the three constants above it -- one place the
 80	// number is written down -- and it is what CurrentRound and RoundAt below
 81	// convert the block's own clock against.
 82	drandGenesisTime = bls254.DrandEvmnetGenesisTime
 83)
 84
 85// blocksPerDay is the block-count day unit, ~3.3s blocks, copied from
 86// r/splash/game which documents the choice. A different package cannot reference
 87// another's unexported constant, so r/splash/game, r/splash/arcade/duel,
 88// r/splash/arcade/raffle and now this realm each restate it.
 89//
 90// IT IS HERE ONLY FOR EstimatedLatestRound, and only because this realm is the
 91// one place that holds both clocks at once: rounds, and the height at which each
 92// round arrived.
 93const blocksPerDay = 26182
 94
 95// groupKey is drand evmnet's 128-byte G2 group public key (EIP-197 wire order),
 96// decoded once at init from the single canonical constant in p/splash/bls254.
 97// There is no rotation function and no admin path to change it: a different drand
 98// chain is a different realm.
 99var groupKey = mustDecodeHex(bls254.DrandEvmnetPubKeyHex)
100
101func mustDecodeHex(s string) []byte {
102	b, err := hex.DecodeString(s)
103	if err != nil {
104		panic("beacon: bad constant hex")
105	}
106	return b
107}
108
109// --- state ----------------------------------------------------------------
110
111// record is one published round: its 64-byte G1 signature and the chain height at
112// which it was relayed.
113//
114// THE HEIGHT IS NOT BOOKKEEPING, IT IS THE ONLY REAL-TIME EVIDENCE A REALM ON
115// THIS CHAIN CAN GET, and adding it is the reason this migration is safe at all.
116// A drand round existing is a statement about wall-clock time -- round r cannot be
117// signed before genesis + 3(r-1) seconds -- and this chain has no block-timestamp
118// getter, so a published round is the only unforgeable "time has passed this far"
119// a realm can observe. Recording the height it arrived at is what lets a consumer
120// relate drand's clock to its own, and without it EstimatedLatestRound cannot
121// exist and the raffle's round pin has no defence against a stale beacon at all
122// (DRAND-MIGRATION.md section 5b).
123type record struct {
124	Sig    []byte
125	Height int64
126}
127
128// rounds maps a zero-padded round key to its record.
129var rounds = avl.NewTree()
130
131// latestRound/latestHeight/haveLatest track the highest published round, and the
132// height at which THAT round was published, without relying on tree iteration
133// order.
134//
135// latestHeight is the height of the highest ROUND, not the height of the most
136// recent Publish. Those differ when somebody relays an old round after a newer
137// one.
138//
139// AND IT IS NOT WHAT EstimatedLatestRound EXTRAPOLATES FROM — anchorHeight is. This
140// comment used to say it was, and that was true of the very first version of this realm
141// only (see anchorScaled's account of why extrapolating from the highest round's own
142// publication height is exploitable). latestHeight is now reported for the record, through
143// LatestPublishedAtHeight and Render, and read by nothing that decides anything. The
144// distance that bounds the estimate's error is ChainHeight() - anchorHeight; read
145// anchorHeight for why measuring from anything else is unsound.
146var (
147	latestRound  int64
148	latestHeight int64
149	haveLatest   bool
150)
151
152// anchorScaled is the clock EstimatedLatestRound extrapolates from, and it is a
153// MAXIMUM over every round ever relayed rather than a property of the highest one.
154//
155//	anchorScaled = max over relays i of ( round_i*blocksPerDay - height_i*RoundsPerDay )
156//
157// which is (round_i - height_i * RoundsPerDay/blocksPerDay) scaled up by blocksPerDay
158// so that no integer division is lost. It is the y-intercept of the round-versus-height
159// line each observation implies, and taking the maximum keeps the highest line anybody
160// has ever evidenced.
161//
162// WHY A MAXIMUM, AND NOT THE HIGHEST ROUND'S OWN (round, height) PAIR. The first
163// version of this realm did the latter — it extrapolated from latestRound and the height
164// at which latestRound was relayed — AND THAT WAS EXPLOITABLE. Relaying a round only
165// slightly above latestRound but still far in the PAST resets the height anchor to now
166// and collapses the estimate:
167//
168//	latestRound=100 relayed at height 0, now at height 1,000,000
169//	  -> estimate 100 + 1,000,000*28800/26182 = 1,100,092
170//	attacker relays round 200 (real, old, and > 100) at height 1,000,000
171//	  -> estimate 200
172//
173// A consumer pinning estimate + offset would then pin a round whose randomness has been
174// public for years, which is AUDIT-2026-09-28.md finding 10's precondition, bought for
175// one Publish. A MAXIMUM CANNOT BE PUSHED DOWN: relaying an old round can only raise
176// anchorScaled or leave it alone, and raising it moves a consumer's pinned round FURTHER
177// into the future, which is the safe direction.
178//
179// AND IT CANNOT BE PUSHED ARBITRARILY HIGH EITHER, which is the other half of the
180// argument. The best an attacker can do is relay drand's CURRENT head as early as
181// possible, and nobody can produce a signature above the current head — so anchorScaled
182// is bounded above by the true round-versus-height intercept.
183//
184// IT IS NOT SELF-CORRECTING, AND THE CLAIM THAT IT WAS IS THE DEFECT anchorHeight AND
185// maxEstimateStaleBlocks EXIST TO CLOSE. DRAND-MIGRATION.md section 9.8 asserted, and
186// this comment asserted with it, that a maximum is "self-correcting in both directions of
187// block-time drift". THAT IS FALSE IN THE UPWARD DIRECTION, and it is false in the worst
188// possible way — monotonically, permanently, on a realm with no owner and no upgrade
189// path. Work it, because reading it will not show it:
190//
191//   - blocks SLOWER than the assumed ~3.3s: each observation's intercept RISES over
192//     time, so the maximum tracks the MOST RECENT observation and any relay of drand's
193//     head pulls the estimate back onto the truth. Self-correcting, as claimed.
194//   - blocks FASTER: intercepts FALL over time, so the maximum keeps an EARLY one. The
195//     estimate then advances at RoundsPerDay/blocksPerDay = 1.099992 rounds per block
196//     AS A PURE FUNCTION OF CHAIN HEIGHT, for ever, REGARDLESS OF DRAND — while drand
197//     advances B/PeriodSeconds rounds per block for real average block time B. The two
198//     agree only at B = 86400/blocksPerDay = 3.29998 s. At ANY faster block time the
199//     estimate outruns drand's true head and CAN NEVER COME BACK DOWN, because a
200//     maximum does not fall and because relaying the true head no longer raises it
201//     (cand < anchorScaled once est > truth). "Pins land later than intended, safe" was
202//     true only of the first day; it is monotone, and it ends in
203//     r/splash/arcade/duel's bound round never being signed inside VoidStale's window
204//     (~10 days at B = 3.0 s) and r/splash/arcade/raffle's pinned round never being
205//     signed inside the refund grace (~111 days at B = 3.0 s). See
206//     maxEstimateStaleBlocks for the arithmetic and AUDIT-2026-09-28.md finding 12.
207//
208// NOTE THE SHAPE OF THE TRADE, because it is the most instructive thing here: the fix
209// that closed the down-push attack above is what REMOVED the only mechanism that
210// corrected upward drift. The single-pair version self-corrected the instant anybody
211// relayed the head — it re-anchored on every relay, which is exactly why it could be
212// pushed down. Maximum: cannot be pushed down, cannot be corrected up. Single pair: can
213// be corrected up, can be pushed down. The bound below is what makes the first of those
214// safe, by refusing to extrapolate far enough for the uncorrectable direction to matter.
215//
216// AND THERE IS AN ACTIVE VARIANT OF THE UPWARD DRIFT, not merely a passive one.
217// anchorScaled is the historical MAXIMUM intercept, so ANY period in which the chain ran
218// slower than 3.29998 s/block raises it permanently, and one permissionless Publish of
219// drand's current head captures that period into it. The captured intercept is CORRECT
220// at the moment of capture — the relay proves drand really did advance that far — but it
221// is retained for ever, so if the chain then speeds up, the estimate resumes outrunning
222// drand from the higher line and no later relay can lower it. A chain HALT is the
223// extreme case: no height passes while drand advances D/PeriodSeconds rounds, and a
224// height-denominated staleness measure cannot see a halt at all (see
225// maxEstimateStaleBlocks' "WHAT THIS DOES NOT BOUND").
226//
227// OVERFLOW HEADROOM: round*blocksPerDay at drand's ~2.1e7 is ~5.5e11, and
228// height*RoundsPerDay at a height of 1e9 is ~2.9e13. Both are five orders of magnitude
229// inside int64.
230//
231// anchorHeight IS THE HEIGHT OF THE RELAY THAT MOST RECENTLY RAISED anchorScaled, and
232// it is NOT "the height of the most recent Publish". Every error in this realm's
233// extrapolation is proportional to ChainHeight() - anchorHeight and to nothing else, so
234// that is the distance the bound below measures. Two reasons the other choice is wrong,
235// and the second one is an attack:
236//
237//  1. A RELAY THAT DOES NOT RAISE THE ANCHOR DOES NOT REDUCE THE ERROR. Once the
238//     estimate has drifted above drand's head, relaying the head leaves anchorScaled
239//     exactly where it was — that is the whole defect above — so a "most recent Publish"
240//     measure would report a fresh beacon while the estimate went on being wrong.
241//  2. "MOST RECENT PUBLISH" FRESHNESS IS FORGEABLE FOR THE COST OF ONE Publish. Any
242//     unrelayed old round — drand has ~2.1e7 of them, all public — would reset it. So an
243//     attacker could hold the beacon "fresh" indefinitely while the estimate drifted
244//     arbitrarily far, which is finding 10's precondition bought for ~16.97M gas a relay.
245//
246// RAISING THE ANCHOR IS HARDER TO FORGE THAN A "LAST Publish" FLAG, AND IT IS NOT
247// UNFORGEABLE. The stronger claim stood here and is RETIRED RATHER THAN QUIETLY DROPPED,
248// because the bound below was justified by it.
249//
250// THE RETIRED TEXT, VERBATIM:
251//
252// RAISING THE ANCHOR, BY CONTRAST, IS UNFORGEABLE, and this is the property that makes
253// the bound sound rather than merely conservative. cand > anchorScaled reduces to
254// round > est(h): raising the anchor requires a valid signature for a round ABOVE this
255// realm's current estimate. Nobody below the DKG threshold can sign a round drand has
256// not reached, so the anchor can be refreshed ONLY by a relay that PROVES the estimate
257// was behind drand's real head — which is exactly the case in which re-anchoring is
258// correct.
259//
260// (END OF THE RETIRED TEXT.)
261//
262// EVERY SENTENCE OF IT IS TRUE EXCEPT THE WORD "ONLY", AND "ONLY" WAS THE LOAD-BEARING
263// ONE. cand > anchorScaled does reduce to round > est(h), and nobody below the DKG
264// threshold can sign a round drand has not reached. What does not follow is that the
265// relay proves anything about NOW: the round that has to be signed is est+1, and when the
266// estimate LAGS — the only situation this bound exists for — est+1 is a round drand passed
267// long ago, one of ~2.1e7 public past rounds whose signature costs an HTTP GET.
268//
269// AND IT ALWAYS WORKS, not sometimes. From est = floor((anchorScaled + h*RoundsPerDay)/
270// blocksPerDay) it follows that
271//
272//	cand(est+1) = (est+1)*blocksPerDay - h*RoundsPerDay > anchorScaled   ALWAYS
273//
274// so relaying est+1 ALWAYS raises the anchor, ALWAYS sets anchorHeight = ChainHeight() so
275// that StaleBlocks() reads 0, and ALWAYS advances the estimate by EXACTLY ONE ROUND —
276// three seconds of drand time bought with one Publish, in exchange for a whole fresh
277// budget of maxEstimateStaleBlocks. Repeat once before each budget expires and the lag
278// grows without limit while the freshness signal reads zero throughout. This is not a
279// derivation to be trusted: it is asserted against the running realm by
280// TestKnownWeaknessRelayingTheRoundJustAboveTheEstimateBuysTheWholeStalenessBudgetBack.
281//
282// SO STATE THE BOUND'S GUARANTEE AT ITS REAL STRENGTH. A raise proves the estimate WAS
283// behind drand's head at the moment of the relay. It does NOT prove the estimate has
284// CAUGHT UP, and the MAGNITUDE of a raise is unconstrained — est+1 and drand's true head
285// are equally valid relays and the realm cannot tell them apart. THE STALENESS BOUND IS
286// THEREFORE NOT FORGERY-PROOF. It is a lower bound on how much drand-time has passed,
287// enforced against a caller who must at least produce a real signature above the
288// estimate; it is not a bound on how far the estimate has fallen behind.
289//
290// AND NO ON-CHAIN FRESHNESS PREDICATE CAN BE, so this is a floor and not a bug to fix
291// here. A drand signature bounds drand's head from BELOW — round R exists implies the
292// head is at least R — and what a freshness test needs is a bound from ABOVE, which no
293// signature and no chain height can supply. DRAND-MIGRATION.md sections 5b and 5c already
294// state that floor and its consequence for every round-number pin; read it there rather
295// than re-deriving it. What remains defensible about measuring from anchorHeight is
296// reason 1 and the WEAKER half of reason 2: an old round BELOW the estimate resets
297// nothing at all, so the bound is still strictly better than "blocks since the last
298// Publish", which any round whatsoever resets. Strictly better is not sound.
299//
300// And the converse is the honest cost, recorded rather than discovered: when the estimate
301// is AHEAD of drand's head, NOBODY can refresh the anchor, so the refusal below becomes
302// permanent until the chain's own block time falls back behind 3.29998 s. See
303// AUDIT-2026-09-28.md finding 12 for what that means for deployment.
304var (
305	anchorScaled int64
306	anchorHeight int64
307	haveAnchor   bool
308)
309
310// maxEstimateStaleBlocks is how far past the anchor EstimatedLatestRound is willing to
311// extrapolate: one day of blocks. Past it, it REFUSES — see EstimatedLatestRoundWithin.
312//
313// THE DERIVATION. Let
314//
315//	S = ChainHeight() - anchorHeight          (blocks since the anchor was last raised)
316//	A = 86400/blocksPerDay = 3.29998 s        (the block time this realm assumes)
317//	B = the chain's real AVERAGE block time over those S blocks
318//
319// The extrapolation reads S*RoundsPerDay/blocksPerDay = S*A/PeriodSeconds rounds off the
320// anchor while drand advances S*B/PeriodSeconds rounds, so
321//
322//	est - truth = S*(A - B)/PeriodSeconds rounds = S*(A - B) SECONDS of drand time
323//
324// ONE CONDITION BOUNDS BOTH DIRECTIONS OF ERROR, which is the reason this bound belongs
325// here and not in the consumers. The expression above is positive when the chain is
326// FASTER than assumed (the estimate outruns drand — the unbounded, uncorrectable
327// direction described at anchorScaled) and negative when it is slower (the estimate lags
328// — the staleness error section 5b of DRAND-MIGRATION.md was about). Both are
329// proportional to the SAME S, so |est - truth| <= S*|A - B| seconds whichever way the
330// drift goes, and refusing above a bound on S bounds the error above and below at once.
331//
332// THE BAND THIS IS STATED AGAINST: B within 10% of A, i.e. 2.97 s to 3.63 s. That is the
333// tolerance DRAND-MIGRATION.md section 5b already reasons in, and |A - B| <= 0.33 s at
334// its edges. At S = blocksPerDay the admitted error is therefore
335//
336//	26182 * 0.33 = 8640 seconds = 2.4 hours of drand time
337//
338// WHY ONE DAY, AND WHY THAT NUMBER IS THE RAFFLE'S AND NOT THE DUEL'S. The two margins
339// at risk differ by more than two orders of magnitude, and no single bound serves both:
340//
341//   - r/splash/arcade/raffle pins a round pinOffsetRounds = 8 days out against a week of
342//     sales, a nominal margin of 86400 s. At the slow edge of the band the week itself
343//     lengthens (it is a BLOCK count while the pin is a ROUND count), leaving 25915 s.
344//     2.4 hours of error fits inside that with 4.8 hours to spare — a 3x margin — so one
345//     day of blocks is a sound bound for the raffle, and the raffle asserts exactly that
346//     relation at compile time beside its own pinOffsetRounds guard.
347//   - r/splash/arcade/duel binds a round duelBindOffsetRounds = 100 rounds out: a margin
348//     of 300 SECONDS. 2.4 hours of error destroys it 29 times over. Its tolerance has to
349//     be ~450 blocks, and it passes that to EstimatedLatestRoundWithin itself.
350//
351// SO THE BEACON DOES BOTH: a compile-time BACKSTOP no consumer can exceed or forget
352// (this constant, set at the scale of the coarsest consumer), plus a per-call tolerance a
353// consumer with a finer margin must state for itself. A fifth consumer that forgets to
354// think about staleness gets the backstop; one that thinks about it can only ever ask for
355// LESS. That asymmetry is deliberate: EstimatedLatestRoundWithin REFUSES a tolerance
356// above this constant, so the backstop cannot be opted out of.
357//
358// WHAT THIS DOES NOT BOUND, stated here so it is not rediscovered as a surprise:
359//
360//   - A CHAIN HALT. S counts blocks, so an interval producing no blocks contributes
361//     nothing to S while drand advances 28800 rounds a day through it. Equivalently: a
362//     halt inflates B, and this bound only holds under the stated band. That is the floor
363//     DRAND-MIGRATION.md section 5b records — no realm on this toolchain can obtain an
364//     UPPER bound on elapsed real time — and no constant here can move it.
365//   - A SUSTAINED BLOCK TIME OUTSIDE THE BAND. At B = 3.0 s the admitted error at this
366//     bound is 26182*0.29998 = 7854 s, still inside the raffle's margin; at B = 2.5 s it
367//     is 20938 s and is not. The band is an assumption, written down rather than implied,
368//     exactly as blocksPerDay itself is.
369const maxEstimateStaleBlocks = blocksPerDay
370
371// MaxEstimateStaleBlocks is maxEstimateStaleBlocks, exported so that a consumer can
372// state its own tolerance as a fraction of the backstop, assert a relation to it at
373// COMPILE TIME (r/splash/arcade/raffle does), or tell a user how fresh the beacon has to
374// be. A const, so no caller can move it.
375const MaxEstimateStaleBlocks = maxEstimateStaleBlocks
376
377// roundKeyWidth is enough decimal digits for any non-negative int64
378// (max is 9223372036854775807, 19 digits).
379const roundKeyWidth = 19
380
381const zeroPad = "0000000000000000000"
382
383// roundKey renders round as a fixed-width, zero-padded decimal string so
384// that avl.Tree's lexicographic key order matches numeric round order.
385// Beacon rounds are round counters and are assumed non-negative; a negative
386// round still stores and retrieves correctly by exact key, it just will not
387// sort where a caller might expect relative to non-negative rounds.
388func roundKey(round int64) string {
389	s := strconv.FormatInt(round, 10)
390	if len(s) < roundKeyWidth {
391		s = zeroPad[:roundKeyWidth-len(s)] + s
392	}
393	return s
394}
395
396// --- Publish --------------------------------------------------------------
397
398// Publish verifies sig for round against drand evmnet's group key and stores it.
399// PERMISSIONLESS: anyone may relay any round, because validity is what matters
400// and not the sender. Panics if the round is already published or the signature
401// does not verify — including if sigHex is not valid hex, which is just another
402// shape of "not a valid signature".
403//
404// THE ROUND NUMBER IS THE MESSAGE and the caller supplies nothing else: the
405// signed digest is keccak256 of the round as a big-endian uint64
406// (bls254.DrandRoundMessage), so a relayer cannot choose what a round's
407// randomness is, only whether to relay it. The chain is UNCHAINED, so no previous
408// signature is needed and any round verifies on its own.
409//
410// bls254.VerifyDrand, NOT bls254.Verify. The two implement different schemes and
411// each rejects every signature of the other; Verify is the retired SPLASH
412// try-and-increment scheme and is left untouched, still exercised by
413// p/splash/bls254's own suite.
414//
415// COST: 16,970,375 gas measured on this toolchain (zz_gas_test.gno), 0.57% of the 3e9
416// block limit — about 176 to a block. Of it, 13,396,451 is bls254.VerifyDrand and
417// 12,004,207 of THAT is the RFC 9380 hash-to-curve rather than the pairing: 90% of the
418// verification, 71% of the whole Publish. (CORRECTED 2026-09-30: this said "98%", which
419// was the pre-optimisation ratio; DRAND-MIGRATION.md section 9.5.) It fits with a wide
420// margin, and it is also the reason this realm cannot mirror drand.
421//
422// A RELAYER CANNOT PUSH latestRound INTO THE FUTURE, and a great deal now rests
423// on that. Under the SPLASH key one Publish of a far-future round moved latest
424// arbitrarily far in a single transaction, which was the operator-triggered half
425// of AUDIT-2026-09-28.md finding 7. drand cannot sign a round whose time has not
426// come, so latestRound is bounded above by drand's real head, and that attack is
427// dead.
428func Publish(cur realm, round int64, sigHex string) {
429	if Published(round) {
430		panic("beacon: round already published")
431	}
432
433	sig, err := hex.DecodeString(sigHex)
434	if err != nil {
435		panic("beacon: invalid signature")
436	}
437
438	if !bls254.VerifyDrand(groupKey, round, sig) {
439		panic("beacon: invalid signature")
440	}
441
442	h := runtime.ChainHeight()
443	rounds.Set(roundKey(round), &record{Sig: sig, Height: h})
444	if !haveLatest || round > latestRound {
445		latestRound = round
446		latestHeight = h
447		haveLatest = true
448	}
449	// THE ANCHOR IS UPDATED ON EVERY RELAY, NOT ONLY ON A NEW HIGHEST ROUND. A relay of
450	// an older round still carries real-time evidence — its signature exists, so time has
451	// passed at least that far — and the maximum is what makes that evidence impossible
452	// to use downwards. See anchorScaled.
453	//
454	// AND anchorHeight MOVES ONLY WHEN THE ANCHOR ACTUALLY RISES, which is what makes it a
455	// BETTER freshness signal than a "somebody called Publish" flag — not, as this comment
456	// used to say, an UNFORGEABLE one. Raising the anchor does require a signature for a
457	// round above this realm's current estimate; that round is est+1, and when the estimate
458	// lags, est+1 is public drand history. Read anchorHeight in full — the claim is retired
459	// there — before relying on StaleBlocks() as proof of freshness. Do not move this line
460	// out of the branch either: outside it, one Publish of ANY old round would reset the
461	// bound, which is strictly worse again.
462	if cand := round*blocksPerDay - h*RoundsPerDay; !haveAnchor || cand > anchorScaled {
463		anchorScaled = cand
464		anchorHeight = h
465		haveAnchor = true
466	}
467
468	chain.Emit(
469		"Publish",
470		"round", strconv.FormatInt(round, 10),
471		"signature", sigHex,
472		"height", strconv.FormatInt(h, 10),
473	)
474}
475
476// --- reads ----------------------------------------------------------------
477
478func get(round int64) *record {
479	key := roundKey(round)
480	// gno.land/p/nt/avl/v0's Get returns a single value; existence is a
481	// separate Has. Check first so an unpublished round panics rather than
482	// type-asserting nil.
483	if !rounds.Has(key) {
484		return nil
485	}
486	return rounds.Get(key).(*record)
487}
488
489// Randomness returns sha256(signature) for a published round, hex-encoded.
490// Panics if the round has not been published.
491//
492// This is drand's own derivation, not one of ours: the "randomness" field
493// /public/<r> serves is exactly sha256 of the signature it serves beside it, which
494// makes every real round an independent oracle for this function. testdata's
495// fixtures were checked against it.
496func Randomness(round int64) string {
497	r := get(round)
498	if r == nil {
499		panic("beacon: round not published")
500	}
501	sum := sha256.Sum256(r.Sig)
502	return hex.EncodeToString(sum[:])
503}
504
505// Published reports whether a round has been published.
506//
507// WHAT IT MEANS NOW: "somebody has relayed this round to this chain". NOT "this
508// round's randomness exists" (it may exist on drand and simply not be here yet)
509// and NOT "this round's randomness is obtainable" (it always is, from drand).
510// Every caller that treats false as "lost" is wrong; see DRAND-MIGRATION.md
511// section 3.
512func Published(round int64) bool {
513	return rounds.Has(roundKey(round))
514}
515
516// PublishedAtHeight returns the chain height at which a round was relayed.
517// Panics if the round has not been published.
518func PublishedAtHeight(round int64) int64 {
519	r := get(round)
520	if r == nil {
521		panic("beacon: round not published")
522	}
523	return r.Height
524}
525
526// Latest returns the highest published round and its randomness. Panics if
527// no round has been published yet.
528//
529// "HIGHEST PUBLISHED", WHICH IS NOT "CURRENT". Under an on-demand record nothing
530// relays routinely, so this can be arbitrarily stale — a week old, a year old.
531// A CONSUMER PINNING A FUTURE ROUND MUST NOT USE THIS AS "now": use
532// EstimatedLatestRound, which corrects for the staleness using a clock no relayer
533// controls. Reading this as the current round is exactly how the raffle's round
534// pin would silently re-open AUDIT-2026-09-28.md finding 10.
535func Latest() (int64, string) {
536	if !haveLatest {
537		panic("beacon: no round published yet")
538	}
539	return latestRound, Randomness(latestRound)
540}
541
542// LatestPublishedAtHeight returns the chain height at which the highest published
543// round was relayed. Panics if no round has been published yet.
544func LatestPublishedAtHeight() int64 {
545	if !haveLatest {
546		panic("beacon: no round published yet")
547	}
548	return latestHeight
549}
550
551// EstimatedLatestRound is this realm's best estimate of the round drand is
552// currently on. Panics if no round has ever been published.
553//
554// A NEW CALLER SHOULD REACH FOR CurrentRound INSTEAD, AND THIS FUNCTION IS KEPT
555// ONLY FOR WHAT ALREADY CONSUMES IT. This estimate extrapolates from the last
556// RELAYED round, and this realm's own
557// TestKnownWeaknessRelayingTheRoundJustAboveTheEstimateBuysTheWholeStalenessBudgetBack
558// proves that extrapolation is poisonable for the cost of one Publish of a
559// freely-fetched PAST round: relaying est+1 resets the staleness clock to zero
560// while moving the estimate forward by exactly one round. CurrentRound instead
561// reads the block's own clock and computes drand's round by arithmetic, so there
562// is no relayed round to poison. See CurrentRound's comment for the full
563// account, including what trusting the block's clock costs in its place.
564// Nothing that calls EstimatedLatestRound today has been switched over --
565// that is a separate change -- so this function must keep working exactly as
566// it does.
567//
568// IT IS AN ESTIMATE AND THE NAME SAYS SO. There is no way to know the true
569// current round on-chain: proving "round r is not signed yet" needs an UPPER bound
570// on elapsed real time, published rounds give only a LOWER bound, and chain height
571// gives none at all because a halted chain advances no height while drand advances
572// 28800 rounds a day. Every round-number pin in this system is therefore an
573// assumption about this chain keeping pace with drand, and this function is where
574// that assumption lives instead of being spread across three realms.
575//
576// THE CONSTRUCTION: take anchorScaled — the highest round-versus-height intercept any
577// relay has ever evidenced — and read this height off that line. It is exactly
578// latestRound at the moment latestRound is relayed. READ anchorScaled BEFORE CHANGING
579// THIS: extrapolating from the highest round's own publication height instead is the
580// obvious simplification, and it is exploitable.
581//
582// WHY IT IS STRICTLY BETTER THAN Latest() FOR A PIN. A consumer pinning
583// Latest() + offset measures its offset from whenever somebody last happened to
584// relay something, so the pin drifts backwards in real time as the beacon goes
585// quiet — and the raffle's offset is exactly one day larger than a week, so a
586// single day of staleness puts the pinned round's randomness on the public API
587// BEFORE the week's ticket sales close, which is finding 10's last-buyer attack
588// verbatim. The correction removes that failure entirely in the honest case.
589//
590// THE ERROR IS BOUNDED BY REFUSAL, AND THAT IS NEW AS OF THE STALENESS BOUND. The
591// conversion assumes A = 86400/blocksPerDay = 3.29998 s blocks, so its error is
592// S*(A - B) seconds of drand time for S blocks since the anchor was last raised and a
593// real average block time B — 2.4 hours at one day of S and a 10% drift in B, NOT the
594// ~14 minutes this comment claimed until the figure was re-derived (that number divided
595// by the period twice; see maxEstimateStaleBlocks and AUDIT-2026-09-28.md finding 13).
596// Unbounded S makes it unbounded in BOTH directions, so this function now REFUSES above
597// maxEstimateStaleBlocks rather than returning a number nobody can put a bound on. What
598// the refusal costs, and why it is nevertheless right, is at maxEstimateStaleBlocks and
599// in AUDIT-2026-09-28.md finding 12.
600//
601// MONOTONE IN HEIGHT AND NEVER BELOW latestRound: ChainHeight() only rises and
602// anchorHeight/anchorScaled never move except upward, so the correction term is never
603// negative.
604func EstimatedLatestRound() int64 {
605	return EstimatedLatestRoundWithin(maxEstimateStaleBlocks)
606}
607
608// EstimatedLatestRoundWithin is EstimatedLatestRound with the caller's own staleness
609// tolerance, in blocks, for a consumer whose real-time margin is finer than the
610// backstop's. r/splash/arcade/duel uses it: its bound round is 300 SECONDS out, and the
611// backstop admits 2.4 hours of error.
612//
613// IT CAN ONLY EVER ASK FOR LESS. A tolerance above maxEstimateStaleBlocks is refused, so
614// this is not a way around the backstop — a consumer can tighten the bound and cannot
615// loosen it. Read maxEstimateStaleBlocks for why the two-level shape exists at all.
616//
617// Panics if no round has ever been published, if the tolerance is not positive, if it
618// exceeds the backstop, or if the beacon is staler than the tolerance allows.
619func EstimatedLatestRoundWithin(maxStaleBlocks int64) int64 {
620	if !haveLatest {
621		panic("beacon: no round published yet")
622	}
623	if maxStaleBlocks <= 0 {
624		panic("beacon: staleness tolerance must be positive")
625	}
626	if maxStaleBlocks > maxEstimateStaleBlocks {
627		// A caller asking for more than the backstop is asking for an error nobody has
628		// bounded. Refused rather than silently clamped, because a clamp would let the
629		// request look granted.
630		panic("beacon: staleness tolerance exceeds the beacon's own bound")
631	}
632	if StaleBlocks() > maxStaleBlocks {
633		// THE REFUSAL, AND IT IS THE POINT OF THE WHOLE BOUND. Loud, because the
634		// alternative is an extrapolation whose error nothing in this system limits: at
635		// this distance the estimate may be arbitrarily far above OR below drand's real
636		// head, and a consumer pinning against it would bind a round that is already
637		// public (finding 10) or one that will not be signed inside its own grace window
638		// (finding 12).
639		//
640		// THE REMEDY IS IN ANY CALLER'S HANDS, AND SO IS THE DIAGNOSIS. One Publish of
641		// drand's CURRENT round clears this whenever the estimate is behind drand's head,
642		// which is the ordinary case — an on-demand record simply going quiet. If a relay
643		// of the current head does NOT clear it, that is itself the diagnosis: the anchor
644		// could not be raised, so this chain's average block time has fallen below
645		// 3.29998 s and this realm's extrapolation no longer tracks drand. That case
646		// cannot be fixed from inside this realm — see anchorHeight, and
647		// AUDIT-2026-09-28.md finding 12, which records it as blocking.
648		panic("beacon: no relay has raised the estimate's anchor recently — the estimate is too stale to extrapolate; relay drand's current round")
649	}
650	return extrapolatedRound()
651}
652
653// extrapolatedRound is the extrapolation itself, WITH NO STALENESS CHECK. Unexported on
654// purpose: an unbounded extrapolation is exactly what this realm must not hand a
655// consumer. It exists separately so that Render can show the number it would have
656// returned without panicking on a read path, and so that this package's own tests can
657// assert the drift arithmetic at distances the exported functions refuse.
658func extrapolatedRound() int64 {
659	est := (anchorScaled + runtime.ChainHeight()*RoundsPerDay) / blocksPerDay
660	if est < latestRound {
661		// BELT AND BRACES, and the belt is what matters: an estimate BELOW the highest
662		// round anybody has relayed would move a consumer's pinned round backwards in
663		// real time, which is the one direction that is unsafe. It is unreachable on
664		// chain — anchorScaled is a maximum that includes latestRound's own observation,
665		// so the line passes through (latestHeight, latestRound) or above it, and height
666		// only rises — but a future change to the anchor must not be able to break it
667		// silently. (It IS reachable under `gno test`, where realm state persists across
668		// test functions while ChainHeight() resets; see DRAND-MIGRATION.md section 9.7.)
669		return latestRound
670	}
671	return est
672}
673
674// StaleBlocks is how far the chain has moved since the last relay that RAISED the
675// estimate's anchor: the one distance every error in this realm's extrapolation is
676// proportional to. Panics if no round has ever been published.
677//
678// IT IS NOT "BLOCKS SINCE THE LAST Publish", and the difference is the whole soundness
679// argument — read anchorHeight. A relay that does not raise the anchor does not reduce
680// the error, and "blocks since the last Publish" is resettable by anyone for the cost of
681// relaying any one of drand's ~2.1e7 public past rounds.
682//
683// EXPORTED SO A CONSUMER OR A FRONT END CAN SEE THE REFUSAL COMING rather than discover
684// it as a panic in the middle of a settlement, and so a caller can decide to relay a
685// round first. Clamped at zero: a negative distance is unreachable on chain (height only
686// rises, anchorHeight only moves to a then-current height) and arises only under
687// `gno test`, where ChainHeight() resets between test functions while realm state
688// persists — DRAND-MIGRATION.md section 9.7.
689func StaleBlocks() int64 {
690	if !haveAnchor {
691		panic("beacon: no round published yet")
692	}
693	s := runtime.ChainHeight() - anchorHeight
694	if s < 0 {
695		return 0
696	}
697	return s
698}
699
700// AnchorHeight is the chain height of the last relay that raised the estimate's anchor.
701// Panics if no round has ever been published. See anchorHeight and StaleBlocks.
702func AnchorHeight() int64 {
703	if !haveAnchor {
704		panic("beacon: no round published yet")
705	}
706	return anchorHeight
707}
708
709// EstimateIsUsable reports whether EstimatedLatestRound would return rather than panic.
710// A read, so a caller can check before committing to a transaction that would revert.
711func EstimateIsUsable() bool {
712	return haveLatest && haveAnchor && StaleBlocks() <= maxEstimateStaleBlocks
713}
714
715// --- CurrentRound: the block's own clock, not a relayed extrapolation -----
716
717// CurrentRound is the drand round that exists NOW, computed from the BLOCK's own
718// clock rather than extrapolated from whatever a relayer last chose to deliver.
719//
720// WHY IT IS BETTER THAN EstimatedLatestRound, WHICH IT IS INTENDED TO REPLACE.
721// The estimate's input is the last RELAYED round, and this realm's own
722// TestKnownWeaknessRelayingTheRoundJustAboveTheEstimateBuysTheWholeStalenessBudgetBack
723// shows that relaying the round just above it resets the staleness clock for
724// ~0.017 GNOT while advancing the estimate by one round. No arithmetic over
725// relayed rounds can fix that: what a caller needs is an UPPER bound on drand's
726// head ("round R is not signed yet"), a signature only ever proves a LOWER bound,
727// and the ABSENCE of a signature is not provable on chain at all because
728// withholding is free and invisible.
729//
730// A CLOCK HAS NO SUCH ASYMMETRY. drand round r is signed at
731// genesis + period*(r-1), so the round that exists at time t is pure arithmetic
732// over t. No relayer is involved, so there is nothing to withhold and nothing to
733// poison.
734//
735// WHAT IT COSTS, SAID PLAINLY RATHER THAN DISCOVERED LATER: it trusts the chain's
736// block time. Under Tendermint that is the voting-power-weighted median of the
737// validators' own timestamps, so moving it requires colluding validators rather
738// than a tenth of a cent. That is a WEAKER assumption than cryptography and a far
739// STRONGER one than "no relayer withheld anything". For a caller that previously
740// trusted the estimate it is an improvement on both axes; for one that trusted
741// nothing, it is a new assumption and should be chosen deliberately.
742func CurrentRound() int64 {
743	return RoundAt(time.Now().Unix())
744}
745
746// RoundAt is CurrentRound's arithmetic made callable at an arbitrary time, so the
747// round-versus-time relationship is testable directly and so a caller can reason
748// about a round in the future (or the past) rather than only the current one.
749// Panics under the same condition as CurrentRound: see there for why.
750func RoundAt(unixSeconds int64) int64 {
751	if unixSeconds < drandGenesisTime {
752		// A ROUND BEFORE ROUND 1 IS NOT A ROUND, and returning a negative number
753		// silently is exactly how a bad round reaches a draw. This is not a
754		// theoretical edge: `gno test`'s default block timestamp is 1234567890
755		// (February 2009), which is BEFORE drand evmnet's genesis of 1727521075
756		// (September 2024), so a naive (t-genesis)/period+1 is negative under the
757		// harness's own starting state and every test must set the time forward
758		// explicitly before this function means anything.
759		panic("beacon: block time " + strconv.FormatInt(unixSeconds, 10) +
760			" is before drand evmnet's genesis " + strconv.FormatInt(drandGenesisTime, 10) +
761			" — there is no round before round 1")
762	}
763	return (unixSeconds-drandGenesisTime)/PeriodSeconds + 1
764}
765
766// TimeOfRound is RoundAt's inverse: the unix second at which round is signed.
767// Needed so a caller can say "this round is signed N seconds from now" (or ago),
768// and so the round<->time relationship can be checked in both directions rather
769// than trusted in one.
770//
771// Unlike RoundAt, this does not panic for round < 1: a round number is just an
772// integer here, and the arithmetic is exact and total in both directions. A
773// caller asking for round 0 or below is asking "what time would this
774// (non-existent) round have been", which is an arithmetic question with a
775// well-defined answer, not a claim that such a round exists.
776func TimeOfRound(round int64) int64 {
777	return drandGenesisTime + PeriodSeconds*(round-1)
778}
779
780// --- no Prune, and that is deliberate -------------------------------------
781//
782// THERE IS NO Prune AND NO PruneWindow, AND NOTHING SHOULD REINTRODUCE THEM.
783// They existed because this realm used to try to mirror a five-minute beacon and
784// needed the state bounded. Four reasons they are gone, in order of weight:
785//
786//  1. A ROUND COUNT CANNOT EXPRESS A RETENTION DURATION any more. PruneWindow was
787//     documented as "7 days" and compared against latestRound — a number that now
788//     advances only when somebody happens to relay something. It was a duration in
789//     a unit that no longer tracks time, and at a three-second cadence its old
790//     value of 2016 meant a hundred minutes.
791//  2. NOTHING SURPLUS IS STORED. Every round in the tree is one a draw or a duel
792//     asked for. A week of mirrored signatures would be 201600 rounds, 12.9 MB of
793//     signature bytes alone; an on-demand record is 64 bytes per round actually
794//     used, and ten thousand rounds is under a megabyte.
795//  3. DELETION IS NO LONGER DESTRUCTION, so a window protects nothing. Any deleted
796//     round is re-fetchable from three drand relays and re-publishable by anyone.
797//     The window existed to stop a delete destroying a live game's randomness; a
798//     delete cannot destroy anything now.
799//  4. AND SO A DELETE FUNCTION WOULD BE A PURE GRIEF VECTOR: its only remaining
800//     use is removing a round some pending Resolve depends on, moments before it
801//     runs. Recoverable, but free to the attacker and not to the victim, with no
802//     compensating benefit left.
803//
804// THE RESIDUAL RISK THIS ACCEPTS, recorded rather than discovered: Publish is
805// permissionless with no owner, so state grows without an upper bound. The bound
806// is economic — 16.97M gas a round, ~176 rounds per block if someone burns every
807// block's entire gas on it. That is a chain-level denial of service with a
808// chain-level answer, and a round window would not have stopped it either.
809
810// Render implements the realm's human-readable view.
811func Render(path string) string {
812	if path == "" {
813		if !haveLatest {
814			return "# SPLASH beacon\n\ndrand evmnet, chain " + ChainHash +
815				"\n\nNo rounds published yet.\n"
816		}
817		r, rnd := Latest()
818		// THE ESTIMATE IS SHOWN THROUGH extrapolatedRound, NOT EstimatedLatestRound, so
819		// that a render NEVER PANICS. EstimatedLatestRound refuses past
820		// maxEstimateStaleBlocks, and a page that goes blank in exactly the state a
821		// reader most needs to understand would be the worst possible behaviour here.
822		// The staleness and the bound are printed beside it, and the line says in words
823		// that a consumer would be refused this number — which is the fact a reader
824		// needs, whereas the panic would only hide it.
825		stale := StaleBlocks()
826		usable := "yes"
827		if stale > maxEstimateStaleBlocks {
828			usable = "NO — too stale to extrapolate; relay drand's current round"
829		}
830		return "# SPLASH beacon\n\ndrand evmnet, chain " + ChainHash +
831			"\n\nHighest published round: " + strconv.FormatInt(r, 10) +
832			"\nRandomness: " + rnd +
833			"\nRelayed at height: " + strconv.FormatInt(latestHeight, 10) +
834			"\nEstimated current round: " + strconv.FormatInt(extrapolatedRound(), 10) +
835			"\nBlocks since the anchor was last raised: " + strconv.FormatInt(stale, 10) +
836			" of " + strconv.FormatInt(maxEstimateStaleBlocks, 10) +
837			"\nUsable as an anchor for a round pin: " + usable + "\n"
838	}
839
840	round, err := strconv.ParseInt(path, 10, 64)
841	if err != nil {
842		return "# SPLASH beacon\n\nUnknown path.\n"
843	}
844	if !Published(round) {
845		return "# SPLASH beacon\n\nRound " + strconv.FormatInt(round, 10) +
846			" has not been relayed to this chain.\n"
847	}
848	return "# SPLASH beacon\n\nRound " + strconv.FormatInt(round, 10) +
849		"\nRandomness: " + Randomness(round) +
850		"\nRelayed at height: " + strconv.FormatInt(PublishedAtHeight(round), 10) + "\n"
851}