From 68b98cece3fcd7ea896a4169979b7899ae8129ad Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 3 Sep 2026 10:35:10 +0200 Subject: [PATCH 01/14] fix(server): read Resources budgets only through view functions The `Resources` slot budgets no longer exist as runtime constants: the refreshed paseo-next-v2 V16 metadata (spec 3000000) declares `get_stmt_store_slots_per_period`, `get_lite_stmt_store_slots_per_period`, `get_stmt_store_grace_window`, `get_stmt_store_replacement_cooldown` and `get_long_term_storage_claims_per_period` as view functions and drops the matching constants, so the constant-lookup branch could only ever error. Drop the fallback from `read_resource_u32` / `supports_resource_u32`, along with `PersonhoodCollection::slots_per_period_constant` and `Metadata::has_view_function`, which existed only to select it. Refresh the V16 fixture from the live chain and add a primed People fixture so the allowance tests keep scripting only the slot reads they exercise. `StubPlatform` now keys a `state_call` by the runtime API it names, so a test can answer metadata and view reads separately. --- .../src/runtime/signing_host/sso_responder.rs | 20 +++++-- .../src/runtime/statement_allowance.rs | 44 +++++++-------- .../runtime/statement_allowance/collection.rs | 33 +---------- .../runtime/statement_allowance/extension.rs | 12 +--- .../runtime/statement_allowance/renewal.rs | 27 ++++----- .../src/runtime/statement_allowance/slot.rs | 53 ++++-------------- .../statement_allowance/test_fixtures.rs | 34 +++++++++++ .../src/runtime/statement_allowance/view.rs | 19 +------ rust/crates/truapi-server/src/test_support.rs | 15 ++++- .../truapi-server/tests/fixtures/README.md | 2 +- .../fixtures/paseo-next-v2-metadata-v16.scale | Bin 445304 -> 526054 bytes 11 files changed, 114 insertions(+), 145 deletions(-) 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 c5f3908dc..56a099205 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 @@ -1732,7 +1732,7 @@ mod tests { /// Metadata for the People chain the signing fixture is configured for. #[cfg(not(target_arch = "wasm32"))] const PEOPLE_METADATA: &[u8] = - include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); + include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata-v16.scale"); /// An existing statement-store allowance must be served without resolving a /// ring or submitting anything. The cache and the scan are covered on their @@ -1766,12 +1766,20 @@ mod tests { "chain_getBlockHash", format!(r#""0x{}""#, hex::encode([0u8; 32])), ), - // `Metadata_metadata_at_version(16)` answering absent, so the - // legacy fetch below is what serves the metadata. - ("state_call", r#""0x00""#.to_string()), ( - "state_getMetadata", - format!(r#""0x{}""#, hex::encode(PEOPLE_METADATA)), + "Metadata_metadata_at_version", + format!( + r#""0x{}""#, + hex::encode(Some(PEOPLE_METADATA.to_vec()).encode()), + ), + ), + // The scan bound, read through the `Resources` view functions. + ( + "RuntimeViewFunction_execute_view_function", + format!( + r#""0x{}""#, + hex::encode(Ok::, ()>(20u32.encode()).encode()), + ), ), ( "state_getStorage", diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index c312344d5..28ac00974 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -1595,7 +1595,7 @@ mod tests { Result, ScriptedRpc, ) { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let chain_state = ChainState { spec_version: 1_000_000, transaction_version: 1, @@ -1622,7 +1622,7 @@ mod tests { let outcome = futures::executor::block_on(register_statement_account( &rpc, - &metadata, + metadata, &chain_state, entropy, RegistrationParams { @@ -1680,7 +1680,7 @@ mod tests { Result, ScriptedRpc, ) { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let chain_state = ChainState { spec_version: 1_000_000, transaction_version: 1, @@ -1695,10 +1695,10 @@ mod tests { let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); let outcome = futures::executor::block_on(async { - let scans = scan_collections(&rpc, &metadata, &candidates, 7, &target, true).await?; + let scans = scan_collections(&rpc, metadata, &candidates, 7, &target, true).await?; register_statement_account_pooled( &rpc, - &metadata, + metadata, &chain_state, &scans, &memberships, @@ -1721,7 +1721,7 @@ mod tests { target: [u8; 32], submit_error: &str, ) -> Result { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let chain_state = ChainState { spec_version: 1_000_000, transaction_version: 1, @@ -1736,10 +1736,10 @@ mod tests { let rpc = RpcClient::new(HostRpcClient::new(scripted)); futures::executor::block_on(async { - let scans = scan_collections(&rpc, &metadata, &candidates, 7, &target, true).await?; + let scans = scan_collections(&rpc, metadata, &candidates, 7, &target, true).await?; register_statement_account_pooled( &rpc, - &metadata, + metadata, &chain_state, &scans, &memberships, @@ -2034,7 +2034,7 @@ mod tests { /// that never needed People at all. #[test] fn a_broken_people_collection_does_not_discard_a_lite_people_membership() { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let candidates = pooled_memberships().map(|membership| CollectionCandidate { collection: membership.collection(), entropy: membership.entropy, @@ -2059,7 +2059,7 @@ mod tests { let memberships = futures::executor::block_on(find_including_rings( &rpc, - &metadata, + metadata, &candidates, u32::MAX, )) @@ -2077,7 +2077,7 @@ mod tests { /// membership" would let a caller conclude the person has no personhood. #[test] fn every_collection_failing_is_reported_as_an_error() { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let candidates = pooled_memberships().map(|membership| CollectionCandidate { collection: membership.collection(), entropy: membership.entropy, @@ -2094,7 +2094,7 @@ mod tests { let err = futures::executor::block_on(find_including_rings( &rpc, - &metadata, + metadata, &candidates, u32::MAX, )) @@ -2179,7 +2179,7 @@ mod tests { /// allocation for a device that could never hold a slot in People. #[test] fn a_failed_people_scan_still_finds_the_lite_people_allocation() { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let candidates = pooled_candidates(); let target = [0x22; 32]; @@ -2197,7 +2197,7 @@ mod tests { let scans = futures::executor::block_on(scan_collections( &rpc, - &metadata, + metadata, &candidates, 7, &target, @@ -2224,7 +2224,7 @@ mod tests { /// taking is replaced, and the registration proceeds. #[test] fn a_full_table_replaces_the_oldest_replaceable_slot() { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let chain_state = ChainState { spec_version: 1_000_000, transaction_version: 1, @@ -2257,7 +2257,7 @@ mod tests { let outcome = futures::executor::block_on(register_statement_account( &rpc, - &metadata, + metadata, &chain_state, entropy, RegistrationParams { @@ -2283,7 +2283,7 @@ mod tests { /// submission can still land, so two takeovers for one call can cost two. #[test] fn a_duplicate_submit_retry_does_not_take_over_a_second_slot() { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let chain_state = ChainState { spec_version: 1_000_000, transaction_version: 1, @@ -2316,7 +2316,7 @@ mod tests { let err = futures::executor::block_on(register_statement_account( &rpc, - &metadata, + metadata, &chain_state, entropy, RegistrationParams { @@ -2340,7 +2340,7 @@ mod tests { /// failure: the host loses the race whenever the chain's clock disagrees. #[test] fn a_refused_takeover_is_named() { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let chain_state = ChainState { spec_version: 1_000_000, transaction_version: 1, @@ -2367,7 +2367,7 @@ mod tests { let err = futures::executor::block_on(register_statement_account( &rpc, - &metadata, + metadata, &chain_state, entropy, RegistrationParams { @@ -2390,7 +2390,7 @@ mod tests { /// Everything occupied and still inside the cooldown stays an error. #[test] fn a_full_table_within_the_cooldown_still_fails() { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let chain_state = ChainState { spec_version: 1_000_000, transaction_version: 1, @@ -2416,7 +2416,7 @@ mod tests { let err = futures::executor::block_on(register_statement_account( &rpc, - &metadata, + metadata, &chain_state, entropy, RegistrationParams { diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/collection.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/collection.rs index ca9ebda54..8212299a4 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/collection.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/collection.rs @@ -50,15 +50,6 @@ impl PersonhoodCollection { } } - /// The `Resources` constant bounding StatementStore slots per period for - /// this collection. - pub fn slots_per_period_constant(self) -> &'static str { - match self { - Self::People => "StmtStoreSlotsPerPeriod", - Self::LitePeople => "LiteStmtStoreSlotsPerPeriod", - } - } - /// The `Resources` view function returning StatementStore slots per period /// for this collection. pub fn slots_per_period_view(self) -> &'static str { @@ -78,11 +69,7 @@ impl PersonhoodCollection { /// Whether this chain exposes a StatementStore slot budget for this collection. pub fn is_supported(self, metadata: &Metadata) -> bool { - view::supports_resource_u32( - metadata, - self.slots_per_period_view(), - self.slots_per_period_constant(), - ) + view::supports_resource_u32(metadata, self.slots_per_period_view()) } /// Max StatementStore slots per period for this collection. @@ -91,13 +78,7 @@ impl PersonhoodCollection { rpc: &RpcClient, metadata: &Metadata, ) -> Result { - view::read_resource_u32( - rpc, - metadata, - self.slots_per_period_view(), - self.slots_per_period_constant(), - ) - .await + view::read_resource_u32(rpc, metadata, self.slots_per_period_view()).await } } @@ -178,20 +159,12 @@ mod tests { } #[test] - fn each_collection_names_its_own_variant_slot_constant_and_view() { + fn each_collection_names_its_own_variant_and_slot_view() { assert_eq!(PersonhoodCollection::People.metadata_variant(), "People"); assert_eq!( PersonhoodCollection::LitePeople.metadata_variant(), "LitePeople" ); - assert_eq!( - PersonhoodCollection::People.slots_per_period_constant(), - "StmtStoreSlotsPerPeriod", - ); - assert_eq!( - PersonhoodCollection::LitePeople.slots_per_period_constant(), - "LiteStmtStoreSlotsPerPeriod", - ); assert_eq!( PersonhoodCollection::People.slots_per_period_view(), "get_stmt_store_slots_per_period", diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs index 9b567039c..cfae4c7f8 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs @@ -448,11 +448,6 @@ impl Metadata { .map(Vec::as_slice) } - pub(super) fn has_view_function(&self, pallet: &str, function: &str) -> bool { - self.view_functions - .contains_key(&(pallet.to_string(), function.to_string())) - } - pub(super) fn view_function(&self, pallet: &str, function: &str) -> Option { self.view_functions .get(&(pallet.to_string(), function.to_string())) @@ -934,7 +929,7 @@ mod tests { call } - /// V16 metadata captured from paseo-next-v2 (spec 1000032), the version the + /// V16 metadata captured from paseo-next-v2 (spec 3000000), the version the /// runtime API serves. Distinct from `FIXTURE`, which is the V14 the legacy /// RPC answers with and predates the `revision` field. const FIXTURE_V16: &[u8] = @@ -968,11 +963,6 @@ mod tests { ), ((0x02, 0x01), (0x03, 0x01)), ); - assert!( - metadata - .constant("Resources", "LiteStmtStoreSlotsPerPeriod") - .is_some() - ); } /// PGAS authorizes with a different extension from the statement-store diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs index 21f965541..d5325ed3a 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs @@ -373,14 +373,13 @@ mod tests { use subxt_rpcs::RpcClient as HostRpcClient; use crate::runtime::statement_allowance::CollectionMembership; - use crate::runtime::statement_allowance::extension::{ChainState, Metadata}; + use crate::runtime::statement_allowance::extension::ChainState; use crate::runtime::statement_allowance::proof; use crate::runtime::statement_allowance::ring::RingParams; use crate::runtime::statement_allowance::rpc::RpcClient; use crate::runtime::statement_allowance::rpc::testing::ScriptedRpc; + use crate::runtime::statement_allowance::test_fixtures; - const FIXTURE: &[u8] = - include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); const NOW: u64 = 10_000_000; /// One occupied slot entry, oldest first by `seq`. @@ -391,7 +390,7 @@ mod tests { format!(r#""0x{}""#, hex::encode((NOW * 1_000).encode())) } - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let chain_state = ChainState { spec_version: 1_000_000, transaction_version: 1, @@ -444,7 +443,7 @@ mod tests { }]; let context = RenewalChainContext { rpc: &rpc, - metadata: &metadata, + metadata: metadata, chain_state: &chain_state, candidates: &candidates, memberships: &memberships, @@ -475,21 +474,20 @@ mod tests { use subxt_rpcs::RpcClient as HostRpcClient; use crate::runtime::statement_allowance::CollectionMembership; - use crate::runtime::statement_allowance::extension::{ChainState, Metadata}; + use crate::runtime::statement_allowance::extension::ChainState; use crate::runtime::statement_allowance::proof; use crate::runtime::statement_allowance::ring::RingParams; use crate::runtime::statement_allowance::rpc::RpcClient; use crate::runtime::statement_allowance::rpc::testing::ScriptedRpc; + use crate::runtime::statement_allowance::test_fixtures; - const FIXTURE: &[u8] = - include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); const NOW: u64 = 10_000_000; fn entry(account: [u8; 32]) -> String { format!(r#""0x{}""#, hex::encode((account, 0u32, NOW).encode())) } - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let chain_state = ChainState { spec_version: 1_000_000, transaction_version: 1, @@ -532,7 +530,7 @@ mod tests { }]; let context = RenewalChainContext { rpc: &rpc, - metadata: &metadata, + metadata: metadata, chain_state: &chain_state, candidates: &candidates, memberships: &memberships, @@ -572,14 +570,13 @@ mod tests { use subxt_rpcs::RpcClient as HostRpcClient; use crate::runtime::statement_allowance::CollectionMembership; - use crate::runtime::statement_allowance::extension::{ChainState, Metadata}; + use crate::runtime::statement_allowance::extension::ChainState; use crate::runtime::statement_allowance::proof; use crate::runtime::statement_allowance::ring::RingParams; use crate::runtime::statement_allowance::rpc::RpcClient; use crate::runtime::statement_allowance::rpc::testing::ScriptedRpc; + use crate::runtime::statement_allowance::test_fixtures; - const FIXTURE: &[u8] = - include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); const NOW: u64 = 10_000_000; fn entry(account: [u8; 32], since: u64) -> String { @@ -589,7 +586,7 @@ mod tests { format!(r#""0x{}""#, hex::encode((NOW * 1_000).encode())) } - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let chain_state = ChainState { spec_version: 1_000_000, transaction_version: 1, @@ -631,7 +628,7 @@ mod tests { }]; let context = RenewalChainContext { rpc: &rpc, - metadata: &metadata, + metadata: metadata, chain_state: &chain_state, candidates: &candidates, memberships: &memberships, 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 a28da7a1d..2779ef0d2 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs @@ -290,13 +290,8 @@ pub async fn long_term_storage_claims_per_period( rpc: &RpcClient, metadata: &Metadata, ) -> Result { - let value = view::read_resource_u32( - rpc, - metadata, - "get_long_term_storage_claims_per_period", - "LongTermStorageClaimsPerPeriod", - ) - .await?; + let value = + view::read_resource_u32(rpc, metadata, "get_long_term_storage_claims_per_period").await?; u8::try_from(value).map_err(|_| SlotError::LongTermStorageClaimsOverflow { value }.into()) } @@ -313,13 +308,7 @@ pub async fn statement_store_grace_window( rpc: &RpcClient, metadata: &Metadata, ) -> Result { - view::read_resource_u32( - rpc, - metadata, - "get_stmt_store_grace_window", - "StmtStoreGraceWindow", - ) - .await + view::read_resource_u32(rpc, metadata, "get_stmt_store_grace_window").await } /// Decode a slot entry: `account_id(32) ‖ seq(u32 LE) ‖ since(u64 LE)`. @@ -384,14 +373,9 @@ pub async fn replacement_cooldown( rpc: &RpcClient, metadata: &Metadata, ) -> Result { - view::read_resource_u32( - rpc, - metadata, - "get_stmt_store_replacement_cooldown", - "StmtStoreReplacementCooldown", - ) - .await - .map(u64::from) + view::read_resource_u32(rpc, metadata, "get_stmt_store_replacement_cooldown") + .await + .map(u64::from) } /// The account holding our alias slot `(period, seq)`, read pinned to @@ -617,7 +601,6 @@ mod tests { /// 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 @@ -643,7 +626,7 @@ mod tests { /// Run `scan_slot_excluding` for `[0x22; 32]` against a scripted period /// whose slot occupancy is `slots`. fn scripted_find(slots: &[Option<[u8; 32]>]) -> SlotSelection { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let entries: Vec = slots .iter() .map(|slot| slot.map_or_else(|| "null".to_string(), slot_entry)) @@ -653,7 +636,7 @@ mod tests { futures::executor::block_on(scan_slot_excluding( &rpc, - &metadata, + metadata, SlotScan { collection: PersonhoodCollection::LitePeople, entropy: [0x11; 32], @@ -716,7 +699,7 @@ mod tests { /// carry them through rather than discard them. #[test] fn the_scan_reports_each_occupied_slots_age() { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let entries: Vec = (0..SLOTS) .map(|seq| entry_with_since([0x99; 32], 1_000 + seq as u64)) .collect(); @@ -726,7 +709,7 @@ mod tests { let SlotSelection::Full { occupied, .. } = futures::executor::block_on(scan_slot_excluding( &rpc, - &metadata, + metadata, SlotScan { collection: PersonhoodCollection::LitePeople, entropy: [0x11; 32], @@ -871,24 +854,12 @@ mod tests { ); } - #[test] - fn metadata_without_views_supplies_the_replacement_cooldown_constant() { - let metadata = Metadata::decode(FIXTURE).unwrap(); - let scripted = ScriptedRpc::new(std::iter::empty::<&str>()); - let rpc = RpcClient::new(HostRpcClient::new(scripted)); - - assert_eq!( - futures::executor::block_on(replacement_cooldown(&rpc, &metadata)).unwrap(), - 60 - ); - } - /// Excluding a slot because a submission for it is in flight must not read as /// "the period is full": the free slot is coming back, and a caller that /// treats this as full would replace a live slot for nothing. #[test] fn an_excluded_free_slot_is_not_reported_as_a_full_period() { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); // Only seq 9 is free, and the caller already excluded it. let mut entries: Vec = (0..SLOTS - 1).map(|_| slot_entry([0x99; 32])).collect(); entries.push("null".to_string()); @@ -897,7 +868,7 @@ mod tests { let selection = futures::executor::block_on(scan_slot_excluding( &rpc, - &metadata, + metadata, SlotScan { collection: PersonhoodCollection::LitePeople, entropy: [0x11; 32], diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/test_fixtures.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/test_fixtures.rs index 61413f0d7..8938b8ea5 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/test_fixtures.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/test_fixtures.rs @@ -29,6 +29,40 @@ pub(crate) fn asset_hub() -> &'static Metadata { &ASSET_HUB } +/// The `Resources` budgets the allowance tests are written against, keyed by +/// the view function that serves each one. +/// +/// Priming them keeps a test's scripted RPC list about the slot table it is +/// exercising: without this every scan would also have to script the budget +/// view calls, so adding one read would renumber every later response. +const PEOPLE_RESOURCE_BUDGETS: [(&str, u32); 5] = [ + ("get_stmt_store_slots_per_period", 20), + ("get_lite_stmt_store_slots_per_period", 10), + ("get_stmt_store_replacement_cooldown", 60), + ("get_stmt_store_grace_window", 3600), + ("get_long_term_storage_claims_per_period", 10), +]; + +/// People-chain V16 metadata with [`PEOPLE_RESOURCE_BUDGETS`] already resolved. +static PEOPLE: LazyLock = LazyLock::new(|| { + let metadata = Metadata::decode(include_bytes!( + "../../../tests/fixtures/paseo-next-v2-metadata-v16.scale" + )) + .expect("the committed People fixture decodes"); + for (function, value) in PEOPLE_RESOURCE_BUDGETS { + let definition = metadata + .view_function("Resources", function) + .unwrap_or_else(|| panic!("the People fixture declares Resources.{function}")); + metadata.cache_view_u32(definition.id, value); + } + metadata +}); + +/// Borrow the decoded People fixture. +pub(crate) fn people() -> &'static Metadata { + &PEOPLE +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/view.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/view.rs index 3b4785874..071dff062 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/view.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/view.rs @@ -157,24 +157,13 @@ pub(super) async fn read_resource_u32( rpc: &RpcClient, metadata: &Metadata, function: &'static str, - fallback_constant: &'static str, ) -> Result { - if metadata.has_view_function("Resources", function) { - read_u32(rpc, metadata, "Resources", function).await - } else { - metadata.constant_u32("Resources", fallback_constant) - } + read_u32(rpc, metadata, "Resources", function).await } -pub(super) fn supports_resource_u32( - metadata: &Metadata, - function: &'static str, - fallback_constant: &'static str, -) -> bool { +pub(super) fn supports_resource_u32(metadata: &Metadata, function: &'static str) -> bool { let Some(definition) = metadata.view_function("Resources", function) else { - return metadata - .constant_u32("Resources", fallback_constant) - .is_ok(); + return false; }; definition.inputs == 0 && matches!( @@ -396,12 +385,10 @@ mod tests { assert!(!supports_resource_u32( &metadata, "get_stmt_store_slots_per_period", - "StmtStoreSlotsPerPeriod", )); assert!(supports_resource_u32( &metadata, "get_lite_stmt_store_slots_per_period", - "LiteStmtStoreSlotsPerPeriod", )); } } diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 633a555ab..f6fea5af8 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -128,7 +128,8 @@ pub(crate) struct StubPlatform { pub(crate) sent_rpc: Arc>>, pub(crate) rpc_responses: Vec, /// Responses keyed by JSON-RPC method, answered as each request arrives with - /// that request's own id echoed back. + /// that request's own id echoed back. A `state_call` is keyed by the runtime + /// API it names instead, so metadata and view-function reads stay separable. /// /// Unlike `rpc_responses` this assumes nothing about request order and waits /// indefinitely for the next request, so a slow step between two requests @@ -1297,6 +1298,9 @@ impl JsonRpcConnection for RecordingConnection { /// Answer each request as it arrives, by method, echoing its id. /// +/// A `state_call` is keyed by the runtime API in its first parameter, because a +/// path that reads both metadata and a view function issues both through it. +/// /// Waits indefinitely for the next request rather than giving up after a fixed /// number of polls, so work between requests cannot race the pump. fn method_keyed_responses( @@ -1318,11 +1322,16 @@ fn method_keyed_responses( serde_json::from_str(&request).expect("request is valid JSON"); let id = value["id"].as_str().expect("request carries a string id"); let method = value["method"].as_str().expect("request carries a method"); + let key = if method == "state_call" { + value["params"][0].as_str().unwrap_or(method) + } else { + method + }; let result = answers .iter() - .find(|(candidate, _)| *candidate == method) + .find(|(candidate, _)| *candidate == key) .map(|(_, body)| body.clone()) - .unwrap_or_else(|| panic!("no scripted response for method `{method}`")); + .unwrap_or_else(|| panic!("no scripted response for `{key}`")); return Some(( format!(r#"{{"jsonrpc":"2.0","id":"{id}","result":{result}}}"#), answered + 1, diff --git a/rust/crates/truapi-server/tests/fixtures/README.md b/rust/crates/truapi-server/tests/fixtures/README.md index e1f6354f6..0299e6616 100644 --- a/rust/crates/truapi-server/tests/fixtures/README.md +++ b/rust/crates/truapi-server/tests/fixtures/README.md @@ -12,7 +12,7 @@ expects. | File | Chain | Metadata | Spec | Captured | Declares | |---|---|---|---|---|---| | `paseo-next-v2-metadata.scale` | Paseo Next v2 | V14 | | | `AsResources`, three-field allowance info | -| `paseo-next-v2-metadata-v16.scale` | Paseo Next v2 | V16 | 1000032 | | `AsResources`, four-field allowance info | +| `paseo-next-v2-metadata-v16.scale` | Paseo Next v2 | V16 | 3000000 | 2026-09-03 | `AsResources`, four-field allowance info; `Resources` slot budgets as view functions | | `paseo-next-asset-hub-metadata.scale` | Paseo Asset Hub Next | V16 | 2000036 | 2026-08-17 | `AsPgas`, `Pgas`, `MembersSubscriber` | | `bulletin_paseo_metadata.scale` | Polkadot Bulletin (Paseo) | V14 | 1000020 | | preimage and storage calls | diff --git a/rust/crates/truapi-server/tests/fixtures/paseo-next-v2-metadata-v16.scale b/rust/crates/truapi-server/tests/fixtures/paseo-next-v2-metadata-v16.scale index efb183e51678c8cc0fda20e47f60053636346769..a12e5b488aa4f4f92c18835e082861913edd682b 100644 GIT binary patch delta 92930 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 SGna41?+mZ1A2IeND*q3GiJoo% delta 45242 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 Date: Thu, 3 Sep 2026 11:07:10 +0200 Subject: [PATCH 02/14] fix(server): require V16 metadata instead of falling back to state_getMetadata `state_getMetadata` answers with whatever version the node serves, V14 on paseo-next-v2, and V14 declares no transaction-extension version map. Metadata fetched that way silently resolves pipeline 0, which signs an extrinsic that looks correct and is not. Both live People chains and both live Asset Hubs answer `Metadata_metadata_at_version(16)`. `fetch_metadata` now returns the runtime-API answer and fails with `MetadataVersionUnavailable` when the runtime does not serve V16. `fetch_legacy_metadata` and its OpaqueMetadata-wrapper branch are gone. The cache tests script the runtime call rather than the legacy RPC, and count its requests to keep pinning one metadata download per spec version. The People capture is included once from `test_fixtures` instead of three times. --- .../src/runtime/signing_host/sso_responder.rs | 13 +- .../src/runtime/statement_allowance.rs | 121 +++++++----------- .../runtime/statement_allowance/extension.rs | 25 ++-- .../statement_allowance/test_fixtures.rs | 10 +- 4 files changed, 74 insertions(+), 95 deletions(-) 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 56a099205..5e7383151 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 @@ -1729,11 +1729,6 @@ mod tests { (services, signing_host) } - /// Metadata for the People chain the signing fixture is configured for. - #[cfg(not(target_arch = "wasm32"))] - const PEOPLE_METADATA: &[u8] = - include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata-v16.scale"); - /// An existing statement-store allowance must be served without resolving a /// ring or submitting anything. The cache and the scan are covered on their /// own; this pins the composition, so removing the early return fails here @@ -1770,7 +1765,13 @@ mod tests { "Metadata_metadata_at_version", format!( r#""0x{}""#, - hex::encode(Some(PEOPLE_METADATA.to_vec()).encode()), + hex::encode( + Some( + crate::runtime::statement_allowance::test_fixtures::PEOPLE_METADATA + .to_vec() + ) + .encode(), + ), ), ), // The scan bound, read through the `Resources` view functions. diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index 28ac00974..8fb9c484f 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -68,6 +68,9 @@ pub enum StatementAllowanceError { /// Bulletin allowance polling timed out. #[error("timed out waiting for Bulletin authorization")] BulletinAuthorizationTimeout, + /// The runtime does not serve the metadata version allowance signing needs. + #[error("runtime does not serve metadata version {0}")] + MetadataVersionUnavailable(u32), } /// Error while decoding generic chain state used by allowance registration. @@ -108,44 +111,28 @@ pub enum ChainStateError { HeaderNumberParse(#[source] std::num::ParseIntError), } -/// Metadata version to ask the runtime for: the first that carries a -/// transaction-extension version map. -const PREFERRED_METADATA_VERSION: u32 = 16; +/// The only metadata version carrying the transaction-extension version map the +/// allowance extrinsics are signed against. +const REQUIRED_METADATA_VERSION: u32 = 16; -/// Fetch and decode the runtime metadata, preferring V16. +/// Fetch and decode the runtime metadata through `Metadata_metadata_at_version`. /// -/// The legacy `state_getMetadata` RPC answers with whatever version the node -/// serves — V14 on paseo-next-v2 — and V14 declares no transaction-extension -/// version map at all, so the pipeline version cannot be resolved from it. V16 is -/// only reachable through the `Metadata_metadata_at_version` runtime API, so ask -/// for it first and fall back for runtimes that do not offer it. +/// A runtime that does not serve [`REQUIRED_METADATA_VERSION`] is rejected +/// rather than signed for with an unresolvable pipeline. pub async fn fetch_metadata(rpc: &RpcClient) -> Result { - match fetch_metadata_at_version(rpc, PREFERRED_METADATA_VERSION).await { - Ok(Some(metadata)) => return Ok(metadata), - Ok(None) => { - debug!( - version = PREFERRED_METADATA_VERSION, - "runtime does not offer this metadata version; using state_getMetadata" - ); - } - Err(reason) => { - debug!( - version = PREFERRED_METADATA_VERSION, - %reason, - "metadata runtime call failed; using state_getMetadata" - ); - } - } - fetch_legacy_metadata(rpc).await + fetch_required_metadata(rpc) + .await? + .ok_or(StatementAllowanceError::MetadataVersionUnavailable( + REQUIRED_METADATA_VERSION, + )) } -/// Ask the runtime for one metadata version through +/// Ask the runtime for [`REQUIRED_METADATA_VERSION`] through /// `Metadata_metadata_at_version`, which answers `Option`. -async fn fetch_metadata_at_version( +async fn fetch_required_metadata( rpc: &RpcClient, - version: u32, ) -> Result, StatementAllowanceError> { - let argument = format!("0x{}", hex::encode(version.encode())); + let argument = format!("0x{}", hex::encode(REQUIRED_METADATA_VERSION.encode())); let value = rpc .call( "state_call", @@ -165,26 +152,6 @@ async fn fetch_metadata_at_version( Metadata::decode(&opaque).map(Some) } -/// Fetch and decode the runtime metadata through the legacy `state_getMetadata`. -async fn fetch_legacy_metadata(rpc: &RpcClient) -> Result { - let value = rpc.call("state_getMetadata", json!([])).await?; - let hex_str = value - .as_str() - .ok_or(MetadataError::MetadataResultNotString)?; - let bytes = hex::decode(hex_str.strip_prefix("0x").unwrap_or(hex_str)) - .map_err(MetadataError::MetadataHex)?; - // `state_getMetadata` may return either the raw `RuntimeMetadataPrefixed` - // (starts with the `meta` magic) or an OpaqueMetadata wrapper - // (`Vec` = compact(len) ‖ bytes). Strip the wrapper only when present. - const META_MAGIC: [u8; 4] = *b"meta"; - if bytes.get(..4) == Some(&META_MAGIC) { - Metadata::decode(&bytes) - } else { - let inner = Vec::::decode(&mut &bytes[..]).map_err(MetadataError::OpaqueMetadata)?; - Metadata::decode(&inner) - } -} - /// 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?; @@ -258,10 +225,10 @@ pub struct ChainContext { /// 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 a -/// concurrent `state_getRuntimeVersion` + `chain_getBlockHash(0)` — two small -/// requests in place of a metadata download on every allowance call. +/// Both are fixed for a given runtime, and a full metadata response is large, so +/// entries are keyed by genesis hash and revalidated with a concurrent +/// `state_getRuntimeVersion` + `chain_getBlockHash(0)` — two small requests in +/// place of a metadata download 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. @@ -1213,9 +1180,6 @@ mod tests { use super::rpc::testing::ScriptedRpc; use super::*; - /// Fixture metadata captured from paseo-next-v2 (raw `RuntimeMetadataPrefixed`). - const FIXTURE: &[u8] = include_bytes!("../../tests/fixtures/paseo-next-v2-metadata.scale"); - fn allowance( remained_size: u64, remained_transactions: u32, @@ -1283,21 +1247,37 @@ mod tests { ); } + /// A runtime that does not serve V16 declares no transaction-extension + /// version map, so the pipeline version cannot be resolved from what it does + /// serve. The fetch has to fail loudly instead of yielding metadata that + /// signs pipeline 0 and looks indistinguishable from a correct signature. + #[test] + fn a_runtime_without_v16_metadata_is_rejected() { + let scripted = ScriptedRpc::new([r#""0x00""#]); + let rpc = RpcClient::new(HostRpcClient::new(scripted)); + + let Err(error) = futures::executor::block_on(fetch_metadata(&rpc)) else { + panic!("a runtime without V16 metadata cannot be signed for"); + }; + + assert_eq!( + error.to_string(), + "runtime does not serve metadata version 16" + ); + } + /// 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. + /// The fixture metadata as a `Metadata_metadata_at_version` hex result: the + /// `Option` the runtime call answers with. fn metadata_result() -> String { - format!(r#""0x{}""#, hex::encode(FIXTURE)) - } - - /// `Metadata_metadata_at_version(16)` answering `None`, so the caller falls - /// back to `state_getMetadata`. These tests are about caching, not about - /// which metadata version a runtime serves. - fn metadata_version_unavailable() -> String { - r#""0x00""#.to_string() + format!( + r#""0x{}""#, + hex::encode(Some(test_fixtures::PEOPLE_METADATA.to_vec()).encode()) + ) } /// A `chain_getBlockHash(0)` result for `genesis_hash`. @@ -1316,13 +1296,10 @@ mod tests { /// The requests one cache miss makes, in order. The two validation reads /// are issued together, so both happen whether or not the entry is reused. - const MISS: [&str; 4] = [ + const MISS: [&str; 3] = [ "state_getRuntimeVersion", "chain_getBlockHash", - // The V16 runtime call is tried first; these scripts answer it as absent, - // so the legacy fetch follows. "state_call", - "state_getMetadata", ]; /// The requests one cache hit makes: validation only, no metadata download. const HIT: [&str; 2] = ["state_getRuntimeVersion", "chain_getBlockHash"]; @@ -1332,7 +1309,6 @@ mod tests { fn call_script(spec_version: u32, reported: [u8; 32], downloads: bool) -> Vec { let mut script = vec![runtime_version(spec_version), genesis_result(reported)]; if downloads { - script.push(metadata_version_unavailable()); script.push(metadata_result()); } script @@ -1387,8 +1363,7 @@ mod tests { let body = match method { "state_getRuntimeVersion" => runtime_version(1_000_000), "chain_getBlockHash" => genesis_result([0xaa; 32]), - "state_call" => metadata_version_unavailable(), - "state_getMetadata" => { + "state_call" => { self.0 .metadata_downloads .fetch_add(1, std::sync::atomic::Ordering::Relaxed); diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs index cfae4c7f8..9da81f778 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs @@ -1,8 +1,9 @@ //! Signed-extension encoding for the unsigned General (v5) `AsResources` //! extrinsic, driven by live chain metadata. //! -//! The extension **order** and per-extension type ids come from the runtime -//! metadata (`state_getMetadata`, V14/V15/V16); the per-extension `extra` / +//! The extension **order** and per-extension type ids come from live V16 +//! metadata read through `Metadata_metadata_at_version`; the V14 and V15 decode +//! arms serve frozen fixtures only. The per-extension `extra` / //! `additional_signed` bytes come from a name-keyed encoder mirroring //! signing-bot `src/core/create-transaction.ts` `encodeSignedExtensions`, with a //! generic default for the personhood extensions (all `Option`/void). @@ -40,8 +41,8 @@ pub const AS_DOTNS_GATEWAY: &str = "AsDotnsGateway"; /// metadata entries. #[derive(Debug, Error)] pub enum MetadataError { - /// `state_getMetadata` did not return a hex string. - #[error("state_getMetadata returned non-string")] + /// The metadata runtime call did not return a hex string. + #[error("Metadata_metadata_at_version returned non-string")] MetadataResultNotString, /// Metadata hex payload was invalid. #[error("metadata hex: {0}")] @@ -368,9 +369,9 @@ macro_rules! collect_metadata_v16 { } impl Metadata { - /// Decode `state_getMetadata` bytes (a `RuntimeMetadataPrefixed`, V14 - /// through V16) into the ordered signed-extension defs, type registry, - /// storage value types, constants, and call enums. + /// Decode a raw `RuntimeMetadataPrefixed` (V14 through V16) into the ordered + /// signed-extension defs, type registry, storage value types, constants, and + /// call enums. pub fn decode(bytes: &[u8]) -> Result { let prefixed = RuntimeMetadataPrefixed::decode(&mut &bytes[..]).map_err(MetadataError::Decode)?; @@ -929,14 +930,14 @@ mod tests { call } - /// V16 metadata captured from paseo-next-v2 (spec 3000000), the version the - /// runtime API serves. Distinct from `FIXTURE`, which is the V14 the legacy - /// RPC answers with and predates the `revision` field. + /// V16 metadata captured from paseo-next-v2 (spec 3000000), the only version + /// the fetch accepts. Distinct from `FIXTURE`, the older V14 capture, which + /// predates the `revision` field. const FIXTURE_V16: &[u8] = include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata-v16.scale"); - /// Preferring V16 makes this decode path load-bearing, so cover it: it has to - /// yield a usable `Metadata`, not merely decode. + /// This is the only metadata the fetch accepts, so the decode path is + /// load-bearing: it has to yield a usable `Metadata`, not merely decode. #[test] fn v16_metadata_decodes_into_a_usable_metadata() { let metadata = Metadata::decode(FIXTURE_V16).unwrap(); diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/test_fixtures.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/test_fixtures.rs index 8938b8ea5..0002fbe1f 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/test_fixtures.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/test_fixtures.rs @@ -43,12 +43,14 @@ const PEOPLE_RESOURCE_BUDGETS: [(&str, u32); 5] = [ ("get_long_term_storage_claims_per_period", 10), ]; +/// People-chain V16 metadata captured from paseo-next-v2, raw +/// `RuntimeMetadataPrefixed` as `Metadata_metadata_at_version` answers with. +pub(crate) const PEOPLE_METADATA: &[u8] = + include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata-v16.scale"); + /// People-chain V16 metadata with [`PEOPLE_RESOURCE_BUDGETS`] already resolved. static PEOPLE: LazyLock = LazyLock::new(|| { - let metadata = Metadata::decode(include_bytes!( - "../../../tests/fixtures/paseo-next-v2-metadata-v16.scale" - )) - .expect("the committed People fixture decodes"); + let metadata = Metadata::decode(PEOPLE_METADATA).expect("the committed People fixture decodes"); for (function, value) in PEOPLE_RESOURCE_BUDGETS { let definition = metadata .view_function("Resources", function) From 6df956f87b3b7c9cd9b7a77f442d671d3761ed8d Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 3 Sep 2026 11:07:21 +0200 Subject: [PATCH 03/14] fix(host-cli): drop the three superseded on-disk state layouts Session state is identity-keyed and product KV is one JSON file per product under `storage/`. The readers for the layouts those replaced accepted state no released `truapi-host` binary ever wrote: - the combined `product-storage.json`, split into per-product files and renamed to `product-storage.v1.json.migrated` on first use - `signing-host/sessions/` session directories, with their separate `signing-host/storage/` product storage - the `pairing-host/storage/default` product-storage directory Removing them collapses `SessionProfile` resolution to one path, so `remove_profile_data`'s out-of-tree storage branch, the `remove_named_data` path list, `promote_to_user`'s storage rename, and `list`'s dedup are all unreachable and go too. `legacy_pairing_storage_moves_to_the_first_resolved_user` is rewritten rather than deleted: it is the only cover for carrying product KV written before the username is known into the resolved user's directory, and it now also asserts that carry is persisted rather than held in memory. --- rust/crates/truapi-host-cli/README.md | 18 +- rust/crates/truapi-host-cli/SPEC.md | 27 +-- rust/crates/truapi-host-cli/src/platform.rs | 181 +++----------------- rust/crates/truapi-host-cli/src/sessions.rs | 114 ++---------- 4 files changed, 55 insertions(+), 285 deletions(-) diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index c8cb725da..377d68d60 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -315,9 +315,9 @@ settings containing arguments, such as `EDITOR='code --wait'`, are supported. Managed sessions isolate signer accounts, product/core storage, and permissions. Once a signer identity is known, its public session name is the Lite username and its files live under -`//_signing_host`. Provisional and legacy named -sessions are promoted to that user-owned root, so an old name such as `pgtest` -does not remain the durable namespace. The selected username is remembered per +`//_signing_host`. Provisional named sessions +are promoted to that user-owned root, so an old name such as `pgtest` does not +remain the durable namespace. The selected username is remembered per network but is not repeated in the status bar as a separate session field. `default` remains only as a compatibility/bootstrap location until a username is resolved. It is hidden from session completion and listing and cannot be @@ -388,8 +388,8 @@ other saved pairings and the signing identity are unchanged. `/session --clear ` permanently deletes that session's local signer keys, scripts, core/product storage, and permissions. `/session --clear-all` does the same for every signing-host session on the current network, including -legacy bootstrap state, while preserving other networks and pairing-host -state. Neither command deregisters an on-chain username. The interactive UI +the network's signing-host bootstrap state, while preserving other networks and +pairing-host state. Neither command deregisters an on-chain username. The interactive UI asks for `[y/N]` confirmation. `exec` treats the explicit one-shot command as confirmation and runs it immediately. Clearing an inactive named session keeps the host running; clearing the active session or all sessions stops the signing @@ -481,16 +481,14 @@ the selected id, so the newly selected product sees its own state. The next Pairing-host state follows the same identity rule under `//_pairing_host`. Before the first identity is known it uses the small `/pairing-host` bootstrap; connecting moves -legacy bootstrap data to the first resolved user. After `/logout`, connecting +that bootstrap data to the first resolved user. After `/logout`, connecting as a different user swaps to that user's KV/core namespace instead of carrying the previous user's product data forward. Product-local KV is persisted independently under each identity root as `storage/--.json`. Each document records its normalized -product id and raw product keys. On first use, the older combined -`product-storage.json` in that profile is split into those files and retained -as `product-storage.v1.json.migrated`. Product and core JSON writes use a -flushed temporary file and atomic rename. +product id and raw product keys. Product and core JSON writes use a flushed +temporary file and atomic rename. Six scripts ship under `js/scripts/`: diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md index 8babd2479..d769ef834 100644 --- a/rust/crates/truapi-host-cli/SPEC.md +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -1164,11 +1164,7 @@ user-selectable and is omitted from session completion and listing. When a managed session has no connected user, startup and bare `/session` add an actionable transcript notice directing the user to `/session `. -`/session --list` includes: - -- legacy directories under `signing-host/sessions/`; and -- network directories ending in `_signing_host`. - +`/session --list` includes the network directories ending in `_signing_host`. The active session is marked with `*`. `/session ` provisions the target before replacing the current runtime: @@ -1218,12 +1214,11 @@ phrase is not written locally until the replacement runtime activates successfully. `/session --clear ` removes the durable name shown by `/session --list`, -its identity or legacy session directory, any separate legacy product storage, -and the matching network account cached in the compatibility account store. -`/session --clear-all` removes every such session, the network's signing-host -bootstrap state, and every compatibility account record for that network. It -does not remove pairing-host state, another network's records, externally -referenced scripts, or on-chain usernames. +its identity directory, and the matching network account cached in the +compatibility account store. `/session --clear-all` removes every such session, +the network's signing-host bootstrap state, and every compatibility account +record for that network. It does not remove pairing-host state, another +network's records, externally referenced scripts, or on-chain usernames. The interactive UI describes the data loss and uses the existing `[y/N]` approval. `exec` executes these explicit one-shot commands without another @@ -1255,15 +1250,14 @@ The layout may contain compatibility paths as well as identity-owned paths: storage/ default/ .json - sessions/ # accepted legacy session layout - / pairing-host/ current-user session.json # bootstrap script metadata, when used core-storage.json # bootstrap auth/core state scripts/ - storage/ # or legacy storage/default/ + storage/ + .json _signing_host/ accounts.json @@ -1383,11 +1377,6 @@ The version `1` JSON document is: The core has already removed its product namespace before the CLI stores the raw key. Identity and host role are isolated by the parent directory. -Legacy combined `product-storage.json` keys are decoded with -`ProductStorageKey` and split into per-product files. A fully safe migration is -retained as `product-storage.v1.json.migrated`. An undecodable legacy key or -document prevents the backup rename. - Noncanonical product filenames, unsupported versions, invalid ids, and invalid hex values are ignored with warnings. diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs index 2a143d2b6..e51c73cbb 100644 --- a/rust/crates/truapi-host-cli/src/platform.rs +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -73,15 +73,8 @@ impl CliStoragePaths { .map(|user_id| network_dir.join(format!("{user_id}_pairing_host"))) .filter(|path| path.is_dir()) .unwrap_or_else(|| bootstrap_dir.clone()); - let product_storage_dir = if state_dir == bootstrap_dir - && bootstrap_dir.join("storage").join("default").is_dir() - { - bootstrap_dir.join("storage").join("default") - } else { - state_dir.join("storage") - }; Self { - product_storage_dir, + product_storage_dir: state_dir.join("storage"), state_dir, pairing_scope: Some(PairingStorageScope { network_dir, @@ -130,7 +123,7 @@ impl CliPlatform { approval: ApprovalPolicy, ui: Option, ) -> Arc { - let (product_storage_dir, legacy_product_storage_path, core_storage_path) = storage + let (product_storage_dir, core_storage_path) = storage .as_ref() .map(|paths| { if let Err(err) = fs::create_dir_all(&paths.state_dir) { @@ -142,15 +135,13 @@ impl CliPlatform { } ( Some(paths.product_storage_dir.clone()), - Some(paths.state_dir.join("product-storage.json")), Some(paths.state_dir.join("core-storage.json")), ) }) - .unwrap_or((None, None, None)); + .unwrap_or((None, None)); let product_storage = product_storage_dir .as_deref() - .zip(legacy_product_storage_path.as_deref()) - .map(|(directory, legacy)| load_product_storage(directory, legacy)) + .map(load_product_storage) .unwrap_or_default(); let core_storage = core_storage_path .as_deref() @@ -336,10 +327,7 @@ impl CliPlatform { let mut target_core = load_hex_key_map(&target_core_path); target_core.extend(carried); - let mut target_products = load_product_storage( - &target_product_dir, - &target_state.join("product-storage.json"), - ); + let mut target_products = load_product_storage(&target_product_dir); if migrating_bootstrap { target_products.extend( self.product_storage @@ -935,47 +923,9 @@ struct ProductStorageDocument { values: HashMap, } -fn load_product_storage( - directory: &Path, - legacy_path: &Path, -) -> HashMap>> { - let legacy_exists = legacy_path.is_file(); - let mut migration_safe = true; +fn load_product_storage(directory: &Path) -> HashMap>> { let mut products = HashMap::>>::new(); - if legacy_exists { - match read_string_map(legacy_path) { - Ok(values) => { - for (key, value) in values { - match ProductStorageKey::decode(&key) { - Ok(scoped) => { - products - .entry(scoped.product_id().to_string()) - .or_default() - .insert(scoped.key().to_string(), value); - } - Err(error) => { - migration_safe = false; - tracing::warn!( - path = %legacy_path.display(), - %error, - "could not migrate an unrecognized product storage key" - ); - } - } - } - } - Err(error) => { - migration_safe = false; - tracing::warn!( - path = %legacy_path.display(), - %error, - "could not decode legacy product storage" - ); - } - } - } - let entries = match fs::read_dir(directory) { Ok(entries) => Some(entries), Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, @@ -1010,36 +960,6 @@ fn load_product_storage( } } - if legacy_exists && migration_safe { - let migrated = products.iter().try_for_each(|(product_id, values)| { - save_product_storage(directory, product_id, values) - }); - match migrated { - Ok(()) => { - let backup = legacy_path.with_file_name("product-storage.v1.json.migrated"); - if backup.exists() { - tracing::warn!( - path = %legacy_path.display(), - backup = %backup.display(), - "legacy product storage was migrated but its backup path already exists" - ); - } else if let Err(error) = fs::rename(legacy_path, &backup) { - tracing::warn!( - path = %legacy_path.display(), - backup = %backup.display(), - %error, - "could not retain migrated product storage backup" - ); - } - } - Err(error) => tracing::warn!( - path = %legacy_path.display(), - %error, - "could not migrate legacy product storage" - ), - } - } - products } @@ -1797,19 +1717,21 @@ mod tests { ); } + /// A pairing login writes product KV before its username is known, so the + /// bootstrap directory's products must follow the first resolved user + /// instead of being stranded outside every identity namespace. #[test] - fn legacy_pairing_storage_moves_to_the_first_resolved_user() { + fn product_storage_written_before_the_username_carries_into_the_resolved_user() { let temporary = tempdir().expect("create pairing storage root"); let network_dir = temporary.path().join("testnet"); - let legacy_product_dir = network_dir.join("pairing-host/storage/default"); let product_key = ProductStorageKey::new("product.dot", "theme").expect("product storage key"); save_product_storage( - &legacy_product_dir, + &network_dir.join("pairing-host/storage"), "product.dot", &HashMap::from([("theme".to_string(), b"dark".to_vec())]), ) - .expect("write legacy pairing product storage"); + .expect("write bootstrap pairing product storage"); let platform = CliPlatform::new( test_network(), Some(CliStoragePaths::pairing(network_dir.clone())), @@ -1819,14 +1741,21 @@ mod tests { platform .switch_pairing_user_storage("alice.dot") - .expect("resolve legacy storage owner"); + .expect("resolve the bootstrap storage owner"); assert_eq!( futures::executor::block_on(platform.read(product_key.encode())) - .expect("read migrated product value"), + .expect("read carried product value"), Some(b"dark".to_vec()) ); - assert!(network_dir.join("alice.dot_pairing_host/storage").is_dir()); + // Persisted, not merely carried in memory: a restart must find it too. + assert_eq!( + load_product_storage(&network_dir.join("alice.dot_pairing_host/storage")), + HashMap::from([( + "product.dot".to_string(), + HashMap::from([("theme".to_string(), b"dark".to_vec())]), + )]) + ); } #[test] @@ -2021,70 +1950,4 @@ mod tests { fs::read_to_string(second_path).expect("read second session file") ); } - - #[test] - fn legacy_product_storage_migrates_and_keeps_a_backup() { - let temporary = tempdir().expect("create migration root"); - let first = ProductStorageKey::new("first.dot", "alpha").expect("first product key"); - let second = ProductStorageKey::new("second.dot", "beta").expect("second product key"); - let legacy_path = temporary.path().join("product-storage.json"); - save_string_map( - &legacy_path, - &HashMap::from([ - (first.encode(), b"one".to_vec()), - (second.encode(), b"two".to_vec()), - ]), - ) - .expect("write legacy product storage"); - - let platform = CliPlatform::new( - test_network(), - Some(test_storage_paths(temporary.path(), "test")), - ApprovalPolicy::AutoAccept, - None, - ); - - assert!(!legacy_path.exists()); - assert!( - temporary - .path() - .join("product-storage.v1.json.migrated") - .is_file() - ); - assert_eq!( - fs::read_dir(temporary.path().join("storage").join("test")) - .expect("list migrated product files") - .count(), - 2 - ); - let values = futures::executor::block_on(async { - ( - platform.read(first.encode()).await.expect("read first"), - platform.read(second.encode()).await.expect("read second"), - ) - }); - assert_eq!(values, (Some(b"one".to_vec()), Some(b"two".to_vec()))); - } - - #[test] - fn corrupt_legacy_product_storage_is_not_marked_as_migrated() { - let temporary = tempdir().expect("create corrupt migration root"); - let legacy_path = temporary.path().join("product-storage.json"); - fs::write(&legacy_path, "{not-json").expect("write corrupt legacy storage"); - - let _platform = CliPlatform::new( - test_network(), - Some(test_storage_paths(temporary.path(), "test")), - ApprovalPolicy::AutoAccept, - None, - ); - - assert!(legacy_path.is_file()); - assert!( - !temporary - .path() - .join("product-storage.v1.json.migrated") - .exists() - ); - } } diff --git a/rust/crates/truapi-host-cli/src/sessions.rs b/rust/crates/truapi-host-cli/src/sessions.rs index 1f1999fe9..511aec1f9 100644 --- a/rust/crates/truapi-host-cli/src/sessions.rs +++ b/rust/crates/truapi-host-cli/src/sessions.rs @@ -201,22 +201,11 @@ impl SessionCatalog { account_base_path: self.base_path.clone(), }); } - let identity_path = self.identity_path(name); - let legacy_path = self.role_path.join("sessions").join(name); - let path = if legacy_path.is_dir() && !identity_path.is_dir() { - legacy_path - } else { - identity_path - }; - let product_storage_dir = if path.starts_with(self.role_path.join("sessions")) { - self.role_path.join("storage").join(name) - } else { - path.join("storage") - }; + let path = self.identity_path(name); Ok(SessionProfile { name: name.to_string(), path: path.clone(), - product_storage_dir, + product_storage_dir: path.join("storage"), account_base_path: path, }) } @@ -262,27 +251,6 @@ impl SessionCatalog { pub fn list(&self) -> Result> { let mut names = Vec::new(); - let sessions_path = self.role_path.join("sessions"); - match fs::read_dir(&sessions_path) { - Ok(entries) => { - for entry in entries.filter_map(std::result::Result::ok) { - if !entry.file_type().is_ok_and(|kind| kind.is_dir()) { - continue; - } - let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else { - continue; - }; - if validate_name(&name).is_ok() && name != DEFAULT_SESSION_NAME { - names.push(name); - } - } - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(error) - .with_context(|| format!("list sessions {}", sessions_path.display())); - } - } for entry in fs::read_dir(&self.network_path) .with_context(|| format!("list host profiles {}", self.network_path.display()))? .filter_map(std::result::Result::ok) @@ -301,7 +269,6 @@ impl SessionCatalog { } } names.sort(); - names.dedup(); Ok(names) } @@ -351,42 +318,18 @@ impl SessionCatalog { } } - fn remove_profile_data(&self, profile: &SessionProfile) -> Result<()> { - if !profile.path.starts_with(&self.network_path) - || !profile.product_storage_dir.starts_with(&self.network_path) - { + fn remove_named_data(&self, name: &str) -> Result<()> { + let profile = self.profile(name)?; + if !profile.path.starts_with(&self.network_path) { anyhow::bail!( "refusing to clear session outside network root {}", self.network_path.display() ); } - remove_dir_if_exists(&profile.path)?; - if !profile.product_storage_dir.starts_with(&profile.path) { - remove_dir_if_exists(&profile.product_storage_dir)?; - } - Ok(()) + remove_dir_if_exists(&profile.path) } - fn remove_named_data(&self, name: &str) -> Result<()> { - let profile = self.profile(name)?; - self.remove_profile_data(&profile)?; - for path in [ - self.identity_path(name), - self.role_path.join("sessions").join(name), - self.role_path.join("storage").join(name), - ] { - if !path.starts_with(&self.network_path) { - anyhow::bail!( - "refusing to clear session outside network root {}", - self.network_path.display() - ); - } - remove_dir_if_exists(&path)?; - } - Ok(()) - } - - /// Move a provisional or legacy session into the user-owned host root. + /// Move a provisional session into the user-owned host root. /// /// The public session name is the Lite username. The suffix is only a /// filesystem discriminator so pairing and signing state cannot collide. @@ -410,21 +353,6 @@ impl SessionCatalog { target_path.display() ) })?; - if profile.product_storage_dir.exists() - && !profile.product_storage_dir.starts_with(&profile.path) - { - let target_storage = target_path.join("storage"); - fs::create_dir_all(&target_path)?; - fs::rename(&profile.product_storage_dir, &target_storage).with_context( - || { - format!( - "move product storage {} to {}", - profile.product_storage_dir.display(), - target_storage.display() - ) - }, - )?; - } } } let promoted = SessionProfile { @@ -578,12 +506,7 @@ fn remove_file_if_exists(path: &Path) -> Result<()> { } fn migrate_default_profile(profile: &SessionProfile, target_path: &Path) -> Result<()> { - for name in [ - "core-storage.json", - "product-storage.json", - SESSION_INFO_FILE, - PAIRED_HOSTS_FILE, - ] { + for name in ["core-storage.json", SESSION_INFO_FILE, PAIRED_HOSTS_FILE] { let source = profile.path.join(name); if source.is_file() { fs::rename(&source, target_path.join(name)) @@ -1126,10 +1049,6 @@ mod tests { let catalog = SessionCatalog::new(temporary.path().to_path_buf(), "testnet")?; let alice = catalog.ensure_profile("alice")?; let bob = catalog.ensure_profile("bob")?; - let legacy_alice = catalog.role_path.join("sessions/alice"); - let legacy_alice_storage = catalog.role_path.join("storage/alice"); - fs::create_dir_all(&legacy_alice)?; - fs::create_dir_all(&legacy_alice_storage)?; fs::write(alice.path.join("state"), "alice")?; fs::write(bob.path.join("state"), "bob")?; catalog.set_current("alice")?; @@ -1140,34 +1059,35 @@ mod tests { ); assert!(!alice.path.exists()); - assert!(!legacy_alice.exists()); - assert!(!legacy_alice_storage.exists()); assert!(bob.path.exists()); assert_eq!(catalog.current_name(), DEFAULT_SESSION_NAME); assert_eq!(catalog.list()?, vec!["bob"]); + + // The pointer is cleared, not merely left unresolvable: recreating the + // name must not silently re-select the session that was just wiped. + catalog.ensure_profile("alice")?; + assert_eq!(catalog.current_name(), DEFAULT_SESSION_NAME); Ok(()) } #[test] - fn clearing_all_sessions_removes_default_legacy_and_identity_state_only() -> Result<()> { + fn clearing_all_sessions_removes_default_and_identity_state_only() -> Result<()> { let temporary = tempdir()?; let catalog = SessionCatalog::new(temporary.path().to_path_buf(), "testnet")?; let default = catalog.ensure_profile(DEFAULT_SESSION_NAME)?; let alice = catalog.ensure_profile("alice")?; - let legacy = catalog.role_path.join("sessions/legacy"); - fs::create_dir_all(&legacy)?; fs::write(default.path.join("core-storage.json"), "{}")?; fs::write(alice.path.join("state"), "alice")?; - fs::write(legacy.join("state"), "legacy")?; let unrelated = catalog.network_path.join("pairing-host"); fs::create_dir_all(&unrelated)?; fs::write(unrelated.join("state"), "keep")?; let cleared = catalog.clear(&SessionClearTarget::All)?; - assert_eq!(cleared, vec!["alice", "legacy"]); + assert_eq!(cleared, vec!["alice"]); assert!(!catalog.role_path.exists()); assert!(!alice.path.exists()); + // The other host role on the same network is not session data. assert!(unrelated.join("state").is_file()); assert!(catalog.list()?.is_empty()); Ok(()) @@ -1209,7 +1129,7 @@ mod tests { } #[test] - fn default_profile_preserves_legacy_storage_locations() -> Result<()> { + fn default_profile_uses_the_bootstrap_storage_locations() -> Result<()> { let temporary = tempdir()?; let catalog = SessionCatalog::new(temporary.path().to_path_buf(), "testnet")?; let profile = catalog.profile(DEFAULT_SESSION_NAME)?; From 6cac8db00667e6cb0b5cfc14839a9966c1446b08 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 3 Sep 2026 11:07:21 +0200 Subject: [PATCH 04/14] fix(codegen): parse only the async_trait future return shape Every trait the parser reads is declared `#[crate::async_trait]`, so a return is either the `Pin + Send>>` desugaring or the method's own type. The explicit `impl Future + Send` arm matched neither, and it held the function's only failure path, so `unwrap_future_output` is now infallible. Cover the pass-through tail, which had no test. --- rust/crates/truapi-codegen/src/rustdoc.rs | 107 ++++------------------ 1 file changed, 17 insertions(+), 90 deletions(-) diff --git a/rust/crates/truapi-codegen/src/rustdoc.rs b/rust/crates/truapi-codegen/src/rustdoc.rs index f84eaff20..0c398741a 100644 --- a/rust/crates/truapi-codegen/src/rustdoc.rs +++ b/rust/crates/truapi-codegen/src/rustdoc.rs @@ -674,7 +674,6 @@ fn extract_method(item_id: &str, item: &Item, names: &NameContext) -> Result bool { .unwrap_or(false) } -/// Resolve the `Output = T` binding from a Send future method return. +/// Resolve the `Output = T` binding from a Send future method return, or the +/// return itself when it is not one. /// /// `async_trait` represents `async fn` as /// `Pin + Send + 'async_trait>>` in rustdoc JSON. -/// Explicit `impl Future + Send` returns are also accepted so the -/// parser remains compatible with older TrUAPI trait snapshots. -fn unwrap_future_output(output: &serde_json::Value) -> Result<&serde_json::Value> { - if let Some(future_output) = extract_async_trait_future_output(output) { - return Ok(future_output); - } - let Some(bounds) = output - .get("impl_trait") - .and_then(serde_json::Value::as_array) - else { - return Ok(output); - }; - let future = bounds - .iter() - .filter_map(|bound| bound.get("trait_bound")) - .filter_map(|bound| bound.get("trait")) - .find(|bound| { - bound - .get("path") - .and_then(serde_json::Value::as_str) - .is_some_and(|path| path_suffix(path) == "Future") - }) - .context("impl Trait return is missing its Future bound")?; - let constraints = future - .get("args") - .and_then(|args| args.get("angle_bracketed")) - .and_then(|args| args.get("constraints")) - .and_then(serde_json::Value::as_array) - .context("Future bound is missing its associated-type constraints")?; - constraints - .iter() - .find(|constraint| { - constraint.get("name").and_then(serde_json::Value::as_str) == Some("Output") - }) - .and_then(|constraint| constraint.get("binding")) - .and_then(|binding| binding.get("equality")) - .and_then(|equality| equality.get("type")) - .context("Future bound is missing its Output equality") +fn unwrap_future_output(output: &serde_json::Value) -> &serde_json::Value { + extract_async_trait_future_output(output).unwrap_or(output) } fn extract_async_trait_future_output(output: &serde_json::Value) -> Option<&serde_json::Value> { @@ -1533,55 +1497,6 @@ mod tests { ); } - #[test] - fn unwraps_send_future_output() { - let output = serde_json::json!({ - "impl_trait": [ - { - "trait_bound": { - "trait": { - "path": "core::future::Future", - "args": { - "angle_bracketed": { - "args": [], - "constraints": [ - { - "name": "Output", - "binding": { - "equality": { - "type": { - "resolved_path": { - "path": "Result", - "id": 1, - "args": null - } - } - } - } - } - ] - } - } - } - } - }, - { - "trait_bound": { - "trait": { - "path": "Send", - "id": 2, - "args": null - } - } - } - ] - }); - - let unwrapped = unwrap_future_output(&output).expect("future output"); - - assert_eq!(get_resolved_name(unwrapped).as_deref(), Some("Result")); - } - #[test] fn unwraps_async_trait_send_future_output() { let output = serde_json::json!({ @@ -1646,8 +1561,20 @@ mod tests { } }); - let unwrapped = unwrap_future_output(&output).expect("async-trait future output"); + let unwrapped = unwrap_future_output(&output); assert_eq!(get_resolved_name(unwrapped).as_deref(), Some("Result")); } + + /// A return that is not an `async_trait` future is the method's own type, so + /// it has to pass through untouched. Rejecting it here would turn every + /// non-async method into a parse failure instead of a plain return type. + #[test] + fn a_return_that_is_not_a_future_passes_through() { + let output = serde_json::json!({ + "resolved_path": { "path": "Result", "id": 1, "args": null } + }); + + assert_eq!(unwrap_future_output(&output), &output); + } } From 2dfbbb0c0cc433f5c19c3b9e0905713916599fb8 Mon Sep 17 00:00:00 2001 From: Sergey Zhuravlev Date: Wed, 2 Sep 2026 10:47:53 +0200 Subject: [PATCH 05/14] fix(server): announce the boot auth state after the initial session restore The session-store sync's boot tick now calls announce_current after reconciling, so a pairing host receives an opening AuthState (Disconnected included) without calling activateStoredSession. --- js/packages/truapi-host/README.md | 11 +-- .../tests/golden/host-callbacks.ts | 12 ++- rust/crates/truapi-platform/src/lib.rs | 12 ++- rust/crates/truapi-server/src/native.rs | 10 +-- rust/crates/truapi-server/src/runtime.rs | 90 +++++++++++++++++-- .../truapi-server/src/runtime/auth_state.rs | 7 +- .../truapi-server/src/runtime/pairing_host.rs | 5 +- 7 files changed, 109 insertions(+), 38 deletions(-) diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index 717e26f92..6fd11ec37 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -212,14 +212,15 @@ and then opens one provider per product id. ## Session lifecycle -The core owns the session; the host owns persistence and drives the transitions -below. Every one of them reports the resulting `AuthState` through the `auth` -callback, including when nothing changed — so a host may await an answer at boot -rather than treating silence as "signed out". +The core owns the session; the host owns persistence. At boot the core restores +the `AuthSession` slot on its own and reports the outcome through the `auth` +callback, `Disconnected` included, so a host waits for the first +`authStateChanged` instead of treating silence as "signed out". Every +transition below reports the resulting `AuthState` the same way. | Runtime method | Use it to | | ------------------------------- | ---------------------------------------------------------------------------- | -| `activateStoredSession()` | Restore the session in the core's `AuthSession` slot. Await before routing. | +| `activateStoredSession()` | Await the restore of the `AuthSession` slot before opening providers. | | `activateExternalSession(blob)` | Install a session the host holds itself, without writing it to core storage. | | `notifySessionStoreChanged()` | Tell the core the persisted blob may have changed; it re-reads it. | | `disconnectSession()` | Log out: clears the session and notifies the peer. | diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 7a66ac7ae..b9ccb6ee7 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -901,13 +901,11 @@ export const UserConfirmationReview: S.Codec = S.lazy( */ export interface AuthPresenter { /** - * Observe an auth state change, in transition order. A pairing host's - * session activation reports its outcome even when it is the default - * `Disconnected`, so a host that awaits activation before routing never - * has to read silence as "signed out". Every other emission, and every - * emission on a host role that has no session activation, happens only - * when the state actually changes. Default is a no-op for hosts that - * render no auth UI. + * Observe an auth state change, in transition order. A pairing host + * always receives an opening state once the core has restored the + * persisted session, `Disconnected` included; later emissions happen + * only when the state changes. Default is a no-op for hosts that render + * no auth UI. */ authStateChanged?(state: AuthState): void; } diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 7f7570686..983d42f50 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -2540,13 +2540,11 @@ pub enum LoginFailureKind { /// Host auth UI driven by core-owned [`AuthState`] transitions. pub trait AuthPresenter: Send + Sync { - /// Observe an auth state change, in transition order. A pairing host's - /// session activation reports its outcome even when it is the default - /// `Disconnected`, so a host that awaits activation before routing never - /// has to read silence as "signed out". Every other emission, and every - /// emission on a host role that has no session activation, happens only - /// when the state actually changes. Default is a no-op for hosts that - /// render no auth UI. + /// Observe an auth state change, in transition order. A pairing host + /// always receives an opening state once the core has restored the + /// persisted session, `Disconnected` included; later emissions happen + /// only when the state changes. Default is a no-op for hosts that render + /// no auth UI. fn auth_state_changed(&self, state: AuthState) { let _ = state; } diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index ede443c2c..415469e97 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -445,12 +445,10 @@ pub trait HostCallbacks: Send + Sync { /// the pairing QR UI, `Connected`/`Disconnected` as the account badge, /// `LoginFailed` as a retryable error unless its `kind` is /// `NoFreeAllowanceSlots`, which is unlikely to succeed before the period - /// rolls over, so retry should not be the primary action. A pairing host's - /// session activation reports its outcome even - /// when it is the default `Disconnected`, so a host that awaits activation - /// before routing never has to read silence as "signed out". Every other - /// emission, and every emission on a host role that has no session - /// activation, happens only when the state actually changes. + /// rolls over, so retry should not be the primary action. A pairing host + /// always receives an opening state once the core has restored the + /// persisted session, `Disconnected` included; later emissions happen + /// only when the state changes. fn auth_state_changed(&self, state: AuthState); /// Read a core-owned host-private storage slot. `key` is a SCALE-encoded diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index fbf538c8e..83ab90d3f 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -7031,6 +7031,72 @@ mod tests { ); } + #[test] + fn session_store_sync_announces_a_signed_out_boot() { + let platform = Arc::new(StubPlatform::default()); + let (_host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + + pairing_host + .clone() + .start_session_store_sync_for_tests(test_spawner()); + + wait_until( + || { + !platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + .is_empty() + }, + "boot reconcile did not report the signed-out state", + ); + assert_eq!( + *platform + .auth_states + .lock() + .expect("auth state list mutex poisoned"), + vec![AuthState::Disconnected] + ); + } + + #[test] + fn session_store_sync_announces_a_restored_boot_once() { + let stored = sso_session_info(); + let platform = Arc::new(StubPlatform { + session_blob: Some(crate::host_logic::session::encode_persisted_session( + &stored, + )), + ..Default::default() + }); + let (_host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + + pairing_host + .clone() + .start_session_store_sync_for_tests(test_spawner()); + + wait_until( + || { + !platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + .is_empty() + }, + "boot reconcile did not report the restored session", + ); + futures::executor::block_on(pairing_host.activate_stored_session()) + .expect("valid stored session activates"); + assert_eq!( + *platform + .auth_states + .lock() + .expect("auth state list mutex poisoned"), + vec![AuthState::Connected(connected_session_ui_info(&stored))] + ); + } + #[test] fn session_store_sync_replaces_valid_blob_and_broadcasts_connected() { let mut replacement = sso_session_info(); @@ -7080,16 +7146,24 @@ mod tests { ); assert!(host.test_session_state().current().is_none()); - // `set_session` bypasses the auth state cell, so the cell never left - // `Disconnected` and clearing the invalid blob emits nothing. Only a - // session activation announces an unchanged state; a store-sync tick - // that finds nothing must not flash signed out at a signed-in host. - assert!( - platform + // `set_session` bypasses the auth state cell, so the clear is not a + // transition; the boot tick's announcement is the only emission. + wait_until( + || { + !platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + .is_empty() + }, + "boot reconcile did not report the cleared session", + ); + assert_eq!( + *platform .auth_states .lock() - .expect("auth state list mutex poisoned") - .is_empty() + .expect("auth state list mutex poisoned"), + vec![AuthState::Disconnected] ); } diff --git a/rust/crates/truapi-server/src/runtime/auth_state.rs b/rust/crates/truapi-server/src/runtime/auth_state.rs index 282727e4d..27dad1e50 100644 --- a/rust/crates/truapi-server/src/runtime/auth_state.rs +++ b/rust/crates/truapi-server/src/runtime/auth_state.rs @@ -16,10 +16,9 @@ use crate::runtime::login_failure::classify_login_failure; /// observed). The cancel channel for an in-flight login lives inside the /// in-flight login states, making its registration atomic with the transition. /// -/// Session activation calls [`AuthStateMachine::announce_current`] when it is -/// done, so an activation whose outcome changed nothing — a host that boots -/// signed out, or one whose blob failed to decode — still reports an answer the -/// host can tell apart from "no answer yet". Only the first emission can come +/// The boot-time restore and explicit session activations call +/// [`AuthStateMachine::announce_current`] when done, so the host gets an +/// opening state even when nothing changed. Only the first emission can come /// from an announcement; everything after it is a real change. #[derive(Clone)] pub(crate) struct AuthStateMachine { diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index d68e5f231..bab85603f 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -566,7 +566,9 @@ impl PairingHost { } /// Spawn the background task that re-reads the persisted auth session on - /// every change notification and reconciles the in-memory session. + /// every change notification and reconciles the in-memory session. The + /// first tick runs at boot and announces its outcome even when nothing + /// changed, so the host always receives an opening auth state. #[instrument(skip_all, fields(runtime.method = "session_store.sync"))] pub(crate) fn start_session_store_sync(self: Arc, spawner: Spawner) { let pairing_host = Arc::downgrade(&self); @@ -598,6 +600,7 @@ impl PairingHost { cleared_after_read_error = true; } } + pairing_host.auth_state.announce_current(); } })); } From a9266705a4c762be7eaa4fca57ef787a5ecab543 Mon Sep 17 00:00:00 2001 From: Sergey Zhuravlev Date: Wed, 2 Sep 2026 10:56:15 +0200 Subject: [PATCH 06/14] chore(ios): sync UniFFI bindings for the HostCallbacks auth_state_changed doc --- .../Sources/TrUAPIHost/truapi_server.swift | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift index 437a02207..a54cc1a3e 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift @@ -705,12 +705,10 @@ public protocol HostCallbacks: AnyObject, Sendable { * the pairing QR UI, `Connected`/`Disconnected` as the account badge, * `LoginFailed` as a retryable error unless its `kind` is * `NoFreeAllowanceSlots`, which is unlikely to succeed before the period - * rolls over, so retry should not be the primary action. A pairing host's - * session activation reports its outcome even - * when it is the default `Disconnected`, so a host that awaits activation - * before routing never has to read silence as "signed out". Every other - * emission, and every emission on a host role that has no session - * activation, happens only when the state actually changes. + * rolls over, so retry should not be the primary action. A pairing host + * always receives an opening state once the core has restored the + * persisted session, `Disconnected` included; later emissions happen + * only when the state changes. */ func authStateChanged(state: AuthState) @@ -1005,12 +1003,10 @@ open func remotePermission(request: RemotePermission)async throws -> Bool { * the pairing QR UI, `Connected`/`Disconnected` as the account badge, * `LoginFailed` as a retryable error unless its `kind` is * `NoFreeAllowanceSlots`, which is unlikely to succeed before the period - * rolls over, so retry should not be the primary action. A pairing host's - * session activation reports its outcome even - * when it is the default `Disconnected`, so a host that awaits activation - * before routing never has to read silence as "signed out". Every other - * emission, and every emission on a host role that has no session - * activation, happens only when the state actually changes. + * rolls over, so retry should not be the primary action. A pairing host + * always receives an opening state once the core has restored the + * persisted session, `Disconnected` included; later emissions happen + * only when the state changes. */ open func authStateChanged(state: AuthState) {try! rustCall() { uniffiCallStatus in @@ -5900,7 +5896,7 @@ private let initializationResult: InitializationResult = { if (uniffi_truapi_server_checksum_method_hostcallbacks_remote_permission() != 12868) { return InitializationResult.apiChecksumMismatch } - if (uniffi_truapi_server_checksum_method_hostcallbacks_auth_state_changed() != 50346) { + if (uniffi_truapi_server_checksum_method_hostcallbacks_auth_state_changed() != 35488) { return InitializationResult.apiChecksumMismatch } if (uniffi_truapi_server_checksum_method_hostcallbacks_core_storage_read() != 61703) { From 61cf8f4e371d2101fba97fba70a725aa4f480b58 Mon Sep 17 00:00:00 2001 From: Sergey Zhuravlev Date: Wed, 2 Sep 2026 17:26:25 +0200 Subject: [PATCH 07/14] refactor(server): make the boot announcement explicit in the session store sync task The notifier no longer injects a synthetic initial tick. The sync task reconciles once at boot, announces the outcome, and then loops over real change notifications without announcing, so the one-shot opening state is visible in the control flow instead of depending on the announced guard. Adds a test that a change tick against an unchanged store stays silent. --- .../src/host_logic/session_store.rs | 19 +---- rust/crates/truapi-server/src/runtime.rs | 47 +++++++++++- .../truapi-server/src/runtime/pairing_host.rs | 75 ++++++++++++------- 3 files changed, 96 insertions(+), 45 deletions(-) diff --git a/rust/crates/truapi-server/src/host_logic/session_store.rs b/rust/crates/truapi-server/src/host_logic/session_store.rs index f6ac8a374..869a01cf4 100644 --- a/rust/crates/truapi-server/src/host_logic/session_store.rs +++ b/rust/crates/truapi-server/src/host_logic/session_store.rs @@ -7,7 +7,7 @@ use std::sync::{Arc, Mutex}; use futures::channel::mpsc; -use futures::stream::{self, BoxStream, StreamExt}; +use futures::stream::BoxStream; /// Fan-out notifier for host session-storage change ticks. #[derive(Default)] @@ -30,14 +30,14 @@ impl SessionStoreChangeNotifier { subscribers.retain(|tx| tx.unbounded_send(()).is_ok()); } - /// Subscribe to storage-change ticks, including one initial tick. + /// Subscribe to storage-change ticks. pub fn subscribe(&self) -> BoxStream<'static, ()> { let (tx, rx) = mpsc::unbounded(); self.subscribers .lock() .expect("session-store notifier mutex poisoned") .push(tx); - Box::pin(stream::once(async {}).chain(rx)) + Box::pin(rx) } } @@ -47,21 +47,11 @@ mod tests { use futures::executor::block_on; use futures::{FutureExt, StreamExt}; - #[test] - fn subscribe_emits_initial_tick() { - let notifier = SessionStoreChangeNotifier::new(); - let mut ticks = notifier.subscribe(); - - assert!(block_on(ticks.next()).is_some()); - } - #[test] fn notify_broadcasts_to_subscribers() { let notifier = SessionStoreChangeNotifier::new(); let mut first = notifier.subscribe(); let mut second = notifier.subscribe(); - let _ = block_on(first.next()); - let _ = block_on(second.next()); notifier.notify(); @@ -88,10 +78,9 @@ mod tests { } #[test] - fn no_tick_without_notify_after_initial() { + fn no_tick_without_notify() { let notifier = SessionStoreChangeNotifier::new(); let mut ticks = notifier.subscribe(); - let _ = block_on(ticks.next()); assert!(ticks.next().now_or_never().is_none()); } diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 83ab90d3f..7390fca8f 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -7097,6 +7097,49 @@ mod tests { ); } + #[test] + fn session_store_sync_stays_silent_on_an_unchanged_tick() { + let stored = sso_session_info(); + let platform = Arc::new(StubPlatform { + session_blob: Some(crate::host_logic::session::encode_persisted_session( + &stored, + )), + ..Default::default() + }); + let (_host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + + pairing_host + .clone() + .start_session_store_sync_for_tests(test_spawner()); + wait_until( + || { + !platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + .is_empty() + }, + "boot reconcile did not report the restored session", + ); + + pairing_host.notify_session_store_changed(); + wait_until( + || pairing_host.session_store_change_ticks_for_tests() == 1, + "session store sync did not process the change tick", + ); + + // The store still holds the same session, so the tick is not a + // transition and must not repeat the opening state. + assert_eq!( + *platform + .auth_states + .lock() + .expect("auth state list mutex poisoned"), + vec![AuthState::Connected(connected_session_ui_info(&stored))] + ); + } + #[test] fn session_store_sync_replaces_valid_blob_and_broadcasts_connected() { let mut replacement = sso_session_info(); @@ -7192,8 +7235,8 @@ mod tests { assert_eq!(*session_clears.lock().unwrap(), 1); } - /// A persistently failing read clears the backing store once for the - /// initial sync tick. Further clears require explicit host notifications. + /// A persistently failing read clears the backing store once at boot. + /// Further clears require explicit host notifications. #[test] fn session_store_sync_clears_once_on_initial_persistent_read_error() { let session_clears = Arc::new(Mutex::new(0)); diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index bab85603f..af367369c 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -251,6 +251,27 @@ enum StoredSessionActivationError { Changed, } +/// State carried across the reconciles of one session store sync task. +#[derive(Default)] +struct SessionStoreSync { + /// Clearing the store can itself notify the sync subscription; clear at + /// most once per read-error streak so a persistently failing read cannot + /// spin the task through its own clear notifications. + cleared_after_read_error: bool, +} + +impl SessionStoreSync { + /// Re-read the persisted auth session and reconcile the in-memory one. + async fn reconcile(&mut self, pairing_host: &PairingHost) { + self.cleared_after_read_error = matches!( + pairing_host + .reconcile_stored_session(!self.cleared_after_read_error, true) + .await, + Err(StoredSessionActivationError::Read(_)) + ); + } +} + /// Remote account authority for a pairing host. pub(crate) struct PairingHost { /// Host platform backing all syscalls. @@ -282,6 +303,9 @@ pub(crate) struct PairingHost { session_lifecycle: Mutex, #[cfg(test)] external_session_activation_pause: Mutex, oneshot::Receiver<()>)>>, + /// Change notifications the sync task has finished reconciling. + #[cfg(test)] + session_store_change_ticks: std::sync::atomic::AtomicUsize, /// Self-reference captured by the spawned disconnect-monitor task. weak_self: Weak, /// Task spawner for background monitors. @@ -316,6 +340,8 @@ impl PairingHost { session_lifecycle: Mutex::new(SessionLifecycle::default()), #[cfg(test)] external_session_activation_pause: Mutex::new(None), + #[cfg(test)] + session_store_change_ticks: std::sync::atomic::AtomicUsize::new(0), weak_self: weak_self.clone(), spawner: services.spawner.clone(), }) @@ -358,6 +384,13 @@ impl PairingHost { self.start_session_store_sync(spawner); } + /// Change notifications the sync task has finished reconciling. + #[cfg(test)] + pub(crate) fn session_store_change_ticks_for_tests(&self) -> usize { + self.session_store_change_ticks + .load(std::sync::atomic::Ordering::SeqCst) + } + /// Test alias for [`Self::start_remote_monitor_for_current_session`]. #[cfg(test)] pub(crate) fn start_session_supervision_for_current_session(&self) { @@ -565,42 +598,28 @@ impl PairingHost { Ok(()) } - /// Spawn the background task that re-reads the persisted auth session on - /// every change notification and reconciles the in-memory session. The - /// first tick runs at boot and announces its outcome even when nothing - /// changed, so the host always receives an opening auth state. + /// Spawn the background task that keeps the in-memory session in step + /// with the persisted auth session. It reconciles once at boot and + /// announces the outcome, so the host always receives an opening auth + /// state, then reconciles again on every change notification. #[instrument(skip_all, fields(runtime.method = "session_store.sync"))] pub(crate) fn start_session_store_sync(self: Arc, spawner: Spawner) { let pairing_host = Arc::downgrade(&self); spawner(Box::pin(async move { - let Some(current) = pairing_host.upgrade() else { - return; - }; - let mut ticks = current.session_store_changes.subscribe(); - drop(current); - // Clearing the store can itself notify this subscription; clear at - // most once per read-error streak so a persistently failing read - // cannot spin the loop through its own clear notifications. - let mut cleared_after_read_error = false; + let mut ticks = self.session_store_changes.subscribe(); + let mut sync = SessionStoreSync::default(); + sync.reconcile(&self).await; + self.auth_state.announce_current(); + drop(self); while ticks.next().await.is_some() { let Some(pairing_host) = pairing_host.upgrade() else { break; }; - match pairing_host - .reconcile_stored_session(!cleared_after_read_error, true) - .await - { - Ok(()) - | Err(StoredSessionActivationError::Missing) - | Err(StoredSessionActivationError::Invalid(_)) - | Err(StoredSessionActivationError::Changed) => { - cleared_after_read_error = false; - } - Err(StoredSessionActivationError::Read(_)) => { - cleared_after_read_error = true; - } - } - pairing_host.auth_state.announce_current(); + sync.reconcile(&pairing_host).await; + #[cfg(test)] + pairing_host + .session_store_change_ticks + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); } })); } From 3d668051a51273e3e5dcd2bed02d57b64c9e01cf Mon Sep 17 00:00:00 2001 From: Sergey Zhuravlev Date: Wed, 2 Sep 2026 18:32:52 +0200 Subject: [PATCH 08/14] fix(server): keep the session store sync task on a weak host reference The spawned task upgrades its weak reference when it first runs and bails if the runtime is already gone, so a pending boot reconcile never keeps a dropped runtime alive or emits to its platform. --- .../truapi-server/src/runtime/pairing_host.rs | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index af367369c..6998a0052 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -7,6 +7,8 @@ mod sso_channel; use std::collections::HashMap; +#[cfg(test)] +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, Weak}; use futures::channel::oneshot; @@ -305,7 +307,7 @@ pub(crate) struct PairingHost { external_session_activation_pause: Mutex, oneshot::Receiver<()>)>>, /// Change notifications the sync task has finished reconciling. #[cfg(test)] - session_store_change_ticks: std::sync::atomic::AtomicUsize, + session_store_change_ticks: AtomicUsize, /// Self-reference captured by the spawned disconnect-monitor task. weak_self: Weak, /// Task spawner for background monitors. @@ -341,7 +343,7 @@ impl PairingHost { #[cfg(test)] external_session_activation_pause: Mutex::new(None), #[cfg(test)] - session_store_change_ticks: std::sync::atomic::AtomicUsize::new(0), + session_store_change_ticks: AtomicUsize::new(0), weak_self: weak_self.clone(), spawner: services.spawner.clone(), }) @@ -387,8 +389,7 @@ impl PairingHost { /// Change notifications the sync task has finished reconciling. #[cfg(test)] pub(crate) fn session_store_change_ticks_for_tests(&self) -> usize { - self.session_store_change_ticks - .load(std::sync::atomic::Ordering::SeqCst) + self.session_store_change_ticks.load(Ordering::SeqCst) } /// Test alias for [`Self::start_remote_monitor_for_current_session`]. @@ -605,12 +606,16 @@ impl PairingHost { #[instrument(skip_all, fields(runtime.method = "session_store.sync"))] pub(crate) fn start_session_store_sync(self: Arc, spawner: Spawner) { let pairing_host = Arc::downgrade(&self); + drop(self); spawner(Box::pin(async move { - let mut ticks = self.session_store_changes.subscribe(); + let Some(booting) = pairing_host.upgrade() else { + return; + }; + let mut ticks = booting.session_store_changes.subscribe(); let mut sync = SessionStoreSync::default(); - sync.reconcile(&self).await; - self.auth_state.announce_current(); - drop(self); + sync.reconcile(&booting).await; + booting.auth_state.announce_current(); + drop(booting); while ticks.next().await.is_some() { let Some(pairing_host) = pairing_host.upgrade() else { break; @@ -619,7 +624,7 @@ impl PairingHost { #[cfg(test)] pairing_host .session_store_change_ticks - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + .fetch_add(1, Ordering::SeqCst); } })); } From 26fa293c74daf8b26fe74fd305a727487c02172d Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 3 Sep 2026 10:53:00 +0200 Subject: [PATCH 09/14] fix: use runtime statement-slot context Proofs must include the live network suffix or the People runtime rejects them as BadProof. --- rust/crates/truapi-host-cli/src/main.rs | 31 +++- .../tests/live_people_chain.rs | 4 + .../runtime/signing_host/allowance_renewal.rs | 4 + .../src/runtime/signing_host/sso_responder.rs | 13 +- .../src/runtime/statement_allowance.rs | 34 +++- .../runtime/statement_allowance/renewal.rs | 8 + .../src/runtime/statement_allowance/slot.rs | 173 +++++++++++++++--- rust/crates/truapi-server/src/test_support.rs | 21 ++- 8 files changed, 253 insertions(+), 35 deletions(-) diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index f6cd0046e..42bb3a267 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -795,6 +795,9 @@ async fn run_alloc_check( let chain_state = alloc::fetch_chain_state(&rpc) .await .map_err(anyhow::Error::msg)?; + let network_suffix = alloc::slot::read_network_suffix(&rpc) + .await + .map_err(anyhow::Error::msg)?; println!( "chain: specVersion={} txVersion={} genesis=0x{}", chain_state.spec_version, @@ -841,16 +844,33 @@ async fn run_alloc_check( continue; } print!("{}: ", candidate.collection); - report_slot_scan(&rpc, &metadata, *candidate, period, &target, now).await?; + report_slot_scan( + &rpc, + &metadata, + *candidate, + &network_suffix, + period, + &target, + now, + ) + .await?; } if submit { if memberships.is_empty() { bail!("cannot submit: member not in any ring"); } - let scans = alloc::scan_collections(&rpc, &metadata, &candidates, period, &target, true) - .await - .map_err(anyhow::Error::msg)?; + let scans = alloc::scan_collections( + &rpc, + &metadata, + &candidates, + &network_suffix, + period, + &target, + true, + ) + .await + .map_err(anyhow::Error::msg)?; match alloc::register_statement_account_pooled( &rpc, &metadata, @@ -860,6 +880,7 @@ async fn run_alloc_check( alloc::PooledRegistrationParams { target: &target, period, + network_suffix: &network_suffix, reuse_existing: true, // A diagnostic that submits behaves as it did before pooling, // where a full table was replaced rather than reported. @@ -893,6 +914,7 @@ async fn report_slot_scan( rpc: &alloc::rpc::RpcClient, metadata: &alloc::extension::Metadata, candidate: alloc::CollectionCandidate, + network_suffix: &[u8], period: u32, target: &[u8; 32], now: u64, @@ -903,6 +925,7 @@ async fn report_slot_scan( alloc::slot::SlotScan { collection: candidate.collection, entropy: candidate.entropy, + network_suffix, period, target, excluded: &[], diff --git a/rust/crates/truapi-host-cli/tests/live_people_chain.rs b/rust/crates/truapi-host-cli/tests/live_people_chain.rs index c8bcc7596..0577fefa9 100644 --- a/rust/crates/truapi-host-cli/tests/live_people_chain.rs +++ b/rust/crates/truapi-host-cli/tests/live_people_chain.rs @@ -103,6 +103,9 @@ async fn scanning_a_live_period_answers_without_erroring() { .await .expect("read the live chain context"); let period = current_period(); + let network_suffix = alloc::slot::read_network_suffix(&rpc) + .await + .expect("read the live network suffix"); // Entropy and target are throwaway: no alias derived from them owns a slot, // so the scan must offer a free one or report the table full — never error. @@ -112,6 +115,7 @@ async fn scanning_a_live_period_answers_without_erroring() { alloc::slot::SlotScan { collection: PersonhoodCollection::LitePeople, entropy: [0x11; 32], + network_suffix: &network_suffix, period, target: &[0x22; 32], excluded: &[], diff --git a/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs b/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs index bcbd17725..0ecb6a5b9 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs @@ -411,6 +411,9 @@ pub(super) async fn renew_now( let chain_state = fetch_chain_state(&rpc) .await .map_err(|err| err.to_string())?; + let network_suffix = statement_allowance::slot::read_network_suffix(&rpc) + .await + .map_err(|err| err.to_string())?; // Every ring back to index 0, because a membership that stopped being // re-included still proves against the ring that holds it. let memberships = find_including_rings(&rpc, &metadata, &candidates, u32::MAX) @@ -426,6 +429,7 @@ pub(super) async fn renew_now( rpc: &rpc, metadata: &metadata, chain_state: &chain_state, + network_suffix: &network_suffix, candidates: &candidates, memberships: &memberships, }; 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 c5f3908dc..5c96daf91 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 @@ -1071,6 +1071,7 @@ pub(super) async fn allocate_statement_store_allowance( .await?; let rpc = client.rpc(); let chain = services.chain_context.get(&client).await?; + let network_suffix = statement_allowance::slot::read_network_suffix(rpc).await?; let period = statement_allowance::slot::current_period(current_unix_secs()?); let reuse_existing = matches!(policy, OnExistingAllowancePolicy::Ignore); @@ -1087,6 +1088,7 @@ pub(super) async fn allocate_statement_store_allowance( rpc, &chain.metadata, &candidates, + &network_suffix, period, &target, reuse_existing, @@ -1120,6 +1122,7 @@ pub(super) async fn allocate_statement_store_allowance( PooledRegistrationParams { target: &target, period, + network_suffix: &network_suffix, reuse_existing, // Connecting a product must not revoke another product's allowance. // A full period is reported as exhaustion; reclaiming space is the @@ -1773,6 +1776,10 @@ mod tests { "state_getMetadata", format!(r#""0x{}""#, hex::encode(PEOPLE_METADATA)), ), + ( + "state_getStorage", + format!(r#""0x{}""#, hex::encode(b"paseo".to_vec().encode())), + ), ( "state_getStorage", format!(r#""0x{}""#, hex::encode(&slot_entry)), @@ -1829,14 +1836,14 @@ mod tests { .any(|method| method.starts_with("author_submit")), "an extrinsic was submitted for an allowance already in place: {methods:?}" ); - // One slot read answered it; the scan stopped at the first match. + // The suffix and one slot read answered it; the scan stopped at the first match. assert_eq!( methods .iter() .filter(|method| *method == "state_getStorage") .count(), - 1, - "expected a single slot read: {methods:?}" + 2, + "expected one suffix and one slot read: {methods:?}" ); } diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index c312344d5..4e3f4757d 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -411,6 +411,8 @@ pub struct RegistrationParams<'a> { pub target: &'a [u8; 32], /// Statement-store period for which the registration is requested. pub period: u32, + /// Runtime-wide suffix used for product-scoped aliases and proofs. + pub network_suffix: &'a [u8], /// Ring parameters used to build the membership proof. pub ring: &'a RingParams, /// Whether an existing registration for this period may be reused. @@ -612,6 +614,7 @@ pub async fn register_statement_account( slot::SlotScan { collection, entropy, + network_suffix: params.network_suffix, period: params.period, target: params.target, excluded: &skipped_duplicate_slots, @@ -668,7 +671,7 @@ pub async fn register_statement_account( }, }; - let context = slot::derive_slot_context(params.period, seq); + let context = slot::derive_slot_context(params.network_suffix, params.period, seq); let call = extrinsic::build_set_statement_store_account_call( metadata, params.period, @@ -691,7 +694,15 @@ pub async fn register_statement_account( match rpc.submit_and_watch(&extrinsic).await { Ok(block_hash) => { - if slot::read_slot_account_at(rpc, entropy, params.period, seq, &block_hash).await? + if slot::read_slot_account_at( + rpc, + entropy, + params.network_suffix, + params.period, + seq, + &block_hash, + ) + .await? != Some(*params.target) { return Err(SlotError::RegistrationVerificationMismatch { @@ -753,6 +764,7 @@ pub async fn scan_collections( rpc: &RpcClient, metadata: &Metadata, candidates: &[CollectionCandidate], + network_suffix: &[u8], period: u32, target: &[u8; 32], reuse_existing: bool, @@ -770,6 +782,7 @@ pub async fn scan_collections( slot::SlotScan { collection, entropy: candidate.entropy, + network_suffix, period, target, excluded: &[], @@ -816,6 +829,8 @@ pub struct PooledRegistrationParams<'a> { pub target: &'a [u8; 32], /// Statement-store period for which the registration is requested. pub period: u32, + /// Runtime-wide suffix used for product-scoped aliases and proofs. + pub network_suffix: &'a [u8], /// Whether an existing registration for this period may be reused. pub reuse_existing: bool, /// Whether a live slot may be replaced once every collection is full. @@ -964,6 +979,7 @@ pub async fn register_statement_account_pooled( RegistrationParams { target: params.target, period: params.period, + network_suffix: params.network_suffix, ring: &membership.ring, reuse_existing: params.reuse_existing, preselected: Some(choice), @@ -1628,6 +1644,7 @@ mod tests { RegistrationParams { target: &[0x22; 32], period: 7, + network_suffix: b"paseo", ring: &ring, reuse_existing: true, preselected, @@ -1695,7 +1712,8 @@ mod tests { let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); let outcome = futures::executor::block_on(async { - let scans = scan_collections(&rpc, &metadata, &candidates, 7, &target, true).await?; + let scans = + scan_collections(&rpc, &metadata, &candidates, b"paseo", 7, &target, true).await?; register_statement_account_pooled( &rpc, &metadata, @@ -1705,6 +1723,7 @@ mod tests { PooledRegistrationParams { target: &target, period: 7, + network_suffix: b"paseo", reuse_existing: true, allow_eviction, protected, @@ -1736,7 +1755,8 @@ mod tests { let rpc = RpcClient::new(HostRpcClient::new(scripted)); futures::executor::block_on(async { - let scans = scan_collections(&rpc, &metadata, &candidates, 7, &target, true).await?; + let scans = + scan_collections(&rpc, &metadata, &candidates, b"paseo", 7, &target, true).await?; register_statement_account_pooled( &rpc, &metadata, @@ -1746,6 +1766,7 @@ mod tests { PooledRegistrationParams { target: &target, period: 7, + network_suffix: b"paseo", reuse_existing: true, allow_eviction: true, protected: &[], @@ -2199,6 +2220,7 @@ mod tests { &rpc, &metadata, &candidates, + b"paseo", 7, &target, true, @@ -2263,6 +2285,7 @@ mod tests { RegistrationParams { target: &[0x22; 32], period: 7, + network_suffix: b"paseo", ring: &ring, reuse_existing: true, preselected: None, @@ -2322,6 +2345,7 @@ mod tests { RegistrationParams { target: &[0x22; 32], period: 7, + network_suffix: b"paseo", ring: &ring, reuse_existing: true, preselected: None, @@ -2373,6 +2397,7 @@ mod tests { RegistrationParams { target: &[0x22; 32], period: 7, + network_suffix: b"paseo", ring: &ring, reuse_existing: true, preselected: None, @@ -2422,6 +2447,7 @@ mod tests { RegistrationParams { target: &[0x22; 32], period: 7, + network_suffix: b"paseo", ring: &ring, reuse_existing: true, preselected: None, diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs index 21f965541..d491cb225 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs @@ -149,6 +149,8 @@ pub struct RenewalChainContext<'a> { pub metadata: &'a Metadata, /// Signed-extension chain state. pub chain_state: &'a ChainState, + /// Runtime-wide suffix used for product-scoped aliases and proofs. + pub network_suffix: &'a [u8], /// Every collection the host can derive aliases for, so an allowance already /// held in a collection whose ring cannot currently be proved is still seen. pub candidates: &'a [CollectionCandidate], @@ -186,6 +188,7 @@ pub async fn renew_targets( context.rpc, context.metadata, context.candidates, + context.network_suffix, period, &target.account_id, true, @@ -218,6 +221,7 @@ pub async fn renew_targets( context.rpc, context.metadata, context.candidates, + context.network_suffix, period, &target.account_id, true, @@ -235,6 +239,7 @@ pub async fn renew_targets( PooledRegistrationParams { target: &target.account_id, period, + network_suffix: context.network_suffix, reuse_existing: true, // Renewal exists to keep the ledger's targets alive across a // period boundary, so it may reclaim space when full. @@ -446,6 +451,7 @@ mod tests { rpc: &rpc, metadata: &metadata, chain_state: &chain_state, + network_suffix: b"paseo", candidates: &candidates, memberships: &memberships, }; @@ -534,6 +540,7 @@ mod tests { rpc: &rpc, metadata: &metadata, chain_state: &chain_state, + network_suffix: b"paseo", candidates: &candidates, memberships: &memberships, }; @@ -633,6 +640,7 @@ mod tests { rpc: &rpc, metadata: &metadata, chain_state: &chain_state, + network_suffix: b"paseo", candidates: &candidates, memberships: &memberships, }; 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 a28da7a1d..b25d09001 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs @@ -1,13 +1,13 @@ //! StatementStore allowance slot selection. //! //! An allowance is claimed at `(period, seq)`. The slot is bound to a 32-byte -//! `SSS_SLOT` context; occupancy is read from +//! product context; occupancy is read from //! `Resources.StatementStoreAllowances[period][alias]`, where the alias is //! derived from OUR bandersnatch entropy in that slot context. Mirrors //! signing-bot `allowance.ts` / `allowance-slots.ts`. -use parity_scale_codec::{Decode, Encode}; -use sp_crypto_hashing::twox_128; +use parity_scale_codec::{Decode, DecodeAll, Encode}; +use sp_crypto_hashing::{blake2_256, twox_128}; use thiserror::Error; use verifiable::Error as VerifiableError; use verifiable::GenerateVerifiable; @@ -22,6 +22,10 @@ use super::view; /// StatementStore allowance period: one UTC day, in seconds. pub const STATEMENT_STORE_PERIOD_SECONDS: u64 = 86_400; +const PRODUCT_CONTEXT_PREFIX: &[u8] = b"product/peopl."; +const STATEMENT_STORE_CONTEXT_PREFIX: &[u8] = b"sys/"; +const STATEMENT_STORE_CONTEXT_FAMILY: u32 = 2; +const MAX_NETWORK_SUFFIX_LENGTH: usize = 16; /// Bulletin long-term-storage claim context prefix. const LONG_TERM_STORAGE_CONTEXT_PREFIX: &[u8] = b"pop:polkadot.net/rsc-lts"; /// Ring-VRF alias context prefix for an Asset Hub PGAS claim. @@ -104,6 +108,18 @@ pub enum SlotError { /// `Timestamp.Now` was absent or undecodable, so slot ages cannot be judged. #[error("Timestamp.Now missing from chain state")] MissingChainTimestamp, + /// The runtime-wide product context suffix was absent from chain storage. + #[error("NetworkSuffix.NetworkSuffix missing from chain state")] + MissingNetworkSuffix, + /// The runtime-wide product context suffix did not have its declared SCALE shape. + #[error("NetworkSuffix.NetworkSuffix is not a valid SCALE byte vector: {0}")] + NetworkSuffixDecode(#[source] parity_scale_codec::Error), + /// The runtime-wide product context suffix was outside its declared bounds. + #[error("NetworkSuffix.NetworkSuffix length {len}, expected 1..={MAX_NETWORK_SUFFIX_LENGTH}")] + InvalidNetworkSuffixLength { + /// Actual suffix length. + len: usize, + }, /// Registration reached a block but the slot was not held by the target. #[error( "registration reached block {block_hash} but slot (period {period}, seq {seq}) is not held by the target account" @@ -152,22 +168,29 @@ pub fn current_long_term_storage_period( Ok((now_seconds / u64::from(period_duration)) as u32) } -/// Derive the 32-byte StatementStore slot context: -/// `"SSS_SLOT:" ‖ u32be(period) ‖ u32be(seq) ‖ 0x20 fill`. -pub fn derive_slot_context(period: u32, seq: u32) -> [u8; 32] { - let mut ctx = [0x20u8; 32]; - ctx[..9].copy_from_slice(b"SSS_SLOT:"); - ctx[9..13].copy_from_slice(&period.to_be_bytes()); - ctx[13..17].copy_from_slice(&seq.to_be_bytes()); - ctx +/// Derive the network-scoped 32-byte StatementStore slot context. +pub fn derive_slot_context(network_suffix: &[u8], period: u32, seq: u32) -> [u8; 32] { + let mut suffix = [0u8; 32]; + suffix[..4].copy_from_slice(STATEMENT_STORE_CONTEXT_PREFIX); + suffix[4..8].copy_from_slice(&STATEMENT_STORE_CONTEXT_FAMILY.to_le_bytes()); + suffix[8..12].copy_from_slice(&period.to_le_bytes()); + suffix[12..16].copy_from_slice(&seq.to_le_bytes()); + + let mut preimage = Vec::with_capacity( + PRODUCT_CONTEXT_PREFIX.len() + network_suffix.len() + b"/".len() + suffix.len(), + ); + preimage.extend_from_slice(PRODUCT_CONTEXT_PREFIX); + preimage.extend_from_slice(network_suffix); + preimage.push(b'/'); + preimage.extend_from_slice(&suffix); + blake2_256(&preimage) } /// Derive the 32-byte Asset Hub PGAS claim context: /// `"pop:gas:" ‖ u32le(day) ‖ u32le(slot_index) ‖ zero fill`. /// -/// The two integers are little-endian here, unlike the big-endian statement-store -/// and long-term-storage contexts. The mobile wallet writes them this way and the -/// runtime verifies against the same bytes, so the layout is not ours to tidy. +/// The mobile wallet writes the integers in little-endian order and the runtime +/// verifies against the same bytes, so the layout is not ours to tidy. pub fn derive_pgas_context(day: u32, slot_index: u32) -> [u8; 32] { let mut ctx = [0u8; 32]; ctx[..PGAS_CONTEXT_PREFIX.len()].copy_from_slice(PGAS_CONTEXT_PREFIX); @@ -191,11 +214,12 @@ pub fn derive_long_term_storage_context(period: u32, counter: u8) -> [u8; 32] { /// The slot alias for our `entropy` at `(period, seq)`. pub fn slot_alias( entropy: [u8; 32], + network_suffix: &[u8], period: u32, seq: u32, ) -> Result<[u8; 32], StatementAllowanceError> { let secret = BandersnatchVrfVerifiable::new_secret(entropy); - let context = derive_slot_context(period, seq); + let context = derive_slot_context(network_suffix, period, seq); BandersnatchVrfVerifiable::alias_in_context(&secret, &context).map_err(|err| { SlotError::AliasInContext { context: "statement-store slot", @@ -366,6 +390,27 @@ fn timestamp_now_key() -> Vec { .concat() } +fn network_suffix_key() -> Vec { + [ + twox_128(b"NetworkSuffix").as_slice(), + twox_128(b"NetworkSuffix").as_slice(), + ] + .concat() +} + +/// Read the runtime-wide suffix used for product-scoped proof contexts. +pub async fn read_network_suffix(rpc: &RpcClient) -> Result, StatementAllowanceError> { + let bytes = rpc + .get_storage(&network_suffix_key()) + .await? + .ok_or(SlotError::MissingNetworkSuffix)?; + let suffix = Vec::::decode_all(&mut &bytes[..]).map_err(SlotError::NetworkSuffixDecode)?; + if suffix.is_empty() || suffix.len() > MAX_NETWORK_SUFFIX_LENGTH { + return Err(SlotError::InvalidNetworkSuffixLength { len: suffix.len() }.into()); + } + Ok(suffix) +} + /// The chain's clock in unix seconds, decoded from `Timestamp.Now` milliseconds. /// /// Slot ages are judged against this rather than the host clock, which runs up to @@ -399,11 +444,12 @@ pub async fn replacement_cooldown( pub async fn read_slot_account_at( rpc: &RpcClient, entropy: [u8; 32], + network_suffix: &[u8], period: u32, seq: u32, block_hash: &str, ) -> Result, StatementAllowanceError> { - let alias = slot_alias(entropy, period, seq)?; + let alias = slot_alias(entropy, network_suffix, period, seq)?; let key = statement_store_allowance_key(period, &alias); Ok(rpc .get_storage_at(&key, block_hash) @@ -441,6 +487,8 @@ pub struct SlotScan<'a> { pub collection: PersonhoodCollection, /// Our bandersnatch entropy for `collection`. pub entropy: [u8; 32], + /// Runtime-wide suffix used for product-scoped aliases. + pub network_suffix: &'a [u8], /// Statement-store period to scan. pub period: u32, /// Account whose existing slot, if any, should be reported. @@ -461,6 +509,7 @@ pub async fn scan_slot_excluding( let SlotScan { collection, entropy, + network_suffix, period, target, excluded, @@ -471,7 +520,7 @@ pub async fn scan_slot_excluding( let mut excluded_free = false; let mut occupied = Vec::new(); for seq in 0..max { - let alias = slot_alias(entropy, period, seq)?; + let alias = slot_alias(entropy, network_suffix, period, seq)?; let key = statement_store_allowance_key(period, &alias); match rpc.get_storage(&key).await? { None => { @@ -619,6 +668,7 @@ mod tests { /// `LiteStmtStoreSlotsPerPeriod` is 10. const FIXTURE: &[u8] = include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); const SLOTS: usize = 10; + const NETWORK_SUFFIX: &[u8] = b"paseo"; /// `StmtStoreAllowanceEntry { account_id, seq: 0, since: 0 }` as a scripted /// JSON storage result. @@ -657,6 +707,7 @@ mod tests { SlotScan { collection: PersonhoodCollection::LitePeople, entropy: [0x11; 32], + network_suffix: NETWORK_SUFFIX, period: 7, target: &[0x22; 32], excluded: &[], @@ -730,6 +781,7 @@ mod tests { SlotScan { collection: PersonhoodCollection::LitePeople, entropy: [0x11; 32], + network_suffix: NETWORK_SUFFIX, period: 7, target: &[0x22; 32], excluded: &[], @@ -901,6 +953,7 @@ mod tests { SlotScan { collection: PersonhoodCollection::LitePeople, entropy: [0x11; 32], + network_suffix: NETWORK_SUFFIX, period: 7, target: &[0x22; 32], excluded: &[(SLOTS - 1) as u32], @@ -1004,12 +1057,86 @@ mod tests { } #[test] - fn slot_context_layout() { - let ctx = derive_slot_context(7, 3); - assert_eq!(&ctx[..9], b"SSS_SLOT:"); - assert_eq!(&ctx[9..13], &7u32.to_be_bytes()); - assert_eq!(&ctx[13..17], &3u32.to_be_bytes()); - assert!(ctx[17..].iter().all(|&b| b == 0x20)); + fn statement_slot_context_matches_mobile_clients_and_runtime() { + let expected: [u8; 32] = + hex::decode("b6c21225dcf4c2aeeca32b6db1fc93b6942ca0e8ff5c3cb1b2c5d8f0b4647ee3") + .unwrap() + .try_into() + .unwrap(); + + assert_eq!(derive_slot_context(NETWORK_SUFFIX, 100, 3), expected); + } + + #[test] + fn statement_slot_context_is_scoped_to_the_network() { + assert_ne!( + derive_slot_context(b"paseo", 100, 3), + derive_slot_context(b"polkadot", 100, 3), + ); + } + + #[test] + fn network_suffix_is_read_from_chain_storage() { + let scripted = ScriptedRpc::new(vec![r#""0x14706173656f""#]); + let rpc = RpcClient::new(HostRpcClient::new(scripted)); + + assert_eq!( + futures::executor::block_on(read_network_suffix(&rpc)).unwrap(), + b"paseo", + ); + } + + #[test] + fn missing_network_suffix_is_rejected() { + let scripted = ScriptedRpc::new(vec!["null"]); + let rpc = RpcClient::new(HostRpcClient::new(scripted)); + + assert!(matches!( + futures::executor::block_on(read_network_suffix(&rpc)), + Err(StatementAllowanceError::Slot( + SlotError::MissingNetworkSuffix + )), + )); + } + + #[test] + fn malformed_network_suffix_is_rejected() { + let malformed = ScriptedRpc::new(vec![r#""0x14""#]); + let malformed_rpc = RpcClient::new(HostRpcClient::new(malformed)); + + assert!(matches!( + futures::executor::block_on(read_network_suffix(&malformed_rpc)), + Err(StatementAllowanceError::Slot( + SlotError::NetworkSuffixDecode(_) + )), + )); + } + + #[test] + fn empty_network_suffix_is_rejected() { + let empty = ScriptedRpc::new(vec![r#""0x00""#]); + let empty_rpc = RpcClient::new(HostRpcClient::new(empty)); + + assert!(matches!( + futures::executor::block_on(read_network_suffix(&empty_rpc)), + Err(StatementAllowanceError::Slot( + SlotError::InvalidNetworkSuffixLength { len: 0 } + )), + )); + } + + #[test] + fn oversized_network_suffix_is_rejected() { + let oversized_response = format!(r#""0x{}""#, hex::encode(vec![0x44; 18])); + let oversized = ScriptedRpc::new([oversized_response.as_str()]); + let oversized_rpc = RpcClient::new(HostRpcClient::new(oversized)); + + assert!(matches!( + futures::executor::block_on(read_network_suffix(&oversized_rpc)), + Err(StatementAllowanceError::Slot( + SlotError::InvalidNetworkSuffixLength { len: 17 } + )), + )); } #[test] diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 633a555ab..43c1ccf77 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -1318,9 +1318,28 @@ fn method_keyed_responses( serde_json::from_str(&request).expect("request is valid JSON"); let id = value["id"].as_str().expect("request carries a string id"); let method = value["method"].as_str().expect("request carries a method"); + let occurrence = sent + .lock() + .expect("rpc list mutex poisoned") + .iter() + .take(answered) + .filter(|request| { + serde_json::from_str::(request) + .ok() + .and_then(|value| value["method"].as_str().map(str::to_owned)) + .is_some_and(|candidate| candidate == method) + }) + .count(); let result = answers .iter() - .find(|(candidate, _)| *candidate == method) + .filter(|(candidate, _)| *candidate == method) + .nth(occurrence) + .or_else(|| { + answers + .iter() + .rev() + .find(|(candidate, _)| *candidate == method) + }) .map(|(_, body)| body.clone()) .unwrap_or_else(|| panic!("no scripted response for method `{method}`")); return Some(( From cd342e479cc121d6ef5f4a6fc529b1f82e9c194b Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 3 Sep 2026 11:37:41 +0200 Subject: [PATCH 10/14] fix: adopt current proof contexts Remove legacy LTS and PGAS contexts and scope every proof family by the live network suffix. Follow the generation-prefixed Asset Hub ring-root layout used by current mobile clients and runtime. --- rust/crates/truapi-host-cli/src/main.rs | 5 + .../truapi-host-cli/tests/live_asset_hub.rs | 18 +- .../src/runtime/signing_host/sso_responder.rs | 21 ++- .../src/runtime/statement_allowance.rs | 41 ++++- .../src/runtime/statement_allowance/pgas.rs | 105 ++++++++--- .../src/runtime/statement_allowance/proof.rs | 4 +- .../src/runtime/statement_allowance/ring.rs | 2 +- .../src/runtime/statement_allowance/slot.rs | 174 ++++++++++++------ .../truapi-server/tests/fixtures/README.md | 7 +- 9 files changed, 273 insertions(+), 104 deletions(-) diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 42bb3a267..323672608 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -642,6 +642,9 @@ async fn run_pgas_check( let asset_hub_state = alloc::fetch_chain_state(&asset_hub_rpc) .await .map_err(anyhow::Error::msg)?; + let network_suffix = alloc::slot::read_network_suffix(&asset_hub_rpc) + .await + .map_err(anyhow::Error::msg)?; println!( "asset hub: metadata V{} specVersion={} txVersion={} genesis=0x{}", asset_hub_metadata.metadata_version(), @@ -715,6 +718,7 @@ async fn run_pgas_check( &asset_hub_metadata, ring.collection, membership.entropy, + &network_suffix, day, &[], ) @@ -738,6 +742,7 @@ async fn run_pgas_check( people_rpc: &people_rpc, people_metadata: &people_metadata, entropy: membership.entropy, + network_suffix: &network_suffix, target: &target, ring: &membership.ring, }) diff --git a/rust/crates/truapi-host-cli/tests/live_asset_hub.rs b/rust/crates/truapi-host-cli/tests/live_asset_hub.rs index 693104035..13794aa0a 100644 --- a/rust/crates/truapi-host-cli/tests/live_asset_hub.rs +++ b/rust/crates/truapi-host-cli/tests/live_asset_hub.rs @@ -22,8 +22,8 @@ fn asset_hub_ws() -> String { } const PEOPLE_WS: &str = "wss://paseo-people-next-system-rpc.polkadot.io"; -/// The ring our onboarded test identity sits in. -const RING_INDEX: u32 = 2; +/// An active lite-person ring mirrored to Asset Hub. +const RING_INDEX: u32 = 1; /// The ring this fixture's index belongs to. const COLLECTION: PersonhoodCollection = PersonhoodCollection::LitePeople; @@ -37,6 +37,20 @@ async fn asset_hub() -> (alloc::rpc::RpcClient, alloc::extension::Metadata) { (rpc, metadata) } +#[tokio::test] +#[ignore = "needs network access to a live Asset Hub"] +async fn live_asset_hub_reports_the_product_context_suffix() { + let (rpc, _metadata) = asset_hub().await; + let expected = std::env::var("LIVE_TLD").unwrap_or_else(|_| "paseo".to_string()); + + assert_eq!( + alloc::slot::read_network_suffix(&rpc) + .await + .expect("read Asset Hub NetworkSuffix"), + expected.as_bytes(), + ); +} + /// The claim encodes five fields for `AsPgas::Claim`. A short payload is accepted /// locally and then panics the runtime inside `validate_transaction`, which is how /// the missing `revision` on the statement-store claim went unnoticed. 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 5c96daf91..0eae554e1 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 @@ -1210,6 +1210,7 @@ pub(super) async fn allocate_bulletin_allowance( .await?; let people_rpc = people_client.rpc(); let chain = services.chain_context.get(&people_client).await?; + let network_suffix = statement_allowance::slot::read_network_suffix(people_rpc).await?; let session = signing_host .current_session() .ok_or(AuthorityError::Disconnected)?; @@ -1237,15 +1238,16 @@ pub(super) async fn allocate_bulletin_allowance( current_unix_secs()?, period_duration, )?; - let outcome = claim_long_term_storage( - people_rpc, - &chain.metadata, - &chain.state, - membership.entropy, - &target, + let outcome = claim_long_term_storage(statement_allowance::LongTermStorageClaim { + rpc: people_rpc, + metadata: &chain.metadata, + chain_state: &chain.state, + entropy: membership.entropy, + network_suffix: &network_suffix, + target: &target, period, - &membership.ring, - ) + ring: &membership.ring, + }) .await?; let statement_allowance::LongTermStorageOutcome::Claimed { block_hash, @@ -1353,6 +1355,8 @@ pub(super) async fn allocate_smart_contract_allowance( debug!(%product_id, "PGAS allowance already funded; leaving it alone"); return Ok(()); } + let network_suffix = + statement_allowance::slot::read_network_suffix(asset_hub_client.rpc()).await?; let people_client = services .statement_store @@ -1376,6 +1380,7 @@ pub(super) async fn allocate_smart_contract_allowance( people_rpc, people_metadata: &people.metadata, entropy: membership.entropy, + network_suffix: &network_suffix, target: &target, ring: &membership.ring, }) diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index 4e3f4757d..62f13b190 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -441,6 +441,26 @@ pub enum LongTermStorageOutcome { }, } +/// Everything one long-term-storage claim needs. +pub struct LongTermStorageClaim<'a> { + /// People connection the claim is submitted on. + pub rpc: &'a RpcClient, + /// People runtime metadata. + pub metadata: &'a Metadata, + /// People signed-extension state. + pub chain_state: &'a ChainState, + /// Our ring-VRF entropy for the collection `ring` names. + pub entropy: [u8; 32], + /// People suffix used for the product-scoped alias and proof. + pub network_suffix: &'a [u8], + /// Account whose Bulletin allowance is authorized. + pub target: &'a [u8; 32], + /// People long-term-storage period. + pub period: u32, + /// Ring the membership proof is built against. + pub ring: &'a RingParams, +} + /// Bulletin authorization state for one account. #[derive(Debug, Clone, Copy)] pub struct BulletinAllowanceInfo { @@ -1004,14 +1024,18 @@ pub async fn register_statement_account_pooled( /// Claim long-term Bulletin storage authorization for `target`, proving /// membership in the already-located `ring`, at People-chain `period`. pub async fn claim_long_term_storage( - rpc: &RpcClient, - metadata: &Metadata, - chain_state: &ChainState, - entropy: [u8; 32], - target: &[u8; 32], - period: u32, - ring: &RingParams, + params: LongTermStorageClaim<'_>, ) -> Result { + let LongTermStorageClaim { + rpc, + metadata, + chain_state, + entropy, + network_suffix, + target, + period, + ring, + } = params; let revision = ring::read_ring_revision( rpc, metadata, @@ -1026,12 +1050,13 @@ pub async fn claim_long_term_storage( rpc, metadata, entropy, + network_suffix, period, &skipped_duplicate_counters, ) .await?; - let context = slot::derive_long_term_storage_context(period, counter); + let context = slot::derive_long_term_storage_context(network_suffix, period, counter); let call = extrinsic::build_claim_long_term_storage_call(metadata, period, counter, target)?; let message = extension::build_proof_message(metadata, &call, chain_state)?; diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/pgas.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/pgas.rs index 0464f5bbe..7d1a778cd 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/pgas.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/pgas.rs @@ -21,7 +21,7 @@ use thiserror::Error; use super::collection::PersonhoodCollection; use super::extension::{AS_PGAS, Metadata, MetadataError}; -use super::ring::{self, RingParams, blake2_128_concat}; +use super::ring::{self, RingParams, blake2_128_concat, twox_64_concat}; use super::rpc::RpcClient; use super::{ ChainContext, StatementAllowanceError, duplicate_submit_error, extension, extrinsic, proof, @@ -55,6 +55,9 @@ pub enum PgasError { /// Revision the proof was built against. revision: u32, }, + /// `MembersSubscriber.CurrentGeneration` was not a SCALE-encoded `u32`. + #[error("MembersSubscriber.CurrentGeneration: {0}")] + GenerationDecode(#[source] parity_scale_codec::Error), /// The asset account's leading balance failed to decode. #[error("PGAS balance: {0}")] BalanceDecode(#[source] parity_scale_codec::Error), @@ -92,15 +95,30 @@ pub struct PgasClaimOutcome { pub ring_index: u32, } -/// `MembersSubscriber.RingRoots[(identifier, ring_index)]` storage key on Asset -/// Hub. -/// -/// Both map keys are `Blake2_128Concat` here, unlike the People chain's -/// `Members` maps which take the collection identifier verbatim. -fn ring_roots_key(collection: PersonhoodCollection, ring_index: u32) -> Vec { +fn current_generation_key() -> Vec { + [ + twox_128(b"MembersSubscriber").as_slice(), + twox_128(b"CurrentGeneration").as_slice(), + ] + .concat() +} + +async fn read_current_generation(rpc: &RpcClient) -> Result { + match rpc.get_storage(¤t_generation_key()).await? { + Some(bytes) => u32::decode(&mut &bytes[..]) + .map_err(PgasError::GenerationDecode) + .map_err(Into::into), + None => Ok(0), + } +} + +/// `MembersSubscriber.RingRoots[(generation, identifier, ring_index)]` storage +/// key on Asset Hub. +fn ring_roots_key(generation: u32, collection: PersonhoodCollection, ring_index: u32) -> Vec { [ twox_128(b"MembersSubscriber").as_slice(), twox_128(b"RingRoots").as_slice(), + &twox_64_concat(&generation.to_le_bytes()), &blake2_128_concat(collection.identifier()), &blake2_128_concat(&ring_index.to_le_bytes()), ] @@ -120,6 +138,8 @@ pub struct PgasClaim<'a> { pub people_metadata: &'a Metadata, /// Our ring-VRF entropy for the collection `ring` names. pub entropy: [u8; 32], + /// Asset Hub suffix used for the product-scoped alias and proof. + pub network_suffix: &'a [u8], /// Account the claim credits. pub target: &'a [u8; 32], /// Ring the membership proof is built against, already located on People. @@ -174,6 +194,7 @@ pub async fn claim_pgas( people_rpc, people_metadata, entropy, + network_suffix, target, ring, } = params; @@ -206,11 +227,12 @@ pub async fn claim_pgas( asset_hub_metadata, ring.collection, entropy, + network_suffix, day, &skipped_duplicate_slots, ) .await?; - let context = slot::derive_pgas_context(day, slot_index); + let context = slot::derive_pgas_context(network_suffix, day, slot_index); let call = extrinsic::build_claim_pgas_call(asset_hub_metadata, slot_index, target)?; let message = extension::build_proof_message_after_extension( asset_hub_metadata, @@ -245,6 +267,7 @@ pub async fn claim_pgas( if !slot::pgas_slot_is_claimed_at( asset_hub_rpc, entropy, + network_suffix, day, slot_index, &block_hash, @@ -322,10 +345,11 @@ pub async fn await_ring_revision( pallet: "MembersSubscriber", entry: "RingRoots", })?; + let generation = read_current_generation(rpc).await?; let started = Instant::now(); loop { if let Some(bytes) = rpc - .get_storage(&ring_roots_key(collection, ring_index)) + .get_storage(&ring_roots_key(generation, collection, ring_index)) .await? { let mut input = bytes.as_slice(); @@ -380,9 +404,10 @@ mod tests { use super::super::test_fixtures; use super::*; - /// The collection the captured roots were read from. `RingRoots` is keyed by - /// collection, so the fixture only means anything paired with this one. + /// The collection the captured roots were read from. The fixture only means + /// anything paired with this identifier. const CAPTURED_COLLECTION: PersonhoodCollection = PersonhoodCollection::LitePeople; + const TEST_GENERATION: u32 = 7; /// The captured ring-5 roots as a scripted `state_getStorage` result, with the /// transport handle so the key that was read can be checked. @@ -391,7 +416,8 @@ mod tests { r#""0x{}""#, hex::encode(test_fixtures::ASSET_HUB_RING_5_ROOTS) ); - let scripted = ScriptedRpc::new([value.as_str()]); + let generation = format!(r#""0x{}""#, hex::encode(TEST_GENERATION.to_le_bytes())); + let scripted = ScriptedRpc::new([generation.as_str(), value.as_str()]); ( RpcClient::new(HostRpcClient::new(scripted.clone())), scripted, @@ -413,12 +439,24 @@ mod tests { CAPTURED_UNDER, "the committed blob was read under the lite-people identifier", ); - let expected = format!( + let current_generation = format!( + r#"["0x{}"]"#, + hex::encode( + [ + twox_128(b"MembersSubscriber").as_slice(), + twox_128(b"CurrentGeneration").as_slice(), + ] + .concat() + ) + ); + let generation = twox_64_concat(&TEST_GENERATION.to_le_bytes()); + let ring_roots = format!( r#"["0x{}"]"#, hex::encode( [ twox_128(b"MembersSubscriber").as_slice(), twox_128(b"RingRoots").as_slice(), + &generation, &blake2_128_concat(CAPTURED_UNDER), &blake2_128_concat(&5u32.to_le_bytes()), ] @@ -433,8 +471,8 @@ mod tests { .collect(); assert_eq!( reads, - vec![expected], - "the captured blob has to be paired with the collection it was read for" + vec![current_generation, ring_roots], + "the roots read must use the current generation and the fixture collection" ); } @@ -507,25 +545,50 @@ mod tests { assert_read_ring_5_of(&scripted); } - /// Both map keys are hashed here, unlike the People chain's `Members` maps. + /// The generation uses `Twox64Concat`; the remaining two keys use + /// `Blake2_128Concat`. #[test] - fn subscriber_ring_key_hashes_both_map_keys() { + fn subscriber_ring_key_hashes_all_three_map_keys() { let collection = PersonhoodCollection::LitePeople; - let key = ring_roots_key(collection, 136); + let key = ring_roots_key(7, collection, 136); - assert_eq!(key.len(), 16 + 16 + 16 + 32 + 16 + 4); + assert_eq!(key.len(), 16 + 16 + 8 + 4 + 16 + 32 + 16 + 4); assert_eq!( - &key[48..80], + &key[40..44], + &7u32.to_le_bytes(), + "generation follows its hash" + ); + assert_eq!( + &key[60..92], collection.identifier(), "identifier follows its hash" ); assert_eq!( - &key[96..], + &key[108..], &136u32.to_le_bytes(), "ring index is little-endian" ); } + #[test] + fn missing_current_generation_uses_the_runtime_default() { + let scripted = ScriptedRpc::new(["null"]); + let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); + + assert_eq!( + futures::executor::block_on(read_current_generation(&rpc)).unwrap(), + 0 + ); + assert_eq!( + scripted.calls(), + vec![( + "state_getStorage".to_string(), + r#"["0xc8d053ab324196afc756c5ae3fbd2917c2dbc4fc2f665a39ada06f0965cccf86"]"# + .to_string(), + )] + ); + } + /// `holds_a_full_claim` reads both of these from the runtime, and compares a /// balance against the claim amount. A missing constant would make the warm /// check answer the same way for every account. diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/proof.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/proof.rs index 942b07676..0f8792971 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/proof.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/proof.rs @@ -117,7 +117,7 @@ mod tests { RingDomainSize::Domain11, entropy, &members, - b"SSS_SLOT:test-context-padding..", + &[0x33; 32], &[0x42; 32], ) .unwrap(); @@ -132,7 +132,7 @@ mod tests { RingDomainSize::Domain11, entropy, &[other], - b"SSS_SLOT:test-context-padding..", + &[0x33; 32], &[0x42; 32], ) .unwrap_err(); diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs index 534f08c59..4edda67f8 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs @@ -199,7 +199,7 @@ pub(super) fn blake2_128_concat(x: &[u8]) -> Vec { } /// `Twox64Concat(x)` = `twox_64(x) ‖ x`. -fn twox_64_concat(x: &[u8]) -> Vec { +pub(super) fn twox_64_concat(x: &[u8]) -> Vec { [twox_64(x).as_slice(), x].concat() } 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 b25d09001..bd3326260 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs @@ -23,13 +23,11 @@ use super::view; /// StatementStore allowance period: one UTC day, in seconds. pub const STATEMENT_STORE_PERIOD_SECONDS: u64 = 86_400; const PRODUCT_CONTEXT_PREFIX: &[u8] = b"product/peopl."; -const STATEMENT_STORE_CONTEXT_PREFIX: &[u8] = b"sys/"; +const SYSTEM_CONTEXT_PREFIX: &[u8] = b"sys/"; const STATEMENT_STORE_CONTEXT_FAMILY: u32 = 2; +const LONG_TERM_STORAGE_CONTEXT_FAMILY: u32 = 3; +const PGAS_CONTEXT_FAMILY: u32 = 4; const MAX_NETWORK_SUFFIX_LENGTH: usize = 16; -/// Bulletin long-term-storage claim context prefix. -const LONG_TERM_STORAGE_CONTEXT_PREFIX: &[u8] = b"pop:polkadot.net/rsc-lts"; -/// Ring-VRF alias context prefix for an Asset Hub PGAS claim. -const PGAS_CONTEXT_PREFIX: &[u8] = b"pop:gas:"; /// Slots probed per batched storage read while scanning for a free PGAS slot. /// /// Reading every slot in one request would cost a round trip flat, but each slot's @@ -168,13 +166,12 @@ pub fn current_long_term_storage_period( Ok((now_seconds / u64::from(period_duration)) as u32) } -/// Derive the network-scoped 32-byte StatementStore slot context. -pub fn derive_slot_context(network_suffix: &[u8], period: u32, seq: u32) -> [u8; 32] { +fn derive_product_context(network_suffix: &[u8], family: u32, first: u32, second: u32) -> [u8; 32] { let mut suffix = [0u8; 32]; - suffix[..4].copy_from_slice(STATEMENT_STORE_CONTEXT_PREFIX); - suffix[4..8].copy_from_slice(&STATEMENT_STORE_CONTEXT_FAMILY.to_le_bytes()); - suffix[8..12].copy_from_slice(&period.to_le_bytes()); - suffix[12..16].copy_from_slice(&seq.to_le_bytes()); + suffix[..4].copy_from_slice(SYSTEM_CONTEXT_PREFIX); + suffix[4..8].copy_from_slice(&family.to_le_bytes()); + suffix[8..12].copy_from_slice(&first.to_le_bytes()); + suffix[12..16].copy_from_slice(&second.to_le_bytes()); let mut preimage = Vec::with_capacity( PRODUCT_CONTEXT_PREFIX.len() + network_suffix.len() + b"/".len() + suffix.len(), @@ -186,29 +183,28 @@ pub fn derive_slot_context(network_suffix: &[u8], period: u32, seq: u32) -> [u8; blake2_256(&preimage) } -/// Derive the 32-byte Asset Hub PGAS claim context: -/// `"pop:gas:" ‖ u32le(day) ‖ u32le(slot_index) ‖ zero fill`. -/// -/// The mobile wallet writes the integers in little-endian order and the runtime -/// verifies against the same bytes, so the layout is not ours to tidy. -pub fn derive_pgas_context(day: u32, slot_index: u32) -> [u8; 32] { - let mut ctx = [0u8; 32]; - ctx[..PGAS_CONTEXT_PREFIX.len()].copy_from_slice(PGAS_CONTEXT_PREFIX); - let offset = PGAS_CONTEXT_PREFIX.len(); - ctx[offset..offset + 4].copy_from_slice(&day.to_le_bytes()); - ctx[offset + 4..offset + 8].copy_from_slice(&slot_index.to_le_bytes()); - ctx +/// Derive the network-scoped 32-byte StatementStore slot context. +pub fn derive_slot_context(network_suffix: &[u8], period: u32, seq: u32) -> [u8; 32] { + derive_product_context(network_suffix, STATEMENT_STORE_CONTEXT_FAMILY, period, seq) +} + +/// Derive the network-scoped 32-byte Asset Hub PGAS claim context. +pub fn derive_pgas_context(network_suffix: &[u8], day: u32, slot_index: u32) -> [u8; 32] { + derive_product_context(network_suffix, PGAS_CONTEXT_FAMILY, day, slot_index) } -/// Derive the 32-byte Bulletin long-term-storage slot context: -/// `"pop:polkadot.net/rsc-lts" ‖ u32be(period) ‖ counter ‖ zero fill`. -pub fn derive_long_term_storage_context(period: u32, counter: u8) -> [u8; 32] { - let mut ctx = [0u8; 32]; - ctx[..LONG_TERM_STORAGE_CONTEXT_PREFIX.len()].copy_from_slice(LONG_TERM_STORAGE_CONTEXT_PREFIX); - let offset = LONG_TERM_STORAGE_CONTEXT_PREFIX.len(); - ctx[offset..offset + 4].copy_from_slice(&period.to_be_bytes()); - ctx[offset + 4] = counter; - ctx +/// Derive the network-scoped 32-byte Bulletin long-term-storage context. +pub fn derive_long_term_storage_context( + network_suffix: &[u8], + period: u32, + counter: u8, +) -> [u8; 32] { + derive_product_context( + network_suffix, + LONG_TERM_STORAGE_CONTEXT_FAMILY, + period, + u32::from(counter), + ) } /// The slot alias for our `entropy` at `(period, seq)`. @@ -232,11 +228,12 @@ pub fn slot_alias( /// The PGAS claim alias for our `entropy` at `(day, slot_index)`. pub fn pgas_alias( entropy: [u8; 32], + network_suffix: &[u8], day: u32, slot_index: u32, ) -> Result<[u8; 32], StatementAllowanceError> { let secret = BandersnatchVrfVerifiable::new_secret(entropy); - let context = derive_pgas_context(day, slot_index); + let context = derive_pgas_context(network_suffix, day, slot_index); BandersnatchVrfVerifiable::alias_in_context(&secret, &context).map_err(|err| { SlotError::AliasInContext { context: "PGAS claim slot", @@ -249,11 +246,12 @@ pub fn pgas_alias( /// The long-term-storage slot alias for our `entropy` at `(period, counter)`. pub fn long_term_storage_alias( entropy: [u8; 32], + network_suffix: &[u8], period: u32, counter: u8, ) -> Result<[u8; 32], StatementAllowanceError> { let secret = BandersnatchVrfVerifiable::new_secret(entropy); - let context = derive_long_term_storage_context(period, counter); + let context = derive_long_term_storage_context(network_suffix, period, counter); BandersnatchVrfVerifiable::alias_in_context(&secret, &context).map_err(|err| { SlotError::AliasInContext { context: "long-term-storage slot", @@ -571,11 +569,12 @@ pub async fn scan_pgas_slot_excluding( metadata: &Metadata, collection: PersonhoodCollection, entropy: [u8; 32], + network_suffix: &[u8], day: u32, excluded: &[u32], ) -> Result { let max = max_pgas_claims(metadata, collection)?; - scan_pgas_slot_in(rpc, entropy, day, max, excluded).await + scan_pgas_slot_in(rpc, entropy, network_suffix, day, max, excluded).await } /// The scan itself, over a known slot count. @@ -584,6 +583,7 @@ pub async fn scan_pgas_slot_excluding( async fn scan_pgas_slot_in( rpc: &RpcClient, entropy: [u8; 32], + network_suffix: &[u8], day: u32, max: u32, excluded: &[u32], @@ -600,7 +600,8 @@ async fn scan_pgas_slot_in( let keys = batch .iter() .map(|&slot_index| { - pgas_alias(entropy, day, slot_index).map(|alias| claimed_gas_alias_key(day, &alias)) + pgas_alias(entropy, network_suffix, day, slot_index) + .map(|alias| claimed_gas_alias_key(day, &alias)) }) .collect::, _>>()?; let claimed = rpc.get_storage_many(&keys).await?; @@ -624,11 +625,12 @@ async fn scan_pgas_slot_in( pub async fn pgas_slot_is_claimed_at( rpc: &RpcClient, entropy: [u8; 32], + network_suffix: &[u8], day: u32, slot_index: u32, block_hash: &str, ) -> Result { - let alias = pgas_alias(entropy, day, slot_index)?; + let alias = pgas_alias(entropy, network_suffix, day, slot_index)?; let key = claimed_gas_alias_key(day, &alias); Ok(rpc.get_storage_at(&key, block_hash).await?.is_some()) } @@ -639,6 +641,7 @@ pub async fn scan_long_term_storage_counter_excluding( rpc: &RpcClient, metadata: &Metadata, entropy: [u8; 32], + network_suffix: &[u8], period: u32, excluded: &[u8], ) -> Result { @@ -647,7 +650,7 @@ pub async fn scan_long_term_storage_counter_excluding( if excluded.contains(&counter) { continue; } - let alias = long_term_storage_alias(entropy, period, counter)?; + let alias = long_term_storage_alias(entropy, network_suffix, period, counter)?; let key = spent_long_term_storage_alias_key(period, &alias); if rpc.get_storage(&key).await?.is_none() { return Ok(counter); @@ -977,7 +980,7 @@ mod tests { // keys that exist, so the absent ones are simply missing from `changes`. let claimed: Vec = (0..3u32) .map(|slot_index| { - let alias = pgas_alias(ENTROPY, DAY, slot_index).unwrap(); + let alias = pgas_alias(ENTROPY, NETWORK_SUFFIX, DAY, slot_index).unwrap(); format!( r#"["0x{}","0x"]"#, hex::encode(claimed_gas_alias_key(DAY, &alias)) @@ -991,8 +994,15 @@ mod tests { let scripted = ScriptedRpc::new(vec![response.as_str()]); let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); - let chosen = - futures::executor::block_on(scan_pgas_slot_in(&rpc, ENTROPY, DAY, 40, &[])).unwrap(); + let chosen = futures::executor::block_on(scan_pgas_slot_in( + &rpc, + ENTROPY, + NETWORK_SUFFIX, + DAY, + 40, + &[], + )) + .unwrap(); assert_eq!(chosen, 3, "the first free slot, in order"); let calls = scripted.calls(); @@ -1015,6 +1025,7 @@ mod tests { futures::executor::block_on(pgas_slot_is_claimed_at( &RpcClient::new(HostRpcClient::new(spent)), ENTROPY, + NETWORK_SUFFIX, DAY, 0, "0xb10c", @@ -1025,6 +1036,7 @@ mod tests { !futures::executor::block_on(pgas_slot_is_claimed_at( &RpcClient::new(HostRpcClient::new(absent)), ENTROPY, + NETWORK_SUFFIX, DAY, 0, "0xb10c", @@ -1033,16 +1045,15 @@ mod tests { ); } - /// The PGAS context is little-endian where the other two are big-endian, and - /// the runtime verifies the proof against these exact bytes. #[test] - fn pgas_context_layout_is_little_endian() { - let ctx = derive_pgas_context(0x0102_0304, 0x0506_0708); + fn pgas_context_matches_mobile_clients_and_runtime() { + let expected: [u8; 32] = + hex::decode("e47ba2c7eae3b97beabaeef8df599afd53e44ba9c2b851cd80850d3ed95a685b") + .unwrap() + .try_into() + .unwrap(); - assert_eq!(&ctx[..8], b"pop:gas:"); - assert_eq!(&ctx[8..12], &[0x04, 0x03, 0x02, 0x01]); - assert_eq!(&ctx[12..16], &[0x08, 0x07, 0x06, 0x05]); - assert_eq!(&ctx[16..], &[0u8; 16]); + assert_eq!(derive_pgas_context(NETWORK_SUFFIX, 100, 3), expected); } /// `ClaimedGasAliases` is `Identity(u32be day) ‖ Blake2_128Concat(alias)`. @@ -1068,10 +1079,15 @@ mod tests { } #[test] - fn statement_slot_context_is_scoped_to_the_network() { - assert_ne!( - derive_slot_context(b"paseo", 100, 3), - derive_slot_context(b"polkadot", 100, 3), + fn product_contexts_are_scoped_to_the_network() { + assert_eq!( + [ + derive_slot_context(b"paseo", 100, 3) != derive_slot_context(b"polkadot", 100, 3), + derive_long_term_storage_context(b"paseo", 100, 3) + != derive_long_term_storage_context(b"polkadot", 100, 3), + derive_pgas_context(b"paseo", 100, 3) != derive_pgas_context(b"polkadot", 100, 3), + ], + [true; 3], ); } @@ -1140,12 +1156,52 @@ mod tests { } #[test] - fn long_term_storage_context_layout() { - let ctx = derive_long_term_storage_context(7, 3); - assert_eq!(&ctx[..24], b"pop:polkadot.net/rsc-lts"); - assert_eq!(&ctx[24..28], &7u32.to_be_bytes()); - assert_eq!(ctx[28], 3); - assert!(ctx[29..].iter().all(|&b| b == 0)); + fn long_term_storage_context_matches_mobile_clients_and_runtime() { + let expected: [u8; 32] = + hex::decode("1b3fbe4dd813ea1e349878c9228c6823db8345207690ca4df656acb7fee81bd1") + .unwrap() + .try_into() + .unwrap(); + + assert_eq!( + derive_long_term_storage_context(NETWORK_SUFFIX, 100, 3), + expected, + ); + } + + #[test] + fn long_term_storage_scan_uses_the_requested_network_suffix() { + const ENTROPY: [u8; 32] = [0x11; 32]; + const PERIOD: u32 = 7; + const SUFFIX: &[u8] = b"previewnet"; + + let metadata = Metadata::decode(FIXTURE).unwrap(); + let scripted = ScriptedRpc::new([r#""0x""#, "null"]); + let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); + + let counter = futures::executor::block_on(scan_long_term_storage_counter_excluding( + &rpc, + &metadata, + ENTROPY, + SUFFIX, + PERIOD, + &[], + )) + .unwrap(); + let calls = (0..=1) + .map(|counter| { + let alias = long_term_storage_alias(ENTROPY, SUFFIX, PERIOD, counter).unwrap(); + ( + "state_getStorage".to_string(), + format!( + r#"["0x{}"]"#, + hex::encode(spent_long_term_storage_alias_key(PERIOD, &alias)) + ), + ) + }) + .collect::>(); + + assert_eq!((counter, scripted.calls()), (1, calls)); } #[test] diff --git a/rust/crates/truapi-server/tests/fixtures/README.md b/rust/crates/truapi-server/tests/fixtures/README.md index e1f6354f6..7161584a6 100644 --- a/rust/crates/truapi-server/tests/fixtures/README.md +++ b/rust/crates/truapi-server/tests/fixtures/README.md @@ -37,7 +37,7 @@ and replace both together. Re-capturing metadata alone will fail | File | Storage | Chain | Block | Captured | |---|---|---|---|---| -| `paseo-next-asset-hub-ring-5-roots.scale` | `MembersSubscriber.RingRoots[(LitePeople, 5)]` | Paseo Asset Hub Next | `0xf25d4e330ade1ce230695976f019df50cdaf97c96b6996838af93b68550654f3` | 2026-08-17 | +| `paseo-next-asset-hub-ring-5-roots.scale` | `MembersSubscriber.RingRoots[(generation, LitePeople, 5)]` | Paseo Asset Hub Next | `0xf25d4e330ade1ce230695976f019df50cdaf97c96b6996838af93b68550654f3` | 2026-08-17 | Ring 5 holds `[105, 106, 108]`. The skipped 107 is the case that distinguishes testing the newest held root from testing the oldest, and freezing it makes that case permanent @@ -50,6 +50,7 @@ does, then call `state_getStorageAt`: ``` twox_128("MembersSubscriber") ‖ twox_128("RingRoots") + ‖ twox_64_concat(current_generation_u32_le) ‖ blake2_128_concat(b"pop:polkadot.network/people-lite") ‖ blake2_128_concat(ring_index_u32_le) ``` @@ -60,8 +61,8 @@ curl -s -H 'Content-Type: application/json' \ https://paseo-asset-hub-next-rpc.polkadot.io ``` -Both map keys are hashed here, unlike the People chain's `Members` maps, which take the -collection identifier verbatim. +Read `MembersSubscriber.CurrentGeneration` first. The generation uses `Twox64Concat`; the +collection and ring index use `Blake2_128Concat`. ## Recapturing From 148de5caac607426ff0a535216764e82960261f6 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 3 Sep 2026 10:53:00 +0200 Subject: [PATCH 11/14] fix: use runtime statement-slot context Proofs must include the live network suffix or the People runtime rejects them as BadProof. --- rust/crates/truapi-host-cli/src/main.rs | 31 +++- .../tests/live_people_chain.rs | 4 + .../runtime/signing_host/allowance_renewal.rs | 4 + .../src/runtime/signing_host/sso_responder.rs | 13 +- .../src/runtime/statement_allowance.rs | 34 +++- .../runtime/statement_allowance/renewal.rs | 8 + .../src/runtime/statement_allowance/slot.rs | 173 +++++++++++++++--- rust/crates/truapi-server/src/test_support.rs | 21 ++- 8 files changed, 253 insertions(+), 35 deletions(-) diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index f6cd0046e..42bb3a267 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -795,6 +795,9 @@ async fn run_alloc_check( let chain_state = alloc::fetch_chain_state(&rpc) .await .map_err(anyhow::Error::msg)?; + let network_suffix = alloc::slot::read_network_suffix(&rpc) + .await + .map_err(anyhow::Error::msg)?; println!( "chain: specVersion={} txVersion={} genesis=0x{}", chain_state.spec_version, @@ -841,16 +844,33 @@ async fn run_alloc_check( continue; } print!("{}: ", candidate.collection); - report_slot_scan(&rpc, &metadata, *candidate, period, &target, now).await?; + report_slot_scan( + &rpc, + &metadata, + *candidate, + &network_suffix, + period, + &target, + now, + ) + .await?; } if submit { if memberships.is_empty() { bail!("cannot submit: member not in any ring"); } - let scans = alloc::scan_collections(&rpc, &metadata, &candidates, period, &target, true) - .await - .map_err(anyhow::Error::msg)?; + let scans = alloc::scan_collections( + &rpc, + &metadata, + &candidates, + &network_suffix, + period, + &target, + true, + ) + .await + .map_err(anyhow::Error::msg)?; match alloc::register_statement_account_pooled( &rpc, &metadata, @@ -860,6 +880,7 @@ async fn run_alloc_check( alloc::PooledRegistrationParams { target: &target, period, + network_suffix: &network_suffix, reuse_existing: true, // A diagnostic that submits behaves as it did before pooling, // where a full table was replaced rather than reported. @@ -893,6 +914,7 @@ async fn report_slot_scan( rpc: &alloc::rpc::RpcClient, metadata: &alloc::extension::Metadata, candidate: alloc::CollectionCandidate, + network_suffix: &[u8], period: u32, target: &[u8; 32], now: u64, @@ -903,6 +925,7 @@ async fn report_slot_scan( alloc::slot::SlotScan { collection: candidate.collection, entropy: candidate.entropy, + network_suffix, period, target, excluded: &[], diff --git a/rust/crates/truapi-host-cli/tests/live_people_chain.rs b/rust/crates/truapi-host-cli/tests/live_people_chain.rs index c8bcc7596..0577fefa9 100644 --- a/rust/crates/truapi-host-cli/tests/live_people_chain.rs +++ b/rust/crates/truapi-host-cli/tests/live_people_chain.rs @@ -103,6 +103,9 @@ async fn scanning_a_live_period_answers_without_erroring() { .await .expect("read the live chain context"); let period = current_period(); + let network_suffix = alloc::slot::read_network_suffix(&rpc) + .await + .expect("read the live network suffix"); // Entropy and target are throwaway: no alias derived from them owns a slot, // so the scan must offer a free one or report the table full — never error. @@ -112,6 +115,7 @@ async fn scanning_a_live_period_answers_without_erroring() { alloc::slot::SlotScan { collection: PersonhoodCollection::LitePeople, entropy: [0x11; 32], + network_suffix: &network_suffix, period, target: &[0x22; 32], excluded: &[], diff --git a/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs b/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs index bcbd17725..0ecb6a5b9 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs @@ -411,6 +411,9 @@ pub(super) async fn renew_now( let chain_state = fetch_chain_state(&rpc) .await .map_err(|err| err.to_string())?; + let network_suffix = statement_allowance::slot::read_network_suffix(&rpc) + .await + .map_err(|err| err.to_string())?; // Every ring back to index 0, because a membership that stopped being // re-included still proves against the ring that holds it. let memberships = find_including_rings(&rpc, &metadata, &candidates, u32::MAX) @@ -426,6 +429,7 @@ pub(super) async fn renew_now( rpc: &rpc, metadata: &metadata, chain_state: &chain_state, + network_suffix: &network_suffix, candidates: &candidates, memberships: &memberships, }; 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 c5f3908dc..5c96daf91 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 @@ -1071,6 +1071,7 @@ pub(super) async fn allocate_statement_store_allowance( .await?; let rpc = client.rpc(); let chain = services.chain_context.get(&client).await?; + let network_suffix = statement_allowance::slot::read_network_suffix(rpc).await?; let period = statement_allowance::slot::current_period(current_unix_secs()?); let reuse_existing = matches!(policy, OnExistingAllowancePolicy::Ignore); @@ -1087,6 +1088,7 @@ pub(super) async fn allocate_statement_store_allowance( rpc, &chain.metadata, &candidates, + &network_suffix, period, &target, reuse_existing, @@ -1120,6 +1122,7 @@ pub(super) async fn allocate_statement_store_allowance( PooledRegistrationParams { target: &target, period, + network_suffix: &network_suffix, reuse_existing, // Connecting a product must not revoke another product's allowance. // A full period is reported as exhaustion; reclaiming space is the @@ -1773,6 +1776,10 @@ mod tests { "state_getMetadata", format!(r#""0x{}""#, hex::encode(PEOPLE_METADATA)), ), + ( + "state_getStorage", + format!(r#""0x{}""#, hex::encode(b"paseo".to_vec().encode())), + ), ( "state_getStorage", format!(r#""0x{}""#, hex::encode(&slot_entry)), @@ -1829,14 +1836,14 @@ mod tests { .any(|method| method.starts_with("author_submit")), "an extrinsic was submitted for an allowance already in place: {methods:?}" ); - // One slot read answered it; the scan stopped at the first match. + // The suffix and one slot read answered it; the scan stopped at the first match. assert_eq!( methods .iter() .filter(|method| *method == "state_getStorage") .count(), - 1, - "expected a single slot read: {methods:?}" + 2, + "expected one suffix and one slot read: {methods:?}" ); } diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index c312344d5..4e3f4757d 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -411,6 +411,8 @@ pub struct RegistrationParams<'a> { pub target: &'a [u8; 32], /// Statement-store period for which the registration is requested. pub period: u32, + /// Runtime-wide suffix used for product-scoped aliases and proofs. + pub network_suffix: &'a [u8], /// Ring parameters used to build the membership proof. pub ring: &'a RingParams, /// Whether an existing registration for this period may be reused. @@ -612,6 +614,7 @@ pub async fn register_statement_account( slot::SlotScan { collection, entropy, + network_suffix: params.network_suffix, period: params.period, target: params.target, excluded: &skipped_duplicate_slots, @@ -668,7 +671,7 @@ pub async fn register_statement_account( }, }; - let context = slot::derive_slot_context(params.period, seq); + let context = slot::derive_slot_context(params.network_suffix, params.period, seq); let call = extrinsic::build_set_statement_store_account_call( metadata, params.period, @@ -691,7 +694,15 @@ pub async fn register_statement_account( match rpc.submit_and_watch(&extrinsic).await { Ok(block_hash) => { - if slot::read_slot_account_at(rpc, entropy, params.period, seq, &block_hash).await? + if slot::read_slot_account_at( + rpc, + entropy, + params.network_suffix, + params.period, + seq, + &block_hash, + ) + .await? != Some(*params.target) { return Err(SlotError::RegistrationVerificationMismatch { @@ -753,6 +764,7 @@ pub async fn scan_collections( rpc: &RpcClient, metadata: &Metadata, candidates: &[CollectionCandidate], + network_suffix: &[u8], period: u32, target: &[u8; 32], reuse_existing: bool, @@ -770,6 +782,7 @@ pub async fn scan_collections( slot::SlotScan { collection, entropy: candidate.entropy, + network_suffix, period, target, excluded: &[], @@ -816,6 +829,8 @@ pub struct PooledRegistrationParams<'a> { pub target: &'a [u8; 32], /// Statement-store period for which the registration is requested. pub period: u32, + /// Runtime-wide suffix used for product-scoped aliases and proofs. + pub network_suffix: &'a [u8], /// Whether an existing registration for this period may be reused. pub reuse_existing: bool, /// Whether a live slot may be replaced once every collection is full. @@ -964,6 +979,7 @@ pub async fn register_statement_account_pooled( RegistrationParams { target: params.target, period: params.period, + network_suffix: params.network_suffix, ring: &membership.ring, reuse_existing: params.reuse_existing, preselected: Some(choice), @@ -1628,6 +1644,7 @@ mod tests { RegistrationParams { target: &[0x22; 32], period: 7, + network_suffix: b"paseo", ring: &ring, reuse_existing: true, preselected, @@ -1695,7 +1712,8 @@ mod tests { let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); let outcome = futures::executor::block_on(async { - let scans = scan_collections(&rpc, &metadata, &candidates, 7, &target, true).await?; + let scans = + scan_collections(&rpc, &metadata, &candidates, b"paseo", 7, &target, true).await?; register_statement_account_pooled( &rpc, &metadata, @@ -1705,6 +1723,7 @@ mod tests { PooledRegistrationParams { target: &target, period: 7, + network_suffix: b"paseo", reuse_existing: true, allow_eviction, protected, @@ -1736,7 +1755,8 @@ mod tests { let rpc = RpcClient::new(HostRpcClient::new(scripted)); futures::executor::block_on(async { - let scans = scan_collections(&rpc, &metadata, &candidates, 7, &target, true).await?; + let scans = + scan_collections(&rpc, &metadata, &candidates, b"paseo", 7, &target, true).await?; register_statement_account_pooled( &rpc, &metadata, @@ -1746,6 +1766,7 @@ mod tests { PooledRegistrationParams { target: &target, period: 7, + network_suffix: b"paseo", reuse_existing: true, allow_eviction: true, protected: &[], @@ -2199,6 +2220,7 @@ mod tests { &rpc, &metadata, &candidates, + b"paseo", 7, &target, true, @@ -2263,6 +2285,7 @@ mod tests { RegistrationParams { target: &[0x22; 32], period: 7, + network_suffix: b"paseo", ring: &ring, reuse_existing: true, preselected: None, @@ -2322,6 +2345,7 @@ mod tests { RegistrationParams { target: &[0x22; 32], period: 7, + network_suffix: b"paseo", ring: &ring, reuse_existing: true, preselected: None, @@ -2373,6 +2397,7 @@ mod tests { RegistrationParams { target: &[0x22; 32], period: 7, + network_suffix: b"paseo", ring: &ring, reuse_existing: true, preselected: None, @@ -2422,6 +2447,7 @@ mod tests { RegistrationParams { target: &[0x22; 32], period: 7, + network_suffix: b"paseo", ring: &ring, reuse_existing: true, preselected: None, diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs index 21f965541..d491cb225 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs @@ -149,6 +149,8 @@ pub struct RenewalChainContext<'a> { pub metadata: &'a Metadata, /// Signed-extension chain state. pub chain_state: &'a ChainState, + /// Runtime-wide suffix used for product-scoped aliases and proofs. + pub network_suffix: &'a [u8], /// Every collection the host can derive aliases for, so an allowance already /// held in a collection whose ring cannot currently be proved is still seen. pub candidates: &'a [CollectionCandidate], @@ -186,6 +188,7 @@ pub async fn renew_targets( context.rpc, context.metadata, context.candidates, + context.network_suffix, period, &target.account_id, true, @@ -218,6 +221,7 @@ pub async fn renew_targets( context.rpc, context.metadata, context.candidates, + context.network_suffix, period, &target.account_id, true, @@ -235,6 +239,7 @@ pub async fn renew_targets( PooledRegistrationParams { target: &target.account_id, period, + network_suffix: context.network_suffix, reuse_existing: true, // Renewal exists to keep the ledger's targets alive across a // period boundary, so it may reclaim space when full. @@ -446,6 +451,7 @@ mod tests { rpc: &rpc, metadata: &metadata, chain_state: &chain_state, + network_suffix: b"paseo", candidates: &candidates, memberships: &memberships, }; @@ -534,6 +540,7 @@ mod tests { rpc: &rpc, metadata: &metadata, chain_state: &chain_state, + network_suffix: b"paseo", candidates: &candidates, memberships: &memberships, }; @@ -633,6 +640,7 @@ mod tests { rpc: &rpc, metadata: &metadata, chain_state: &chain_state, + network_suffix: b"paseo", candidates: &candidates, memberships: &memberships, }; 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 a28da7a1d..b25d09001 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs @@ -1,13 +1,13 @@ //! StatementStore allowance slot selection. //! //! An allowance is claimed at `(period, seq)`. The slot is bound to a 32-byte -//! `SSS_SLOT` context; occupancy is read from +//! product context; occupancy is read from //! `Resources.StatementStoreAllowances[period][alias]`, where the alias is //! derived from OUR bandersnatch entropy in that slot context. Mirrors //! signing-bot `allowance.ts` / `allowance-slots.ts`. -use parity_scale_codec::{Decode, Encode}; -use sp_crypto_hashing::twox_128; +use parity_scale_codec::{Decode, DecodeAll, Encode}; +use sp_crypto_hashing::{blake2_256, twox_128}; use thiserror::Error; use verifiable::Error as VerifiableError; use verifiable::GenerateVerifiable; @@ -22,6 +22,10 @@ use super::view; /// StatementStore allowance period: one UTC day, in seconds. pub const STATEMENT_STORE_PERIOD_SECONDS: u64 = 86_400; +const PRODUCT_CONTEXT_PREFIX: &[u8] = b"product/peopl."; +const STATEMENT_STORE_CONTEXT_PREFIX: &[u8] = b"sys/"; +const STATEMENT_STORE_CONTEXT_FAMILY: u32 = 2; +const MAX_NETWORK_SUFFIX_LENGTH: usize = 16; /// Bulletin long-term-storage claim context prefix. const LONG_TERM_STORAGE_CONTEXT_PREFIX: &[u8] = b"pop:polkadot.net/rsc-lts"; /// Ring-VRF alias context prefix for an Asset Hub PGAS claim. @@ -104,6 +108,18 @@ pub enum SlotError { /// `Timestamp.Now` was absent or undecodable, so slot ages cannot be judged. #[error("Timestamp.Now missing from chain state")] MissingChainTimestamp, + /// The runtime-wide product context suffix was absent from chain storage. + #[error("NetworkSuffix.NetworkSuffix missing from chain state")] + MissingNetworkSuffix, + /// The runtime-wide product context suffix did not have its declared SCALE shape. + #[error("NetworkSuffix.NetworkSuffix is not a valid SCALE byte vector: {0}")] + NetworkSuffixDecode(#[source] parity_scale_codec::Error), + /// The runtime-wide product context suffix was outside its declared bounds. + #[error("NetworkSuffix.NetworkSuffix length {len}, expected 1..={MAX_NETWORK_SUFFIX_LENGTH}")] + InvalidNetworkSuffixLength { + /// Actual suffix length. + len: usize, + }, /// Registration reached a block but the slot was not held by the target. #[error( "registration reached block {block_hash} but slot (period {period}, seq {seq}) is not held by the target account" @@ -152,22 +168,29 @@ pub fn current_long_term_storage_period( Ok((now_seconds / u64::from(period_duration)) as u32) } -/// Derive the 32-byte StatementStore slot context: -/// `"SSS_SLOT:" ‖ u32be(period) ‖ u32be(seq) ‖ 0x20 fill`. -pub fn derive_slot_context(period: u32, seq: u32) -> [u8; 32] { - let mut ctx = [0x20u8; 32]; - ctx[..9].copy_from_slice(b"SSS_SLOT:"); - ctx[9..13].copy_from_slice(&period.to_be_bytes()); - ctx[13..17].copy_from_slice(&seq.to_be_bytes()); - ctx +/// Derive the network-scoped 32-byte StatementStore slot context. +pub fn derive_slot_context(network_suffix: &[u8], period: u32, seq: u32) -> [u8; 32] { + let mut suffix = [0u8; 32]; + suffix[..4].copy_from_slice(STATEMENT_STORE_CONTEXT_PREFIX); + suffix[4..8].copy_from_slice(&STATEMENT_STORE_CONTEXT_FAMILY.to_le_bytes()); + suffix[8..12].copy_from_slice(&period.to_le_bytes()); + suffix[12..16].copy_from_slice(&seq.to_le_bytes()); + + let mut preimage = Vec::with_capacity( + PRODUCT_CONTEXT_PREFIX.len() + network_suffix.len() + b"/".len() + suffix.len(), + ); + preimage.extend_from_slice(PRODUCT_CONTEXT_PREFIX); + preimage.extend_from_slice(network_suffix); + preimage.push(b'/'); + preimage.extend_from_slice(&suffix); + blake2_256(&preimage) } /// Derive the 32-byte Asset Hub PGAS claim context: /// `"pop:gas:" ‖ u32le(day) ‖ u32le(slot_index) ‖ zero fill`. /// -/// The two integers are little-endian here, unlike the big-endian statement-store -/// and long-term-storage contexts. The mobile wallet writes them this way and the -/// runtime verifies against the same bytes, so the layout is not ours to tidy. +/// The mobile wallet writes the integers in little-endian order and the runtime +/// verifies against the same bytes, so the layout is not ours to tidy. pub fn derive_pgas_context(day: u32, slot_index: u32) -> [u8; 32] { let mut ctx = [0u8; 32]; ctx[..PGAS_CONTEXT_PREFIX.len()].copy_from_slice(PGAS_CONTEXT_PREFIX); @@ -191,11 +214,12 @@ pub fn derive_long_term_storage_context(period: u32, counter: u8) -> [u8; 32] { /// The slot alias for our `entropy` at `(period, seq)`. pub fn slot_alias( entropy: [u8; 32], + network_suffix: &[u8], period: u32, seq: u32, ) -> Result<[u8; 32], StatementAllowanceError> { let secret = BandersnatchVrfVerifiable::new_secret(entropy); - let context = derive_slot_context(period, seq); + let context = derive_slot_context(network_suffix, period, seq); BandersnatchVrfVerifiable::alias_in_context(&secret, &context).map_err(|err| { SlotError::AliasInContext { context: "statement-store slot", @@ -366,6 +390,27 @@ fn timestamp_now_key() -> Vec { .concat() } +fn network_suffix_key() -> Vec { + [ + twox_128(b"NetworkSuffix").as_slice(), + twox_128(b"NetworkSuffix").as_slice(), + ] + .concat() +} + +/// Read the runtime-wide suffix used for product-scoped proof contexts. +pub async fn read_network_suffix(rpc: &RpcClient) -> Result, StatementAllowanceError> { + let bytes = rpc + .get_storage(&network_suffix_key()) + .await? + .ok_or(SlotError::MissingNetworkSuffix)?; + let suffix = Vec::::decode_all(&mut &bytes[..]).map_err(SlotError::NetworkSuffixDecode)?; + if suffix.is_empty() || suffix.len() > MAX_NETWORK_SUFFIX_LENGTH { + return Err(SlotError::InvalidNetworkSuffixLength { len: suffix.len() }.into()); + } + Ok(suffix) +} + /// The chain's clock in unix seconds, decoded from `Timestamp.Now` milliseconds. /// /// Slot ages are judged against this rather than the host clock, which runs up to @@ -399,11 +444,12 @@ pub async fn replacement_cooldown( pub async fn read_slot_account_at( rpc: &RpcClient, entropy: [u8; 32], + network_suffix: &[u8], period: u32, seq: u32, block_hash: &str, ) -> Result, StatementAllowanceError> { - let alias = slot_alias(entropy, period, seq)?; + let alias = slot_alias(entropy, network_suffix, period, seq)?; let key = statement_store_allowance_key(period, &alias); Ok(rpc .get_storage_at(&key, block_hash) @@ -441,6 +487,8 @@ pub struct SlotScan<'a> { pub collection: PersonhoodCollection, /// Our bandersnatch entropy for `collection`. pub entropy: [u8; 32], + /// Runtime-wide suffix used for product-scoped aliases. + pub network_suffix: &'a [u8], /// Statement-store period to scan. pub period: u32, /// Account whose existing slot, if any, should be reported. @@ -461,6 +509,7 @@ pub async fn scan_slot_excluding( let SlotScan { collection, entropy, + network_suffix, period, target, excluded, @@ -471,7 +520,7 @@ pub async fn scan_slot_excluding( let mut excluded_free = false; let mut occupied = Vec::new(); for seq in 0..max { - let alias = slot_alias(entropy, period, seq)?; + let alias = slot_alias(entropy, network_suffix, period, seq)?; let key = statement_store_allowance_key(period, &alias); match rpc.get_storage(&key).await? { None => { @@ -619,6 +668,7 @@ mod tests { /// `LiteStmtStoreSlotsPerPeriod` is 10. const FIXTURE: &[u8] = include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); const SLOTS: usize = 10; + const NETWORK_SUFFIX: &[u8] = b"paseo"; /// `StmtStoreAllowanceEntry { account_id, seq: 0, since: 0 }` as a scripted /// JSON storage result. @@ -657,6 +707,7 @@ mod tests { SlotScan { collection: PersonhoodCollection::LitePeople, entropy: [0x11; 32], + network_suffix: NETWORK_SUFFIX, period: 7, target: &[0x22; 32], excluded: &[], @@ -730,6 +781,7 @@ mod tests { SlotScan { collection: PersonhoodCollection::LitePeople, entropy: [0x11; 32], + network_suffix: NETWORK_SUFFIX, period: 7, target: &[0x22; 32], excluded: &[], @@ -901,6 +953,7 @@ mod tests { SlotScan { collection: PersonhoodCollection::LitePeople, entropy: [0x11; 32], + network_suffix: NETWORK_SUFFIX, period: 7, target: &[0x22; 32], excluded: &[(SLOTS - 1) as u32], @@ -1004,12 +1057,86 @@ mod tests { } #[test] - fn slot_context_layout() { - let ctx = derive_slot_context(7, 3); - assert_eq!(&ctx[..9], b"SSS_SLOT:"); - assert_eq!(&ctx[9..13], &7u32.to_be_bytes()); - assert_eq!(&ctx[13..17], &3u32.to_be_bytes()); - assert!(ctx[17..].iter().all(|&b| b == 0x20)); + fn statement_slot_context_matches_mobile_clients_and_runtime() { + let expected: [u8; 32] = + hex::decode("b6c21225dcf4c2aeeca32b6db1fc93b6942ca0e8ff5c3cb1b2c5d8f0b4647ee3") + .unwrap() + .try_into() + .unwrap(); + + assert_eq!(derive_slot_context(NETWORK_SUFFIX, 100, 3), expected); + } + + #[test] + fn statement_slot_context_is_scoped_to_the_network() { + assert_ne!( + derive_slot_context(b"paseo", 100, 3), + derive_slot_context(b"polkadot", 100, 3), + ); + } + + #[test] + fn network_suffix_is_read_from_chain_storage() { + let scripted = ScriptedRpc::new(vec![r#""0x14706173656f""#]); + let rpc = RpcClient::new(HostRpcClient::new(scripted)); + + assert_eq!( + futures::executor::block_on(read_network_suffix(&rpc)).unwrap(), + b"paseo", + ); + } + + #[test] + fn missing_network_suffix_is_rejected() { + let scripted = ScriptedRpc::new(vec!["null"]); + let rpc = RpcClient::new(HostRpcClient::new(scripted)); + + assert!(matches!( + futures::executor::block_on(read_network_suffix(&rpc)), + Err(StatementAllowanceError::Slot( + SlotError::MissingNetworkSuffix + )), + )); + } + + #[test] + fn malformed_network_suffix_is_rejected() { + let malformed = ScriptedRpc::new(vec![r#""0x14""#]); + let malformed_rpc = RpcClient::new(HostRpcClient::new(malformed)); + + assert!(matches!( + futures::executor::block_on(read_network_suffix(&malformed_rpc)), + Err(StatementAllowanceError::Slot( + SlotError::NetworkSuffixDecode(_) + )), + )); + } + + #[test] + fn empty_network_suffix_is_rejected() { + let empty = ScriptedRpc::new(vec![r#""0x00""#]); + let empty_rpc = RpcClient::new(HostRpcClient::new(empty)); + + assert!(matches!( + futures::executor::block_on(read_network_suffix(&empty_rpc)), + Err(StatementAllowanceError::Slot( + SlotError::InvalidNetworkSuffixLength { len: 0 } + )), + )); + } + + #[test] + fn oversized_network_suffix_is_rejected() { + let oversized_response = format!(r#""0x{}""#, hex::encode(vec![0x44; 18])); + let oversized = ScriptedRpc::new([oversized_response.as_str()]); + let oversized_rpc = RpcClient::new(HostRpcClient::new(oversized)); + + assert!(matches!( + futures::executor::block_on(read_network_suffix(&oversized_rpc)), + Err(StatementAllowanceError::Slot( + SlotError::InvalidNetworkSuffixLength { len: 17 } + )), + )); } #[test] diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 633a555ab..43c1ccf77 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -1318,9 +1318,28 @@ fn method_keyed_responses( serde_json::from_str(&request).expect("request is valid JSON"); let id = value["id"].as_str().expect("request carries a string id"); let method = value["method"].as_str().expect("request carries a method"); + let occurrence = sent + .lock() + .expect("rpc list mutex poisoned") + .iter() + .take(answered) + .filter(|request| { + serde_json::from_str::(request) + .ok() + .and_then(|value| value["method"].as_str().map(str::to_owned)) + .is_some_and(|candidate| candidate == method) + }) + .count(); let result = answers .iter() - .find(|(candidate, _)| *candidate == method) + .filter(|(candidate, _)| *candidate == method) + .nth(occurrence) + .or_else(|| { + answers + .iter() + .rev() + .find(|(candidate, _)| *candidate == method) + }) .map(|(_, body)| body.clone()) .unwrap_or_else(|| panic!("no scripted response for method `{method}`")); return Some(( From 01a932128b2048c2cc36fba972ef4eed38346c6d Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 3 Sep 2026 11:37:41 +0200 Subject: [PATCH 12/14] fix: adopt current proof contexts Remove legacy LTS and PGAS contexts and scope every proof family by the live network suffix. Follow the generation-prefixed Asset Hub ring-root layout used by current mobile clients and runtime. --- rust/crates/truapi-host-cli/src/main.rs | 5 + .../truapi-host-cli/tests/live_asset_hub.rs | 18 +- .../src/runtime/signing_host/sso_responder.rs | 21 ++- .../src/runtime/statement_allowance.rs | 41 ++++- .../src/runtime/statement_allowance/pgas.rs | 105 ++++++++--- .../src/runtime/statement_allowance/proof.rs | 4 +- .../src/runtime/statement_allowance/ring.rs | 2 +- .../src/runtime/statement_allowance/slot.rs | 174 ++++++++++++------ .../truapi-server/tests/fixtures/README.md | 7 +- 9 files changed, 273 insertions(+), 104 deletions(-) diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 42bb3a267..323672608 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -642,6 +642,9 @@ async fn run_pgas_check( let asset_hub_state = alloc::fetch_chain_state(&asset_hub_rpc) .await .map_err(anyhow::Error::msg)?; + let network_suffix = alloc::slot::read_network_suffix(&asset_hub_rpc) + .await + .map_err(anyhow::Error::msg)?; println!( "asset hub: metadata V{} specVersion={} txVersion={} genesis=0x{}", asset_hub_metadata.metadata_version(), @@ -715,6 +718,7 @@ async fn run_pgas_check( &asset_hub_metadata, ring.collection, membership.entropy, + &network_suffix, day, &[], ) @@ -738,6 +742,7 @@ async fn run_pgas_check( people_rpc: &people_rpc, people_metadata: &people_metadata, entropy: membership.entropy, + network_suffix: &network_suffix, target: &target, ring: &membership.ring, }) diff --git a/rust/crates/truapi-host-cli/tests/live_asset_hub.rs b/rust/crates/truapi-host-cli/tests/live_asset_hub.rs index 693104035..13794aa0a 100644 --- a/rust/crates/truapi-host-cli/tests/live_asset_hub.rs +++ b/rust/crates/truapi-host-cli/tests/live_asset_hub.rs @@ -22,8 +22,8 @@ fn asset_hub_ws() -> String { } const PEOPLE_WS: &str = "wss://paseo-people-next-system-rpc.polkadot.io"; -/// The ring our onboarded test identity sits in. -const RING_INDEX: u32 = 2; +/// An active lite-person ring mirrored to Asset Hub. +const RING_INDEX: u32 = 1; /// The ring this fixture's index belongs to. const COLLECTION: PersonhoodCollection = PersonhoodCollection::LitePeople; @@ -37,6 +37,20 @@ async fn asset_hub() -> (alloc::rpc::RpcClient, alloc::extension::Metadata) { (rpc, metadata) } +#[tokio::test] +#[ignore = "needs network access to a live Asset Hub"] +async fn live_asset_hub_reports_the_product_context_suffix() { + let (rpc, _metadata) = asset_hub().await; + let expected = std::env::var("LIVE_TLD").unwrap_or_else(|_| "paseo".to_string()); + + assert_eq!( + alloc::slot::read_network_suffix(&rpc) + .await + .expect("read Asset Hub NetworkSuffix"), + expected.as_bytes(), + ); +} + /// The claim encodes five fields for `AsPgas::Claim`. A short payload is accepted /// locally and then panics the runtime inside `validate_transaction`, which is how /// the missing `revision` on the statement-store claim went unnoticed. 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 5c96daf91..0eae554e1 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 @@ -1210,6 +1210,7 @@ pub(super) async fn allocate_bulletin_allowance( .await?; let people_rpc = people_client.rpc(); let chain = services.chain_context.get(&people_client).await?; + let network_suffix = statement_allowance::slot::read_network_suffix(people_rpc).await?; let session = signing_host .current_session() .ok_or(AuthorityError::Disconnected)?; @@ -1237,15 +1238,16 @@ pub(super) async fn allocate_bulletin_allowance( current_unix_secs()?, period_duration, )?; - let outcome = claim_long_term_storage( - people_rpc, - &chain.metadata, - &chain.state, - membership.entropy, - &target, + let outcome = claim_long_term_storage(statement_allowance::LongTermStorageClaim { + rpc: people_rpc, + metadata: &chain.metadata, + chain_state: &chain.state, + entropy: membership.entropy, + network_suffix: &network_suffix, + target: &target, period, - &membership.ring, - ) + ring: &membership.ring, + }) .await?; let statement_allowance::LongTermStorageOutcome::Claimed { block_hash, @@ -1353,6 +1355,8 @@ pub(super) async fn allocate_smart_contract_allowance( debug!(%product_id, "PGAS allowance already funded; leaving it alone"); return Ok(()); } + let network_suffix = + statement_allowance::slot::read_network_suffix(asset_hub_client.rpc()).await?; let people_client = services .statement_store @@ -1376,6 +1380,7 @@ pub(super) async fn allocate_smart_contract_allowance( people_rpc, people_metadata: &people.metadata, entropy: membership.entropy, + network_suffix: &network_suffix, target: &target, ring: &membership.ring, }) diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index 4e3f4757d..62f13b190 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -441,6 +441,26 @@ pub enum LongTermStorageOutcome { }, } +/// Everything one long-term-storage claim needs. +pub struct LongTermStorageClaim<'a> { + /// People connection the claim is submitted on. + pub rpc: &'a RpcClient, + /// People runtime metadata. + pub metadata: &'a Metadata, + /// People signed-extension state. + pub chain_state: &'a ChainState, + /// Our ring-VRF entropy for the collection `ring` names. + pub entropy: [u8; 32], + /// People suffix used for the product-scoped alias and proof. + pub network_suffix: &'a [u8], + /// Account whose Bulletin allowance is authorized. + pub target: &'a [u8; 32], + /// People long-term-storage period. + pub period: u32, + /// Ring the membership proof is built against. + pub ring: &'a RingParams, +} + /// Bulletin authorization state for one account. #[derive(Debug, Clone, Copy)] pub struct BulletinAllowanceInfo { @@ -1004,14 +1024,18 @@ pub async fn register_statement_account_pooled( /// Claim long-term Bulletin storage authorization for `target`, proving /// membership in the already-located `ring`, at People-chain `period`. pub async fn claim_long_term_storage( - rpc: &RpcClient, - metadata: &Metadata, - chain_state: &ChainState, - entropy: [u8; 32], - target: &[u8; 32], - period: u32, - ring: &RingParams, + params: LongTermStorageClaim<'_>, ) -> Result { + let LongTermStorageClaim { + rpc, + metadata, + chain_state, + entropy, + network_suffix, + target, + period, + ring, + } = params; let revision = ring::read_ring_revision( rpc, metadata, @@ -1026,12 +1050,13 @@ pub async fn claim_long_term_storage( rpc, metadata, entropy, + network_suffix, period, &skipped_duplicate_counters, ) .await?; - let context = slot::derive_long_term_storage_context(period, counter); + let context = slot::derive_long_term_storage_context(network_suffix, period, counter); let call = extrinsic::build_claim_long_term_storage_call(metadata, period, counter, target)?; let message = extension::build_proof_message(metadata, &call, chain_state)?; diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/pgas.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/pgas.rs index 0464f5bbe..7d1a778cd 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/pgas.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/pgas.rs @@ -21,7 +21,7 @@ use thiserror::Error; use super::collection::PersonhoodCollection; use super::extension::{AS_PGAS, Metadata, MetadataError}; -use super::ring::{self, RingParams, blake2_128_concat}; +use super::ring::{self, RingParams, blake2_128_concat, twox_64_concat}; use super::rpc::RpcClient; use super::{ ChainContext, StatementAllowanceError, duplicate_submit_error, extension, extrinsic, proof, @@ -55,6 +55,9 @@ pub enum PgasError { /// Revision the proof was built against. revision: u32, }, + /// `MembersSubscriber.CurrentGeneration` was not a SCALE-encoded `u32`. + #[error("MembersSubscriber.CurrentGeneration: {0}")] + GenerationDecode(#[source] parity_scale_codec::Error), /// The asset account's leading balance failed to decode. #[error("PGAS balance: {0}")] BalanceDecode(#[source] parity_scale_codec::Error), @@ -92,15 +95,30 @@ pub struct PgasClaimOutcome { pub ring_index: u32, } -/// `MembersSubscriber.RingRoots[(identifier, ring_index)]` storage key on Asset -/// Hub. -/// -/// Both map keys are `Blake2_128Concat` here, unlike the People chain's -/// `Members` maps which take the collection identifier verbatim. -fn ring_roots_key(collection: PersonhoodCollection, ring_index: u32) -> Vec { +fn current_generation_key() -> Vec { + [ + twox_128(b"MembersSubscriber").as_slice(), + twox_128(b"CurrentGeneration").as_slice(), + ] + .concat() +} + +async fn read_current_generation(rpc: &RpcClient) -> Result { + match rpc.get_storage(¤t_generation_key()).await? { + Some(bytes) => u32::decode(&mut &bytes[..]) + .map_err(PgasError::GenerationDecode) + .map_err(Into::into), + None => Ok(0), + } +} + +/// `MembersSubscriber.RingRoots[(generation, identifier, ring_index)]` storage +/// key on Asset Hub. +fn ring_roots_key(generation: u32, collection: PersonhoodCollection, ring_index: u32) -> Vec { [ twox_128(b"MembersSubscriber").as_slice(), twox_128(b"RingRoots").as_slice(), + &twox_64_concat(&generation.to_le_bytes()), &blake2_128_concat(collection.identifier()), &blake2_128_concat(&ring_index.to_le_bytes()), ] @@ -120,6 +138,8 @@ pub struct PgasClaim<'a> { pub people_metadata: &'a Metadata, /// Our ring-VRF entropy for the collection `ring` names. pub entropy: [u8; 32], + /// Asset Hub suffix used for the product-scoped alias and proof. + pub network_suffix: &'a [u8], /// Account the claim credits. pub target: &'a [u8; 32], /// Ring the membership proof is built against, already located on People. @@ -174,6 +194,7 @@ pub async fn claim_pgas( people_rpc, people_metadata, entropy, + network_suffix, target, ring, } = params; @@ -206,11 +227,12 @@ pub async fn claim_pgas( asset_hub_metadata, ring.collection, entropy, + network_suffix, day, &skipped_duplicate_slots, ) .await?; - let context = slot::derive_pgas_context(day, slot_index); + let context = slot::derive_pgas_context(network_suffix, day, slot_index); let call = extrinsic::build_claim_pgas_call(asset_hub_metadata, slot_index, target)?; let message = extension::build_proof_message_after_extension( asset_hub_metadata, @@ -245,6 +267,7 @@ pub async fn claim_pgas( if !slot::pgas_slot_is_claimed_at( asset_hub_rpc, entropy, + network_suffix, day, slot_index, &block_hash, @@ -322,10 +345,11 @@ pub async fn await_ring_revision( pallet: "MembersSubscriber", entry: "RingRoots", })?; + let generation = read_current_generation(rpc).await?; let started = Instant::now(); loop { if let Some(bytes) = rpc - .get_storage(&ring_roots_key(collection, ring_index)) + .get_storage(&ring_roots_key(generation, collection, ring_index)) .await? { let mut input = bytes.as_slice(); @@ -380,9 +404,10 @@ mod tests { use super::super::test_fixtures; use super::*; - /// The collection the captured roots were read from. `RingRoots` is keyed by - /// collection, so the fixture only means anything paired with this one. + /// The collection the captured roots were read from. The fixture only means + /// anything paired with this identifier. const CAPTURED_COLLECTION: PersonhoodCollection = PersonhoodCollection::LitePeople; + const TEST_GENERATION: u32 = 7; /// The captured ring-5 roots as a scripted `state_getStorage` result, with the /// transport handle so the key that was read can be checked. @@ -391,7 +416,8 @@ mod tests { r#""0x{}""#, hex::encode(test_fixtures::ASSET_HUB_RING_5_ROOTS) ); - let scripted = ScriptedRpc::new([value.as_str()]); + let generation = format!(r#""0x{}""#, hex::encode(TEST_GENERATION.to_le_bytes())); + let scripted = ScriptedRpc::new([generation.as_str(), value.as_str()]); ( RpcClient::new(HostRpcClient::new(scripted.clone())), scripted, @@ -413,12 +439,24 @@ mod tests { CAPTURED_UNDER, "the committed blob was read under the lite-people identifier", ); - let expected = format!( + let current_generation = format!( + r#"["0x{}"]"#, + hex::encode( + [ + twox_128(b"MembersSubscriber").as_slice(), + twox_128(b"CurrentGeneration").as_slice(), + ] + .concat() + ) + ); + let generation = twox_64_concat(&TEST_GENERATION.to_le_bytes()); + let ring_roots = format!( r#"["0x{}"]"#, hex::encode( [ twox_128(b"MembersSubscriber").as_slice(), twox_128(b"RingRoots").as_slice(), + &generation, &blake2_128_concat(CAPTURED_UNDER), &blake2_128_concat(&5u32.to_le_bytes()), ] @@ -433,8 +471,8 @@ mod tests { .collect(); assert_eq!( reads, - vec![expected], - "the captured blob has to be paired with the collection it was read for" + vec![current_generation, ring_roots], + "the roots read must use the current generation and the fixture collection" ); } @@ -507,25 +545,50 @@ mod tests { assert_read_ring_5_of(&scripted); } - /// Both map keys are hashed here, unlike the People chain's `Members` maps. + /// The generation uses `Twox64Concat`; the remaining two keys use + /// `Blake2_128Concat`. #[test] - fn subscriber_ring_key_hashes_both_map_keys() { + fn subscriber_ring_key_hashes_all_three_map_keys() { let collection = PersonhoodCollection::LitePeople; - let key = ring_roots_key(collection, 136); + let key = ring_roots_key(7, collection, 136); - assert_eq!(key.len(), 16 + 16 + 16 + 32 + 16 + 4); + assert_eq!(key.len(), 16 + 16 + 8 + 4 + 16 + 32 + 16 + 4); assert_eq!( - &key[48..80], + &key[40..44], + &7u32.to_le_bytes(), + "generation follows its hash" + ); + assert_eq!( + &key[60..92], collection.identifier(), "identifier follows its hash" ); assert_eq!( - &key[96..], + &key[108..], &136u32.to_le_bytes(), "ring index is little-endian" ); } + #[test] + fn missing_current_generation_uses_the_runtime_default() { + let scripted = ScriptedRpc::new(["null"]); + let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); + + assert_eq!( + futures::executor::block_on(read_current_generation(&rpc)).unwrap(), + 0 + ); + assert_eq!( + scripted.calls(), + vec![( + "state_getStorage".to_string(), + r#"["0xc8d053ab324196afc756c5ae3fbd2917c2dbc4fc2f665a39ada06f0965cccf86"]"# + .to_string(), + )] + ); + } + /// `holds_a_full_claim` reads both of these from the runtime, and compares a /// balance against the claim amount. A missing constant would make the warm /// check answer the same way for every account. diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/proof.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/proof.rs index 942b07676..0f8792971 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/proof.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/proof.rs @@ -117,7 +117,7 @@ mod tests { RingDomainSize::Domain11, entropy, &members, - b"SSS_SLOT:test-context-padding..", + &[0x33; 32], &[0x42; 32], ) .unwrap(); @@ -132,7 +132,7 @@ mod tests { RingDomainSize::Domain11, entropy, &[other], - b"SSS_SLOT:test-context-padding..", + &[0x33; 32], &[0x42; 32], ) .unwrap_err(); diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs index 534f08c59..4edda67f8 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs @@ -199,7 +199,7 @@ pub(super) fn blake2_128_concat(x: &[u8]) -> Vec { } /// `Twox64Concat(x)` = `twox_64(x) ‖ x`. -fn twox_64_concat(x: &[u8]) -> Vec { +pub(super) fn twox_64_concat(x: &[u8]) -> Vec { [twox_64(x).as_slice(), x].concat() } 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 b25d09001..bd3326260 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs @@ -23,13 +23,11 @@ use super::view; /// StatementStore allowance period: one UTC day, in seconds. pub const STATEMENT_STORE_PERIOD_SECONDS: u64 = 86_400; const PRODUCT_CONTEXT_PREFIX: &[u8] = b"product/peopl."; -const STATEMENT_STORE_CONTEXT_PREFIX: &[u8] = b"sys/"; +const SYSTEM_CONTEXT_PREFIX: &[u8] = b"sys/"; const STATEMENT_STORE_CONTEXT_FAMILY: u32 = 2; +const LONG_TERM_STORAGE_CONTEXT_FAMILY: u32 = 3; +const PGAS_CONTEXT_FAMILY: u32 = 4; const MAX_NETWORK_SUFFIX_LENGTH: usize = 16; -/// Bulletin long-term-storage claim context prefix. -const LONG_TERM_STORAGE_CONTEXT_PREFIX: &[u8] = b"pop:polkadot.net/rsc-lts"; -/// Ring-VRF alias context prefix for an Asset Hub PGAS claim. -const PGAS_CONTEXT_PREFIX: &[u8] = b"pop:gas:"; /// Slots probed per batched storage read while scanning for a free PGAS slot. /// /// Reading every slot in one request would cost a round trip flat, but each slot's @@ -168,13 +166,12 @@ pub fn current_long_term_storage_period( Ok((now_seconds / u64::from(period_duration)) as u32) } -/// Derive the network-scoped 32-byte StatementStore slot context. -pub fn derive_slot_context(network_suffix: &[u8], period: u32, seq: u32) -> [u8; 32] { +fn derive_product_context(network_suffix: &[u8], family: u32, first: u32, second: u32) -> [u8; 32] { let mut suffix = [0u8; 32]; - suffix[..4].copy_from_slice(STATEMENT_STORE_CONTEXT_PREFIX); - suffix[4..8].copy_from_slice(&STATEMENT_STORE_CONTEXT_FAMILY.to_le_bytes()); - suffix[8..12].copy_from_slice(&period.to_le_bytes()); - suffix[12..16].copy_from_slice(&seq.to_le_bytes()); + suffix[..4].copy_from_slice(SYSTEM_CONTEXT_PREFIX); + suffix[4..8].copy_from_slice(&family.to_le_bytes()); + suffix[8..12].copy_from_slice(&first.to_le_bytes()); + suffix[12..16].copy_from_slice(&second.to_le_bytes()); let mut preimage = Vec::with_capacity( PRODUCT_CONTEXT_PREFIX.len() + network_suffix.len() + b"/".len() + suffix.len(), @@ -186,29 +183,28 @@ pub fn derive_slot_context(network_suffix: &[u8], period: u32, seq: u32) -> [u8; blake2_256(&preimage) } -/// Derive the 32-byte Asset Hub PGAS claim context: -/// `"pop:gas:" ‖ u32le(day) ‖ u32le(slot_index) ‖ zero fill`. -/// -/// The mobile wallet writes the integers in little-endian order and the runtime -/// verifies against the same bytes, so the layout is not ours to tidy. -pub fn derive_pgas_context(day: u32, slot_index: u32) -> [u8; 32] { - let mut ctx = [0u8; 32]; - ctx[..PGAS_CONTEXT_PREFIX.len()].copy_from_slice(PGAS_CONTEXT_PREFIX); - let offset = PGAS_CONTEXT_PREFIX.len(); - ctx[offset..offset + 4].copy_from_slice(&day.to_le_bytes()); - ctx[offset + 4..offset + 8].copy_from_slice(&slot_index.to_le_bytes()); - ctx +/// Derive the network-scoped 32-byte StatementStore slot context. +pub fn derive_slot_context(network_suffix: &[u8], period: u32, seq: u32) -> [u8; 32] { + derive_product_context(network_suffix, STATEMENT_STORE_CONTEXT_FAMILY, period, seq) +} + +/// Derive the network-scoped 32-byte Asset Hub PGAS claim context. +pub fn derive_pgas_context(network_suffix: &[u8], day: u32, slot_index: u32) -> [u8; 32] { + derive_product_context(network_suffix, PGAS_CONTEXT_FAMILY, day, slot_index) } -/// Derive the 32-byte Bulletin long-term-storage slot context: -/// `"pop:polkadot.net/rsc-lts" ‖ u32be(period) ‖ counter ‖ zero fill`. -pub fn derive_long_term_storage_context(period: u32, counter: u8) -> [u8; 32] { - let mut ctx = [0u8; 32]; - ctx[..LONG_TERM_STORAGE_CONTEXT_PREFIX.len()].copy_from_slice(LONG_TERM_STORAGE_CONTEXT_PREFIX); - let offset = LONG_TERM_STORAGE_CONTEXT_PREFIX.len(); - ctx[offset..offset + 4].copy_from_slice(&period.to_be_bytes()); - ctx[offset + 4] = counter; - ctx +/// Derive the network-scoped 32-byte Bulletin long-term-storage context. +pub fn derive_long_term_storage_context( + network_suffix: &[u8], + period: u32, + counter: u8, +) -> [u8; 32] { + derive_product_context( + network_suffix, + LONG_TERM_STORAGE_CONTEXT_FAMILY, + period, + u32::from(counter), + ) } /// The slot alias for our `entropy` at `(period, seq)`. @@ -232,11 +228,12 @@ pub fn slot_alias( /// The PGAS claim alias for our `entropy` at `(day, slot_index)`. pub fn pgas_alias( entropy: [u8; 32], + network_suffix: &[u8], day: u32, slot_index: u32, ) -> Result<[u8; 32], StatementAllowanceError> { let secret = BandersnatchVrfVerifiable::new_secret(entropy); - let context = derive_pgas_context(day, slot_index); + let context = derive_pgas_context(network_suffix, day, slot_index); BandersnatchVrfVerifiable::alias_in_context(&secret, &context).map_err(|err| { SlotError::AliasInContext { context: "PGAS claim slot", @@ -249,11 +246,12 @@ pub fn pgas_alias( /// The long-term-storage slot alias for our `entropy` at `(period, counter)`. pub fn long_term_storage_alias( entropy: [u8; 32], + network_suffix: &[u8], period: u32, counter: u8, ) -> Result<[u8; 32], StatementAllowanceError> { let secret = BandersnatchVrfVerifiable::new_secret(entropy); - let context = derive_long_term_storage_context(period, counter); + let context = derive_long_term_storage_context(network_suffix, period, counter); BandersnatchVrfVerifiable::alias_in_context(&secret, &context).map_err(|err| { SlotError::AliasInContext { context: "long-term-storage slot", @@ -571,11 +569,12 @@ pub async fn scan_pgas_slot_excluding( metadata: &Metadata, collection: PersonhoodCollection, entropy: [u8; 32], + network_suffix: &[u8], day: u32, excluded: &[u32], ) -> Result { let max = max_pgas_claims(metadata, collection)?; - scan_pgas_slot_in(rpc, entropy, day, max, excluded).await + scan_pgas_slot_in(rpc, entropy, network_suffix, day, max, excluded).await } /// The scan itself, over a known slot count. @@ -584,6 +583,7 @@ pub async fn scan_pgas_slot_excluding( async fn scan_pgas_slot_in( rpc: &RpcClient, entropy: [u8; 32], + network_suffix: &[u8], day: u32, max: u32, excluded: &[u32], @@ -600,7 +600,8 @@ async fn scan_pgas_slot_in( let keys = batch .iter() .map(|&slot_index| { - pgas_alias(entropy, day, slot_index).map(|alias| claimed_gas_alias_key(day, &alias)) + pgas_alias(entropy, network_suffix, day, slot_index) + .map(|alias| claimed_gas_alias_key(day, &alias)) }) .collect::, _>>()?; let claimed = rpc.get_storage_many(&keys).await?; @@ -624,11 +625,12 @@ async fn scan_pgas_slot_in( pub async fn pgas_slot_is_claimed_at( rpc: &RpcClient, entropy: [u8; 32], + network_suffix: &[u8], day: u32, slot_index: u32, block_hash: &str, ) -> Result { - let alias = pgas_alias(entropy, day, slot_index)?; + let alias = pgas_alias(entropy, network_suffix, day, slot_index)?; let key = claimed_gas_alias_key(day, &alias); Ok(rpc.get_storage_at(&key, block_hash).await?.is_some()) } @@ -639,6 +641,7 @@ pub async fn scan_long_term_storage_counter_excluding( rpc: &RpcClient, metadata: &Metadata, entropy: [u8; 32], + network_suffix: &[u8], period: u32, excluded: &[u8], ) -> Result { @@ -647,7 +650,7 @@ pub async fn scan_long_term_storage_counter_excluding( if excluded.contains(&counter) { continue; } - let alias = long_term_storage_alias(entropy, period, counter)?; + let alias = long_term_storage_alias(entropy, network_suffix, period, counter)?; let key = spent_long_term_storage_alias_key(period, &alias); if rpc.get_storage(&key).await?.is_none() { return Ok(counter); @@ -977,7 +980,7 @@ mod tests { // keys that exist, so the absent ones are simply missing from `changes`. let claimed: Vec = (0..3u32) .map(|slot_index| { - let alias = pgas_alias(ENTROPY, DAY, slot_index).unwrap(); + let alias = pgas_alias(ENTROPY, NETWORK_SUFFIX, DAY, slot_index).unwrap(); format!( r#"["0x{}","0x"]"#, hex::encode(claimed_gas_alias_key(DAY, &alias)) @@ -991,8 +994,15 @@ mod tests { let scripted = ScriptedRpc::new(vec![response.as_str()]); let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); - let chosen = - futures::executor::block_on(scan_pgas_slot_in(&rpc, ENTROPY, DAY, 40, &[])).unwrap(); + let chosen = futures::executor::block_on(scan_pgas_slot_in( + &rpc, + ENTROPY, + NETWORK_SUFFIX, + DAY, + 40, + &[], + )) + .unwrap(); assert_eq!(chosen, 3, "the first free slot, in order"); let calls = scripted.calls(); @@ -1015,6 +1025,7 @@ mod tests { futures::executor::block_on(pgas_slot_is_claimed_at( &RpcClient::new(HostRpcClient::new(spent)), ENTROPY, + NETWORK_SUFFIX, DAY, 0, "0xb10c", @@ -1025,6 +1036,7 @@ mod tests { !futures::executor::block_on(pgas_slot_is_claimed_at( &RpcClient::new(HostRpcClient::new(absent)), ENTROPY, + NETWORK_SUFFIX, DAY, 0, "0xb10c", @@ -1033,16 +1045,15 @@ mod tests { ); } - /// The PGAS context is little-endian where the other two are big-endian, and - /// the runtime verifies the proof against these exact bytes. #[test] - fn pgas_context_layout_is_little_endian() { - let ctx = derive_pgas_context(0x0102_0304, 0x0506_0708); + fn pgas_context_matches_mobile_clients_and_runtime() { + let expected: [u8; 32] = + hex::decode("e47ba2c7eae3b97beabaeef8df599afd53e44ba9c2b851cd80850d3ed95a685b") + .unwrap() + .try_into() + .unwrap(); - assert_eq!(&ctx[..8], b"pop:gas:"); - assert_eq!(&ctx[8..12], &[0x04, 0x03, 0x02, 0x01]); - assert_eq!(&ctx[12..16], &[0x08, 0x07, 0x06, 0x05]); - assert_eq!(&ctx[16..], &[0u8; 16]); + assert_eq!(derive_pgas_context(NETWORK_SUFFIX, 100, 3), expected); } /// `ClaimedGasAliases` is `Identity(u32be day) ‖ Blake2_128Concat(alias)`. @@ -1068,10 +1079,15 @@ mod tests { } #[test] - fn statement_slot_context_is_scoped_to_the_network() { - assert_ne!( - derive_slot_context(b"paseo", 100, 3), - derive_slot_context(b"polkadot", 100, 3), + fn product_contexts_are_scoped_to_the_network() { + assert_eq!( + [ + derive_slot_context(b"paseo", 100, 3) != derive_slot_context(b"polkadot", 100, 3), + derive_long_term_storage_context(b"paseo", 100, 3) + != derive_long_term_storage_context(b"polkadot", 100, 3), + derive_pgas_context(b"paseo", 100, 3) != derive_pgas_context(b"polkadot", 100, 3), + ], + [true; 3], ); } @@ -1140,12 +1156,52 @@ mod tests { } #[test] - fn long_term_storage_context_layout() { - let ctx = derive_long_term_storage_context(7, 3); - assert_eq!(&ctx[..24], b"pop:polkadot.net/rsc-lts"); - assert_eq!(&ctx[24..28], &7u32.to_be_bytes()); - assert_eq!(ctx[28], 3); - assert!(ctx[29..].iter().all(|&b| b == 0)); + fn long_term_storage_context_matches_mobile_clients_and_runtime() { + let expected: [u8; 32] = + hex::decode("1b3fbe4dd813ea1e349878c9228c6823db8345207690ca4df656acb7fee81bd1") + .unwrap() + .try_into() + .unwrap(); + + assert_eq!( + derive_long_term_storage_context(NETWORK_SUFFIX, 100, 3), + expected, + ); + } + + #[test] + fn long_term_storage_scan_uses_the_requested_network_suffix() { + const ENTROPY: [u8; 32] = [0x11; 32]; + const PERIOD: u32 = 7; + const SUFFIX: &[u8] = b"previewnet"; + + let metadata = Metadata::decode(FIXTURE).unwrap(); + let scripted = ScriptedRpc::new([r#""0x""#, "null"]); + let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); + + let counter = futures::executor::block_on(scan_long_term_storage_counter_excluding( + &rpc, + &metadata, + ENTROPY, + SUFFIX, + PERIOD, + &[], + )) + .unwrap(); + let calls = (0..=1) + .map(|counter| { + let alias = long_term_storage_alias(ENTROPY, SUFFIX, PERIOD, counter).unwrap(); + ( + "state_getStorage".to_string(), + format!( + r#"["0x{}"]"#, + hex::encode(spent_long_term_storage_alias_key(PERIOD, &alias)) + ), + ) + }) + .collect::>(); + + assert_eq!((counter, scripted.calls()), (1, calls)); } #[test] diff --git a/rust/crates/truapi-server/tests/fixtures/README.md b/rust/crates/truapi-server/tests/fixtures/README.md index e1f6354f6..7161584a6 100644 --- a/rust/crates/truapi-server/tests/fixtures/README.md +++ b/rust/crates/truapi-server/tests/fixtures/README.md @@ -37,7 +37,7 @@ and replace both together. Re-capturing metadata alone will fail | File | Storage | Chain | Block | Captured | |---|---|---|---|---| -| `paseo-next-asset-hub-ring-5-roots.scale` | `MembersSubscriber.RingRoots[(LitePeople, 5)]` | Paseo Asset Hub Next | `0xf25d4e330ade1ce230695976f019df50cdaf97c96b6996838af93b68550654f3` | 2026-08-17 | +| `paseo-next-asset-hub-ring-5-roots.scale` | `MembersSubscriber.RingRoots[(generation, LitePeople, 5)]` | Paseo Asset Hub Next | `0xf25d4e330ade1ce230695976f019df50cdaf97c96b6996838af93b68550654f3` | 2026-08-17 | Ring 5 holds `[105, 106, 108]`. The skipped 107 is the case that distinguishes testing the newest held root from testing the oldest, and freezing it makes that case permanent @@ -50,6 +50,7 @@ does, then call `state_getStorageAt`: ``` twox_128("MembersSubscriber") ‖ twox_128("RingRoots") + ‖ twox_64_concat(current_generation_u32_le) ‖ blake2_128_concat(b"pop:polkadot.network/people-lite") ‖ blake2_128_concat(ring_index_u32_le) ``` @@ -60,8 +61,8 @@ curl -s -H 'Content-Type: application/json' \ https://paseo-asset-hub-next-rpc.polkadot.io ``` -Both map keys are hashed here, unlike the People chain's `Members` maps, which take the -collection identifier verbatim. +Read `MembersSubscriber.CurrentGeneration` first. The generation uses `Twox64Concat`; the +collection and ring index use `Blake2_128Concat`. ## Recapturing From a6f8c4aeb3ff841e0d71f55448d1351a68109038 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 3 Sep 2026 16:06:26 +0200 Subject: [PATCH 13/14] fix(server): harden the ring-root generation read and its test scripts Address review on #587. `read_current_generation` gates on the runtime declaring `MembersSubscriber.CurrentGeneration` before treating an absent value as the `ValueQuery` default, so a renamed pallet or item is named instead of reading as generation 0, and decodes with `decode_all`, so an entry that stops being a bare `u32` fails here rather than yielding the first four bytes of another layout. Either way the failure would otherwise be a key nothing answers and a wait that can only time out. `await_ring_revision` re-reads the generation each poll. A rebuild landing during the wait is what the loop waits through, and a generation read once up front would key every remaining poll at a generation the roots have left. `method_keyed_responses` panics when a method's scripted answers run out instead of replaying the last one, so a script that answers fewer calls than the code makes fails rather than feeding one read's response to another. The Asset Hub fixture is recaptured at spec 3000000, which is what declares `CurrentGeneration`, and the fixtures README records the generation the committed ring-roots value is addressed under. Pins the fourth statement-slot context vector iOS carries, family 2 at period 0 seq 0, where every suffix field is already zero and an offset slip would hide. --- .../src/runtime/statement_allowance/pgas.rs | 85 ++++++++++++++++-- .../src/runtime/statement_allowance/slot.rs | 21 +++-- .../statement_allowance/test_fixtures.rs | 5 +- rust/crates/truapi-server/src/test_support.rs | 25 ++++-- .../truapi-server/tests/fixtures/README.md | 16 +++- .../paseo-next-asset-hub-metadata.scale | Bin 672225 -> 687563 bytes 6 files changed, 126 insertions(+), 26 deletions(-) diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/pgas.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/pgas.rs index 7d1a778cd..5a2c96261 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/pgas.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/pgas.rs @@ -14,7 +14,7 @@ use std::time::{Duration, Instant}; -use parity_scale_codec::Decode; +use parity_scale_codec::{Decode, DecodeAll}; use scale_decode::DecodeAsType; use sp_crypto_hashing::twox_128; use thiserror::Error; @@ -103,9 +103,31 @@ fn current_generation_key() -> Vec { .concat() } -async fn read_current_generation(rpc: &RpcClient) -> Result { +/// The generation `RingRoots` is currently keyed under. +/// +/// An absent value is the `ValueQuery` default, so it only means generation 0 +/// once the runtime is known to declare the entry. Checking the metadata first +/// keeps a renamed pallet or item from reading as generation 0 and silently +/// building keys nothing will ever answer. +/// +/// Decoded with `decode_all`, so an entry that stops being a bare `u32` fails by +/// name here instead of yielding the first four bytes of some other layout. +async fn read_current_generation( + rpc: &RpcClient, + metadata: &Metadata, +) -> Result { + if metadata + .storage_value_type("MembersSubscriber", "CurrentGeneration") + .is_none() + { + return Err(MetadataError::MissingStorageType { + pallet: "MembersSubscriber", + entry: "CurrentGeneration", + } + .into()); + } match rpc.get_storage(¤t_generation_key()).await? { - Some(bytes) => u32::decode(&mut &bytes[..]) + Some(bytes) => u32::decode_all(&mut &bytes[..]) .map_err(PgasError::GenerationDecode) .map_err(Into::into), None => Ok(0), @@ -345,9 +367,13 @@ pub async fn await_ring_revision( pallet: "MembersSubscriber", entry: "RingRoots", })?; - let generation = read_current_generation(rpc).await?; let started = Instant::now(); loop { + // Re-read per poll rather than once up front: a rebuild landing while we + // wait is exactly what this loop is waiting through, and a generation + // read from before it would key every remaining poll at a generation the + // roots have left, so the wait could only ever time out. + let generation = read_current_generation(rpc, metadata).await?; if let Some(bytes) = rpc .get_storage(&ring_roots_key(generation, collection, ring_index)) .await? @@ -409,6 +435,12 @@ mod tests { const CAPTURED_COLLECTION: PersonhoodCollection = PersonhoodCollection::LitePeople; const TEST_GENERATION: u32 = 7; + /// A real runtime that declares no `MembersSubscriber` at all, for the + /// metadata gate below. Preferred over a synthetic `Metadata` because the + /// gate is about a runtime not carrying the pallet, which is what this is. + const PEOPLE_METADATA: &[u8] = + include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata-v16.scale"); + /// The captured ring-5 roots as a scripted `state_getStorage` result, with the /// transport handle so the key that was read can be checked. fn scripted_ring_5_roots() -> (RpcClient, ScriptedRpc) { @@ -576,7 +608,8 @@ mod tests { let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); assert_eq!( - futures::executor::block_on(read_current_generation(&rpc)).unwrap(), + futures::executor::block_on(read_current_generation(&rpc, test_fixtures::asset_hub())) + .unwrap(), 0 ); assert_eq!( @@ -589,6 +622,48 @@ mod tests { ); } + /// A trailing byte means the entry is no longer a bare `u32`, which is the + /// same layout drift the three-key ring-root key exists to track. Taking the + /// first four bytes would build keys for a generation nothing answers, and + /// the wait would read as "the ring never arrived". + #[test] + fn a_current_generation_that_is_not_a_bare_u32_is_rejected() { + let overlong = format!(r#""0x{}""#, hex::encode([7u8, 0, 0, 0, 0])); + let scripted = ScriptedRpc::new([overlong.as_str()]); + let rpc = RpcClient::new(HostRpcClient::new(scripted)); + + let err = + futures::executor::block_on(read_current_generation(&rpc, test_fixtures::asset_hub())) + .expect_err("a five-byte value is not a u32"); + + assert_eq!( + err.to_string(), + "MembersSubscriber.CurrentGeneration: Input buffer has still data left after decoding!" + ); + } + + /// An absent value is only the `ValueQuery` default if the runtime declares + /// the entry at all. A renamed pallet or item reads as absent too, and + /// defaulting there would key every ring-root read at generation 0. + #[test] + fn a_runtime_without_current_generation_is_named_rather_than_defaulted() { + let people = Metadata::decode(PEOPLE_METADATA).unwrap(); + let scripted = ScriptedRpc::new(["null"]); + let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); + + let err = futures::executor::block_on(read_current_generation(&rpc, &people)) + .expect_err("the People runtime declares no MembersSubscriber"); + + assert_eq!( + (err.to_string(), scripted.calls()), + ( + "MembersSubscriber.CurrentGeneration type not in metadata".to_string(), + vec![], + ), + "the metadata check comes before the read, so nothing is asked of the chain", + ); + } + /// `holds_a_full_claim` reads both of these from the runtime, and compares a /// balance against the claim amount. A missing constant would make the warm /// check answer the same way for every account. 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 bd3326260..761835c93 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs @@ -1067,15 +1067,24 @@ mod tests { assert_eq!(&key[52..], &alias, "alias follows its blake2_128 prefix"); } + /// Both vectors are pinned against the mobile clients and the runtime. The + /// all-zero one is the offset check: every field of the suffix is already + /// zero there, so a field written at the wrong offset still hashes to this + /// answer only if the offsets agree. #[test] fn statement_slot_context_matches_mobile_clients_and_runtime() { - let expected: [u8; 32] = - hex::decode("b6c21225dcf4c2aeeca32b6db1fc93b6942ca0e8ff5c3cb1b2c5d8f0b4647ee3") - .unwrap() - .try_into() - .unwrap(); + let vector = |hex: &str| -> [u8; 32] { hex::decode(hex).unwrap().try_into().unwrap() }; - assert_eq!(derive_slot_context(NETWORK_SUFFIX, 100, 3), expected); + assert_eq!( + ( + derive_slot_context(NETWORK_SUFFIX, 100, 3), + derive_slot_context(NETWORK_SUFFIX, 0, 0), + ), + ( + vector("b6c21225dcf4c2aeeca32b6db1fc93b6942ca0e8ff5c3cb1b2c5d8f0b4647ee3"), + vector("deee1c90cf0d31093d318ac6629b4c4ab08650d4a4164511cc2496205f20f067"), + ), + ); } #[test] diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/test_fixtures.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/test_fixtures.rs index 61413f0d7..fc28cacb2 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/test_fixtures.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/test_fixtures.rs @@ -14,9 +14,10 @@ use super::extension::Metadata; pub(crate) const ASSET_HUB_RING_5_ROOTS: &[u8] = include_bytes!("../../../tests/fixtures/paseo-next-asset-hub-ring-5-roots.scale"); -/// Asset Hub metadata captured from paseo Asset Hub Next at spec 2000036. +/// Asset Hub metadata captured from paseo Asset Hub Next at spec 3000000. /// -/// The only fixture declaring `AsPgas`, `Pgas` and `MembersSubscriber`. +/// The only fixture declaring `AsPgas`, `Pgas` and `MembersSubscriber`, so it is +/// also the only one that can exercise the ring-root generation. static ASSET_HUB: LazyLock = LazyLock::new(|| { Metadata::decode(include_bytes!( "../../../tests/fixtures/paseo-next-asset-hub-metadata.scale" diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 43c1ccf77..8f5aa129d 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -1297,6 +1297,11 @@ impl JsonRpcConnection for RecordingConnection { /// Answer each request as it arrives, by method, echoing its id. /// +/// Repeated entries for one method are answered in call order. Running past the +/// last one panics rather than replaying it: a script that answers fewer calls +/// than the code makes would otherwise hand a response meant for one read to a +/// different one, which decodes to a plausible wrong value instead of failing. +/// /// Waits indefinitely for the next request rather than giving up after a fixed /// number of polls, so work between requests cannot race the pump. fn method_keyed_responses( @@ -1330,18 +1335,20 @@ fn method_keyed_responses( .is_some_and(|candidate| candidate == method) }) .count(); - let result = answers + let scripted = answers .iter() .filter(|(candidate, _)| *candidate == method) - .nth(occurrence) - .or_else(|| { - answers - .iter() - .rev() - .find(|(candidate, _)| *candidate == method) - }) + .collect::>(); + let result = scripted + .get(occurrence) .map(|(_, body)| body.clone()) - .unwrap_or_else(|| panic!("no scripted response for method `{method}`")); + .unwrap_or_else(|| { + panic!( + "method `{method}` was called {} times, and the script has {} response(s) for it", + occurrence + 1, + scripted.len(), + ) + }); return Some(( format!(r#"{{"jsonrpc":"2.0","id":"{id}","result":{result}}}"#), answered + 1, diff --git a/rust/crates/truapi-server/tests/fixtures/README.md b/rust/crates/truapi-server/tests/fixtures/README.md index 7161584a6..45bda954f 100644 --- a/rust/crates/truapi-server/tests/fixtures/README.md +++ b/rust/crates/truapi-server/tests/fixtures/README.md @@ -13,7 +13,7 @@ expects. |---|---|---|---|---|---| | `paseo-next-v2-metadata.scale` | Paseo Next v2 | V14 | | | `AsResources`, three-field allowance info | | `paseo-next-v2-metadata-v16.scale` | Paseo Next v2 | V16 | 1000032 | | `AsResources`, four-field allowance info | -| `paseo-next-asset-hub-metadata.scale` | Paseo Asset Hub Next | V16 | 2000036 | 2026-08-17 | `AsPgas`, `Pgas`, `MembersSubscriber` | +| `paseo-next-asset-hub-metadata.scale` | Paseo Asset Hub Next | V16 | 3000000 | 2026-09-03 | `AsPgas`, `Pgas`, `MembersSubscriber` incl. `CurrentGeneration` | | `bulletin_paseo_metadata.scale` | Polkadot Bulletin (Paseo) | V14 | 1000020 | | preimage and storage calls | The two paseo-next-v2 fixtures deliberately disagree about arity: V14 predates the @@ -35,15 +35,23 @@ fixture and the metadata beside it are a matched pair. Capture both from the sam and replace both together. Re-capturing metadata alone will fail `captured_ring_roots_project_to_their_revisions` if the record layout changed. -| File | Storage | Chain | Block | Captured | -|---|---|---|---|---| -| `paseo-next-asset-hub-ring-5-roots.scale` | `MembersSubscriber.RingRoots[(generation, LitePeople, 5)]` | Paseo Asset Hub Next | `0xf25d4e330ade1ce230695976f019df50cdaf97c96b6996838af93b68550654f3` | 2026-08-17 | +| File | Storage | Chain | Generation | Block | Captured | +|---|---|---|---|---|---| +| `paseo-next-asset-hub-ring-5-roots.scale` | `MembersSubscriber.RingRoots[(generation, LitePeople, 5)]` | Paseo Asset Hub Next | 0 | `0xf25d4e330ade1ce230695976f019df50cdaf97c96b6996838af93b68550654f3` | 2026-08-17 | Ring 5 holds `[105, 106, 108]`. The skipped 107 is the case that distinguishes testing the newest held root from testing the oldest, and freezing it makes that case permanent instead of dependent on a chain window that moves. Of the fourteen lite-people rings holding roots at that block, it was the only one that was not contiguous. +The generation column is what the key's first term has to be to address this value. +It is 0 because the block predates the generation term, so at that block the entry was +addressed by the two remaining keys, and `MembersSubscriber.CurrentGeneration` is still +unset on paseo Asset Hub Next, which reads as 0 through its `ValueQuery` default. A +capture taken after the first rebuild has to record the generation it was read under, or +the key recipe below will not reach it. The offline tests build the key from a synthetic +generation and assert it separately, so they do not depend on this value. + There is no CLI for a storage read by raw key. Build the key the way `pgas::ring_roots_key` does, then call `state_getStorageAt`: diff --git a/rust/crates/truapi-server/tests/fixtures/paseo-next-asset-hub-metadata.scale b/rust/crates/truapi-server/tests/fixtures/paseo-next-asset-hub-metadata.scale index e38a0bf5272fa463212489131d16ccca239917d3..dc510ac791e83487a6af6b9967c412d1cb189162 100644 GIT binary patch delta 66181 zcmeFa3wRXO`9D5qX3p$xvLOk%-^d0MNFWIbB#?w~2@oJcfN)VjLb4c(L&)ev6HaI2-j|sNP_Ii6`jl(Sq z4TK!l8hf86%k6|5)IRJ=64~r)Q|=M>5npH0GM~egj;yVwHgeQAzux0qfE0YXk0)J7x69yZ=FG zIyvn-6?zXji{`3)FNN7im-aR$Je^$kRfTU7!>O-IT;`iTaIl!Bzn&PFB4K@SfnVZO4mpH9gusdw7lB{elJ&;tk{jgTMKwl>-uoHbQV z_7;1CyQs*{i&WW-GDSsmdAh^nXmJaYApD+nWzY$Srrz|liN9sF74$Bj9#EW^ZCZcR z91gRPK}h$PAY>7;bWW4U>1r%C9tKrTm>>wk()k{Hy+f-ze~rV@RMv>@SKI5&LLut) z%Cz?63IaQJzZfeE=u3kwsSj)c{R0AD#o}SPxkQfD0g&YdB%cs-*lbs$12BZmcQrU< zA)g2W{`iBM(7o1hR-cA@gA@pnAdDyMy&M|vBlFTo$)REM9w4OR(2MhbE6NKA+2z~1 zIOfn(H%}xPCA}ai?GH(*4oRsmXi-*T8OfYn@2Xif+uN|*(LxG{Iku$6<6I4h%YE|~ z<&rXA*pexv+;?KpaJ>I*Q5LE24PKm`QPHcLvi{wa>D?^!wJy#LTa0|${3b_@v(8x~ z3zfc~ES?Z+C&WK#H#=&Iy#<&vR9eh?v{d3vTucPJZ|Rb>HV3iRI~!}At3h4$^$t%} zqt2|V+#0uv)l*1empn^Iv+seWFX3ZKJqGhnk1T zI$uNWAYP;P(Zps#(iapJ8H6s7^i?43DlDJX4)38!j$dLAfQx_*YZg|OY#QXHE*mzsQUtdB_OAjEd7w_u!YOn#*x zbYKKh309L;*dACivOQ(lj$V|wvp*&7L}mQ(hiuKq;w%t$_o}m}f1N#gojrk_9coV_ zdwpl{|32S0?bH0Tbn9K~Ws-mBkN14WA8+3O62ar{E%_qE&XEU4lf#EDKR85&oCtq7 z4-9qM!(;IOx`(IBM~StTwTGJH*s_C)>Cj&vJ|sgjJ-%ZEIqqBkM1m!;!Qpn>S2)Uj zM~}z(=woqO`Y=7$9BX%j#@(BJVaU9FU(BJ$9)o1$HNT}q^r3$}p_niiU$qL%a6fZ2EU))*l`z6R+>Uk(2+fr0J(NGsp?! z2+3UTXmo%@IPER19ESKqD}TO02PYwL#MwaM#N%m@-;U#4N=-Z-?b~}i0(|P|@nHb) z;_(Kn)@{O}#@`(Gk8<|ua^BYC@7N$zkG_+bb&-&q)pmjbN3oMi7^I(I~UfE3NmJh!Uf%z0N6;;8{Xq zC%bA}%W4G@2K~u%!I$%%t>_FPsfI{Gjztho_hMVf?yF^-(b?7+-+k|0g)+PTkIzH@ zJ9s9-H}UKt==)!sofvirjryT?3&JJeL#3_`9ONhx2!n{vO5OF*aT%PV_zV{)Dh3vDZ{2`96O? zUrZ5wt>+S#=JrQUz+6DM^qr}IxhU+P8!#8+^|>g~=b}`fi?aSzjJZhRaPzrH6NNM% z`HMCeVut7|IUmJI+W(5#&|l|#H&JI{(7lK{Lkv`%k==JXvH$@9$U^7mZqDW4;2#Gk zpTG0zz^FKY%4!ZhJaZ2 zVX_!5`JVVNUaSyVRT_zg&DQy0LeN4~5*LfYVv&6x_8zHZC(=j;sr1eM>u`Jw8cHTY zQ``M@CfUQXa!8!-o4>{({gt64k(^-f4JElTl_JS3_ttxyC7Srgdarf3Jtur`e-ce9 z*tTMl3bm+e7#U0U9Xj@niTDm)iSdPf90p|~<>M@95jmG5S$Q}Kfe359o$OsJa82c=r0d}rmj=}JMGJrcZ- zyD#*!T(Z+w^4Sc%dEb+tJwOh!-IPosoxX-EL&Ye4k#4 zMe8qQ17L@5WLGpkKF%h9$=BQ!g^$dUm?DSopqA4xl4KJvYnqCgI>G)pl8ge>p*bW; zhb0lv=6n(9JMwwhAiGG4xn0sTTQDi@oB|x8;LwVF`T4*hPLU*PbIF;vwa(kvfEk|V zZCv48?yPruS_Q$0vfPZn^@}Xhpw-*?MLcO{4e=xvYo+sxNc_)x{nr;m@s{$p3_w_A zAu-S}6B5X<7_XmJtwJk*4FlO)q0aCA+fYmQ+_Fv!Nzl+3%l0OaY;lXo&Logx;I#Q_ z)adqJt88QcRkjf=W0moTY%bU=3Y!ldxf&v&8DIF4oTv^_o{t=1he%>MRu%vm9lnrj zBcRouYXP)>@%Ns+ykL-OTL;DB|ekrWW-q_5LRC!3Q?ipV9t^2O^S>xv-}Y(O!I(@OeM zE=7`yY*I1FOyxCuEqrL8wr=f{@L?bC#{^_TF0uWi!5Ih(3kTbwY(hAe+eMa~M@EQL zVw3VoWWGzA-ImgXfCn?Q!#BiqcI2s z>OdzOWF|4}UnUY9cUmOrd|7EgVQHP_BzHy>&hRp+L(cYPG8g(XnX`IhcbJJav==1^ z7xbJTnMpU(xu`Lni+aWY3%McN0nt5a0k`aAZ&}EoK+bYGkh6%Fbq3L8Fo$5KvYAA) zbygBTEP#CAa^pM!N~c6p`I|A(0uuBZzq69$h<+#yk$lg8ReZzre|C*v0Fp@- zvMVtpQfCspEN2q1$yrG;8Ii^lG@#vvdJre}S-#EJfs<)MZ zcNVga1kB0F5^w=s%~-7S45NF_GsHB}*LVe*9m|d;@f@843c+yDf728)C^k*%&owwQ zIoF5`$TYYHfa)j&dkDzU*#7-@=#j*%YqdU`4%cf#Rl3>@-r!exE?b8`@5x z6lhb7m-?}k@faJr;tyy$C6Z90E=weXBs9}J1TFQ1emZD$XDPuoRCZol2$ya|Z zl`A~@>ugf4)nQL;QwHW%aHN$#d78a1PqXV4PNaaP^_v`r&eCp_4wgn7yzaCNXD?72`Xj4BM^=O0)!zogx~OC^ zL$pWH5D;=@AHIR1HETF2Z7}fY4Zx$9qX=!b_UbpOKZ=rc6s^;zq}^z0V?a~wEGw1d z#`UH%D6zbUc1hUG)})d_iO`XYSC1|#>QNK<7L@tsJQ*_hU@D1<0IJiu!_VNlupPIs zGpQt9?2vrRJ{%PXFUz_{?E%kq(SQqNMHKG20 zp8hWh`wS)WpiX56Ih7rjgu^Us9H@+Qf6d7JpQAw}N zkicc23VDS2rja&S)=$>aC>cb2lx0pQ$&=xL$>dU3*Y9fm5*Kz?Cf{@OCmUOC&`~nG zB`{oR5W&aTmg%HHJ}z0C*u)^3$ZT$EVr-2R!m?*TSMstIGsx}I3CYTi{ZffyvsEh&d+I?d*lla=~DVUUf-NY&as_A~5! zIgE`o^?Es}Ceat5ah2;EvE0KKY3F|1H0GW|PNIR5xnveO!?w5+M4h&pN!nJbbmc6~XS%#JT1J|q2(TgiG8=~9tCorGQD+`}eZ#)Ha2m&9Jn zq=Vvf=Cn9hI2(Cf1b6({V%@E7kE1~ruCfPeNiMm@Ua2K%*6R{kS>tW+)_dJm>{u;{ z2(<~e`8D>I8aObq^CPox2dv%@nN4<(!RD~JE9?mR31`nbG!x=&2N^A=$Yc|%{!ET% zwmM=JufjlGtHiQtb!4u5O|qu5t~!!nwl1nc@6dLS){)roUV0mp;p=?H6WQ~1B#2p8 z0HuT-n}n!5{q8;)C#gm3Xvn(u}qqz zu3ZTah8Qlh3;O_>c@-HkA`11V+FKDfQR4zyv@P5?1c&fJP3cbI%}23itB6~S(`rYs zzpNtpVv@`bJg!8C#Oj#k(LR7;Qavo)6q$8iloQ#qGbR(ezn+_uNa+L;PSwLLt_5{O zs|gLnERxIYzpGSmlMZT*5o3I zYH&L_CH1Nut%htQ)nZ159B2)pMqgVb3mYdue-?Y>UNRr_`q8~)5j+*oK2A#14V%az z0we3ojWkoOy^p-u{ZaY=nI#%I?3Jz1Q7@}+Y$aQyxI&raAVLMfHEUZJv^K%2i$wH} zW9?KJh@+r=`oBm8=Q?*%k`+mW_2GE$4YAJk4deWG^^)+ zLc&BmtM`aM6d8LJ`vcADv44s)B}Pv{67PQ%e(Gj*)>ooaV!wU^Or%-exnF!pQh)ki zM>f5iKHV-`*@nsCO2Ktq6Eh z|5+s7B9SfX+zH}DiUDt%B8Dl%sqQYr%<6!om?G`9pcM_O6iZ@Vi^K6fM~eaEx^i*3 zL>wx*i#9_Dz4{!D!}!7%ix)+hh7*s{RF<$*909*p!;gWLl}lke=&??5Y(%wq(~Sy! zSS>#2udRN+2D3@Fs~1*?CJ{k!Y|vmaMGd}9oFR9At!@-onLsh`d&QSXUp)SGjflN` z1CQ5Qfnc&j9d^4ogOELH(;ebMik%lcN2*ub#g7oL%g)~|W{|^b&<3$qBuAO+9&tE6 zAG=5F6h#O4-zzkdwQm+<(dc8Fk>_N$HycKL8}Rvu)eZf9MvmB4cAO zKC`($pV_SQnax`Dom<3Yzd(%xIA zZQ^tp%_Oqj4~khV!zT`7cXo(_LB_i~#IZL<%VG9u2dL->i+u^>JtmZV*xCjJH$Fa6l2++e4sFml~0q5#HruS z(s`+aFOg_<+mDGTc7N@Ckzg^!i7N7@_9R=6kS#nKj^!T0?0x?WF%ydl!r*uQ#L>+5 zOYCzSlA*h#ArCYO(Kk^O65WX>zY$AOT@w~aRMF9u?#9*gZ(>V~Ctpw`i}pg^CUE8J z6#Q*YFjZwp_(&3(gG`=E+bmuYTjb@?SXfQaFCYX2|_JPs`qv0{^ z!9S4te=DLs&~UDyX4m^9#g7V2Xmvw{oQr;d$EXudieGepu~W$sN2n&(cm-`@G|}d- z&L*Ww97;{ZbtkSGDp_?=_pEEz>J`l@C_Et`FJdS#3TrD2^@_I+NNEc4>ReAz` z#2(tM^ph()^`Th$-L%RMeefI&ara5c0g=7I zpBi1UN2rvie)eDFC{gjcH%OoMJt**Rl#mZ3-g*fd!lb^+B?{(hHrJ~#AtNA+xw`Qz zQ3s_Qu`2lAzr+I08tdRCzniVoHnO3?$fma4!B z9f+9L(uQD|&lE~C2~4>wMN%3{Mvs#Q;qixJX*5Rd94BE;s*7JH++&e3Am14S;#qOQrrZBDlh2X_#czS7a+u-O}HZ8i4BMwiF7++nL(X>VMC zEEl#|Y))gZ#cgw}b-F#-Y+I2S$@V`8@jY$^jOjg-F)*#ak?QA@rDhUj{lpI#+c#Cp zD~%%4?9O`h=W0Pm-3{7YbvC>0)@pxYV_-!^^IhKhhgGboDt;fI{=0*trS`R>l8Dgt-Iq(hC)V8>qGHq_xVq$M>^{j-tVRK`h!zRQV z9~txz9_Q~_Y4_I!QjIbgYb^?UG`#8sEq13H zJ#)}>Kw9j0Z{f{1V)E*7#$kn)yV6zf$h56l>8x34%$*xwfWfia(bBrf<@NCGvf2Q6 zU5&ODhuc+;lN;#3rMAtMej~!?!0qR0{pre_(=$o+4hZDb?m6O?;6+Zcmt+Bd*dIG9 z;ULCknLYTGX{gvGv)sSaSlthEs6vVci`l(dicrs1NV{b5s;n)1i(mg}X2-74G;&RS z{buQ`g@HER;rw}XW1Mi7lHv#MLc5M%UO2c4>;lCYcc z6i81mw$)PHKossdmtk$Rx8}2%t0f!szP{;!35oXQAR3K_cGt!R8x4RZRPPUpwG|{` zF>*dyE!{Rega-N$;H&Cus&mg1C?}jU+cz|hlgBVPtzKONZW_fdu8}fGk{Y^JYEgiN zyY7^NB$A?TX_uZN$j-T2$|Z4Z`Q0d0$sV{{8jr94x?4in1Z7>VG=`ZrNH;_OShfMp zrKuY>NE?WlLD|-+rdT%eAUuL2?~xEpr_aKz_eiB&G=eM-Sp10&pY*sSWl<~JHNzCm z;+ZrHMOH9rH7|PZG3i$kIJ?IZBb9je`j4b!KP4mn5vvq(;_DwtclJx%v0u8wpU6fW zkoJoCl&vo|B_yOyMb}Nje*UP>S<6V{M`n`K5}jnQZoG``(vvxBBrJ8Ya$ds;cytJWoNHJfyc6wR!v6vKA@ zN=h`nkY!r{7RJu~0^GKoWZCBR@HL24S4%BeVzt(EbznQ$s0Jy8KkfXK#|4(P0ul%u z5OI^WoH2sMt%3NeBsQC0z_p+;n2Ih9UIRGeRH4n~ZS!Qg>avz|ahLAMUXhI^YrHKj zoHK&oJ6am;^7gh2Iuq(dCMyu7#{#_0M}j(md$lKl(N~^VD;G`)cAWnLNYM% z{Jmx+!iaRlI34b6Tbak!;B{+~HF5{?A! za=zc_SYy+X&OwIMvDs^Dxj5v2)Fw#MPm zrlrkc!?6S|!u0+zg-sAQI85oN%`VkILsAVt4yVc1;lm+ahYv^Fj+#|Ho%WeMNIA4# z4V37FTRjc}R~uJ3;PN)X8*fOq_wYPF1Xah$B61Zvs9BYCaQ>q_1g~-R$ z)Y}XWZL@p$+{1bAsn?`2rv^Xr{BlAc)XB{ISz|S_GW&c*14#)87-%9a>l(3H9 zNyUTDO7hL5l*(F+a2X{9lNZ2gR&H-9o|$R889tLz_Sx^Gk)z9~Z1ZQC<(b5&2M$>d z@vHqOR!{+dn%+@Lg@vr*GYN&5+`O7sDNRms~=V}Cp^Wy6)Iz2{tjF7I!Sc`rz%_|kIDUw}F5 zWFhZs4~#sT&3#|Wk{ZC&D;7Z(J^H?6gE%?zzO;agl>vW|UgXc8Uy?{LB+<}+N-gTx ztJ0_3)r^lxMBf64Qt!AXeSuS;YSWhxC}JyRSMKE-7GFbaF+PG*5w%~{gb*+HIyUiZ zm?wzg`WjMp9lPgiDV?;d`@fb>;UMSs^>T*xe?mwmUt63$Y8)**J^t_~at0jD41gu5 zxm13hkj?5@MTSnYMZI8_*AqCDmj}s71pdeM!SY-R&tYenOiU~^PEKH#W8}yz2s3jg z*qg(UDw=rf)+q!bah`)SQbaGpcgMowFc8-gXgcMw@^Fk73F&FjcW^@5)r3j6!qcZU z!B6fAvGW{!z2MZ5u#OiU#y*La5$vnIreP75pKaLD0f>z4jgPavp-i4#vwvGj{Z9WM}_IcIu7nd?Z13k=<%j zg8Yb#WBqJofijGZv|)Povc)#}UU*tB*yIh|^|_^0j#2MSk#AGFzeu_~4YoWrvz!7Y znK`rMo5())VwOCT9Ar?(~)webP`Qy1>8CEIBdJ_&yiE5PJgXsg|fm(uKY{sw7=YyTsaeVcG4($ zC^jdjj*^E*U!x?i)Yzw+59LCO5j?}1M$3cQv!mo3dY0ng3OhGij$xmVlCxkU`WI$G z@&$t>U8OzyAs4g}$``3{k+(7%M*ORze~~8-jlNMS;rb)_@>Y>3?7|w; zFsA0q<3y^cf6JFAOA=KKWCLwkqD5g_3gtu~>A6DL7Hq*0Cox13LQsfre6r6A<&3s) zq>@-}OSnfR^}LVH?Rn1|-}9bd*7H7QVbA+md(V48L(h9*D}PVa+Rx3|81RwX!9UuJ ztWi4yvPSO>_{cjP@R5Hk;A6~5dEV=R0{m{_*?e{P6~M@cbP-KS|A--xERy{jj&Xf$v%92au z=SD!p1>DaOqV&@SLeMD0)deMtwJ$<2$*a?p0W4;+%#F&N$zW_aEpdx7L|rvm{uj1( z)w-$jN&d{6CO@QrXZ&)Oe8tqIkXl3wvkDnr-P7eVoCrp>adYL*Byv?9u|OW7z;L@| ziF}32;Tcu(YowZ4s^y)qgMUyhXZsF0V#Mqe_P1&|m%p>gcKL~fQo@!070@=h`2=$a zx|(J))VKO}-Wp%lSZmW%Zd*0Hq*Yv{uXof!^26m;WV0_MD#ugYyzcteGUB$^!Fur6-7BF0Yjznf zhG=lQ-2kT^aWYV9p>{WECKYZ#fwfd(Tmhro0d?F+SnjBIt&)yr{7Ci?CJh-YnOOmyD0kHp_!@E-O7k8_p!{tU+y$@^BfnuRzuA!V_xO zRgGm1Vz=HWheloFY1b72e_XLZDPjs-B6IJTLn0u{`f78`_JABo`j*smImyzmh4iaR z-xiS4N3Rn?`q%qU^g$uKU%jCid>@FB(zm}TW`9)9EjG4@TCq9nKMD|M3S=iqrhe=s z2^<>iBuQ%_CdH&1Mro#=CQ?jRozq-@Ku(&VZP^-rpFpRb725;V#8%p3#}ap}1ye=C zVv0$~z*DwjJ_>{^6Kh_g*x1@Fayo){c5jjYmY7IvHT8C!@on^!nMlqQ<9#OF&H}r$ z5>)cSR(Ui|E}Y*guOPK-#)I;y@G_0bxScC-w2k|OJS<_GJTIpV_IR7eL(`{V3GLd~I%`3XQob4VarkUV0Ea{-``JZoG-iK)9I(@)Y=@oE~o3 z;^+u3GYj_bx*gDtOZ4`Cw?j?|D&ZTm$%}uz+hLe<5Dr*I9d4W?E62n%_ z`hql$rF4n4^D&Oy=FJ;9w)o{l$^;v-7PmFyrl9exq=OTMjjQ_ zY+!0#0H&IG*=5OI6X|YzPE&EYgWquFo>PaT%kW2d^#-4KS`LMx@S~^Y5mBusBG1Eo zBu>?LbvUwG*{4s-nU-}N`OPMwU8k7>eK{`<*fib<3Gk#Tm2G)O4nnxtqtD2lWV3qP z9vP>m7L#<)ysNR!xuU2@-Sn*dHMhF&{h|B`X`74vA?zv}=+j6YH|k-N!w{NVOyp;i zb^n&ed=NOR=aj+UhZwtZ``Y>4mz+(udumor5R}X-NNZ@qO4IV@!x!X$3kT?S*dbXP z>`hJFcyM^pdD!LB6prf3Y{5=SdYaH&aPW?4JBtDJ zG;Gg@gAhR`=-Le*Ea;%7#(~S)c-QdVaiyY-s}25z?{qIFYgRfMwNXx2 zad5eNQx8r(BSm8{iEf| zPm5PWL$Y$_CtE1}f5=3>ix_P>E$;GDpMS;Z zYp^BE&m#R3sa=`4SrfDRv576(d*lt2@DBC-KKbvmxE;n8Lh{v5e=M8C@E%piP^a0X zpU9J7sZT#F&w?-B_++PFmZ!kC9d`tt#-7ynpFly}q5kA2@f|@%M=5%K@7MCf5~)<1-j=8P-4g1_-^dS0 zWCyk~SS#E&a%Uz7NGNE1GQ?EqjjH&Bu>HmOb5<6%<}KlA@&`6#Mte0)E{IA8k9)& z(Rby~m8d?ggdq%N)nWC?bMhXF&6UzW%ctP41^`1?;YE1>{HrrA$|-O~Ye`!!%GR+R zB=EMzgS}K#wB9#XV6U#C9_B3+4eQ-Kjym(3Q)Qvo7;vi#%l!)mew5w*7y0+%QImf1 z!~LOLL5``%Ka~F|;RsUhC-D6L;bVCqB1|rSEGJ}~=mpBj{-B)HK{?5**1|(w)P>!O z1E0W~bee&@BH5)+j!%F%EM4g5?VoN=)iXqWnfc1Z{AQg{7gy_@pEhh?%9x?hP0 zj$MzYN6&}RW{0l^h6%1uGnv`Bb0!;`c14ah`|W%-whJWGenrk7jrFBsv0Xi5j2m1%WEni==m=)!U;&TGLYHv4QVV%T3~8od~X zbQlS%gNX*GME9N);cQylt?rc?tDnZ`l@?i7Z(rfgFq3rn@LTND_!n*h*1M>uG&C5+ zjaZH>RuYR?mnSKmwLBL-S25o#@bs`vGBXGO>|bFgJ3Rwr2t_K3;{-nUu4TobOJ*Dcbn+2*?lki-fJc=z%B4?ck31v zX#*&}@y*}+*zZgfS0AxZGc7@oRE3#NM%d58W}1aquh-3VtbEN(o?&yIRpMB*g(i{f zEYCu7L>$*@3pEd78!U8?Of6(3`=f;>_)L~iG0MVzW1-^^0yH3q){99Nc9~z2x;+TQ zl*Wz)p`#2Y1=FDjv=|>H|22f>0FQA4 zXo1{nA-h;g4t9GR22g&J#+Ct~(T(i20W{aV-9ql*w-K`gp*XP^6-o!nJ1pd3wtI#t zicJbd`JJpflopfSY-cDPi%B^hO7rD?7V-kKr6>t3BMjkkN7$S&T8Mi;Hiyxh!Pm}* z(Me!wDdDtC#ywxBSXZ{3${q@*Ir3=>`KSK*Q8<9RXdzeh&t21L5L+;i@{O&gfpm;` z&B9)b!B~GWkfw?_VfWEMI#ooOoCrErv;?vCSaURMi=c60NDzB0g67KMLF9+}nZ8RA zbeJ3!1S0#>Ia4y9$?%{ZxZB!W>%wt9HaimNO=9aKF%9F{zDPO+sPBrTmB9I&C@f^{ zD@xrMMd3#CvTvekEwXB3=o)1GF$UCG$`WE}LU0-Q0D?QPG5p+P#Q0{n#nI78Ah|&n?q>^fyy+DPGtGRK>FJIFX7_Qn8%EC8WR&% zJRkdzFaiC$s<;~33?dtlMP2M-Ivt43_J5|+^&sii4C>A6d)fPDGs4V^zx%p(b|8zE zo4NV{3Am0O97(f%IkUsnk;CaQg7%S6z#beySE3Uw^QMXcEH9HTX9q^ok$o?N&+EAi zUejpI-8ZnyxT8JrKKO6F@?91R`(F7TaKAdxxdpiQ-{79KfOG)yeXelt(DB~EQCSdq z11xT5!CCYkd50MXs=iF8E7|od8YAyCV;$d-LFPK4`yzBVu>YSRbUr^_y}cWm z>|{3m0Hg!_Z3b5{&=|_r=1@D>3;*tm$l@ME>c9kCtuJF2r29g4W8WI``8zOL(ChMe zPLq4g)~Ut~?G4TqEq3)%4tsfdE;t#_VqA29J+1gSa64Y_R?5-8y+bI^k;aS zE1)y+7+y%{;jylew%~E4kapm4R}meIdEZk+U%^-HINFHEZ^vQ9=d=06;B)!x*Ts|v z8^n*-@-~mBv+(uKcv_FglnL}sJYJtbH{daSB5lIs<%w8@`K)jfor}kP`s2MxS}Vg# z=#(g^BE8N^@Nol_5=gnjtaCDrV}B{3iKr`0rloz7+9rd$YNa?(wkhcGFzc8?=i}?* z6k6A(_+3*W*)Ou=Q|WT?pqU*Qgb>zgrQk4!b=J~R3O0X){g@{mW#>w1Hpo6?8kYNU zmOqV-kx!V(<7`(bO=1sB1FBB4{nH@SI@y(Jv{*iEhNQ_YPzJHt)9G;ejG3J8mSQRL zSu<_rkGqin_uid!Ao z+8N;K$Jvi&&?4!CIWP!!Kp6z$Ni#UQlFAm9(ZOP;nPoSc;@Kl*P;O6~^}wBv%Ahcu zG3y)7C^mqV-bACMv*y5EuJt$3c<}=81B;Zm6~bPCMCT>;K2DWc3(Lg`lLgKa4B^W6Xk>6W?iR!K0LAGU6WF9$95MJ{5gj@1yXpnr ziWlkNT0ppkq|S$fQ0XdN}k+|2gW(3QMGXf238iJiKQrm#h|^f{g; zJLqwLV+i!tU@l=_IOy{rOCaRjT0!5^4iss?+~tI7iu#W@X^Ox9H=XoKpNsdx zYFuuQ*5##d%}HUOuA<-jj_jTFw6Sk?ox|ZyV#y7(jYDA!^u-2xIHosdd#=7)-$-Bn z*33CBx`H?KvWqU}6tcewG5*7w=qxeB!qzs?r1%gEnZz%JYr(H`fJU@Q;+xaRN8$1i zOkSj(D1=*>rI|8rH*B3w64=pZdWtvtU<(#R93yVJ6!g2yt#MX7Mzd%Sohc<*dhQL; zNY}#d_dvVUE)U5y>2s5!hI^r$>WOzDKJ&M$>0aYAm;Goh-LGdwGfykM9fs-^L~Dv^ z7IxuHC6gWg9!>s#L-@mPr+M%{EWMphV@DoExZF*rlu&l`cJS14^^@DV4qeXb)=?KM zlXuq9JMcCCPI|MLW#RWt#IxWriiN#(Cw*D&z8-=-+eV-4{W;nksHaA<7uVC}0VT$? z(`SIU_u65!<+E$;kPo@+w!6R^n%OgV(M_F)qHC}jT?-OfJ!Ejad! z-_yI0@$28ybvz^J*R+!T^Z`1Atsf6RM?TxXoo<22QT-l0gRgJiq)Q%QFg)^E^GP~` zozmYsPtu$H?-Sp`h)UF-JW3bwNM!x816Hz&e%SMM%WnD}OpMV_(XD|OCLHA-yKBT` z^^K?LLmV93dl1PQ_CN#Ie;S27x<`Wp>66)u&%#VA#SCN9mhIR~2dJSxp!+3^*0_P- z%yTrxh$cx!1K}@#Rp{-gGhd)(lK<`irtYU7bblN@K;H#$+Yf3F+*pw5zn|chAJaFC zcXjU}`UZZTray%HRobbipTuL%5&EWnwZJGg`zQ1-0q@D|o}WTwX!j0inaS+UpFx4r zZcKO;IREA-y4LSZVCAm>A!Ud}hi+7^%{S*3e|n3$__Mu(rmN7wJ5+-Y=J&OaYG4H1Fo z`O|bdpafmsZd7@kKTF=#BrPXX{yFaVw241o_&r=XP#b?w9sD!*41I_{0j1V4zK8hH zzzNwX)13xd_VS}{TtoC9kLz}XxleK;U&ZTedz0S6pM+6A(AZPwHRb-x^K=OYeDej^<51`SOmo$t?*j)Y zQT%~6Q5!y>3z7AQ59m{RiTQBjcm5e(9DH_NgqoetivL2V!NQsOSGtvje+WbW206b$ zlY=hN*FqK}@KQF%0fR!Ng-50iVwR6+Sfm~4qQfHKPs@iJ)5)?vqPTL1?f3{%DW84# zk>=5h`xr*Ernbkk^Pf-!IZuB~+xsL<`vitZ?@}LrLaW7Qh|d{Nt?ZZSX`MO5sdGQ2 zu@XYHTyrGsU#x)2a(5T?_DYEO9K6}uT_Ta~yiB8IiyThav!Bx;gKco6HfTFY2nNC4 zDz+f-g*~v94(tXyJ=qIbMTQu~&VLRy^DL9Tpbz8m=oj<>JktI~AIIbMzoFB!tl%o$ zipMXn(&=qsNd(3qcA5Q0mihTt974hm&GFzvY`6t%-tn#0<$RmA8w3<-T+NTfBEoMW znNwGnh5Zq11Nt}hd29iH*OUw%s)XP0>fQ@n?R2!Q!4YR;U>KCvH+J^0H;U*2V`n#w za1T5W6{vT)R(YFjYqVg+MjV{tK}z;|9y5TLM83stoKNC+H=+D;9$ASZuI0u#DzC*_ zY?w73)N*^-q`yNC(2tg~O@F5i{&&ki;H=WpAuj#_E6ONy>>soW?q;Uu>KFoh~7gts5M|VHGMi+^&4kmqt<}b5dUqREk z%o49tw{b|e&3H~?^S-82k;lHK_rfun{0*e?Ww!MjkZctD$2SlN`PxyOk$Tb0ED z9?uST+Nxydp(wnSI0@*fayHhvwpd758J>KDfSWjQ3Fb|8x|?_;hM@s(hvqvK=IZx1 zLszT}QMk)OJCprhh?34(l{WK!QV3J?LY3M4o+X})+sOGLA>-(wQB)0Arb;`YAZur3 zUnnzK)TDt5Ja_r5G(tHIZ&N~~QVI{hJyPM?(b`Cb`}?1bRC0#*rzh^R$)_aXM6BkZ z$$unD$$_IPH%b{z@>EBZa)Dq&YI=r-_5kQ-aHybC(G^H|* z9bIAy(q%|23(iy?M(?{amB;WHm!)ij=Nae8hOp1Flmjqc8^x>f;%TQPjL-DilyfF z)-J%o!+y?M1S<12cFe+>6R?vx%uPepi+Ktm;&F@a+Y!eo+{1V47-cmczZs)=@R&YU z;U2eNja3}jZ^7b`>H;Yu$C{GIAe#US^Aos`(L4L|-9D#Yw@b*c}HS8PN)Z2>*V#q6UA%1ClX zjh_gNMD{=YBwS#RPEs~XXY`(ivS}sCNeIly$x4=Z7Q@YxBiPN8l@Eqr=!GM+)YqYX zO~=ti3wLRaVLPS(zH91#O;KP*U~_w_0w1J0zf@U3q)V0_C)X@wTBn07U|>yG;D6W7 zIE+B*oijN0)Nf`eacDt}yGaS-Ee)Tk*zx(mOyyl%YRPJ6D`&v#C(Kc}+I4o0asZL` zTjwe3;U zS4$}iUj)Lt%-R+yi{TbKvq%{(T@4t;02Q+~4Gc$ZTB2k~(si66J71+lss}2SwFEwv zj4EY^c-_KscYtI+t5WcDWkIY9p|fgsHCR4;0l&5@+eN;EhRDyML0mJ@yw;KGL$%-t z5mT|vQm_B|tR9qBi-_;pP`<7^lyzhiyS+|{Pt;@kYO`kq7UrVHQ45Tt^icV6?xi-O z!;ILq?kE~9K9Uz_@pa0wHj<;Y#}8jr**)nv=QJiy{sph^=9&(Up>uUcKQzJPef;g{ z@kmBwP;Itkt{^=zo_Y|b4igV+>$#c)A3XA@I|#{zaMXVBqWrWx)Eq4a@^^Y5l&(D? z^opMa(LkyWgu9wYs_Ka0T3&X*&*IM0qR*!As5FOx&Q$r~{s_{e)NCAde#c9bjvm>( zFkoDI8R%)+nDj`o?ocxQbhsAM#Yf)L5)alY(a(MB6?sV3Gkq~}14P(d5_DOXM3^~ww2%>x=h;``Xb24x93s3PQN1n1>{Y*g}byWc>U@*cmS z@zW;db}=f5ojqq7&aBCB9X-(u7I2ik)QoCzEW8E9o7pb6GKt;W0v@69{}}dmi*ia& ze*`weSPzDg&pJHHF}T6zdzFReG@K$6-xM;~0k5(+FDr;lo#I+kJPf$P4lNHiZo&Ri zR*-B(GQUVQHwdTm@E=a*;XjHWcw^&NEAxZB-N#%D+4ol~%isd~*J@=b9w}>-?1GXY zXmGKOt`_{FSxJy~iX#(@Ko2nQPQvNwM(vQ=9pBGX{)9_jniN9B6+;}!ig;A5OcUW z%A8~tygWNH?6|4ZblP;mB(!o6ok7@{O}R@MtUB8OkENX_ZVVDO@_bGRS0yeYT?#68BaAoZ6)!M%P zt?koW+o!jdw?W}Mb{qq&bpuqA!|dt?=w?UQ$c@Ta{V`O;eeC`cQS6P4$}JH`_%MzJ z3Hal~I2I%v;|)F&kwQt15ezT`=i@Mu$F-hbx)<6X)Ct!nB}O_C6vp1Z3vSX~n?T7Y z**BY%S-G7zK;KD@zSHPK`%gR*B;b#Ch>WuwrXjx9G6u4Po0SsF1rE~1AmJkWay0=PwBbJGaNOk}lF2EI@BL~aSC@I|$AvC^V2Ez8sO#=m)^oA@^%kWt0ixBM zxv;Ut(d@-8jRW@)B^u_zWj1dsR>?KCWvfz}ajh4=FZWk4F6#=$b@t^}B|iZ-AoOY0 z`1Kwd%w|5Qj2A7z?7jz;GLZ!H?ZsKM07v??o*%jvC{CT?3>pa*EWv?vgIf;!sy4hR z=q5y~QCuK|vtxS{{UUBLDwthJrt#{V+mv-8_#4j|pA^iO5q`-&xF_NYX#q-RO8;I` z^j=cf%RXg9Q55a{Tk42f;9>o4{ibNrC8ZisYh|@W79t6xZ|iag?z`H-k{(vZz(QW| zuu_)Th2O;KRTBYj=toQn1}8o8uu@C1)Ir;!qlme|{3qcP*^7_B49;i6m@+P-uoukZ z`@=k5hj~1^pD9nnUK#l)m|H$u@~9FI&%F0hWmIx$@OSRhvu%GeMUE_OX!5k``|`ls zhLS$}=mF;l9K0ku^*nUpGLGzMw(WqD8eMK+d|?2_%Y(H@zR(>?v;{6OD=|A^+GOug z;!{BkJ>h&%oA_R@yCRt2vI*@KAw7EKyLTvqEekou%7YOF#Ai3U(&)<`&{w5i-1?Ye zi}Z^Aw(;}eu)ei!Cq1Sl@nib_EJ3jIRx5)Ahu*5wXtg1rRi{R~N$lOnl!$`EW`Eu8 zW)Gurevf?5^{oCP;Oo43XE1&{M+4exG`}&Rd2c}Txvjzey4}sUV#2?z`PN`PX7;b& zS2E;v!3auge;-#mWj>CXZ)ejVR|ZS#f(>hP!{eZecDDaVIQxC z>{KR7(w1Osa{m<$klS`CHnAg^<=&0kP@dYQM1*b+=8Qj>OJY1?*}*}kG{rh!NugfcIjH?OeF4I>kMSne=pQI&<+S6x+=*ZTf1FbtCc;Uc(itq^ z4=DwwgN4&9_5~#)7B*|oMH*+c+_S;4;OvWEfR=Va-S~o1!%Ztr3WJUqD7q9dluNwj zBe<9^m~~-6vROY;!h^fChAsye2v>Q{Xjb)@DU@ybkuqq+wP1wn@Ux2uqtSl8^IEVW zv-$liP`@6>tZ~sGT-Q2{C03m!hCsXWAABWNv+;H`SP3gRphU-$9EcvAi>O-O z3V&Py?3mG`pb>U={>^?RO}2D@qR!CB5ZpN+5~~o7C!{3qh={@l?|M_Ac8)-f>n{Dl z0cDw-WHlle)MW?Z8ia;Zav1R?fBdnMtM9}|v(!UyHKwt`Lzs^Y<~XEGH*$jcMeh1- zX^nQ!eqJr_U=YLZ~3lX(Dvk_zjJGN&ngU?sSOXe65KBbX>^atm)yTKp1c3cf}7Y zF($Y)V1i4u^2brA6mKFv5rBCZUKu<4*43LRx(FDYf=SkZb+G|_+J zP77v;X6s*4On!7e@sbh*y?O6TN};$=10a2|0YGH{0E;yM!mv6kIRN;y>xCUgVP`;L zhgKM2J=iYcrSaXs9#)mC=w_?6ykrsaTEWfXg~us99R6%&@2bl10nKP(f%X%lP@Jur zuE7V;OV{w~gIoWy5+}B507R`b0B8>YU>!UEvQpw7V!;t5#?sCKS!abB%_&N)KcbAa zKv8|>b>)ZfX_fyRIv})W#~aGTVSSvUIUp+-cPlid%iC@AbgB&-R6QzxNl7{6?yt0>&>?O5yS zpDygw@O)A#07C{aJ}BYGf2CxKdpKq$OM9()D20ljV0lO)`*5qZvP^<*J@J&%7<5>x zESnLQrJjOamCrst1zjMEg?8eB)7YI#HmuF%ol3fRR73TRPWv2FPjxDFa>#M5wG&nl z$O(O3P8##l889y=*`{}(!xysG-cj;o{IWzUBlpk<7W}R^PMjB0B2x#gobHA(HX}Q2FowW-8qqT2=kE*)*J!fW;z0YKl%w!TsAb|-akU$a= zU?2ga1PDSUJOo69n1p12k-W%cgwSF{r4{>OMa3;v)F`M_QBmoLh?TZjX+=$2s;D4n zsi36=B^DI){?|TdUI|*idoREIn3;3-bM3X)T6^vFI3D=w9muHhC#kZ{;#U12!GW3Y z;q(hN)AYl!>EgBbV;v^9D(*ZPn~nlXp?LOWY>G(uAa-(;JQ3zK;`$F_`=cmA*{=DW zsW(XG)(>K@Nb0h|(}6rCW`mykVG|7j9gSGe%YheLK&jDTKH@JFPl93u(Ju^JGC=i? z!?CVHYSDNEk%OqR--p!UBMm{+XqN1iNrr;4+L6PtmzmUf@$Cl~@Q5R^MJ7CyKYn}! z=;s#q9syY0;>{zmiJB4*89IVncLRaE#A)!PV75{92o7xxi<;_F#M5ys&lcWi zDe;8NrnGpRa&K|VNoZZ&V*5#)%w<6SUKtUo zMS^rA3>!hv1do!H^FNJ65)pCwQ|M^jqW;s^R8yrzTy_?wmsfVg#6>7u+v)QwK6}C9gt8N59u!WP!XADp~uCkKsGFEuK8TbrA1w_sp2^}!(plt`6>w; zQb>LpG~EJi#!+1us)_Fz+ps1+TKOT1gDHD3b@`vk8?WFG5V0HugOZ)E`XV+1k@WI( zY@X(A@FM9$_@qsH%h=PD1Q>o!^F`@dpvQ8(`uQ&a4Zm=ljZJg<m_tSL2{{jrnMp9Na3<~n&FWuHDAKQKq{n+-M z^kZ9h`mv3GGQqSxl70-?5k(ia_M{72F`az^``xN%IL8#mwC|>mW46beR$-mRt8eC^B&tP<^`fLUpXV5rd z?#CA3QB^-ijA#`;OvmD>L@MH(Khuv@^bq2XQ!{Xiz}aLI0p{NXHy`Y-ZYH$2hV(X z7|Swc%J~k?PSANkZbJA#oGtq=R%4W0d^6!^zOWat(Zc{iisVi9B$2RF(`##*y|_e+ ze5e$K2Q@g0YAW4)Ym}(s?r`xK@l^pMriH2Y6+?%yID8*HjOAr?4^E)Ih(<_UZUa6ekDKo}YM)Sk(a@qMn+O6?{8HEh-)2E6xqp&MJ9Kq5?pE8d$ zTt!2A&(sP;+HP!Bi|rGk5mi%Sg1Z6xCV37S`9Re)Y$O{Bj=x|ey9u1VY!n-8QuI%e z=f)QA_H3h@rFDPyZI5!7qeTpDHOiO{PA{{vsYL`j%T9eGHct+%oW_?pq z5U8JKrl?t-M(71-vKeCjXjW#Km7vy(L^qaZ*Jwtb12nNXm11;zDGJMc@$G1qo4rOe z0HLVZj8#(k;SULN$w*m;%Pmn+%mzUMlCWN1EcfP9#Vq5xDBPHv5Gk;t|L(elSy)Ff zocU4ZDtnfz)XP=s{f#eS*N&)(s^jTd$D`Mw4=B6dP`nbe#xxk1wLAi|8uT(itmXJ- z!cRYawk0U|L+w-M)d{$tl9Qb|hV@Q&t6@AMKTEnU%pyyz22YW=Weghz#D9JayKNxy z*CkK%BQ&|9%8Y-8?N<0P9@PMLmY2 z!&=ckj^#SmC8#zp3M%UWl^7d@C=A^x?p$GJLdrkK#<9oE>W09&@oXc4HjDlfBqV0w zqg$c~_;9$zofBC4kVu44?Gz<4>WOhs+?1f+3G)p2Isak`OHeljE>2)qs;14frW4eT z!1_rn-wYA%!6_*6mn#^_ieFyAMtxVeZX@ohV9i0nBW*fMG+y)( zlcuv#DE#B;tbgzsgPv_qsI_;evl*7Xp`VjyNWge!00Yj8pU+@908_*`;`T{&FFva5 zPmro8oaUodQ3n8&V8#d@*gTUVn0cr8dM^8{*Fn@Z9ZFE}2cZJN)M55?7&Az-q=}Sy z0HIqHLN#S`awK|(~O!m6QRmU!{9+g3Zp{ie8_HwVOZ6C z76Y}9XFhX5aq`poY`Lo2gbh{BMA4azJuenF>KBV$Mze@6K03HqhE*EU)b$cau4P<* zo&p)mD4e=LZV&(@MN}?eVuqI3b1AgM=%Z#aPNr%i z)+L?-;?jR6N1}p1qQ7{NlBi$k1RkqoPG(9=4D7CBWwGWo2J%}ctGmR zlQ^Ci)?;Ce?=$Klk!Ffj^M=*jO_XMK_cpLz5S@BAu!r&SVgp-d zEs{gIZ~?X)8yZ=rnBK?=3B$WVwS?X-z~S~(BbH*hNNHmIoyc9%tyn1)>l;|q#OlrQ zWwPn=M5wA0jTq@)wJJW37SRJ~qW$HyBImPYg?r z4RZLF<%ziOlNENYln0x?u4Gx7pZY|JHhSeTt(MJdfNya-g-YP`MfM$r0g za?HtZ=-VwDaT|lzT-(KmH?n*XMR1kmRl8&)U)H zPVs0v%g0A&J1fja8;DauFs$m?P^t`N?As*<-NXvDT?D8-iMVB~x`~Y%Dr@$G8#ibm$1cf;uzFEV1^X;Ip zN@s9shTjYdud5nG+s*89SWWG^nPnzKN;}FyIf{Xiqc~)sVrQaqn7;C2#H~GM3~kXZ z%zY_KWF02Q#g1Fp>>$zUcPnF;AREW2%Q3w!6{i2BT-?lyvHI$sj}5QxDLIsKI5I0| zjIsRtRyM=b6@rhh)#__(^&VQS^HGbwYc2c0d@q^!ymQvjwC&qu9rHu~BBtZtRAlyID3QCLm8S^!Cd>1=xtZX*AeY^ZqYKFBd`;)DBGt(J{Src67`QVM^`axR;6 z6?WlF3I>bB-$;w(!-WM7Q`Iy0N&G7#V5aHx8c;=`~aIw&Tub20BNeiF0F$j z6*qZ-gw1TNseRm4MwmMU)as;H!e>RWNlm;)Eg&6asG|04k0wxUTe2QX|T- zs7GTYk18WJYADoc9r+^QL|-svPE%6_(n(DWc`*4Dt3{PcqOl0*Nl_A8aJEE*X55CZ zLcMd4?&jj2NAXY- zA3e&(z`QzR3#+!($j(x5Vd$|-OWDAqTNrZCd&RuR*dz!C4?G5~PJ?*wF$g6MBKC1M z91nMF!zpCeIq274?V$b`u9Qj_{$S4FK)FQAQa4N2&pDe2pj6$vkhCLQv7TiG+XQF(ISYt z>JBzYjQJJo+rAkwnCA|6k5bf;2vxnP3Q^>$5M;J4{5+@5yBHcWg#T#^KO9F{xO~2< zV4d)r9PE#lnku>**Z?*)flMV7HH+GH)F%gSH(D^HTo%N@@6shNeKFxqW(!70AcO+Bu*Ba#qlR0s%;kWPeE+y z5IIk=e&(%qRUFt3ZAaZx(A%C79Z#`>dv`!JbmA#6#*GAxRFS@&CH388SF@xE4MqSX znoR(ynbZ=ZdbPJ<*S}%ZhIJf2q)LnP${-NEvMc2G;m~x_}9~rb$96H zhdzUo@J`IV`B}`*_Y6xjAHc6WpJ%HV?&PhsBtsB|IXFFXH#sh0j%6(fRaDcFrHq(I zC1O~X>0i-$x89*PX|6%EuZWHE9q>^O=)GVuMo#UF2Jpz@kt6>*fP40(bW)qc#pN$x zQhUV07udM|;R#$Cs(}|+|Gwcu<0zRI#P2E)dLjis7~Z)N>wHk38gR!Z#7<}y4vANH zvVLjdZvG3R9Mb18{nspg+8zidAe(^vG~^#tWH8b1#TQ9wnkkEXxWE534jmSb04o|1 zYC$<1wY4jIZtY5aYaff4`{M!3{kV8L@V}Y+aeeOBy~xt9|NlAnFe&biwAeCoy4gb8 z#cUDlcv2rqI<4b%yPyw&+Y&wohM=NGuVd&r?xW=HI_`l zgUk?XUt&&Ys5Rvb?b-2b=Td%0`=?p?aPvJ47 z?_uluY7W&!Mg2viz(8`~ukER7Kmb6W<`A#$fw+&)Lwj(8#su80KM~C0Inr~>RqQUybA6{VtBTlm^4n6X* z?Kf-%VhUiC$B`86@poCaDZ?S&d>3~p_>>RR4&g_p{-b@8As&$$U5`vkk~-r4wtBK1 z>LkO5f!>zG)u;_Kv{Sh0OTSaH9g)m9*U^I+=VJ0;vbhc$!s{c@sCf6drSFogQ2W$* zvJ6Wc9SRt_cf=6LcZ8zSP1Hjglh;sGpWAyNSge5}2d+-R9n27s!1d8HPdb1Ta@;86T$cOdxhd7)}75$GwR@J?C zaJ}@CqbyCUpyi$AP-ck-kK*V*SG;kQ{RrZ5?_(_Yn#!oL&hH7I`8s@RP%eC>j#!XL zJBh;bN_{zE4+gK)J$iAE==TvDAYM8KG21JS9b*IfHaOHNa~qd6LOl^7MiA`10Tjk@ zpn)n46q7%Oc(7b-{1~(Ji`^fyAw_;i^y$fOf@(1p5=tAolmD4kI~4rkpy@ZI;tr+P z-&y{cFq-Bqh9FvBC4JPxPgCV@-*SgqIJJ@TGBmd8L3`Kg!++@SY*gX8sF|;KM9zFY zb#FM%8#7<8R~>wuZH+Kj+92LJ&K8W_7zOQ3J)ylx2OoWyHaisjVMeKk<6zp@@DElp ztIcG@vMz#P3S%)pWb&0!50$M^<4whIw*?o9{IiVFZqqy3M$IB*>L3MxVHS*`YL3tKPIX_!*SxcSpOLtIqfik zrGzWTFoLjKdj$e#!pjj zl9HPg3Iqf7S4%C9IeEzNLUCiNU_1h&?baGP_0RGnHVz_2x+qW{<@O`Pxt!n+*eM{E zK570$XF1{wHJ^@2YKPfrSkq&Hl}TavCn^BD@}tIHlmsx91;<{50S3WTrjMN-ncPW= z`}@aStdF_9wPqSZt+YThXqTa=WOSaAB=s6Y<_J!0P7TzEv|}P7>gTBoX)nnQanjosoLI*)qsnLN( zk4$rul(|V#MV%elO|j9<41a4ajD-*wBN@plTYd0jonTJB+=lGu^OMw(vthy{H8)as zt|-Zqk#HTkXbc1P$|N<|5WOJ7QP6|=dVk|1`|H+{%2Tg`dw5d=#_XDC_}5#M%E+{7 zdMo`RTj|kRs(AZemeyNQCK+N_cz0nW;?94?^zMsImOj0S_N5OhFZkI?d5wNsXtK@{ zi?T2{gwT9xI-WYqcZL80S*+0q=#CuV55xvflE^yE7DGSv^V6)zk+!}_-`kHQHGVO|73?Cv4VVY)*rNPVb~9elIpHR;?Mvv8*Hc@tGCYfRH;AQYp$6U{ zZa<5Qw~cx)N6xaeg%F#7WuC=ICDc7(dMXY!v`M3^{4kA8QNUbJ8!QwMem!6k++Y>r z$!~EK@OMEk-Vrget^cMIJarEi=qp?WCv+Z4qT# z)L^$p4R&kfVD&KS+ml4*E|x8B{0|!lJ?bO>fuVuaRQ{;X&LmxGl4@uW_mJii&PV5< zb(Kn#gC&@6X|)#q?-+a)%hW7+t%`yAGwLmWPbMp23L-uQ5x$7;&DeGv|N z)_K-@Qina{u}`Xb94vQ4p&0Z+-BFVCnpm2hgpwQV$}VFXo#$Dv_B|j`1|tiOU7GIq ze6H+?>IeG!?){)`>Su3MxqUtN;64ll<@O~dj}(7_Dqh_56|)!ak6Mfav>2U1(tIFd zF*^0y2MB50Wc8aYY+vF83vcqTnX6Z%sIR7bT9@joN@P0fgIy(e(A{6N;dX4@RNaw* z#&R%;)Fr(|m5p1)mtV7a&`VCgfE76`nlG@7s}4oY$=}mWl0+)}9gZq@tmoX1>7zep ztbyesr0?UR&qbDNd)8800vN@jNXN z{b#zEq~H%i{fz(AiW9{{1So~zXb7@KN{SSx*!vYw@W8jwVYKs7_0Ic3Pr$S`yjm2s6(a z5o$vn8EhJoaqY73^B#i{cbIuP6h%*(c_uy%n0ZNZDcU!cITic?8H>xEN_j+A`N>c~ z)k6HTNy})9GS#VOk&U$eSW#RM_|R=Dm|mq*G+KDS)YVS4n3#+_YwG2V?$6@RjD zx?%p%!gJjr&J(=23hT5}k&2V9vcgaF(24#efiTZ56F#eAcfdZVA*`9crAu!Cshy(raF~b^f}k5%#9ptRxD4(SW9C0K!^jC zv3fm87ztfPM=Za_gt-TwIK)7PGBr*yo^iMFnMJ<3hyEtaB_f$m3OkSs0#rc3Myxp4FU6#~t0OzJYP>2nj75dW@Cji-kF~f#tkHO{0c)J<6oPALnP4_1JDr1bK2KO?Ya#;cijd|1Z%Y6QosdAM_3FLkpG!B zITid#;Ns2ZsFurnwu}v?j6~BNC>YifmlH`2ads*bB%uZc-)c-3yg1L`zhk#WHL;zV z*ntkEkiI>li5>Wk9_bPJZWSv#bb2hlL1U(!PCcAReQb>8kQtcNzlPG1)rh)yKGJfA zGDgX?WV_>eKZ?BbempNU<;RI(EG9(^O63WnFoCDRxaUU+yk~B*^0@z&n~XaCj@)GH z#Oee-3xh$=*-kqb+;mg4xK1CGrfMcq$TkAq-$|Oys3FFT9+H8r#G_6M4C1 zyG{MQ`1z`sOmVdxMR$l+J6`S;TkL4=xcIXj5IrTnrC({HUlKQRj+G0)gAau*bBBW$ zLDehY#3k`0>_B{<0c*U*By@IAJe9;N$@y(VJbaz9oqQa8zZN+GAGkQ(((3_deN%E~v=ExtG5X>?5QZnyPhVwOvF};w2i)Wh-B!~wS z;c&MnnI~wI6x*blW(#hxXQZIs&QRX5M^jKVB~i?@BcjkhQb6yKWUUv!+Ekh-j@e@} z#L8ZLfMsf;`l6nv<>g*H$5N9R%tdDF%?m7>5~Grj)%ONHy9m?ZBv=(cg4AOs7@i>o z*?3%=sHwc%RB5N=V=l2Y6+6u%Zc4@6ai{fMDlf7WIfA*z)HFWWQtAli9=kG)_raaa z;xs-0cZ2KFct1-fFH=R$yV?x#RT{q+9BM-!060JJRv$j!q!tBoGx(jVnjLtlAFncD zUVSq8{#a=76ibDu8o&n%+W=mHYS#|nH^aW=@Bl1@JJ4q!zeP9Jvib9viER+CHqWwd5KV*lu-sQ)%cni4tUSDY{lb027yT6*NU?kpllkyp z{(KJXOzAcB%!biVKD9^e9>m{Hx%sNqH7AaZ|CmaBGw4E}_=8U?s<>@1uSGbhe-7p& zfMKp2{x5{zNX+Gb0HVE<%Qpa4-XZ(}P^H+R{Aq****TP-!@H;R_{+%4zAT^5Rn@J5 zy#+j#vLU=ToPVlr3+x-gUscu3fuFkh923F;zCW5TQPq8ci6wl3YCd4SN$g1D@XH*- zUq`gkC$`_Yf!B^pq?d}TxQB0|-V8W}W z^S4x@B{Mn2&2FE`uTe_^@6P0JtH79TSMg)*(b*5x=%1G8p9m_Vro#N$=V`KJt1crK zm60gQg-Dlh6@VdKYm*C3=XFR_Vgx3aS;vtt*n0yEB17*eY@?^w%2wpJS3MZ+Hkx}m z;`J!^D`MSjPA=Nd%;uDg>6_WyW69>KN-J`C;ITP;Bstl=KaYQn3BPj<-v)56na>Mw zhxhD!o*UDE_O^>7^ErYs2fF6-Z_K^d#VPhlh9_P)JJL>Jy^iM~o};DSHAftt%TvY7 z>mXD4Mcs9LGpylERh+V=R8{fpwDoeG#?4uYgnZ=FHW8+a#GK@crCR)C z5l;*Q zZWRs!k9oMux>4PX$M%)3tMBeynJoV9;jf!FqRummc{%hcU5oi_q&ri9Srtn_U$=?& zCH$wDeC$%b07$)PDVH&Wr(e%S;&zlfkNulGvkA#Y_XN&f&u5sx0u+nr-@@~RuK}`I zn-C3r86aS8na1Ui3HTBbQtwlFP8G+A#&X=okYY~{n^zvK;&94?!zm3flzgW*7h}{bd zKP2fXx`U5a*9W|J@ZYI1`|vDH6y3>3#T+niu(cA#XNYxo@<~{OH}B-*&6{lbTHdF%nnqtcfg;4Q%+=03GVJBiUZP~=*R80EW`}s5= z$Hn_`+jda=?w5Q*pCVK&(v;NkFn3-V#0Sd8>=ykWz)p1s`flc5(VFynh`)!AGYB#Np@ojWHD% zY?-KjUeb{po=0zPap-yMO1BvQ0>2R-zj*=tK#tiNsMrYv0*|=s*TDX%5d&ByCcg+_ zLylrIU{xW$c@c<&lwLdd81eWn$QBJW4wv|77rI(bgBdLPzrV6+f0Nj~{%>LI-qU?%-oU7L zd82{in85p(z{l@FM%IxX!+#%ZvDX+pz1{IX4`NJn zH6=ZH+RVuwPo;R|F!+O8s7Lrz@*|jb1f;xD>^cH6_>(v@bbQFKv?GiL4?DD? z=!FmYQpf_HqkIm6ouo?7*WrMi*msowEi2+lD9%!zG#jat<{f^uNDchv82__6=!X zncU*>tb(~2MVe|8*ZzZ7pqhtTiq~8E-9LCacBEUxeZp(g8>s#v)W4JvbNeU!ZV09Q zP7qra^(XiO^U3&A;*TfzugquSPX$(-9jlFZ+W3oNM@P zdD}Hxy!8dB>iRFZCeqJBXg?!vI?F!<7p?AsR8T1%?gDGw6FAq!>rF|HgvdqgvRLxP z-hNtv*m8~!!I<7U$M1?sNk~h0mzdxv@xXbA_$8v`EB>dLix_HKK>M2ik*L~VFW}8o zvHT*RU_NE`ieF#kA4A%>{~JireH}51x>{3`C(ZURYVi8VPAEuvb`IBWGTHnIDSgH( z3gV(SNp_nO_=`zG=3Kt=NQYYpde4vY1>{z&%);1_3bew>YiI1h`C2(Du_# zawKZ2=yQFd_G7?Wvul%3YPwyss_-zM@6ZxVko}fAwJULOc>y0deB9ehn-`OhRyT=- zy)@!aANSIVAp)lN){4zt7Ppw!Ticpigc6%+pgR&2OL*^eiR4u6Zy=r+aB<9DtfNiT zJGH@q8EM+}#H?THqy5ReFJXV+w!T_F)4&om(*f~d3VA;osYY-$?QQTkfOspf_kO}6 z>~?L4_+u~39hnjOX^$ozh+b*&NcPM-2Wm%D2xhCYwclf}Up`1%56~SLq&3FOMdj^*@q@LaW=th>DAvav zxNfL6m1gino`xI^fv!AFHG|pzXP9Q9liXK@+BQfMTZ**R$a*kfxRwF57vo20|3PZ3 z;*nZk6K+N4jMAngHK6rf(L;NBly-&LnF3eik-GQVL;{DW#U0>dv@A0uf&5bKw~#|m zmuhQbdUv3Oy~txTazf3LIZL7HU5bP(zU;tl6Sc945CwjJ6$Vihm@r#w#)t#Y&(X$G zlE8n@)s`phL%lFiMB~+3IdJ}ktFVo{V> z?zvW5q)rXIel0KxAdO!L1gi+FTc{-==6&FSO07VR*^i2yfw!*Hc9{U?bv4>ti25Ja zXeJYGtUjvM8o||P)oHh=-oU{+?G+@85ce!bJLiRc36Q=`tX_gF-WSoz!N6lnGz;p% zzsajrsB;5fd9^Jnj^+<7(=sgU660gVtIM=1(6{wsK)trbdMFmcDWT^k@pipd3Lqpj zpam@7+9qwbSlpyh>>Ygf;p2Rhc70rV46X#B-a7#SzNp!fExv8mlEvfA+HfrLd(GO- zRJqcpy^oKQR*h5?AGKPbiUHyI zvGxtfY28n>wd%>hr$5o$7HmS-8tpF(oAB&y+J4C0udLG!ppor?J8st=FqzjS?g-?s z*PJSz?G+R6(7wc%lVaR2w9hf6VRvfCIIvGF*q~9e#`7Drry;FAaTkE!CEmFUAT1a7 z-mTq-cf;<{wxMcg;Qf2Fx0OON^ z#~;wjsN+-Ov&|p`U4fPdL1l2jy6qwD&p2R-1s%Yz^Wu#TZGOx#0Q_WN$Rk>-hB-g< zq!w#Uxd?n7hgcynORa4)Vikb`?9}cf=gm34*6NTL%`JBSTB|o9cSvqP3#g@mV*zc7 zYKpf9hVIgy!Wqdf4!xx9ipggcKs=?D zlv|@7$1s_&g0he;@z&PVyJ{&#h714QfJ|u={t5dJ4uERkZZ*=EkvNX=5_!*Hi{+}GfxOLl3c~^@7>HFF4vp0=a)u;}cxYIB5M3zN zm9L1JlRhdf#w>lUF6v0EeMKACUaGpty^&1%8(XO--%L*n ztx2^6m@!gW!AAkvX~vWzPES-4h73Y$`CDn;D^cQl7$IR;aK0o3501d_uG9~}17^D**{5J8MvXl5IXddp?>Rj!$I+>>V0MT&a*NWnPuMd{^OI%^sTwk7^73Kp% zr%2Qw_tT*JXnq}3TJTP7@#KdbN%eSA1)DUBX*L3L|NwbmzAu~F7Psu4RscCkR=Q5DJhMX&b z$=H00C}ms01xkq10C!Zptfe%8gr9SpF%J*nrtAaADZSv^S9qaAk- zcmx;Q$zar0PBdvHi8roBzgXW({%H& zLWj%>Y4A=!tD$YZ1WT^t1380_l~YALBuRsiO0flg@2=rK5@eBzhl=>_G4>=0s5;67 zheSll7*^o9-)U>qCR}fKs@pNhhYG43# zY10bn8;*hE&Jv!w!TIY+l`g}^AY>raS?2LHlPa22(hnG=K?IszhN=loV7ui@_!ln*H%H`QEiM`(Rf@)oW z9y3Po#K1#6=C==gl1LaJ+(;rY~2MRYzh>QyIlV z2UH{jAqeETC`lI0&`8K7QGYQgh!1;1PAwRpK;m{ACVH8UHpcLRK-DLO>ySkr7Xog1 z>m(1?>2(9>jk*@}HpZ%?i(mdx`&0XP$UeN>Y5Tn?ws(p%#A=U zz;YP`ki015z)WX{FtfT6b6ZTSC^Mdc6-D9-SaXY~2eKF<0B9RPL%g)bv&iqQ2L@xL zghYfk1eAz{9_)i47nVh^YI=sX2(*^FbGE1D2CxVK9|4M++Jm$~kJm|yA6iJce+jy1 zm^uqW7+Ti<}N>=F0Bp%uq^ z7_#{;6%W0krA;qTi0er(N@R)PddVQ>b7(v;NeK>$Bsp;J6+~cK!_MnK>J}1R5W&He z(GJ>BW|Rq1bE*|to;alLmM_r?`w+n}uB*5IQgI$n!1)iwUsrHI?O%2O>jo_vAl1Yg?az!i`J{Cd$KsJ z2ecJ zrHFsiR-oV}7T0fh4U7*~@il>-Q4$hlga)%)K|+qJ9Yo*LD%q&sGPsQwgi)faZe~{u zZoJ4>+X8Q22&e^0E?8JiOCz@W7`<0u86g!NY6wq^O-_Pr3$RI-SM-_C^)6-QlZPY7 z>#g~DlHL;q26;xWQPfA~e6FJ4l|&92>%`@sYkk{kC+A^;m=7@^zy{rdK|&s)v=AgJ zd2lKW_LcAR)_Ghx#NbHKY=$sZqwguufLsh5^P!>Rph$ibfSF`Qgy%tUNkJ5hUv8WB zHX0_*8Fel=T!gy856=o;0bPM(yp%P23C>4-SQa>p-`tPiN=Y8pYb0!&HAA|o+( z7z~DOCd9Ge0LHk+iShG!zX2rt-+)|I;CiiXNF(0R;76#Pwqek}B8S>A_wdWagP&`? zKqq#7uDuq!6+6;l4XpV>bD8=SITXvxG9U@K3!&j`1pJUu&v%O5UD{8~dsvAW^QAVV z|1Q)@A5j~hQpoI9rtYTw7~Bg=whav~O^li5?td^2Hbe~{UW z%Y2iXS{r>==v(m^v%^(hAA}z3*LRB9r%YVxsa-a`sqtD*OVez!kZ3u>?BnIr+0CBX zt8rnBNp`W~V)<;&O5@3SRy?;*KB6h2VZF|2vs!WYpqwB4QKet9wIM953n17H;6>yX%zmiwZc;Hxj96w>L8M} z=qEoC(t*EiF3Bz^z!Q1FM^x7jJFVVk5qCjLYhUC6cWyv*5IGt>7O)W`RF zuU#@!FlZcwkkut!Ut41>*rAMx=fb{U>0Ko6!V8p=&`FHOv>00icx&*o#eRyjKfqPf zP877xOUF?RyG^FGCl*W7jqH-n;kx|znLj8&*iH2)hbF*^D_zR5T!KcLZ3+_0((i&cSR#y6b?aeql^B~ct!1W?Ct{B41JdtVxthQyJ)9E#A$5u z`{q)+a?`d;?TuV93V>&j7`b533F|Es1zV_zXd<-djm;_%e z83!OH)svtBvgo0cl{{pT;NMPD$BEhN1G#dMf(z-J!2l#bCCR&<#1~_Z-60f$aS`

G=oG|{R09yEyA2goli@qW0kVN1h5s_{TNED=Qq1TWnP%jCfWG5)wiD(8p3$R#FeHC#_@kQ#p}u@Lt9}=655fk^MkCSn=}|nK zIJhrN>l-5GM$`~{N~#d+P)L%V5Y35@Gl&A#d!8TxpQ2t6#)LbT$=-d-yi)Naw}h|Y zTn~u1M?riN9Q~!t=)wTN8rsI31^Eg-D;k5l5gFBzj{YQC>ZiPF2~C3w8oZsByuR&? zh}y%)xDaCw*dMju+HsW;s?s1g3RkX~9pbH5k0@`XJyiz;w#B;Lks9!qQFCHYq?_c?&(=*X=tQ2q!^Mr2Q8EmP8Ng_O3@SND zJ8niO!VTzwtQEOqf*7Ae6jxrHVa4U;CR&Ot=*uPborEXN!T`08<|`Q!biXU)v9i8t z1x}(kqt_ZT`U8Uq&B>rUgiQo{I^E@?J)&?8SciIX_XVxYd@9Z#c;|xFX@QP>n`$kE zVa_M2wHPLaE|c}wmiZ3L{|J}a+Dn`^S@UoJ%rIL&hBb$3u^upctu><4Vtw1Z+|ees z#8{VEr#hs^@N&l*krrz`oxIk;TUUCmIg<5M;ZAhvxOIUHX8nW7T%&FXe4<%hl=Xqf zS$}Qqa3JCu{N7Txir3?;=Z1K#Hbm|z-tIs=0-O_C+GM~%kO~T#z-|7OtwxC*;!~S7 z6Ph4fJeqTh{CG@byI2-)T?I?Yf5cm7;NhkO>mac(!3s+g`o0#XY5fzeeshIszYvMm zwdOtM1ERm(x+8U;nhxIJ!);B433LJk5m6`im^%X}?A8u5Y@}{awx+|1_VHxvaBZJz zLpYrE=B~g;$=2VPU}$_%Z0T+NWnxSc1g%a` z3*+~xsXA%fr=}$dUmxpZri>)fx36_&dmiraWGEHdv&GodGK5N%)EipT2|Q3%W+&w* z!F)QjZ%I`o^C|)nBxXV&plF;xG>U{HZ@(mDC7wbV0!D?ymd+%l@fC>=L~Pq(_kYe+ z22_<)iURN&`PF3=P=b}s^^iNIR)YkbpdJOJqY+T>fQeWxU|=G zOp_}(LAn?RnM4o4S%UMlQ`D6L*iuYi+&Yntxjur!NFvfu?o>gPX!V7_P@YtgWCs{@ z_c_VtgQqTCDu=>Js!vI0tIwZ!HPp+J+Fc4_?W8KA&>p@5obDzX$}&=@aq(rfuD>ITl{F>MXNdK4Qb5P&Sp(GEzI>}6A7cxw z6Jb!*USQpYUnRppM*SkA(E4+jCT=UVo`vo8gGJWs&6`=Kh#PLLfZ@;l;nv+SY)Tnn zz03mV_|qbFgf(6GMp%bK-M(2oF~YhKuVY49hk!5_jlSXebzowVirXiU zRB2$5x=y?>(s~m*r+2-J^!NaqlO)AXwZjjhbrb6lO>XN#^Y*0u;!U^p7YW;wQo%A8 zdzTzc+Mi?-Z@V#opN+CkjE{JsIRo#EvW|h~-YJ~L*4r#as>3NBDz>i1cx=l&Z9L5x z$S%S3jo+#Gy?l&yifZ>*jpj3*rOwnCRs3T$WtFr9C=M0cEdDUg z`n4_8d8V^Nyjf~p)o*)J9_g)_Chx_}DTYY@$+}9dGqw3ATyqGV4!bO88Ev@_zu<4RS&N delta 52287 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%0D?BU5a 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 From 21aade3ed9346be55404ca1aadbe8e3250bac7ed Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 4 Sep 2026 14:20:42 +0200 Subject: [PATCH 14/14] Revert "fix(server): require V16 metadata instead of falling back to state_getMetadata" This reverts commit 91c512ad. Unlike the other fallbacks removed here, this one is not unreachable. It fires on any chain that does not serve V16, and which chain a host connects to is the host's choice, not the core's. Both mobile hosts keep the equivalent: iOS retains `state_getMetadata` as a catch around the versioned path (RuntimeFetchOperationFactory.swift), and both negotiate the highest metadata version at or below 16 rather than requiring 16, so they accept V14 and V15. Removing it here would make the core the only host of the three that fails where the others degrade. --- .../src/runtime/signing_host/sso_responder.rs | 13 +- .../src/runtime/statement_allowance.rs | 121 +++++++++++------- .../runtime/statement_allowance/extension.rs | 25 ++-- .../statement_allowance/test_fixtures.rs | 10 +- 4 files changed, 95 insertions(+), 74 deletions(-) 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 f7694b6e7..174075f72 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 @@ -1737,6 +1737,11 @@ mod tests { (services, signing_host) } + /// Metadata for the People chain the signing fixture is configured for. + #[cfg(not(target_arch = "wasm32"))] + const PEOPLE_METADATA: &[u8] = + include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata-v16.scale"); + /// An existing statement-store allowance must be served without resolving a /// ring or submitting anything. The cache and the scan are covered on their /// own; this pins the composition, so removing the early return fails here @@ -1773,13 +1778,7 @@ mod tests { "Metadata_metadata_at_version", format!( r#""0x{}""#, - hex::encode( - Some( - crate::runtime::statement_allowance::test_fixtures::PEOPLE_METADATA - .to_vec() - ) - .encode(), - ), + hex::encode(Some(PEOPLE_METADATA.to_vec()).encode()), ), ), // The scan bound, read through the `Resources` view functions. diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index 33bb80ab0..8994c4c90 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -68,9 +68,6 @@ pub enum StatementAllowanceError { /// Bulletin allowance polling timed out. #[error("timed out waiting for Bulletin authorization")] BulletinAuthorizationTimeout, - /// The runtime does not serve the metadata version allowance signing needs. - #[error("runtime does not serve metadata version {0}")] - MetadataVersionUnavailable(u32), } /// Error while decoding generic chain state used by allowance registration. @@ -111,28 +108,44 @@ pub enum ChainStateError { HeaderNumberParse(#[source] std::num::ParseIntError), } -/// The only metadata version carrying the transaction-extension version map the -/// allowance extrinsics are signed against. -const REQUIRED_METADATA_VERSION: u32 = 16; +/// Metadata version to ask the runtime for: the first that carries a +/// transaction-extension version map. +const PREFERRED_METADATA_VERSION: u32 = 16; -/// Fetch and decode the runtime metadata through `Metadata_metadata_at_version`. +/// Fetch and decode the runtime metadata, preferring V16. /// -/// A runtime that does not serve [`REQUIRED_METADATA_VERSION`] is rejected -/// rather than signed for with an unresolvable pipeline. +/// The legacy `state_getMetadata` RPC answers with whatever version the node +/// serves — V14 on paseo-next-v2 — and V14 declares no transaction-extension +/// version map at all, so the pipeline version cannot be resolved from it. V16 is +/// only reachable through the `Metadata_metadata_at_version` runtime API, so ask +/// for it first and fall back for runtimes that do not offer it. pub async fn fetch_metadata(rpc: &RpcClient) -> Result { - fetch_required_metadata(rpc) - .await? - .ok_or(StatementAllowanceError::MetadataVersionUnavailable( - REQUIRED_METADATA_VERSION, - )) + match fetch_metadata_at_version(rpc, PREFERRED_METADATA_VERSION).await { + Ok(Some(metadata)) => return Ok(metadata), + Ok(None) => { + debug!( + version = PREFERRED_METADATA_VERSION, + "runtime does not offer this metadata version; using state_getMetadata" + ); + } + Err(reason) => { + debug!( + version = PREFERRED_METADATA_VERSION, + %reason, + "metadata runtime call failed; using state_getMetadata" + ); + } + } + fetch_legacy_metadata(rpc).await } -/// Ask the runtime for [`REQUIRED_METADATA_VERSION`] through +/// Ask the runtime for one metadata version through /// `Metadata_metadata_at_version`, which answers `Option`. -async fn fetch_required_metadata( +async fn fetch_metadata_at_version( rpc: &RpcClient, + version: u32, ) -> Result, StatementAllowanceError> { - let argument = format!("0x{}", hex::encode(REQUIRED_METADATA_VERSION.encode())); + let argument = format!("0x{}", hex::encode(version.encode())); let value = rpc .call( "state_call", @@ -152,6 +165,26 @@ async fn fetch_required_metadata( Metadata::decode(&opaque).map(Some) } +/// Fetch and decode the runtime metadata through the legacy `state_getMetadata`. +async fn fetch_legacy_metadata(rpc: &RpcClient) -> Result { + let value = rpc.call("state_getMetadata", json!([])).await?; + let hex_str = value + .as_str() + .ok_or(MetadataError::MetadataResultNotString)?; + let bytes = hex::decode(hex_str.strip_prefix("0x").unwrap_or(hex_str)) + .map_err(MetadataError::MetadataHex)?; + // `state_getMetadata` may return either the raw `RuntimeMetadataPrefixed` + // (starts with the `meta` magic) or an OpaqueMetadata wrapper + // (`Vec` = compact(len) ‖ bytes). Strip the wrapper only when present. + const META_MAGIC: [u8; 4] = *b"meta"; + if bytes.get(..4) == Some(&META_MAGIC) { + Metadata::decode(&bytes) + } else { + let inner = Vec::::decode(&mut &bytes[..]).map_err(MetadataError::OpaqueMetadata)?; + Metadata::decode(&inner) + } +} + /// 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?; @@ -225,10 +258,10 @@ pub struct ChainContext { /// Runtime metadata and chain state cached per chain. /// -/// Both are fixed for a given runtime, and a full metadata response is large, so -/// entries are keyed by genesis hash and revalidated with a concurrent -/// `state_getRuntimeVersion` + `chain_getBlockHash(0)` — two small requests in -/// place of a metadata 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 a +/// concurrent `state_getRuntimeVersion` + `chain_getBlockHash(0)` — two small +/// requests in place of a metadata download 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. @@ -1221,6 +1254,9 @@ mod tests { use super::rpc::testing::ScriptedRpc; use super::*; + /// Fixture metadata captured from paseo-next-v2 (raw `RuntimeMetadataPrefixed`). + const FIXTURE: &[u8] = include_bytes!("../../tests/fixtures/paseo-next-v2-metadata.scale"); + fn allowance( remained_size: u64, remained_transactions: u32, @@ -1288,37 +1324,21 @@ mod tests { ); } - /// A runtime that does not serve V16 declares no transaction-extension - /// version map, so the pipeline version cannot be resolved from what it does - /// serve. The fetch has to fail loudly instead of yielding metadata that - /// signs pipeline 0 and looks indistinguishable from a correct signature. - #[test] - fn a_runtime_without_v16_metadata_is_rejected() { - let scripted = ScriptedRpc::new([r#""0x00""#]); - let rpc = RpcClient::new(HostRpcClient::new(scripted)); - - let Err(error) = futures::executor::block_on(fetch_metadata(&rpc)) else { - panic!("a runtime without V16 metadata cannot be signed for"); - }; - - assert_eq!( - error.to_string(), - "runtime does not serve metadata version 16" - ); - } - /// 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 `Metadata_metadata_at_version` hex result: the - /// `Option` the runtime call answers with. + /// The fixture metadata as a `state_getMetadata` hex result. fn metadata_result() -> String { - format!( - r#""0x{}""#, - hex::encode(Some(test_fixtures::PEOPLE_METADATA.to_vec()).encode()) - ) + format!(r#""0x{}""#, hex::encode(FIXTURE)) + } + + /// `Metadata_metadata_at_version(16)` answering `None`, so the caller falls + /// back to `state_getMetadata`. These tests are about caching, not about + /// which metadata version a runtime serves. + fn metadata_version_unavailable() -> String { + r#""0x00""#.to_string() } /// A `chain_getBlockHash(0)` result for `genesis_hash`. @@ -1337,10 +1357,13 @@ mod tests { /// The requests one cache miss makes, in order. The two validation reads /// are issued together, so both happen whether or not the entry is reused. - const MISS: [&str; 3] = [ + const MISS: [&str; 4] = [ "state_getRuntimeVersion", "chain_getBlockHash", + // The V16 runtime call is tried first; these scripts answer it as absent, + // so the legacy fetch follows. "state_call", + "state_getMetadata", ]; /// The requests one cache hit makes: validation only, no metadata download. const HIT: [&str; 2] = ["state_getRuntimeVersion", "chain_getBlockHash"]; @@ -1350,6 +1373,7 @@ mod tests { fn call_script(spec_version: u32, reported: [u8; 32], downloads: bool) -> Vec { let mut script = vec![runtime_version(spec_version), genesis_result(reported)]; if downloads { + script.push(metadata_version_unavailable()); script.push(metadata_result()); } script @@ -1404,7 +1428,8 @@ mod tests { let body = match method { "state_getRuntimeVersion" => runtime_version(1_000_000), "chain_getBlockHash" => genesis_result([0xaa; 32]), - "state_call" => { + "state_call" => metadata_version_unavailable(), + "state_getMetadata" => { self.0 .metadata_downloads .fetch_add(1, std::sync::atomic::Ordering::Relaxed); diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs index 9da81f778..cfae4c7f8 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs @@ -1,9 +1,8 @@ //! Signed-extension encoding for the unsigned General (v5) `AsResources` //! extrinsic, driven by live chain metadata. //! -//! The extension **order** and per-extension type ids come from live V16 -//! metadata read through `Metadata_metadata_at_version`; the V14 and V15 decode -//! arms serve frozen fixtures only. The per-extension `extra` / +//! The extension **order** and per-extension type ids come from the runtime +//! metadata (`state_getMetadata`, V14/V15/V16); the per-extension `extra` / //! `additional_signed` bytes come from a name-keyed encoder mirroring //! signing-bot `src/core/create-transaction.ts` `encodeSignedExtensions`, with a //! generic default for the personhood extensions (all `Option`/void). @@ -41,8 +40,8 @@ pub const AS_DOTNS_GATEWAY: &str = "AsDotnsGateway"; /// metadata entries. #[derive(Debug, Error)] pub enum MetadataError { - /// The metadata runtime call did not return a hex string. - #[error("Metadata_metadata_at_version returned non-string")] + /// `state_getMetadata` did not return a hex string. + #[error("state_getMetadata returned non-string")] MetadataResultNotString, /// Metadata hex payload was invalid. #[error("metadata hex: {0}")] @@ -369,9 +368,9 @@ macro_rules! collect_metadata_v16 { } impl Metadata { - /// Decode a raw `RuntimeMetadataPrefixed` (V14 through V16) into the ordered - /// signed-extension defs, type registry, storage value types, constants, and - /// call enums. + /// Decode `state_getMetadata` bytes (a `RuntimeMetadataPrefixed`, V14 + /// through V16) into the ordered signed-extension defs, type registry, + /// storage value types, constants, and call enums. pub fn decode(bytes: &[u8]) -> Result { let prefixed = RuntimeMetadataPrefixed::decode(&mut &bytes[..]).map_err(MetadataError::Decode)?; @@ -930,14 +929,14 @@ mod tests { call } - /// V16 metadata captured from paseo-next-v2 (spec 3000000), the only version - /// the fetch accepts. Distinct from `FIXTURE`, the older V14 capture, which - /// predates the `revision` field. + /// V16 metadata captured from paseo-next-v2 (spec 3000000), the version the + /// runtime API serves. Distinct from `FIXTURE`, which is the V14 the legacy + /// RPC answers with and predates the `revision` field. const FIXTURE_V16: &[u8] = include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata-v16.scale"); - /// This is the only metadata the fetch accepts, so the decode path is - /// load-bearing: it has to yield a usable `Metadata`, not merely decode. + /// Preferring V16 makes this decode path load-bearing, so cover it: it has to + /// yield a usable `Metadata`, not merely decode. #[test] fn v16_metadata_decodes_into_a_usable_metadata() { let metadata = Metadata::decode(FIXTURE_V16).unwrap(); diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/test_fixtures.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/test_fixtures.rs index f3d531f57..5fb127dd3 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/test_fixtures.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/test_fixtures.rs @@ -44,14 +44,12 @@ const PEOPLE_RESOURCE_BUDGETS: [(&str, u32); 5] = [ ("get_long_term_storage_claims_per_period", 10), ]; -/// People-chain V16 metadata captured from paseo-next-v2, raw -/// `RuntimeMetadataPrefixed` as `Metadata_metadata_at_version` answers with. -pub(crate) const PEOPLE_METADATA: &[u8] = - include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata-v16.scale"); - /// People-chain V16 metadata with [`PEOPLE_RESOURCE_BUDGETS`] already resolved. static PEOPLE: LazyLock = LazyLock::new(|| { - let metadata = Metadata::decode(PEOPLE_METADATA).expect("the committed People fixture decodes"); + let metadata = Metadata::decode(include_bytes!( + "../../../tests/fixtures/paseo-next-v2-metadata-v16.scale" + )) + .expect("the committed People fixture decodes"); for (function, value) in PEOPLE_RESOURCE_BUDGETS { let definition = metadata .view_function("Resources", function)