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}