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/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/services.rs b/rust/crates/truapi-server/src/runtime/services.rs index 7c6fb3c4..7288c258 100644 --- a/rust/crates/truapi-server/src/runtime/services.rs +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -34,6 +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 and chain state shared by the native allowance + /// paths, per chain. + #[cfg(not(target_arch = "wasm32"))] + 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, @@ -67,6 +71,8 @@ impl RuntimeServices { chain, statement_store, bulletin, + #[cfg(not(target_arch = "wasm32"))] + 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 0e9fa8b9..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, fetch_metadata, find_including_ring, - register_statement_account, + self, RegistrationParams, find_including_ring, register_statement_account, }; let entropy = signing_host.root_entropy()?; @@ -871,19 +870,44 @@ pub(super) async fn allocate_statement_store_allowance( .client("statement-store allowance") .await?, ); - let metadata = fetch_metadata(&rpc).await?; - let chain_state = fetch_chain_state(&rpc).await?; + let chain = services + .chain_context + .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 + // 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, + &chain.metadata, + bandersnatch, + period, + &target, + ) + .await? + { + debug!( + %product_id, + period, + seq, + "statement-store allowance already allocated" + ); + return Ok(allowance.secret.to_bytes().to_vec()); + } + 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 period = statement_allowance::slot::current_period(current_unix_secs()?); let outcome = register_statement_account( &rpc, - &metadata, - &chain_state, + &chain.metadata, + &chain.state, bandersnatch, RegistrationParams { target: &target, @@ -926,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, fetch_metadata, - 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()?; @@ -957,24 +981,27 @@ pub(super) async fn allocate_bulletin_allowance( .client("bulletin allowance claim") .await?, ); - let metadata = fetch_metadata(&people_rpc).await?; - let chain_state = fetch_chain_state(&people_rpc).await?; + let chain = services + .chain_context + .get(&people_rpc, services.statement_store.genesis_hash()) + .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 ba9f71e8..bde47a3b 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,8 +115,17 @@ 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?; + Ok(( + json_u32(&runtime, "specVersion")?, + json_u32(&runtime, "transactionVersion")?, + )) +} + +/// 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() @@ -122,14 +133,15 @@ pub async fn fetch_chain_state(rpc: &RpcClient) -> Result 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, @@ -138,6 +150,84 @@ pub async fn fetch_chain_state(rpc: &RpcClient) -> Result, + /// Chain state filling the standard signed extensions. + pub state: ChainState, +} + +/// Runtime metadata and chain state cached per chain. +/// +/// 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. +/// +/// 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>, +} + +impl ChainContextCache { + /// Metadata and chain state for the chain reached over `rpc`, read from the + /// chain only when no entry matches its current spec version. + /// + /// `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, + configured_genesis_hash: [u8; 32], + ) -> Result { + let (spec_version, transaction_version) = fetch_runtime_version(rpc).await?; + if let Some(cached) = self.cached(configured_genesis_hash, spec_version) { + return Ok(cached); + } + 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?), + state: ChainState { + spec_version, + transaction_version, + genesis_hash, + nonce: 0, + }, + }; + self.entries + .lock() + .expect("chain context cache mutex poisoned") + .insert(configured_genesis_hash, context.clone()); + Ok(context) + } + + fn cached(&self, configured_genesis_hash: [u8; 32], spec_version: u32) -> Option { + self.entries + .lock() + .expect("chain context cache mutex poisoned") + .get(&configured_genesis_hash) + .filter(|cached| cached.state.spec_version == spec_version) + .cloned() + } +} + /// Read a u32 field from a JSON object. fn json_u32(value: &Value, field: &'static str) -> Result { value @@ -604,6 +694,140 @@ 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)) + } + + /// 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 + .calls() + .into_iter() + .map(|(method, _)| method) + .collect() + } + + /// 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 = ChainContextCache::default(); + + futures::executor::block_on(async { + for genesis_hash in chains { + cache + .get(&rpc, *genesis_hash) + .await + .expect("scripted chain context fetch succeeds"); + } + }); + methods(&scripted) + } + + #[test] + 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, [MISS.as_slice(), HIT.as_slice()].concat()); + } + + #[test] + fn a_spec_version_bump_refreshes_the_entry() { + 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, [MISS, MISS].concat()); + } + + #[test] + fn chains_do_not_share_a_cache_entry() { + 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, [MISS, MISS].concat()); + } + + #[test] + 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)); + let cache = ChainContextCache::default(); + + let context = futures::executor::block_on(cache.get(&rpc, [0xaa; 32])) + .expect("a stale configured genesis is not fatal"); + + // `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]], + ); + + assert_eq!(seen, [MISS.as_slice(), HIT.as_slice()].concat()); + } + /// `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_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); 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(