From 0c93801cc74f914a55880fe1d0bd500c40353d06 Mon Sep 17 00:00:00 2001 From: tarikgul Date: Tue, 11 Aug 2026 14:08:13 -0400 Subject: [PATCH 1/5] perf(server): skip ring resolution when SSS allowance already exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `allocate_statement_store_allowance` resolved a LitePeople ring before scanning for a slot, so every `statement_create_proof_authorized` paid a `Members.RingKeys` page walk plus `chain_getBlockHash`, `state_getRuntimeVersion` and `CurrentRingIndex` — even when the account already held a slot for the period and nothing needed submitting. The ring is only ever used to build a proof for an extrinsic. Scan for an existing slot first under the `Ignore` policy and return the allowance secret when one is held, resolving the ring only when a submission is actually required. This matches what `allocate_bulletin_allowance` already does: it reads `TransactionStorage.Authorizations` and returns before opening a People-chain connection at all. `slot::find_allocated_slot` is the query form of `scan_slot_excluding` — it ignores free slots, so a table fully occupied by other accounts answers "no slot held" rather than erroring with `NoFreeStatementStoreSlot`. The `Increase` policy is untouched: it always wants an additional slot, so it goes straight to ring resolution as before. One semantic change worth calling out: an account that already holds a slot no longer has to prove ring membership to receive its own allowance key. Membership is what earns a slot, not what makes an already-granted slot usable, so a user whose membership lapses mid-period keeps working until the period rolls over instead of failing with `MissingLitePeopleMembership`. --- .../src/runtime/signing_host/sso_responder.rs | 25 +++++- .../src/runtime/statement_allowance/slot.rs | 80 +++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs index 0e9fa8b9..5321a543 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -872,6 +872,30 @@ pub(super) async fn allocate_statement_store_allowance( .await?, ); let metadata = fetch_metadata(&rpc).await?; + let period = statement_allowance::slot::current_period(current_unix_secs()?); + + // An allowance already recorded on chain is usable as it stands, so the + // steady state needs neither a ring proof nor a submission. Under + // `Increase` the caller wants an additional slot, so the scan is skipped. + if matches!(policy, OnExistingAllowancePolicy::Ignore) + && let Some(seq) = statement_allowance::slot::find_allocated_slot( + &rpc, + &metadata, + bandersnatch, + period, + &target, + ) + .await? + { + debug!( + %product_id, + period, + seq, + "statement-store allowance already allocated" + ); + return Ok(allowance.secret.to_bytes().to_vec()); + } + let chain_state = fetch_chain_state(&rpc).await?; let current = statement_allowance::ring::read_current_ring_index(&rpc).await?; let ring = find_including_ring(&rpc, &metadata, bandersnatch, current) @@ -879,7 +903,6 @@ pub(super) async fn allocate_statement_store_allowance( .ok_or(AllowanceAllocationError::MissingLitePeopleMembership { resource: "statement-store", })?; - let period = statement_allowance::slot::current_period(current_unix_secs()?); let outcome = register_statement_account( &rpc, &metadata, diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs index 3ba81bf6..2f73280c 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs @@ -284,6 +284,33 @@ pub async fn scan_slot_excluding( .ok_or_else(|| SlotError::NoFreeStatementStoreSlot { period, max }.into()) } +/// The slot `target` holds at `period`, if any. `entropy` is our bandersnatch +/// entropy. +/// +/// Answers only "is an allowance already in place", so unlike +/// [`scan_slot_excluding`] it ignores free slots and a fully occupied table is +/// not an error. Callers on a request path use it to avoid resolving a ring +/// (which pages through `Members.RingKeys`) when no submission is needed. +pub async fn find_allocated_slot( + rpc: &RpcClient, + metadata: &Metadata, + entropy: [u8; 32], + period: u32, + target: &[u8; 32], +) -> Result, StatementAllowanceError> { + let max = max_slots(metadata)?; + for seq in 0..max { + let alias = slot_alias(entropy, period, seq)?; + let key = statement_store_allowance_key(period, &alias); + if let Some(bytes) = rpc.get_storage(&key).await? + && entry_account_id(&bytes) == Some(*target) + { + return Ok(Some(seq)); + } + } + Ok(None) +} + /// Scan long-term-storage aliases `0..max` for `period`, returning the first /// free counter not listed in `excluded`. `entropy` is our bandersnatch entropy. pub async fn scan_long_term_storage_counter_excluding( @@ -309,8 +336,61 @@ pub async fn scan_long_term_storage_counter_excluding( #[cfg(test)] mod tests { + use subxt_rpcs::RpcClient as HostRpcClient; + + use super::super::rpc::testing::ScriptedRpc; use super::*; + /// Fixture metadata captured from paseo-next-v2; its + /// `LiteStmtStoreSlotsPerPeriod` is 10. + const FIXTURE: &[u8] = include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); + const SLOTS: usize = 10; + + /// `StmtStoreAllowanceEntry { account_id, seq: 0, since: 0 }` as a scripted + /// JSON storage result. + fn slot_entry(account: [u8; 32]) -> String { + format!(r#""0x{}""#, hex::encode((account, 0u32, 0u64).encode())) + } + + /// Run `find_allocated_slot` for `[0x22; 32]` against a scripted period + /// whose slot occupancy is `slots`. + fn scripted_find(slots: &[Option<[u8; 32]>]) -> Option { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let entries: Vec = slots + .iter() + .map(|slot| slot.map_or_else(|| "null".to_string(), slot_entry)) + .collect(); + let scripted = ScriptedRpc::new(entries.iter().map(String::as_str)); + let rpc = RpcClient::new(HostRpcClient::new(scripted)); + + futures::executor::block_on(find_allocated_slot( + &rpc, + &metadata, + [0x11; 32], + 7, + &[0x22; 32], + )) + .unwrap() + } + + #[test] + fn an_empty_period_holds_no_slot() { + assert_eq!(scripted_find(&[None; SLOTS]), None); + } + + #[test] + fn the_slot_the_target_holds_is_found() { + let mut slots = [None; SLOTS]; + slots[2] = Some([0x22; 32]); + + assert_eq!(scripted_find(&slots), Some(2)); + } + + #[test] + fn a_table_filled_by_other_accounts_is_not_an_error() { + assert_eq!(scripted_find(&[Some([0x99; 32]); SLOTS]), None); + } + #[test] fn slot_context_layout() { let ctx = derive_slot_context(7, 3); From a69fb0d44ff47455d56a2470ba312c64f0301f07 Mon Sep 17 00:00:00 2001 From: tarikgul Date: Tue, 11 Aug 2026 14:15:34 -0400 Subject: [PATCH 2/5] perf(server): cache runtime metadata per chain for allowance calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both native allowance paths downloaded the full `state_getMetadata` response on every call. For statement-store that download exists only to read one constant — `Resources.LiteStmtStoreSlotsPerPeriod`, the slot count the scan needs — so the steady state pulled the entire runtime metadata to learn a single `u32`. `MetadataCache` keys decoded metadata by genesis hash and revalidates it with `state_getRuntimeVersion`, a small request that only misses across a runtime upgrade. It lives on `RuntimeServices` next to the existing preimage and statement caches, so it is shared by every product runtime built from one host role, and is keyed rather than per-chain-wrapper so the Asset Hub PGAS path can reuse it without a second cache. Combined with scanning before ring resolution, an already-allocated statement-store product call now costs one `state_getRuntimeVersion` plus the slot scan, where it previously cost a metadata download, two chain-state reads, a ring-index read and a `Members.RingKeys` page walk on top of the same scan. `StatementStoreRpc::genesis_hash` and the `RuntimeServices` field are both gated to non-wasm targets: `statement_allowance` is native-only, so on wasm the accessor would be dead code. --- .../truapi-server/src/runtime/services.rs | 5 + .../src/runtime/signing_host/sso_responder.rs | 14 +- .../src/runtime/statement_allowance.rs | 165 ++++++++++++++++++ .../src/runtime/statement_store_rpc.rs | 8 + 4 files changed, 188 insertions(+), 4 deletions(-) diff --git a/rust/crates/truapi-server/src/runtime/services.rs b/rust/crates/truapi-server/src/runtime/services.rs index 7c6fb3c4..289fc61e 100644 --- a/rust/crates/truapi-server/src/runtime/services.rs +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -34,6 +34,9 @@ pub(crate) struct RuntimeServices { pub(crate) statement_store: StatementStoreRpc, /// In-core Bulletin submission over the configured Bulletin chain. pub(crate) bulletin: BulletinRpc, + /// Runtime metadata shared by the native allowance paths, per chain. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) metadata: crate::runtime::statement_allowance::MetadataCache, /// Values from confirmed in-core submissions, served to `lookup_subscribe` /// until the host's content backend has them. Byte-bounded, oldest-first. preimage_cache: Mutex, @@ -67,6 +70,8 @@ impl RuntimeServices { chain, statement_store, bulletin, + #[cfg(not(target_arch = "wasm32"))] + metadata: crate::runtime::statement_allowance::MetadataCache::default(), preimage_cache: Mutex::new(PreimageCache::default()), statement_cache: Mutex::new(StatementCache::default()), spawner, diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs index 5321a543..23672e1c 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -856,7 +856,7 @@ pub(super) async fn allocate_statement_store_allowance( policy: OnExistingAllowancePolicy, ) -> Result, AllowanceAllocationError> { use crate::runtime::statement_allowance::{ - self, RegistrationParams, fetch_chain_state, fetch_metadata, find_including_ring, + self, RegistrationParams, fetch_chain_state, find_including_ring, register_statement_account, }; @@ -871,7 +871,10 @@ pub(super) async fn allocate_statement_store_allowance( .client("statement-store allowance") .await?, ); - let metadata = fetch_metadata(&rpc).await?; + let metadata = services + .metadata + .get(&rpc, services.statement_store.genesis_hash()) + .await?; let period = statement_allowance::slot::current_period(current_unix_secs()?); // An allowance already recorded on chain is usable as it stands, so the @@ -949,7 +952,7 @@ pub(super) async fn allocate_bulletin_allowance( policy: OnExistingAllowancePolicy, ) -> Result, AllowanceAllocationError> { use crate::runtime::statement_allowance::{ - self, claim_long_term_storage, fetch_bulletin_allowance, fetch_chain_state, fetch_metadata, + self, claim_long_term_storage, fetch_bulletin_allowance, fetch_chain_state, find_including_ring, wait_bulletin_authorization, }; @@ -980,7 +983,10 @@ pub(super) async fn allocate_bulletin_allowance( .client("bulletin allowance claim") .await?, ); - let metadata = fetch_metadata(&people_rpc).await?; + let metadata = services + .metadata + .get(&people_rpc, services.statement_store.genesis_hash()) + .await?; let chain_state = fetch_chain_state(&people_rpc).await?; let bandersnatch = derive_lite_person_ring_vrf_entropy(&entropy); let current = statement_allowance::ring::read_current_ring_index(&people_rpc).await?; diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index ba9f71e8..fae6abd8 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -13,6 +13,8 @@ pub mod ring; pub mod rpc; pub mod slot; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use futures::FutureExt; @@ -113,6 +115,65 @@ pub async fn fetch_metadata(rpc: &RpcClient) -> Result Result { + let runtime = rpc.call("state_getRuntimeVersion", json!([])).await?; + json_u32(&runtime, "specVersion") +} + +/// Metadata held for one chain, valid while its spec version is unchanged. +struct CachedMetadata { + spec_version: u32, + metadata: Arc, +} + +/// Runtime metadata cached per chain. +/// +/// A full `state_getMetadata` response is large and only changes across a +/// runtime upgrade, so entries are keyed by genesis hash and revalidated with +/// `state_getRuntimeVersion` — one small request instead of a fresh download on +/// every allowance call. +#[derive(Default)] +pub struct MetadataCache { + entries: Mutex>, +} + +impl MetadataCache { + /// Metadata for the chain at `genesis_hash`, downloaded only when no entry + /// matches the chain's current spec version. + pub async fn get( + &self, + rpc: &RpcClient, + genesis_hash: [u8; 32], + ) -> Result, StatementAllowanceError> { + let spec_version = fetch_spec_version(rpc).await?; + if let Some(cached) = self.cached(genesis_hash, spec_version) { + return Ok(cached); + } + let metadata = Arc::new(fetch_metadata(rpc).await?); + self.entries + .lock() + .expect("metadata cache mutex poisoned") + .insert( + genesis_hash, + CachedMetadata { + spec_version, + metadata: Arc::clone(&metadata), + }, + ); + Ok(metadata) + } + + fn cached(&self, genesis_hash: [u8; 32], spec_version: u32) -> Option> { + self.entries + .lock() + .expect("metadata cache mutex poisoned") + .get(&genesis_hash) + .filter(|cached| cached.spec_version == spec_version) + .map(|cached| Arc::clone(&cached.metadata)) + } +} + /// Fetch the chain state needed to fill the signed extensions. pub async fn fetch_chain_state(rpc: &RpcClient) -> Result { let genesis_hex = rpc.call("chain_getBlockHash", json!([0])).await?; @@ -604,6 +665,110 @@ mod tests { ); } + /// A `state_getRuntimeVersion` result for `spec_version`. + fn runtime_version(spec_version: u32) -> String { + format!(r#"{{"specVersion":{spec_version},"transactionVersion":1}}"#) + } + + /// The fixture metadata as a `state_getMetadata` hex result. + fn metadata_result() -> String { + format!(r#""0x{}""#, hex::encode(FIXTURE)) + } + + /// Method names the scripted transport saw, in order. + fn methods(scripted: &ScriptedRpc) -> Vec { + scripted + .calls() + .into_iter() + .map(|(method, _)| method) + .collect() + } + + /// Drive `MetadataCache::get` over `responses`, fetching each entry in + /// `chains` in turn, and report the methods the transport saw. + fn scripted_cache_run(responses: &[String], chains: &[[u8; 32]]) -> Vec { + let scripted = ScriptedRpc::new(responses.iter().map(String::as_str)); + let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); + let cache = MetadataCache::default(); + + futures::executor::block_on(async { + for genesis_hash in chains { + cache + .get(&rpc, *genesis_hash) + .await + .expect("scripted metadata fetch succeeds"); + } + }); + methods(&scripted) + } + + #[test] + fn metadata_is_downloaded_once_per_spec_version() { + let seen = scripted_cache_run( + &[ + runtime_version(1_000_000), + metadata_result(), + runtime_version(1_000_000), + ], + &[[0xaa; 32], [0xaa; 32]], + ); + + assert_eq!( + seen, + [ + "state_getRuntimeVersion", + "state_getMetadata", + "state_getRuntimeVersion", + ] + ); + } + + #[test] + fn a_spec_version_bump_refreshes_the_entry() { + let seen = scripted_cache_run( + &[ + runtime_version(1_000_000), + metadata_result(), + runtime_version(1_000_001), + metadata_result(), + ], + &[[0xaa; 32], [0xaa; 32]], + ); + + assert_eq!( + seen, + [ + "state_getRuntimeVersion", + "state_getMetadata", + "state_getRuntimeVersion", + "state_getMetadata", + ] + ); + } + + #[test] + fn chains_do_not_share_a_cache_entry() { + let seen = scripted_cache_run( + &[ + runtime_version(1_000_000), + metadata_result(), + runtime_version(1_000_000), + metadata_result(), + ], + &[[0xaa; 32], [0xbb; 32]], + ); + + assert_eq!( + seen, + [ + "state_getRuntimeVersion", + "state_getMetadata", + "state_getRuntimeVersion", + "state_getMetadata", + ] + ); + } + /// `StmtStoreAllowanceEntry { account_id, seq: 0, since: 0 }` as a scripted /// JSON storage result. fn slot_entry(account: [u8; 32]) -> String { diff --git a/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs b/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs index 5a1d2755..dc6572a2 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs @@ -63,6 +63,14 @@ impl StatementStoreRpc { } } + /// Genesis hash of the People chain this helper talks to. + /// + /// Keys the shared metadata cache used by the native allowance paths. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn genesis_hash(&self) -> [u8; 32] { + self.people_chain_genesis_hash + } + /// Open a statement-store RPC client over the host-provided People-chain /// connection. pub(super) async fn client( From cd6680b5885067382c804948ddea0f5b92374326 Mon Sep 17 00:00:00 2001 From: tarikgul Date: Tue, 11 Aug 2026 14:28:57 -0400 Subject: [PATCH 3/5] perf(server): cache chain state with metadata and verify the genesis hash The metadata cache validated its entry with `state_getRuntimeVersion` and then `fetch_chain_state` asked for the same runtime version again, so the submission path made two identical requests. Metadata and `ChainState` are both fixed for a given runtime, so one entry holds both: `MetadataCache` becomes `ChainContextCache` returning `ChainContext { metadata, state }`, and the allowance paths no longer call `fetch_chain_state` at all. `fetch_chain_state` keeps its signature for `truapi-host-cli` and is now composed from `fetch_genesis_hash` and `fetch_runtime_version`, the same two requests in the same order. Filling `ChainState` from the cache key also closes a gap: the genesis hash baked into every allowance extrinsic came from `chain_getBlockHash(0)` on whatever connection the host supplied, and nothing checked it against the chain the caller asked for. A host that wires `connect()` to the wrong chain produced extrinsics signed for that chain, failing with an opaque validity error. `ChainContextCache::get` now compares the two and reports `GenesisHashMismatch` before downloading metadata. Per allowance-gated product call, the steady state is one `state_getRuntimeVersion` plus the slot scan; a submission adds the ring walk and the extrinsic. Neither re-reads metadata or the genesis hash while the runtime is unchanged. --- .../truapi-server/src/runtime/services.rs | 7 +- .../src/runtime/signing_host/sso_responder.rs | 34 ++- .../src/runtime/statement_allowance.rs | 223 +++++++++++------- 3 files changed, 156 insertions(+), 108 deletions(-) diff --git a/rust/crates/truapi-server/src/runtime/services.rs b/rust/crates/truapi-server/src/runtime/services.rs index 289fc61e..7288c258 100644 --- a/rust/crates/truapi-server/src/runtime/services.rs +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -34,9 +34,10 @@ pub(crate) struct RuntimeServices { pub(crate) statement_store: StatementStoreRpc, /// In-core Bulletin submission over the configured Bulletin chain. pub(crate) bulletin: BulletinRpc, - /// Runtime metadata shared by the native allowance paths, per chain. + /// Runtime metadata and chain state shared by the native allowance + /// paths, per chain. #[cfg(not(target_arch = "wasm32"))] - pub(crate) metadata: crate::runtime::statement_allowance::MetadataCache, + pub(crate) chain_context: crate::runtime::statement_allowance::ChainContextCache, /// Values from confirmed in-core submissions, served to `lookup_subscribe` /// until the host's content backend has them. Byte-bounded, oldest-first. preimage_cache: Mutex, @@ -71,7 +72,7 @@ impl RuntimeServices { statement_store, bulletin, #[cfg(not(target_arch = "wasm32"))] - metadata: crate::runtime::statement_allowance::MetadataCache::default(), + chain_context: crate::runtime::statement_allowance::ChainContextCache::default(), preimage_cache: Mutex::new(PreimageCache::default()), statement_cache: Mutex::new(StatementCache::default()), spawner, diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs index 23672e1c..83d82c2b 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -856,8 +856,7 @@ pub(super) async fn allocate_statement_store_allowance( policy: OnExistingAllowancePolicy, ) -> Result, AllowanceAllocationError> { use crate::runtime::statement_allowance::{ - self, RegistrationParams, fetch_chain_state, find_including_ring, - register_statement_account, + self, RegistrationParams, find_including_ring, register_statement_account, }; let entropy = signing_host.root_entropy()?; @@ -871,8 +870,8 @@ pub(super) async fn allocate_statement_store_allowance( .client("statement-store allowance") .await?, ); - let metadata = services - .metadata + let chain = services + .chain_context .get(&rpc, services.statement_store.genesis_hash()) .await?; let period = statement_allowance::slot::current_period(current_unix_secs()?); @@ -883,7 +882,7 @@ pub(super) async fn allocate_statement_store_allowance( if matches!(policy, OnExistingAllowancePolicy::Ignore) && let Some(seq) = statement_allowance::slot::find_allocated_slot( &rpc, - &metadata, + &chain.metadata, bandersnatch, period, &target, @@ -899,17 +898,16 @@ pub(super) async fn allocate_statement_store_allowance( return Ok(allowance.secret.to_bytes().to_vec()); } - let chain_state = fetch_chain_state(&rpc).await?; let current = statement_allowance::ring::read_current_ring_index(&rpc).await?; - let ring = find_including_ring(&rpc, &metadata, bandersnatch, current) + let ring = find_including_ring(&rpc, &chain.metadata, bandersnatch, current) .await? .ok_or(AllowanceAllocationError::MissingLitePeopleMembership { resource: "statement-store", })?; let outcome = register_statement_account( &rpc, - &metadata, - &chain_state, + &chain.metadata, + &chain.state, bandersnatch, RegistrationParams { target: &target, @@ -952,8 +950,8 @@ pub(super) async fn allocate_bulletin_allowance( policy: OnExistingAllowancePolicy, ) -> Result, AllowanceAllocationError> { use crate::runtime::statement_allowance::{ - self, claim_long_term_storage, fetch_bulletin_allowance, fetch_chain_state, - find_including_ring, wait_bulletin_authorization, + self, claim_long_term_storage, fetch_bulletin_allowance, find_including_ring, + wait_bulletin_authorization, }; let entropy = signing_host.root_entropy()?; @@ -983,27 +981,27 @@ pub(super) async fn allocate_bulletin_allowance( .client("bulletin allowance claim") .await?, ); - let metadata = services - .metadata + let chain = services + .chain_context .get(&people_rpc, services.statement_store.genesis_hash()) .await?; - let chain_state = fetch_chain_state(&people_rpc).await?; let bandersnatch = derive_lite_person_ring_vrf_entropy(&entropy); let current = statement_allowance::ring::read_current_ring_index(&people_rpc).await?; - let ring = find_including_ring(&people_rpc, &metadata, bandersnatch, current) + let ring = find_including_ring(&people_rpc, &chain.metadata, bandersnatch, current) .await? .ok_or(AllowanceAllocationError::MissingLitePeopleMembership { resource: "Bulletin", })?; - let period_duration = statement_allowance::slot::long_term_storage_period_duration(&metadata)?; + let period_duration = + statement_allowance::slot::long_term_storage_period_duration(&chain.metadata)?; let period = statement_allowance::slot::current_long_term_storage_period( current_unix_secs()?, period_duration, )?; let outcome = claim_long_term_storage( &people_rpc, - &metadata, - &chain_state, + &chain.metadata, + &chain.state, bandersnatch, &target, period, diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index fae6abd8..b449a5f4 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -72,6 +72,14 @@ pub enum ChainStateError { /// Actual decoded length. len: usize, }, + /// The connected chain is not the one the caller asked for. + #[error("chain reports genesis 0x{actual}, expected 0x{expected}")] + GenesisHashMismatch { + /// Genesis hash the caller asked for. + expected: String, + /// Genesis hash the connected chain reported. + actual: String, + }, /// Runtime JSON lacked an expected u32 field. #[error("missing/invalid {field}")] MissingU32Field { @@ -115,90 +123,112 @@ pub async fn fetch_metadata(rpc: &RpcClient) -> Result Result { +/// Read the chain's runtime `(specVersion, transactionVersion)`. +pub async fn fetch_runtime_version(rpc: &RpcClient) -> Result<(u32, u32), StatementAllowanceError> { let runtime = rpc.call("state_getRuntimeVersion", json!([])).await?; - json_u32(&runtime, "specVersion") + Ok(( + json_u32(&runtime, "specVersion")?, + json_u32(&runtime, "transactionVersion")?, + )) } -/// Metadata held for one chain, valid while its spec version is unchanged. -struct CachedMetadata { - spec_version: u32, - metadata: Arc, +/// Read the chain's genesis block hash. +pub async fn fetch_genesis_hash(rpc: &RpcClient) -> Result<[u8; 32], StatementAllowanceError> { + let genesis_hex = rpc.call("chain_getBlockHash", json!([0])).await?; + let genesis_str = genesis_hex + .as_str() + .ok_or(ChainStateError::GenesisHashNotString)?; + let genesis = hex::decode(genesis_str.strip_prefix("0x").unwrap_or(genesis_str)) + .map_err(ChainStateError::GenesisHex)?; + let len = genesis.len(); + genesis + .try_into() + .map_err(|_| ChainStateError::GenesisHashLength { len }.into()) } -/// Runtime metadata cached per chain. +/// Fetch the chain state needed to fill the signed extensions. +pub async fn fetch_chain_state(rpc: &RpcClient) -> Result { + let genesis_hash = fetch_genesis_hash(rpc).await?; + let (spec_version, transaction_version) = fetch_runtime_version(rpc).await?; + Ok(ChainState { + spec_version, + transaction_version, + genesis_hash, + nonce: 0, + }) +} + +/// Runtime metadata and signed-extension chain state for one chain. +#[derive(Clone)] +pub struct ChainContext { + /// Decoded runtime metadata. + pub metadata: Arc, + /// Chain state filling the standard signed extensions. + pub state: ChainState, +} + +/// Runtime metadata and chain state cached per chain. /// -/// A full `state_getMetadata` response is large and only changes across a -/// runtime upgrade, so entries are keyed by genesis hash and revalidated with -/// `state_getRuntimeVersion` — one small request instead of a fresh download on -/// every allowance call. +/// Both are fixed for a given runtime, and a full `state_getMetadata` response +/// is large, so entries are keyed by genesis hash and revalidated with +/// `state_getRuntimeVersion` — one small request in place of a metadata +/// download and a genesis read on every allowance call. #[derive(Default)] -pub struct MetadataCache { - entries: Mutex>, +pub struct ChainContextCache { + entries: Mutex>, } -impl MetadataCache { - /// Metadata for the chain at `genesis_hash`, downloaded only when no entry - /// matches the chain's current spec version. +impl ChainContextCache { + /// Metadata and chain state for the chain at `genesis_hash`, read from the + /// chain only when no entry matches its current spec version. + /// + /// Fails when the connected chain reports a genesis hash other than + /// `genesis_hash`: the host has wired this client to the wrong chain, and + /// extrinsics built against it would be signed for a chain the caller did + /// not ask for. pub async fn get( &self, rpc: &RpcClient, genesis_hash: [u8; 32], - ) -> Result, StatementAllowanceError> { - let spec_version = fetch_spec_version(rpc).await?; + ) -> Result { + let (spec_version, transaction_version) = fetch_runtime_version(rpc).await?; if let Some(cached) = self.cached(genesis_hash, spec_version) { return Ok(cached); } - let metadata = Arc::new(fetch_metadata(rpc).await?); + let reported = fetch_genesis_hash(rpc).await?; + if reported != genesis_hash { + return Err(ChainStateError::GenesisHashMismatch { + expected: hex::encode(genesis_hash), + actual: hex::encode(reported), + } + .into()); + } + let context = ChainContext { + metadata: Arc::new(fetch_metadata(rpc).await?), + state: ChainState { + spec_version, + transaction_version, + genesis_hash, + nonce: 0, + }, + }; self.entries .lock() - .expect("metadata cache mutex poisoned") - .insert( - genesis_hash, - CachedMetadata { - spec_version, - metadata: Arc::clone(&metadata), - }, - ); - Ok(metadata) + .expect("chain context cache mutex poisoned") + .insert(genesis_hash, context.clone()); + Ok(context) } - fn cached(&self, genesis_hash: [u8; 32], spec_version: u32) -> Option> { + fn cached(&self, genesis_hash: [u8; 32], spec_version: u32) -> Option { self.entries .lock() - .expect("metadata cache mutex poisoned") + .expect("chain context cache mutex poisoned") .get(&genesis_hash) - .filter(|cached| cached.spec_version == spec_version) - .map(|cached| Arc::clone(&cached.metadata)) + .filter(|cached| cached.state.spec_version == spec_version) + .cloned() } } -/// Fetch the chain state needed to fill the signed extensions. -pub async fn fetch_chain_state(rpc: &RpcClient) -> Result { - let genesis_hex = rpc.call("chain_getBlockHash", json!([0])).await?; - let genesis_str = genesis_hex - .as_str() - .ok_or(ChainStateError::GenesisHashNotString)?; - let genesis = hex::decode(genesis_str.strip_prefix("0x").unwrap_or(genesis_str)) - .map_err(ChainStateError::GenesisHex)?; - let len = genesis.len(); - let genesis_hash: [u8; 32] = genesis - .try_into() - .map_err(|_| ChainStateError::GenesisHashLength { len })?; - - let runtime = rpc.call("state_getRuntimeVersion", json!([])).await?; - let spec_version = json_u32(&runtime, "specVersion")?; - let transaction_version = json_u32(&runtime, "transactionVersion")?; - - Ok(ChainState { - spec_version, - transaction_version, - genesis_hash, - nonce: 0, - }) -} - /// Read a u32 field from a JSON object. fn json_u32(value: &Value, field: &'static str) -> Result { value @@ -675,6 +705,11 @@ mod tests { format!(r#""0x{}""#, hex::encode(FIXTURE)) } + /// A `chain_getBlockHash(0)` result for `genesis_hash`. + fn genesis_result(genesis_hash: [u8; 32]) -> String { + format!(r#""0x{}""#, hex::encode(genesis_hash)) + } + /// Method names the scripted transport saw, in order. fn methods(scripted: &ScriptedRpc) -> Vec { scripted @@ -684,43 +719,46 @@ mod tests { .collect() } - /// Drive `MetadataCache::get` over `responses`, fetching each entry in + /// The requests one cache miss makes, in order. + const MISS: [&str; 3] = [ + "state_getRuntimeVersion", + "chain_getBlockHash", + "state_getMetadata", + ]; + /// The requests one cache hit makes. + const HIT: [&str; 1] = ["state_getRuntimeVersion"]; + + /// Drive `ChainContextCache::get` over `responses`, fetching each entry in /// `chains` in turn, and report the methods the transport saw. fn scripted_cache_run(responses: &[String], chains: &[[u8; 32]]) -> Vec { let scripted = ScriptedRpc::new(responses.iter().map(String::as_str)); let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); - let cache = MetadataCache::default(); + let cache = ChainContextCache::default(); futures::executor::block_on(async { for genesis_hash in chains { cache .get(&rpc, *genesis_hash) .await - .expect("scripted metadata fetch succeeds"); + .expect("scripted chain context fetch succeeds"); } }); methods(&scripted) } #[test] - fn metadata_is_downloaded_once_per_spec_version() { + fn a_chain_is_read_once_per_spec_version() { let seen = scripted_cache_run( &[ runtime_version(1_000_000), + genesis_result([0xaa; 32]), metadata_result(), runtime_version(1_000_000), ], &[[0xaa; 32], [0xaa; 32]], ); - assert_eq!( - seen, - [ - "state_getRuntimeVersion", - "state_getMetadata", - "state_getRuntimeVersion", - ] - ); + assert_eq!(seen, [MISS.as_slice(), HIT.as_slice()].concat()); } #[test] @@ -728,22 +766,16 @@ mod tests { let seen = scripted_cache_run( &[ runtime_version(1_000_000), + genesis_result([0xaa; 32]), metadata_result(), runtime_version(1_000_001), + genesis_result([0xaa; 32]), metadata_result(), ], &[[0xaa; 32], [0xaa; 32]], ); - assert_eq!( - seen, - [ - "state_getRuntimeVersion", - "state_getMetadata", - "state_getRuntimeVersion", - "state_getMetadata", - ] - ); + assert_eq!(seen, [MISS, MISS].concat()); } #[test] @@ -751,22 +783,39 @@ mod tests { let seen = scripted_cache_run( &[ runtime_version(1_000_000), + genesis_result([0xaa; 32]), metadata_result(), runtime_version(1_000_000), + genesis_result([0xbb; 32]), metadata_result(), ], &[[0xaa; 32], [0xbb; 32]], ); - assert_eq!( - seen, - [ - "state_getRuntimeVersion", - "state_getMetadata", - "state_getRuntimeVersion", - "state_getMetadata", - ] + assert_eq!(seen, [MISS, MISS].concat()); + } + + #[test] + fn a_chain_reporting_another_genesis_is_rejected() { + let responses = [ + runtime_version(1_000_000), + genesis_result([0xbb; 32]), + metadata_result(), + ]; + let scripted = ScriptedRpc::new(responses.iter().map(String::as_str)); + let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); + let cache = ChainContextCache::default(); + + let Err(err) = futures::executor::block_on(cache.get(&rpc, [0xaa; 32])) else { + panic!("a chain reporting another genesis must be rejected"); + }; + + assert!( + err.to_string().contains("expected 0xaaaa"), + "unexpected error: {err}" ); + // The mismatch is caught before the metadata download. + assert_eq!(methods(&scripted), MISS[..2]); } /// `StmtStoreAllowanceEntry { account_id, seq: 0, since: 0 }` as a scripted From 618667d5cb8fb2494474429499aaf0e8ebe84180 Mon Sep 17 00:00:00 2001 From: tarikgul Date: Tue, 11 Aug 2026 14:29:49 -0400 Subject: [PATCH 4/5] docs(server): note the per-chain scope of the allowance chain cache Records why the cache needs no eviction policy (one entry per configured chain, not per call) and corrects the crate README, which said the allowance paths need no metadata. --- rust/crates/truapi-server/README.md | 4 +++- rust/crates/truapi-server/src/runtime/statement_allowance.rs | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index dffb7110..1a116045 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -198,7 +198,9 @@ role-specific lifecycle, so no method exists on a role that can't mean it: personhood. Extrinsic-payload signing and v4 transaction construction work from pre-encoded payload fields, so no chain metadata is needed; statement-store and Bulletin allowance allocation are native-only (wasm - builds report them as unavailable). + builds report them as unavailable) and do need metadata, which they take from + the `RuntimeServices`-owned per-chain cache rather than re-reading it per + call. `host_logic` stays pure: the orchestrators above call into it for codecs, session/SSO crypto, key derivation, and permission policy, while all I/O diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index b449a5f4..5c2d6fdb 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -173,6 +173,9 @@ pub struct ChainContext { /// is large, so entries are keyed by genesis hash and revalidated with /// `state_getRuntimeVersion` — one small request in place of a metadata /// download and a genesis read on every allowance call. +/// +/// One entry per chain the host is configured for, so the map needs no eviction +/// policy: it is bounded by that chain set, not by call volume. #[derive(Default)] pub struct ChainContextCache { entries: Mutex>, From 9ab10b63dc411a8df7815402861a0329d76f9011 Mon Sep 17 00:00:00 2001 From: tarikgul Date: Tue, 11 Aug 2026 14:54:11 -0400 Subject: [PATCH 5/5] fix(server): trust the chain's genesis hash, not the host's constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testing against the live paseo-next-v2 People chain showed the previous commit's genesis check was wrong in both directions. `network.rs` configures the People genesis as `c5af1826…`, but the chain reports `89a63b11…`; Asset Hub has likewise diverged (`bf0488db…` configured, `23e730eb…` live). Only Bulletin still matches. The testnet was wiped and the constants were never refreshed — the exact failure RFC-0026 exists to remove. So a divergence does not mean the host connected to the wrong chain, and rejecting it broke the allowance path on a network where it had been working. It also had the polarity backwards: `CheckGenesis` is signed over `ChainState.genesis_hash`, so the value must come from the chain. Using the configured constant would have produced extrinsics no node accepts. `ChainContextCache::get` now keys entries by the caller's constant, since that is the identity it routes connections by, and fills `ChainState` from what the chain reports. A divergence is logged rather than fatal. That split also exposed a cache bug the live run confirmed: the insert keyed by the reported hash while the lookup used the configured one, so on any network with a stale constant the cache missed on every call — silently undoing the previous commit. `a_stale_configured_genesis_still_keys_the_cache` covers it. `tests/live_people_chain.rs` holds the checks that found this, `#[ignore]`d so `cargo test` stays offline: cargo +nightly test -p truapi-host-cli --test live_people_chain \ -- --ignored --nocapture They confirm the reported genesis reaches `ChainState`, that a second read hits the cache (`Arc::ptr_eq` on the metadata), that `find_allocated_slot` scans a live period without erroring, and that live spec 1000032 still exposes the `AsResources` variant indices the offline fixture pins at spec 1000000 — (2, 1) and (3, 1) in both. --- .../tests/live_people_chain.rs | 143 ++++++++++++++++++ .../src/runtime/statement_allowance.rs | 77 +++++----- 2 files changed, 185 insertions(+), 35 deletions(-) create mode 100644 rust/crates/truapi-host-cli/tests/live_people_chain.rs diff --git a/rust/crates/truapi-host-cli/tests/live_people_chain.rs b/rust/crates/truapi-host-cli/tests/live_people_chain.rs new file mode 100644 index 00000000..466c60ca --- /dev/null +++ b/rust/crates/truapi-host-cli/tests/live_people_chain.rs @@ -0,0 +1,143 @@ +//! Live People-chain checks for the native allowance chain reads. +//! +//! Ignored by default: these need network access and a reachable testnet, so +//! `cargo test` stays offline and deterministic. Run them explicitly: +//! +//! ```bash +//! cargo +nightly test -p truapi-host-cli --test live_people_chain -- --ignored --nocapture +//! ``` +//! +//! `TRUAPI_LIVE_PEOPLE_WS` overrides the endpoint. The default is the +//! `paseo-next-v2` People chain, matching `network.rs`. +//! +//! These read chain state only; nothing here submits an extrinsic. + +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use truapi_server::statement_allowance::{self as alloc, ChainContextCache}; + +/// Default People-chain endpoint, kept in step with `network.rs`. +const DEFAULT_PEOPLE_WS: &str = "wss://paseo-people-next-system-rpc.polkadot.io"; + +/// A genesis hash no chain will report, standing in for a host whose configured +/// constant has gone stale after a testnet wipe. +const STALE_CONFIGURED_GENESIS: [u8; 32] = [0xff; 32]; + +fn people_ws() -> String { + std::env::var("TRUAPI_LIVE_PEOPLE_WS").unwrap_or_else(|_| DEFAULT_PEOPLE_WS.to_string()) +} + +async fn connect() -> alloc::rpc::RpcClient { + alloc::rpc::RpcClient::connect(&people_ws()) + .await + .expect("connect to the live People chain") +} + +fn current_period() -> u32 { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock after UNIX epoch") + .as_secs(); + alloc::slot::current_period(now) +} + +/// The genesis hash signed into allowance extrinsics must be the one the chain +/// reports, not the caller's constant, and the entry must still be keyed by that +/// constant so the cache actually hits. +#[tokio::test] +#[ignore = "needs network access to a live People chain"] +async fn chain_context_reports_the_chains_genesis_and_caches_by_the_configured_hash() { + let rpc = connect().await; + let live = alloc::fetch_genesis_hash(&rpc) + .await + .expect("read the live genesis hash"); + assert_ne!( + live, STALE_CONFIGURED_GENESIS, + "the stand-in stale hash must not collide with the real chain" + ); + + let cache = ChainContextCache::default(); + let first = cache + .get(&rpc, STALE_CONFIGURED_GENESIS) + .await + .expect("a stale configured genesis is not fatal"); + + assert_eq!( + first.state.genesis_hash, live, + "CheckGenesis is signed over this; it must come from the chain" + ); + assert!(first.state.spec_version > 0); + println!( + "live People chain: spec_version={} transaction_version={} genesis=0x{}", + first.state.spec_version, + first.state.transaction_version, + hex::encode(live) + ); + + let second = cache + .get(&rpc, STALE_CONFIGURED_GENESIS) + .await + .expect("second read succeeds"); + assert!( + Arc::ptr_eq(&first.metadata, &second.metadata), + "second read re-downloaded metadata; the entry is keyed by the wrong hash" + ); +} + +/// `find_allocated_slot` must scan a live period without erroring, whatever the +/// table's occupancy — the property that lets the steady state skip ring +/// resolution. +#[tokio::test] +#[ignore = "needs network access to a live People chain"] +async fn find_allocated_slot_scans_a_live_period_without_erroring() { + let rpc = connect().await; + let cache = ChainContextCache::default(); + let chain = cache + .get(&rpc, STALE_CONFIGURED_GENESIS) + .await + .expect("read the live chain context"); + let period = current_period(); + + // Entropy and target are throwaway: no alias derived from them owns a slot, + // so the scan must report "no slot held" rather than failing. + let held = + alloc::slot::find_allocated_slot(&rpc, &chain.metadata, [0x11; 32], period, &[0x22; 32]) + .await + .expect("scanning a live period is not an error"); + + assert_eq!(held, None); + println!("scanned live period {period}: no slot held by the throwaway target"); +} + +/// The live runtime must still expose the metadata shape the allowance path +/// decodes. The offline fixture is pinned to one spec version, so this is what +/// catches a runtime upgrade that moves the `AsResources` extension. +#[tokio::test] +#[ignore = "needs network access to a live People chain"] +async fn live_metadata_still_exposes_the_allowance_extension_shape() { + let rpc = connect().await; + let cache = ChainContextCache::default(); + let chain = cache + .get(&rpc, STALE_CONFIGURED_GENESIS) + .await + .expect("read the live chain context"); + + let register = chain + .metadata + .as_resources_variant_indices("RegisterStatementStoreAllowance") + .expect("live runtime exposes RegisterStatementStoreAllowance"); + let claim = chain + .metadata + .as_resources_variant_indices("ClaimLongTermStorage") + .expect("live runtime exposes ClaimLongTermStorage"); + let period_duration = alloc::slot::long_term_storage_period_duration(&chain.metadata) + .expect("live runtime exposes Resources.LongTermStoragePeriodDuration"); + + assert!(period_duration > 0); + println!( + "live spec {}: RegisterStatementStoreAllowance={register:?} ClaimLongTermStorage={claim:?} \ + long-term-storage period={period_duration}s", + chain.state.spec_version, + ); +} diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index 5c2d6fdb..bde47a3b 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -72,14 +72,6 @@ pub enum ChainStateError { /// Actual decoded length. len: usize, }, - /// The connected chain is not the one the caller asked for. - #[error("chain reports genesis 0x{actual}, expected 0x{expected}")] - GenesisHashMismatch { - /// Genesis hash the caller asked for. - expected: String, - /// Genesis hash the connected chain reported. - actual: String, - }, /// Runtime JSON lacked an expected u32 field. #[error("missing/invalid {field}")] MissingU32Field { @@ -182,29 +174,33 @@ pub struct ChainContextCache { } impl ChainContextCache { - /// Metadata and chain state for the chain at `genesis_hash`, read from the + /// Metadata and chain state for the chain reached over `rpc`, read from the /// chain only when no entry matches its current spec version. /// - /// Fails when the connected chain reports a genesis hash other than - /// `genesis_hash`: the host has wired this client to the wrong chain, and - /// extrinsics built against it would be signed for a chain the caller did - /// not ask for. + /// `configured_genesis_hash` is the caller's identity for the chain — the + /// hash it routes connections by — and keys the cache. The genesis hash + /// placed in [`ChainState`], and therefore signed into every allowance + /// extrinsic, is always the one the chain itself reports: a host whose + /// configured constant has gone stale (a wiped testnet) still produces + /// valid extrinsics. A divergence is logged, since it means the host's + /// chain configuration needs refreshing — see RFC-0026, which lets hosts + /// discover these hashes instead of hard-coding them. pub async fn get( &self, rpc: &RpcClient, - genesis_hash: [u8; 32], + configured_genesis_hash: [u8; 32], ) -> Result { let (spec_version, transaction_version) = fetch_runtime_version(rpc).await?; - if let Some(cached) = self.cached(genesis_hash, spec_version) { + if let Some(cached) = self.cached(configured_genesis_hash, spec_version) { return Ok(cached); } - let reported = fetch_genesis_hash(rpc).await?; - if reported != genesis_hash { - return Err(ChainStateError::GenesisHashMismatch { - expected: hex::encode(genesis_hash), - actual: hex::encode(reported), - } - .into()); + let genesis_hash = fetch_genesis_hash(rpc).await?; + if genesis_hash != configured_genesis_hash { + warn!( + configured = %hex::encode(configured_genesis_hash), + reported = %hex::encode(genesis_hash), + "chain reports a different genesis than the host configured; using the chain's" + ); } let context = ChainContext { metadata: Arc::new(fetch_metadata(rpc).await?), @@ -218,15 +214,15 @@ impl ChainContextCache { self.entries .lock() .expect("chain context cache mutex poisoned") - .insert(genesis_hash, context.clone()); + .insert(configured_genesis_hash, context.clone()); Ok(context) } - fn cached(&self, genesis_hash: [u8; 32], spec_version: u32) -> Option { + fn cached(&self, configured_genesis_hash: [u8; 32], spec_version: u32) -> Option { self.entries .lock() .expect("chain context cache mutex poisoned") - .get(&genesis_hash) + .get(&configured_genesis_hash) .filter(|cached| cached.state.spec_version == spec_version) .cloned() } @@ -799,26 +795,37 @@ mod tests { } #[test] - fn a_chain_reporting_another_genesis_is_rejected() { + fn a_stale_configured_genesis_still_yields_the_chains_own_hash() { let responses = [ runtime_version(1_000_000), genesis_result([0xbb; 32]), metadata_result(), ]; let scripted = ScriptedRpc::new(responses.iter().map(String::as_str)); - let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); + let rpc = RpcClient::new(HostRpcClient::new(scripted)); let cache = ChainContextCache::default(); - let Err(err) = futures::executor::block_on(cache.get(&rpc, [0xaa; 32])) else { - panic!("a chain reporting another genesis must be rejected"); - }; + let context = futures::executor::block_on(cache.get(&rpc, [0xaa; 32])) + .expect("a stale configured genesis is not fatal"); - assert!( - err.to_string().contains("expected 0xaaaa"), - "unexpected error: {err}" + // `CheckGenesis` is signed over this, so it must be the chain's value, + // not the caller's possibly-stale constant. + assert_eq!(context.state.genesis_hash, [0xbb; 32]); + } + + #[test] + fn a_stale_configured_genesis_still_keys_the_cache() { + let seen = scripted_cache_run( + &[ + runtime_version(1_000_000), + genesis_result([0xbb; 32]), + metadata_result(), + runtime_version(1_000_000), + ], + &[[0xaa; 32], [0xaa; 32]], ); - // The mismatch is caught before the metadata download. - assert_eq!(methods(&scripted), MISS[..2]); + + assert_eq!(seen, [MISS.as_slice(), HIT.as_slice()].concat()); } /// `StmtStoreAllowanceEntry { account_id, seq: 0, since: 0 }` as a scripted