use crate::{
Blockchain,
rpc_server::{
RpcServerError, parse_block_hash, parse_hex_bytes,
types::{
ArchiveCallResult, ArchiveStorageDiffResult, ArchiveStorageItem, ArchiveStorageResult,
HexString, StorageDiffItem, StorageDiffQueryItem, StorageDiffType, StorageQueryItem,
StorageQueryType,
},
},
};
use jsonrpsee::{core::RpcResult, proc_macros::rpc, tracing};
use std::sync::Arc;
#[rpc(server, namespace = "archive")]
pub trait ArchiveApi {
#[method(name = "v1_finalizedHeight")]
async fn finalized_height(&self) -> RpcResult<u32>;
#[method(name = "v1_hashByHeight")]
async fn hash_by_height(&self, height: u32) -> RpcResult<Option<Vec<String>>>;
#[method(name = "v1_header")]
async fn header(&self, hash: String) -> RpcResult<Option<String>>;
#[method(name = "v1_body")]
async fn body(&self, hash: String) -> RpcResult<Option<Vec<String>>>;
#[method(name = "v1_call")]
async fn call(
&self,
hash: String,
function: String,
call_parameters: String,
) -> RpcResult<Option<ArchiveCallResult>>;
#[method(name = "v1_storage")]
async fn storage(
&self,
hash: String,
items: Vec<StorageQueryItem>,
child_trie: Option<String>,
) -> RpcResult<ArchiveStorageResult>;
#[method(name = "v1_genesisHash")]
async fn genesis_hash(&self) -> RpcResult<String>;
#[method(name = "v1_storageDiff")]
async fn storage_diff(
&self,
hash: String,
items: Vec<StorageDiffQueryItem>,
previous_hash: Option<String>,
) -> RpcResult<ArchiveStorageDiffResult>;
}
pub struct ArchiveApi {
blockchain: Arc<Blockchain>,
}
impl ArchiveApi {
pub fn new(blockchain: Arc<Blockchain>) -> Self {
Self { blockchain }
}
}
#[async_trait::async_trait]
impl ArchiveApiServer for ArchiveApi {
async fn finalized_height(&self) -> RpcResult<u32> {
Ok(self.blockchain.head_number().await)
}
async fn hash_by_height(&self, height: u32) -> RpcResult<Option<Vec<String>>> {
match self.blockchain.block_hash_at(height).await {
Ok(Some(hash)) => Ok(Some(vec![HexString::from_bytes(hash.as_bytes()).into()])),
Ok(None) => Ok(None),
Err(e) =>
Err(RpcServerError::Internal(format!("Failed to fetch block hash: {e}")).into()),
}
}
async fn header(&self, hash: String) -> RpcResult<Option<String>> {
let block_hash = parse_block_hash(&hash)?;
match self.blockchain.block_header(block_hash).await {
Ok(Some(header)) => Ok(Some(HexString::from_bytes(&header).into())),
Ok(None) => Ok(None),
Err(e) =>
Err(RpcServerError::Internal(format!("Failed to fetch block header: {e}")).into()),
}
}
async fn body(&self, hash: String) -> RpcResult<Option<Vec<String>>> {
let block_hash = parse_block_hash(&hash)?;
match self.blockchain.block_body(block_hash).await {
Ok(Some(extrinsics)) => {
let hex_extrinsics: Vec<String> =
extrinsics.iter().map(|ext| HexString::from_bytes(ext).into()).collect();
Ok(Some(hex_extrinsics))
},
Ok(None) => Ok(None),
Err(e) =>
Err(RpcServerError::Internal(format!("Failed to fetch block body: {e}")).into()),
}
}
async fn call(
&self,
hash: String,
function: String,
call_parameters: String,
) -> RpcResult<Option<ArchiveCallResult>> {
let block_hash = parse_block_hash(&hash)?;
let params = parse_hex_bytes(&call_parameters, "parameters")?;
match self.blockchain.call_at_block(block_hash, &function, ¶ms).await {
Ok(Some(result)) =>
Ok(Some(ArchiveCallResult::ok(HexString::from_bytes(&result).into()))),
Ok(None) => Ok(None), Err(e) => Ok(Some(ArchiveCallResult::err(e.to_string()))),
}
}
async fn storage(
&self,
hash: String,
items: Vec<StorageQueryItem>,
_child_trie: Option<String>,
) -> RpcResult<ArchiveStorageResult> {
let block_hash = parse_block_hash(&hash)?;
let block_number = match self.blockchain.block_number_by_hash(block_hash).await {
Ok(Some(num)) => num,
Ok(None) =>
return Ok(ArchiveStorageResult::Err { error: "Block not found".to_string() }),
Err(e) =>
return Err(RpcServerError::Internal(format!("Failed to resolve block: {e}")).into()),
};
let mut results = Vec::new();
for item in items {
let key_bytes = parse_hex_bytes(&item.key, "key")?;
match item.query_type {
StorageQueryType::ClosestDescendantMerkleValue => {
results.push(ArchiveStorageItem { key: item.key, value: None, hash: None });
continue;
},
StorageQueryType::DescendantsValues => {
tracing::debug!(
prefix = %item.key,
"archive_v1_storage: DescendantsValues query"
);
match self.blockchain.storage_keys_by_prefix(&key_bytes, block_hash).await {
Ok(keys) => {
tracing::debug!(
prefix = %item.key,
keys_found = keys.len(),
"archive_v1_storage: DescendantsValues fetching values in parallel"
);
let futs: Vec<_> = keys
.iter()
.map(|k| self.blockchain.storage_at(block_number, k))
.collect();
let values = futures::future::join_all(futs).await;
for (k, v) in keys.into_iter().zip(values) {
let value = match v {
Ok(Some(val)) => Some(HexString::from_bytes(&val).into()),
_ => None,
};
results.push(ArchiveStorageItem {
key: HexString::from_bytes(&k).into(),
value,
hash: None,
});
}
},
Err(e) => {
tracing::debug!(
prefix = %item.key,
error = %e,
"archive_v1_storage: DescendantsValues prefix lookup failed"
);
},
}
continue;
},
StorageQueryType::DescendantsHashes => {
tracing::debug!(
prefix = %item.key,
"archive_v1_storage: DescendantsHashes query"
);
match self.blockchain.storage_keys_by_prefix(&key_bytes, block_hash).await {
Ok(keys) => {
tracing::debug!(
prefix = %item.key,
keys_found = keys.len(),
"archive_v1_storage: DescendantsHashes fetching values in parallel"
);
let futs: Vec<_> = keys
.iter()
.map(|k| self.blockchain.storage_at(block_number, k))
.collect();
let values = futures::future::join_all(futs).await;
for (k, v) in keys.into_iter().zip(values) {
let hash = match v {
Ok(Some(val)) => Some(
HexString::from_bytes(&sp_core::blake2_256(&val)).into(),
),
_ => None,
};
results.push(ArchiveStorageItem {
key: HexString::from_bytes(&k).into(),
value: None,
hash,
});
}
},
Err(e) => {
tracing::debug!(
prefix = %item.key,
error = %e,
"archive_v1_storage: DescendantsHashes prefix lookup failed"
);
},
}
continue;
},
_ => {},
}
match self.blockchain.storage_at(block_number, &key_bytes).await {
Ok(Some(value)) => match item.query_type {
StorageQueryType::Value => {
results.push(ArchiveStorageItem {
key: item.key,
value: Some(HexString::from_bytes(&value).into()),
hash: None,
});
},
StorageQueryType::Hash => {
let hash = sp_core::blake2_256(&value);
results.push(ArchiveStorageItem {
key: item.key,
value: None,
hash: Some(HexString::from_bytes(&hash).into()),
});
},
StorageQueryType::ClosestDescendantMerkleValue |
StorageQueryType::DescendantsValues |
StorageQueryType::DescendantsHashes => unreachable!(),
},
Ok(None) => {
results.push(ArchiveStorageItem { key: item.key, value: None, hash: None });
},
Err(e) => {
return Err(RpcServerError::Storage(e.to_string()).into());
},
}
}
Ok(ArchiveStorageResult::Ok { items: results })
}
async fn genesis_hash(&self) -> RpcResult<String> {
self.blockchain.genesis_hash().await.map_err(|e| {
RpcServerError::Internal(format!("Failed to fetch genesis hash: {e}")).into()
})
}
async fn storage_diff(
&self,
hash: String,
items: Vec<StorageDiffQueryItem>,
previous_hash: Option<String>,
) -> RpcResult<ArchiveStorageDiffResult> {
let block_hash = parse_block_hash(&hash)?;
let block_number = match self.blockchain.block_number_by_hash(block_hash).await {
Ok(Some(num)) => num,
Ok(None) =>
return Ok(ArchiveStorageDiffResult::Err { error: "Block not found".to_string() }),
Err(e) =>
return Err(RpcServerError::Internal(format!("Failed to resolve block: {e}")).into()),
};
let prev_block_hash = match previous_hash {
Some(prev_hash_str) => parse_block_hash(&prev_hash_str)?,
None => {
match self.blockchain.block_parent_hash(block_hash).await {
Ok(Some(parent_hash)) => parent_hash,
Ok(None) =>
return Ok(ArchiveStorageDiffResult::Err {
error: "Block not found".to_string(),
}),
Err(e) =>
return Err(RpcServerError::Internal(format!(
"Failed to get parent hash: {e}"
))
.into()),
}
},
};
let prev_block_number = match self.blockchain.block_number_by_hash(prev_block_hash).await {
Ok(Some(num)) => num,
Ok(None) =>
return Ok(ArchiveStorageDiffResult::Err {
error: "Previous block not found".to_string(),
}),
Err(e) =>
return Err(RpcServerError::Internal(format!(
"Failed to resolve previous block: {e}"
))
.into()),
};
let mut results = Vec::new();
for item in items {
let key_bytes = parse_hex_bytes(&item.key, "key")?;
let current_value = match self.blockchain.storage_at(block_number, &key_bytes).await {
Ok(v) => v,
Err(e) => {
return Err(RpcServerError::Storage(e.to_string()).into());
},
};
let previous_value =
match self.blockchain.storage_at(prev_block_number, &key_bytes).await {
Ok(v) => v,
Err(e) => {
return Err(RpcServerError::Storage(e.to_string()).into());
},
};
let diff_item = match (¤t_value, &previous_value) {
(None, None) => continue,
(Some(value), None) => {
let (value_field, hash_field) = match item.return_type {
StorageQueryType::Value =>
(Some(HexString::from_bytes(value).into()), None),
StorageQueryType::Hash =>
(None, Some(HexString::from_bytes(&sp_core::blake2_256(value)).into())),
StorageQueryType::ClosestDescendantMerkleValue |
StorageQueryType::DescendantsValues |
StorageQueryType::DescendantsHashes => (Some(HexString::from_bytes(value).into()), None),
};
StorageDiffItem {
key: item.key,
value: value_field,
hash: hash_field,
diff_type: StorageDiffType::Added,
}
},
(None, Some(_)) => {
StorageDiffItem {
key: item.key,
value: None,
hash: None,
diff_type: StorageDiffType::Deleted,
}
},
(Some(curr), Some(prev)) => {
if curr == prev {
continue;
}
let (value_field, hash_field) = match item.return_type {
StorageQueryType::Value => (Some(HexString::from_bytes(curr).into()), None),
StorageQueryType::Hash =>
(None, Some(HexString::from_bytes(&sp_core::blake2_256(curr)).into())),
StorageQueryType::ClosestDescendantMerkleValue |
StorageQueryType::DescendantsValues |
StorageQueryType::DescendantsHashes => (Some(HexString::from_bytes(curr).into()), None),
};
StorageDiffItem {
key: item.key,
value: value_field,
hash: hash_field,
diff_type: StorageDiffType::Modified,
}
},
};
results.push(diff_item);
}
Ok(ArchiveStorageDiffResult::Ok { items: results })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
rpc_server::types::{ArchiveCallResult, ArchiveStorageResult},
strings::rpc_server::storage,
testing::TestContext,
};
use jsonrpsee::{core::client::ClientT, rpc_params, ws_client::WsClientBuilder};
#[tokio::test(flavor = "multi_thread")]
async fn archive_finalized_height_returns_correct_value() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let expected_block_height = ctx.blockchain().head_number().await;
let height: u32 = client
.request("archive_v1_finalizedHeight", rpc_params![])
.await
.expect("RPC call failed");
assert_eq!(height, expected_block_height);
ctx.blockchain().build_empty_block().await.unwrap();
let height: u32 = client
.request("archive_v1_finalizedHeight", rpc_params![])
.await
.expect("RPC call failed");
assert_eq!(height, expected_block_height + 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_genesis_hash_returns_valid_hash() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let hash: String = client
.request("archive_v1_genesisHash", rpc_params![])
.await
.expect("RPC call failed");
assert!(hash.starts_with("0x"), "Hash should start with 0x");
assert_eq!(hash.len(), 66, "Hash should be 0x + 64 hex chars");
let expected_hash = ctx
.blockchain()
.block_hash_at(0)
.await
.expect("Failed to get genesis hash")
.expect("Genesis block should exist");
let expected = format!("0x{}", hex::encode(expected_hash.as_bytes()));
assert_eq!(hash, expected);
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_hash_by_height_returns_hash_at_different_heights() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let block_1 = ctx.blockchain().build_empty_block().await.unwrap();
let block_2 = ctx.blockchain().build_empty_block().await.unwrap();
let fork_height = ctx.blockchain().fork_point_number();
let result: Option<Vec<String>> = client
.request("archive_v1_hashByHeight", rpc_params![fork_height])
.await
.expect("RPC call failed");
let result = result.unwrap();
assert_eq!(result.len(), 1, "Should return exactly one hash");
assert!(result[0].starts_with("0x"), "Hash should start with 0x");
let expected = format!("0x{}", hex::encode(ctx.blockchain().fork_point().as_bytes()));
assert_eq!(result[0], expected);
let result: Option<Vec<String>> = client
.request("archive_v1_hashByHeight", rpc_params![block_1.number])
.await
.expect("RPC call failed");
let result = result.unwrap();
assert_eq!(result.len(), 1, "Should return exactly one hash");
assert!(result[0].starts_with("0x"), "Hash should start with 0x");
let expected = format!("0x{}", hex::encode(block_1.hash.as_bytes()));
assert_eq!(result[0], expected);
let result: Option<Vec<String>> = client
.request("archive_v1_hashByHeight", rpc_params![block_2.number])
.await
.expect("RPC call failed");
let result = result.unwrap();
assert_eq!(result.len(), 1, "Should return exactly one hash");
assert!(result[0].starts_with("0x"), "Hash should start with 0x");
let expected = format!("0x{}", hex::encode(block_2.hash.as_bytes()));
assert_eq!(result[0], expected);
if fork_height > 0 {
let result: Option<Vec<String>> = client
.request("archive_v1_hashByHeight", rpc_params![fork_height - 1])
.await
.expect("RPC call failed");
let result = result.unwrap();
assert_eq!(result.len(), 1, "Should return exactly one hash");
assert!(result[0].starts_with("0x"), "Hash should start with 0x");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_hash_by_height_returns_none_for_unknown_height() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let result: Option<Vec<String>> = client
.request("archive_v1_hashByHeight", rpc_params![999999999u64])
.await
.expect("RPC call failed");
assert!(result.is_none(), "Should return none array for unknown height");
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_header_returns_header_for_head_hash() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
ctx.blockchain().build_empty_block().await.unwrap();
let head_hash = format!("0x{}", hex::encode(ctx.blockchain().head_hash().await.as_bytes()));
let header: Option<String> = client
.request("archive_v1_header", rpc_params![head_hash])
.await
.expect("RPC call failed");
assert!(header.is_some(), "Should return header for head hash");
let header_hex = header.unwrap();
assert!(header_hex.starts_with("0x"), "Header should be hex-encoded");
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_header_returns_none_for_unknown_hash() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let unknown_hash = "0x0000000000000000000000000000000000000000000000000000000000000001";
let header: Option<String> = client
.request("archive_v1_header", rpc_params![unknown_hash])
.await
.expect("RPC call failed");
assert!(header.is_none(), "Should return None for unknown hash");
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_header_returns_header_for_fork_point() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let fork_point_hash = format!("0x{}", hex::encode(ctx.blockchain().fork_point().0));
let header: Option<String> = client
.request("archive_v1_header", rpc_params![fork_point_hash])
.await
.expect("RPC call failed");
assert!(header.is_some(), "Should return header for fork point");
let header_hex = header.unwrap();
assert!(header_hex.starts_with("0x"), "Header should be hex-encoded");
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_header_returns_header_for_parent_block() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let block1 = ctx.blockchain().build_empty_block().await.unwrap();
let _block2 = ctx.blockchain().build_empty_block().await.unwrap();
let block1_hash = format!("0x{}", hex::encode(block1.hash.as_bytes()));
let header: Option<String> = client
.request("archive_v1_header", rpc_params![block1_hash])
.await
.expect("RPC call failed");
assert!(header.is_some(), "Should return header for parent block");
let header_hex = header.unwrap();
assert!(header_hex.starts_with("0x"), "Header should be hex-encoded");
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_header_is_idempotent_over_finalized_blocks() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
ctx.blockchain().build_empty_block().await.unwrap();
ctx.blockchain().build_empty_block().await.unwrap();
ctx.blockchain().build_empty_block().await.unwrap();
let height: u32 = client
.request("archive_v1_finalizedHeight", rpc_params![])
.await
.expect("RPC call failed");
let hash: Option<Vec<String>> = client
.request("archive_v1_hashByHeight", rpc_params![height])
.await
.expect("RPC call failed");
let hash = hash.unwrap().pop();
let header_1: Option<String> = client
.request("archive_v1_header", rpc_params![hash.clone()])
.await
.expect("RPC call failed");
let header_2: Option<String> = client
.request("archive_v1_header", rpc_params![hash])
.await
.expect("RPC call failed");
assert_eq!(header_1, header_2, "Header should be idempotent");
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_body_returns_extrinsics_for_valid_hashes() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let fork_point_hash = format!("0x{}", hex::encode(ctx.blockchain().fork_point().0));
let fork_point_body: Option<Vec<String>> = client
.request("archive_v1_body", rpc_params![fork_point_hash])
.await
.expect("RPC call failed");
ctx.blockchain().build_empty_block().await.unwrap();
ctx.blockchain().build_empty_block().await.unwrap();
ctx.blockchain().build_empty_block().await.unwrap();
let head_hash = format!("0x{}", hex::encode(ctx.blockchain().head_hash().await.as_bytes()));
let body: Option<Vec<String>> = client
.request("archive_v1_body", rpc_params![head_hash])
.await
.expect("RPC call failed");
assert_ne!(fork_point_body.unwrap(), body.unwrap());
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_body_is_idempotent_over_finalized_blocks() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
ctx.blockchain().build_empty_block().await.unwrap();
ctx.blockchain().build_empty_block().await.unwrap();
ctx.blockchain().build_empty_block().await.unwrap();
let height: u32 = client
.request("archive_v1_finalizedHeight", rpc_params![])
.await
.expect("RPC call failed");
let hash: Option<Vec<String>> = client
.request("archive_v1_hashByHeight", rpc_params![height])
.await
.expect("RPC call failed");
let hash = hash.unwrap().pop();
let body_1: Option<Vec<String>> = client
.request("archive_v1_body", rpc_params![hash.clone()])
.await
.expect("RPC call failed");
let body_2: Option<Vec<String>> = client
.request("archive_v1_body", rpc_params![hash])
.await
.expect("RPC call failed");
assert_eq!(body_1, body_2);
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_body_returns_none_for_unknown_hash() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let unknown_hash = "0x0000000000000000000000000000000000000000000000000000000000000001";
let body: Option<Vec<String>> = client
.request("archive_v1_body", rpc_params![unknown_hash])
.await
.expect("RPC call failed");
assert!(body.is_none(), "Should return None for unknown hash");
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_call_executes_runtime_api() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.request_timeout(std::time::Duration::from_secs(120))
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let head_hash = format!("0x{}", hex::encode(ctx.blockchain().head_hash().await.as_bytes()));
let result: Option<serde_json::Value> = client
.request("archive_v1_call", rpc_params![head_hash, "Core_version", "0x"])
.await
.expect("RPC call failed");
let result = result.expect("Should return result for valid block hash");
assert_eq!(result.get("success").and_then(|v| v.as_bool()), Some(true));
let value = result.get("value").and_then(|v| v.as_str());
assert!(value.is_some(), "Should have value field");
assert!(value.unwrap().starts_with("0x"), "Value should be hex-encoded");
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_call_returns_error_for_invalid_function() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.request_timeout(std::time::Duration::from_secs(120))
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let head_hash = format!("0x{}", hex::encode(ctx.blockchain().head_hash().await.as_bytes()));
let result: Option<serde_json::Value> = client
.request("archive_v1_call", rpc_params![head_hash, "NonExistent_function", "0x"])
.await
.expect("RPC call failed");
let result = result.expect("Should return result for valid block hash");
assert_eq!(result.get("success").and_then(|v| v.as_bool()), Some(false));
assert!(result.get("error").is_some(), "Should have error field");
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_call_returns_null_for_unknown_block() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let unknown_hash = "0x0000000000000000000000000000000000000000000000000000000000000001";
let result: Option<serde_json::Value> = client
.request("archive_v1_call", rpc_params![unknown_hash, "Core_version", "0x"])
.await
.expect("RPC call failed");
assert!(result.is_none(), "Should return null for unknown block hash");
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_call_executes_at_specific_block() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.request_timeout(std::time::Duration::from_secs(120))
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let fork_hash = format!("0x{}", hex::encode(ctx.blockchain().fork_point().as_bytes()));
ctx.blockchain().build_empty_block().await.unwrap();
let head_hash = format!("0x{}", hex::encode(ctx.blockchain().head_hash().await.as_bytes()));
let result_at_fork: Option<serde_json::Value> = client
.request("archive_v1_call", rpc_params![fork_hash.clone(), "Core_version", "0x"])
.await
.expect("RPC call at fork point failed");
let result_at_head: Option<serde_json::Value> = client
.request("archive_v1_call", rpc_params![head_hash, "Core_version", "0x"])
.await
.expect("RPC call at head failed");
assert!(result_at_fork.is_some(), "Should find fork point block");
assert!(result_at_head.is_some(), "Should find head block");
let fork_result = result_at_fork.unwrap();
let head_result = result_at_head.unwrap();
assert_eq!(fork_result.get("success").and_then(|v| v.as_bool()), Some(true));
assert_eq!(head_result.get("success").and_then(|v| v.as_bool()), Some(true));
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_call_rejects_invalid_hex_hash() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let result: Result<Option<serde_json::Value>, _> = client
.request("archive_v1_call", rpc_params!["not_valid_hex", "Core_version", "0x"])
.await;
assert!(result.is_err(), "Should reject invalid hex hash");
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_storage_returns_value_for_existing_key() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let head_hash = format!("0x{}", hex::encode(ctx.blockchain().head_hash().await.as_bytes()));
let mut key = Vec::new();
key.extend(sp_core::twox_128(storage::SYSTEM_PALLET));
key.extend(sp_core::twox_128(storage::NUMBER_STORAGE));
let key_hex = format!("0x{}", hex::encode(&key));
let items = vec![serde_json::json!({
"key": key_hex,
"type": "value"
})];
let result: ArchiveStorageResult = client
.request("archive_v1_storage", rpc_params![head_hash, items, Option::<String>::None])
.await
.expect("RPC call failed");
match result {
ArchiveStorageResult::Ok { items } => {
assert_eq!(items.len(), 1, "Should return one item");
assert!(items[0].value.is_some(), "Value should be present");
},
_ => panic!("Expected Ok result"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_storage_returns_none_for_nonexistent_key() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let head_hash = format!("0x{}", hex::encode(ctx.blockchain().head_hash().await.as_bytes()));
let key_hex = format!("0x{}", hex::encode(b"nonexistent_key_12345"));
let items = vec![serde_json::json!({
"key": key_hex,
"type": "value"
})];
let result: ArchiveStorageResult = client
.request("archive_v1_storage", rpc_params![head_hash, items, Option::<String>::None])
.await
.expect("RPC call failed");
match result {
ArchiveStorageResult::Ok { items } => {
assert_eq!(items.len(), 1, "Should return one item");
assert!(items[0].value.is_none(), "Value should be None for non-existent key");
},
_ => panic!("Expected Ok result"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_header_rejects_invalid_hex() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let result: Result<Option<String>, _> =
client.request("archive_v1_header", rpc_params!["not_valid_hex"]).await;
assert!(result.is_err(), "Should reject invalid hex");
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_call_rejects_invalid_hex_parameters() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let head_hash = format!("0x{}", hex::encode(ctx.blockchain().head_hash().await.as_bytes()));
let result: Result<Option<serde_json::Value>, _> = client
.request("archive_v1_call", rpc_params![head_hash, "Core_version", "not_hex"])
.await;
assert!(result.is_err(), "Should reject invalid hex parameters");
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_call_does_not_persist_storage_changes() {
use crate::{DigestItem, consensus_engine, create_next_header};
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.request_timeout(std::time::Duration::from_secs(120))
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let head = ctx.blockchain().head().await;
let head_hash = format!("0x{}", hex::encode(head.hash.as_bytes()));
let head_number = head.number;
let system_number_key: Vec<u8> = [
sp_core::twox_128(storage::SYSTEM_PALLET).as_slice(),
sp_core::twox_128(storage::NUMBER_STORAGE).as_slice(),
]
.concat();
let number_before = ctx
.blockchain()
.storage(&system_number_key)
.await
.expect("Failed to get System::Number")
.map(|v| u32::from_le_bytes(v.try_into().expect("System::Number should be 4 bytes")))
.expect("System::Number should exist");
let header = create_next_header(
&head,
vec![DigestItem::PreRuntime(consensus_engine::AURA, 0u64.to_le_bytes().to_vec())],
);
let header_hex = format!("0x{}", hex::encode(&header));
let init_result: Option<ArchiveCallResult> = client
.request("archive_v1_call", rpc_params![head_hash, "Core_initialize_block", header_hex])
.await
.expect("Core_initialize_block RPC call failed");
let init_result = init_result.expect("Block should exist");
assert!(
init_result.success,
"Core_initialize_block should succeed: {:?}",
init_result.error
);
let number_after = ctx
.blockchain()
.storage(&system_number_key)
.await
.expect("Failed to get System::Number after")
.map(|v| u32::from_le_bytes(v.try_into().expect("System::Number should be 4 bytes")))
.expect("System::Number should still exist");
assert_eq!(
number_before,
number_after,
"System::Number should NOT be modified by archive_v1_call. \
Before: {}, After: {} (would have been {} if persisted)",
number_before,
number_after,
head_number + 1
);
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_storage_returns_hash_when_requested() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let head_hash = format!("0x{}", hex::encode(ctx.blockchain().head_hash().await.as_bytes()));
let mut key = Vec::new();
key.extend(sp_core::twox_128(storage::SYSTEM_PALLET));
key.extend(sp_core::twox_128(storage::NUMBER_STORAGE));
let key_hex = format!("0x{}", hex::encode(&key));
let items = vec![serde_json::json!({
"key": key_hex,
"type": "hash"
})];
let result: ArchiveStorageResult = client
.request("archive_v1_storage", rpc_params![head_hash, items, Option::<String>::None])
.await
.expect("RPC call failed");
match result {
ArchiveStorageResult::Ok { items } => {
assert_eq!(items.len(), 1, "Should return one item");
assert!(items[0].hash.is_some(), "Hash should be present");
assert!(items[0].value.is_none(), "Value should not be present");
let hash = items[0].hash.as_ref().unwrap();
assert!(hash.starts_with("0x"), "Hash should be hex-encoded");
assert_eq!(hash.len(), 66, "Hash should be 32 bytes (0x + 64 hex chars)");
},
_ => panic!("Expected Ok result"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_storage_queries_at_specific_block() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
ctx.blockchain().build_empty_block().await.unwrap();
let block1_hash =
format!("0x{}", hex::encode(ctx.blockchain().head_hash().await.as_bytes()));
ctx.blockchain().build_empty_block().await.unwrap();
let block2_hash =
format!("0x{}", hex::encode(ctx.blockchain().head_hash().await.as_bytes()));
let mut key = Vec::new();
key.extend(sp_core::twox_128(storage::SYSTEM_PALLET));
key.extend(sp_core::twox_128(storage::NUMBER_STORAGE));
let key_hex = format!("0x{}", hex::encode(&key));
let items = vec![serde_json::json!({ "key": key_hex, "type": "value" })];
let result1: ArchiveStorageResult = client
.request(
"archive_v1_storage",
rpc_params![block1_hash, items.clone(), Option::<String>::None],
)
.await
.expect("RPC call failed");
let result2: ArchiveStorageResult = client
.request("archive_v1_storage", rpc_params![block2_hash, items, Option::<String>::None])
.await
.expect("RPC call failed");
match (result1, result2) {
(
ArchiveStorageResult::Ok { items: items1 },
ArchiveStorageResult::Ok { items: items2 },
) => {
assert_ne!(items1[0].value, items2[0].value, "Block numbers should differ");
},
_ => panic!("Expected Ok results"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_storage_returns_error_for_unknown_block() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let unknown_hash = "0x0000000000000000000000000000000000000000000000000000000000000001";
let items = vec![serde_json::json!({ "key": "0x1234", "type": "value" })];
let result: ArchiveStorageResult = client
.request("archive_v1_storage", rpc_params![unknown_hash, items, Option::<String>::None])
.await
.expect("RPC call failed");
match result {
ArchiveStorageResult::Err { error } => {
assert!(
error.contains("not found") || error.contains("Block"),
"Should indicate block not found"
);
},
_ => panic!("Expected Err result for unknown block"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_storage_diff_detects_modified_value() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let test_key = b"test_storage_diff_key";
let test_key_hex = format!("0x{}", hex::encode(test_key));
ctx.blockchain().set_storage_for_testing(test_key, Some(b"value1")).await;
let block1 = ctx.blockchain().build_empty_block().await.expect("Failed to build block");
let block1_hash = format!("0x{}", hex::encode(block1.hash.as_bytes()));
ctx.blockchain().set_storage_for_testing(test_key, Some(b"value2")).await;
let block2 = ctx.blockchain().build_empty_block().await.expect("Failed to build block");
let block2_hash = format!("0x{}", hex::encode(block2.hash.as_bytes()));
let items = vec![serde_json::json!({
"key": test_key_hex,
"returnType": "value"
})];
let result: ArchiveStorageDiffResult = client
.request("archive_v1_storageDiff", rpc_params![block2_hash, items, block1_hash])
.await
.expect("RPC call failed");
match result {
ArchiveStorageDiffResult::Ok { items } => {
assert_eq!(items.len(), 1, "Should return one modified item");
assert_eq!(items[0].key, test_key_hex);
assert_eq!(items[0].diff_type, StorageDiffType::Modified);
assert!(items[0].value.is_some(), "Value should be present");
assert_eq!(
items[0].value.as_ref().unwrap(),
&format!("0x{}", hex::encode(b"value2"))
);
},
ArchiveStorageDiffResult::Err { error } =>
panic!("Expected Ok result, got error: {error}"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_storage_diff_returns_empty_for_unchanged_keys() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let test_key = b"test_unchanged_key";
let test_key_hex = format!("0x{}", hex::encode(test_key));
ctx.blockchain()
.set_storage_for_testing(test_key, Some(b"constant_value"))
.await;
let block1 = ctx.blockchain().build_empty_block().await.expect("Failed to build block");
let block1_hash = format!("0x{}", hex::encode(block1.hash.as_bytes()));
let block2 = ctx.blockchain().build_empty_block().await.expect("Failed to build block");
let block2_hash = format!("0x{}", hex::encode(block2.hash.as_bytes()));
let items = vec![serde_json::json!({
"key": test_key_hex,
"returnType": "value"
})];
let result: ArchiveStorageDiffResult = client
.request("archive_v1_storageDiff", rpc_params![block2_hash, items, block1_hash])
.await
.expect("RPC call failed");
match result {
ArchiveStorageDiffResult::Ok { items } => {
assert!(items.is_empty(), "Should return empty for unchanged keys");
},
ArchiveStorageDiffResult::Err { error } =>
panic!("Expected Ok result, got error: {error}"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_storage_diff_returns_added_for_new_key() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let block1 = ctx.blockchain().build_empty_block().await.expect("Failed to build block");
let block1_hash = format!("0x{}", hex::encode(block1.hash.as_bytes()));
let test_key = b"test_added_key";
let test_key_hex = format!("0x{}", hex::encode(test_key));
ctx.blockchain().set_storage_for_testing(test_key, Some(b"new_value")).await;
let block2 = ctx.blockchain().build_empty_block().await.expect("Failed to build block");
let block2_hash = format!("0x{}", hex::encode(block2.hash.as_bytes()));
let items = vec![serde_json::json!({
"key": test_key_hex,
"returnType": "value"
})];
let result: ArchiveStorageDiffResult = client
.request("archive_v1_storageDiff", rpc_params![block2_hash, items, block1_hash])
.await
.expect("RPC call failed");
match result {
ArchiveStorageDiffResult::Ok { items } => {
assert_eq!(items.len(), 1, "Should return one added item");
assert_eq!(items[0].key, test_key_hex);
assert_eq!(items[0].diff_type, StorageDiffType::Added);
assert!(items[0].value.is_some(), "Value should be present");
assert_eq!(
items[0].value.as_ref().unwrap(),
&format!("0x{}", hex::encode(b"new_value"))
);
},
ArchiveStorageDiffResult::Err { error } =>
panic!("Expected Ok result, got error: {error}"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_storage_diff_returns_deleted_for_removed_key() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let test_key = b"test_deleted_key";
let test_key_hex = format!("0x{}", hex::encode(test_key));
ctx.blockchain()
.set_storage_for_testing(test_key, Some(b"will_be_deleted"))
.await;
let block1 = ctx.blockchain().build_empty_block().await.expect("Failed to build block");
let block1_hash = format!("0x{}", hex::encode(block1.hash.as_bytes()));
ctx.blockchain().set_storage_for_testing(test_key, None).await;
let block2 = ctx.blockchain().build_empty_block().await.expect("Failed to build block");
let block2_hash = format!("0x{}", hex::encode(block2.hash.as_bytes()));
let items = vec![serde_json::json!({
"key": test_key_hex,
"returnType": "value"
})];
let result: ArchiveStorageDiffResult = client
.request("archive_v1_storageDiff", rpc_params![block2_hash, items, block1_hash])
.await
.expect("RPC call failed");
match result {
ArchiveStorageDiffResult::Ok { items } => {
assert_eq!(items.len(), 1, "Should return one deleted item");
assert_eq!(items[0].key, test_key_hex);
assert_eq!(items[0].diff_type, StorageDiffType::Deleted);
assert!(items[0].value.is_none(), "Value should be None for deleted key");
assert!(items[0].hash.is_none(), "Hash should be None for deleted key");
},
ArchiveStorageDiffResult::Err { error } =>
panic!("Expected Ok result, got error: {error}"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_storage_diff_returns_hash_when_requested() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let test_key = b"test_hash_key";
let test_key_hex = format!("0x{}", hex::encode(test_key));
ctx.blockchain().set_storage_for_testing(test_key, Some(b"value1")).await;
let block1 = ctx.blockchain().build_empty_block().await.expect("Failed to build block");
let block1_hash = format!("0x{}", hex::encode(block1.hash.as_bytes()));
let new_value = b"value2";
ctx.blockchain().set_storage_for_testing(test_key, Some(new_value)).await;
let block2 = ctx.blockchain().build_empty_block().await.expect("Failed to build block");
let block2_hash = format!("0x{}", hex::encode(block2.hash.as_bytes()));
let items = vec![serde_json::json!({
"key": test_key_hex,
"returnType": "hash"
})];
let result: ArchiveStorageDiffResult = client
.request("archive_v1_storageDiff", rpc_params![block2_hash, items, block1_hash])
.await
.expect("RPC call failed");
match result {
ArchiveStorageDiffResult::Ok { items } => {
assert_eq!(items.len(), 1, "Should return one modified item");
assert_eq!(items[0].diff_type, StorageDiffType::Modified);
assert!(items[0].value.is_none(), "Value should not be present");
assert!(items[0].hash.is_some(), "Hash should be present");
let expected_hash = format!("0x{}", hex::encode(sp_core::blake2_256(new_value)));
assert_eq!(items[0].hash.as_ref().unwrap(), &expected_hash);
},
ArchiveStorageDiffResult::Err { error } =>
panic!("Expected Ok result, got error: {error}"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_storage_diff_returns_error_for_unknown_hash() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let unknown_hash = "0x0000000000000000000000000000000000000000000000000000000000000001";
let valid_hash =
format!("0x{}", hex::encode(ctx.blockchain().head_hash().await.as_bytes()));
let items = vec![serde_json::json!({ "key": "0x1234", "returnType": "value" })];
let result: ArchiveStorageDiffResult = client
.request("archive_v1_storageDiff", rpc_params![unknown_hash, items, valid_hash])
.await
.expect("RPC call failed");
match result {
ArchiveStorageDiffResult::Err { error } => {
assert!(
error.contains("not found") || error.contains("Block"),
"Should indicate block not found"
);
},
_ => panic!("Expected Err result for unknown block"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_storage_diff_returns_error_for_unknown_previous_hash() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let valid_hash =
format!("0x{}", hex::encode(ctx.blockchain().head_hash().await.as_bytes()));
let unknown_hash = "0x0000000000000000000000000000000000000000000000000000000000000001";
let items = vec![serde_json::json!({ "key": "0x1234", "returnType": "value" })];
let result: ArchiveStorageDiffResult = client
.request("archive_v1_storageDiff", rpc_params![valid_hash, items, unknown_hash])
.await
.expect("RPC call failed");
match result {
ArchiveStorageDiffResult::Err { error } => {
assert!(
error.contains("not found") || error.contains("Previous block"),
"Should indicate previous block not found"
);
},
_ => panic!("Expected Err result for unknown previous block"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_storage_diff_uses_parent_when_previous_hash_omitted() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let test_key = b"test_parent_key";
let test_key_hex = format!("0x{}", hex::encode(test_key));
ctx.blockchain().set_storage_for_testing(test_key, Some(b"parent_value")).await;
ctx.blockchain().build_empty_block().await.expect("Failed to build block");
ctx.blockchain().set_storage_for_testing(test_key, Some(b"child_value")).await;
let child_block =
ctx.blockchain().build_empty_block().await.expect("Failed to build block");
let child_hash = format!("0x{}", hex::encode(child_block.hash.as_bytes()));
let items = vec![serde_json::json!({
"key": test_key_hex,
"returnType": "value"
})];
let result: ArchiveStorageDiffResult = client
.request(
"archive_v1_storageDiff",
rpc_params![child_hash, items, Option::<String>::None],
)
.await
.expect("RPC call failed");
match result {
ArchiveStorageDiffResult::Ok { items } => {
assert_eq!(items.len(), 1, "Should return one modified item");
assert_eq!(items[0].diff_type, StorageDiffType::Modified);
assert_eq!(
items[0].value.as_ref().unwrap(),
&format!("0x{}", hex::encode(b"child_value"))
);
},
ArchiveStorageDiffResult::Err { error } =>
panic!("Expected Ok result, got error: {error}"),
}
}
#[tokio::test(flavor = "multi_thread")]
async fn archive_storage_diff_handles_multiple_items() {
let ctx = TestContext::for_rpc_server().await;
let client = WsClientBuilder::default()
.build(&ctx.ws_url())
.await
.expect("Failed to connect");
let added_key = b"test_multi_added";
let modified_key = b"test_multi_modified";
let deleted_key = b"test_multi_deleted";
let unchanged_key = b"test_multi_unchanged";
ctx.blockchain().set_storage_for_testing(modified_key, Some(b"old_value")).await;
ctx.blockchain().set_storage_for_testing(deleted_key, Some(b"to_delete")).await;
ctx.blockchain().set_storage_for_testing(unchanged_key, Some(b"constant")).await;
let block1 = ctx.blockchain().build_empty_block().await.expect("Failed to build block");
let block1_hash = format!("0x{}", hex::encode(block1.hash.as_bytes()));
ctx.blockchain().set_storage_for_testing(added_key, Some(b"new_key")).await;
ctx.blockchain().set_storage_for_testing(modified_key, Some(b"new_value")).await;
ctx.blockchain().set_storage_for_testing(deleted_key, None).await;
let block2 = ctx.blockchain().build_empty_block().await.expect("Failed to build block");
let block2_hash = format!("0x{}", hex::encode(block2.hash.as_bytes()));
let items = vec![
serde_json::json!({ "key": format!("0x{}", hex::encode(added_key)), "returnType": "value" }),
serde_json::json!({ "key": format!("0x{}", hex::encode(modified_key)), "returnType": "value" }),
serde_json::json!({ "key": format!("0x{}", hex::encode(deleted_key)), "returnType": "value" }),
serde_json::json!({ "key": format!("0x{}", hex::encode(unchanged_key)), "returnType": "value" }),
];
let result: ArchiveStorageDiffResult = client
.request("archive_v1_storageDiff", rpc_params![block2_hash, items, block1_hash])
.await
.expect("RPC call failed");
match result {
ArchiveStorageDiffResult::Ok { items } => {
assert_eq!(items.len(), 3, "Should return 3 changed items (not unchanged)");
let added = items.iter().find(|i| i.key == format!("0x{}", hex::encode(added_key)));
let modified =
items.iter().find(|i| i.key == format!("0x{}", hex::encode(modified_key)));
let deleted =
items.iter().find(|i| i.key == format!("0x{}", hex::encode(deleted_key)));
let unchanged =
items.iter().find(|i| i.key == format!("0x{}", hex::encode(unchanged_key)));
assert!(added.is_some(), "Added key should be in results");
assert_eq!(added.unwrap().diff_type, StorageDiffType::Added);
assert!(modified.is_some(), "Modified key should be in results");
assert_eq!(modified.unwrap().diff_type, StorageDiffType::Modified);
assert!(deleted.is_some(), "Deleted key should be in results");
assert_eq!(deleted.unwrap().diff_type, StorageDiffType::Deleted);
assert!(unchanged.is_none(), "Unchanged key should NOT be in results");
},
ArchiveStorageDiffResult::Err { error } =>
panic!("Expected Ok result, got error: {error}"),
}
}
}