pallet_randomness_beacon/
lib.rs

1/*
2 * Copyright 2025 by Ideal Labs, LLC
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *     http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17//! # Randomness Beacon Aggregation and Verification Pallet
18//!
19//! This pallet facilitates the aggregation and verification of randomness pulses from an external
20//! verifiable randomness beacon, such as [drand](https://drand.love)'s Quicknet. It enables
21//! runtime access to externally sourced, cryptographically secure randomness while ensuring that
22//! only properly signed pulses are accepted.
23//!
24//! ## Overview
25//!
26//! - Provides a mechanism to ingest randomness pulses from an external randomness beacon.
27//! - Aggregates and verifies pulses using the [`SignatureVerifier`] trait.
28//! - Ensures that the runtime only uses verified randomness for security-critical applications.
29//! - Stores the latest aggregated signature to enable efficient verification within the runtime.
30//!
31//! This pallet is particularly useful for use cases that require externally verifiable randomness,
32//! such as fair lotteries, gaming applications, and leader election mechanisms.
33//!
34//! ## Terminology
35//!
36//! - **Randomness Pulse**: A cryptographically signed value representing a random output from an
37//!   external randomness beacon.
38//! - **Round Number**: A sequential identifier corresponding to each randomness pulse.
39//! - **Aggregated Signature**: A combined (aggregated) cryptographic signature that ensures all
40//!   observed pulses originate from the trusted randomness beacon.
41//!
42//! ## Implementation Details
43//!
44//! The pallet relies on a [`SignatureVerifier`] implementation to aggregate and verify randomness
45//! pulses. It maintains the latest observed rounds, validates incoming pulses, and aggregates valid
46//! signatures before storing them in runtime storage. It expects a monotonically increasing
47//! sequence of beacon pulses delivered in packets of size `T::SignatureToBlockRatio`, beginning at
48//! the genesis round.
49//!
50//! To be more specific, if the randomness beacon incrementally outputs pulses A -> B -> C -> D,
51//! the genesis round expects pulse A first, and the SignatureToBlockRatio is 2, then this pallet
52//! would first expect the 'aggregated' pulse AB = A + B, which produces both an aggregated
53//! *signature* (asig) and an aggregated *public key* (apk). Subsequently, it would expected the
54//! next value to be CD = C + D. On-chain, this results in the aggregated signature, ABCD = AB + CD,
55//! which we can use to prove we have observed all pulses between A and D.
56//!
57//! ### Storage Items
58//!
59//! - `BeaconConfig`: Stores the beacon configuration details.
60//! - `GenesisRound`: The first round number from which randomness pulses are considered valid.
61//! - `NextRound`: Tracks the next minimum future round number for which a signature can be
62//!   consumed.
63//! - `Accumulation`: Stores the latest aggregated signature for verification purposes.
64//!
65//! ## Usage
66//!
67//! This pallet is designed to securely ingest verifiable randomness into the runtime.
68//! Authorized callers (block authors) can inject signatures into the runtime, which are verified
69//! on-chain.
70//!
71//! ## Interface
72//!
73//! - **Extrinsics**
74//!   - `try_submit_asig`: Submit an aggregated signature for verification. This is an unsigned
75//!     extrinsic, intended to be called by and hold a signature produced by the block author.
76//!
77//! - **Inherent Implementation**
78//!   - This pallet provides an inherent that automatically submits aggregated randomness pulses
79//!     during block execution.
80//!
81//! Run `cargo doc --package pallet-randomness-beacon --open` to view this pallet's documentation.
82
83#![cfg_attr(not(feature = "std"), no_std)]
84
85pub use pallet::*;
86
87use ark_serialize::CanonicalSerialize;
88use frame_support::pallet_prelude::*;
89
90use frame_support::traits::{FindAuthor, Randomness};
91use frame_system::pallet_prelude::BlockNumberFor;
92use sp_consensus_randomness_beacon::types::{OpaquePublicKey, OpaqueSignature, RoundNumber};
93use sp_core::H256;
94use sp_idn_crypto::{
95	bls12_381::zero_on_g1, drand::compute_round_on_g1, verifier::SignatureVerifier,
96};
97use sp_idn_traits::{
98	pulse::{Dispatcher, Pulse as TPulse},
99	Hashable,
100};
101use sp_runtime::traits::Verify;
102use sp_std::fmt::Debug;
103
104extern crate alloc;
105use alloc::{vec, vec::Vec};
106
107pub mod types;
108pub mod weights;
109pub use weights::*;
110
111pub use types::*;
112
113#[cfg(test)]
114mod mock;
115
116#[cfg(test)]
117mod tests;
118
119#[cfg(feature = "runtime-benchmarks")]
120mod benchmarking;
121
122const LOG_TARGET: &str = "pallet-randomness-beacon";
123
124#[frame_support::pallet]
125pub mod pallet {
126	use super::*;
127	use frame_support::ensure;
128	use frame_system::pallet_prelude::*;
129	use sp_runtime::traits::{IdentifyAccount, Verify};
130
131	#[pallet::pallet]
132	pub struct Pallet<T>(_);
133
134	#[pallet::config]
135	pub trait Config: frame_system::Config {
136		/// The overarching runtime event type.
137		type RuntimeEvent: From<Event<Self>> + IsType<<Self as frame_system::Config>::RuntimeEvent>;
138
139		/// A type representing the weights required by the dispatchables of this pallet.
140		type WeightInfo: WeightInfo;
141
142		/// something that knows how to aggregate and verify beacon pulses.
143		type SignatureVerifier: SignatureVerifier;
144
145		/// The number of signatures per block.
146		type MaxSigsPerBlock: Get<u8>;
147
148		/// The pulse type
149		type Pulse: TPulse
150			+ Encode
151			+ Decode
152			+ Debug
153			+ Clone
154			+ TypeInfo
155			+ PartialEq
156			+ From<Accumulation>;
157
158		/// Something that can dispatch pulses
159		type Dispatcher: Dispatcher<Self::Pulse>;
160
161		/// The fallback randomness source
162		type FallbackRandomness: Randomness<Self::Hash, BlockNumberFor<Self>>;
163
164		/// Signature type that the extension of this pallet can verify.
165		type Signature: Verify<Signer = Self::AccountIdentifier>
166			+ Parameter
167			+ Encode
168			+ Decode
169			+ Send
170			+ Sync;
171
172		/// The account identifier used by this pallet's signature type.
173		type AccountIdentifier: IdentifyAccount<AccountId = Self::AccountId>;
174
175		/// Find the author of a block.
176		type FindAuthor: FindAuthor<Self::AccountId>;
177	}
178
179	/// The beacon public key
180	#[pallet::storage]
181	pub type BeaconConfig<T: Config> = StorageValue<_, OpaquePublicKey, OptionQuery>;
182
183	/// The next smallest round number for which a signature can be accepted
184	#[pallet::storage]
185	pub type NextRound<T: Config> = StorageValue<_, RoundNumber, ValueQuery>;
186
187	/// The aggregated signature and (start, end) rounds
188	#[pallet::storage]
189	pub type SparseAccumulation<T: Config> = StorageValue<_, Accumulation, OptionQuery>;
190
191	/// Whether the asig has been updated in this block.
192	///
193	/// This value is updated to `true` upon successful submission of an asig by a node.
194	/// It is then checked at the end of each block execution in the `on_finalize` hook.
195	#[pallet::storage]
196	pub(super) type DidUpdate<T: Config> = StorageValue<_, bool, ValueQuery>;
197
198	#[pallet::genesis_config]
199	#[derive(frame_support::DefaultNoBound)]
200	pub struct GenesisConfig<T: Config> {
201		/// The randomness beacon public key
202		pub beacon_pubkey_hex: Vec<u8>,
203		_phantom: core::marker::PhantomData<T>,
204	}
205
206	#[pallet::genesis_build]
207	impl<T: Config> BuildGenesisConfig for GenesisConfig<T> {
208		fn build(&self) {
209			Pallet::<T>::initialize_beacon_pubkey(&self.beacon_pubkey_hex)
210		}
211	}
212
213	#[pallet::event]
214	#[pallet::generate_deposit(pub(super) fn deposit_event)]
215	pub enum Event<T: Config> {
216		/// The beacon config has been set by a root address
217		BeaconConfigSet,
218		/// Signature verification succeeded for the provided rounds.
219		SignatureVerificationSuccess,
220	}
221
222	#[pallet::error]
223	pub enum Error<T> {
224		/// The beacon config is not set.
225		BeaconConfigNotSet,
226		/// The height exceeds the maximum allowed signatures per block.
227		ExcessiveHeightProvided,
228		/// The provided authority signature could not be verified.
229		InvalidSignature,
230		/// Only one aggregated signature can be provided per block.
231		SignatureAlreadyVerified,
232		/// A critical error occurred where serialization failed.
233		SerializationFailed,
234		/// The first round provided has already happened.
235		StartExpired,
236		/// The pulse could not be verified.
237		VerificationFailed,
238		/// There must be at least one signature to construct an asig.
239		ZeroHeightProvided,
240	}
241
242	#[pallet::validate_unsigned]
243	impl<T: Config> ValidateUnsigned for Pallet<T> {
244		type Call = Call<T>;
245
246		/// It restricts calls to `try_submit_asig` to local calls (i.e. extrinsics generated
247		/// on this node) or that already in a block. This guarantees that only block authors can
248		/// include unsigned equivocation reports.
249		fn validate_unsigned(source: TransactionSource, call: &Self::Call) -> TransactionValidity {
250			// reject if not from local
251			if !matches!(source, TransactionSource::Local | TransactionSource::InBlock) {
252				return InvalidTransaction::Call.into();
253			}
254
255			match call {
256				Call::try_submit_asig { asig, start, end, .. } => {
257					// invalidate early if start < next_round since it will fail anyway
258					let next_round = NextRound::<T>::get();
259					if *start < next_round {
260						log::info!(
261							"Invalidating transation early: start = {:?} is less than {:?}",
262							start,
263							next_round
264						);
265						return InvalidTransaction::Call.into();
266					}
267
268					ValidTransaction::with_tag_prefix("RandomnessBeacon")
269						// prioritize execution
270						.priority(TransactionPriority::MAX)
271						// unique tag per call
272						.and_provides(vec![(b"beacon_pulse", asig, start, end).encode()])
273						// how long?
274						.longevity(5)
275						// do not propagate to other nodes
276						.propagate(false)
277						.build()
278				},
279				_ => InvalidTransaction::Call.into(),
280			}
281		}
282	}
283
284	#[pallet::hooks]
285	impl<T: Config> Hooks<BlockNumberFor<T>> for Pallet<T> {
286		/// A dummy `on_initialize` to return the amount of weight that `on_finalize` requires to
287		/// execute.
288		fn on_initialize(_n: BlockNumberFor<T>) -> Weight {
289			// weight of `on_finalize`
290			<T as pallet::Config>::WeightInfo::on_finalize()
291		}
292
293		/// At the end of block execution, the `on_finalize` hook checks that the asig was
294		/// updated. Upon success, it removes the boolean value from storage. If the value resolves
295		/// to `false`, then the runtime did  **not** receive any valid pulses from drand and we log
296		/// an error. If the value resolves to `true`, then process subscriptions.
297		fn on_finalize(n: BlockNumberFor<T>) {
298			if !DidUpdate::<T>::take() && BeaconConfig::<T>::get().is_some() {
299				// this implies we did not ingest randomness from drand during this block
300				log::error!(target: LOG_TARGET, "Failed to ingest pulses during lifetime of block {:?}", n);
301			}
302		}
303	}
304
305	#[pallet::call]
306	impl<T: Config> Pallet<T> {
307		/// Write a set of pulses to the runtime
308		///
309		/// * `origin`: An unsigned origin.
310		/// * `asig`: An aggregated signature as bytes
311		/// * `start`: The round number where sig aggregation began
312		/// * `end`: The round number where sig aggregation stopped
313		#[pallet::call_index(0)]
314		#[pallet::weight((<T as pallet::Config>::WeightInfo::try_submit_asig(
315			T::MaxSigsPerBlock::get().into())
316				.saturating_add(
317					T::Dispatcher::dispatch_weight()),
318			DispatchClass::Operational
319		))]
320		#[allow(clippy::useless_conversion)]
321		pub fn try_submit_asig(
322			origin: OriginFor<T>,
323			asig: OpaqueSignature,
324			start: RoundNumber,
325			end: RoundNumber,
326			signature: T::Signature,
327		) -> DispatchResult {
328			ensure_none(origin)?;
329
330			// verify that that the block author signed this tx
331			let payload = (asig.to_vec().clone(), start, end).encode();
332			Self::verify_signature(payload, signature)?;
333
334			// the extrinsic can only be successfully executed once per block
335			ensure!(!DidUpdate::<T>::exists(), Error::<T>::SignatureAlreadyVerified);
336
337			let pk = BeaconConfig::<T>::get().ok_or(Error::<T>::BeaconConfigNotSet)?;
338			// 0 < num_sigs <= MaxSigsPerBlock
339			let height = end.saturating_sub(start);
340			// must allow start = end if start > 0
341			if height == 0 {
342				// we allow height == 0 iff there is a single pulse being ingested (start == end)
343				ensure!(start == end, Error::<T>::ZeroHeightProvided);
344			}
345			ensure!(
346				height <= T::MaxSigsPerBlock::get() as u64,
347				Error::<T>::ExcessiveHeightProvided
348			);
349
350			let next_round: RoundNumber = NextRound::<T>::get();
351			// we accept any *new* pulses, a somewhat weaker condition than expecting
352			// a monotonically increasing sequence of pulses.
353			// This will be strengthened in: https://github.com/ideal-lab5/idn-sdk/issues/392
354			if next_round > 0 {
355				ensure!(start >= next_round, Error::<T>::StartExpired);
356			}
357
358			Self::verify_beacon_signature(pk, asig, start, end)?;
359
360			// update storage
361			NextRound::<T>::set(end.saturating_add(1));
362			let sacc = Accumulation::new(asig, start, end);
363			SparseAccumulation::<T>::set(Some(sacc.clone()));
364			DidUpdate::<T>::put(true);
365
366			// handle vraas subs
367			let runtime_pulse = T::Pulse::from(sacc);
368			T::Dispatcher::dispatch(runtime_pulse);
369
370			// events
371			Self::deposit_event(Event::<T>::SignatureVerificationSuccess);
372			Ok(())
373		}
374
375		/// Set the genesis round exactly once if you are root
376		///
377		/// * `origin`: A root origin
378		/// * `config`: The randomness beacon configuration (genesis round and public key).
379		#[pallet::call_index(1)]
380		#[pallet::weight(<T as pallet::Config>::WeightInfo::set_beacon_config())]
381		#[allow(clippy::useless_conversion)]
382		pub fn set_beacon_config(
383			origin: OriginFor<T>,
384			pk: OpaquePublicKey,
385		) -> DispatchResultWithPostInfo {
386			ensure_root(origin)?;
387			BeaconConfig::<T>::set(Some(pk));
388			Self::deposit_event(Event::<T>::BeaconConfigSet);
389			Ok(Pays::No.into())
390		}
391	}
392}
393
394impl<T: Config> Pallet<T> {
395	/// Initial the beacon public key.
396	///
397	/// The storage will be applied immediately.
398	///
399	/// The beacon_pubkey_hex must be 96  bytes.
400	pub fn initialize_beacon_pubkey(beacon_pubkey_hex: &[u8]) {
401		if !beacon_pubkey_hex.is_empty() {
402			assert!(<BeaconConfig<T>>::get().is_none(), "Beacon config is already initialized!");
403			let bytes = hex::decode(beacon_pubkey_hex)
404				.expect("The beacon public key must be hex-encoded and 96 bytes.");
405			let bpk: OpaquePublicKey =
406				bytes.try_into().expect("The beacon public key must be exactly 96 bytes.");
407			BeaconConfig::<T>::set(Some(bpk));
408		}
409	}
410
411	/// Verify that asig is a BLS signature on the message $\sum_{i = start}^{end} Sha256(i)$
412	///
413	/// *`pk`: The beacon public key
414	/// * `asig`: The signature to verify
415	/// * `start`: The first round to use when constructing the message
416	/// * `end`: The last round to use when constructing the message
417	fn verify_beacon_signature(
418		pk: OpaquePublicKey,
419		asig: OpaqueSignature,
420		start: RoundNumber,
421		end: RoundNumber,
422	) -> DispatchResult {
423		// build the message
424		let mut amsg = zero_on_g1();
425		for r in start..=end {
426			let msg = compute_round_on_g1(r).map_err(|_| Error::<T>::SerializationFailed)?;
427			amsg = (amsg + msg).into();
428		}
429
430		// convert to bytes
431		let mut amsg_bytes = Vec::new();
432		amsg.serialize_compressed(&mut amsg_bytes)
433			.map_err(|_| Error::<T>::SerializationFailed)?;
434		// verify the signature
435		T::SignatureVerifier::verify(
436			pk.as_ref().to_vec(),
437			asig.clone().as_ref().to_vec(),
438			amsg_bytes,
439		)
440		.map_err(|_| {
441			log::info!("asig verification failed for rounds: {} - {}", start, end);
442			Error::<T>::VerificationFailed
443		})?;
444
445		Ok(())
446	}
447
448	/// Verify that the `signature` is a valid signature on the `payload`
449	/// under the current block author's public key
450	fn verify_signature(payload: Vec<u8>, signature: T::Signature) -> DispatchResult {
451		let digest = <frame_system::Pallet<T>>::digest();
452		let pre_runtime_digests = digest.logs.iter().filter_map(|d| d.as_pre_runtime());
453		let author_id = T::FindAuthor::find_author(pre_runtime_digests)
454			.ok_or(DispatchError::Other("No block author found"))?;
455		// verify sig
456		ensure!(signature.verify(&payload[..], &author_id), Error::<T>::InvalidSignature);
457		Ok(())
458	}
459
460	/// get the latest round from the runtime
461	pub fn next_round() -> RoundNumber {
462		NextRound::<T>::get()
463	}
464
465	/// get the max number of pulses we can hold in a block
466	pub fn max_rounds() -> u8 {
467		T::MaxSigsPerBlock::get()
468	}
469}
470
471impl<T: Config> Randomness<T::Hash, BlockNumberFor<T>> for Pallet<T>
472where
473	T::Hash: From<H256>,
474{
475	fn random(subject: &[u8]) -> (T::Hash, BlockNumberFor<T>) {
476		match SparseAccumulation::<T>::get() {
477			Some(accumulation) => {
478				let randomness_hash = accumulation.signature.hash(subject).into();
479				(randomness_hash, frame_system::Pallet::<T>::block_number())
480			},
481			None => {
482				log::warn!(
483					target: LOG_TARGET,
484					"Randomness requested but no sparse accumulation available. Returning fallback values."
485				);
486				T::FallbackRandomness::random(subject)
487			},
488		}
489	}
490}
491
492sp_api::decl_runtime_apis! {
493	pub trait RandomnessBeaconApi {
494		/// Get the latest round finalized on-chain
495		fn next_round() -> sp_consensus_randomness_beacon::types::RoundNumber;
496		/// Get the maximum number of outputs from the beacon we can verify simultaneously onchain
497		fn max_rounds() -> u8;
498		/// Build an unsigned extrinsic with signed payload
499		fn build_extrinsic(
500			asig: Vec<u8>,
501			start: u64,
502			end: u64,
503			signature: Vec<u8>,
504		) -> Block::Extrinsic;
505	}
506}