diff --git a/rust/crates/truapi-codegen/src/rustdoc.rs b/rust/crates/truapi-codegen/src/rustdoc.rs index 63c1748b0..339fcbecc 100644 --- a/rust/crates/truapi-codegen/src/rustdoc.rs +++ b/rust/crates/truapi-codegen/src/rustdoc.rs @@ -726,7 +726,6 @@ fn extract_method(item_id: &str, item: &Item, names: &NameContext) -> Result bool { .unwrap_or(false) } -/// Resolve the `Output = T` binding from a Send future method return. +/// Resolve the `Output = T` binding from a Send future method return, or the +/// return itself when it is not one. /// /// `async_trait` represents `async fn` as /// `Pin + Send + 'async_trait>>` in rustdoc JSON. -/// Explicit `impl Future + Send` returns are also accepted so the -/// parser remains compatible with older TrUAPI trait snapshots. -fn unwrap_future_output(output: &serde_json::Value) -> Result<&serde_json::Value> { - if let Some(future_output) = extract_async_trait_future_output(output) { - return Ok(future_output); - } - let Some(bounds) = output - .get("impl_trait") - .and_then(serde_json::Value::as_array) - else { - return Ok(output); - }; - let future = bounds - .iter() - .filter_map(|bound| bound.get("trait_bound")) - .filter_map(|bound| bound.get("trait")) - .find(|bound| { - bound - .get("path") - .and_then(serde_json::Value::as_str) - .is_some_and(|path| path_suffix(path) == "Future") - }) - .context("impl Trait return is missing its Future bound")?; - let constraints = future - .get("args") - .and_then(|args| args.get("angle_bracketed")) - .and_then(|args| args.get("constraints")) - .and_then(serde_json::Value::as_array) - .context("Future bound is missing its associated-type constraints")?; - constraints - .iter() - .find(|constraint| { - constraint.get("name").and_then(serde_json::Value::as_str) == Some("Output") - }) - .and_then(|constraint| constraint.get("binding")) - .and_then(|binding| binding.get("equality")) - .and_then(|equality| equality.get("type")) - .context("Future bound is missing its Output equality") +fn unwrap_future_output(output: &serde_json::Value) -> &serde_json::Value { + extract_async_trait_future_output(output).unwrap_or(output) } fn extract_async_trait_future_output(output: &serde_json::Value) -> Option<&serde_json::Value> { @@ -1646,55 +1610,6 @@ mod tests { ); } - #[test] - fn unwraps_send_future_output() { - let output = serde_json::json!({ - "impl_trait": [ - { - "trait_bound": { - "trait": { - "path": "core::future::Future", - "args": { - "angle_bracketed": { - "args": [], - "constraints": [ - { - "name": "Output", - "binding": { - "equality": { - "type": { - "resolved_path": { - "path": "Result", - "id": 1, - "args": null - } - } - } - } - } - ] - } - } - } - } - }, - { - "trait_bound": { - "trait": { - "path": "Send", - "id": 2, - "args": null - } - } - } - ] - }); - - let unwrapped = unwrap_future_output(&output).expect("future output"); - - assert_eq!(get_resolved_name(unwrapped).as_deref(), Some("Result")); - } - #[test] fn unwraps_async_trait_send_future_output() { let output = serde_json::json!({ @@ -1759,8 +1674,20 @@ mod tests { } }); - let unwrapped = unwrap_future_output(&output).expect("async-trait future output"); + let unwrapped = unwrap_future_output(&output); assert_eq!(get_resolved_name(unwrapped).as_deref(), Some("Result")); } + + /// A return that is not an `async_trait` future is the method's own type, so + /// it has to pass through untouched. Rejecting it here would turn every + /// non-async method into a parse failure instead of a plain return type. + #[test] + fn a_return_that_is_not_a_future_passes_through() { + let output = serde_json::json!({ + "resolved_path": { "path": "Result", "id": 1, "args": null } + }); + + assert_eq!(unwrap_future_output(&output), &output); + } } diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index e25ef00dd..f045c1489 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -317,9 +317,9 @@ settings containing arguments, such as `EDITOR='code --wait'`, are supported. Managed sessions isolate signer accounts, product/core storage, and permissions. Once a signer identity is known, its public session name is the Lite username and its files live under -`//_signing_host`. Provisional and legacy named -sessions are promoted to that user-owned root, so an old name such as `pgtest` -does not remain the durable namespace. The selected username is remembered per +`//_signing_host`. Provisional named sessions +are promoted to that user-owned root, so an old name such as `pgtest` does not +remain the durable namespace. The selected username is remembered per network but is not repeated in the status bar as a separate session field. `default` remains only as a compatibility/bootstrap location until a username is resolved. It is hidden from session completion and listing and cannot be @@ -390,8 +390,8 @@ other saved pairings and the signing identity are unchanged. `/session --clear ` permanently deletes that session's local signer keys, scripts, core/product storage, and permissions. `/session --clear-all` does the same for every signing-host session on the current network, including -legacy bootstrap state, while preserving other networks and pairing-host -state. Neither command deregisters an on-chain username. The interactive UI +the network's signing-host bootstrap state, while preserving other networks and +pairing-host state. Neither command deregisters an on-chain username. The interactive UI asks for `[y/N]` confirmation. `exec` treats the explicit one-shot command as confirmation and runs it immediately. Clearing an inactive named session keeps the host running; clearing the active session or all sessions stops the signing @@ -483,16 +483,14 @@ the selected id, so the newly selected product sees its own state. The next Pairing-host state follows the same identity rule under `//_pairing_host`. Before the first identity is known it uses the small `/pairing-host` bootstrap; connecting moves -legacy bootstrap data to the first resolved user. After `/logout`, connecting +that bootstrap data to the first resolved user. After `/logout`, connecting as a different user swaps to that user's KV/core namespace instead of carrying the previous user's product data forward. Product-local KV is persisted independently under each identity root as `storage/--.json`. Each document records its normalized -product id and raw product keys. On first use, the older combined -`product-storage.json` in that profile is split into those files and retained -as `product-storage.v1.json.migrated`. Product and core JSON writes use a -flushed temporary file and atomic rename. +product id and raw product keys. Product and core JSON writes use a flushed +temporary file and atomic rename. Six scripts ship under `js/scripts/`: diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md index 356b34320..d66034066 100644 --- a/rust/crates/truapi-host-cli/SPEC.md +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -1168,11 +1168,7 @@ user-selectable and is omitted from session completion and listing. When a managed session has no connected user, startup and bare `/session` add an actionable transcript notice directing the user to `/session `. -`/session --list` includes: - -- legacy directories under `signing-host/sessions/`; and -- network directories ending in `_signing_host`. - +`/session --list` includes the network directories ending in `_signing_host`. The active session is marked with `*`. `/session ` provisions the target before replacing the current runtime: @@ -1222,12 +1218,11 @@ phrase is not written locally until the replacement runtime activates successfully. `/session --clear ` removes the durable name shown by `/session --list`, -its identity or legacy session directory, any separate legacy product storage, -and the matching network account cached in the compatibility account store. -`/session --clear-all` removes every such session, the network's signing-host -bootstrap state, and every compatibility account record for that network. It -does not remove pairing-host state, another network's records, externally -referenced scripts, or on-chain usernames. +its identity directory, and the matching network account cached in the +compatibility account store. `/session --clear-all` removes every such session, +the network's signing-host bootstrap state, and every compatibility account +record for that network. It does not remove pairing-host state, another +network's records, externally referenced scripts, or on-chain usernames. The interactive UI describes the data loss and uses the existing `[y/N]` approval. `exec` executes these explicit one-shot commands without another @@ -1260,15 +1255,14 @@ The layout may contain compatibility paths as well as identity-owned paths: storage/ default/ .json - sessions/ # accepted legacy session layout - / pairing-host/ current-user session.json # bootstrap script metadata, when used core-storage.json # bootstrap auth/core state scripts/ - storage/ # or legacy storage/default/ + storage/ + .json _signing_host/ accounts.json @@ -1388,11 +1382,6 @@ The version `1` JSON document is: The core has already removed its product namespace before the CLI stores the raw key. Identity and host role are isolated by the parent directory. -Legacy combined `product-storage.json` keys are decoded with -`ProductStorageKey` and split into per-product files. A fully safe migration is -retained as `product-storage.v1.json.migrated`. An undecodable legacy key or -document prevents the backup rename. - Noncanonical product filenames, unsupported versions, invalid ids, and invalid hex values are ignored with warnings. diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs index d349d0c12..facad2333 100644 --- a/rust/crates/truapi-host-cli/src/platform.rs +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -73,15 +73,8 @@ impl CliStoragePaths { .map(|user_id| network_dir.join(format!("{user_id}_pairing_host"))) .filter(|path| path.is_dir()) .unwrap_or_else(|| bootstrap_dir.clone()); - let product_storage_dir = if state_dir == bootstrap_dir - && bootstrap_dir.join("storage").join("default").is_dir() - { - bootstrap_dir.join("storage").join("default") - } else { - state_dir.join("storage") - }; Self { - product_storage_dir, + product_storage_dir: state_dir.join("storage"), state_dir, pairing_scope: Some(PairingStorageScope { network_dir, @@ -130,7 +123,7 @@ impl CliPlatform { approval: ApprovalPolicy, ui: Option, ) -> Arc { - let (product_storage_dir, legacy_product_storage_path, core_storage_path) = storage + let (product_storage_dir, core_storage_path) = storage .as_ref() .map(|paths| { if let Err(err) = fs::create_dir_all(&paths.state_dir) { @@ -142,15 +135,13 @@ impl CliPlatform { } ( Some(paths.product_storage_dir.clone()), - Some(paths.state_dir.join("product-storage.json")), Some(paths.state_dir.join("core-storage.json")), ) }) - .unwrap_or((None, None, None)); + .unwrap_or((None, None)); let product_storage = product_storage_dir .as_deref() - .zip(legacy_product_storage_path.as_deref()) - .map(|(directory, legacy)| load_product_storage(directory, legacy)) + .map(load_product_storage) .unwrap_or_default(); let core_storage = core_storage_path .as_deref() @@ -336,10 +327,7 @@ impl CliPlatform { let mut target_core = load_hex_key_map(&target_core_path); target_core.extend(carried); - let mut target_products = load_product_storage( - &target_product_dir, - &target_state.join("product-storage.json"), - ); + let mut target_products = load_product_storage(&target_product_dir); if migrating_bootstrap { target_products.extend( self.product_storage @@ -935,47 +923,9 @@ struct ProductStorageDocument { values: HashMap, } -fn load_product_storage( - directory: &Path, - legacy_path: &Path, -) -> HashMap>> { - let legacy_exists = legacy_path.is_file(); - let mut migration_safe = true; +fn load_product_storage(directory: &Path) -> HashMap>> { let mut products = HashMap::>>::new(); - if legacy_exists { - match read_string_map(legacy_path) { - Ok(values) => { - for (key, value) in values { - match ProductStorageKey::decode(&key) { - Ok(scoped) => { - products - .entry(scoped.product_id().to_string()) - .or_default() - .insert(scoped.key().to_string(), value); - } - Err(error) => { - migration_safe = false; - tracing::warn!( - path = %legacy_path.display(), - %error, - "could not migrate an unrecognized product storage key" - ); - } - } - } - } - Err(error) => { - migration_safe = false; - tracing::warn!( - path = %legacy_path.display(), - %error, - "could not decode legacy product storage" - ); - } - } - } - let entries = match fs::read_dir(directory) { Ok(entries) => Some(entries), Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, @@ -1010,36 +960,6 @@ fn load_product_storage( } } - if legacy_exists && migration_safe { - let migrated = products.iter().try_for_each(|(product_id, values)| { - save_product_storage(directory, product_id, values) - }); - match migrated { - Ok(()) => { - let backup = legacy_path.with_file_name("product-storage.v1.json.migrated"); - if backup.exists() { - tracing::warn!( - path = %legacy_path.display(), - backup = %backup.display(), - "legacy product storage was migrated but its backup path already exists" - ); - } else if let Err(error) = fs::rename(legacy_path, &backup) { - tracing::warn!( - path = %legacy_path.display(), - backup = %backup.display(), - %error, - "could not retain migrated product storage backup" - ); - } - } - Err(error) => tracing::warn!( - path = %legacy_path.display(), - %error, - "could not migrate legacy product storage" - ), - } - } - products } @@ -1797,19 +1717,21 @@ mod tests { ); } + /// A pairing login writes product KV before its username is known, so the + /// bootstrap directory's products must follow the first resolved user + /// instead of being stranded outside every identity namespace. #[test] - fn legacy_pairing_storage_moves_to_the_first_resolved_user() { + fn product_storage_written_before_the_username_carries_into_the_resolved_user() { let temporary = tempdir().expect("create pairing storage root"); let network_dir = temporary.path().join("testnet"); - let legacy_product_dir = network_dir.join("pairing-host/storage/default"); let product_key = ProductStorageKey::new("product.dot", "theme").expect("product storage key"); save_product_storage( - &legacy_product_dir, + &network_dir.join("pairing-host/storage"), "product.dot", &HashMap::from([("theme".to_string(), b"dark".to_vec())]), ) - .expect("write legacy pairing product storage"); + .expect("write bootstrap pairing product storage"); let platform = CliPlatform::new( test_network(), Some(CliStoragePaths::pairing(network_dir.clone())), @@ -1819,14 +1741,21 @@ mod tests { platform .switch_pairing_user_storage("alice.dot") - .expect("resolve legacy storage owner"); + .expect("resolve the bootstrap storage owner"); assert_eq!( futures::executor::block_on(platform.read(product_key.encode())) - .expect("read migrated product value"), + .expect("read carried product value"), Some(b"dark".to_vec()) ); - assert!(network_dir.join("alice.dot_pairing_host/storage").is_dir()); + // Persisted, not merely carried in memory: a restart must find it too. + assert_eq!( + load_product_storage(&network_dir.join("alice.dot_pairing_host/storage")), + HashMap::from([( + "product.dot".to_string(), + HashMap::from([("theme".to_string(), b"dark".to_vec())]), + )]) + ); } #[test] @@ -2021,70 +1950,4 @@ mod tests { fs::read_to_string(second_path).expect("read second session file") ); } - - #[test] - fn legacy_product_storage_migrates_and_keeps_a_backup() { - let temporary = tempdir().expect("create migration root"); - let first = ProductStorageKey::new("first.dot", "alpha").expect("first product key"); - let second = ProductStorageKey::new("second.dot", "beta").expect("second product key"); - let legacy_path = temporary.path().join("product-storage.json"); - save_string_map( - &legacy_path, - &HashMap::from([ - (first.encode(), b"one".to_vec()), - (second.encode(), b"two".to_vec()), - ]), - ) - .expect("write legacy product storage"); - - let platform = CliPlatform::new( - test_network(), - Some(test_storage_paths(temporary.path(), "test")), - ApprovalPolicy::AutoAccept, - None, - ); - - assert!(!legacy_path.exists()); - assert!( - temporary - .path() - .join("product-storage.v1.json.migrated") - .is_file() - ); - assert_eq!( - fs::read_dir(temporary.path().join("storage").join("test")) - .expect("list migrated product files") - .count(), - 2 - ); - let values = futures::executor::block_on(async { - ( - platform.read(first.encode()).await.expect("read first"), - platform.read(second.encode()).await.expect("read second"), - ) - }); - assert_eq!(values, (Some(b"one".to_vec()), Some(b"two".to_vec()))); - } - - #[test] - fn corrupt_legacy_product_storage_is_not_marked_as_migrated() { - let temporary = tempdir().expect("create corrupt migration root"); - let legacy_path = temporary.path().join("product-storage.json"); - fs::write(&legacy_path, "{not-json").expect("write corrupt legacy storage"); - - let _platform = CliPlatform::new( - test_network(), - Some(test_storage_paths(temporary.path(), "test")), - ApprovalPolicy::AutoAccept, - None, - ); - - assert!(legacy_path.is_file()); - assert!( - !temporary - .path() - .join("product-storage.v1.json.migrated") - .exists() - ); - } } diff --git a/rust/crates/truapi-host-cli/src/sessions.rs b/rust/crates/truapi-host-cli/src/sessions.rs index 1f1999fe9..511aec1f9 100644 --- a/rust/crates/truapi-host-cli/src/sessions.rs +++ b/rust/crates/truapi-host-cli/src/sessions.rs @@ -201,22 +201,11 @@ impl SessionCatalog { account_base_path: self.base_path.clone(), }); } - let identity_path = self.identity_path(name); - let legacy_path = self.role_path.join("sessions").join(name); - let path = if legacy_path.is_dir() && !identity_path.is_dir() { - legacy_path - } else { - identity_path - }; - let product_storage_dir = if path.starts_with(self.role_path.join("sessions")) { - self.role_path.join("storage").join(name) - } else { - path.join("storage") - }; + let path = self.identity_path(name); Ok(SessionProfile { name: name.to_string(), path: path.clone(), - product_storage_dir, + product_storage_dir: path.join("storage"), account_base_path: path, }) } @@ -262,27 +251,6 @@ impl SessionCatalog { pub fn list(&self) -> Result> { let mut names = Vec::new(); - let sessions_path = self.role_path.join("sessions"); - match fs::read_dir(&sessions_path) { - Ok(entries) => { - for entry in entries.filter_map(std::result::Result::ok) { - if !entry.file_type().is_ok_and(|kind| kind.is_dir()) { - continue; - } - let Some(name) = entry.file_name().to_str().map(ToOwned::to_owned) else { - continue; - }; - if validate_name(&name).is_ok() && name != DEFAULT_SESSION_NAME { - names.push(name); - } - } - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(error) - .with_context(|| format!("list sessions {}", sessions_path.display())); - } - } for entry in fs::read_dir(&self.network_path) .with_context(|| format!("list host profiles {}", self.network_path.display()))? .filter_map(std::result::Result::ok) @@ -301,7 +269,6 @@ impl SessionCatalog { } } names.sort(); - names.dedup(); Ok(names) } @@ -351,42 +318,18 @@ impl SessionCatalog { } } - fn remove_profile_data(&self, profile: &SessionProfile) -> Result<()> { - if !profile.path.starts_with(&self.network_path) - || !profile.product_storage_dir.starts_with(&self.network_path) - { + fn remove_named_data(&self, name: &str) -> Result<()> { + let profile = self.profile(name)?; + if !profile.path.starts_with(&self.network_path) { anyhow::bail!( "refusing to clear session outside network root {}", self.network_path.display() ); } - remove_dir_if_exists(&profile.path)?; - if !profile.product_storage_dir.starts_with(&profile.path) { - remove_dir_if_exists(&profile.product_storage_dir)?; - } - Ok(()) + remove_dir_if_exists(&profile.path) } - fn remove_named_data(&self, name: &str) -> Result<()> { - let profile = self.profile(name)?; - self.remove_profile_data(&profile)?; - for path in [ - self.identity_path(name), - self.role_path.join("sessions").join(name), - self.role_path.join("storage").join(name), - ] { - if !path.starts_with(&self.network_path) { - anyhow::bail!( - "refusing to clear session outside network root {}", - self.network_path.display() - ); - } - remove_dir_if_exists(&path)?; - } - Ok(()) - } - - /// Move a provisional or legacy session into the user-owned host root. + /// Move a provisional session into the user-owned host root. /// /// The public session name is the Lite username. The suffix is only a /// filesystem discriminator so pairing and signing state cannot collide. @@ -410,21 +353,6 @@ impl SessionCatalog { target_path.display() ) })?; - if profile.product_storage_dir.exists() - && !profile.product_storage_dir.starts_with(&profile.path) - { - let target_storage = target_path.join("storage"); - fs::create_dir_all(&target_path)?; - fs::rename(&profile.product_storage_dir, &target_storage).with_context( - || { - format!( - "move product storage {} to {}", - profile.product_storage_dir.display(), - target_storage.display() - ) - }, - )?; - } } } let promoted = SessionProfile { @@ -578,12 +506,7 @@ fn remove_file_if_exists(path: &Path) -> Result<()> { } fn migrate_default_profile(profile: &SessionProfile, target_path: &Path) -> Result<()> { - for name in [ - "core-storage.json", - "product-storage.json", - SESSION_INFO_FILE, - PAIRED_HOSTS_FILE, - ] { + for name in ["core-storage.json", SESSION_INFO_FILE, PAIRED_HOSTS_FILE] { let source = profile.path.join(name); if source.is_file() { fs::rename(&source, target_path.join(name)) @@ -1126,10 +1049,6 @@ mod tests { let catalog = SessionCatalog::new(temporary.path().to_path_buf(), "testnet")?; let alice = catalog.ensure_profile("alice")?; let bob = catalog.ensure_profile("bob")?; - let legacy_alice = catalog.role_path.join("sessions/alice"); - let legacy_alice_storage = catalog.role_path.join("storage/alice"); - fs::create_dir_all(&legacy_alice)?; - fs::create_dir_all(&legacy_alice_storage)?; fs::write(alice.path.join("state"), "alice")?; fs::write(bob.path.join("state"), "bob")?; catalog.set_current("alice")?; @@ -1140,34 +1059,35 @@ mod tests { ); assert!(!alice.path.exists()); - assert!(!legacy_alice.exists()); - assert!(!legacy_alice_storage.exists()); assert!(bob.path.exists()); assert_eq!(catalog.current_name(), DEFAULT_SESSION_NAME); assert_eq!(catalog.list()?, vec!["bob"]); + + // The pointer is cleared, not merely left unresolvable: recreating the + // name must not silently re-select the session that was just wiped. + catalog.ensure_profile("alice")?; + assert_eq!(catalog.current_name(), DEFAULT_SESSION_NAME); Ok(()) } #[test] - fn clearing_all_sessions_removes_default_legacy_and_identity_state_only() -> Result<()> { + fn clearing_all_sessions_removes_default_and_identity_state_only() -> Result<()> { let temporary = tempdir()?; let catalog = SessionCatalog::new(temporary.path().to_path_buf(), "testnet")?; let default = catalog.ensure_profile(DEFAULT_SESSION_NAME)?; let alice = catalog.ensure_profile("alice")?; - let legacy = catalog.role_path.join("sessions/legacy"); - fs::create_dir_all(&legacy)?; fs::write(default.path.join("core-storage.json"), "{}")?; fs::write(alice.path.join("state"), "alice")?; - fs::write(legacy.join("state"), "legacy")?; let unrelated = catalog.network_path.join("pairing-host"); fs::create_dir_all(&unrelated)?; fs::write(unrelated.join("state"), "keep")?; let cleared = catalog.clear(&SessionClearTarget::All)?; - assert_eq!(cleared, vec!["alice", "legacy"]); + assert_eq!(cleared, vec!["alice"]); assert!(!catalog.role_path.exists()); assert!(!alice.path.exists()); + // The other host role on the same network is not session data. assert!(unrelated.join("state").is_file()); assert!(catalog.list()?.is_empty()); Ok(()) @@ -1209,7 +1129,7 @@ mod tests { } #[test] - fn default_profile_preserves_legacy_storage_locations() -> Result<()> { + fn default_profile_uses_the_bootstrap_storage_locations() -> Result<()> { let temporary = tempdir()?; let catalog = SessionCatalog::new(temporary.path().to_path_buf(), "testnet")?; let profile = catalog.profile(DEFAULT_SESSION_NAME)?; 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 5f1bcec68..174075f72 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -1740,7 +1740,7 @@ mod tests { /// Metadata for the People chain the signing fixture is configured for. #[cfg(not(target_arch = "wasm32"))] const PEOPLE_METADATA: &[u8] = - include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); + include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata-v16.scale"); /// An existing statement-store allowance must be served without resolving a /// ring or submitting anything. The cache and the scan are covered on their @@ -1774,13 +1774,22 @@ mod tests { "chain_getBlockHash", format!(r#""0x{}""#, hex::encode([0u8; 32])), ), - // `Metadata_metadata_at_version(16)` answering absent, so the - // legacy fetch below is what serves the metadata. - ("state_call", r#""0x00""#.to_string()), ( - "state_getMetadata", - format!(r#""0x{}""#, hex::encode(PEOPLE_METADATA)), + "Metadata_metadata_at_version", + format!( + r#""0x{}""#, + hex::encode(Some(PEOPLE_METADATA.to_vec()).encode()), + ), ), + // The scan bound, read through the `Resources` view functions. + ( + "RuntimeViewFunction_execute_view_function", + format!( + r#""0x{}""#, + hex::encode(Ok::, ()>(20u32.encode()).encode()), + ), + ), + // The network suffix, read once before the scan. ( "state_getStorage", format!(r#""0x{}""#, hex::encode(b"paseo".to_vec().encode())), @@ -1841,6 +1850,7 @@ 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.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index 62f13b190..8994c4c90 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -1636,7 +1636,7 @@ mod tests { Result, ScriptedRpc, ) { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let chain_state = ChainState { spec_version: 1_000_000, transaction_version: 1, @@ -1663,7 +1663,7 @@ mod tests { let outcome = futures::executor::block_on(register_statement_account( &rpc, - &metadata, + metadata, &chain_state, entropy, RegistrationParams { @@ -1722,7 +1722,7 @@ mod tests { Result, ScriptedRpc, ) { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let chain_state = ChainState { spec_version: 1_000_000, transaction_version: 1, @@ -1738,10 +1738,10 @@ mod tests { let outcome = futures::executor::block_on(async { let scans = - scan_collections(&rpc, &metadata, &candidates, b"paseo", 7, &target, true).await?; + scan_collections(&rpc, metadata, &candidates, b"paseo", 7, &target, true).await?; register_statement_account_pooled( &rpc, - &metadata, + metadata, &chain_state, &scans, &memberships, @@ -1765,7 +1765,7 @@ mod tests { target: [u8; 32], submit_error: &str, ) -> Result { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let chain_state = ChainState { spec_version: 1_000_000, transaction_version: 1, @@ -1781,10 +1781,10 @@ mod tests { futures::executor::block_on(async { let scans = - scan_collections(&rpc, &metadata, &candidates, b"paseo", 7, &target, true).await?; + scan_collections(&rpc, metadata, &candidates, b"paseo", 7, &target, true).await?; register_statement_account_pooled( &rpc, - &metadata, + metadata, &chain_state, &scans, &memberships, @@ -2080,7 +2080,7 @@ mod tests { /// that never needed People at all. #[test] fn a_broken_people_collection_does_not_discard_a_lite_people_membership() { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let candidates = pooled_memberships().map(|membership| CollectionCandidate { collection: membership.collection(), entropy: membership.entropy, @@ -2105,7 +2105,7 @@ mod tests { let memberships = futures::executor::block_on(find_including_rings( &rpc, - &metadata, + metadata, &candidates, u32::MAX, )) @@ -2123,7 +2123,7 @@ mod tests { /// membership" would let a caller conclude the person has no personhood. #[test] fn every_collection_failing_is_reported_as_an_error() { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let candidates = pooled_memberships().map(|membership| CollectionCandidate { collection: membership.collection(), entropy: membership.entropy, @@ -2140,7 +2140,7 @@ mod tests { let err = futures::executor::block_on(find_including_rings( &rpc, - &metadata, + metadata, &candidates, u32::MAX, )) @@ -2225,7 +2225,7 @@ mod tests { /// allocation for a device that could never hold a slot in People. #[test] fn a_failed_people_scan_still_finds_the_lite_people_allocation() { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let candidates = pooled_candidates(); let target = [0x22; 32]; @@ -2243,7 +2243,7 @@ mod tests { let scans = futures::executor::block_on(scan_collections( &rpc, - &metadata, + metadata, &candidates, b"paseo", 7, @@ -2271,7 +2271,7 @@ mod tests { /// taking is replaced, and the registration proceeds. #[test] fn a_full_table_replaces_the_oldest_replaceable_slot() { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let chain_state = ChainState { spec_version: 1_000_000, transaction_version: 1, @@ -2304,7 +2304,7 @@ mod tests { let outcome = futures::executor::block_on(register_statement_account( &rpc, - &metadata, + metadata, &chain_state, entropy, RegistrationParams { @@ -2331,7 +2331,7 @@ mod tests { /// submission can still land, so two takeovers for one call can cost two. #[test] fn a_duplicate_submit_retry_does_not_take_over_a_second_slot() { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let chain_state = ChainState { spec_version: 1_000_000, transaction_version: 1, @@ -2364,7 +2364,7 @@ mod tests { let err = futures::executor::block_on(register_statement_account( &rpc, - &metadata, + metadata, &chain_state, entropy, RegistrationParams { @@ -2389,7 +2389,7 @@ mod tests { /// failure: the host loses the race whenever the chain's clock disagrees. #[test] fn a_refused_takeover_is_named() { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let chain_state = ChainState { spec_version: 1_000_000, transaction_version: 1, @@ -2416,7 +2416,7 @@ mod tests { let err = futures::executor::block_on(register_statement_account( &rpc, - &metadata, + metadata, &chain_state, entropy, RegistrationParams { @@ -2440,7 +2440,7 @@ mod tests { /// Everything occupied and still inside the cooldown stays an error. #[test] fn a_full_table_within_the_cooldown_still_fails() { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let chain_state = ChainState { spec_version: 1_000_000, transaction_version: 1, @@ -2466,7 +2466,7 @@ mod tests { let err = futures::executor::block_on(register_statement_account( &rpc, - &metadata, + metadata, &chain_state, entropy, RegistrationParams { diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/collection.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/collection.rs index ca9ebda54..8212299a4 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/collection.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/collection.rs @@ -50,15 +50,6 @@ impl PersonhoodCollection { } } - /// The `Resources` constant bounding StatementStore slots per period for - /// this collection. - pub fn slots_per_period_constant(self) -> &'static str { - match self { - Self::People => "StmtStoreSlotsPerPeriod", - Self::LitePeople => "LiteStmtStoreSlotsPerPeriod", - } - } - /// The `Resources` view function returning StatementStore slots per period /// for this collection. pub fn slots_per_period_view(self) -> &'static str { @@ -78,11 +69,7 @@ impl PersonhoodCollection { /// Whether this chain exposes a StatementStore slot budget for this collection. pub fn is_supported(self, metadata: &Metadata) -> bool { - view::supports_resource_u32( - metadata, - self.slots_per_period_view(), - self.slots_per_period_constant(), - ) + view::supports_resource_u32(metadata, self.slots_per_period_view()) } /// Max StatementStore slots per period for this collection. @@ -91,13 +78,7 @@ impl PersonhoodCollection { rpc: &RpcClient, metadata: &Metadata, ) -> Result { - view::read_resource_u32( - rpc, - metadata, - self.slots_per_period_view(), - self.slots_per_period_constant(), - ) - .await + view::read_resource_u32(rpc, metadata, self.slots_per_period_view()).await } } @@ -178,20 +159,12 @@ mod tests { } #[test] - fn each_collection_names_its_own_variant_slot_constant_and_view() { + fn each_collection_names_its_own_variant_and_slot_view() { assert_eq!(PersonhoodCollection::People.metadata_variant(), "People"); assert_eq!( PersonhoodCollection::LitePeople.metadata_variant(), "LitePeople" ); - assert_eq!( - PersonhoodCollection::People.slots_per_period_constant(), - "StmtStoreSlotsPerPeriod", - ); - assert_eq!( - PersonhoodCollection::LitePeople.slots_per_period_constant(), - "LiteStmtStoreSlotsPerPeriod", - ); assert_eq!( PersonhoodCollection::People.slots_per_period_view(), "get_stmt_store_slots_per_period", diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs index 9b567039c..cfae4c7f8 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs @@ -448,11 +448,6 @@ impl Metadata { .map(Vec::as_slice) } - pub(super) fn has_view_function(&self, pallet: &str, function: &str) -> bool { - self.view_functions - .contains_key(&(pallet.to_string(), function.to_string())) - } - pub(super) fn view_function(&self, pallet: &str, function: &str) -> Option { self.view_functions .get(&(pallet.to_string(), function.to_string())) @@ -934,7 +929,7 @@ mod tests { call } - /// V16 metadata captured from paseo-next-v2 (spec 1000032), the version the + /// V16 metadata captured from paseo-next-v2 (spec 3000000), the version the /// runtime API serves. Distinct from `FIXTURE`, which is the V14 the legacy /// RPC answers with and predates the `revision` field. const FIXTURE_V16: &[u8] = @@ -968,11 +963,6 @@ mod tests { ), ((0x02, 0x01), (0x03, 0x01)), ); - assert!( - metadata - .constant("Resources", "LiteStmtStoreSlotsPerPeriod") - .is_some() - ); } /// PGAS authorizes with a different extension from the statement-store diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/pgas.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/pgas.rs index 3f090032d..ca0d7542d 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/pgas.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/pgas.rs @@ -424,11 +424,6 @@ mod tests { 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. fn scripted_ring_5_roots() -> (RpcClient, ScriptedRpc) { @@ -670,11 +665,12 @@ mod tests { /// 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(); + // Real metadata, because the regression depends on the pallet being absent. + let people = test_fixtures::people(); let scripted = ScriptedRpc::new(["null"]); let rpc = RpcClient::new(HostRpcClient::new(scripted.clone())); - let err = futures::executor::block_on(read_current_generation(&rpc, &people)) + let err = futures::executor::block_on(read_current_generation(&rpc, people)) .expect_err("the People runtime declares no MembersSubscriber"); assert_eq!( 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 d491cb225..32821f849 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/renewal.rs @@ -378,14 +378,13 @@ mod tests { use subxt_rpcs::RpcClient as HostRpcClient; use crate::runtime::statement_allowance::CollectionMembership; - use crate::runtime::statement_allowance::extension::{ChainState, Metadata}; + use crate::runtime::statement_allowance::extension::ChainState; use crate::runtime::statement_allowance::proof; use crate::runtime::statement_allowance::ring::RingParams; use crate::runtime::statement_allowance::rpc::RpcClient; use crate::runtime::statement_allowance::rpc::testing::ScriptedRpc; + use crate::runtime::statement_allowance::test_fixtures; - const FIXTURE: &[u8] = - include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); const NOW: u64 = 10_000_000; /// One occupied slot entry, oldest first by `seq`. @@ -396,7 +395,7 @@ mod tests { format!(r#""0x{}""#, hex::encode((NOW * 1_000).encode())) } - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let chain_state = ChainState { spec_version: 1_000_000, transaction_version: 1, @@ -449,7 +448,7 @@ mod tests { }]; let context = RenewalChainContext { rpc: &rpc, - metadata: &metadata, + metadata, chain_state: &chain_state, network_suffix: b"paseo", candidates: &candidates, @@ -481,21 +480,20 @@ mod tests { use subxt_rpcs::RpcClient as HostRpcClient; use crate::runtime::statement_allowance::CollectionMembership; - use crate::runtime::statement_allowance::extension::{ChainState, Metadata}; + use crate::runtime::statement_allowance::extension::ChainState; use crate::runtime::statement_allowance::proof; use crate::runtime::statement_allowance::ring::RingParams; use crate::runtime::statement_allowance::rpc::RpcClient; use crate::runtime::statement_allowance::rpc::testing::ScriptedRpc; + use crate::runtime::statement_allowance::test_fixtures; - const FIXTURE: &[u8] = - include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); const NOW: u64 = 10_000_000; fn entry(account: [u8; 32]) -> String { format!(r#""0x{}""#, hex::encode((account, 0u32, NOW).encode())) } - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let chain_state = ChainState { spec_version: 1_000_000, transaction_version: 1, @@ -538,7 +536,7 @@ mod tests { }]; let context = RenewalChainContext { rpc: &rpc, - metadata: &metadata, + metadata, chain_state: &chain_state, network_suffix: b"paseo", candidates: &candidates, @@ -579,14 +577,13 @@ mod tests { use subxt_rpcs::RpcClient as HostRpcClient; use crate::runtime::statement_allowance::CollectionMembership; - use crate::runtime::statement_allowance::extension::{ChainState, Metadata}; + use crate::runtime::statement_allowance::extension::ChainState; use crate::runtime::statement_allowance::proof; use crate::runtime::statement_allowance::ring::RingParams; use crate::runtime::statement_allowance::rpc::RpcClient; use crate::runtime::statement_allowance::rpc::testing::ScriptedRpc; + use crate::runtime::statement_allowance::test_fixtures; - const FIXTURE: &[u8] = - include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); const NOW: u64 = 10_000_000; fn entry(account: [u8; 32], since: u64) -> String { @@ -596,7 +593,7 @@ mod tests { format!(r#""0x{}""#, hex::encode((NOW * 1_000).encode())) } - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let chain_state = ChainState { spec_version: 1_000_000, transaction_version: 1, @@ -638,7 +635,7 @@ mod tests { }]; let context = RenewalChainContext { rpc: &rpc, - metadata: &metadata, + metadata, chain_state: &chain_state, network_suffix: b"paseo", candidates: &candidates, 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 d338a3f5e..3e3300cee 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs @@ -312,13 +312,8 @@ pub async fn long_term_storage_claims_per_period( rpc: &RpcClient, metadata: &Metadata, ) -> Result { - let value = view::read_resource_u32( - rpc, - metadata, - "get_long_term_storage_claims_per_period", - "LongTermStorageClaimsPerPeriod", - ) - .await?; + let value = + view::read_resource_u32(rpc, metadata, "get_long_term_storage_claims_per_period").await?; u8::try_from(value).map_err(|_| SlotError::LongTermStorageClaimsOverflow { value }.into()) } @@ -335,13 +330,7 @@ pub async fn statement_store_grace_window( rpc: &RpcClient, metadata: &Metadata, ) -> Result { - view::read_resource_u32( - rpc, - metadata, - "get_stmt_store_grace_window", - "StmtStoreGraceWindow", - ) - .await + view::read_resource_u32(rpc, metadata, "get_stmt_store_grace_window").await } /// Decode a slot entry: `account_id(32) ‖ seq(u32 LE) ‖ since(u64 LE)`. @@ -427,14 +416,9 @@ pub async fn replacement_cooldown( rpc: &RpcClient, metadata: &Metadata, ) -> Result { - view::read_resource_u32( - rpc, - metadata, - "get_stmt_store_replacement_cooldown", - "StmtStoreReplacementCooldown", - ) - .await - .map(u64::from) + view::read_resource_u32(rpc, metadata, "get_stmt_store_replacement_cooldown") + .await + .map(u64::from) } /// The account holding our alias slot `(period, seq)`, read pinned to @@ -669,7 +653,6 @@ mod tests { /// Fixture metadata captured from paseo-next-v2; its /// `LiteStmtStoreSlotsPerPeriod` is 10. - const FIXTURE: &[u8] = include_bytes!("../../../tests/fixtures/paseo-next-v2-metadata.scale"); const SLOTS: usize = 10; const NETWORK_SUFFIX: &[u8] = b"paseo"; @@ -696,7 +679,7 @@ mod tests { /// Run `scan_slot_excluding` for `[0x22; 32]` against a scripted period /// whose slot occupancy is `slots`. fn scripted_find(slots: &[Option<[u8; 32]>]) -> SlotSelection { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let entries: Vec = slots .iter() .map(|slot| slot.map_or_else(|| "null".to_string(), slot_entry)) @@ -706,7 +689,7 @@ mod tests { futures::executor::block_on(scan_slot_excluding( &rpc, - &metadata, + metadata, SlotScan { collection: PersonhoodCollection::LitePeople, entropy: [0x11; 32], @@ -770,7 +753,7 @@ mod tests { /// carry them through rather than discard them. #[test] fn the_scan_reports_each_occupied_slots_age() { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); let entries: Vec = (0..SLOTS) .map(|seq| entry_with_since([0x99; 32], 1_000 + seq as u64)) .collect(); @@ -780,7 +763,7 @@ mod tests { let SlotSelection::Full { occupied, .. } = futures::executor::block_on(scan_slot_excluding( &rpc, - &metadata, + metadata, SlotScan { collection: PersonhoodCollection::LitePeople, entropy: [0x11; 32], @@ -926,24 +909,12 @@ mod tests { ); } - #[test] - fn metadata_without_views_supplies_the_replacement_cooldown_constant() { - let metadata = Metadata::decode(FIXTURE).unwrap(); - let scripted = ScriptedRpc::new(std::iter::empty::<&str>()); - let rpc = RpcClient::new(HostRpcClient::new(scripted)); - - assert_eq!( - futures::executor::block_on(replacement_cooldown(&rpc, &metadata)).unwrap(), - 60 - ); - } - /// Excluding a slot because a submission for it is in flight must not read as /// "the period is full": the free slot is coming back, and a caller that /// treats this as full would replace a live slot for nothing. #[test] fn an_excluded_free_slot_is_not_reported_as_a_full_period() { - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); // Only seq 9 is free, and the caller already excluded it. let mut entries: Vec = (0..SLOTS - 1).map(|_| slot_entry([0x99; 32])).collect(); entries.push("null".to_string()); @@ -952,7 +923,7 @@ mod tests { let selection = futures::executor::block_on(scan_slot_excluding( &rpc, - &metadata, + metadata, SlotScan { collection: PersonhoodCollection::LitePeople, entropy: [0x11; 32], @@ -1190,13 +1161,13 @@ mod tests { const PERIOD: u32 = 7; const SUFFIX: &[u8] = b"previewnet"; - let metadata = Metadata::decode(FIXTURE).unwrap(); + let metadata = test_fixtures::people(); 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, + metadata, ENTROPY, SUFFIX, PERIOD, 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 fc28cacb2..5fb127dd3 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/test_fixtures.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/test_fixtures.rs @@ -30,6 +30,40 @@ pub(crate) fn asset_hub() -> &'static Metadata { &ASSET_HUB } +/// The `Resources` budgets the allowance tests are written against, keyed by +/// the view function that serves each one. +/// +/// Priming them keeps a test's scripted RPC list about the slot table it is +/// exercising: without this every scan would also have to script the budget +/// view calls, so adding one read would renumber every later response. +const PEOPLE_RESOURCE_BUDGETS: [(&str, u32); 5] = [ + ("get_stmt_store_slots_per_period", 20), + ("get_lite_stmt_store_slots_per_period", 10), + ("get_stmt_store_replacement_cooldown", 60), + ("get_stmt_store_grace_window", 3600), + ("get_long_term_storage_claims_per_period", 10), +]; + +/// People-chain V16 metadata with [`PEOPLE_RESOURCE_BUDGETS`] already resolved. +static PEOPLE: LazyLock = LazyLock::new(|| { + let metadata = Metadata::decode(include_bytes!( + "../../../tests/fixtures/paseo-next-v2-metadata-v16.scale" + )) + .expect("the committed People fixture decodes"); + for (function, value) in PEOPLE_RESOURCE_BUDGETS { + let definition = metadata + .view_function("Resources", function) + .unwrap_or_else(|| panic!("the People fixture declares Resources.{function}")); + metadata.cache_view_u32(definition.id, value); + } + metadata +}); + +/// Borrow the decoded People fixture. +pub(crate) fn people() -> &'static Metadata { + &PEOPLE +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/view.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/view.rs index 3b4785874..071dff062 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/view.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/view.rs @@ -157,24 +157,13 @@ pub(super) async fn read_resource_u32( rpc: &RpcClient, metadata: &Metadata, function: &'static str, - fallback_constant: &'static str, ) -> Result { - if metadata.has_view_function("Resources", function) { - read_u32(rpc, metadata, "Resources", function).await - } else { - metadata.constant_u32("Resources", fallback_constant) - } + read_u32(rpc, metadata, "Resources", function).await } -pub(super) fn supports_resource_u32( - metadata: &Metadata, - function: &'static str, - fallback_constant: &'static str, -) -> bool { +pub(super) fn supports_resource_u32(metadata: &Metadata, function: &'static str) -> bool { let Some(definition) = metadata.view_function("Resources", function) else { - return metadata - .constant_u32("Resources", fallback_constant) - .is_ok(); + return false; }; definition.inputs == 0 && matches!( @@ -396,12 +385,10 @@ mod tests { assert!(!supports_resource_u32( &metadata, "get_stmt_store_slots_per_period", - "StmtStoreSlotsPerPeriod", )); assert!(supports_resource_u32( &metadata, "get_lite_stmt_store_slots_per_period", - "LiteStmtStoreSlotsPerPeriod", )); } } diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 516e143aa..62484a53d 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -139,7 +139,8 @@ pub(crate) struct StubPlatform { pub(crate) sent_rpc: Arc>>, pub(crate) rpc_responses: Vec, /// Responses keyed by JSON-RPC method, answered as each request arrives with - /// that request's own id echoed back. + /// that request's own id echoed back. A `state_call` is keyed by the runtime + /// API it names instead, so metadata and view-function reads stay separable. /// /// Unlike `rpc_responses` this assumes nothing about request order and waits /// indefinitely for the next request, so a slow step between two requests @@ -1306,7 +1307,26 @@ impl JsonRpcConnection for RecordingConnection { fn close(&self) {} } -/// Answer each request as it arrives, by method, echoing its id. +/// The scripting key for one request: a `state_call` is keyed by the runtime API +/// it names, every other method by its own name. +/// +/// A path that reads both metadata and a view function issues both through +/// `state_call`, so keying those two apart is what lets a script answer them +/// differently. +fn response_key(request: &serde_json::Value) -> Option<&str> { + let method = request["method"].as_str()?; + if method == "state_call" { + return Some(request["params"][0].as_str().unwrap_or(method)); + } + Some(method) +} + +/// Answer each request as it arrives, keyed by [`response_key`], echoing its id. +/// +/// Repeated entries for one key 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. /// @@ -1330,29 +1350,29 @@ fn method_keyed_responses( let value: serde_json::Value = serde_json::from_str(&request).expect("request is valid JSON"); let id = value["id"].as_str().expect("request carries a string id"); - let method = value["method"].as_str().expect("request carries a method"); + let key = response_key(&value).expect("request carries a method"); let occurrence = sent .lock() .expect("rpc list mutex poisoned") .iter() .take(answered) - .filter(|request| { - serde_json::from_str::(request) + .filter(|earlier| { + serde_json::from_str::(earlier) .ok() - .and_then(|value| value["method"].as_str().map(str::to_owned)) - .is_some_and(|candidate| candidate == method) + .and_then(|earlier| response_key(&earlier).map(str::to_owned)) + .is_some_and(|candidate| candidate == key) }) .count(); let scripted = answers .iter() - .filter(|(candidate, _)| *candidate == method) + .filter(|(candidate, _)| *candidate == key) .collect::>(); let result = scripted .get(occurrence) .map(|(_, body)| body.clone()) .unwrap_or_else(|| { panic!( - "method `{method}` was called {} times, and the script has {} response(s) for it", + "`{key}` was called {} times, and the script has {} response(s) for it", occurrence + 1, scripted.len(), ) @@ -1370,7 +1390,7 @@ fn method_keyed_responses( #[test] #[should_panic( - expected = "method `state_getStorage` was called 2 times, and the script has 1 response(s) for it" + expected = "`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; diff --git a/rust/crates/truapi-server/tests/fixtures/README.md b/rust/crates/truapi-server/tests/fixtures/README.md index 45bda954f..1f4bd4af1 100644 --- a/rust/crates/truapi-server/tests/fixtures/README.md +++ b/rust/crates/truapi-server/tests/fixtures/README.md @@ -12,7 +12,7 @@ expects. | File | Chain | Metadata | Spec | Captured | Declares | |---|---|---|---|---|---| | `paseo-next-v2-metadata.scale` | Paseo Next v2 | V14 | | | `AsResources`, three-field allowance info | -| `paseo-next-v2-metadata-v16.scale` | Paseo Next v2 | V16 | 1000032 | | `AsResources`, four-field allowance info | +| `paseo-next-v2-metadata-v16.scale` | Paseo Next v2 | V16 | 3000000 | 2026-09-03 | `AsResources`, four-field allowance info; `Resources` slot budgets as view functions | | `paseo-next-asset-hub-metadata.scale` | Paseo Asset Hub Next | V16 | 3000000 | 2026-09-03 | `AsPgas`, `Pgas`, `MembersSubscriber` incl. `CurrentGeneration` | | `bulletin_paseo_metadata.scale` | Polkadot Bulletin (Paseo) | V14 | 1000020 | | preimage and storage calls | diff --git a/rust/crates/truapi-server/tests/fixtures/paseo-next-v2-metadata-v16.scale b/rust/crates/truapi-server/tests/fixtures/paseo-next-v2-metadata-v16.scale index efb183e51..a12e5b488 100644 Binary files a/rust/crates/truapi-server/tests/fixtures/paseo-next-v2-metadata-v16.scale and b/rust/crates/truapi-server/tests/fixtures/paseo-next-v2-metadata-v16.scale differ