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}