diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index f6cd0046e..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, }) @@ -795,6 +800,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 +849,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 +885,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 +919,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 +930,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_asset_hub.rs b/rust/crates/truapi-host-cli/tests/live_asset_hub.rs index 693104035..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,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; +/// 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; @@ -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-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..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 @@ -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 @@ -1207,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)?; @@ -1234,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, @@ -1350,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 @@ -1373,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, }) @@ -1773,6 +1781,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 +1841,13 @@ 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. 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..62f13b190 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. @@ -439,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 { @@ -612,6 +634,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 +691,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 +714,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 +784,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 +802,7 @@ pub async fn scan_collections( slot::SlotScan { collection, entropy: candidate.entropy, + network_suffix, period, target, excluded: &[], @@ -816,6 +849,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 +999,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), @@ -988,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, @@ -1010,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)?; @@ -1628,6 +1669,7 @@ mod tests { RegistrationParams { target: &[0x22; 32], period: 7, + network_suffix: b"paseo", ring: &ring, reuse_existing: true, preselected, @@ -1695,7 +1737,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 +1748,7 @@ mod tests { PooledRegistrationParams { target: &target, period: 7, + network_suffix: b"paseo", reuse_existing: true, allow_eviction, protected, @@ -1736,7 +1780,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 +1791,7 @@ mod tests { PooledRegistrationParams { target: &target, period: 7, + network_suffix: b"paseo", reuse_existing: true, allow_eviction: true, protected: &[], @@ -2199,6 +2245,7 @@ mod tests { &rpc, &metadata, &candidates, + b"paseo", 7, &target, true, @@ -2263,6 +2310,7 @@ mod tests { RegistrationParams { target: &[0x22; 32], period: 7, + network_suffix: b"paseo", ring: &ring, reuse_existing: true, preselected: None, @@ -2322,6 +2370,7 @@ mod tests { RegistrationParams { target: &[0x22; 32], period: 7, + network_suffix: b"paseo", ring: &ring, reuse_existing: true, preselected: None, @@ -2373,6 +2422,7 @@ mod tests { RegistrationParams { target: &[0x22; 32], period: 7, + network_suffix: b"paseo", ring: &ring, reuse_existing: true, preselected: None, @@ -2422,6 +2472,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/pgas.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/pgas.rs index 0464f5bbe..3f090032d 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/pgas.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/pgas.rs @@ -14,14 +14,14 @@ 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; 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,43 @@ 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, + 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() + { + return Err(MetadataError::MissingStorageType { + pallet: "MembersSubscriber", + entry: "CurrentGeneration", + } + .into()); + } + match rpc.get_storage(¤t_generation_key()).await? { + Some(bytes) => u32::decode_all(&mut &bytes[..]) + .map_err(PgasError::GenerationDecode) + .map_err(Into::into), + None => Ok(0), + } +} + +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 +151,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 +207,7 @@ pub async fn claim_pgas( people_rpc, people_metadata, entropy, + network_suffix, target, ring, } = params; @@ -206,11 +240,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 +280,7 @@ pub async fn claim_pgas( if !slot::pgas_slot_is_claimed_at( asset_hub_rpc, entropy, + network_suffix, day, slot_index, &block_hash, @@ -324,8 +360,11 @@ pub async fn await_ring_revision( })?; let started = Instant::now(); loop { + // 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(collection, ring_index)) + .get_storage(&ring_roots_key(generation, collection, ring_index)) .await? { let mut input = bytes.as_slice(); @@ -380,9 +419,15 @@ 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; + + /// 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"); /// 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 +436,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 +459,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 +491,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,22 +565,125 @@ mod tests { assert_read_ring_5_of(&scripted); } - /// Both map keys are hashed here, unlike the People chain's `Members` maps. + /// A wrong key hasher makes imported roots look absent until timeout. + #[test] + fn subscriber_ring_key_matches_the_runtime_layout() { + assert_eq!( + ring_roots_key(7, PersonhoodCollection::LitePeople, 136), + hex::decode( + "c8d053ab324196afc756c5ae3fbd29178034a4ffd558299b9ac8097424772405\ + 0e0d969b0e48cab7070000000bf6e234fafa085fd6710b952081ed8d706f703a\ + 706f6c6b61646f742e6e6574776f726b2f70656f706c652d6c697465751c83\ + d291ed9a90887239b8a92828a688000000" + ) + .unwrap(), + ); + } + + #[test] + fn missing_current_generation_uses_the_runtime_default() { + let scripted = ScriptedRpc::new(["null"]); + let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); + + let generation = + futures::executor::block_on(read_current_generation(&rpc, test_fixtures::asset_hub())) + .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![ + 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)), + ], + ); + } + + /// 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 subscriber_ring_key_hashes_both_map_keys() { - let collection = PersonhoodCollection::LitePeople; - let key = ring_roots_key(collection, 136); + 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!(key.len(), 16 + 16 + 16 + 32 + 16 + 4); assert_eq!( - &key[48..80], - collection.identifier(), - "identifier follows its hash" + 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!( - &key[96..], - &136u32.to_le_bytes(), - "ring index is little-endian" + (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", ); } 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/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/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 a28da7a1d..d338a3f5e 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,10 +22,12 @@ use super::view; /// StatementStore allowance period: one UTC day, in seconds. pub const STATEMENT_STORE_PERIOD_SECONDS: u64 = 86_400; -/// 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:"; +const PRODUCT_CONTEXT_PREFIX: &[u8] = b"product/peopl."; +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; /// 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 @@ -104,6 +106,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,50 +166,56 @@ 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 +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(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(), + ); + 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. -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 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 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 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)`. 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", @@ -208,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", @@ -225,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", @@ -366,6 +388,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 +442,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 +485,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 +507,7 @@ pub async fn scan_slot_excluding( let SlotScan { collection, entropy, + network_suffix, period, target, excluded, @@ -471,7 +518,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 => { @@ -522,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. @@ -535,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], @@ -551,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?; @@ -575,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()) } @@ -590,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 { @@ -598,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); @@ -619,6 +671,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 +710,7 @@ mod tests { SlotScan { collection: PersonhoodCollection::LitePeople, entropy: [0x11; 32], + network_suffix: NETWORK_SUFFIX, period: 7, target: &[0x22; 32], excluded: &[], @@ -730,6 +784,7 @@ mod tests { SlotScan { collection: PersonhoodCollection::LitePeople, entropy: [0x11; 32], + network_suffix: NETWORK_SUFFIX, period: 7, target: &[0x22; 32], excluded: &[], @@ -901,6 +956,7 @@ mod tests { SlotScan { collection: PersonhoodCollection::LitePeople, entropy: [0x11; 32], + network_suffix: NETWORK_SUFFIX, period: 7, target: &[0x22; 32], excluded: &[(SLOTS - 1) as u32], @@ -924,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)) @@ -938,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(); @@ -962,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", @@ -972,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", @@ -980,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); - - 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]); + fn pgas_context_matches_mobile_clients_and_runtime() { + let expected: [u8; 32] = + hex::decode("e47ba2c7eae3b97beabaeef8df599afd53e44ba9c2b851cd80850d3ed95a685b") + .unwrap() + .try_into() + .unwrap(); + + assert_eq!(derive_pgas_context(NETWORK_SUFFIX, 100, 3), expected); } /// `ClaimedGasAliases` is `Identity(u32be day) ‖ Blake2_128Concat(alias)`. @@ -1003,22 +1067,156 @@ mod tests { assert_eq!(&key[52..], &alias, "alias follows its blake2_128 prefix"); } + /// 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() }; + + assert_eq!( + ( + derive_slot_context(NETWORK_SUFFIX, 100, 3), + derive_slot_context(NETWORK_SUFFIX, 0, 0), + ), + ( + vector("b6c21225dcf4c2aeeca32b6db1fc93b6942ca0e8ff5c3cb1b2c5d8f0b4647ee3"), + vector("deee1c90cf0d31093d318ac6629b4c4ab08650d4a4164511cc2496205f20f067"), + ), + ); + } + + #[test] + 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], + ); + } + + #[test] + fn network_suffix_is_read_from_chain_storage() { + let scripted = ScriptedRpc::new(vec![r#""0x14706173656f""#]); + let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); + let suffix = futures::executor::block_on(read_network_suffix(&rpc)).unwrap(); + + assert_eq!( + (suffix, scripted.calls()), + ( + b"paseo".to_vec(), + vec![( + "state_getStorage".to_string(), + r#"["0x63c2d4c355d2d188c234d0a0669dc90863c2d4c355d2d188c234d0a0669dc908"]"# + .to_string(), + )], + ), + ); + } + + #[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 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 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] - 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/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 633a555ab..4991a6f1c 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -1297,6 +1297,8 @@ impl JsonRpcConnection for RecordingConnection { /// Answer each request as it arrives, by method, echoing its id. /// +/// 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. fn method_keyed_responses( @@ -1318,11 +1320,32 @@ 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 result = answers + 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 scripted = answers .iter() - .find(|(candidate, _)| *candidate == method) + .filter(|(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, @@ -1334,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; diff --git a/rust/crates/truapi-server/tests/fixtures/README.md b/rust/crates/truapi-server/tests/fixtures/README.md index e1f6354f6..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,21 +35,30 @@ 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[(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`: ``` 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 +69,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 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 e38a0bf52..dc510ac79 100644 Binary files a/rust/crates/truapi-server/tests/fixtures/paseo-next-asset-hub-metadata.scale and b/rust/crates/truapi-server/tests/fixtures/paseo-next-asset-hub-metadata.scale differ