diff --git a/substrate/bin/node/cli/src/chain_spec.rs b/substrate/bin/node/cli/src/chain_spec.rs index d46a7797a702fa95bfe052c2f0ffa1944c7604f6..eb3ee5124ac0a647bfb7da002e615baf60aa509a 100644 --- a/substrate/bin/node/cli/src/chain_spec.rs +++ b/substrate/bin/node/cli/src/chain_spec.rs @@ -284,7 +284,7 @@ pub fn testnet_genesis( }).collect::<Vec<_>>(), }, pallet_staking: StakingConfig { - validator_count: initial_authorities.len() as u32 * 2, + validator_count: initial_authorities.len() as u32, minimum_validator_count: initial_authorities.len() as u32, invulnerables: initial_authorities.iter().map(|x| x.0.clone()).collect(), slash_reward_fraction: Perbill::from_percent(10), diff --git a/substrate/frame/election-provider-multi-phase/Cargo.toml b/substrate/frame/election-provider-multi-phase/Cargo.toml index 643c768ce87098c5835611e4433849d7d7aea00c..cd84ef3778c503fcb741f7c29584c6adb60501eb 100644 --- a/substrate/frame/election-provider-multi-phase/Cargo.toml +++ b/substrate/frame/election-provider-multi-phase/Cargo.toml @@ -22,6 +22,7 @@ frame-system = { version = "3.0.0", default-features = false, path = "../system" sp-io = { version = "3.0.0", default-features = false, path = "../../primitives/io" } sp-std = { version = "3.0.0", default-features = false, path = "../../primitives/std" } +sp-core = { version = "3.0.0", default-features = false, path = "../../primitives/core" } sp-runtime = { version = "3.0.0", default-features = false, path = "../../primitives/runtime" } sp-npos-elections = { version = "3.0.0", default-features = false, path = "../../primitives/npos-elections" } sp-arithmetic = { version = "3.0.0", default-features = false, path = "../../primitives/arithmetic" } @@ -56,6 +57,7 @@ std = [ "sp-io/std", "sp-std/std", + "sp-core/std", "sp-runtime/std", "sp-npos-elections/std", "sp-arithmetic/std", diff --git a/substrate/frame/election-provider-multi-phase/src/lib.rs b/substrate/frame/election-provider-multi-phase/src/lib.rs index e4fed277cf4fc0a52bddefd9275a9ff95c4501ba..d1de16f7f744fa43d9389b564834f30914075ab2 100644 --- a/substrate/frame/election-provider-multi-phase/src/lib.rs +++ b/substrate/frame/election-provider-multi-phase/src/lib.rs @@ -653,38 +653,24 @@ pub mod pallet { } fn offchain_worker(now: T::BlockNumber) { - match Self::current_phase() { - Phase::Unsigned((true, opened)) if opened == now => { - // mine a new solution, cache it, and attempt to submit it - let initial_output = Self::try_acquire_offchain_lock(now) - .and_then(|_| Self::mine_check_save_submit()); - log!(info, "initial OCW output at {:?}: {:?}", now, initial_output); - } - Phase::Unsigned((true, opened)) if opened < now => { - // keep trying to submit solutions. worst case, we note that the stored solution - // is better than our cached/computed one, and decide not to submit after all. - // - // the offchain_lock prevents us from spamming submissions too often. - let resubmit_output = Self::try_acquire_offchain_lock(now) - .and_then(|_| Self::restore_or_compute_then_maybe_submit()); - log!(info, "resubmit OCW output at {:?}: {:?}", now, resubmit_output); + use sp_runtime::offchain::storage_lock::{StorageLock, BlockAndTime}; + + // create a lock with the maximum deadline of number of blocks in the unsigned phase. + // This should only come useful in an **abrupt** termination of execution, otherwise the + // guard will be dropped upon successful execution. + let mut lock = StorageLock::<BlockAndTime<frame_system::Pallet::<T>>>::with_block_deadline( + unsigned::OFFCHAIN_LOCK, + T::UnsignedPhase::get().saturated_into(), + ); + + match lock.try_lock() { + Ok(_guard) => { + Self::do_synchronized_offchain_worker(now); + }, + Err(deadline) => { + log!(debug, "offchain worker lock not released, deadline is {:?}", deadline); } - _ => {} - } - // after election finalization, clear OCW solution storage - if <frame_system::Pallet<T>>::events() - .into_iter() - .filter_map(|event_record| { - let local_event = <T as Config>::Event::from(event_record.event); - local_event.try_into().ok() - }) - .find(|event| { - matches!(event, Event::ElectionFinalized(_)) - }) - .is_some() - { - unsigned::kill_ocw_solution::<T>(); - } + }; } fn integrity_test() { @@ -929,6 +915,44 @@ pub mod pallet { } impl<T: Config> Pallet<T> { + /// Internal logic of the offchain worker, to be executed only when the offchain lock is + /// acquired with success. + fn do_synchronized_offchain_worker(now: T::BlockNumber) { + log!(trace, "lock for offchain worker acquired."); + match Self::current_phase() { + Phase::Unsigned((true, opened)) if opened == now => { + // mine a new solution, cache it, and attempt to submit it + let initial_output = Self::ensure_offchain_repeat_frequency(now).and_then(|_| { + Self::mine_check_save_submit() + }); + log!(debug, "initial offchain thread output: {:?}", initial_output); + } + Phase::Unsigned((true, opened)) if opened < now => { + // try and resubmit the cached solution, and recompute ONLY if it is not + // feasible. + let resubmit_output = Self::ensure_offchain_repeat_frequency(now).and_then(|_| { + Self::restore_or_compute_then_maybe_submit() + }); + log!(debug, "resubmit offchain thread output: {:?}", resubmit_output); + } + _ => {} + } + + // after election finalization, clear OCW solution storage. + if <frame_system::Pallet<T>>::events() + .into_iter() + .filter_map(|event_record| { + let local_event = <T as Config>::Event::from(event_record.event); + local_event.try_into().ok() + }) + .any(|event| { + matches!(event, Event::ElectionFinalized(_)) + }) + { + unsigned::kill_ocw_solution::<T>(); + } + } + /// Logic for [`<Pallet as Hooks>::on_initialize`] when signed phase is being opened. /// /// This is decoupled for easy weight calculation. diff --git a/substrate/frame/election-provider-multi-phase/src/unsigned.rs b/substrate/frame/election-provider-multi-phase/src/unsigned.rs index 66b985c8efb9470978e0d680deb3bfbda5593376..ef1cdfd5a71c4cd6908a35223cca346c056d0ecd 100644 --- a/substrate/frame/election-provider-multi-phase/src/unsigned.rs +++ b/substrate/frame/election-provider-multi-phase/src/unsigned.rs @@ -15,26 +15,27 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! The unsigned phase implementation. +//! The unsigned phase, and its miner. use crate::{ - helpers, Call, CompactAccuracyOf, CompactOf, Config, - ElectionCompute, Error, FeasibilityError, Pallet, RawSolution, ReadySolution, RoundSnapshot, - SolutionOrSnapshotSize, Weight, WeightInfo, + helpers, Call, CompactAccuracyOf, CompactOf, Config, ElectionCompute, Error, FeasibilityError, + Pallet, RawSolution, ReadySolution, RoundSnapshot, SolutionOrSnapshotSize, Weight, WeightInfo, }; use codec::{Encode, Decode}; use frame_support::{dispatch::DispatchResult, ensure, traits::Get}; use frame_system::offchain::SubmitTransaction; use sp_arithmetic::Perbill; use sp_npos_elections::{ - CompactSolution, ElectionResult, ElectionScore, assignment_ratio_to_staked_normalized, + CompactSolution, ElectionResult, assignment_ratio_to_staked_normalized, assignment_staked_to_ratio_normalized, is_score_better, seq_phragmen, }; use sp_runtime::{offchain::storage::StorageValueRef, traits::TrailingZeroInput, SaturatedConversion}; use sp_std::{cmp::Ordering, convert::TryFrom, vec::Vec}; -/// Storage key used to store the persistent offchain worker status. -pub(crate) const OFFCHAIN_LOCK: &[u8] = b"parity/multi-phase-unsigned-election"; +/// Storage key used to store the last block number at which offchain worker ran. +pub(crate) const OFFCHAIN_LAST_BLOCK: &[u8] = b"parity/multi-phase-unsigned-election"; +/// Storage key used to store the offchain worker running status. +pub(crate) const OFFCHAIN_LOCK: &[u8] = b"parity/multi-phase-unsigned-election/lock"; /// Storage key used to cache the solution `call`. pub(crate) const OFFCHAIN_CACHED_CALL: &[u8] = b"parity/multi-phase-unsigned-election/call"; @@ -72,8 +73,6 @@ pub enum MinerError { Lock(&'static str), /// Cannot restore a solution that was not stored. NoStoredSolution, - /// Cached solution does not match the current round. - SolutionOutOfDate, /// Cached solution is not a `submit_unsigned` call. SolutionCallInvalid, /// Failed to store a solution. @@ -96,15 +95,16 @@ impl From<FeasibilityError> for MinerError { /// Save a given call into OCW storage. fn save_solution<T: Config>(call: &Call<T>) -> Result<(), MinerError> { + log!(debug, "saving a call to the offchain storage."); let storage = StorageValueRef::persistent(&OFFCHAIN_CACHED_CALL); match storage.mutate::<_, (), _>(|_| Ok(call.clone())) { Ok(Ok(_)) => Ok(()), Ok(Err(_)) => Err(MinerError::FailedToStoreSolution), Err(_) => { - // this branch should be unreachable according to the definition of `StorageValueRef::mutate`: - // that function should only ever `Err` if the closure we pass it return an error. - // however, for safety in case the definition changes, we do not optimize the branch away - // or panic. + // this branch should be unreachable according to the definition of + // `StorageValueRef::mutate`: that function should only ever `Err` if the closure we + // pass it returns an error. however, for safety in case the definition changes, we do + // not optimize the branch away or panic. Err(MinerError::FailedToStoreSolution) }, } @@ -120,10 +120,20 @@ fn restore_solution<T: Config>() -> Result<Call<T>, MinerError> { /// Clear a saved solution from OCW storage. pub(super) fn kill_ocw_solution<T: Config>() { + log!(debug, "clearing offchain call cache storage."); let mut storage = StorageValueRef::persistent(&OFFCHAIN_CACHED_CALL); storage.clear(); } +/// Clear the offchain repeat storage. +/// +/// After calling this, the next offchain worker is guaranteed to work, with respect to the +/// frequency repeat. +fn clear_offchain_repeat_frequency() { + let mut last_block = StorageValueRef::persistent(&OFFCHAIN_LAST_BLOCK); + last_block.clear(); +} + /// `true` when OCW storage contains a solution /// /// More precise than `restore_solution::<T>().is_ok()`; that invocation will return `false` @@ -137,54 +147,59 @@ impl<T: Config> Pallet<T> { /// Attempt to restore a solution from cache. Otherwise, compute it fresh. Either way, submit /// if our call's score is greater than that of the cached solution. pub fn restore_or_compute_then_maybe_submit() -> Result<(), MinerError> { - log!( - debug, - "OCW attempting to restore or compute an unsigned solution for the current election" - ); + log!(debug,"miner attempting to restore or compute an unsigned solution."); let call = restore_solution::<T>() - .and_then(|call| { - // ensure the cached call is still current before submitting - if let Call::submit_unsigned(solution, _) = &call { - // prevent errors arising from state changes in a forkful chain - Self::basic_checks(solution, "restored")?; + .and_then(|call| { + // ensure the cached call is still current before submitting + if let Call::submit_unsigned(solution, _) = &call { + // prevent errors arising from state changes in a forkful chain + Self::basic_checks(solution, "restored")?; + Ok(call) + } else { + Err(MinerError::SolutionCallInvalid) + } + }).or_else::<MinerError, _>(|error| { + log!(debug, "restoring solution failed due to {:?}", error); + match error { + MinerError::NoStoredSolution => { + log!(trace, "mining a new solution."); + // if not present or cache invalidated due to feasibility, regenerate. + // note that failing `Feasibility` can only mean that the solution was + // computed over a snapshot that has changed due to a fork. + let call = Self::mine_checked_call()?; + save_solution(&call)?; Ok(call) - } else { - Err(MinerError::SolutionCallInvalid) } - }) - .or_else::<MinerError, _>(|_| { - // if not present or cache invalidated, regenerate - let (call, _) = Self::mine_checked_call()?; - save_solution(&call)?; - Ok(call) - })?; - - // the runtime will catch it and reject the transaction if the phase is wrong, but it's - // cheap and easy to check it here to ease the workload on the runtime, so: - if !Self::current_phase().is_unsigned_open() { - // don't bother submitting; it's not an error, we're just too late. - return Ok(()); - } + MinerError::Feasibility(_) => { + log!(trace, "wiping infeasible solution."); + // kill the infeasible solution, hopefully in the next runs (whenever they + // may be) we mine a new one. + kill_ocw_solution::<T>(); + clear_offchain_repeat_frequency(); + Err(error) + }, + _ => { + // nothing to do. Return the error as-is. + Err(error) + } + } + })?; - // in case submission fails for any reason, `submit_call` kills the stored solution Self::submit_call(call) } /// Mine a new solution, cache it, and submit it back to the chain as an unsigned transaction. pub fn mine_check_save_submit() -> Result<(), MinerError> { - log!( - debug, - "OCW attempting to compute an unsigned solution for the current election" - ); + log!(debug, "miner attempting to compute an unsigned solution."); - let (call, _) = Self::mine_checked_call()?; + let call = Self::mine_checked_call()?; save_solution(&call)?; Self::submit_call(call) } /// Mine a new solution as a call. Performs all checks. - fn mine_checked_call() -> Result<(Call<T>, ElectionScore), MinerError> { + fn mine_checked_call() -> Result<Call<T>, MinerError> { let iters = Self::get_balancing_iters(); // get the solution, with a load of checks to ensure if submitted, IT IS ABSOLUTELY VALID. let (raw_solution, witness) = Self::mine_and_check(iters)?; @@ -194,38 +209,35 @@ impl<T: Config> Pallet<T> { log!( debug, - "OCW mined a solution with score {:?} and size {}", + "mined a solution with score {:?} and size {}", score, call.using_encoded(|b| b.len()) ); - Ok((call, score)) + Ok(call) } fn submit_call(call: Call<T>) -> Result<(), MinerError> { - log!( - debug, - "OCW submitting a solution as an unsigned transaction", - ); + log!(debug, "miner submitting a solution as an unsigned transaction"); SubmitTransaction::<T, Call<T>>::submit_unsigned_transaction(call.into()) - .map_err(|_| { - kill_ocw_solution::<T>(); - MinerError::PoolSubmissionFailed - }) + .map_err(|_| MinerError::PoolSubmissionFailed) } // perform basic checks of a solution's validity // // Performance: note that it internally clones the provided solution. - fn basic_checks(raw_solution: &RawSolution<CompactOf<T>>, solution_type: &str) -> Result<(), MinerError> { + fn basic_checks( + raw_solution: &RawSolution<CompactOf<T>>, + solution_type: &str, + ) -> Result<(), MinerError> { Self::unsigned_pre_dispatch_checks(raw_solution).map_err(|err| { - log!(warn, "pre-dispatch checks fialed for {} solution: {:?}", solution_type, err); + log!(debug, "pre-dispatch checks failed for {} solution: {:?}", solution_type, err); MinerError::PreDispatchChecksFailed })?; Self::feasibility_check(raw_solution.clone(), ElectionCompute::Unsigned).map_err(|err| { - log!(warn, "feasibility check failed for {} solution: {:?}", solution_type, err); + log!(debug, "feasibility check failed for {} solution: {:?}", solution_type, err); err })?; @@ -561,18 +573,18 @@ impl<T: Config> Pallet<T> { /// Checks if an execution of the offchain worker is permitted at the given block number, or /// not. /// - /// This essentially makes sure that we don't run on previous blocks in case of a re-org, and we - /// don't run twice within a window of length `threshold`. + /// This makes sure that + /// 1. we don't run on previous blocks in case of a re-org + /// 2. we don't run twice within a window of length `T::OffchainRepeat`. /// - /// Returns `Ok(())` if offchain worker should happen, `Err(reason)` otherwise. - pub(crate) fn try_acquire_offchain_lock( - now: T::BlockNumber, - ) -> Result<(), MinerError> { + /// Returns `Ok(())` if offchain worker limit is respected, `Err(reason)` otherwise. If `Ok()` + /// is returned, `now` is written in storage and will be used in further calls as the baseline. + pub(crate) fn ensure_offchain_repeat_frequency(now: T::BlockNumber) -> Result<(), MinerError> { let threshold = T::OffchainRepeat::get(); - let storage = StorageValueRef::persistent(&OFFCHAIN_LOCK); + let last_block = StorageValueRef::persistent(&OFFCHAIN_LAST_BLOCK); - let mutate_stat = - storage.mutate::<_, &'static str, _>(|maybe_head: Option<Option<T::BlockNumber>>| { + let mutate_stat = last_block.mutate::<_, &'static str, _>( + |maybe_head: Option<Option<T::BlockNumber>>| { match maybe_head { Some(Some(head)) if now < head => Err("fork."), Some(Some(head)) if now >= head && now <= head + threshold => { @@ -587,7 +599,8 @@ impl<T: Config> Pallet<T> { Ok(now) } } - }); + }, + ); match mutate_stat { // all good @@ -731,11 +744,13 @@ mod tests { mock::{ Call as OuterCall, ExtBuilder, Extrinsic, MinerMaxWeight, MultiPhase, Origin, Runtime, TestCompact, TrimHelpers, roll_to, roll_to_with_ocw, trim_helpers, witness, + UnsignedPhase, BlockNumber, System, }, }; use frame_benchmarking::Zero; use frame_support::{assert_noop, assert_ok, dispatch::Dispatchable, traits::OffchainWorker}; use sp_npos_elections::IndexAssignment; + use sp_runtime::offchain::storage_lock::{StorageLock, BlockAndTime}; use sp_runtime::{traits::ValidateUnsigned, PerU16}; type Assignment = crate::unsigned::Assignment<Runtime>; @@ -1052,7 +1067,7 @@ mod tests { } #[test] - fn ocw_check_prevent_duplicate() { + fn ocw_lock_prevents_frequent_execution() { let (mut ext, _) = ExtBuilder::default().build_offchainify(0); ext.execute_with(|| { let offchain_repeat = <Runtime as Config>::OffchainRepeat::get(); @@ -1061,21 +1076,88 @@ mod tests { assert!(MultiPhase::current_phase().is_unsigned()); // first execution -- okay. - assert!(MultiPhase::try_acquire_offchain_lock(25).is_ok()); + assert!(MultiPhase::ensure_offchain_repeat_frequency(25).is_ok()); // next block: rejected. - assert_noop!(MultiPhase::try_acquire_offchain_lock(26), MinerError::Lock("recently executed.")); + assert_noop!( + MultiPhase::ensure_offchain_repeat_frequency(26), + MinerError::Lock("recently executed.") + ); // allowed after `OFFCHAIN_REPEAT` - assert!(MultiPhase::try_acquire_offchain_lock((26 + offchain_repeat).into()).is_ok()); + assert!( + MultiPhase::ensure_offchain_repeat_frequency((26 + offchain_repeat).into()).is_ok() + ); // a fork like situation: re-execute last 3. - assert!(MultiPhase::try_acquire_offchain_lock((26 + offchain_repeat - 3).into()).is_err()); - assert!(MultiPhase::try_acquire_offchain_lock((26 + offchain_repeat - 2).into()).is_err()); - assert!(MultiPhase::try_acquire_offchain_lock((26 + offchain_repeat - 1).into()).is_err()); + assert!(MultiPhase::ensure_offchain_repeat_frequency( + (26 + offchain_repeat - 3).into() + ) + .is_err()); + assert!(MultiPhase::ensure_offchain_repeat_frequency( + (26 + offchain_repeat - 2).into() + ) + .is_err()); + assert!(MultiPhase::ensure_offchain_repeat_frequency( + (26 + offchain_repeat - 1).into() + ) + .is_err()); }) } + #[test] + fn ocw_lock_released_after_successful_execution() { + // first, ensure that a successful execution releases the lock + let (mut ext, pool) = ExtBuilder::default().build_offchainify(0); + ext.execute_with(|| { + let guard = StorageValueRef::persistent(&OFFCHAIN_LOCK); + let last_block = StorageValueRef::persistent(OFFCHAIN_LAST_BLOCK); + + roll_to(25); + assert!(MultiPhase::current_phase().is_unsigned()); + + // initially, the lock is not set. + assert!(guard.get::<bool>().is_none()); + + // a successful a-z execution. + MultiPhase::offchain_worker(25); + assert_eq!(pool.read().transactions.len(), 1); + + // afterwards, the lock is not set either.. + assert!(guard.get::<bool>().is_none()); + assert_eq!(last_block.get::<BlockNumber>().unwrap().unwrap(), 25); + }); + } + + #[test] + fn ocw_lock_prevents_overlapping_execution() { + // ensure that if the guard is in hold, a new execution is not allowed. + let (mut ext, pool) = ExtBuilder::default().build_offchainify(0); + ext.execute_with(|| { + roll_to(25); + assert!(MultiPhase::current_phase().is_unsigned()); + + // artificially set the value, as if another thread is mid-way. + let mut lock = StorageLock::<BlockAndTime<System>>::with_block_deadline( + OFFCHAIN_LOCK, + UnsignedPhase::get().saturated_into(), + ); + let guard = lock.lock(); + + // nothing submitted. + MultiPhase::offchain_worker(25); + assert_eq!(pool.read().transactions.len(), 0); + MultiPhase::offchain_worker(26); + assert_eq!(pool.read().transactions.len(), 0); + + drop(guard); + + // 🎉 ! + MultiPhase::offchain_worker(25); + assert_eq!(pool.read().transactions.len(), 1); + }); + } + #[test] fn ocw_only_runs_when_unsigned_open_now() { let (mut ext, pool) = ExtBuilder::default().build_offchainify(0); @@ -1085,7 +1167,7 @@ mod tests { // we must clear the offchain storage to ensure the offchain execution check doesn't get // in the way. - let mut storage = StorageValueRef::persistent(&OFFCHAIN_LOCK); + let mut storage = StorageValueRef::persistent(&OFFCHAIN_LAST_BLOCK); MultiPhase::offchain_worker(24); assert!(pool.read().transactions.len().is_zero()); @@ -1112,7 +1194,7 @@ mod tests { // we must clear the offchain storage to ensure the offchain execution check doesn't get // in the way. - let mut storage = StorageValueRef::persistent(&OFFCHAIN_LOCK); + let mut storage = StorageValueRef::persistent(&OFFCHAIN_LAST_BLOCK); storage.clear(); assert!(!ocw_solution_exists::<Runtime>(), "no solution should be present before we mine one"); @@ -1143,7 +1225,7 @@ mod tests { // we must clear the offchain storage to ensure the offchain execution check doesn't get // in the way. - let mut storage = StorageValueRef::persistent(&OFFCHAIN_LOCK); + let mut storage = StorageValueRef::persistent(&OFFCHAIN_LAST_BLOCK); MultiPhase::offchain_worker(block_plus(-1)); assert!(pool.read().transactions.len().is_zero()); @@ -1181,7 +1263,7 @@ mod tests { // we must clear the offchain storage to ensure the offchain execution check doesn't get // in the way. - let mut storage = StorageValueRef::persistent(&OFFCHAIN_LOCK); + let mut storage = StorageValueRef::persistent(&OFFCHAIN_LAST_BLOCK); MultiPhase::offchain_worker(block_plus(-1)); assert!(pool.read().transactions.len().is_zero()); diff --git a/substrate/primitives/runtime/src/offchain/storage_lock.rs b/substrate/primitives/runtime/src/offchain/storage_lock.rs index 4bb9799678430079fb732cc12dc35610cdb232ac..c3e63a7924d7b1a43b6e941621efce22d36c0aff 100644 --- a/substrate/primitives/runtime/src/offchain/storage_lock.rs +++ b/substrate/primitives/runtime/src/offchain/storage_lock.rs @@ -66,6 +66,7 @@ use crate::traits::AtLeast32BitUnsigned; use codec::{Codec, Decode, Encode}; use sp_core::offchain::{Duration, Timestamp}; use sp_io::offchain; +use sp_std::fmt; /// Default expiry duration for time based locks in milliseconds. const STORAGE_LOCK_DEFAULT_EXPIRY_DURATION: Duration = Duration::from_millis(20_000); @@ -173,6 +174,17 @@ impl<B: BlockNumberProvider> Default for BlockAndTimeDeadline<B> { } } +impl<B: BlockNumberProvider> fmt::Debug for BlockAndTimeDeadline<B> + where <B as BlockNumberProvider>::BlockNumber: fmt::Debug +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BlockAndTimeDeadline") + .field("block_number", &self.block_number) + .field("timestamp", &self.timestamp) + .finish() + } +} + /// Lockable based on block number and timestamp. /// /// Expiration is defined if both, block number _and_ timestamp