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

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