From 148de5caac607426ff0a535216764e82960261f6 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 3 Sep 2026 10:53:00 +0200 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 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 delta 52287 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%0D?BU5a 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 From 8ccf1dfe4bcd7c781aaff6a787a7c502bc611cae Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 3 Sep 2026 18:21:19 +0200 Subject: [PATCH 4/4] test(server): tighten proof-context coverage --- .../truapi-host-cli/tests/live_asset_hub.rs | 2 +- .../src/runtime/signing_host/sso_responder.rs | 1 - .../src/runtime/statement_allowance/pgas.rs | 115 +++++++++++------- .../src/runtime/statement_allowance/slot.rs | 20 +-- rust/crates/truapi-server/src/test_support.rs | 24 +++- 5 files changed, 103 insertions(+), 59 deletions(-) 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 13794aa0a..c6588cac1 100644 --- a/rust/crates/truapi-host-cli/tests/live_asset_hub.rs +++ b/rust/crates/truapi-host-cli/tests/live_asset_hub.rs @@ -22,7 +22,7 @@ fn asset_hub_ws() -> String { } const PEOPLE_WS: &str = "wss://paseo-people-next-system-rpc.polkadot.io"; -/// An active lite-person ring mirrored to Asset Hub. +/// Must stay active so the ignored live tests can resolve its mirrored roots. const RING_INDEX: u32 = 1; /// The ring this fixture's index belongs to. const COLLECTION: PersonhoodCollection = PersonhoodCollection::LitePeople; 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 0eae554e1..5f1bcec68 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 @@ -1841,7 +1841,6 @@ mod tests { .any(|method| method.starts_with("author_submit")), "an extrinsic was submitted for an allowance already in place: {methods:?}" ); - // The suffix and one slot read answered it; the scan stopped at the first match. assert_eq!( methods .iter() 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 5a2c96261..3f090032d 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/pgas.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/pgas.rs @@ -103,19 +103,12 @@ fn current_generation_key() -> Vec { .concat() } -/// 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 { + // Missing entries and absent values both read as `None`, so distinguish them + // before accepting the runtime's default generation. if metadata .storage_value_type("MembersSubscriber", "CurrentGeneration") .is_none() @@ -134,8 +127,6 @@ async fn read_current_generation( } } -/// `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(), @@ -369,10 +360,8 @@ pub async fn await_ring_revision( })?; 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. + // A rebuild can land during this wait, leaving cached-generation polls + // on roots that will never return. let generation = read_current_generation(rpc, metadata).await?; if let Some(bytes) = rpc .get_storage(&ring_roots_key(generation, collection, ring_index)) @@ -435,9 +424,8 @@ 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. + /// Real metadata is needed because this regression depends on the pallet + /// being absent. const PEOPLE_METADATA: &[u8] = include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata-v16.scale"); @@ -577,28 +565,18 @@ mod tests { assert_read_ring_5_of(&scripted); } - /// The generation uses `Twox64Concat`; the remaining two keys use - /// `Blake2_128Concat`. + /// A wrong key hasher makes imported roots look absent until timeout. #[test] - fn subscriber_ring_key_hashes_all_three_map_keys() { - let collection = PersonhoodCollection::LitePeople; - let key = ring_roots_key(7, collection, 136); - - assert_eq!(key.len(), 16 + 16 + 8 + 4 + 16 + 32 + 16 + 4); - assert_eq!( - &key[40..44], - &7u32.to_le_bytes(), - "generation follows its hash" - ); + fn subscriber_ring_key_matches_the_runtime_layout() { assert_eq!( - &key[60..92], - collection.identifier(), - "identifier follows its hash" - ); - assert_eq!( - &key[108..], - &136u32.to_le_bytes(), - "ring index is little-endian" + ring_roots_key(7, PersonhoodCollection::LitePeople, 136), + hex::decode( + "c8d053ab324196afc756c5ae3fbd29178034a4ffd558299b9ac8097424772405\ + 0e0d969b0e48cab7070000000bf6e234fafa085fd6710b952081ed8d706f703a\ + 706f6c6b61646f742e6e6574776f726b2f70656f706c652d6c697465751c83\ + d291ed9a90887239b8a92828a688000000" + ) + .unwrap(), ); } @@ -607,18 +585,63 @@ mod tests { let scripted = ScriptedRpc::new(["null"]); let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); - assert_eq!( + let generation = futures::executor::block_on(read_current_generation(&rpc, test_fixtures::asset_hub())) - .unwrap(), - 0 + .unwrap(); + + assert_eq!( + (generation, scripted.calls()), + ( + 0, + vec![( + "state_getStorage".to_string(), + r#"["0xc8d053ab324196afc756c5ae3fbd2917c2dbc4fc2f665a39ada06f0965cccf86"]"# + .to_string(), + )], + ), ); + } + + /// Caching the generation would leave later polls on abandoned roots. + #[test] + fn a_generation_change_during_wait_moves_the_ring_root_read() { + let first_generation = format!(r#""0x{}""#, hex::encode(7u32.to_le_bytes())); + let second_generation = format!(r#""0x{}""#, hex::encode(8u32.to_le_bytes())); + let roots = format!( + r#""0x{}""#, + hex::encode(test_fixtures::ASSET_HUB_RING_5_ROOTS) + ); + let scripted = ScriptedRpc::new([ + first_generation.as_str(), + "null", + second_generation.as_str(), + roots.as_str(), + ]); + let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); + + futures::executor::block_on(await_ring_revision( + &rpc, + test_fixtures::asset_hub(), + CAPTURED_COLLECTION, + 5, + 106, + )) + .unwrap(); + + let storage_call = |key: Vec| { + ( + "state_getStorage".to_string(), + format!(r#"["0x{}"]"#, hex::encode(key)), + ) + }; assert_eq!( scripted.calls(), - vec![( - "state_getStorage".to_string(), - r#"["0xc8d053ab324196afc756c5ae3fbd2917c2dbc4fc2f665a39ada06f0965cccf86"]"# - .to_string(), - )] + vec![ + storage_call(current_generation_key()), + storage_call(ring_roots_key(7, CAPTURED_COLLECTION, 5)), + storage_call(current_generation_key()), + storage_call(ring_roots_key(8, CAPTURED_COLLECTION, 5)), + ], ); } 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 761835c93..d338a3f5e 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs @@ -1067,10 +1067,8 @@ 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. + /// The nonzero vector pins field order and endianness. The second separately + /// pins the period-zero, sequence-zero reference vector. #[test] fn statement_slot_context_matches_mobile_clients_and_runtime() { let vector = |hex: &str| -> [u8; 32] { hex::decode(hex).unwrap().try_into().unwrap() }; @@ -1103,11 +1101,19 @@ mod tests { #[test] fn network_suffix_is_read_from_chain_storage() { let scripted = ScriptedRpc::new(vec![r#""0x14706173656f""#]); - let rpc = RpcClient::new(HostRpcClient::new(scripted)); + let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); + let suffix = futures::executor::block_on(read_network_suffix(&rpc)).unwrap(); assert_eq!( - futures::executor::block_on(read_network_suffix(&rpc)).unwrap(), - b"paseo", + (suffix, scripted.calls()), + ( + b"paseo".to_vec(), + vec![( + "state_getStorage".to_string(), + r#"["0x63c2d4c355d2d188c234d0a0669dc90863c2d4c355d2d188c234d0a0669dc908"]"# + .to_string(), + )], + ), ); } diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 8f5aa129d..4991a6f1c 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -1297,10 +1297,7 @@ 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. +/// Exhausted method scripts panic so one read cannot reuse another's response. /// /// Waits indefinitely for the next request rather than giving up after a fixed /// number of polls, so work between requests cannot race the pump. @@ -1360,6 +1357,25 @@ fn method_keyed_responses( })) } +#[test] +#[should_panic( + expected = "method `state_getStorage` was called 2 times, and the script has 1 response(s) for it" +)] +fn method_keyed_responses_do_not_replay_an_exhausted_answer() { + use futures::StreamExt; + + let request = + |id| format!(r#"{{"jsonrpc":"2.0","id":"{id}","method":"state_getStorage","params":[]}}"#); + let sent = Arc::new(Mutex::new(vec![request(1), request(2)])); + let mut responses = + method_keyed_responses(sent, vec![("state_getStorage", "null".to_string())]); + + futures::executor::block_on(async { + responses.next().await.expect("first scripted response"); + responses.next().await.expect("second scripted response"); + }); +} + async fn wait_for_matching_request_id(sent: Arc>>, response: &str) { let Some(id) = json_rpc_id(response) else { return;