diff --git a/pallets/hyperdrive/Cargo.toml b/pallets/hyperdrive/Cargo.toml new file mode 100644 index 00000000..9d7b9ed6 --- /dev/null +++ b/pallets/hyperdrive/Cargo.toml @@ -0,0 +1,59 @@ +[package] +name = "pallet-acurast-hyperdrive" +authors = ["Papers AG"] +description = "Acurast Hyperdrive is a building block allowing for general bidirectional message passing" +version = "0.0.1" +license = "MIT" +homepage = "https://docs.acurast.com/" +edition = "2021" +publish = false +repository = "https://github.com/acurast/acurast-core" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +codec = { package = "parity-scale-codec", version = "3.0.0", default-features = false, features = [ + "derive", +] } + +# Benchmarks +hex-literal = { version = "0.3", optional = true } +frame-benchmarking = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.36", optional = true, default-features = false } + +# Substrate +frame-support = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.36", default-features = false } +frame-system = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.36", default-features = false } +scale-info = { version = "2.2.0", default-features = false, features = [ + "derive", +] } +sp-std = { git = "https://github.com/paritytech/substrate", default-features = false, branch = "polkadot-v0.9.36" } +sp-runtime = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.36", default-features = false } +sp-arithmetic = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.36", default-features = false } + +[dev-dependencies] +hex-literal = "0.3" + +sp-io = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.36", default-features = false } +sp-runtime = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.36", default-features = false } +sp-core = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.36", default-features = false } +log = "0.4.17" + + +[features] +default = ["std"] +std = [ + "codec/std", + "scale-info/std", + "frame-support/std", + "frame-system/std", + "frame-benchmarking/std", + "sp-std/std", + "sp-runtime/std", +] + +runtime-benchmarks = [ + "frame-benchmarking/runtime-benchmarks", + "hex-literal", +] +try-runtime = ["frame-support/try-runtime"] diff --git a/pallets/hyperdrive/src/benchmarking.rs b/pallets/hyperdrive/src/benchmarking.rs new file mode 100644 index 00000000..401c5ef8 --- /dev/null +++ b/pallets/hyperdrive/src/benchmarking.rs @@ -0,0 +1,11 @@ +//! Benchmarking setup + +use super::*; + +use frame_benchmarking::{benchmarks_instance_pallet, whitelisted_caller}; +use frame_system::RawOrigin; + +benchmarks_instance_pallet! { + + impl_benchmark_test_suite!(crate::Pallet, crate::mock::new_test_ext(), crate::mock::Test); +} diff --git a/pallets/hyperdrive/src/lib.rs b/pallets/hyperdrive/src/lib.rs new file mode 100644 index 00000000..58e63bd9 --- /dev/null +++ b/pallets/hyperdrive/src/lib.rs @@ -0,0 +1,258 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +#[cfg(test)] +mod mock; + +#[cfg(test)] +mod tests; + +#[cfg(feature = "runtime-benchmarks")] +mod benchmarking; + +mod types; + +use frame_support::{dispatch::Weight, traits::Get}; + +pub use pallet::*; + +#[frame_support::pallet] +pub mod pallet { + use super::*; + use core::{fmt::Debug, str::FromStr}; + use frame_support::{ + pallet_prelude::*, + sp_runtime::traits::{ + AtLeast32BitUnsigned, Bounded, CheckEqual, MaybeDisplay, SimpleBitOps, + }, + }; + use frame_system::pallet_prelude::*; + use sp_arithmetic::traits::{CheckedRem, Zero}; + use sp_runtime::traits::Hash; + use types::*; + + /// A instantiable pallet for receiving secure state synchronizations into Acurast. + #[pallet::pallet] + #[pallet::generate_store(pub(super) trait Store)] + #[pallet::without_storage_info] + pub struct Pallet(PhantomData<(T, I)>); + + /// Configures the pallet instance for a specific target chain from which we synchronize state into Acurast. + #[pallet::config] + pub trait Config: frame_system::Config { + type RuntimeEvent: From> + + IsType<::RuntimeEvent>; + + /// The output of the `Hashing` function used to derive hashes of target chain state. + type TargetChainHash: Parameter + + Member + + MaybeSerializeDeserialize + + Debug + + MaybeDisplay + + SimpleBitOps + + Ord + + Default + + Copy + + CheckEqual + + sp_std::hash::Hash + + AsRef<[u8]> + + AsMut<[u8]> + + MaxEncodedLen; + /// The block number type used by the target runtime. + type TargetChainBlockNumber: Parameter + + Member + + MaybeSerializeDeserialize + + Debug + + MaybeDisplay + + AtLeast32BitUnsigned + + Default + + Bounded + + Copy + + sp_std::hash::Hash + + FromStr + + MaxEncodedLen + + TypeInfo + + Zero + + CheckedRem; + /// The hashing system (algorithm) being used in the runtime (e.g. Blake2). + type TargetChainHashing: Hash + TypeInfo; + /// Transmission rate in blocks; `block % transmission_rate == 0` must hold. + type TransmissionRate: Get; + /// The quorum size of transmitters that need to agree on a state merkle root before accepting in proofs. + /// + /// **NOTE**: the quorum size must be larger than `ceil(number of transmitters / 2)`, otherwise multiple root hashes could become valid in terms of [`Pallet::validate_state_merkle_root`]. + type TransmissionQuorum: Get; + } + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event, I: 'static = ()> { + StateTransmittersUpdate { + added: Vec<( + T::AccountId, + types::ActivityWindow<::BlockNumber>, + )>, + updated: Vec<( + T::AccountId, + types::ActivityWindow<::BlockNumber>, + )>, + removed: Vec, + }, + StateMerkleRootSubmitted { + block: T::TargetChainBlockNumber, + state_merkle_root: T::TargetChainHash, + }, + StateMerkleRootAccepted { + block: T::TargetChainBlockNumber, + state_merkle_root: T::TargetChainHash, + }, + } + + /// This storage field maps the state transmitters to their respective activity window. + /// + /// These transmitters are responsible for submitting the merkle roots of supported + /// source chains to acurast. + #[pallet::storage] + #[pallet::getter(fn state_transmitter)] + pub type StateTransmitter, I: 'static = ()> = StorageMap< + _, + Blake2_128, + T::AccountId, + ActivityWindow<::BlockNumber>, + ValueQuery, + >; + + #[pallet::storage] + #[pallet::getter(fn state_merkle_root)] + pub type StateMerkleRootCount, I: 'static = ()> = StorageDoubleMap< + _, + Blake2_128, + T::TargetChainBlockNumber, + Identity, + T::TargetChainHash, + u8, + >; + + #[pallet::error] + pub enum Error { + /// A known transmitter submits outside the window of activity he is permissioned to. + SubmitOutsideTransmitterActivityWindow, + SubmitOutsideTransmissionRate, + CalculationOverflow, + } + + #[pallet::call] + impl, I: 'static> Pallet { + /// Used to add, update or remove state transmitters. + #[pallet::call_index(0)] + #[pallet::weight(Weight::from_ref_time(10_000).saturating_add(T::DbWeight::get().reads_writes(1, 2)))] + pub fn update_state_transmitters( + origin: OriginFor, + actions: Vec< + StateTransmitterUpdate::BlockNumber>, + >, + ) -> DispatchResult { + ensure_root(origin)?; + + // Process actions + let (added, updated, removed) = + actions + .iter() + .fold((vec![], vec![], vec![]), |acc, action| { + let (mut added, mut updated, mut removed) = acc; + match action { + StateTransmitterUpdate::Add(account, activity_window) => { + >::set( + account.clone(), + activity_window.clone(), + ); + added.push((account.clone(), activity_window.clone())) + } + StateTransmitterUpdate::Update(account, activity_window) => { + >::set( + account.clone(), + activity_window.clone(), + ); + updated.push((account.clone(), activity_window.clone())) + } + StateTransmitterUpdate::Remove(account) => { + >::remove(account); + removed.push(account.clone()) + } + } + (added, updated, removed) + }); + + // Emit event to inform that the state transmitters were updated + Self::deposit_event(Event::StateTransmittersUpdate { + added, + updated, + removed, + }); + + Ok(()) + } + + /// Used by transmitters to submit a `state_merkle_root` at the specified `block` on the target chain. + #[pallet::call_index(1)] + #[pallet::weight(Weight::from_ref_time(10_000).saturating_add(T::DbWeight::get().reads_writes(1, 2)))] + pub fn submit_state_merkle_root( + origin: OriginFor, + block: T::TargetChainBlockNumber, + state_merkle_root: T::TargetChainHash, + ) -> DispatchResult { + let who = ensure_signed(origin)?; + + let activity_window = >::get(&who); + let current_block = >::block_number(); + // valid window is defined inclusive start_block, exclusive end_block + ensure!( + activity_window.start_block <= current_block + && current_block < activity_window.end_block, + Error::::SubmitOutsideTransmitterActivityWindow + ); + ensure!( + block + .checked_rem(&T::TransmissionRate::get()) + .ok_or(Error::::CalculationOverflow)? + .is_zero(), + Error::::SubmitOutsideTransmissionRate + ); + + // insert merkle root proposal since all checks passed + // allows for constant-time validity checks + let accepted = + StateMerkleRootCount::::mutate(&block, &state_merkle_root, |count| { + let count_ = count.unwrap_or(0) + 1; + *count = Some(count_); + count_ >= >::TransmissionQuorum::get() + }); + + // Emit event to inform that the state merkle root has been sumitted + Self::deposit_event(Event::StateMerkleRootSubmitted { + block, + state_merkle_root, + }); + + if accepted { + Self::deposit_event(Event::StateMerkleRootAccepted { + block, + state_merkle_root, + }); + } + + Ok(()) + } + } + + impl, I: 'static> Pallet { + /// Validates a state merkle root with respect to roots submitted by a quorum of transmitters. + pub fn validate_state_merkle_root( + block: T::TargetChainBlockNumber, + state_merkle_root: T::TargetChainHash, + ) -> bool { + StateMerkleRootCount::::get(&block, &state_merkle_root).map_or(false, |count| { + count >= >::TransmissionQuorum::get() + }) + } + } +} diff --git a/pallets/hyperdrive/src/mock.rs b/pallets/hyperdrive/src/mock.rs new file mode 100644 index 00000000..381b87e4 --- /dev/null +++ b/pallets/hyperdrive/src/mock.rs @@ -0,0 +1,92 @@ +use frame_support::{ + parameter_types, + traits::{ConstU16, ConstU64}, +}; +use frame_system as system; +use sp_core::H256; +use sp_runtime::traits::Keccak256; +use sp_runtime::{ + testing::Header, + traits::{BlakeTwo256, IdentityLookup}, + AccountId32, +}; + +type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; +type Block = frame_system::mocking::MockBlock; + +parameter_types! { + pub const TransmissionRate: u64 = 5; + pub const TransmissionQuorum: u8 = 2; +} + +// Configure a mock runtime to test the pallet. +frame_support::construct_runtime!( + pub enum Test where + Block = Block, + NodeBlock = Block, + UncheckedExtrinsic = UncheckedExtrinsic, + { + System: frame_system, + TezosHyperdrive: crate, + } +); + +impl system::Config for Test { + type BaseCallFilter = frame_support::traits::Everything; + type BlockWeights = (); + type BlockLength = (); + type DbWeight = (); + type RuntimeOrigin = RuntimeOrigin; + type RuntimeCall = RuntimeCall; + type Index = u64; + type BlockNumber = u64; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = AccountId32; + type Lookup = IdentityLookup; + type Header = Header; + type RuntimeEvent = RuntimeEvent; + type BlockHashCount = ConstU64<250>; + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = (); + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = ConstU16<42>; + type OnSetCode = (); + type MaxConsumers = frame_support::traits::ConstU32<16>; +} + +impl crate::Config for Test { + type RuntimeEvent = RuntimeEvent; + type TargetChainHash = H256; + type TargetChainBlockNumber = u64; + type TargetChainHashing = Keccak256; + type TransmissionRate = TransmissionRate; + type TransmissionQuorum = TransmissionQuorum; +} + +// Build genesis storage according to the mock runtime. +pub fn new_test_ext() -> sp_io::TestExternalities { + let storage = system::GenesisConfig::default() + .build_storage::() + .unwrap() + .into(); + + let mut ext = sp_io::TestExternalities::new(storage); + ext.execute_with(|| System::set_block_number(1)); + ext +} + +pub fn events() -> Vec { + log::debug!("{:#?}", System::events()); + let evt = System::events() + .into_iter() + .map(|evt| evt.event) + .collect::>(); + + System::reset_events(); + + evt +} diff --git a/pallets/hyperdrive/src/tests.rs b/pallets/hyperdrive/src/tests.rs new file mode 100644 index 00000000..33cfd0bb --- /dev/null +++ b/pallets/hyperdrive/src/tests.rs @@ -0,0 +1,320 @@ +#![cfg(test)] + +use frame_support::{assert_err, assert_ok, error::BadOrigin}; +use hex_literal::hex; +use sp_core::H256; +use sp_runtime::AccountId32; + +use crate::{ + mock::*, + types::{ActivityWindow, StateTransmitterUpdate}, + Error, +}; + +pub fn alice_account_id() -> AccountId32 { + [0; 32].into() +} +pub fn bob_account_id() -> AccountId32 { + [1; 32].into() +} +pub const HASH: H256 = H256(hex!( + "a3f18e4c6f0cdd0d8666f407610351cacb9a263678cf058294be9977b69f2cb3" +)); + +#[test] +fn update_single_state_transmitters() { + let mut test = new_test_ext(); + + test.execute_with(|| { + // A single action + + let actions = vec![StateTransmitterUpdate::Add( + alice_account_id(), + ActivityWindow { + start_block: 0, + end_block: 100, + }, + )]; + + assert_ok!(TezosHyperdrive::update_state_transmitters( + RuntimeOrigin::root().into(), + actions + )); + + assert_eq!( + events(), + [RuntimeEvent::TezosHyperdrive( + crate::Event::StateTransmittersUpdate { + added: vec![( + alice_account_id(), + ActivityWindow { + start_block: 0, + end_block: 100 + } + )], + updated: vec![], + removed: vec![], + } + )] + ); + }); +} + +#[test] +fn update_multiple_state_transmitters() { + let mut test = new_test_ext(); + + test.execute_with(|| { + let actions = vec![ + StateTransmitterUpdate::Add( + alice_account_id(), + ActivityWindow { + start_block: 0, + end_block: 100, + }, + ), + StateTransmitterUpdate::Update( + alice_account_id(), + ActivityWindow { + start_block: 0, + end_block: 100, + }, + ), + StateTransmitterUpdate::Remove(alice_account_id()), + ]; + + assert_ok!(TezosHyperdrive::update_state_transmitters( + RuntimeOrigin::root().into(), + actions.clone() + )); + + assert_eq!( + events(), + [RuntimeEvent::TezosHyperdrive( + crate::Event::StateTransmittersUpdate { + added: vec![( + alice_account_id(), + ActivityWindow { + start_block: 0, + end_block: 100 + } + )], + updated: vec![( + alice_account_id(), + ActivityWindow { + start_block: 0, + end_block: 100 + } + )], + removed: vec![(alice_account_id())], + } + )] + ); + }); +} + +/// Non root calls should fail +#[test] +fn update_state_transmitters_non_root() { + let mut test = new_test_ext(); + + test.execute_with(|| { + let actions = vec![StateTransmitterUpdate::Add( + alice_account_id(), + ActivityWindow { + start_block: 0, + end_block: 100, + }, + )]; + + assert_err!( + TezosHyperdrive::update_state_transmitters( + RuntimeOrigin::signed(alice_account_id()).into(), + actions + ), + BadOrigin + ); + }); +} + +#[test] +fn submit_outside_activity_window() { + let mut test = new_test_ext(); + + test.execute_with(|| { + let actions = vec![StateTransmitterUpdate::Add( + alice_account_id(), + ActivityWindow { + start_block: 10, + end_block: 20, + }, + )]; + + assert_ok!(TezosHyperdrive::update_state_transmitters( + RuntimeOrigin::root().into(), + actions + )); + + System::set_block_number(9); + assert_err!( + TezosHyperdrive::submit_state_merkle_root( + RuntimeOrigin::signed(alice_account_id()), + 5, + HASH + ), + Error::::SubmitOutsideTransmitterActivityWindow + ); + + System::set_block_number(10); + assert_ok!(TezosHyperdrive::submit_state_merkle_root( + RuntimeOrigin::signed(alice_account_id()), + 10, + HASH + )); + + System::set_block_number(19); + assert_ok!(TezosHyperdrive::submit_state_merkle_root( + RuntimeOrigin::signed(alice_account_id()), + 10, + HASH + )); + + System::set_block_number(20); + assert_err!( + TezosHyperdrive::submit_state_merkle_root( + RuntimeOrigin::signed(bob_account_id()), + 5, + HASH + ), + Error::::SubmitOutsideTransmitterActivityWindow + ); + }); +} + +#[test] +fn submit_outside_transmission_rate() { + let mut test = new_test_ext(); + + test.execute_with(|| { + let actions = vec![StateTransmitterUpdate::Add( + alice_account_id(), + ActivityWindow { + start_block: 10, + end_block: 20, + }, + )]; + + assert_ok!(TezosHyperdrive::update_state_transmitters( + RuntimeOrigin::root().into(), + actions + )); + + System::set_block_number(10); + assert_err!( + TezosHyperdrive::submit_state_merkle_root( + RuntimeOrigin::signed(bob_account_id()), + 6, + HASH + ), + Error::::SubmitOutsideTransmitterActivityWindow + ); + }); +} + +#[test] +fn submit_state_merkle_root() { + let mut test = new_test_ext(); + + test.execute_with(|| { + let actions = vec![ + StateTransmitterUpdate::Add( + alice_account_id(), + ActivityWindow { + start_block: 10, + end_block: 20, + }, + ), + StateTransmitterUpdate::Add( + bob_account_id(), + ActivityWindow { + start_block: 10, + end_block: 50, + }, + ), + ]; + + assert_ok!(TezosHyperdrive::update_state_transmitters( + RuntimeOrigin::root().into(), + actions + )); + + System::set_block_number(10); + + // first submission for target chain block 10 + assert_ok!(TezosHyperdrive::submit_state_merkle_root( + RuntimeOrigin::signed(alice_account_id()), + 10, + HASH + )); + // does not validate until quorum reached + assert_eq!(TezosHyperdrive::validate_state_merkle_root(10, HASH), false); + + // intermitted submission for different block is allowed! + assert_ok!(TezosHyperdrive::submit_state_merkle_root( + RuntimeOrigin::signed(bob_account_id()), + 15, + HASH + )); + + // second submission for target chain block 10 + assert_ok!(TezosHyperdrive::submit_state_merkle_root( + RuntimeOrigin::signed(bob_account_id()), + 10, + HASH + )); + // does validate since quorum reached + assert_eq!(TezosHyperdrive::validate_state_merkle_root(10, HASH), true); + + assert_eq!( + events(), + [ + RuntimeEvent::TezosHyperdrive(crate::Event::StateTransmittersUpdate { + added: vec![ + ( + alice_account_id(), + ActivityWindow { + start_block: 10, + end_block: 20 + } + ), + ( + bob_account_id(), + ActivityWindow { + start_block: 10, + end_block: 50 + } + ) + ], + updated: vec![], + removed: vec![], + }), + RuntimeEvent::TezosHyperdrive(crate::Event::StateMerkleRootSubmitted { + block: 10, + state_merkle_root: HASH + }), + RuntimeEvent::TezosHyperdrive(crate::Event::StateMerkleRootSubmitted { + block: 15, + state_merkle_root: HASH + }), + RuntimeEvent::TezosHyperdrive(crate::Event::StateMerkleRootSubmitted { + block: 10, + state_merkle_root: HASH + }), + RuntimeEvent::TezosHyperdrive(crate::Event::StateMerkleRootAccepted { + block: 10, + state_merkle_root: HASH + }) + ] + ); + }); +} diff --git a/pallets/hyperdrive/src/types.rs b/pallets/hyperdrive/src/types.rs new file mode 100644 index 00000000..ca3d1ee4 --- /dev/null +++ b/pallets/hyperdrive/src/types.rs @@ -0,0 +1,27 @@ +use codec::{Decode, Encode}; +use frame_support::RuntimeDebug; +use scale_info::TypeInfo; + +/// This struct defines the transmitter activity window +#[derive(RuntimeDebug, Encode, Decode, TypeInfo, Clone, PartialEq)] +pub struct ActivityWindow { + /// From this block on, the transmitter is permitted to submit Merkle roots. + pub start_block: BlockNumber, + /// From this block on, the transmitter is not permitted to submit any Merkle root. + pub end_block: BlockNumber, +} +impl> Default for ActivityWindow { + fn default() -> Self { + Self { + start_block: BlockNumber::from(0), + end_block: BlockNumber::from(0), + } + } +} + +#[derive(RuntimeDebug, Encode, Decode, TypeInfo, Clone, PartialEq)] +pub enum StateTransmitterUpdate { + Add(AccountId, ActivityWindow), + Remove(AccountId), + Update(AccountId, ActivityWindow), +}