diff --git a/.gitignore b/.gitignore index d370a23..50995d5 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,4 @@ rls*.log runtime/wasm/target/ substrate.code-workspace target*/ -*.profraw \ No newline at end of file +*.profraw diff --git a/pallets/randomness-beacon/src/aggregator.rs b/pallets/randomness-beacon/src/aggregator.rs index b138bcd..394439e 100644 --- a/pallets/randomness-beacon/src/aggregator.rs +++ b/pallets/randomness-beacon/src/aggregator.rs @@ -108,6 +108,9 @@ impl SignatureAggregator for QuicknetAggregator { // compute new rounds let latest = start + height; let rounds = (start..latest).collect::>(); + + // TODO: Investigate lookup table for round numbers + // https://github.com/ideal-lab5/idn-sdk/issues/119 for r in rounds { let q = compute_round_on_g1(r)?; apk = (apk + q).into() diff --git a/pallets/randomness-beacon/src/lib.rs b/pallets/randomness-beacon/src/lib.rs index 7c38b5a..fdff567 100644 --- a/pallets/randomness-beacon/src/lib.rs +++ b/pallets/randomness-beacon/src/lib.rs @@ -116,6 +116,7 @@ const SERIALIZED_SIG_SIZE: usize = 48; #[frame_support::pallet] pub mod pallet { use super::*; + use frame_support::ensure; use frame_system::pallet_prelude::*; #[pallet::pallet] @@ -131,8 +132,8 @@ pub mod pallet { type BeaconConfig: Get; /// something that knows how to aggregate and verify beacon pulses. type SignatureAggregator: SignatureAggregator; - /// The number of pulses per block. - type SignatureToBlockRatio: Get; + /// The number of signatures per block. + type MaxSigsPerBlock: Get; } /// A first round number for which a pulse was observed @@ -148,6 +149,13 @@ pub mod pallet { #[pallet::storage] pub type AggregatedSignature = StorageValue<_, Aggregate, OptionQuery>; + /// Whether the asig has been updated in this block. + /// + /// This value is updated to `true` upon successful submission of an asig by a node. + /// It is then checked at the end of each block execution in the `on_finalize` hook. + #[pallet::storage] + pub(super) type DidUpdate = StorageValue<_, bool, ValueQuery>; + #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event { @@ -173,6 +181,13 @@ pub mod pallet { GenesisRoundNotSet, /// The genesis is already set. GenesisRoundAlreadySet, + /// There must be at least one signature to construct an asig + ZeroHeightProvided, + /// The number of aggregated signatures exceeds the maximum rounds that we can verify per + /// block. + ExcessiveHeightProvided, + /// Only one aggregated signature can be provided per block + SignatureAlreadyVerified, } #[pallet::inherent] @@ -187,6 +202,9 @@ pub mod pallet { // if we do not find any pulse data, then do nothing if let Ok(Some(raw_pulses)) = data.get_data::>>(&Self::INHERENT_IDENTIFIER) { + // ignores non-deserializable messages + // if all messages are invalid, it outputs 0 on the G1 curve (so serialization of + // asig always works) let asig = raw_pulses .iter() .filter_map(|rp| OpaquePulse::deserialize_from_vec(rp).ok()) @@ -194,14 +212,25 @@ pub mod pallet { .fold(zero_on_g1(), |acc, sig| (acc + sig).into()); let mut asig_bytes = Vec::with_capacity(SERIALIZED_SIG_SIZE); - if asig.serialize_compressed(&mut asig_bytes).is_err() { - log::error!("Failed to serialize the aggregated signature."); - return None; - } + // [SRLABS]: This error is untestable since we know the signature is correct here. + // Is it reasonable to use an expect? + asig.serialize_compressed(&mut asig_bytes) + .expect("The signature is well formatted."); + + // if the genesis round is not configured, then the first call sets it + let round = (GenesisRound::::get() == 0) + .then(|| { + // get the round from the first pulse observed + raw_pulses.iter().find_map(|rp| { + OpaquePulse::deserialize_from_vec(rp).ok().map(|p| p.round) + }) + }) + .unwrap_or(None); return Some(Call::try_submit_asig { asig: OpaqueSignature::truncate_from(asig_bytes), - round: None, + height: raw_pulses.len() as RoundNumber, + round, }); } else { log::info!("The node provided empty pulse data to the inherent!"); @@ -210,8 +239,11 @@ pub mod pallet { None } - fn check_inherent(_call: &Self::Call, _data: &InherentData) -> Result<(), Self::Error> { - Ok(()) + fn check_inherent(call: &Self::Call, _data: &InherentData) -> Result<(), Self::Error> { + match call { + Call::try_submit_asig { .. } => Ok(()), + _ => unreachable!("other calls are not inherents"), + } } fn is_inherent(call: &Self::Call) -> bool { @@ -219,12 +251,36 @@ pub mod pallet { } } + #[pallet::hooks] + impl Hooks> for Pallet { + /// A dummy `on_initialize` to return the amount of weight that `on_finalize` requires to + /// execute. + fn on_initialize(_n: BlockNumberFor) -> Weight { + // weight of `on_finalize` + T::WeightInfo::on_finalize() + } + + /// At the end of block execution, the `on_finalize` hook checks that the timestamp was + /// updated. Upon success, it removes the boolean value from storage. If the value resolves + /// to `false`, the pallet will panic. + /// + /// ## Complexity + /// - `O(1)` + fn on_finalize(_n: BlockNumberFor) { + assert!( + DidUpdate::::take(), + "The aggregated siganture must be updated once in the block" + ); + } + } + #[pallet::call] impl Pallet { /// Write a set of pulses to the runtime /// /// * `origin`: A None origin /// * `asig`: An aggregated signature + /// * `height`: The number of sigs aggregated to construct asig /// * `round`: An optional genesis round number. It can only be set if the existing genesis /// round is 0. #[pallet::call_index(0)] @@ -232,44 +288,47 @@ pub mod pallet { pub fn try_submit_asig( origin: OriginFor, asig: OpaqueSignature, + height: RoundNumber, round: Option, ) -> DispatchResult { - // In the future, this will expected a signed payload - // https://github.com/ideal-lab5/idn-sdk/issues/117 ensure_none(origin)?; + ensure!(!DidUpdate::::exists(), Error::::SignatureAlreadyVerified,); + let config = T::BeaconConfig::get(); let mut genesis_round = GenesisRound::::get(); let mut latest_round = LatestRound::::get(); + ensure!(height > 0, Error::::ZeroHeightProvided); + ensure!( + height <= T::MaxSigsPerBlock::get() as u64, + Error::::ExcessiveHeightProvided + ); + if let Some(r) = round { // if a round is provided and the genesis round is not set - frame_support::ensure!(genesis_round == 0, Error::::GenesisRoundAlreadySet); + ensure!(genesis_round == 0, Error::::GenesisRoundAlreadySet); GenesisRound::::set(r); genesis_round = r; latest_round = genesis_round; } else { // if the genesis round is not set and a round is not provided - frame_support::ensure!( - GenesisRound::::get() > 0, - Error::::GenesisRoundNotSet - ); + ensure!(GenesisRound::::get() > 0, Error::::GenesisRoundNotSet); } - // aggregate old asig/apk with the new one and verify the aggregation + // Q: do we care about the entire linear history of message hashes? + // https://github.com/ideal-lab5/idn-sdk/issues/119 let aggr = T::SignatureAggregator::aggregate_and_verify( config.public_key, asig, latest_round, - T::SignatureToBlockRatio::get() as u64, + height, AggregatedSignature::::get(), ) .map_err(|_| Error::::VerificationFailed)?; - LatestRound::::set( - latest_round.saturating_add(T::SignatureToBlockRatio::get() as u64), - ); - + LatestRound::::set(latest_round.saturating_add(height)); AggregatedSignature::::set(Some(aggr)); + DidUpdate::::put(true); Self::deposit_event(Event::::SignatureVerificationSuccess); diff --git a/pallets/randomness-beacon/src/mock.rs b/pallets/randomness-beacon/src/mock.rs index cbaae8d..fcd1275 100644 --- a/pallets/randomness-beacon/src/mock.rs +++ b/pallets/randomness-beacon/src/mock.rs @@ -63,7 +63,7 @@ impl pallet_drand_bridge::Config for Test { type WeightInfo = (); type BeaconConfig = QuicknetBeaconConfig; type SignatureAggregator = QuicknetAggregator; - type SignatureToBlockRatio = ConstU8<2>; + type MaxSigsPerBlock = ConstU8<2>; } // Build genesis storage according to the mock runtime. diff --git a/pallets/randomness-beacon/src/tests.rs b/pallets/randomness-beacon/src/tests.rs index 03b5b0c..c992d89 100644 --- a/pallets/randomness-beacon/src/tests.rs +++ b/pallets/randomness-beacon/src/tests.rs @@ -1,7 +1,24 @@ +/* + * Copyright 2025 by Ideal Labs, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + use crate::{ - aggregator::test::*, mock::*, AggregatedSignature, Call, Error, GenesisRound, LatestRound, + aggregator::test::*, mock::*, weights::*, AggregatedSignature, Call, Error, GenesisRound, + LatestRound, }; -use frame_support::{assert_noop, assert_ok, inherent::ProvideInherent}; +use frame_support::{assert_noop, assert_ok, inherent::ProvideInherent, traits::OnFinalize}; #[test] fn can_construct_pallet_and_set_genesis_params() { @@ -12,12 +29,12 @@ fn can_construct_pallet_and_set_genesis_params() { } #[test] -fn can_fail_write_pulse_when_genesis_round_zero() { +fn can_fail_write_pulse_when_genesis_round_zero_and_none_provided() { let (sig, _pk) = get(vec![PULSE1000]); new_test_ext().execute_with(|| { System::set_block_number(1); assert_noop!( - Drand::try_submit_asig(RuntimeOrigin::none(), sig, None), + Drand::try_submit_asig(RuntimeOrigin::none(), sig, 1, None), Error::::GenesisRoundNotSet, ); }); @@ -31,7 +48,7 @@ fn can_submit_min_required_valid_pulses_on_genesis() { new_test_ext().execute_with(|| { System::set_block_number(1); - assert_ok!(Drand::try_submit_asig(RuntimeOrigin::none(), asig.clone(), Some(round))); + assert_ok!(Drand::try_submit_asig(RuntimeOrigin::none(), asig.clone(), 2, Some(round))); // then the gensis round is set to `round` let genesis_round = GenesisRound::::get(); @@ -46,20 +63,30 @@ fn can_submit_min_required_valid_pulses_on_genesis() { }); } -// note: this test is equivalent to either specifying: -// a) an incorrect signature but correct round -// b) a correct signature but incorrect round #[test] -fn can_not_submit_less_than_min_required_valid_pulses_on_genesis() { +fn can_fail_when_sig_height_is_0() { let round = 1000u64; - let (asig, _apk) = get(vec![PULSE1000]); + let (asig, _apk) = get(vec![PULSE1000, PULSE1001]); new_test_ext().execute_with(|| { System::set_block_number(1); + assert_noop!( + Drand::try_submit_asig(RuntimeOrigin::none(), asig.clone(), 0, Some(round)), + Error::::ZeroHeightProvided + ); + }); +} +#[test] +fn can_fail_when_sig_height_is_exceeds_max() { + let round = 1000u64; + let (asig, _apk) = get(vec![PULSE1000, PULSE1001]); + + new_test_ext().execute_with(|| { + System::set_block_number(1); assert_noop!( - Drand::try_submit_asig(RuntimeOrigin::none(), asig.clone(), Some(round)), - Error::::VerificationFailed, + Drand::try_submit_asig(RuntimeOrigin::none(), asig.clone(), 10, Some(round)), + Error::::ExcessiveHeightProvided ); }); } @@ -77,8 +104,12 @@ fn can_submit_valid_sigs_in_sequence() { new_test_ext().execute_with(|| { System::set_block_number(1); - assert_ok!(Drand::try_submit_asig(RuntimeOrigin::none(), asig1.clone(), Some(round1))); - assert_ok!(Drand::try_submit_asig(RuntimeOrigin::none(), asig2.clone(), None)); + assert_ok!(Drand::try_submit_asig(RuntimeOrigin::none(), asig1.clone(), 2, Some(round1))); + + Drand::on_finalize(1); + System::set_block_number(2); + + assert_ok!(Drand::try_submit_asig(RuntimeOrigin::none(), asig2.clone(), 2, None)); // then the gensis round is set to `round` let genesis_round = GenesisRound::::get(); @@ -95,6 +126,28 @@ fn can_submit_valid_sigs_in_sequence() { assert_eq!(round2, actual_latest); }); } + +#[test] +fn can_fail_to_calls_to_try_submit_asig_per_block() { + let round1 = 1000u64; + let round2 = 1004u64; + + let (asig1, _apk1) = get(vec![PULSE1000, PULSE1001]); + let (asig2, _apk2) = get(vec![PULSE1002, PULSE1003]); + // the aggregated values + let (asig, apk) = get(vec![PULSE1000, PULSE1001, PULSE1002, PULSE1003]); + + new_test_ext().execute_with(|| { + System::set_block_number(1); + + assert_ok!(Drand::try_submit_asig(RuntimeOrigin::none(), asig1.clone(), 2, Some(round1))); + assert_noop!( + Drand::try_submit_asig(RuntimeOrigin::none(), asig1.clone(), 2, None), + Error::::SignatureAlreadyVerified, + ); + }); +} + #[test] fn can_fail_to_submit_invalid_sigs_in_sequence() { let round1 = 1000u64; @@ -104,13 +157,17 @@ fn can_fail_to_submit_invalid_sigs_in_sequence() { new_test_ext().execute_with(|| { System::set_block_number(1); - assert_ok!(Drand::try_submit_asig(RuntimeOrigin::none(), asig1.clone(), Some(round1))); + assert_ok!(Drand::try_submit_asig(RuntimeOrigin::none(), asig1.clone(), 2, Some(round1))); + + Drand::on_finalize(1); + System::set_block_number(2); + assert_noop!( - Drand::try_submit_asig(RuntimeOrigin::none(), asig1.clone(), None), + Drand::try_submit_asig(RuntimeOrigin::none(), asig1.clone(), 2, None), Error::::VerificationFailed, ); assert_noop!( - Drand::try_submit_asig(RuntimeOrigin::none(), asig1.clone(), Some(round1)), + Drand::try_submit_asig(RuntimeOrigin::none(), asig1.clone(), 2, Some(round1)), Error::::GenesisRoundAlreadySet, ); @@ -133,14 +190,12 @@ fn can_fail_to_submit_invalid_sigs_in_sequence() { /* Inherents Tests */ - -use ark_serialize::CanonicalSerialize; use sc_consensus_randomness_beacon::types::OpaquePulse; use sp_consensus_randomness_beacon::inherents::INHERENT_IDENTIFIER; use sp_inherents::InherentData; #[test] -fn can_create_inherent() { +fn can_create_inherent_and_set_genesis_round() { // setup the inherent data let (asig1, _apk1) = get(vec![PULSE1000]); let pulse1 = OpaquePulse { round: 1000u64, signature: asig1.to_vec().try_into().unwrap() }; @@ -155,8 +210,36 @@ fn can_create_inherent() { new_test_ext().execute_with(|| { let result = Drand::create_inherent(&inherent_data); - if let Some(Call::try_submit_asig { asig: actual_asig, round: None }) = result { - assert_eq!(actual_asig, asig); + if let Some(Call::try_submit_asig { asig: actual_asig, height, round: Some(1000) }) = result + { + assert_eq!(height, 2, "The asig height should equal the number of pulses."); + assert_eq!(actual_asig, asig, "The output should match the aggregated input."); + } else { + panic!("Expected Some(Call::try_submit_asig), got None"); + } + }); +} + +#[test] +fn can_create_inherent_when_genesis_round_is_set() { + // setup the inherent data + let (asig1, _apk1) = get(vec![PULSE1000]); + let pulse1 = OpaquePulse { round: 1000u64, signature: asig1.to_vec().try_into().unwrap() }; + let (asig2, _apk2) = get(vec![PULSE1001]); + let pulse2 = OpaquePulse { round: 1001u64, signature: asig2.to_vec().try_into().unwrap() }; + + let (asig, _apk) = get(vec![PULSE1000, PULSE1001]); + + let bytes: Vec> = vec![pulse1.serialize_to_vec(), pulse2.serialize_to_vec()]; + let mut inherent_data = InherentData::new(); + inherent_data.put_data(INHERENT_IDENTIFIER, &bytes.clone()).unwrap(); + + new_test_ext().execute_with(|| { + GenesisRound::::set(999); + let result = Drand::create_inherent(&inherent_data); + if let Some(Call::try_submit_asig { asig: actual_asig, height, round: None }) = result { + assert_eq!(height, 2, "The asig height should equal the number of pulses."); + assert_eq!(actual_asig, asig, "The output should match the aggregated input."); } else { panic!("Expected Some(Call::try_submit_asig), got None"); } @@ -173,20 +256,24 @@ fn can_not_create_inherent_when_data_is_unavailable() { } #[test] -fn can_create_inherent_when_data_is_non_decodable() { - // set bad inherent data - let bytes: Vec> = vec![vec![1, 2, 3, 4, 5]]; +fn can_check_inherent() { + // setup the inherent data + let (asig1, _apk1) = get(vec![PULSE1000]); + let pulse1 = OpaquePulse { round: 1000u64, signature: asig1.to_vec().try_into().unwrap() }; + let (asig2, _apk2) = get(vec![PULSE1001]); + let pulse2 = OpaquePulse { round: 1001u64, signature: asig2.to_vec().try_into().unwrap() }; + + let bytes: Vec> = vec![pulse1.serialize_to_vec(), pulse2.serialize_to_vec()]; let mut inherent_data = InherentData::new(); inherent_data.put_data(INHERENT_IDENTIFIER, &bytes.clone()).unwrap(); - let asig = crate::aggregator::zero_on_g1(); - let mut bytes = Vec::new(); - asig.serialize_compressed(&mut bytes).unwrap(); - new_test_ext().execute_with(|| { + GenesisRound::::set(999); let result = Drand::create_inherent(&inherent_data); - if let Some(Call::try_submit_asig { asig: actual_asig, round: None }) = result { - assert_eq!(actual_asig.to_vec(), bytes.to_vec()); + if let Some(call) = result { + assert!(Drand::is_inherent(&call), "The inherent should be allowed."); + let res = Drand::check_inherent(&call, &inherent_data); + assert!(res.is_ok(), "The inherent should be allowed."); } else { panic!("Expected Some(Call::try_submit_asig), got None"); } diff --git a/pallets/randomness-beacon/src/weights.rs b/pallets/randomness-beacon/src/weights.rs index bff1c99..d8925a8 100644 --- a/pallets/randomness-beacon/src/weights.rs +++ b/pallets/randomness-beacon/src/weights.rs @@ -18,10 +18,14 @@ use frame_support::weights::Weight; pub trait WeightInfo { + fn on_finalize() -> Weight; fn try_submit_asig() -> Weight; } impl WeightInfo for () { + fn on_finalize() -> Weight { + Weight::from_parts(2_956_000, 1627) + } fn try_submit_asig() -> Weight { Weight::from_parts(2_956_000, 1627) }