use crate::{
Block, BlockBuilderError, RuntimeExecutor,
inherent::InherentProvider,
strings::inherent::timestamp::{
self as strings,
slot_duration::{
PARACHAIN_FALLBACK_MS as DEFAULT_PARA_SLOT_DURATION_MS,
RELAY_CHAIN_FALLBACK_MS as DEFAULT_RELAY_SLOT_DURATION_MS,
},
},
};
use async_trait::async_trait;
use scale::{Compact, Decode, Encode};
use std::sync::atomic::{AtomicU64, Ordering};
use subxt::Metadata;
const EXTRINSIC_FORMAT_VERSION: u8 = 5;
pub struct TimestampInherent {
slot_duration_ms: u64,
cached_slot_duration: AtomicU64,
}
impl TimestampInherent {
pub fn new(slot_duration_ms: u64) -> Self {
Self { slot_duration_ms, cached_slot_duration: AtomicU64::new(0) }
}
pub fn default_relay() -> Self {
Self::new(DEFAULT_RELAY_SLOT_DURATION_MS)
}
pub fn default_para() -> Self {
Self::new(DEFAULT_PARA_SLOT_DURATION_MS)
}
pub fn timestamp_now_key() -> Vec<u8> {
let pallet_hash = sp_core::twox_128(strings::storage_keys::PALLET_NAME);
let storage_hash = sp_core::twox_128(strings::storage_keys::NOW);
[pallet_hash.as_slice(), storage_hash.as_slice()].concat()
}
fn encode_timestamp_set_call(pallet_index: u8, call_index: u8, timestamp: u64) -> Vec<u8> {
let mut call = vec![pallet_index, call_index];
call.extend(Compact(timestamp).encode());
call
}
fn encode_inherent_extrinsic(call: Vec<u8>) -> Vec<u8> {
let mut extrinsic = vec![EXTRINSIC_FORMAT_VERSION];
extrinsic.extend(call);
let len = Compact(extrinsic.len() as u32);
let mut result = len.encode();
result.extend(extrinsic);
result
}
pub async fn get_slot_duration_from_runtime(
executor: &RuntimeExecutor,
storage: &crate::LocalStorageLayer,
metadata: &Metadata,
fallback: u64,
) -> u64 {
if let Some(duration) = executor
.call(strings::slot_duration::AURA_API_METHOD, &[], storage)
.await
.ok()
.and_then(|r| u64::decode(&mut r.output.as_slice()).ok())
{
return duration;
}
if let Some(duration) = Self::get_constant_from_metadata(
metadata,
strings::slot_duration::BABE_PALLET,
strings::slot_duration::BABE_EXPECTED_BLOCK_TIME,
) {
return duration;
}
fallback
}
fn get_constant_from_metadata(
metadata: &Metadata,
pallet_name: &str,
constant_name: &str,
) -> Option<u64> {
metadata
.pallet_by_name(pallet_name)?
.constant_by_name(constant_name)
.and_then(|c| u64::decode(&mut &c.value()[..]).ok())
}
}
impl Default for TimestampInherent {
fn default() -> Self {
Self::default_relay()
}
}
#[async_trait]
impl InherentProvider for TimestampInherent {
fn identifier(&self) -> &'static str {
strings::IDENTIFIER
}
async fn provide(
&self,
parent: &Block,
executor: &RuntimeExecutor,
) -> Result<Vec<Vec<u8>>, BlockBuilderError> {
let metadata = parent.metadata().await?;
let pallet = metadata.pallet_by_name(strings::metadata::PALLET_NAME).ok_or_else(|| {
BlockBuilderError::InherentProvider {
provider: self.identifier().to_string(),
message: format!(
"{}: {}",
strings::errors::PALLET_NOT_FOUND,
strings::metadata::PALLET_NAME
),
}
})?;
let pallet_index = pallet.index();
let call_variant = pallet
.call_variant_by_name(strings::metadata::SET_CALL_NAME)
.ok_or_else(|| BlockBuilderError::InherentProvider {
provider: self.identifier().to_string(),
message: format!(
"{}: {}",
strings::errors::CALL_NOT_FOUND,
strings::metadata::SET_CALL_NAME
),
})?;
let call_index = call_variant.index;
let storage = parent.storage();
let slot_duration = match self.cached_slot_duration.load(Ordering::Acquire) {
0 => {
let duration = Self::get_slot_duration_from_runtime(
executor,
storage,
&metadata,
self.slot_duration_ms,
)
.await;
self.cached_slot_duration.store(duration, Ordering::Release);
duration
},
cached => cached,
};
let key = Self::timestamp_now_key();
let current_timestamp = match storage.get(parent.number, &key).await? {
Some(value) if value.value.is_some() => u64::decode(
&mut value
.value
.as_ref()
.expect("The match guard ensures it's Some; qed;")
.as_slice(),
)
.map_err(|e| BlockBuilderError::InherentProvider {
provider: self.identifier().to_string(),
message: format!("{}: {}", strings::errors::DECODE_FAILED, e),
})?,
_ => {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
},
};
let new_timestamp = current_timestamp.saturating_add(slot_duration);
log::debug!(
"[Timestamp] current_timestamp={current_timestamp}, slot_duration={slot_duration}, new_timestamp={new_timestamp}"
);
let call = Self::encode_timestamp_set_call(pallet_index, call_index, new_timestamp);
let extrinsic = Self::encode_inherent_extrinsic(call);
Ok(vec![extrinsic])
}
async fn warmup(&self, parent: &Block, executor: &RuntimeExecutor) {
let metadata = match parent.metadata().await {
Ok(m) => m,
Err(e) => {
log::warn!("[Timestamp] Warmup: failed to get metadata: {e}");
return;
},
};
let storage = parent.storage();
let duration = Self::get_slot_duration_from_runtime(
executor,
storage,
&metadata,
self.slot_duration_ms,
)
.await;
self.cached_slot_duration.store(duration, Ordering::Release);
log::debug!("[Timestamp] Warmup: cached slot_duration={duration}ms");
}
fn invalidate_cache(&self) {
self.cached_slot_duration.store(0, Ordering::Release);
log::debug!("[Timestamp] Cache invalidated (runtime upgrade detected)");
}
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_SLOT_DURATION_MS: u64 = 1_000;
const TEST_PALLET_INDEX: u8 = 3;
const TEST_CALL_INDEX: u8 = 0;
#[test]
fn new_creates_provider_with_slot_duration() {
let provider = TimestampInherent::new(TEST_SLOT_DURATION_MS);
assert_eq!(provider.slot_duration_ms, TEST_SLOT_DURATION_MS);
}
#[test]
fn default_relay_uses_configured_slot_duration() {
let provider = TimestampInherent::default_relay();
assert_eq!(provider.slot_duration_ms, DEFAULT_RELAY_SLOT_DURATION_MS);
}
#[test]
fn default_para_uses_configured_slot_duration() {
let provider = TimestampInherent::default_para();
assert_eq!(provider.slot_duration_ms, DEFAULT_PARA_SLOT_DURATION_MS);
}
#[test]
fn timestamp_now_key_is_32_bytes() {
let key = TimestampInherent::timestamp_now_key();
const TWOX128_OUTPUT_BYTES: usize = 16;
const STORAGE_KEY_LEN: usize = TWOX128_OUTPUT_BYTES * 2;
assert_eq!(key.len(), STORAGE_KEY_LEN);
}
#[test]
fn encode_timestamp_set_call_produces_valid_encoding() {
let call = TimestampInherent::encode_timestamp_set_call(
TEST_PALLET_INDEX,
TEST_CALL_INDEX,
1_000_000,
);
assert_eq!(call[0], TEST_PALLET_INDEX);
assert_eq!(call[1], TEST_CALL_INDEX);
assert!(call.len() > 2);
}
#[test]
fn encode_inherent_extrinsic_includes_version_and_length() {
let call = vec![TEST_PALLET_INDEX, TEST_CALL_INDEX, 1, 2, 3];
let extrinsic = TimestampInherent::encode_inherent_extrinsic(call.clone());
const EXPECTED_COMPACT_LEN: u8 = 0x18;
assert_eq!(extrinsic[0], EXPECTED_COMPACT_LEN);
assert_eq!(extrinsic[1], EXTRINSIC_FORMAT_VERSION);
assert_eq!(&extrinsic[2..], &call[..]);
}
#[test]
fn identifier_returns_timestamp() {
let provider = TimestampInherent::default();
assert_eq!(provider.identifier(), strings::IDENTIFIER);
}
mod sequential {
use super::*;
use crate::{
ForkRpcClient, LocalStorageLayer, RemoteStorageLayer, RuntimeExecutor, StorageCache,
};
use pop_common::test_env::TestNode;
use url::Url;
const ASSET_HUB_PASEO_ENDPOINTS: &[&str] = &[
"wss://sys.ibp.network/asset-hub-paseo",
"wss://sys.turboflakes.io/asset-hub-paseo",
"wss://asset-hub-paseo.dotters.network",
];
const PASEO_RELAY_ENDPOINTS: &[&str] = &[
"wss://rpc.ibp.network/paseo",
"wss://pas-rpc.stakeworld.io",
"wss://paseo.dotters.network",
];
struct LocalTestContext {
#[allow(dead_code)]
node: TestNode,
executor: RuntimeExecutor,
storage: LocalStorageLayer,
metadata: Metadata,
}
struct RemoteTestContext {
executor: RuntimeExecutor,
storage: LocalStorageLayer,
metadata: Metadata,
}
async fn create_local_context() -> LocalTestContext {
let node = TestNode::spawn().await.expect("Failed to spawn test node");
let endpoint: Url = node.ws_url().parse().expect("Invalid WebSocket URL");
let RemoteTestContext { executor, storage, metadata } =
try_create_remote_context(&endpoint).await.expect("Failed to create context");
LocalTestContext { node, executor, storage, metadata }
}
async fn try_create_remote_context(endpoint: &Url) -> Option<RemoteTestContext> {
let rpc = ForkRpcClient::connect(endpoint).await.ok()?;
let block_hash = rpc.finalized_head().await.ok()?;
let header = rpc.header(block_hash).await.ok()?;
let block_number = header.number;
let runtime_code = rpc.runtime_code(block_hash).await.ok()?;
let metadata = rpc.metadata(block_hash).await.ok()?;
let cache = StorageCache::in_memory().await.ok()?;
let remote = RemoteStorageLayer::new(rpc, cache);
let storage =
LocalStorageLayer::new(remote, block_number, block_hash, metadata.clone());
let executor = RuntimeExecutor::new(runtime_code, None).ok()?;
Some(RemoteTestContext { executor, storage, metadata })
}
async fn create_context_with_fallbacks(endpoints: &[&str]) -> Option<RemoteTestContext> {
for endpoint_str in endpoints {
let endpoint: Url = match endpoint_str.parse() {
Ok(url) => url,
Err(_) => continue,
};
println!("Trying endpoint: {endpoint_str}");
if let Some(ctx) = try_create_remote_context(&endpoint).await {
println!("Connected to: {endpoint_str}");
return Some(ctx);
}
println!("Failed to connect to: {endpoint_str}");
}
None
}
#[tokio::test(flavor = "multi_thread")]
async fn get_slot_duration_falls_back_when_aura_api_unavailable() {
let ctx = create_local_context().await;
let slot_duration = TimestampInherent::get_slot_duration_from_runtime(
&ctx.executor,
&ctx.storage,
&ctx.metadata,
DEFAULT_RELAY_SLOT_DURATION_MS,
)
.await;
println!("Slot duration (with fallback): {slot_duration}ms");
assert_eq!(
slot_duration, DEFAULT_RELAY_SLOT_DURATION_MS,
"Expected fallback to configured default since test node doesn't implement AuraApi or Babe"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn get_slot_duration_from_live_aura_chain() {
const EXPECTED_SLOT_DURATION_MS: u64 = DEFAULT_PARA_SLOT_DURATION_MS;
let ctx = match create_context_with_fallbacks(ASSET_HUB_PASEO_ENDPOINTS).await {
Some(ctx) => ctx,
None => {
eprintln!(
"Skipping test: all Asset Hub Paseo endpoints unavailable: {:?}",
ASSET_HUB_PASEO_ENDPOINTS
);
return;
},
};
let slot_duration = TimestampInherent::get_slot_duration_from_runtime(
&ctx.executor,
&ctx.storage,
&ctx.metadata,
0, )
.await;
println!("Asset Hub Paseo - slot duration: {slot_duration}ms");
assert_eq!(
slot_duration, EXPECTED_SLOT_DURATION_MS,
"Expected 12-second slots from Asset Hub Paseo via AuraApi"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn get_slot_duration_from_live_babe_chain() {
const EXPECTED_SLOT_DURATION_MS: u64 = DEFAULT_RELAY_SLOT_DURATION_MS;
let ctx = match create_context_with_fallbacks(PASEO_RELAY_ENDPOINTS).await {
Some(ctx) => ctx,
None => {
eprintln!(
"Skipping test: all Paseo relay endpoints unavailable: {:?}",
PASEO_RELAY_ENDPOINTS
);
return;
},
};
let slot_duration = TimestampInherent::get_slot_duration_from_runtime(
&ctx.executor,
&ctx.storage,
&ctx.metadata,
0, )
.await;
println!("Paseo (Babe) - slot duration: {slot_duration}ms");
assert_eq!(
slot_duration, EXPECTED_SLOT_DURATION_MS,
"Expected 6-second slots from Paseo via Babe::ExpectedBlockTime"
);
}
}
}