From 88c6989e94b83ca9fd3723e76d89b7de3eba377c Mon Sep 17 00:00:00 2001 From: pgherveou Date: Sun, 5 Jul 2026 12:40:35 +0200 Subject: [PATCH 01/13] wip --- .../truapi-host-wasm/src/adapter-support.ts | 4 +- .../src/host-callbacks-adapter.test.ts | 15 +- js/packages/truapi-host-wasm/src/index.ts | 29 +- js/packages/truapi-host-wasm/src/runtime.ts | 18 +- .../truapi-host-wasm/src/test-support.ts | 43 +- .../src/web/create-worker-host-runtime.ts | 4 +- .../src/web/worker-provider.test.ts | 20 +- rust/crates/truapi-codegen/src/main.rs | 53 +- .../truapi-codegen/src/platform_callbacks.rs | 181 ++++ rust/crates/truapi-codegen/src/rust.rs | 17 + .../truapi-codegen/src/rust/wasm_bridge.rs | 842 ++++++++++++++++++ .../truapi-codegen/src/ts/host_callbacks.rs | 352 +++++--- .../tests/golden/host-callbacks-adapter.ts | 81 +- .../tests/golden/host-callbacks.ts | 44 +- .../tests/golden/wasm_bridge.rs | 300 +++++++ .../tests/golden/worker-callbacks.ts | 5 +- .../truapi-codegen/tests/golden_rust_emit.rs | 44 +- rust/crates/truapi-platform/src/lib.rs | 4 +- rust/crates/truapi-server/src/wasm.rs | 473 +--------- .../src/wasm/generated_bridge.rs | 300 +++++++ scripts/codegen.sh | 3 + 21 files changed, 2156 insertions(+), 676 deletions(-) create mode 100644 rust/crates/truapi-codegen/src/platform_callbacks.rs create mode 100644 rust/crates/truapi-codegen/src/rust/wasm_bridge.rs create mode 100644 rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs create mode 100644 rust/crates/truapi-server/src/wasm/generated_bridge.rs diff --git a/js/packages/truapi-host-wasm/src/adapter-support.ts b/js/packages/truapi-host-wasm/src/adapter-support.ts index ec4a2052..b2b999e7 100644 --- a/js/packages/truapi-host-wasm/src/adapter-support.ts +++ b/js/packages/truapi-host-wasm/src/adapter-support.ts @@ -8,7 +8,7 @@ import { type GenericError, type Result } from "@parity/truapi"; import { hexToBytes } from "@parity/truapi/scale"; import type { ChainConnect, ChainConnection } from "./runtime.js"; -import type { HostCallbacks } from "./generated/host-callbacks.js"; +import type { ChainProvider } from "./generated/host-callbacks.js"; type WireResult = | { success: true; value: T } @@ -106,7 +106,7 @@ export function driveResultStream( * `send`/`close`. */ export function chainConnectAdapter( - host: Pick, + host: Pick, ): ChainConnect { return async (genesisHash, onResponse): Promise => { const connection = await host.connect(hexToBytes(genesisHash)); diff --git a/js/packages/truapi-host-wasm/src/host-callbacks-adapter.test.ts b/js/packages/truapi-host-wasm/src/host-callbacks-adapter.test.ts index c6eaac01..f70a5f5d 100644 --- a/js/packages/truapi-host-wasm/src/host-callbacks-adapter.test.ts +++ b/js/packages/truapi-host-wasm/src/host-callbacks-adapter.test.ts @@ -15,14 +15,13 @@ import { import type { HostSignPayloadData } from "@parity/truapi"; import { createWasmRawCallbacks } from "./generated/host-callbacks-adapter.js"; -import { CoreStorageKey, UserConfirmationReview } from "./generated/host-callbacks.js"; +import { AuthState, CoreStorageKey, UserConfirmationReview } from "./generated/host-callbacks.js"; import { makeHostCallbacks, settle } from "./test-support.js"; // The generated `createWasmRawCallbacks` adapter speaks the symmetric SCALE // byte boundary: codec-typed requests arrive as `Uint8Array` and are decoded // for the typed host callback; codec-typed responses are SCALE-encoded back to -// `Uint8Array`. Primitives, strings, byte blobs and the local `AuthState` pass -// through unchanged. +// `Uint8Array`. Primitives, strings and byte blobs pass through unchanged. const GENESIS = `0x${"11".repeat(32)}` as `0x${string}`; const PRODUCT_ACCOUNT = { @@ -195,10 +194,12 @@ describe("createWasmRawCallbacks", () => { preimageEvents.push(value ? [...value] : null), ); - raw.authStateChanged?.({ - tag: "Pairing", - value: { deeplink: "polkadotapp://example" }, - }); + raw.authStateChanged?.( + AuthState.enc({ + tag: "Pairing", + value: { deeplink: "polkadotapp://example" }, + }), + ); const authSessionKey = CoreStorageKey.enc({ tag: "AuthSession" }); expect(await raw.readCoreStorage!(authSessionKey)).toEqual(new Uint8Array([1, 2, 3])); await raw.writeCoreStorage!(authSessionKey, new Uint8Array([3, 2, 1])); diff --git a/js/packages/truapi-host-wasm/src/index.ts b/js/packages/truapi-host-wasm/src/index.ts index 60127c6b..b3744f17 100644 --- a/js/packages/truapi-host-wasm/src/index.ts +++ b/js/packages/truapi-host-wasm/src/index.ts @@ -1,30 +1,3 @@ export type { Payload, ProtocolMessage, WireProvider } from "@parity/truapi"; -export { encodeCoreStorageKey } from "./runtime.js"; - -export type { - AuthState, - Awaitable, - ChainConnect, - ChainConnection, - ChainProvider, - CoreAdmin, - CoreStorage, - CoreStorageKey, - Features, - HostCallbacks, - LogLevel, - Navigation, - Notifications, - PairingHostAdmin, - PermissionAuthorizationRequest, - PermissionAuthorizationStatus, - Permissions, - PreimageHost, - ProductStorage, - PlatformJsonRpcConnection, - SessionUiInfo, - ThemeHost, - TrUApiProductProvider, - ProductRuntimeConfig, -} from "./runtime.js"; +export * from "./runtime.js"; diff --git a/js/packages/truapi-host-wasm/src/runtime.ts b/js/packages/truapi-host-wasm/src/runtime.ts index 7bd80d2a..d91872eb 100644 --- a/js/packages/truapi-host-wasm/src/runtime.ts +++ b/js/packages/truapi-host-wasm/src/runtime.ts @@ -11,25 +11,9 @@ import type { // typed wrappers (`HostDevicePermissionRequest`, etc.) rather than raw // SCALE bytes. The web worker pairing-host runtime adapts this typed surface // into the byte-oriented callback bridge consumed by the WASM core. +export * from "./generated/host-callbacks.js"; export type { - AuthState, - ChainProvider, - CoreAdmin, - CoreStorage, - CoreStorageKey, - Features, - HostCallbacks, JsonRpcConnection as PlatformJsonRpcConnection, - Navigation, - Notifications, - PairingHostAdmin, - PermissionAuthorizationRequest, - PermissionAuthorizationStatus, - Permissions, - PreimageHost, - ProductStorage, - SessionUiInfo, - ThemeHost, } from "./generated/host-callbacks.js"; /** Encode a typed core-storage slot for hosts that need an opaque backing key. */ diff --git a/js/packages/truapi-host-wasm/src/test-support.ts b/js/packages/truapi-host-wasm/src/test-support.ts index 7f5c5cde..346b180f 100644 --- a/js/packages/truapi-host-wasm/src/test-support.ts +++ b/js/packages/truapi-host-wasm/src/test-support.ts @@ -1,13 +1,18 @@ -import type { HostCallbacks } from "./generated/host-callbacks.js"; +import type { + FlatHostCallbacks, + RequiredHostCallbacks, +} from "./generated/host-callbacks.js"; /** `HostCallbacks` with every optional member required, for exhaustive test fixtures. */ -export type CompleteHostCallbacks = Required; +export type CompleteHostCallbacks = RequiredHostCallbacks; + +type FlatHostCallbackOverrides = Partial>; /** Default no-op host callbacks with optional per-test overrides. */ export function makeHostCallbacks( - overrides: Partial = {}, + overrides: FlatHostCallbackOverrides = {}, ): CompleteHostCallbacks { - return { + const flat: Required = { navigateTo: async () => {}, pushNotification: async () => ({ id: 0 }), cancelNotification: async () => {}, @@ -32,6 +37,36 @@ export function makeHostCallbacks( }), ...overrides, }; + return { + navigation: { navigateTo: flat.navigateTo }, + notifications: { + pushNotification: flat.pushNotification, + cancelNotification: flat.cancelNotification, + }, + permissions: { + devicePermission: flat.devicePermission, + remotePermission: flat.remotePermission, + }, + features: { featureSupported: flat.featureSupported }, + productStorage: { + read: flat.read, + write: flat.write, + clear: flat.clear, + }, + coreStorage: { + readCoreStorage: flat.readCoreStorage, + writeCoreStorage: flat.writeCoreStorage, + clearCoreStorage: flat.clearCoreStorage, + }, + auth: { authStateChanged: flat.authStateChanged }, + userConfirmation: { confirmUserAction: flat.confirmUserAction }, + preimage: { + submitPreimage: flat.submitPreimage, + lookupPreimage: flat.lookupPreimage, + }, + theme: { subscribeTheme: flat.subscribeTheme }, + chain: { connect: flat.connect }, + }; } /** Resolve after the current microtask/immediate queue, letting pending async work run. */ diff --git a/js/packages/truapi-host-wasm/src/web/create-worker-host-runtime.ts b/js/packages/truapi-host-wasm/src/web/create-worker-host-runtime.ts index 61ce72d2..3116816d 100644 --- a/js/packages/truapi-host-wasm/src/web/create-worker-host-runtime.ts +++ b/js/packages/truapi-host-wasm/src/web/create-worker-host-runtime.ts @@ -1,10 +1,10 @@ import type { ChainConnection, - HostCallbacks, ProductRuntimeConfig, LogLevel, PermissionAuthorizationRequest, PermissionAuthorizationStatus, + RequiredHostCallbacks, TrUApiProductProvider, } from "../index.js"; import { PermissionAuthorizationRequest as PermissionAuthorizationRequestCodec } from "../generated/host-callbacks.js"; @@ -457,7 +457,7 @@ export interface CreateWebWorkerPairingHostRuntimeOptions { initTimeoutMs?: number; } -export type WebWorkerHostCallbacks = Required; +export type WebWorkerHostCallbacks = RequiredHostCallbacks; export function createWebWorkerPairingHostRuntime( worker: Worker, diff --git a/js/packages/truapi-host-wasm/src/web/worker-provider.test.ts b/js/packages/truapi-host-wasm/src/web/worker-provider.test.ts index 540820ae..e0e6a3f8 100644 --- a/js/packages/truapi-host-wasm/src/web/worker-provider.test.ts +++ b/js/packages/truapi-host-wasm/src/web/worker-provider.test.ts @@ -5,8 +5,8 @@ import { HostPushNotificationRequest, HostPushNotificationResponse } from "@pari import type { GenericError, Result, ThemeVariant } from "@parity/truapi"; import { createWasmRawCallbacks } from "../generated/host-callbacks-adapter.js"; -import { CoreStorageKey } from "../generated/host-callbacks.js"; -import type { AuthState, HostCallbacks } from "../generated/host-callbacks.js"; +import { AuthState, CoreStorageKey } from "../generated/host-callbacks.js"; +import type { AuthState as AuthStateValue, PreimageHost } from "../generated/host-callbacks.js"; import type { ProductRuntimeConfig, TrUApiProductProvider } from "../runtime.js"; import { makeHostCallbacks, settle } from "../test-support.js"; import { createWebWorkerPairingHostRuntime } from "./index.js"; @@ -429,7 +429,7 @@ describe("createWebWorkerPairingHostRuntime", () => { it("forwards authStateChanged callback requests", async () => { const worker = new FakeWorker(); - const states: AuthState[] = []; + const states: AuthStateValue[] = []; const providerPromise = createProviderFromRuntime( asWorker(worker), makeHostCallbacks({ @@ -442,19 +442,21 @@ describe("createWebWorkerPairingHostRuntime", () => { worker.emit({ kind: "loaded" }); worker.emit({ kind: "ready" }); const provider = await finishProviderReady(worker, providerPromise); + const publicKey = new Uint8Array(32); + publicKey.set([1, 2]); worker.emit({ kind: "callbackRequest", requestId: 3, name: "authStateChanged", args: [ - { + AuthState.enc({ tag: "Connected", value: { - publicKey: new Uint8Array([1, 2]), + publicKey, liteUsername: "alice", }, - }, + }), ], }); await settle(); @@ -463,7 +465,7 @@ describe("createWebWorkerPairingHostRuntime", () => { { tag: "Connected", value: { - publicKey: new Uint8Array([1, 2]), + publicKey, liteUsername: "alice", }, }, @@ -684,7 +686,7 @@ describe("createWebWorkerPairingHostRuntime", () => { lookupPreimage: (() => { preimageStarts += 1; return () => {}; - }) as unknown as HostCallbacks["lookupPreimage"], + }) as unknown as PreimageHost["lookupPreimage"], }), { runtimeConfig: runtimeConfig() }, ); @@ -714,7 +716,7 @@ describe("createWebWorkerPairingHostRuntime", () => { lookupPreimage: (() => { preimageStarts += 1; return () => {}; - }) as unknown as HostCallbacks["lookupPreimage"], + }) as unknown as PreimageHost["lookupPreimage"], }), { runtimeConfig: runtimeConfig() }, ); diff --git a/rust/crates/truapi-codegen/src/main.rs b/rust/crates/truapi-codegen/src/main.rs index 42a5aa1e..d4927a61 100644 --- a/rust/crates/truapi-codegen/src/main.rs +++ b/rust/crates/truapi-codegen/src/main.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use std::str::FromStr; mod platform; +mod platform_callbacks; mod rust; mod rustdoc; mod ts; @@ -72,6 +73,11 @@ struct Cli { #[arg(long)] platform_wasm_adapter_output: Option, + /// Output directory for the generated Rust WASM platform bridge. + /// Only honored when `--platform-input` is also set. + #[arg(long)] + platform_rust_output: Option, + /// Output directory for generated explorer metadata (optional). When set, /// writes `codegen/types.ts` with the DataType list consumed by the /// explorer site. @@ -145,33 +151,44 @@ fn main() -> Result<()> { .with_context(|| format!("writing Rust dispatcher to {}", path.display()))?; println!("Wrote Rust dispatcher to {}", path.display()); } - if let (Some(input), Some(output)) = (&cli.platform_input, &cli.platform_ts_output) { + if let Some(input) = &cli.platform_input { + if cli.platform_wasm_adapter_output.is_some() && cli.platform_ts_output.is_none() { + anyhow::bail!("--platform-wasm-adapter-output requires --platform-ts-output"); + } let json = std::fs::read_to_string(input) .with_context(|| format!("reading platform rustdoc JSON from {input}"))?; let krate = rustdoc::parse(&json).with_context(|| format!("parsing platform rustdoc {input}"))?; let definition = platform::extract(&krate) .with_context(|| format!("extracting platform definition from {input}"))?; - let codec_types = api - .types - .iter() - .filter(|t| !matches!(t.kind, rustdoc::TypeDefKind::Alias(_))) - .map(|t| t.name.clone()) - .collect(); - let adapter_output = cli - .platform_wasm_adapter_output - .as_deref() - .unwrap_or(output.as_str()); - ts::generate_host_callbacks(&definition, &codec_types, output, adapter_output) - .with_context(|| format!("writing host callbacks TS to {output}"))?; - println!("Generated typed HostCallbacks TS surface in {output}"); - println!("Generated WASM HostCallbacks adapter in {adapter_output}"); - } else if cli.platform_input.is_some() != cli.platform_ts_output.is_some() + if let Some(output) = &cli.platform_ts_output { + let codec_types = api + .types + .iter() + .filter(|t| !matches!(t.kind, rustdoc::TypeDefKind::Alias(_))) + .map(|t| t.name.clone()) + .collect(); + let adapter_output = cli + .platform_wasm_adapter_output + .as_deref() + .unwrap_or(output.as_str()); + ts::generate_host_callbacks(&definition, &codec_types, output, adapter_output) + .with_context(|| format!("writing host callbacks TS to {output}"))?; + println!("Generated typed HostCallbacks TS surface in {output}"); + println!("Generated WASM HostCallbacks adapter in {adapter_output}"); + } + if let Some(output) = &cli.platform_rust_output { + rust::generate_wasm_bridge_file(&definition, &api, output) + .with_context(|| format!("writing Rust WASM bridge to {}", output.display()))?; + println!("Generated Rust WASM bridge in {}", output.display()); + } + } else if cli.platform_ts_output.is_some() || cli.platform_wasm_adapter_output.is_some() + || cli.platform_rust_output.is_some() { anyhow::bail!( - "--platform-input and --platform-ts-output must be provided together; \ - --platform-wasm-adapter-output additionally requires both" + "--platform-input is required for platform output; \ + --platform-wasm-adapter-output additionally requires --platform-ts-output" ); } if let Some(path) = &cli.explorer_output { diff --git a/rust/crates/truapi-codegen/src/platform_callbacks.rs b/rust/crates/truapi-codegen/src/platform_callbacks.rs new file mode 100644 index 00000000..98a33f65 --- /dev/null +++ b/rust/crates/truapi-codegen/src/platform_callbacks.rs @@ -0,0 +1,181 @@ +//! Shared callback naming and selection rules for generated platform bridges. + +use std::collections::BTreeSet; + +use crate::platform::{PlatformDefinition, PlatformInner, PlatformMethod, PlatformTrait}; +use crate::rustdoc::TypeRef; + +pub(crate) fn composed_traits(definition: &PlatformDefinition) -> Vec<&PlatformTrait> { + let composed: BTreeSet = match &definition.super_trait { + Some(s) => s.composes.iter().cloned().collect(), + None => definition.traits.iter().map(|t| t.name.clone()).collect(), + }; + definition + .traits + .iter() + .filter(|t| composed.contains(&t.name)) + .collect() +} + +pub(crate) fn raw_callback_name(method: &PlatformMethod) -> String { + to_camel_case(&method.name) +} + +pub(crate) fn platform_trait_names(definition: &PlatformDefinition) -> BTreeSet { + definition.traits.iter().map(|t| t.name.clone()).collect() +} + +pub(crate) fn trait_object_return_name<'a>( + method: &'a PlatformMethod, + platform_trait_names: &BTreeSet, +) -> Option<&'a str> { + match &method.return_shape.inner { + PlatformInner::TraitObject(name) => Some(name.as_str()), + PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { + named_platform_trait(ok, platform_trait_names) + } + PlatformInner::Unit | PlatformInner::Stream(_) => None, + } +} + +pub(crate) fn raw_callback_wire_name( + trait_def: &PlatformTrait, + method: &PlatformMethod, + platform_trait_names: &BTreeSet, +) -> String { + let raw = raw_callback_name(method); + if trait_object_return_name(method, platform_trait_names).is_some() { + return format!( + "{}{}", + callback_namespace(&trait_def.name), + upper_first(&raw) + ); + } + raw +} + +pub(crate) fn raw_callback_field_name( + trait_def: &PlatformTrait, + method: &PlatformMethod, + platform_trait_names: &BTreeSet, +) -> String { + snake_case(&raw_callback_wire_name( + trait_def, + method, + platform_trait_names, + )) +} + +pub(crate) fn raw_callback_type_name( + trait_def: &PlatformTrait, + method: &PlatformMethod, + platform_trait_names: &BTreeSet, +) -> String { + upper_first(&raw_callback_wire_name( + trait_def, + method, + platform_trait_names, + )) +} + +pub(crate) fn raw_callback_adapter_name( + trait_def: &PlatformTrait, + method: &PlatformMethod, + platform_trait_names: &BTreeSet, +) -> String { + format!( + "{}Adapter", + raw_callback_wire_name(trait_def, method, platform_trait_names) + ) +} + +pub(crate) fn callback_namespace(trait_name: &str) -> String { + let stem = ["Provider", "Presenter", "Host"] + .into_iter() + .find_map(|suffix| trait_name.strip_suffix(suffix)) + .unwrap_or(trait_name); + lower_pascal_case(stem) +} + +fn named_platform_trait<'a>( + ty: &'a TypeRef, + platform_trait_names: &BTreeSet, +) -> Option<&'a str> { + let TypeRef::Named { name, args } = ty else { + return None; + }; + if args.is_empty() && platform_trait_names.contains(name) { + return Some(name.as_str()); + } + None +} + +/// Unwrap a `Result` stream item to its `T`; other item types pass +/// through. Streams carry `Result`s on the Rust side but the JS raw bridge +/// already unwraps them before handing each item to the WASM callback sink. +pub(crate) fn stream_item(item: &TypeRef) -> &TypeRef { + if let TypeRef::Named { name, args } = item + && name == "Result" + && let Some(ok) = args.first() + { + return ok; + } + item +} + +pub(crate) fn to_camel_case(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + let mut upper_next = false; + for (idx, ch) in name.chars().enumerate() { + if ch == '_' { + upper_next = idx != 0; + continue; + } + if upper_next { + out.extend(ch.to_uppercase()); + upper_next = false; + } else { + out.push(ch); + } + } + out +} + +fn lower_pascal_case(name: &str) -> String { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + format!( + "{}{}", + first.to_ascii_lowercase(), + chars.collect::() + ) +} + +fn upper_first(name: &str) -> String { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + format!( + "{}{}", + first.to_ascii_uppercase(), + chars.collect::() + ) +} + +pub(crate) fn snake_case(name: &str) -> String { + let mut out = String::with_capacity(name.len() + 4); + for (idx, ch) in name.chars().enumerate() { + if ch.is_ascii_uppercase() { + if idx != 0 { + out.push('_'); + } + out.push(ch.to_ascii_lowercase()); + } else { + out.push(ch); + } + } + out +} diff --git a/rust/crates/truapi-codegen/src/rust.rs b/rust/crates/truapi-codegen/src/rust.rs index 1ca6f77c..c7878829 100644 --- a/rust/crates/truapi-codegen/src/rust.rs +++ b/rust/crates/truapi-codegen/src/rust.rs @@ -11,12 +11,15 @@ use anyhow::Result; use convert_case::{Case, Casing}; +use crate::platform::PlatformDefinition; use crate::rustdoc::*; mod dispatcher; +mod wasm_bridge; mod wire_table; pub use dispatcher::generate_dispatcher; +pub use wasm_bridge::generate_wasm_bridge; pub use wire_table::generate_wire_table; /// Generates the Rust wire dispatcher and wire-table sources into `output_dir`. @@ -29,6 +32,20 @@ pub fn generate(api: &ApiDefinition, output_dir: &Path) -> Result<()> { Ok(()) } +/// Generates the Rust wasm-bindgen platform bridge source into `output_dir`. +pub fn generate_wasm_bridge_file( + definition: &PlatformDefinition, + api: &ApiDefinition, + output_dir: &Path, +) -> Result<()> { + fs::create_dir_all(output_dir)?; + fs::write( + output_dir.join("generated_bridge.rs"), + generate_wasm_bridge(definition, api)?, + )?; + Ok(()) +} + /// Trait -> versioned-module mapping. Trait names are PascalCase /// (`JsonRpc`, `LocalStorage`); module names are snake_case /// (`jsonrpc`, `local_storage`). The mapping is irregular enough diff --git a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs new file mode 100644 index 00000000..88c05435 --- /dev/null +++ b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs @@ -0,0 +1,842 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write; + +use anyhow::{Result, bail}; +use indoc::{formatdoc, writedoc}; + +use crate::platform::{PlatformDefinition, PlatformInner, PlatformMethod, PlatformTrait}; +use crate::platform_callbacks::{ + composed_traits, platform_trait_names, raw_callback_field_name, raw_callback_name, + raw_callback_wire_name, snake_case, stream_item, trait_object_return_name, +}; +use crate::rustdoc::{ApiDefinition, TypeDef, TypeDefKind, TypeRef, VariantFields}; + +pub fn generate_wasm_bridge( + definition: &PlatformDefinition, + api: &ApiDefinition, +) -> Result { + let ctx = BridgeCtx::new(definition, api); + let traits = composed_traits(definition); + let trait_names = platform_trait_names(definition); + validate_errors(&traits, &ctx)?; + let mut out = String::new(); + writedoc!( + out, + r#" + //! Auto-generated by truapi-codegen. Do not edit. + //! + //! Mechanical wasm-bindgen callback bridge derived from + //! `truapi-platform`. Raw callback names and payload shapes match the + //! generated TypeScript host-callback adapter. + + use futures::stream::BoxStream; + use js_sys::{{Function, Uint8Array}}; + use parity_scale_codec::Encode; + use truapi::v01; + use wasm_bindgen::JsValue; + + use super::{{ + WasmPlatform, call_js_function, decode_bytes, decode_js_item, generic, get_function, + invoke_bool, invoke_bytes_return, invoke_js_subscription, invoke_optional_bytes_return, + invoke_unit, parse_optional_bytes_item, + }}; + + /// JS-side callbacks invoked by the wasm platform bridge. Methods with + /// Rust default bodies are still required here because the generated TS + /// adapter resolves optional host callbacks before constructing this + /// raw callback object. + pub(super) struct JsBridge {{ + "#, + ) + .unwrap(); + + for field in bridge_fields(&traits, &trait_names) { + writeln!(out, " pub(super) {}: Function,", field.field_name).unwrap(); + } + out.push_str("}\n\n"); + + writedoc!( + out, + r#" + impl JsBridge {{ + pub(super) fn from_js(callbacks: &JsValue) -> Result {{ + Ok(Self {{ + "#, + ) + .unwrap(); + for field in bridge_fields(&traits, &trait_names) { + writeln!( + out, + " {}: get_function(callbacks, \"{}\")?,", + field.field_name, field.raw_name + ) + .unwrap(); + } + out.push_str(" })\n }\n}\n\n"); + + let mut parse_fns = BTreeMap::new(); + for trait_def in traits { + if requires_manual_trait_impl(trait_def, &trait_names) { + continue; + } + out.push_str(&emit_trait_impl(trait_def, &ctx, &mut parse_fns)?); + out.push('\n'); + } + for (idx, (_name, body)) in parse_fns.into_iter().enumerate() { + if idx != 0 { + out.push('\n'); + } + out.push_str(body.trim_end()); + out.push('\n'); + } + + Ok(out) +} + +struct BridgeCtx<'a> { + api_types: BTreeMap<&'a str, &'a TypeDef>, + codec_types: BTreeSet<&'a str>, + local_types: BTreeSet<&'a str>, + local_codec_types: BTreeSet<&'a str>, +} + +impl<'a> BridgeCtx<'a> { + fn new(definition: &'a PlatformDefinition, api: &'a ApiDefinition) -> Self { + let api_types = api + .types + .iter() + .map(|ty| (ty.name.as_str(), ty)) + .collect::>(); + let codec_types = api + .types + .iter() + .filter(|ty| !matches!(ty.kind, TypeDefKind::Alias(_))) + .map(|ty| ty.name.as_str()) + .collect::>(); + let local_types = definition + .types + .iter() + .map(|ty| ty.name.as_str()) + .collect::>(); + let local_codec_types = collect_local_bridge_payload_types(definition); + Self { + api_types, + codec_types, + local_types, + local_codec_types, + } + } + + fn is_api_codec(&self, ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Named { name, .. } if self.codec_types.contains(name.as_str())) + } + + fn is_local_codec(&self, ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Named { name, .. } if self.local_codec_types.contains(name.as_str())) + } + + fn alias_primitive(&self, ty: &TypeRef) -> Option<&'a str> { + let TypeRef::Named { name, .. } = ty else { + return None; + }; + let mut seen = BTreeSet::new(); + let mut current = name.as_str(); + loop { + if !seen.insert(current) { + return None; + } + let TypeDefKind::Alias(target) = &self.api_types.get(current)?.kind else { + return None; + }; + match target { + TypeRef::Primitive(primitive) => return Some(primitive.as_str()), + TypeRef::Named { name, .. } => current = name, + _ => return None, + } + } + } +} + +struct BridgeField { + field_name: String, + raw_name: String, +} + +fn bridge_fields( + traits: &[&PlatformTrait], + platform_trait_names: &BTreeSet, +) -> Vec { + let mut fields = Vec::new(); + for trait_def in traits { + for method in &trait_def.methods { + fields.push(BridgeField { + field_name: raw_callback_field_name(trait_def, method, platform_trait_names), + raw_name: raw_callback_wire_name(trait_def, method, platform_trait_names), + }); + } + } + fields +} + +fn requires_manual_trait_impl( + trait_def: &PlatformTrait, + platform_trait_names: &BTreeSet, +) -> bool { + trait_def + .methods + .iter() + .any(|method| trait_object_return_name(method, platform_trait_names).is_some()) +} + +fn emit_trait_impl( + trait_def: &PlatformTrait, + ctx: &BridgeCtx<'_>, + parse_fns: &mut BTreeMap, +) -> Result { + let mut out = String::new(); + if trait_def + .methods + .iter() + .any(|method| method.return_shape.is_async) + { + out.push_str("#[truapi_platform::async_trait]\n"); + } + writeln!( + out, + "impl truapi_platform::{} for WasmPlatform {{", + trait_def.name + ) + .unwrap(); + for (idx, method) in trait_def.methods.iter().enumerate() { + if idx != 0 { + out.push('\n'); + } + out.push_str(&emit_method(method, ctx, parse_fns)?); + } + out.push_str("}\n"); + Ok(out) +} + +fn emit_method( + method: &PlatformMethod, + ctx: &BridgeCtx<'_>, + parse_fns: &mut BTreeMap, +) -> Result { + match &method.return_shape.inner { + PlatformInner::Result { ok, err } => emit_result_method(method, ok, err, ctx), + PlatformInner::Stream(item) => emit_stream_method(method, item, ctx, parse_fns), + PlatformInner::Unit => emit_unit_method(method, ctx), + PlatformInner::Plain(ok) => emit_plain_method(method, ok, ctx), + PlatformInner::TraitObject(_) => bail!( + "trait-object platform method `{}` must be handled manually", + method.name + ), + } +} + +fn emit_result_method( + method: &PlatformMethod, + ok: &TypeRef, + err: &TypeRef, + ctx: &BridgeCtx<'_>, +) -> Result { + let raw = raw_callback_name(method); + let args = js_arg_vec(method, ctx)?; + let ret = format!("Result<{}, {}>", rust_type(ok, ctx)?, rust_type(err, ctx)?); + let params = rust_params(method, ctx)?; + let map_err = error_mapper(err, ctx)?; + + let body = if is_unit(ok) { + await_chain( + &bridge_call("invoke_unit", &method.name, &args, &[]), + &map_err, + ) + } else if is_bool(ok) { + await_chain( + &bridge_call("invoke_bool", &method.name, &args, &[]), + &map_err, + ) + } else if is_bytes(ok) { + await_chain( + &bridge_call("invoke_bytes_return", &method.name, &args, &[]), + &map_err, + ) + } else if is_optional_bytes(ok) { + await_chain( + &bridge_call( + "invoke_optional_bytes_return", + &method.name, + &args, + &[format!( + "{:?}", + format!("{raw} must resolve to Uint8Array, null or undefined") + )], + ), + &map_err, + ) + } else if ctx.is_api_codec(ok) || ctx.is_local_codec(ok) { + formatdoc_decode_result(method, ok, &raw, &args, &map_err, ctx)? + } else { + bail!("unsupported wasm bridge result type for `{raw}`: {ok:?}"); + }; + + Ok(format!( + "{header}\n{body}\n }}\n", + header = method_header("async ", &method.name, ¶ms, Some(&ret)), + body = indent_body(&body, 8), + )) +} + +fn emit_plain_method(method: &PlatformMethod, ok: &TypeRef, ctx: &BridgeCtx<'_>) -> Result { + if !is_unit(ok) { + bail!( + "unsupported non-async plain return for wasm bridge method `{}`", + method.name + ); + } + emit_unit_method(method, ctx) +} + +fn emit_unit_method(method: &PlatformMethod, ctx: &BridgeCtx<'_>) -> Result { + let args = js_arg_vec(method, ctx)?; + let params = rust_params(method, ctx)?; + let body = if method.return_shape.is_async { + format!( + "if let Err(reason) = {}\n .await\n{{\n web_sys::console::error_1(&JsValue::from_str(&reason));\n}}", + bridge_call("invoke_unit", &method.name, &args, &[]) + ) + } else { + let args = if args == "Vec::new()" { + "&[]".to_string() + } else { + format!("&{args}") + }; + format!( + "if let Err(reason) = {} {{\n web_sys::console::error_1(&JsValue::from_str(&reason));\n}}", + bridge_call("call_js_function", &method.name, &args, &[]) + ) + }; + Ok(format!( + "{header}\n{body}\n }}\n", + header = method_header( + if method.return_shape.is_async { + "async " + } else { + "" + }, + &method.name, + ¶ms, + None + ), + body = indent_body(&body, 8), + )) +} + +fn emit_stream_method( + method: &PlatformMethod, + item: &TypeRef, + ctx: &BridgeCtx<'_>, + parse_fns: &mut BTreeMap, +) -> Result { + let raw = raw_callback_name(method); + let TypeRef::Named { name, args } = item else { + bail!("stream `{raw}` must yield Result"); + }; + if name != "Result" || args.len() != 2 { + bail!("stream `{raw}` must yield Result"); + } + let ok = stream_item(item); + let err = &args[1]; + let ret = format!( + "BoxStream<'static, Result<{}, {}>>", + rust_type(ok, ctx)?, + rust_type(err, ctx)? + ); + let params = rust_params(method, ctx)?; + let payload = subscription_payload(method, ctx)?; + let parser = stream_parser(ok, ctx, parse_fns)?; + let body = if payload == "None" { + format!( + "invoke_js_subscription(&self.bridge.{}, None, {parser})", + method.name + ) + } else { + bridge_call( + "invoke_js_subscription", + &method.name, + &payload, + &[parser.to_string()], + ) + }; + Ok(format!( + "{header}\n{body}\n }}\n", + header = method_header("", &method.name, ¶ms, Some(&ret)), + body = indent_body(&body, 8), + )) +} + +fn formatdoc_decode_result( + method: &PlatformMethod, + ok: &TypeRef, + raw: &str, + args: &str, + map_err: &str, + ctx: &BridgeCtx<'_>, +) -> Result { + let ty = rust_type(ok, ctx)?; + let call = bridge_call("invoke_bytes_return", &method.name, args, &[]); + let await_bytes = await_chain(&call, &format!("{map_err}?")); + Ok(format!( + "let bytes = {await_bytes};\ndecode_bytes::<{ty}>(\n bytes,\n {:?},\n)\n{map_err}", + format!("{raw} response did not decode"), + )) +} + +fn rust_params(method: &PlatformMethod, ctx: &BridgeCtx<'_>) -> Result { + method + .params + .iter() + .map(|param| { + Ok(format!( + "{}: {}", + param.name, + rust_type(¶m.type_ref, ctx)? + )) + }) + .collect::>>() + .map(|params| params.join(", ")) +} + +fn method_header(keyword: &str, name: &str, params: &str, ret: Option<&str>) -> String { + let ret = ret.map(|ret| format!(" -> {ret}")).unwrap_or_default(); + if params.is_empty() { + return format!(" {keyword}fn {name}(&self){ret} {{"); + } + + let one_line = format!(" {keyword}fn {name}(&self, {params}){ret} {{"); + if one_line.len() <= 100 { + return one_line; + } + + let mut out = format!(" {keyword}fn {name}(\n &self,\n"); + for param in params.split(", ") { + writeln!(out, " {param},").unwrap(); + } + write!(out, " ){ret} {{").unwrap(); + out +} + +fn bridge_call(name: &str, field: &str, second_arg: &str, extra_args: &[String]) -> String { + let mut args = vec![format!("&self.bridge.{field}"), second_arg.to_string()]; + args.extend(extra_args.iter().cloned()); + let one_line = format!("{name}({})", args.join(", ")); + if !one_line.contains('\n') && one_line.len() <= 72 { + return one_line; + } + + let mut out = format!("{name}(\n &self.bridge.{field},\n"); + out.push_str(&indent_body(second_arg, 4)); + out.push_str(",\n"); + for arg in extra_args { + writeln!(out, " {arg},").unwrap(); + } + out.push(')'); + out +} + +fn await_chain(call: &str, map_err: &str) -> String { + if call.contains('\n') { + format!("{call}\n.await\n{map_err}") + } else { + format!("{call}\n .await\n {map_err}") + } +} + +fn rust_type(ty: &TypeRef, ctx: &BridgeCtx<'_>) -> Result { + match ty { + TypeRef::Primitive(name) if name == "String" || name == "str" => Ok("String".to_string()), + TypeRef::Primitive(name) => Ok(name.clone()), + TypeRef::Named { name, args } if name == "String" && args.is_empty() => { + Ok("String".to_string()) + } + TypeRef::Named { name, args } if name == "Result" && args.len() == 2 => Ok(format!( + "Result<{}, {}>", + rust_type(&args[0], ctx)?, + rust_type(&args[1], ctx)? + )), + TypeRef::Named { name, args } if ctx.api_types.contains_key(name.as_str()) => { + if args.is_empty() { + Ok(format!("v01::{name}")) + } else { + bail!("generic API type `{name}` is not supported in wasm bridge") + } + } + TypeRef::Named { name, args } if ctx.local_types.contains(name.as_str()) => { + if args.is_empty() { + Ok(format!("truapi_platform::{name}")) + } else { + bail!("generic platform type `{name}` is not supported in wasm bridge") + } + } + TypeRef::Named { name, .. } => Ok(name.clone()), + TypeRef::Vec(inner) if is_u8(inner) => Ok("Vec".to_string()), + TypeRef::Vec(inner) => Ok(format!("Vec<{}>", rust_type(inner, ctx)?)), + TypeRef::Option(inner) => Ok(format!("Option<{}>", rust_type(inner, ctx)?)), + TypeRef::Array(inner, len) => Ok(format!("[{}; {len}]", rust_type(inner, ctx)?)), + TypeRef::Tuple(items) if items.is_empty() => Ok("()".to_string()), + TypeRef::Tuple(items) => Ok(format!( + "({})", + items + .iter() + .map(|item| rust_type(item, ctx)) + .collect::>>()? + .join(", ") + )), + TypeRef::Generic(name) => Ok(name.clone()), + TypeRef::Unit => Ok("()".to_string()), + } +} + +fn js_arg_vec(method: &PlatformMethod, ctx: &BridgeCtx<'_>) -> Result { + let args = method + .params + .iter() + .map(|param| js_arg_expr(¶m.name, ¶m.type_ref, ctx)) + .collect::>>()?; + if args.is_empty() { + Ok("Vec::new()".to_string()) + } else if args.len() == 1 { + Ok(format!("vec![{}]", args[0])) + } else { + let mut out = "vec![\n".to_string(); + for arg in args { + writeln!(out, " {arg},").unwrap(); + } + out.push(']'); + Ok(out) + } +} + +fn js_arg_expr(name: &str, ty: &TypeRef, ctx: &BridgeCtx<'_>) -> Result { + if is_string(ty) { + return Ok(format!("JsValue::from_str(&{name})")); + } + if is_bytes(ty) { + return Ok(format!("Uint8Array::from({name}.as_slice()).into()")); + } + if ctx.is_api_codec(ty) || ctx.is_local_codec(ty) { + return Ok(format!( + "Uint8Array::from({name}.encode().as_slice()).into()" + )); + } + if let Some(primitive) = ctx.alias_primitive(ty) { + return numeric_js_arg(name, primitive); + } + if let TypeRef::Primitive(primitive) = ty { + return numeric_js_arg(name, primitive); + } + bail!("unsupported wasm bridge callback parameter `{name}`: {ty:?}") +} + +fn numeric_js_arg(name: &str, primitive: &str) -> Result { + match primitive { + "u8" | "u16" | "u32" | "i8" | "i16" | "i32" => { + Ok(format!("JsValue::from_f64(f64::from({name}))")) + } + "bool" => Ok(format!("JsValue::from_bool({name})")), + other => bail!("numeric callback parameter `{name}: {other}` is not JS-number safe"), + } +} + +fn subscription_payload(method: &PlatformMethod, ctx: &BridgeCtx<'_>) -> Result { + match method.params.as_slice() { + [] => Ok("None".to_string()), + [param] if is_bytes(¶m.type_ref) => Ok(format!("Some({})", param.name)), + [param] if ctx.is_api_codec(¶m.type_ref) || ctx.is_local_codec(¶m.type_ref) => { + Ok(format!("Some({}.encode())", param.name)) + } + [param] => bail!( + "unsupported subscription payload for `{}`: {:?}", + method.name, + param.type_ref + ), + _ => bail!( + "subscription `{}` has more than one payload parameter", + method.name + ), + } +} + +fn stream_parser( + ok: &TypeRef, + ctx: &BridgeCtx<'_>, + parse_fns: &mut BTreeMap, +) -> Result { + if is_optional_bytes(ok) { + return Ok("parse_optional_bytes_item".to_string()); + } + if ctx.is_api_codec(ok) || ctx.is_local_codec(ok) { + let ty = rust_type(ok, ctx)?; + let label = named_type_name(ok)?; + let fn_name = format!("parse_{}_item", snake_case(label)); + parse_fns.entry(fn_name.clone()).or_insert_with(|| { + formatdoc! { + r#" + fn {fn_name}(value: JsValue) -> Result<{ty}, String> {{ + decode_js_item::<{ty}>(value, "{label}") + }} + "#, + fn_name = fn_name, + ty = ty, + label = label, + } + }); + return Ok(fn_name); + } + bail!("unsupported stream item type: {ok:?}") +} + +fn error_mapper(err: &TypeRef, ctx: &BridgeCtx<'_>) -> Result { + let name = named_type_name(err)?; + if name == "GenericError" { + return Ok(".map_err(generic)".to_string()); + } + let err_ty = rust_type(err, ctx)?; + Ok(format!(".map_err(|reason| {err_ty}::Unknown {{ reason }})")) +} + +fn validate_errors(traits: &[&PlatformTrait], ctx: &BridgeCtx<'_>) -> Result<()> { + for trait_def in traits { + for method in &trait_def.methods { + match &method.return_shape.inner { + PlatformInner::Result { err, .. } => validate_error_type(err, ctx)?, + PlatformInner::Stream(item) => { + let TypeRef::Named { name, args } = item else { + bail!("stream `{}` must yield Result", method.name); + }; + if name == "Result" && args.len() == 2 { + validate_error_type(&args[1], ctx)?; + } + } + PlatformInner::Unit | PlatformInner::Plain(_) | PlatformInner::TraitObject(_) => {} + } + } + } + Ok(()) +} + +fn validate_error_type(err: &TypeRef, ctx: &BridgeCtx<'_>) -> Result<()> { + let name = named_type_name(err)?; + if name == "GenericError" { + return Ok(()); + } + let mut seen = BTreeSet::new(); + validate_error_name(name, ctx, &mut seen) +} + +fn validate_error_name<'a>( + name: &'a str, + ctx: &BridgeCtx<'a>, + seen: &mut BTreeSet<&'a str>, +) -> Result<()> { + if name == "GenericError" { + return Ok(()); + } + if !seen.insert(name) { + bail!("platform error type `{name}` contains a recursive alias/envelope"); + } + let Some(type_def) = resolve_alias_type(name, ctx) else { + bail!("platform error type `{name}` is not present in the API definition"); + }; + let TypeDefKind::Enum(variants) = &type_def.kind else { + bail!("platform error type `{name}` must resolve to an enum"); + }; + let has_unknown_reason = variants.iter().any(|variant| { + variant.name == "Unknown" + && matches!( + &variant.fields, + VariantFields::Named(fields) + if fields.iter().any(|field| { + field.name == "reason" && is_string(&field.type_ref) + }) + ) + }); + if !has_unknown_reason { + let versioned_payloads = variants + .iter() + .filter_map(|variant| match &variant.fields { + VariantFields::Unnamed(types) if types.len() == 1 => Some(&types[0]), + _ => None, + }) + .collect::>(); + if !versioned_payloads.is_empty() && versioned_payloads.len() == variants.len() { + for payload in versioned_payloads { + validate_error_name(named_type_name(payload)?, ctx, seen)?; + } + return Ok(()); + } + bail!("platform error type `{name}` must define `Unknown {{ reason: String }}`"); + } + Ok(()) +} + +fn resolve_alias_type<'a>(name: &'a str, ctx: &BridgeCtx<'a>) -> Option<&'a TypeDef> { + let mut seen = BTreeSet::new(); + let mut current = name; + loop { + if !seen.insert(current) { + return None; + } + let type_def = *ctx.api_types.get(current)?; + match &type_def.kind { + TypeDefKind::Alias(TypeRef::Named { name, .. }) => current = name, + _ => return Some(type_def), + } + } +} + +fn named_type_name(ty: &TypeRef) -> Result<&str> { + let TypeRef::Named { name, .. } = ty else { + bail!("expected named type, got {ty:?}"); + }; + Ok(name) +} + +fn is_unit(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Unit) || matches!(ty, TypeRef::Tuple(items) if items.is_empty()) +} + +fn is_bool(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Primitive(name) if name == "bool") +} + +fn is_string(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Primitive(name) if name == "String" || name == "str") + || matches!(ty, TypeRef::Named { name, args } if name == "String" && args.is_empty()) +} + +fn is_bytes(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Vec(inner) if is_u8(inner)) + || matches!(ty, TypeRef::Array(inner, _) if is_u8(inner)) +} + +fn is_optional_bytes(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Option(inner) if is_bytes(inner)) +} + +fn is_u8(ty: &TypeRef) -> bool { + matches!(ty, TypeRef::Primitive(name) if name == "u8") +} + +fn indent_body(body: &str, spaces: usize) -> String { + let indent = " ".repeat(spaces); + body.lines() + .map(|line| { + if line.is_empty() { + String::new() + } else { + format!("{indent}{line}") + } + }) + .collect::>() + .join("\n") +} + +fn collect_local_bridge_payload_types(definition: &PlatformDefinition) -> BTreeSet<&str> { + let local: BTreeSet<&str> = definition.types.iter().map(|ty| ty.name.as_str()).collect(); + let mut out = BTreeSet::new(); + for trait_def in &definition.traits { + for method in &trait_def.methods { + for param in &method.params { + collect_local_from_type(¶m.type_ref, &local, &mut out); + } + match &method.return_shape.inner { + PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { + collect_local_from_type(ok, &local, &mut out); + } + PlatformInner::Stream(item) => { + collect_local_from_type(stream_item(item), &local, &mut out) + } + PlatformInner::Unit | PlatformInner::TraitObject(_) => {} + } + } + } + let mut changed = true; + while changed { + changed = false; + let referenced = definition + .types + .iter() + .filter(|ty| out.contains(ty.name.as_str())) + .collect::>(); + for type_def in referenced { + let before = out.len(); + collect_local_from_type_def(type_def, &local, &mut out); + changed |= out.len() != before; + } + } + out +} + +fn collect_local_from_type_def<'a>( + type_def: &'a TypeDef, + local: &BTreeSet<&'a str>, + out: &mut BTreeSet<&'a str>, +) { + match &type_def.kind { + TypeDefKind::Alias(type_ref) => collect_local_from_type(type_ref, local, out), + TypeDefKind::Struct(fields) => { + for field in fields { + collect_local_from_type(&field.type_ref, local, out); + } + } + TypeDefKind::TupleStruct(fields) => { + for field in fields { + collect_local_from_type(field, local, out); + } + } + TypeDefKind::Enum(variants) => { + for variant in variants { + match &variant.fields { + VariantFields::Unit => {} + VariantFields::Unnamed(types) => { + for ty in types { + collect_local_from_type(ty, local, out); + } + } + VariantFields::Named(fields) => { + for field in fields { + collect_local_from_type(&field.type_ref, local, out); + } + } + } + } + } + } +} + +fn collect_local_from_type<'a>( + ty: &'a TypeRef, + local: &BTreeSet<&'a str>, + out: &mut BTreeSet<&'a str>, +) { + match ty { + TypeRef::Named { name, args } => { + if local.contains(name.as_str()) { + out.insert(name); + } + for arg in args { + collect_local_from_type(arg, local, out); + } + } + TypeRef::Vec(inner) | TypeRef::Option(inner) | TypeRef::Array(inner, _) => { + collect_local_from_type(inner, local, out); + } + TypeRef::Tuple(items) => { + for item in items { + collect_local_from_type(item, local, out); + } + } + TypeRef::Primitive(_) | TypeRef::Generic(_) | TypeRef::Unit => {} + } +} diff --git a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs index 4d3f1882..ac0f52b6 100644 --- a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs +++ b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs @@ -18,6 +18,11 @@ use indoc::{formatdoc, writedoc}; use crate::platform::{ PlatformDefinition, PlatformInner, PlatformMethod, PlatformParam, PlatformReturn, PlatformTrait, }; +use crate::platform_callbacks::{ + callback_namespace, composed_traits, platform_trait_names, raw_callback_adapter_name, + raw_callback_name, raw_callback_type_name, raw_callback_wire_name, stream_item, to_camel_case, + trait_object_return_name, +}; use crate::rustdoc::{FieldDef, TypeDef, TypeDefKind, TypeRef, VariantDef, VariantFields}; use crate::ts::ts_string_literal; @@ -34,7 +39,7 @@ pub fn generate( ) -> Result<()> { fs::create_dir_all(callbacks_output_dir)?; fs::create_dir_all(adapter_output_dir)?; - let local_codec_types = collect_local_async_payload_types(definition); + let local_codec_types = collect_local_bridge_payload_types(definition); let body = emit_host_callbacks(definition, codec_types, &local_codec_types)?; fs::write( Path::new(callbacks_output_dir).join("host-callbacks.ts"), @@ -152,25 +157,26 @@ fn emit_host_callbacks( None, ), }; - out.push_str(&emit_super_interface("HostCallbacks", &composes, docs)); + out.push_str(&emit_host_callback_composites(&composes, docs)); Ok(out) } /// Emit the `createWasmRawCallbacks` adapter that maps the typed /// `HostCallbacks` surface onto the byte-oriented surface the WASM core -/// invokes. Named wire types cross as SCALE bytes (`.enc`/`.dec`); strings, -/// primitives, byte blobs and platform-local types (`AuthState`) pass through -/// unchanged. `ChainProvider` is delegated to the hand-written -/// `chainConnectAdapter` since its connection handle is bespoke. +/// invokes. Codec-backed wire and platform-local types cross as SCALE bytes +/// (`.enc`/`.dec`); strings, primitives and byte blobs pass through unchanged. +/// Trait-object returns are delegated to hand-written adapters whose names are +/// derived from the raw callback name. fn emit_wasm_adapter( definition: &PlatformDefinition, codec_types: &BTreeSet, local_codec_types: &BTreeSet, ) -> Result { // Only the capability traits the `Platform` super-trait composes are host - // callbacks; returned handles like `JsonRpcConnection` are not. + // callbacks; returned handle traits are adapted separately. let traits = composed_traits(definition); + let trait_names = platform_trait_names(definition); // Local types are emitted in `host-callbacks.ts` (e.g. `AuthState`); codec // types carry SCALE codecs and are imported as values for `.enc`/`.dec`. @@ -181,6 +187,8 @@ fn emit_wasm_adapter( let mut imports: BTreeSet = BTreeSet::new(); let mut extra_types: BTreeSet = BTreeSet::new(); let mut adapter_local_codec_types: BTreeSet = BTreeSet::new(); + let mut runtime_types: BTreeSet = BTreeSet::new(); + let mut support_imports: BTreeSet = BTreeSet::new(); for trait_def in &traits { for method in &trait_def.methods { for param in &method.params { @@ -192,15 +200,32 @@ fn emit_wasm_adapter( ); collect_extra_named(¶m.type_ref, codec_types, &local, &mut extra_types); } + if trait_object_return_name(method, &trait_names).is_some() { + runtime_types.insert(raw_callback_type_name(trait_def, method, &trait_names)); + support_imports.insert(raw_callback_adapter_name(trait_def, method, &trait_names)); + continue; + } match &method.return_shape.inner { PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { collect_codec_imports(ok, codec_types, &mut imports); + collect_local_codec_names( + ok, + local_codec_types, + &mut adapter_local_codec_types, + ); collect_extra_named(ok, codec_types, &local, &mut extra_types); } PlatformInner::Stream(item) => { - collect_codec_imports(stream_item(item), codec_types, &mut imports) + support_imports.insert("driveResultStream".to_string()); + collect_codec_imports(stream_item(item), codec_types, &mut imports); + collect_local_codec_names( + stream_item(item), + local_codec_types, + &mut adapter_local_codec_types, + ); } - PlatformInner::Unit | PlatformInner::TraitObject(_) => {} + PlatformInner::TraitObject(_) => {} + PlatformInner::Unit => {} } } } @@ -212,9 +237,9 @@ fn emit_wasm_adapter( // Auto-generated by truapi-codegen. Do not edit. // // Adapts the typed `HostCallbacks` surface onto the byte-oriented - // callback surface the WASM core invokes. Named wire types cross as - // SCALE bytes (`.enc`/`.dec`); strings, primitives, byte blobs and - // platform-local types pass through unchanged. + // callback surface the WASM core invokes. Codec-backed wire and + // platform-local types cross as SCALE bytes (`.enc`/`.dec`); strings, + // primitives and byte blobs pass through unchanged. "#, ) @@ -230,40 +255,53 @@ fn emit_wasm_adapter( writedoc!( out, r#" - import type {{ AuthState, HostCallbacks }} from "./host-callbacks.js"; - import type {{ ChainConnect }} from "../runtime.js"; - import {{ - chainConnectAdapter, - driveResultStream, - }} from "../adapter-support.js"; + import type {{ + FlatHostCallbacks, + RequiredHostCallbacks, + }} from "./host-callbacks.js"; "#, ) .unwrap(); - out.push_str(&emit_raw_callbacks(&traits, codec_types, local_codec_types)); + emit_import_block(&mut out, true, "../runtime.js", &runtime_types); + emit_import_block(&mut out, false, "../adapter-support.js", &support_imports); + if !runtime_types.is_empty() || !support_imports.is_empty() { + out.push('\n'); + } + out.push_str(&emit_raw_callbacks( + &traits, + codec_types, + local_codec_types, + &trait_names, + )); writedoc!( out, r#" /** Adapt typed host callbacks into the raw SCALE callback surface the * WASM core invokes. */ export function createWasmRawCallbacks( - host: Required, + host: RequiredHostCallbacks | Required, ): RawCallbacks {{ + const callbacks = normalizeHostCallbacks(host); return {{ "#, ) .unwrap(); for trait_def in &traits { - if trait_def.name == "ChainProvider" { - writeln!(out, " chainConnect: chainConnectAdapter(host),").unwrap(); - continue; - } for method in &trait_def.methods { - let entry = emit_adapter_entry(method, codec_types, local_codec_types)?; + let entry = emit_adapter_entry( + trait_def, + method, + codec_types, + local_codec_types, + &trait_names, + )?; writeln!(out, " {entry}").unwrap(); } } out.push_str(" };\n}\n"); + out.push('\n'); + out.push_str(&emit_normalize_host_callbacks(&traits)); Ok(out) } @@ -279,16 +317,24 @@ fn emit_worker_callbacks( _local_codec_types: &BTreeSet, ) -> Result { let traits = composed_traits(definition); + let trait_names = platform_trait_names(definition); let mut callbacks = Vec::new(); let mut subscriptions = Vec::new(); + let mut trait_object_callbacks = Vec::new(); + let mut runtime_types = BTreeSet::new(); for trait_def in traits { - if trait_def.name == "ChainProvider" { - continue; - } for method in &trait_def.methods { + if trait_object_return_name(method, &trait_names).is_some() { + runtime_types.insert(raw_callback_type_name(trait_def, method, &trait_names)); + trait_object_callbacks.push((trait_def, method)); + continue; + } match &method.return_shape.inner { PlatformInner::Stream(_) => subscriptions.push(method), + PlatformInner::TraitObject(_) => { + unreachable!("trait-object callbacks are handled above") + } _ => callbacks.push(method), } } @@ -305,12 +351,15 @@ fn emit_worker_callbacks( // file owns the callback names, host-hook arity, and // subscription payload shape derived from `truapi-platform`. - import type {{ ChainConnect }} from "../runtime.js"; import type {{ RawCallbacks }} from "./host-callbacks-adapter.js"; "# ) .unwrap(); + emit_import_block(&mut out, true, "../runtime.js", &runtime_types); + if !runtime_types.is_empty() { + out.push('\n'); + } out.push_str(&const_name_array("CALLBACK_NAMES", &callbacks)); out.push_str("export type CallbackName = typeof CALLBACK_NAMES[number];\n\n"); @@ -327,7 +376,21 @@ fn emit_worker_callbacks( payload: Uint8Array | null, sendItem: (value: T) => void, ): () => void; - chainConnect: ChainConnect; + "# + ) + .unwrap(); + for (trait_def, method) in &trait_object_callbacks { + writeln!( + out, + " {}: {};", + raw_callback_wire_name(trait_def, method, &trait_names), + raw_callback_type_name(trait_def, method, &trait_names) + ) + .unwrap(); + } + writedoc!( + out, + r#" }} "# @@ -352,7 +415,16 @@ fn emit_worker_callbacks( const callbacks: Record = {{ ...rawCallbacks(bridge), ...subscriptionRawCallbacks(bridge), - chainConnect: bridge.chainConnect, + "# + ) + .unwrap(); + for (trait_def, method) in &trait_object_callbacks { + let raw = raw_callback_wire_name(trait_def, method, &trait_names); + writeln!(out, " {raw}: bridge.{raw},").unwrap(); + } + writedoc!( + out, + r#" }}; return callbacks; }} @@ -378,18 +450,6 @@ fn emit_worker_callbacks( Ok(out) } -fn composed_traits(definition: &PlatformDefinition) -> Vec<&PlatformTrait> { - let composed: BTreeSet = match &definition.super_trait { - Some(s) => s.composes.iter().cloned().collect(), - None => definition.traits.iter().map(|t| t.name.clone()).collect(), - }; - definition - .traits - .iter() - .filter(|t| composed.contains(&t.name)) - .collect() -} - /// All names defined locally in the platform crate: capability trait names plus /// the struct/enum type names emitted into `host-callbacks.ts`. fn local_names(definition: &PlatformDefinition) -> BTreeSet { @@ -404,7 +464,7 @@ fn local_names(definition: &PlatformDefinition) -> BTreeSet { fn const_name_array(const_name: &str, methods: &[&PlatformMethod]) -> String { let entries = methods .iter() - .map(|method| format!(" \"{}\",", to_camel_case(&method.name))) + .map(|method| format!(" \"{}\",", raw_callback_name(method))) .collect::>() .join("\n"); format!("export const {const_name} = [\n{entries}\n] as const;\n") @@ -431,7 +491,7 @@ fn emit_worker_callback_factory( } fn emit_worker_callback_entry(method: &PlatformMethod) -> Result { - let raw = to_camel_case(&method.name); + let raw = raw_callback_name(method); let args = method .params .iter() @@ -471,7 +531,7 @@ fn emit_worker_subscription_factory(methods: &[&PlatformMethod]) -> Result Result, local_codec_types: &BTreeSet, + platform_trait_names: &BTreeSet, ) -> String { let mut out = String::new(); out.push_str("export interface RawCallbacks {\n"); for trait_def in traits { - if trait_def.name == "ChainProvider" { - continue; - } for method in &trait_def.methods { out.push_str(" "); - out.push_str(&raw_member(method, codec_types, local_codec_types)); + out.push_str(&raw_member( + trait_def, + method, + codec_types, + local_codec_types, + platform_trait_names, + )); out.push('\n'); } } - out.push_str(" chainConnect: ChainConnect;\n"); out.push_str("}\n"); out } /// One `RawCallbacks` member signature for `method`. fn raw_member( + trait_def: &PlatformTrait, method: &PlatformMethod, codec_types: &BTreeSet, local_codec_types: &BTreeSet, + platform_trait_names: &BTreeSet, ) -> String { - let name = to_camel_case(&method.name); + let name = raw_callback_wire_name(trait_def, method, platform_trait_names); match &method.return_shape.inner { PlatformInner::Stream(_) => { let mut params: Vec = method @@ -593,7 +658,13 @@ fn raw_member( params.push("sendItem: (item?: Uint8Array) => void".to_string()); format!("{name}({}): (() => void) | void;", params.join(", ")) } - PlatformInner::TraitObject(_) => String::new(), + _ if trait_object_return_name(method, platform_trait_names).is_some() => { + format!( + "{}: {};", + name, + raw_callback_type_name(trait_def, method, platform_trait_names) + ) + } inner => { let params = method .params @@ -609,7 +680,7 @@ fn raw_member( .join(", "); let ok = match inner { PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { - raw_ok_ts(ok, codec_types) + raw_ok_ts(ok, codec_types, local_codec_types) } _ => "void".to_string(), }; @@ -649,14 +720,22 @@ fn raw_param_ts( } /// TS type for a `RawCallbacks` `Result` ok value under the byte boundary. -fn raw_ok_ts(ty: &TypeRef, codec_types: &BTreeSet) -> String { +fn raw_ok_ts( + ty: &TypeRef, + codec_types: &BTreeSet, + local_codec_types: &BTreeSet, +) -> String { match ty { TypeRef::Named { name, .. } if codec_types.contains(name) => "Uint8Array".to_string(), + TypeRef::Named { name, .. } if local_codec_types.contains(name) => "Uint8Array".to_string(), TypeRef::Named { name, .. } => name.clone(), TypeRef::Vec(inner) | TypeRef::Array(inner, _) if matches!(inner.as_ref(), TypeRef::Primitive(p) if p == "u8") => { "Uint8Array".to_string() } - TypeRef::Option(inner) => format!("{} | null | undefined", raw_ok_ts(inner, codec_types)), + TypeRef::Option(inner) => format!( + "{} | null | undefined", + raw_ok_ts(inner, codec_types, local_codec_types) + ), TypeRef::Primitive(p) => raw_primitive_ts(p), TypeRef::Unit => "void".to_string(), TypeRef::Tuple(items) if items.is_empty() => "void".to_string(), @@ -716,19 +795,6 @@ fn collect_codec_imports(ty: &TypeRef, codec_types: &BTreeSet, out: &mut } } -/// Unwrap a `Result` stream item to its `T`; other item types pass -/// through. Streams carry `Result`s on the Rust side but `driveResultStream` -/// already unwraps them, so the adapter encodes the inner item type. -fn stream_item(item: &TypeRef) -> &TypeRef { - if let TypeRef::Named { name, args } = item - && name == "Result" - && let Some(ok) = args.first() - { - return ok; - } - item -} - /// The call argument expression for one Rust param. Codec types arrive as /// `Uint8Array` and are decoded; `u64`-family integers arrive as JS numbers and /// are widened to `bigint`; everything else passes through. Arrow parameter @@ -767,25 +833,39 @@ fn param_names(method: &PlatformMethod) -> String { /// host provides a complete callback implementation, so missing capability /// behavior is expressed inside the host callback rather than by omitting it. fn emit_adapter_entry( + trait_def: &PlatformTrait, method: &PlatformMethod, codec_types: &BTreeSet, local_codec_types: &BTreeSet, + platform_trait_names: &BTreeSet, ) -> Result { - let raw = to_camel_case(&method.name); + let raw = raw_callback_wire_name(trait_def, method, platform_trait_names); + let host_method = format!("callbacks.{}.{}", callback_namespace(&trait_def.name), raw); + if trait_object_return_name(method, platform_trait_names).is_some() { + let adapter = raw_callback_adapter_name(trait_def, method, platform_trait_names); + return Ok(format!( + "{raw}: {adapter}(callbacks.{}),", + callback_namespace(&trait_def.name) + )); + } let impl_expr = match &method.return_shape.inner { PlatformInner::Stream(item) => { - adapter_stream_impl(&raw, method, item, codec_types, local_codec_types)? + adapter_stream_impl(&host_method, method, item, codec_types, local_codec_types)? } PlatformInner::Result { ok, .. } => { - adapter_unary_impl(&raw, method, ok, codec_types, local_codec_types)? + adapter_unary_impl(&host_method, method, ok, codec_types, local_codec_types)? } PlatformInner::Plain(ok) => { - adapter_unary_impl(&raw, method, ok, codec_types, local_codec_types)? - } - PlatformInner::Unit => { - adapter_unary_impl(&raw, method, &TypeRef::Unit, codec_types, local_codec_types)? + adapter_unary_impl(&host_method, method, ok, codec_types, local_codec_types)? } - PlatformInner::TraitObject(_) => bail!("unexpected trait-object return on `{raw}`"), + PlatformInner::Unit => adapter_unary_impl( + &host_method, + method, + &TypeRef::Unit, + codec_types, + local_codec_types, + )?, + PlatformInner::TraitObject(_) => unreachable!("trait-object callbacks are handled above"), }; Ok(format!("{raw}: {impl_expr},")) } @@ -793,7 +873,7 @@ fn emit_adapter_entry( /// The adapter implementation expression for a unary callback: decode codec /// params, call the typed host method, SCALE-encode a codec result. fn adapter_unary_impl( - raw: &str, + host_method: &str, method: &PlatformMethod, ok: &TypeRef, codec_types: &BTreeSet, @@ -806,9 +886,11 @@ fn adapter_unary_impl( .map(|p| adapter_arg(p, codec_types, local_codec_types)) .collect::>() .join(", "); - let call = format!("host.{raw}({args})"); + let call = format!("{host_method}({args})"); let body = match ok { - TypeRef::Named { name: ty, .. } if codec_types.contains(ty) => { + TypeRef::Named { name: ty, .. } + if codec_types.contains(ty) || local_codec_types.contains(ty) => + { format!("{ty}.enc(await {call})") } _ => format!("await {call}"), @@ -819,7 +901,7 @@ fn adapter_unary_impl( /// The adapter implementation expression for a subscription callback: drive /// the host's stream into `sendItem`, SCALE-encoding each codec item. fn adapter_stream_impl( - raw: &str, + host_method: &str, method: &PlatformMethod, item: &TypeRef, codec_types: &BTreeSet, @@ -838,7 +920,9 @@ fn adapter_stream_impl( // Tick subscription: the item carries no value, so ignore it and emit // a bare tick to the core's sink. _ if is_unit => "() => sendItem()".to_string(), - TypeRef::Named { name: ty, .. } if codec_types.contains(ty) => { + TypeRef::Named { name: ty, .. } + if codec_types.contains(ty) || local_codec_types.contains(ty) => + { format!("(item) => sendItem({ty}.enc(item))") } _ => "sendItem".to_string(), @@ -850,23 +934,29 @@ fn adapter_stream_impl( .collect(); names.push("sendItem".to_string()); let params = names.join(", "); - let call = format!("host.{raw}({args})"); + let call = format!("{host_method}({args})"); Ok(format!( "({params}) => driveResultStream({call}, {item_expr})" )) } -fn collect_local_async_payload_types(definition: &PlatformDefinition) -> BTreeSet { +fn collect_local_bridge_payload_types(definition: &PlatformDefinition) -> BTreeSet { let local: BTreeSet = definition.types.iter().map(|ty| ty.name.clone()).collect(); let mut out = BTreeSet::new(); for trait_def in &definition.traits { for method in &trait_def.methods { - if !method.return_shape.is_async { - continue; - } for param in &method.params { collect_local_from_type(¶m.type_ref, &local, &mut out); } + match &method.return_shape.inner { + PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => { + collect_local_from_type(ok, &local, &mut out); + } + PlatformInner::Stream(item) => { + collect_local_from_type(stream_item(item), &local, &mut out) + } + PlatformInner::Unit | PlatformInner::TraitObject(_) => {} + } } } let mut changed = true; @@ -1307,17 +1397,77 @@ fn inline_object_type(fields: &[FieldDef]) -> Result { Ok(format!("{{ {body} }}")) } -fn emit_super_interface(name: &str, composes: &[String], docs: Option<&str>) -> String { +fn emit_host_callback_composites(composes: &[String], docs: Option<&str>) -> String { let jsdoc = render_jsdoc("", docs); if composes.is_empty() { - return format!("{jsdoc}export interface {name} {{}}\n"); + return format!( + "{jsdoc}export interface HostCallbacks {{}}\n\nexport interface RequiredHostCallbacks {{}}\n" + ); } let extends = composes .iter() .map(|name| name.to_string()) .collect::>() .join(", "); - format!("{jsdoc}export interface {name} extends {extends} {{}}\n") + let host_members = composes + .iter() + .map(|trait_name| format!(" {}: {};", callback_namespace(trait_name), trait_name)) + .collect::>() + .join("\n"); + let required_members = composes + .iter() + .map(|trait_name| { + format!( + " {}: Required<{}>;", + callback_namespace(trait_name), + trait_name + ) + }) + .collect::>() + .join("\n"); + formatdoc! { + r#" + /** @deprecated Use the namespaced `HostCallbacks` shape instead. */ + export interface FlatHostCallbacks extends {extends} {{}} + + {jsdoc}export interface HostCallbacks {{ + {host_members} + }} + + export interface RequiredHostCallbacks {{ + {required_members} + }} + "#, + } +} + +fn emit_normalize_host_callbacks(traits: &[&PlatformTrait]) -> String { + let namespace_guard = traits + .first() + .map(|trait_def| callback_namespace(&trait_def.name)) + .unwrap_or_default(); + let members = traits + .iter() + .map(|trait_def| { + let namespace = callback_namespace(&trait_def.name); + format!(" {namespace}: host,") + }) + .collect::>() + .join("\n"); + formatdoc! { + r#" + function normalizeHostCallbacks( + host: RequiredHostCallbacks | Required, + ): RequiredHostCallbacks {{ + if ("{namespace_guard}" in host) {{ + return host; + }} + return {{ + {members} + }}; + }} + "#, + } } fn collect_named_types(definition: &PlatformDefinition) -> BTreeSet { @@ -1463,21 +1613,3 @@ fn render_ts_doc_line(line: &str) -> String { .replace("Ok(())", "success") .replace("None", "`undefined`") } - -fn to_camel_case(name: &str) -> String { - let mut out = String::with_capacity(name.len()); - let mut upper_next = false; - for (idx, ch) in name.chars().enumerate() { - if ch == '_' { - upper_next = idx != 0; - continue; - } - if upper_next { - out.extend(ch.to_uppercase()); - upper_next = false; - } else { - out.push(ch); - } - } - out -} diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts index 3edef0ec..f56af427 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts @@ -1,9 +1,9 @@ // Auto-generated by truapi-codegen. Do not edit. // // Adapts the typed `HostCallbacks` surface onto the byte-oriented -// callback surface the WASM core invokes. Named wire types cross as -// SCALE bytes (`.enc`/`.dec`); strings, primitives, byte blobs and -// platform-local types pass through unchanged. +// callback surface the WASM core invokes. Codec-backed wire and +// platform-local types cross as SCALE bytes (`.enc`/`.dec`); strings, +// primitives and byte blobs pass through unchanged. import { HostDevicePermissionRequest, @@ -20,18 +20,26 @@ import type { NotificationId, } from "@parity/truapi"; import { + AuthState, CoreStorageKey, UserConfirmationReview, } from "./host-callbacks.js"; -import type { AuthState, HostCallbacks } from "./host-callbacks.js"; -import type { ChainConnect } from "../runtime.js"; +import type { + FlatHostCallbacks, + RequiredHostCallbacks, +} from "./host-callbacks.js"; + +import type { + ChainConnect, +} from "../runtime.js"; import { chainConnectAdapter, driveResultStream, } from "../adapter-support.js"; export interface RawCallbacks { - authStateChanged(state: AuthState): void; + authStateChanged(state: Uint8Array): void; + chainConnect: ChainConnect; readCoreStorage(key: Uint8Array): Promise; writeCoreStorage(key: Uint8Array, value: Uint8Array): Promise; clearCoreStorage(key: Uint8Array): Promise; @@ -48,31 +56,52 @@ export interface RawCallbacks { clear(key: string): Promise; subscribeTheme(sendItem: (item?: Uint8Array) => void): (() => void) | void; confirmUserAction(review: Uint8Array): Promise; - chainConnect: ChainConnect; } /** Adapt typed host callbacks into the raw SCALE callback surface the * WASM core invokes. */ export function createWasmRawCallbacks( - host: Required, + host: RequiredHostCallbacks | Required, ): RawCallbacks { + const callbacks = normalizeHostCallbacks(host); + return { + authStateChanged: async (state) => await callbacks.auth.authStateChanged(AuthState.dec(state)), + chainConnect: chainConnectAdapter(callbacks.chain), + readCoreStorage: async (key) => await callbacks.coreStorage.readCoreStorage(CoreStorageKey.dec(key)), + writeCoreStorage: async (key, value) => await callbacks.coreStorage.writeCoreStorage(CoreStorageKey.dec(key), value), + clearCoreStorage: async (key) => await callbacks.coreStorage.clearCoreStorage(CoreStorageKey.dec(key)), + featureSupported: async (request) => HostFeatureSupportedResponse.enc(await callbacks.features.featureSupported(HostFeatureSupportedRequest.dec(request))), + navigateTo: async (url) => await callbacks.navigation.navigateTo(url), + pushNotification: async (notification) => HostPushNotificationResponse.enc(await callbacks.notifications.pushNotification(HostPushNotificationRequest.dec(notification))), + cancelNotification: async (id) => await callbacks.notifications.cancelNotification(id), + devicePermission: async (request) => HostDevicePermissionResponse.enc(await callbacks.permissions.devicePermission(HostDevicePermissionRequest.dec(request))), + remotePermission: async (request) => RemotePermissionResponse.enc(await callbacks.permissions.remotePermission(RemotePermissionRequest.dec(request))), + submitPreimage: async (value) => await callbacks.preimage.submitPreimage(value), + lookupPreimage: (key, sendItem) => driveResultStream(callbacks.preimage.lookupPreimage(key), sendItem), + read: async (key) => await callbacks.productStorage.read(key), + write: async (key, value) => await callbacks.productStorage.write(key, value), + clear: async (key) => await callbacks.productStorage.clear(key), + subscribeTheme: (sendItem) => driveResultStream(callbacks.theme.subscribeTheme(), (item) => sendItem(ThemeVariant.enc(item))), + confirmUserAction: async (review) => await callbacks.userConfirmation.confirmUserAction(UserConfirmationReview.dec(review)), + }; +} + +function normalizeHostCallbacks( + host: RequiredHostCallbacks | Required, +): RequiredHostCallbacks { + if ("auth" in host) { + return host; + } return { - authStateChanged: async (state) => await host.authStateChanged(state), - chainConnect: chainConnectAdapter(host), - readCoreStorage: async (key) => await host.readCoreStorage(CoreStorageKey.dec(key)), - writeCoreStorage: async (key, value) => await host.writeCoreStorage(CoreStorageKey.dec(key), value), - clearCoreStorage: async (key) => await host.clearCoreStorage(CoreStorageKey.dec(key)), - featureSupported: async (request) => HostFeatureSupportedResponse.enc(await host.featureSupported(HostFeatureSupportedRequest.dec(request))), - navigateTo: async (url) => await host.navigateTo(url), - pushNotification: async (notification) => HostPushNotificationResponse.enc(await host.pushNotification(HostPushNotificationRequest.dec(notification))), - cancelNotification: async (id) => await host.cancelNotification(id), - devicePermission: async (request) => HostDevicePermissionResponse.enc(await host.devicePermission(HostDevicePermissionRequest.dec(request))), - remotePermission: async (request) => RemotePermissionResponse.enc(await host.remotePermission(RemotePermissionRequest.dec(request))), - submitPreimage: async (value) => await host.submitPreimage(value), - lookupPreimage: (key, sendItem) => driveResultStream(host.lookupPreimage(key), sendItem), - read: async (key) => await host.read(key), - write: async (key, value) => await host.write(key, value), - clear: async (key) => await host.clear(key), - subscribeTheme: (sendItem) => driveResultStream(host.subscribeTheme(), (item) => sendItem(ThemeVariant.enc(item))), - confirmUserAction: async (review) => await host.confirmUserAction(UserConfirmationReview.dec(review)), + auth: host, + chain: host, + coreStorage: host, + features: host, + navigation: host, + notifications: host, + permissions: host, + preimage: host, + productStorage: host, + theme: host, + userConfirmation: host, }; } diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 4321e226..4c060456 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -241,6 +241,13 @@ export type UserConfirmationReview = */ export const AccountAliasReview: S.Codec = S.lazy((): S.Codec => S.Struct({requestingProductId: S.str, targetProductId: S.str}) as S.Codec); +/** + * Auth/session lifecycle state the core projects for host UI. The core owns + * every transition and emits states in order; hosts render the current state + * and never derive auth UI from any other signal. + */ +export const AuthState: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({Disconnected: S._void, Pairing: S.Struct({deeplink: S.str}) as S.Codec<{ deeplink: string }>, Connected: SessionUiInfo, LoginFailed: S.Struct({reason: S.str}) as S.Codec<{ reason: string }>})); + /** * Core-owned host-private storage slots. Products never address these slots; * the host chooses the backing store for each slot. @@ -279,6 +286,12 @@ export const PermissionAuthorizationStatus: S.Codec = S.lazy((): S.Codec => S.Struct({size: S.u64}) as S.Codec); +/** + * Decoded session fields a host shell needs to render account UI without + * parsing the opaque session blob the core persists through `CoreStorage`. + */ +export const SessionUiInfo: S.Codec = S.lazy((): S.Codec => S.Struct({publicKey: S.Bytes(32), identityAccountId: S.Option(S.Bytes(32)), liteUsername: S.Option(S.str), fullUsername: S.Option(S.str)}) as S.Codec); + /** * Review shown before a sign-payload request is sent to the paired wallet. */ @@ -532,7 +545,36 @@ export interface UserConfirmation { confirmUserAction(review: UserConfirmationReview): Promise; } +/** @deprecated Use the namespaced `HostCallbacks` shape instead. */ +export interface FlatHostCallbacks extends Navigation, Notifications, Permissions, Features, ProductStorage, CoreStorage, ChainProvider, AuthPresenter, UserConfirmation, ThemeHost, PreimageHost {} + /** * Combined platform interface. A host must provide all capability traits. */ -export interface HostCallbacks extends Navigation, Notifications, Permissions, Features, ProductStorage, CoreStorage, ChainProvider, AuthPresenter, UserConfirmation, ThemeHost, PreimageHost {} +export interface HostCallbacks { + navigation: Navigation; + notifications: Notifications; + permissions: Permissions; + features: Features; + productStorage: ProductStorage; + coreStorage: CoreStorage; + chain: ChainProvider; + auth: AuthPresenter; + userConfirmation: UserConfirmation; + theme: ThemeHost; + preimage: PreimageHost; +} + +export interface RequiredHostCallbacks { + navigation: Required; + notifications: Required; + permissions: Required; + features: Required; + productStorage: Required; + coreStorage: Required; + chain: Required; + auth: Required; + userConfirmation: Required; + theme: Required; + preimage: Required; +} diff --git a/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs new file mode 100644 index 00000000..50f2614b --- /dev/null +++ b/rust/crates/truapi-codegen/tests/golden/wasm_bridge.rs @@ -0,0 +1,300 @@ +//! Auto-generated by truapi-codegen. Do not edit. +//! +//! Mechanical wasm-bindgen callback bridge derived from +//! `truapi-platform`. Raw callback names and payload shapes match the +//! generated TypeScript host-callback adapter. + +use futures::stream::BoxStream; +use js_sys::{Function, Uint8Array}; +use parity_scale_codec::Encode; +use truapi::v01; +use wasm_bindgen::JsValue; + +use super::{ + WasmPlatform, call_js_function, decode_bytes, decode_js_item, generic, get_function, + invoke_bool, invoke_bytes_return, invoke_js_subscription, invoke_optional_bytes_return, + invoke_unit, parse_optional_bytes_item, +}; + +/// JS-side callbacks invoked by the wasm platform bridge. Methods with +/// Rust default bodies are still required here because the generated TS +/// adapter resolves optional host callbacks before constructing this +/// raw callback object. +pub(super) struct JsBridge { + pub(super) auth_state_changed: Function, + pub(super) chain_connect: Function, + pub(super) read_core_storage: Function, + pub(super) write_core_storage: Function, + pub(super) clear_core_storage: Function, + pub(super) feature_supported: Function, + pub(super) navigate_to: Function, + pub(super) push_notification: Function, + pub(super) cancel_notification: Function, + pub(super) device_permission: Function, + pub(super) remote_permission: Function, + pub(super) submit_preimage: Function, + pub(super) lookup_preimage: Function, + pub(super) read: Function, + pub(super) write: Function, + pub(super) clear: Function, + pub(super) subscribe_theme: Function, + pub(super) confirm_user_action: Function, +} + +impl JsBridge { + pub(super) fn from_js(callbacks: &JsValue) -> Result { + Ok(Self { + auth_state_changed: get_function(callbacks, "authStateChanged")?, + chain_connect: get_function(callbacks, "chainConnect")?, + read_core_storage: get_function(callbacks, "readCoreStorage")?, + write_core_storage: get_function(callbacks, "writeCoreStorage")?, + clear_core_storage: get_function(callbacks, "clearCoreStorage")?, + feature_supported: get_function(callbacks, "featureSupported")?, + navigate_to: get_function(callbacks, "navigateTo")?, + push_notification: get_function(callbacks, "pushNotification")?, + cancel_notification: get_function(callbacks, "cancelNotification")?, + device_permission: get_function(callbacks, "devicePermission")?, + remote_permission: get_function(callbacks, "remotePermission")?, + submit_preimage: get_function(callbacks, "submitPreimage")?, + lookup_preimage: get_function(callbacks, "lookupPreimage")?, + read: get_function(callbacks, "read")?, + write: get_function(callbacks, "write")?, + clear: get_function(callbacks, "clear")?, + subscribe_theme: get_function(callbacks, "subscribeTheme")?, + confirm_user_action: get_function(callbacks, "confirmUserAction")?, + }) + } +} + +impl truapi_platform::AuthPresenter for WasmPlatform { + fn auth_state_changed(&self, state: truapi_platform::AuthState) { + if let Err(reason) = call_js_function( + &self.bridge.auth_state_changed, + &vec![Uint8Array::from(state.encode().as_slice()).into()], + ) { + web_sys::console::error_1(&JsValue::from_str(&reason)); + } + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::CoreStorage for WasmPlatform { + async fn read_core_storage( + &self, + key: truapi_platform::CoreStorageKey, + ) -> Result>, v01::GenericError> { + invoke_optional_bytes_return( + &self.bridge.read_core_storage, + vec![Uint8Array::from(key.encode().as_slice()).into()], + "readCoreStorage must resolve to Uint8Array, null or undefined", + ) + .await + .map_err(generic) + } + + async fn write_core_storage( + &self, + key: truapi_platform::CoreStorageKey, + value: Vec, + ) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.write_core_storage, + vec![ + Uint8Array::from(key.encode().as_slice()).into(), + Uint8Array::from(value.as_slice()).into(), + ], + ) + .await + .map_err(generic) + } + + async fn clear_core_storage( + &self, + key: truapi_platform::CoreStorageKey, + ) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.clear_core_storage, + vec![Uint8Array::from(key.encode().as_slice()).into()], + ) + .await + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Features for WasmPlatform { + async fn feature_supported( + &self, + request: v01::HostFeatureSupportedRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.feature_supported, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "featureSupported response did not decode", + ) + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Navigation for WasmPlatform { + async fn navigate_to(&self, url: String) -> Result<(), v01::HostNavigateToError> { + invoke_unit(&self.bridge.navigate_to, vec![JsValue::from_str(&url)]) + .await + .map_err(|reason| v01::HostNavigateToError::Unknown { reason }) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Notifications for WasmPlatform { + async fn push_notification( + &self, + notification: v01::HostPushNotificationRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.push_notification, + vec![Uint8Array::from(notification.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "pushNotification response did not decode", + ) + .map_err(generic) + } + + async fn cancel_notification(&self, id: v01::NotificationId) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.cancel_notification, + vec![JsValue::from_f64(f64::from(id))], + ) + .await + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Permissions for WasmPlatform { + async fn device_permission( + &self, + request: v01::HostDevicePermissionRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.device_permission, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "devicePermission response did not decode", + ) + .map_err(generic) + } + + async fn remote_permission( + &self, + request: v01::RemotePermissionRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.remote_permission, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "remotePermission response did not decode", + ) + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::PreimageHost for WasmPlatform { + async fn submit_preimage(&self, value: Vec) -> Result, v01::PreimageSubmitError> { + invoke_bytes_return( + &self.bridge.submit_preimage, + vec![Uint8Array::from(value.as_slice()).into()], + ) + .await + .map_err(|reason| v01::PreimageSubmitError::Unknown { reason }) + } + + fn lookup_preimage( + &self, + key: Vec, + ) -> BoxStream<'static, Result>, v01::GenericError>> { + invoke_js_subscription( + &self.bridge.lookup_preimage, + Some(key), + parse_optional_bytes_item, + ) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::ProductStorage for WasmPlatform { + async fn read(&self, key: String) -> Result>, v01::HostLocalStorageReadError> { + invoke_optional_bytes_return( + &self.bridge.read, + vec![JsValue::from_str(&key)], + "read must resolve to Uint8Array, null or undefined", + ) + .await + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } + + async fn write( + &self, + key: String, + value: Vec, + ) -> Result<(), v01::HostLocalStorageReadError> { + invoke_unit( + &self.bridge.write, + vec![ + JsValue::from_str(&key), + Uint8Array::from(value.as_slice()).into(), + ], + ) + .await + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } + + async fn clear(&self, key: String) -> Result<(), v01::HostLocalStorageReadError> { + invoke_unit(&self.bridge.clear, vec![JsValue::from_str(&key)]) + .await + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } +} + +impl truapi_platform::ThemeHost for WasmPlatform { + fn subscribe_theme(&self) -> BoxStream<'static, Result> { + invoke_js_subscription(&self.bridge.subscribe_theme, None, parse_theme_variant_item) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::UserConfirmation for WasmPlatform { + async fn confirm_user_action( + &self, + review: truapi_platform::UserConfirmationReview, + ) -> Result { + invoke_bool( + &self.bridge.confirm_user_action, + vec![Uint8Array::from(review.encode().as_slice()).into()], + ) + .await + .map_err(generic) + } +} + +fn parse_theme_variant_item(value: JsValue) -> Result { + decode_js_item::(value, "ThemeVariant") +} diff --git a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts index 34275dbf..451b7bcc 100644 --- a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts @@ -5,9 +5,12 @@ // file owns the callback names, host-hook arity, and // subscription payload shape derived from `truapi-platform`. -import type { ChainConnect } from "../runtime.js"; import type { RawCallbacks } from "./host-callbacks-adapter.js"; +import type { + ChainConnect, +} from "../runtime.js"; + export const CALLBACK_NAMES = [ "authStateChanged", "readCoreStorage", diff --git a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs index b26d9e6e..bd650d13 100644 --- a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs +++ b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs @@ -31,27 +31,6 @@ fn quoted_strings_in_const_array(src: &str, const_name: &str) -> Vec { .collect() } -fn wasm_optional_callback_names(workspace: &Path) -> Vec { - let src = fs::read_to_string(workspace.join("rust/crates/truapi-server/src/wasm.rs")) - .expect("read wasm.rs"); - let mut names = src - .lines() - .filter_map(|line| { - let line = line.trim(); - let start = line.find("get_optional_function(callbacks, \"")?; - let quoted = &line[start + "get_optional_function(callbacks, \"".len()..]; - let end = quoted.find('"')?; - let name = "ed[..end]; - match name { - "chainConnect" | "dispose" => None, - _ => Some(name.to_string()), - } - }) - .collect::>(); - names.sort(); - names -} - /// Run `cargo +nightly rustdoc -p truapi --output-format json` into the /// given `target_dir` and return the path to the produced JSON file. /// Panics with a clear message if nightly is unavailable so CI cannot @@ -214,6 +193,8 @@ fn golden_host_callbacks_ts() { tempdir.path().join("host").to_str().unwrap(), "--platform-wasm-adapter-output", tempdir.path().join("wasm").to_str().unwrap(), + "--platform-rust-output", + tempdir.path().join("rust-wasm").to_str().unwrap(), ]) .output() .expect("run truapi-codegen"); @@ -264,6 +245,20 @@ fn golden_host_callbacks_ts() { ); } + let wasm_bridge_golden_path = manifest_dir.join("tests/golden/wasm_bridge.rs"); + let wasm_bridge_actual = + fs::read_to_string(tempdir.path().join("rust-wasm/generated_bridge.rs")) + .expect("read generated wasm bridge"); + let wasm_bridge_golden = fs::read_to_string(&wasm_bridge_golden_path).unwrap_or_default(); + if wasm_bridge_golden != wasm_bridge_actual { + let dump = manifest_dir.join("tests/golden/wasm_bridge.rs.actual"); + let _ = fs::write(&dump, &wasm_bridge_actual); + panic!( + "golden mismatch for wasm_bridge.rs; wrote actual to {}", + dump.display() + ); + } + assert!( !worker_actual.contains("OPTIONAL_CALLBACK_NAMES"), "worker callback generation should not expose an optional callback manifest" @@ -273,11 +268,10 @@ fn golden_host_callbacks_ts() { &worker_actual, "SUBSCRIPTION_NAMES", )); - let wasm_optional = wasm_optional_callback_names(&workspace); - for name in wasm_optional { + for name in generated_names { assert!( - generated_names.contains(&name), - "generated worker names must include JsBridge optional callback `{name}`" + wasm_bridge_actual.contains(&format!("get_function(callbacks, \"{name}\")?")), + "generated wasm bridge must bind worker callback `{name}`" ); } } diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 940d2ee2..b133af52 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -462,7 +462,7 @@ pub trait CoreStorage: Send + Sync { /// Decoded session fields a host shell needs to render account UI without /// parsing the opaque session blob the core persists through [`CoreStorage`]. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Encode, Decode)] pub struct SessionUiInfo { /// 32-byte sr25519 root public key of the active session. pub public_key: [u8; 32], @@ -477,7 +477,7 @@ pub struct SessionUiInfo { /// Auth/session lifecycle state the core projects for host UI. The core owns /// every transition and emits states in order; hosts render the current state /// and never derive auth UI from any other signal. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Encode, Decode)] pub enum AuthState { /// No active session and no login in progress. #[default] diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index ff5386b1..79dfd3a4 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -19,14 +19,12 @@ use std::sync::atomic::{AtomicBool, Ordering}; use futures::channel::mpsc; use futures::stream::{self, BoxStream, Stream, StreamExt}; use js_sys::{Array, Function, Reflect, Uint8Array}; -use parity_scale_codec::{Decode, Encode}; +use parity_scale_codec::Decode; use send_wrapper::SendWrapper; use truapi::v01; use truapi_platform::{ - AuthPresenter, AuthState, ChainProvider, CoreStorage, CoreStorageKey, Features, HostInfo, - JsonRpcConnection, Navigation, Notifications, PairingHostConfig, Permissions, PlatformInfo, - PreimageHost, ProductContext, ProductStorage, RuntimeConfigValidationError, SessionUiInfo, - ThemeHost, UserConfirmation, UserConfirmationReview, + ChainProvider, HostInfo, JsonRpcConnection, PairingHostConfig, PlatformInfo, ProductContext, + RuntimeConfigValidationError, }; use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; @@ -37,28 +35,9 @@ use crate::{ ProductRuntime, }; -/// Bundle of JS-side callbacks the bridge invokes. Names map to camelCase -/// keys on the JS object passed to the constructor. -struct JsBridge { - navigate_to: Function, - push_notification: Function, - cancel_notification: Function, - device_permission: Function, - remote_permission: Function, - feature_supported: Function, - local_storage_read: Function, - local_storage_write: Function, - local_storage_clear: Function, - core_storage_read: Function, - core_storage_write: Function, - core_storage_clear: Function, - confirm_user_action: Function, - submit_preimage: Function, - lookup_preimage: Function, - subscribe_theme: Function, - auth_state_changed: Function, - chain_connect: Function, -} +mod generated_bridge; + +use generated_bridge::JsBridge; /// Per-core JS channel: outgoing frames and teardown for one product core. struct CoreChannel { @@ -66,31 +45,6 @@ struct CoreChannel { dispose: Function, } -impl JsBridge { - fn from_js(callbacks: &JsValue) -> Result { - Ok(Self { - navigate_to: get_function(callbacks, "navigateTo")?, - push_notification: get_function(callbacks, "pushNotification")?, - cancel_notification: get_function(callbacks, "cancelNotification")?, - device_permission: get_function(callbacks, "devicePermission")?, - remote_permission: get_function(callbacks, "remotePermission")?, - feature_supported: get_function(callbacks, "featureSupported")?, - local_storage_read: get_function(callbacks, "read")?, - local_storage_write: get_function(callbacks, "write")?, - local_storage_clear: get_function(callbacks, "clear")?, - core_storage_read: get_function(callbacks, "readCoreStorage")?, - core_storage_write: get_function(callbacks, "writeCoreStorage")?, - core_storage_clear: get_function(callbacks, "clearCoreStorage")?, - confirm_user_action: get_function(callbacks, "confirmUserAction")?, - submit_preimage: get_function(callbacks, "submitPreimage")?, - lookup_preimage: get_function(callbacks, "lookupPreimage")?, - subscribe_theme: get_function(callbacks, "subscribeTheme")?, - auth_state_changed: get_function(callbacks, "authStateChanged")?, - chain_connect: get_function(callbacks, "chainConnect")?, - }) - } -} - impl CoreChannel { fn from_js(callbacks: &JsValue) -> Result { Ok(Self { @@ -125,99 +79,6 @@ impl WasmPlatform { } } -#[truapi_platform::async_trait] -impl Navigation for WasmPlatform { - async fn navigate_to(&self, url: String) -> Result<(), v01::HostNavigateToError> { - invoke_navigate_to(&self.bridge, &url) - .await - .map_err(|reason| v01::HostNavigateToError::Unknown { reason }) - } -} - -#[truapi_platform::async_trait] -impl Notifications for WasmPlatform { - async fn push_notification( - &self, - notification: v01::HostPushNotificationRequest, - ) -> Result { - let bytes = invoke_bytes_return(&self.bridge.push_notification, notification.encode()) - .await - .map_err(generic)?; - v01::HostPushNotificationResponse::decode(&mut bytes.as_slice()) - .map_err(|_| generic("pushNotification response did not decode".to_string())) - } - - async fn cancel_notification(&self, id: v01::NotificationId) -> Result<(), v01::GenericError> { - invoke_u32_unit(&self.bridge.cancel_notification, id) - .await - .map_err(generic) - } -} - -#[truapi_platform::async_trait] -impl Permissions for WasmPlatform { - async fn device_permission( - &self, - request: v01::HostDevicePermissionRequest, - ) -> Result { - let bytes = invoke_bytes_return(&self.bridge.device_permission, request.encode()) - .await - .map_err(generic)?; - v01::HostDevicePermissionResponse::decode(&mut bytes.as_slice()) - .map_err(|_| generic("devicePermission response did not decode".to_string())) - } - - async fn remote_permission( - &self, - request: v01::RemotePermissionRequest, - ) -> Result { - let bytes = invoke_bytes_return(&self.bridge.remote_permission, request.encode()) - .await - .map_err(generic)?; - v01::RemotePermissionResponse::decode(&mut bytes.as_slice()) - .map_err(|_| generic("remotePermission response did not decode".to_string())) - } -} - -#[truapi_platform::async_trait] -impl Features for WasmPlatform { - async fn feature_supported( - &self, - request: v01::HostFeatureSupportedRequest, - ) -> Result { - let bytes = invoke_bytes_return(&self.bridge.feature_supported, request.encode()) - .await - .map_err(generic)?; - v01::HostFeatureSupportedResponse::decode(&mut bytes.as_slice()) - .map_err(|_| generic("featureSupported response did not decode".to_string())) - } -} - -#[truapi_platform::async_trait] -impl ProductStorage for WasmPlatform { - async fn read(&self, key: String) -> Result>, v01::HostLocalStorageReadError> { - invoke_local_storage_read(&self.bridge, &key) - .await - .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) - } - - async fn write( - &self, - key: String, - value: Vec, - ) -> Result<(), v01::HostLocalStorageReadError> { - invoke_local_storage_write(&self.bridge, &key, &value) - .await - .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) - } - - async fn clear(&self, key: String) -> Result<(), v01::HostLocalStorageReadError> { - invoke_local_storage_clear(&self.bridge, &key) - .await - .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) - } -} - #[truapi_platform::async_trait] impl ChainProvider for WasmPlatform { async fn connect( @@ -275,85 +136,6 @@ impl ChainProvider for WasmPlatform { } } -impl AuthPresenter for WasmPlatform { - fn auth_state_changed(&self, state: AuthState) { - if let Err(err) = self - .bridge - .auth_state_changed - .call1(&JsValue::NULL, &auth_state_to_js(&state)) - { - web_sys::console::error_1(&err); - } - } -} - -#[truapi_platform::async_trait] -impl CoreStorage for WasmPlatform { - async fn read_core_storage( - &self, - key: CoreStorageKey, - ) -> Result>, v01::GenericError> { - invoke_core_storage_read(&self.bridge, key) - .await - .map_err(generic) - } - - async fn write_core_storage( - &self, - key: CoreStorageKey, - value: Vec, - ) -> Result<(), v01::GenericError> { - invoke_core_storage_write(&self.bridge, key, value) - .await - .map_err(generic) - } - - async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), v01::GenericError> { - invoke_core_storage_clear(&self.bridge, key) - .await - .map_err(generic) - } -} - -#[truapi_platform::async_trait] -impl UserConfirmation for WasmPlatform { - async fn confirm_user_action( - &self, - review: UserConfirmationReview, - ) -> Result { - invoke_bool(&self.bridge.confirm_user_action, review.encode()) - .await - .map_err(generic) - } -} - -impl ThemeHost for WasmPlatform { - fn subscribe_theme(&self) -> BoxStream<'static, Result> { - invoke_js_subscription(&self.bridge.subscribe_theme, None, parse_theme_item).boxed() - } -} - -#[truapi_platform::async_trait] -impl PreimageHost for WasmPlatform { - async fn submit_preimage(&self, value: Vec) -> Result, v01::PreimageSubmitError> { - invoke_bytes_return(&self.bridge.submit_preimage, value) - .await - .map_err(|reason| v01::PreimageSubmitError::Unknown { reason }) - } - - fn lookup_preimage( - &self, - key: Vec, - ) -> BoxStream<'static, Result>, v01::GenericError>> { - invoke_js_subscription( - &self.bridge.lookup_preimage, - Some(key), - parse_preimage_lookup_item, - ) - .boxed() - } -} - // Account, signing, and statement-store flows live in the Rust core itself. // The JS bridge only carries callbacks for platform capabilities the core // cannot satisfy alone; account authority is selected by the runtime. @@ -492,27 +274,32 @@ async fn await_optional_promise(returned: JsValue) -> Result { } } -fn invoke_navigate_to( - bridge: &JsBridge, - url: &str, +fn call_js_function(fn_: &Function, args: &[JsValue]) -> Result { + let js_args = Array::new(); + for arg in args { + js_args.push(arg); + } + fn_.apply(&JsValue::NULL, &js_args).map_err(js_to_string) +} + +fn invoke_unit( + fn_: &Function, + args: Vec, ) -> impl Future> + Send { - let fn_ = bridge.navigate_to.clone(); - let url = url.to_string(); + let fn_ = fn_.clone(); SendWrapper::new(async move { - let arg = JsValue::from_str(&url); - let returned = fn_.call1(&JsValue::NULL, &arg).map_err(js_to_string)?; + let returned = call_js_function(&fn_, &args)?; await_optional_promise(returned).await.map(|_| ()) }) } fn invoke_bool( fn_: &Function, - payload: Vec, + args: Vec, ) -> impl Future> + Send { let fn_ = fn_.clone(); SendWrapper::new(async move { - let arg = Uint8Array::from(payload.as_slice()); - let returned = fn_.call1(&JsValue::NULL, &arg).map_err(js_to_string)?; + let returned = call_js_function(&fn_, &args)?; let resolved = await_optional_promise(returned).await?; // A non-boolean resolved value is a host contract violation; surface it // rather than silently masking it as `false` (which would read as a @@ -523,23 +310,13 @@ fn invoke_bool( }) } -fn invoke_u32_unit(fn_: &Function, value: u32) -> impl Future> + Send { - let fn_ = fn_.clone(); - SendWrapper::new(async move { - let arg = JsValue::from_f64(f64::from(value)); - let returned = fn_.call1(&JsValue::NULL, &arg).map_err(js_to_string)?; - await_optional_promise(returned).await.map(|_| ()) - }) -} - fn invoke_bytes_return( fn_: &Function, - value: Vec, + args: Vec, ) -> impl Future, String>> + Send { let fn_ = fn_.clone(); SendWrapper::new(async move { - let arg = Uint8Array::from(value.as_slice()); - let returned = fn_.call1(&JsValue::NULL, &arg).map_err(js_to_string)?; + let returned = call_js_function(&fn_, &args)?; let resolved = await_optional_promise(returned).await?; resolved .dyn_into::() @@ -548,197 +325,45 @@ fn invoke_bytes_return( }) } -fn parse_preimage_lookup_item(value: JsValue) -> Result>, String> { - if value.is_null() || value.is_undefined() { - return Ok(None); - } - value - .dyn_into::() - .map(|array| Some(array.to_vec())) - .map_err(|_| "preimage lookup item must be Uint8Array, null, or undefined".to_string()) -} - -fn parse_theme_item(value: JsValue) -> Result { - if let Some(theme) = value.as_string() { - return match theme.as_str() { - "Light" | "light" => Ok(v01::ThemeVariant::Light), - "Dark" | "dark" => Ok(v01::ThemeVariant::Dark), - _ => Err("theme item string must be Light or Dark".to_string()), - }; - } - if let Some(theme) = value.as_f64() { - return match theme as u8 { - 0 if theme == 0.0 => Ok(v01::ThemeVariant::Light), - 1 if theme == 1.0 => Ok(v01::ThemeVariant::Dark), - _ => Err("theme item number must be 0 or 1".to_string()), - }; - } - value - .dyn_into::() - .map_err(|_| "theme item must be Light, Dark, 0, 1, or encoded ThemeVariant".to_string()) - .and_then(|array| { - v01::ThemeVariant::decode(&mut array.to_vec().as_slice()) - .map_err(|_| "encoded ThemeVariant item did not decode".to_string()) - }) -} - -/// Plain JS object mirroring the generated `AuthState` TS tagged union: -/// `{ tag, value }` with `value` omitted for unit variants. -fn auth_state_to_js(state: &AuthState) -> JsValue { - let object = js_sys::Object::new(); - let set = |key: &str, value: &JsValue| { - let _ = Reflect::set(&object, &JsValue::from_str(key), value); - }; - match state { - AuthState::Disconnected => { - set("tag", &JsValue::from_str("Disconnected")); - } - AuthState::Pairing { deeplink } => { - set("tag", &JsValue::from_str("Pairing")); - let value = js_sys::Object::new(); - let _ = Reflect::set( - &value, - &JsValue::from_str("deeplink"), - &JsValue::from_str(deeplink), - ); - set("value", &value.into()); - } - AuthState::Connected(info) => { - set("tag", &JsValue::from_str("Connected")); - set("value", &session_ui_info_to_js(info)); - } - AuthState::LoginFailed { reason } => { - set("tag", &JsValue::from_str("LoginFailed")); - let value = js_sys::Object::new(); - let _ = Reflect::set( - &value, - &JsValue::from_str("reason"), - &JsValue::from_str(reason), - ); - set("value", &value.into()); - } - } - object.into() -} - -/// Plain JS object mirroring the generated `SessionUiInfo` TS interface. -fn session_ui_info_to_js(info: &SessionUiInfo) -> JsValue { - let object = js_sys::Object::new(); - let set = |key: &str, value: &JsValue| { - let _ = Reflect::set(&object, &JsValue::from_str(key), value); - }; - set("publicKey", &Uint8Array::from(info.public_key.as_slice())); - if let Some(identity_account_id) = &info.identity_account_id { - set( - "identityAccountId", - &Uint8Array::from(identity_account_id.as_slice()), - ); - } - if let Some(lite_username) = &info.lite_username { - set("liteUsername", &JsValue::from_str(lite_username)); - } - if let Some(full_username) = &info.full_username { - set("fullUsername", &JsValue::from_str(full_username)); - } - object.into() -} - -fn invoke_core_storage_read( - bridge: &JsBridge, - key: CoreStorageKey, +fn invoke_optional_bytes_return( + fn_: &Function, + args: Vec, + expected: &'static str, ) -> impl Future>, String>> + Send { - let fn_ = bridge.core_storage_read.clone(); + let fn_ = fn_.clone(); SendWrapper::new(async move { - let key_arg = Uint8Array::from(key.encode().as_slice()); - let returned = fn_.call1(&JsValue::NULL, &key_arg).map_err(js_to_string)?; + let returned = call_js_function(&fn_, &args)?; let resolved = await_optional_promise(returned).await?; if resolved.is_null() || resolved.is_undefined() { return Ok(None); } - let array = resolved.dyn_into::().map_err(|_| { - "readCoreStorage must resolve to Uint8Array, null or undefined".to_string() - })?; - Ok(Some(array.to_vec())) - }) -} - -fn invoke_core_storage_write( - bridge: &JsBridge, - key: CoreStorageKey, - value: Vec, -) -> impl Future> + Send { - let fn_ = bridge.core_storage_write.clone(); - SendWrapper::new(async move { - let key_arg = Uint8Array::from(key.encode().as_slice()); - let value_arg = Uint8Array::from(value.as_slice()); - let returned = fn_ - .call2(&JsValue::NULL, &key_arg, &value_arg) - .map_err(js_to_string)?; - await_optional_promise(returned).await.map(|_| ()) + resolved + .dyn_into::() + .map(|array| Some(array.to_vec())) + .map_err(|_| expected.to_string()) }) } -fn invoke_core_storage_clear( - bridge: &JsBridge, - key: CoreStorageKey, -) -> impl Future> + Send { - let fn_ = bridge.core_storage_clear.clone(); - SendWrapper::new(async move { - let key_arg = Uint8Array::from(key.encode().as_slice()); - let returned = fn_.call1(&JsValue::NULL, &key_arg).map_err(js_to_string)?; - await_optional_promise(returned).await.map(|_| ()) - }) +fn decode_bytes(bytes: Vec, message: &str) -> Result { + T::decode(&mut bytes.as_slice()).map_err(|_| message.to_string()) } -fn invoke_local_storage_read( - bridge: &JsBridge, - key: &str, -) -> impl Future>, String>> + Send { - let fn_ = bridge.local_storage_read.clone(); - let key = key.to_string(); - SendWrapper::new(async move { - let key_arg = JsValue::from_str(&key); - let returned = fn_.call1(&JsValue::NULL, &key_arg).map_err(js_to_string)?; - let resolved = await_optional_promise(returned).await?; - if resolved.is_null() || resolved.is_undefined() { - return Ok(None); - } - let array = resolved - .dyn_into::() - .map_err(|_| "read must resolve to Uint8Array, null or undefined".to_string())?; - Ok(Some(array.to_vec())) - }) -} - -fn invoke_local_storage_write( - bridge: &JsBridge, - key: &str, - value: &[u8], -) -> impl Future> + Send { - let fn_ = bridge.local_storage_write.clone(); - let key = key.to_string(); - let value = value.to_vec(); - SendWrapper::new(async move { - let key_arg = JsValue::from_str(&key); - let value_arg = Uint8Array::from(value.as_slice()); - let returned = fn_ - .call2(&JsValue::NULL, &key_arg, &value_arg) - .map_err(js_to_string)?; - await_optional_promise(returned).await.map(|_| ()) - }) +fn decode_js_item(value: JsValue, label: &str) -> Result { + let bytes = value + .dyn_into::() + .map_err(|_| format!("{label} item must be Uint8Array"))? + .to_vec(); + decode_bytes(bytes, &format!("encoded {label} item did not decode")) } -fn invoke_local_storage_clear( - bridge: &JsBridge, - key: &str, -) -> impl Future> + Send { - let fn_ = bridge.local_storage_clear.clone(); - let key = key.to_string(); - SendWrapper::new(async move { - let key_arg = JsValue::from_str(&key); - let returned = fn_.call1(&JsValue::NULL, &key_arg).map_err(js_to_string)?; - await_optional_promise(returned).await.map(|_| ()) - }) +fn parse_optional_bytes_item(value: JsValue) -> Result>, String> { + if value.is_null() || value.is_undefined() { + return Ok(None); + } + value + .dyn_into::() + .map(|array| Some(array.to_vec())) + .map_err(|_| "optional bytes item must be Uint8Array, null, or undefined".to_string()) } fn js_to_string(value: JsValue) -> String { diff --git a/rust/crates/truapi-server/src/wasm/generated_bridge.rs b/rust/crates/truapi-server/src/wasm/generated_bridge.rs new file mode 100644 index 00000000..50f2614b --- /dev/null +++ b/rust/crates/truapi-server/src/wasm/generated_bridge.rs @@ -0,0 +1,300 @@ +//! Auto-generated by truapi-codegen. Do not edit. +//! +//! Mechanical wasm-bindgen callback bridge derived from +//! `truapi-platform`. Raw callback names and payload shapes match the +//! generated TypeScript host-callback adapter. + +use futures::stream::BoxStream; +use js_sys::{Function, Uint8Array}; +use parity_scale_codec::Encode; +use truapi::v01; +use wasm_bindgen::JsValue; + +use super::{ + WasmPlatform, call_js_function, decode_bytes, decode_js_item, generic, get_function, + invoke_bool, invoke_bytes_return, invoke_js_subscription, invoke_optional_bytes_return, + invoke_unit, parse_optional_bytes_item, +}; + +/// JS-side callbacks invoked by the wasm platform bridge. Methods with +/// Rust default bodies are still required here because the generated TS +/// adapter resolves optional host callbacks before constructing this +/// raw callback object. +pub(super) struct JsBridge { + pub(super) auth_state_changed: Function, + pub(super) chain_connect: Function, + pub(super) read_core_storage: Function, + pub(super) write_core_storage: Function, + pub(super) clear_core_storage: Function, + pub(super) feature_supported: Function, + pub(super) navigate_to: Function, + pub(super) push_notification: Function, + pub(super) cancel_notification: Function, + pub(super) device_permission: Function, + pub(super) remote_permission: Function, + pub(super) submit_preimage: Function, + pub(super) lookup_preimage: Function, + pub(super) read: Function, + pub(super) write: Function, + pub(super) clear: Function, + pub(super) subscribe_theme: Function, + pub(super) confirm_user_action: Function, +} + +impl JsBridge { + pub(super) fn from_js(callbacks: &JsValue) -> Result { + Ok(Self { + auth_state_changed: get_function(callbacks, "authStateChanged")?, + chain_connect: get_function(callbacks, "chainConnect")?, + read_core_storage: get_function(callbacks, "readCoreStorage")?, + write_core_storage: get_function(callbacks, "writeCoreStorage")?, + clear_core_storage: get_function(callbacks, "clearCoreStorage")?, + feature_supported: get_function(callbacks, "featureSupported")?, + navigate_to: get_function(callbacks, "navigateTo")?, + push_notification: get_function(callbacks, "pushNotification")?, + cancel_notification: get_function(callbacks, "cancelNotification")?, + device_permission: get_function(callbacks, "devicePermission")?, + remote_permission: get_function(callbacks, "remotePermission")?, + submit_preimage: get_function(callbacks, "submitPreimage")?, + lookup_preimage: get_function(callbacks, "lookupPreimage")?, + read: get_function(callbacks, "read")?, + write: get_function(callbacks, "write")?, + clear: get_function(callbacks, "clear")?, + subscribe_theme: get_function(callbacks, "subscribeTheme")?, + confirm_user_action: get_function(callbacks, "confirmUserAction")?, + }) + } +} + +impl truapi_platform::AuthPresenter for WasmPlatform { + fn auth_state_changed(&self, state: truapi_platform::AuthState) { + if let Err(reason) = call_js_function( + &self.bridge.auth_state_changed, + &vec![Uint8Array::from(state.encode().as_slice()).into()], + ) { + web_sys::console::error_1(&JsValue::from_str(&reason)); + } + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::CoreStorage for WasmPlatform { + async fn read_core_storage( + &self, + key: truapi_platform::CoreStorageKey, + ) -> Result>, v01::GenericError> { + invoke_optional_bytes_return( + &self.bridge.read_core_storage, + vec![Uint8Array::from(key.encode().as_slice()).into()], + "readCoreStorage must resolve to Uint8Array, null or undefined", + ) + .await + .map_err(generic) + } + + async fn write_core_storage( + &self, + key: truapi_platform::CoreStorageKey, + value: Vec, + ) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.write_core_storage, + vec![ + Uint8Array::from(key.encode().as_slice()).into(), + Uint8Array::from(value.as_slice()).into(), + ], + ) + .await + .map_err(generic) + } + + async fn clear_core_storage( + &self, + key: truapi_platform::CoreStorageKey, + ) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.clear_core_storage, + vec![Uint8Array::from(key.encode().as_slice()).into()], + ) + .await + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Features for WasmPlatform { + async fn feature_supported( + &self, + request: v01::HostFeatureSupportedRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.feature_supported, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "featureSupported response did not decode", + ) + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Navigation for WasmPlatform { + async fn navigate_to(&self, url: String) -> Result<(), v01::HostNavigateToError> { + invoke_unit(&self.bridge.navigate_to, vec![JsValue::from_str(&url)]) + .await + .map_err(|reason| v01::HostNavigateToError::Unknown { reason }) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Notifications for WasmPlatform { + async fn push_notification( + &self, + notification: v01::HostPushNotificationRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.push_notification, + vec![Uint8Array::from(notification.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "pushNotification response did not decode", + ) + .map_err(generic) + } + + async fn cancel_notification(&self, id: v01::NotificationId) -> Result<(), v01::GenericError> { + invoke_unit( + &self.bridge.cancel_notification, + vec![JsValue::from_f64(f64::from(id))], + ) + .await + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::Permissions for WasmPlatform { + async fn device_permission( + &self, + request: v01::HostDevicePermissionRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.device_permission, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "devicePermission response did not decode", + ) + .map_err(generic) + } + + async fn remote_permission( + &self, + request: v01::RemotePermissionRequest, + ) -> Result { + let bytes = invoke_bytes_return( + &self.bridge.remote_permission, + vec![Uint8Array::from(request.encode().as_slice()).into()], + ) + .await + .map_err(generic)?; + decode_bytes::( + bytes, + "remotePermission response did not decode", + ) + .map_err(generic) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::PreimageHost for WasmPlatform { + async fn submit_preimage(&self, value: Vec) -> Result, v01::PreimageSubmitError> { + invoke_bytes_return( + &self.bridge.submit_preimage, + vec![Uint8Array::from(value.as_slice()).into()], + ) + .await + .map_err(|reason| v01::PreimageSubmitError::Unknown { reason }) + } + + fn lookup_preimage( + &self, + key: Vec, + ) -> BoxStream<'static, Result>, v01::GenericError>> { + invoke_js_subscription( + &self.bridge.lookup_preimage, + Some(key), + parse_optional_bytes_item, + ) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::ProductStorage for WasmPlatform { + async fn read(&self, key: String) -> Result>, v01::HostLocalStorageReadError> { + invoke_optional_bytes_return( + &self.bridge.read, + vec![JsValue::from_str(&key)], + "read must resolve to Uint8Array, null or undefined", + ) + .await + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } + + async fn write( + &self, + key: String, + value: Vec, + ) -> Result<(), v01::HostLocalStorageReadError> { + invoke_unit( + &self.bridge.write, + vec![ + JsValue::from_str(&key), + Uint8Array::from(value.as_slice()).into(), + ], + ) + .await + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } + + async fn clear(&self, key: String) -> Result<(), v01::HostLocalStorageReadError> { + invoke_unit(&self.bridge.clear, vec![JsValue::from_str(&key)]) + .await + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) + } +} + +impl truapi_platform::ThemeHost for WasmPlatform { + fn subscribe_theme(&self) -> BoxStream<'static, Result> { + invoke_js_subscription(&self.bridge.subscribe_theme, None, parse_theme_variant_item) + } +} + +#[truapi_platform::async_trait] +impl truapi_platform::UserConfirmation for WasmPlatform { + async fn confirm_user_action( + &self, + review: truapi_platform::UserConfirmationReview, + ) -> Result { + invoke_bool( + &self.bridge.confirm_user_action, + vec![Uint8Array::from(review.encode().as_slice()).into()], + ) + .await + .map_err(generic) + } +} + +fn parse_theme_variant_item(value: JsValue) -> Result { + decode_js_item::(value, "ThemeVariant") +} diff --git a/scripts/codegen.sh b/scripts/codegen.sh index 86c17f78..da696fd6 100755 --- a/scripts/codegen.sh +++ b/scripts/codegen.sh @@ -11,6 +11,7 @@ # --platform-input target/doc/truapi_platform.json # --platform-ts-output js/packages/truapi-host-wasm/src/generated # --platform-wasm-adapter-output js/packages/truapi-host-wasm/src/generated +# --platform-rust-output rust/crates/truapi-server/src/wasm # --codec-version 1 # # The client surface defaults to the latest wire version any versioned @@ -34,6 +35,7 @@ cargo run -p truapi-codegen -- \ --platform-input target/doc/truapi_platform.json \ --platform-ts-output js/packages/truapi-host-wasm/src/generated \ --platform-wasm-adapter-output js/packages/truapi-host-wasm/src/generated \ + --platform-rust-output rust/crates/truapi-server/src/wasm \ --explorer-output js/packages/truapi/src/explorer \ --codec-version 1 @@ -70,3 +72,4 @@ echo "Generated playground metadata at js/packages/truapi/src/playground/codegen echo "Generated client examples at playground/test/generated/examples/" echo "Generated Rust dispatcher at rust/crates/truapi-server/src/generated/" echo "Generated host-callbacks WASM adapter at js/packages/truapi-host-wasm/src/generated/" +echo "Generated Rust WASM bridge at rust/crates/truapi-server/src/wasm/generated_bridge.rs" From f4468fc4a3faa45e5f222d142df9576a724d9967 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Sun, 5 Jul 2026 19:32:15 +0200 Subject: [PATCH 02/13] feat(truapi-host-cli): headless pairing + signing hosts for local e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `truapi-host` binary with three roles that replace the external signing-bot for local end-to-end testing: - `relay`: an in-memory statement-store the two hosts pair over. - `pairing-host`: a seedless host (PairingHostRuntime) that presents a pairing deeplink and bridges product byte-frames over WebSocket. - `signing-host`: a wallet-local host (SigningHostRuntime) that answers the handshake and auto-signs, driven by the new SSO responder. truapi-server gains the §B responder half and the signing-host operations it needs: encode-side handshake helpers and responder codecs in `host_logic/sso/`, v4 extrinsic assembly (`host_logic/transaction.rs`), `runtime/signing_host/sso_responder.rs`, and `sign_payload` / `create_transaction` on the signing host. Product accounts and `rootUserAccountId` derive from `//wallet` (host-spec C.0/C.5) via a new sr25519 hard-junction helper. Bandersnatch ring-VRF product-account aliases (`get_account_alias`) use the `verifiable` crate, and lite-username attestation (`host_logic/attestation.rs` + CLI) registers a People-chain username so `get_user_id` resolves; the pairing host can resolve usernames from the real People chain while SSO stays on the relay (`identity_chain_genesis_hash`). The bun e2e driver (`e2e/`) runs the playground's own generated example sources against the pairing host and writes `explorer/diagnosis-reports/ headless.md`. With `E2E_LIVE_CHAIN=1` and a registered signer account it matches the browser host on every method it passes (43/44; the lone failure is a chain example that sends a deliberately-invalid operation id). --- CLAUDE.md | 1 + Cargo.lock | 960 +++++++++++++++++- README.md | 1 + explorer/diagnosis-reports/headless.md | 68 ++ rust/crates/truapi-host-cli/Cargo.toml | 40 + rust/crates/truapi-host-cli/README.md | 108 ++ rust/crates/truapi-host-cli/e2e/diagnosis.ts | 97 ++ rust/crates/truapi-host-cli/e2e/driver.ts | 151 +++ rust/crates/truapi-host-cli/e2e/run-e2e.ts | 277 +++++ rust/crates/truapi-host-cli/e2e/run.sh | 17 + .../truapi-host-cli/e2e/wait-and-sign.sh | 19 + .../crates/truapi-host-cli/e2e/ws-provider.ts | 57 ++ .../crates/truapi-host-cli/src/attestation.rs | 216 ++++ rust/crates/truapi-host-cli/src/chain.rs | 170 ++++ .../truapi-host-cli/src/frame_server.rs | 114 +++ rust/crates/truapi-host-cli/src/main.rs | 279 +++++ rust/crates/truapi-host-cli/src/platform.rs | 247 +++++ rust/crates/truapi-host-cli/src/relay.rs | 297 ++++++ rust/crates/truapi-platform/src/lib.rs | 21 + rust/crates/truapi-server/Cargo.toml | 4 + rust/crates/truapi-server/src/host_core.rs | 24 +- rust/crates/truapi-server/src/host_logic.rs | 7 + .../truapi-server/src/host_logic/alias.rs | 80 ++ .../src/host_logic/attestation.rs | 166 +++ .../truapi-server/src/host_logic/entropy.rs | 10 +- .../src/host_logic/product_account.rs | 21 + .../src/host_logic/sso/messages.rs | 303 +++++- .../src/host_logic/sso/pairing.rs | 267 +++++ .../src/host_logic/transaction.rs | 207 ++++ rust/crates/truapi-server/src/lib.rs | 1 + rust/crates/truapi-server/src/runtime.rs | 5 +- .../truapi-server/src/runtime/identity.rs | 81 +- .../truapi-server/src/runtime/signing_host.rs | 332 +++++- .../src/runtime/signing_host/sso_responder.rs | 397 ++++++++ .../truapi-server/src/runtime/sso_pairing.rs | 2 +- 35 files changed, 4926 insertions(+), 121 deletions(-) create mode 100644 explorer/diagnosis-reports/headless.md create mode 100644 rust/crates/truapi-host-cli/Cargo.toml create mode 100644 rust/crates/truapi-host-cli/README.md create mode 100644 rust/crates/truapi-host-cli/e2e/diagnosis.ts create mode 100644 rust/crates/truapi-host-cli/e2e/driver.ts create mode 100644 rust/crates/truapi-host-cli/e2e/run-e2e.ts create mode 100755 rust/crates/truapi-host-cli/e2e/run.sh create mode 100755 rust/crates/truapi-host-cli/e2e/wait-and-sign.sh create mode 100644 rust/crates/truapi-host-cli/e2e/ws-provider.ts create mode 100644 rust/crates/truapi-host-cli/src/attestation.rs create mode 100644 rust/crates/truapi-host-cli/src/chain.rs create mode 100644 rust/crates/truapi-host-cli/src/frame_server.rs create mode 100644 rust/crates/truapi-host-cli/src/main.rs create mode 100644 rust/crates/truapi-host-cli/src/platform.rs create mode 100644 rust/crates/truapi-host-cli/src/relay.rs create mode 100644 rust/crates/truapi-server/src/host_logic/alias.rs create mode 100644 rust/crates/truapi-server/src/host_logic/attestation.rs create mode 100644 rust/crates/truapi-server/src/host_logic/transaction.rs create mode 100644 rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs diff --git a/CLAUDE.md b/CLAUDE.md index 4ad2ef6e..19beb772 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,7 @@ rust/crates/ truapi-macros/ #[wire(id = N)] proc-macro truapi-platform/ Host syscall traits (storage, navigation, consent, ...) truapi-server/ Rust runtime hosts implement; ships as WASM (browser/node) + truapi-host-cli/ Headless pairing-host + signing-host + dev statement-store relay CLIs; local e2e signing-bot replacement js/packages/ truapi/ @parity/truapi TS package; generated TS lives under ignored paths truapi-host-wasm/ @parity/truapi-host-wasm: WASM-backed host runtime. Subpath entries: diff --git a/Cargo.lock b/Cargo.lock index 5004eda9..cd8a25d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,7 +20,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -37,6 +37,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -99,6 +120,194 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ark-bls12-381" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df4dcc01ff89867cd86b0da835f23c3f02738353aaee7dde7495af71363b8d5" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-serialize", + "ark-std", +] + +[[package]] +name = "ark-ec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" +dependencies = [ + "ahash", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "educe", + "fnv", + "hashbrown 0.15.5", + "itertools 0.13.0", + "num-bigint", + "num-integer", + "num-traits", + "rayon", + "zeroize", +] + +[[package]] +name = "ark-ed-on-bls12-381-bandersnatch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1786b2e3832f6f0f7c8d62d5d5a282f6952a1ab99981c54cd52b6ac1d8f02df5" +dependencies = [ + "ark-bls12-381", + "ark-ec", + "ark-ff", + "ark-std", +] + +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "arrayvec 0.7.6", + "digest 0.10.7", + "educe", + "itertools 0.13.0", + "num-bigint", + "num-traits", + "paste", + "rayon", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "ark-ff-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ark-poly" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27" +dependencies = [ + "ahash", + "ark-ff", + "ark-serialize", + "ark-std", + "educe", + "fnv", + "hashbrown 0.15.5", + "rayon", +] + +[[package]] +name = "ark-scale" +version = "0.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985c81a9c7b23a72f62b7b20686d5326d2a9956806f37de9ee35cb1238faf0c0" +dependencies = [ + "ark-serialize", + "ark-std", + "parity-scale-codec", + "scale-info", +] + +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "arrayvec 0.7.6", + "digest 0.10.7", + "num-bigint", + "rayon", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ark-std" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" +dependencies = [ + "num-traits", + "rand 0.8.6", + "rayon", +] + +[[package]] +name = "ark-transcript" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c1c928edb9d8ff24cb5dcb7651d3a98494fff3099eee95c2404cd813a9139f" +dependencies = [ + "ark-ff", + "ark-serialize", + "ark-std", + "digest 0.10.7", + "rand_core 0.6.4", + "sha3", +] + +[[package]] +name = "ark-vrf" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b9bd02dbd2f282fe742d51f681adb2745a79dc8a025bc8d16cac9b255d55bf7" +dependencies = [ + "ark-bls12-381", + "ark-ec", + "ark-ed-on-bls12-381-bandersnatch", + "ark-ff", + "ark-serialize", + "ark-std", + "digest 0.10.7", + "generic-array", + "rayon", + "sha2 0.10.9", + "w3f-ring-proof", + "zeroize", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -299,6 +508,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ "bitcoin_hashes", + "serde", + "unicode-normalization", ] [[package]] @@ -380,6 +591,19 @@ dependencies = [ "piper", ] +[[package]] +name = "bounded-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee8eddd066a8825ec5570528e6880471210fd5d88cb6abbe1cfdd51ca249c33" +dependencies = [ + "jam-codec", + "log", + "parity-scale-codec", + "scale-info", + "serde", +] + [[package]] name = "bs58" version = "0.5.1" @@ -441,6 +665,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.9.1" @@ -449,7 +679,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -619,6 +860,34 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -647,7 +916,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -659,7 +928,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "typenum", ] @@ -689,7 +958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", "fiat-crypto", @@ -709,6 +978,12 @@ dependencies = [ "syn", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "der" version = "0.7.10" @@ -831,12 +1106,24 @@ dependencies = [ "ed25519", "hashbrown 0.16.1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sha2 0.10.9", "subtle", "zeroize", ] +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "either" version = "1.16.0" @@ -856,12 +1143,32 @@ dependencies = [ "generic-array", "group", "hkdf", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", ] +[[package]] +name = "enum-ordinalize" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07f808d588c10e464ea6f7d3eaed500049eff30aaac103460f61828c2d65b3eb" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -923,7 +1230,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -956,7 +1263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" dependencies = [ "byteorder", - "rand", + "rand 0.8.6", "rustc-hex", "static_assertions", ] @@ -1147,10 +1454,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", + "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -1159,7 +1469,8 @@ version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea1015b5a70616b688dc230cfe50c8af89d972cb132d5a622814d29773b10b9" dependencies = [ - "rand_core", + "rand 0.8.6", + "rand_core 0.6.4", ] [[package]] @@ -1225,7 +1536,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1235,6 +1546,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", "foldhash 0.1.5", ] @@ -1333,12 +1645,94 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.7", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1507,12 +1901,27 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1528,6 +1937,34 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jam-codec" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb948eace373d99de60501a02fb17125d30ac632570de20dccc74370cdd611b9" +dependencies = [ + "arrayvec 0.7.6", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "jam-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "jam-codec-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "319af585c4c8a6b5552a52b7787a1ab3e4d59df7614190b1f85b9b842488789d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "jni" version = "0.21.1" @@ -1685,7 +2122,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -1740,7 +2177,7 @@ dependencies = [ "libsecp256k1-core", "libsecp256k1-gen-ecmult", "libsecp256k1-gen-genmult", - "rand", + "rand 0.8.6", "serde", "sha2 0.9.9", "typenum", @@ -1811,6 +2248,21 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.8.0" @@ -1825,7 +2277,7 @@ checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" dependencies = [ "byteorder", "keccak", - "rand_core", + "rand_core 0.6.4", "zeroize", ] @@ -2017,6 +2469,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pbkdf2" version = "0.12.2" @@ -2108,7 +2566,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -2120,7 +2578,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -2184,18 +2642,74 @@ dependencies = [ name = "proc-macro-crate" version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ - "toml_edit", + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "quinn-udp" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ - "unicode-ident", + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", ] [[package]] @@ -2227,7 +2741,18 @@ checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20 0.10.1", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] @@ -2237,7 +2762,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2249,6 +2774,41 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2258,6 +2818,61 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.7", +] + [[package]] name = "ring" version = "0.17.14" @@ -2339,6 +2954,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -2392,6 +3008,12 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -2407,6 +3029,7 @@ version = "2.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346a3b32eba2640d17a9cb5927056b08f3de90f65b72fe09402c2ad07d684d0b" dependencies = [ + "bitvec", "cfg-if", "derive_more 1.0.0", "parity-scale-codec", @@ -2446,7 +3069,7 @@ dependencies = [ "curve25519-dalek", "getrandom_or_panic", "merlin", - "rand_core", + "rand_core 0.6.4", "serde_bytes", "sha2 0.10.9", "subtle", @@ -2569,6 +3192,18 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2576,7 +3211,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -2588,7 +3223,7 @@ checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ "block-buffer 0.9.0", "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.9.0", "opaque-debug", ] @@ -2600,7 +3235,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -2694,7 +3329,7 @@ dependencies = [ "bip39", "blake2-rfc", "bs58", - "chacha20", + "chacha20 0.9.1", "crossbeam-queue", "derive_more 2.1.1", "ed25519-zebra", @@ -2707,7 +3342,7 @@ dependencies = [ "hashbrown 0.16.1", "hex", "hmac 0.12.1", - "itertools", + "itertools 0.14.0", "libm", "libsecp256k1", "merlin", @@ -2718,7 +3353,7 @@ dependencies = [ "pbkdf2", "pin-project", "poly1305", - "rand", + "rand 0.8.6", "rand_chacha", "ruzstd", "schnorrkel", @@ -2750,7 +3385,7 @@ dependencies = [ "bip39", "blake2-rfc", "bs58", - "chacha20", + "chacha20 0.9.1", "crossbeam-queue", "derive_more 2.1.1", "ed25519-zebra", @@ -2763,7 +3398,7 @@ dependencies = [ "hashbrown 0.16.1", "hex", "hmac 0.12.1", - "itertools", + "itertools 0.14.0", "libm", "libsecp256k1", "merlin", @@ -2774,7 +3409,7 @@ dependencies = [ "pbkdf2", "pin-project", "poly1305", - "rand", + "rand 0.8.6", "rand_chacha", "ruzstd", "schnorrkel", @@ -2812,12 +3447,12 @@ dependencies = [ "futures-util", "hashbrown 0.16.1", "hex", - "itertools", + "itertools 0.14.0", "log", "lru", "parking_lot", "pin-project", - "rand", + "rand 0.8.6", "rand_chacha", "serde", "serde_json", @@ -2849,7 +3484,7 @@ dependencies = [ "futures", "httparse", "log", - "rand", + "rand 0.8.6", "sha1", ] @@ -2983,6 +3618,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -3093,9 +3737,11 @@ version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ + "bytes", "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -3131,6 +3777,23 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", ] [[package]] @@ -3177,6 +3840,51 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -3206,6 +3914,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", ] [[package]] @@ -3214,9 +3934,16 @@ version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", "sharded-slab", + "smallvec", "thread_local", + "tracing", "tracing-core", + "tracing-log", ] [[package]] @@ -3244,6 +3971,32 @@ dependencies = [ "truapi", ] +[[package]] +name = "truapi-host-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "bip39", + "blake2-rfc", + "clap", + "futures", + "futures-util", + "hex", + "parity-scale-codec", + "reqwest", + "rustls", + "serde_json", + "tokio", + "tokio-stream", + "tokio-tungstenite", + "tracing", + "tracing-subscriber", + "truapi", + "truapi-platform", + "truapi-server", +] + [[package]] name = "truapi-macros" version = "0.1.0" @@ -3302,6 +4055,7 @@ dependencies = [ "truapi-platform", "unicode-normalization", "url", + "verifiable", "wasm-bindgen", "wasm-bindgen-futures", "wasm-bindgen-test", @@ -3310,6 +4064,32 @@ dependencies = [ "zeroize", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.6", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "twox-hash" version = "1.6.3" @@ -3400,6 +4180,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3412,12 +4198,83 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "verifiable" +version = "0.5.0" +source = "git+https://github.com/paritytech/verifiable?rev=f65b39df04f2f9a453d78550438b189c96785285#f65b39df04f2f9a453d78550438b189c96785285" +dependencies = [ + "ark-scale", + "ark-serialize", + "ark-vrf", + "bounded-collections", + "parity-scale-codec", + "scale-info", + "sha2 0.10.9", + "smallvec", + "spin", +] + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "w3f-pcs" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ea1046a1deb6d26c34ba2d1f1bab4222d695d126502ee765f80b021753cb674" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "merlin", + "rayon", +] + +[[package]] +name = "w3f-plonk-common" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30408cda37b81bd7257319942584c794c5784d00d749757bc664656749a1472a" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "getrandom_or_panic", + "rand_core 0.6.4", + "rayon", + "w3f-pcs", +] + +[[package]] +name = "w3f-ring-proof" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cbfc4cb881a934e6f33c25927bf955d0cb18e52b94528bbc5fa28dddedb4cd1" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "ark-transcript", + "rayon", + "w3f-pcs", + "w3f-plonk-common", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -3428,6 +4285,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -3668,6 +4534,24 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -3965,7 +4849,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ "curve25519-dalek", - "rand_core", + "rand_core 0.6.4", "serde", "zeroize", ] diff --git a/README.md b/README.md index f8abf3c9..6f48cff9 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ rust/crates/ truapi-macros/ #[wire(id = N)] proc-macro truapi-platform/ Host syscall traits used by truapi-server (storage, navigation, consent, ...) truapi-server/ Rust runtime that hosts implement: dispatcher, frames, SCALE, WASM surface + truapi-host-cli/ Headless pairing-host + signing-host + dev relay CLIs for local e2e (signing-bot replacement) js/packages/ truapi/ @parity/truapi TypeScript client truapi-host-wasm/ @parity/truapi-host-wasm: WASM-backed host runtime; entries `.` diff --git a/explorer/diagnosis-reports/headless.md b/explorer/diagnosis-reports/headless.md new file mode 100644 index 00000000..ca1791cd --- /dev/null +++ b/explorer/diagnosis-reports/headless.md @@ -0,0 +1,68 @@ +## Truapi Headless Pairing Host Diagnosis + +| Method | Status | Details | +| --- | --- | --- | +| `Account/connection_status_subscribe` | ✅ | | +| `Account/get_account` | ✅ | | +| `Account/get_account_alias` | ✅ | | +| `Account/create_account_proof` | ⏭️ | | +| `Account/get_legacy_accounts` | ✅ | | +| `Account/get_user_id` | ✅ | | +| `Account/request_login` | ✅ | | +| `Chain/follow_head_subscribe` | ✅ | | +| `Chain/get_head_header` | ✅ | | +| `Chain/get_head_body` | ✅ | | +| `Chain/get_head_storage` | ✅ | | +| `Chain/call_head` | ✅ | | +| `Chain/unpin_head` | ✅ | | +| `Chain/continue_head` | ✅ | | +| `Chain/stop_head_operation` | ✅ | | +| `Chain/get_spec_genesis_hash` | ✅ | | +| `Chain/get_spec_chain_name` | ✅ | | +| `Chain/get_spec_properties` | ✅ | | +| `Chain/broadcast_transaction` | ✅ | | +| `Chain/stop_transaction` | ❌ | stopTransaction failed: { "error": { "tag": "HostFailure", "value": { "reason": "remote_chain_transaction_stop: User error: Invalid operation id (-32602)" } } } | +| `Chat/create_room` | ⏭️ | | +| `Chat/register_bot` | ⏭️ | | +| `Chat/list_subscribe` | ⏭️ | | +| `Chat/post_message` | ⏭️ | | +| `Chat/action_subscribe` | ⏭️ | | +| `Chat/custom_message_render_subscribe` | ⏭️ | | +| `Coin Payment/create_purse` | ⏭️ | | +| `Coin Payment/query_purse` | ⏭️ | | +| `Coin Payment/rebalance_purse` | ⏭️ | | +| `Coin Payment/delete_purse` | ⏭️ | | +| `Coin Payment/create_receivable` | ⏭️ | | +| `Coin Payment/create_cheque` | ⏭️ | | +| `Coin Payment/deposit` | ⏭️ | | +| `Coin Payment/refund` | ⏭️ | | +| `Coin Payment/listen_for_payment` | ⏭️ | | +| `Entropy/derive` | ✅ | | +| `Local Storage/read` | ✅ | | +| `Local Storage/write` | ✅ | | +| `Local Storage/clear` | ✅ | | +| `Notifications/send_push_notification` | ✅ | | +| `Notifications/cancel_push_notification` | ✅ | | +| `Payment/balance_subscribe` | ⏭️ | | +| `Payment/top_up` | ⏭️ | | +| `Payment/request` | ⏭️ | | +| `Payment/status_subscribe` | ⏭️ | | +| `Permissions/request_device_permission` | ✅ | | +| `Permissions/request_remote_permission` | ✅ | | +| `Preimage/lookup_subscribe` | ✅ | | +| `Preimage/submit` | ✅ | | +| `Resource Allocation/request` | ✅ | | +| `Signing/create_transaction` | ✅ | | +| `Signing/create_transaction_with_legacy_account` | ✅ | | +| `Signing/sign_raw_with_legacy_account` | ✅ | | +| `Signing/sign_payload_with_legacy_account` | ✅ | | +| `Signing/sign_raw` | ✅ | | +| `Signing/sign_payload` | ✅ | | +| `Statement Store/subscribe` | ✅ | | +| `Statement Store/create_proof` | ✅ | | +| `Statement Store/submit` | ✅ | | +| `Statement Store/create_proof_authorized` | ✅ | | +| `System/handshake` | ✅ | | +| `System/feature_supported` | ✅ | | +| `System/navigate_to` | ✅ | | +| `Theme/subscribe` | ✅ | | diff --git a/rust/crates/truapi-host-cli/Cargo.toml b/rust/crates/truapi-host-cli/Cargo.toml new file mode 100644 index 00000000..c9548dac --- /dev/null +++ b/rust/crates/truapi-host-cli/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "truapi-host-cli" +version = "0.1.0" +edition.workspace = true +description = "Headless TrUAPI hosts: a signing-host companion and a pairing host, plus a dev statement-store relay, for end-to-end testing without an external signer service" +license = "MIT" + +[[bin]] +name = "truapi-host" +path = "src/main.rs" + +[lints.rust] +unsafe_code = "forbid" + +[dependencies] +truapi = { path = "../truapi" } +truapi-platform = { path = "../truapi-platform" } +truapi-server = { path = "../truapi-server" } +anyhow = "1" +async-trait = "0.1" +bip39 = "2" +blake2-rfc = { version = "0.2", default-features = false } +clap = { version = "4", features = ["derive"] } +futures = "0.3" +futures-util = "0.3" +hex = "0.4" +parity-scale-codec = { version = "3", features = ["derive"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +rustls = { version = "0.23", default-features = false, features = ["ring"] } +serde_json = "1" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "net", "time", "signal"] } +tokio-stream = { version = "0.1", features = ["sync"] } +tokio-tungstenite = { version = "0.24", features = ["connect", "rustls-tls-webpki-roots"] } +# TLS is compiled in so the pairing host *can* reach real `wss://` chain nodes +# for the `Chain/*` playground methods. Live-chain routing is opt-in +# (`E2E_LIVE_CHAIN=1`); it is off by default because streaming chainHead from a +# live node over the shared product connection is not yet robust and can drop +# that connection mid-run. See src/chain.rs. +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md new file mode 100644 index 00000000..add81aa9 --- /dev/null +++ b/rust/crates/truapi-host-cli/README.md @@ -0,0 +1,108 @@ +# truapi-host-cli + +Headless TrUAPI hosts for local end-to-end testing, built on `truapi-server`. +They replace the external signing-bot service: two CLI processes take the two +host-spec §B roles and pair over a local statement-store, so the playground's +own tests can run against a real signer with no Novasama-operated dependency. + +One binary, `truapi-host`, with three roles: + +| Command | Role | +| --- | --- | +| `relay` | In-memory statement-store the two hosts pair over (dev test double). | +| `pairing-host` | Seedless host: presents a pairing deeplink, serves product frames over WebSocket. | +| `signing-host` | Wallet-local host: answers a pairing deeplink, auto-signs (the signing-bot replacement). | + +The signing host reuses the `truapi-server` signing-host runtime and its new +SSO responder (`runtime/signing_host/sso_responder.rs`); the pairing host is the +existing `PairingHostRuntime`. Both talk to the relay over a native WebSocket +`JsonRpcConnection`, and the pairing host bridges product byte-frames to the +runtime over a second WebSocket (one binary message per SCALE `ProtocolMessage`, +matching the browser transport). + +## End-to-end test + +`e2e/run-e2e.ts` boots the relay, a pairing host, and (once login begins) a +signing host, then drives the real `@parity/truapi` client against the pairing +host. Two modes: + +- default: a curated battery of signer-backed methods (login, get account, raw + and payload signing, transaction construction, entropy) — a deterministic + gate, 7/7. +- `E2E_DIAGNOSIS=1`: runs the playground's own generated example sources through + the playground's `runExample`, i.e. literally the playground diagnosis. Gated + on the signer-critical methods; chain-node methods and deferred features + (ring-VRF alias, identity, live-chain transaction assembly) are reported but + not gated, since the hermetic relay is a statement store, not a full node. + +```bash +# One-time JS setup (generated client + built package + playground deps): +./scripts/codegen.sh +( cd js/packages/truapi && bun install && bunx tsc -b ) +( cd playground && bun install ) + +# Run it: +bash rust/crates/truapi-host-cli/e2e/run.sh # curated battery +E2E_DIAGNOSIS=1 bash rust/crates/truapi-host-cli/e2e/run.sh # full diagnosis +``` + +## Manual use + +```bash +cargo build -p truapi-host-cli +BIN=target/debug/truapi-host + +$BIN relay --listen 127.0.0.1:9944 & +$BIN pairing-host --relay ws://127.0.0.1:9944 --frame-listen 127.0.0.1:9955 & +# A product connects to ws://127.0.0.1:9955 and calls account.requestLogin; +# the pairing host prints `PAIRING_DEEPLINK `. Hand it to the signer: +$BIN signing-host --relay ws://127.0.0.1:9944 --deeplink '' +``` + +Signing is auto-approved via the platform's `UserConfirmation`; pass +`--reject` to the signing host to refuse every sensitive action (negative +tests). + +## Playground diagnosis coverage + +`E2E_DIAGNOSIS=1` writes `explorer/diagnosis-reports/headless.md` in the same +table shape as `web.md` (the dotli browser host), for a direct diff. Run with +`E2E_LIVE_CHAIN=1` to route `Chain/*` to real paseo-next-v2 nodes: + +```bash +E2E_LIVE_CHAIN=1 E2E_DIAGNOSIS=1 bun rust/crates/truapi-host-cli/e2e/run-e2e.ts +``` + +With live chain on, the diagnosis is **43 passed, 1 failed, 20 skipped**. The +headless stack matches the browser host on every method it passes and also +passes 3 the browser host fails (`Signing/sign_raw_with_legacy_account`, +`Signing/sign_payload_with_legacy_account`, +`Statement Store/create_proof_authorized`). The one remaining failure is +environmental, not a host defect: + +- `Chain/stop_transaction` — the example passes a hardcoded bogus `operationId`; + the real RPC node rejects it (`-32602`), whereas the browser host's smoldot + tolerates unknown ids. + +## Scope / gaps + +- **Chain methods** route to real `wss://` nodes when `E2E_LIVE_CHAIN=1` + (`src/chain.rs`, `PASEO_NEXT_V2_CHAIN_ENDPOINTS`); off by default so the + curated battery stays hermetic and network-free. A rustls crypto provider is + installed at startup for the TLS connections. +- **Ring-VRF product-account aliases** are implemented natively via the + `verifiable` crate (`get_account_alias`); on wasm they remain `Unavailable`. +- **`get_user_id`** resolves the signing account's username from People-chain + `Resources.Consumers`, so the pairing host needs `--resolve-identity` (which + points identity lookups at the real People chain while SSO stays on the + relay). The signing host presents its `//wallet//sso` account as its + statement identity. `truapi-host signing-host --username ` registers a + fresh lite username via the identity backend (`src/attestation.rs`); first + registration is backend-async and can take minutes (ring onboarding), so the + e2e uses an account that is already registered. `truapi-host identity-check + --mnemonic ` probes which derivation carries a username. +- **On-chain resource allocation** returns `Unavailable` on the signing host. +- Everything else the browser host exercises passes: signing (raw, payload, + create-transaction, and their legacy variants), statement store, entropy, + aliases, preimage, storage, permissions, notifications, theme, system, chain + (with `E2E_LIVE_CHAIN=1`), and user id. diff --git a/rust/crates/truapi-host-cli/e2e/diagnosis.ts b/rust/crates/truapi-host-cli/e2e/diagnosis.ts new file mode 100644 index 00000000..d55a571f --- /dev/null +++ b/rust/crates/truapi-host-cli/e2e/diagnosis.ts @@ -0,0 +1,97 @@ +// Runs the playground's own generated example sources against a headless +// pairing host, using the playground's `runExample` so these are literally the +// tests the playground diagnosis runs. Pass/fail is decided by the example +// body (it resolves on success, throws via `assert` on failure), exactly as in +// `playground/src/lib/auto-test.ts`. +import { + runExample, + type LogEntry, +} from "../../../../playground/src/lib/example-runner.ts"; +import { services } from "../../../../js/packages/truapi/src/playground/codegen/services.ts"; +import type { TrUApiClient } from "../../../../js/packages/truapi/src/index.ts"; + +// Mirrors auto-test.ts. +const UNARY_TIMEOUT_MS = 10_000; +const SIGNING_TIMEOUT_MS = 30_000; +const SSO_TIMEOUT_MS = 60_000; +const SKIPPED_SERVICES = new Set(["Chat", "Coin Payment", "Payment"]); +const SKIPPED_METHODS = new Set(["Account/create_account_proof"]); +const LONG_TIMEOUT_METHODS = new Set([ + "Account/get_account_alias", + "Resource Allocation/request", + "Signing/sign_payload", + "Signing/sign_raw", + "Signing/sign_raw_with_legacy_account", + "Signing/sign_payload_with_legacy_account", + "Signing/create_transaction", + "Signing/create_transaction_with_legacy_account", + "Preimage/submit", +]); +const METHOD_TIMEOUT_MS = new Map([ + ["Account/get_account_alias", SSO_TIMEOUT_MS], + ["Resource Allocation/request", SSO_TIMEOUT_MS], + ["Preimage/lookup_subscribe", SSO_TIMEOUT_MS], + ["Preimage/submit", SSO_TIMEOUT_MS], + ["Signing/create_transaction", SSO_TIMEOUT_MS], +]); + +export type DiagnosisStatus = "pass" | "fail" | "skipped"; +export interface DiagnosisRow { + id: string; + status: DiagnosisStatus; + output: string; +} + +async function runOne( + client: TrUApiClient, + serviceName: string, + method: { name: string; exampleSource?: string }, +): Promise { + const id = `${serviceName}/${method.name}`; + if (SKIPPED_SERVICES.has(serviceName) || SKIPPED_METHODS.has(id)) { + return { id, status: "skipped", output: "" }; + } + if (!method.exampleSource) { + return { id, status: "fail", output: "no runnable example" }; + } + const timeoutMs = + METHOD_TIMEOUT_MS.get(id) ?? + (LONG_TIMEOUT_METHODS.has(id) ? SIGNING_TIMEOUT_MS : UNARY_TIMEOUT_MS); + + const logs: LogEntry[] = []; + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`timed out after ${timeoutMs / 1000}s`)), timeoutMs); + }); + let run: Awaited> | undefined; + try { + run = await Promise.race([ + runExample({ source: method.exampleSource, client, onLog: (e) => logs.push(e) }), + timeout, + ]); + await Promise.race([run.promise, timeout]); + return { id, status: "pass", output: joinLogs(logs) ?? "ok" }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const log = joinLogs(logs); + return { id, status: "fail", output: log ? `${log}\n${message}` : message }; + } finally { + if (timer !== undefined) clearTimeout(timer); + run?.cancel(); + } +} + +function joinLogs(logs: LogEntry[]): string | undefined { + return logs.length === 0 ? undefined : logs.map((l) => l.text).join("\n"); +} + +/** Run every generated example sequentially, like the playground diagnosis. */ +export async function runDiagnosis(client: TrUApiClient): Promise { + const rows: DiagnosisRow[] = []; + for (const service of services) { + for (const method of service.methods) { + rows.push(await runOne(client, service.name, method)); + } + } + return rows; +} diff --git a/rust/crates/truapi-host-cli/e2e/driver.ts b/rust/crates/truapi-host-cli/e2e/driver.ts new file mode 100644 index 00000000..10b8f1c4 --- /dev/null +++ b/rust/crates/truapi-host-cli/e2e/driver.ts @@ -0,0 +1,151 @@ +// Product-side test battery run against a headless pairing host. +// +// Constructs the real @parity/truapi client over a WebSocket to the pairing +// host's frame endpoint and drives the SSO-class methods the playground +// diagnosis exercises against the signer: login, account lookup, raw signing, +// payload signing, transaction construction, and product entropy. Each method +// is one pass/fail case, mirroring how the playground diagnosis reports. +import { + createClient, + createTransport, + type TrUApiClient, +} from "../../../../js/packages/truapi/src/index.ts"; +import { wsProvider } from "./ws-provider.ts"; + +export interface CaseResult { + name: string; + ok: boolean; + detail: string; +} + +const PRODUCT_ID = "truapi-playground.dot"; +const GENESIS_HASH = `0x${"11".repeat(32)}` as const; + +function productAccount(index = 0) { + return { dotNsIdentifier: PRODUCT_ID, derivationIndex: index }; +} + +/** Connect a client to the pairing host and return it plus the open signal. */ +export function connect(frameUrl: string): { + client: TrUApiClient; + opened: Promise; + dispose: () => void; +} { + const provider = wsProvider(frameUrl); + const transport = createTransport(provider); + const client = createClient(transport); + return { client, opened: provider.opened, dispose: () => provider.dispose() }; +} + +/** Begin login; resolves when the host reports the pairing outcome. */ +export function beginLogin(client: TrUApiClient) { + return client.account.requestLogin({ reason: undefined }); +} + +/** Run the signing battery against an already-paired client. */ +export async function runBattery(client: TrUApiClient): Promise { + const results: CaseResult[] = []; + + const record = async ( + name: string, + run: () => Promise<{ ok: boolean; detail: string }>, + ) => { + try { + results.push({ name, ...(await run()) }); + } catch (error) { + results.push({ name, ok: false, detail: `threw: ${String(error)}` }); + } + }; + + await record("account.getAccount", async () => { + const result = await client.account.getAccount({ + productAccountId: productAccount(), + }); + return result.match( + (value) => ({ + ok: value.account.publicKey.startsWith("0x") && value.account.publicKey.length > 4, + detail: value.account.publicKey.slice(0, 18), + }), + (error) => ({ ok: false, detail: JSON.stringify(error) }), + ); + }); + + await record("signing.signRaw(bytes)", async () => { + const result = await client.signing.signRaw({ + account: productAccount(), + payload: { tag: "Bytes", value: { bytes: "0xdeadbeef" } }, + }); + return result.match( + (value) => ({ + ok: value.signature.length === 130 || value.signature.length === 132, + detail: value.signature.slice(0, 18), + }), + (error) => ({ ok: false, detail: JSON.stringify(error) }), + ); + }); + + await record("signing.signRaw(message)", async () => { + const result = await client.signing.signRaw({ + account: productAccount(), + payload: { tag: "Payload", value: { payload: "hello from e2e" } }, + }); + return result.match( + (value) => ({ ok: value.signature.startsWith("0x"), detail: value.signature.slice(0, 18) }), + (error) => ({ ok: false, detail: JSON.stringify(error) }), + ); + }); + + await record("signing.signPayload", async () => { + const result = await client.signing.signPayload({ + account: productAccount(), + payload: { + blockHash: GENESIS_HASH, + blockNumber: "0x01", + era: "0x00", + genesisHash: GENESIS_HASH, + method: "0x0400", + nonce: "0x00", + specVersion: "0x01000000", + tip: "0x00", + transactionVersion: "0x01000000", + signedExtensions: [], + version: 4, + assetId: undefined, + metadataHash: undefined, + mode: undefined, + withSignedTransaction: undefined, + }, + }); + return result.match( + (value) => ({ ok: value.signature.startsWith("0x"), detail: value.signature.slice(0, 18) }), + (error) => ({ ok: false, detail: JSON.stringify(error) }), + ); + }); + + await record("signing.createTransaction", async () => { + const result = await client.signing.createTransaction({ + signer: productAccount(), + genesisHash: GENESIS_HASH, + callData: "0x0000", + extensions: [{ id: "CheckNonce", extra: "0x04", additionalSigned: "0x" }], + txExtVersion: 0, + }); + return result.match( + (value) => ({ + ok: value.transaction.startsWith("0x") && value.transaction.length > 4, + detail: `${value.transaction.length} chars`, + }), + (error) => ({ ok: false, detail: JSON.stringify(error) }), + ); + }); + + await record("entropy.derive", async () => { + const result = await client.entropy.derive({ context: "0x6d792d6b6579" }); + return result.match( + (value) => ({ ok: value.entropy.startsWith("0x"), detail: value.entropy.slice(0, 18) }), + (error) => ({ ok: false, detail: JSON.stringify(error) }), + ); + }); + + return results; +} diff --git a/rust/crates/truapi-host-cli/e2e/run-e2e.ts b/rust/crates/truapi-host-cli/e2e/run-e2e.ts new file mode 100644 index 00000000..19fd5757 --- /dev/null +++ b/rust/crates/truapi-host-cli/e2e/run-e2e.ts @@ -0,0 +1,277 @@ +// End-to-end orchestrator: relay + pairing host + signing host + product driver. +// +// Boots the dev statement-store relay, a headless pairing host (frame server), +// connects the real @parity/truapi client, starts login, hands the pairing +// deeplink to a headless signing host, and once paired runs the signing +// battery. Prints a pass/fail summary and exits non-zero on any failure. +import { resolve } from "node:path"; +import { readFileSync } from "node:fs"; +import { beginLogin, connect, runBattery, type CaseResult } from "./driver.ts"; +import { runDiagnosis, type DiagnosisRow } from "./diagnosis.ts"; + +// Load `e2e/.env` (gitignored) so a registered signer mnemonic can be kept +// out of the command line. Existing environment variables win. +function loadDotenv() { + try { + for (const line of readFileSync(resolve(import.meta.dir, ".env"), "utf8").split("\n")) { + const match = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); + if (!match || line.trimStart().startsWith("#")) continue; + const key = match[1]; + const value = match[2].replace(/^["']|["']$/g, ""); + if (process.env[key] === undefined) process.env[key] = value; + } + } catch { + /* no .env: fall back to the process environment */ + } +} +loadDotenv(); + +// When set, run the playground's own generated example sources (the literal +// playground diagnosis) instead of the curated battery. +const USE_DIAGNOSIS = process.env.E2E_DIAGNOSIS === "1"; + +// Signer-critical, chain-node-independent methods that must pass in diagnosis +// mode. Everything else in the full diagnosis either needs a live chain node +// (all Chain/*, live-chain transaction assembly) or is a deferred feature. +const MUST_PASS = new Set([ + "account.requestLogin", + "Account/request_login", + "Account/connection_status_subscribe", + "Account/get_account", + "Account/get_legacy_accounts", + "Signing/sign_raw", + "Signing/sign_payload", + "Signing/sign_raw_with_legacy_account", + "Signing/sign_payload_with_legacy_account", + "Resource Allocation/request", + "Statement Store/create_proof", + "Statement Store/create_proof_authorized", + "Statement Store/submit", + "Entropy/derive", +]); + +const REPO_ROOT = resolve(import.meta.dir, "../../../.."); +const BINARY = resolve(REPO_ROOT, "target/debug/truapi-host"); + +/** A spawned host process whose stdout lines can be awaited by prefix. */ +class HostProcess { + private readonly lines: string[] = []; + private readonly waiters: Array<{ prefix: string; resolve: (line: string) => void }> = []; + readonly proc: ReturnType; + + constructor(label: string, args: string[]) { + this.proc = Bun.spawn([BINARY, ...args], { + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, RUST_LOG: process.env.RUST_LOG ?? "info" }, + }); + this.pump(label, this.proc.stdout, false); + this.pump(label, this.proc.stderr, true); + } + + private async pump(label: string, stream: ReadableStream, isErr: boolean) { + const decoder = new TextDecoder(); + let buffer = ""; + for await (const chunk of stream) { + buffer += decoder.decode(chunk, { stream: true }); + let index: number; + while ((index = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, index); + buffer = buffer.slice(index + 1); + if (isErr) { + if (process.env.E2E_VERBOSE) console.error(`[${label}:err] ${line}`); + continue; + } + console.error(`[${label}] ${line}`); + this.lines.push(line); + for (let i = this.waiters.length - 1; i >= 0; i--) { + if (line.startsWith(this.waiters[i].prefix)) { + this.waiters.splice(i, 1)[0].resolve(line); + } + } + } + } + } + + waitFor(prefix: string, timeoutMs = 30_000): Promise { + const existing = this.lines.find((line) => line.startsWith(prefix)); + if (existing) return Promise.resolve(existing); + return new Promise((resolvePromise, rejectPromise) => { + const timer = setTimeout( + () => rejectPromise(new Error(`timed out waiting for "${prefix}"`)), + timeoutMs, + ); + this.waiters.push({ + prefix, + resolve: (line) => { + clearTimeout(timer); + resolvePromise(line); + }, + }); + }); + } + + kill() { + this.proc.kill(); + } +} + +function wsUrlFrom(line: string, prefix: string): string { + return line.slice(prefix.length).trim(); +} + +async function main() { + if (!(await Bun.file(BINARY).exists())) { + console.error(`missing binary ${BINARY}; run: cargo build -p truapi-host-cli`); + process.exit(2); + } + + const processes: HostProcess[] = []; + const cleanup = () => processes.forEach((p) => p.kill()); + + try { + const relay = new HostProcess("relay", ["relay", "--listen", "127.0.0.1:0"]); + processes.push(relay); + const relayUrl = wsUrlFrom(await relay.waitFor("RELAY_LISTENING "), "RELAY_LISTENING "); + + // With live chain on, resolve usernames from the real People chain so + // get_user_id works (SSO still runs over the relay). + const liveChain = process.env.E2E_LIVE_CHAIN === "1"; + const pairing = new HostProcess("pairing", [ + "pairing-host", + "--relay", + relayUrl, + "--frame-listen", + "127.0.0.1:0", + ...(liveChain ? ["--resolve-identity"] : []), + ]); + processes.push(pairing); + const frameUrl = wsUrlFrom(await pairing.waitFor("FRAMES_LISTENING "), "FRAMES_LISTENING "); + + console.error(`connecting product client to ${frameUrl}`); + const { client, opened, dispose } = connect(frameUrl); + await opened; + + // Start login without awaiting: the pairing host emits the deeplink, which + // we hand to the signing host; login resolves once pairing completes. + const loginPromise = beginLogin(client); + + const deeplink = wsUrlFrom( + await pairing.waitFor("PAIRING_DEEPLINK "), + "PAIRING_DEEPLINK ", + ); + + // External-signer mode: hand the relay URL + deeplink to a separate + // signing-host process (e.g. a second tmux pane) via a file, instead of + // spawning the signer here. Lets the two hosts run as visibly separate + // processes. + const handoffFile = process.env.E2E_HANDOFF_FILE; + if (handoffFile) { + // Two lines: relay URL, then deeplink — trivially read by a shell script. + await Bun.write(handoffFile, `${relayUrl}\n${deeplink}\n`); + console.error(`wrote relay + deeplink handoff to ${handoffFile}; waiting for external signer`); + } else { + console.error(`launching signing host for deeplink ${deeplink.slice(0, 48)}...`); + const mnemonic = process.env.E2E_SIGNER_MNEMONIC; + const signing = new HostProcess("signing", [ + "signing-host", + "--relay", + relayUrl, + "--deeplink", + deeplink, + ...(mnemonic ? ["--mnemonic", mnemonic] : []), + ]); + processes.push(signing); + await signing.waitFor("SIGNING_HOST_READY"); + } + + const login = await loginPromise; + const loginOk = login.isOk() && login.value === "Success"; + console.error(`login result: ${login.isOk() ? login.value : JSON.stringify(login.error)}`); + if (!loginOk) throw new Error("pairing/login did not succeed"); + + if (USE_DIAGNOSIS) { + const rows = await runDiagnosis(client); + dispose(); + const report = renderReport(rows); + const path = resolve(REPO_ROOT, "explorer/diagnosis-reports/headless.md"); + await Bun.write(path, report); + // Print the full web.md-shape table so the pane carries the same detail. + console.log("\n" + report); + const pass = rows.filter((r) => r.status === "pass").length; + const fail = rows.filter((r) => r.status === "fail").length; + const skip = rows.filter((r) => r.status === "skipped").length; + console.log(`\nwrote ${path}`); + console.log(`${pass} passed, ${fail} failed, ${skip} skipped (of ${rows.length})`); + cleanup(); + // Gate on the signer path: chain-node methods and deferred features + // (ring-VRF alias, identity, live-chain transaction assembly) fail + // against the hermetic statement-store relay; those are environmental, + // not signing regressions. + const critical = rows.filter((r) => MUST_PASS.has(r.id) && r.status === "fail"); + if (critical.length > 0) { + console.log(`\nGATE FAILED: ${critical.map((r) => r.id).join(", ")}`); + } else { + console.log(`\nGATE PASSED: all signer-critical methods pass`); + } + process.exit(critical.length === 0 ? 0 : 1); + } + + const results: CaseResult[] = [ + { name: "account.requestLogin", ok: true, detail: String(login.value) }, + ...(await runBattery(client)), + ]; + dispose(); + printSummary(results); + cleanup(); + const failures = results.filter((r) => !r.ok); + console.log( + failures.length > 0 + ? `\nGATE FAILED: ${failures.map((r) => r.name).join(", ")}` + : `\nGATE PASSED: ${results.length} signer-critical cases`, + ); + process.exit(failures.length === 0 ? 0 : 1); + } catch (error) { + console.error(`e2e failed: ${String(error)}`); + cleanup(); + process.exit(1); + } +} + +/** Collapse whitespace runs so multi-line JSON fits one table cell. */ +function cleanDetail(output: string): string { + const collapsed = output.replace(/\s+/g, " ").trim(); + return collapsed.length > 300 ? collapsed.slice(0, 297) + "..." : collapsed; +} + +// Emit a report in the same table shape as explorer/diagnosis-reports/web.md +// so the headless run can be diffed against the browser host directly. +// Details are shown for failures (matching web.md); pass/skip cells are blank. +function renderReport(rows: DiagnosisRow[]): string { + const icon = (s: DiagnosisRow["status"]) => + s === "pass" ? "✅" : s === "skipped" ? "⏭️" : "❌"; + return ( + [ + "## Truapi Headless Pairing Host Diagnosis", + "", + "| Method | Status | Details |", + "| --- | --- | --- |", + ...rows.map( + (r) => + `| \`${r.id}\` | ${icon(r.status)} | ${r.status === "fail" ? cleanDetail(r.output) : ""} |`, + ), + ].join("\n") + "\n" + ); +} + +function printSummary(results: CaseResult[]) { + const pass = results.filter((r) => r.ok).length; + console.log("\n=== Headless host e2e results ==="); + for (const r of results) { + console.log(`${r.ok ? "PASS" : "FAIL"} ${r.name.padEnd(28)} ${r.detail}`); + } + console.log(`--------------------------------`); + console.log(`${pass}/${results.length} passed`); +} + +main(); diff --git a/rust/crates/truapi-host-cli/e2e/run.sh b/rust/crates/truapi-host-cli/e2e/run.sh new file mode 100755 index 00000000..78db0a29 --- /dev/null +++ b/rust/crates/truapi-host-cli/e2e/run.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Build the headless hosts and run the end-to-end pairing + signing test. +# +# run.sh curated signer battery (deterministic gate) +# E2E_DIAGNOSIS=1 run.sh full playground diagnosis (gated on signer methods) +# +# Prerequisites for the JS driver (one-time): +# ./scripts/codegen.sh (or generate js/packages/truapi/src/generated) +# (cd js/packages/truapi && bun install && bunx tsc -b) +# (cd playground && bun install) +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)" +cd "$ROOT" + +cargo build -p truapi-host-cli +exec bun rust/crates/truapi-host-cli/e2e/run-e2e.ts diff --git a/rust/crates/truapi-host-cli/e2e/wait-and-sign.sh b/rust/crates/truapi-host-cli/e2e/wait-and-sign.sh new file mode 100755 index 00000000..5944b650 --- /dev/null +++ b/rust/crates/truapi-host-cli/e2e/wait-and-sign.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Signing-host side of the two-pane demo: wait for the pairing host (run by +# the orchestrator in E2E_HANDOFF_FILE mode) to emit the relay URL + deeplink, +# then answer the handshake and serve the SSO session. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)" +cd "$ROOT" + +HANDOFF="${1:-/tmp/truapi-e2e-handoff.txt}" +BIN="target/debug/truapi-host" + +echo "SIGNING PANE: waiting for pairing host to present a deeplink ($HANDOFF)..." +while [ ! -s "$HANDOFF" ]; do sleep 0.2; done + +{ read -r RELAY; read -r DEEPLINK; } < "$HANDOFF" +echo "SIGNING PANE: got deeplink, answering pairing on $RELAY" +echo +exec "$BIN" signing-host --relay "$RELAY" --deeplink "$DEEPLINK" diff --git a/rust/crates/truapi-host-cli/e2e/ws-provider.ts b/rust/crates/truapi-host-cli/e2e/ws-provider.ts new file mode 100644 index 00000000..7cd7a5e4 --- /dev/null +++ b/rust/crates/truapi-host-cli/e2e/ws-provider.ts @@ -0,0 +1,57 @@ +// WebSocket `WireProvider` for @parity/truapi: one binary WS message per +// SCALE protocol frame, sends buffered until the socket opens. +import type { WireProvider } from "../../../../js/packages/truapi/src/index.ts"; + +export function wsProvider(url: string): WireProvider & { opened: Promise } { + const ws = new WebSocket(url); + ws.binaryType = "arraybuffer"; + const listeners = new Set<(message: Uint8Array) => void>(); + const closeListeners = new Set<(error: Error) => void>(); + const pending: Uint8Array[] = []; + let open = false; + + let resolveOpened: () => void; + let rejectOpened: (error: Error) => void; + const opened = new Promise((resolve, reject) => { + resolveOpened = resolve; + rejectOpened = reject; + }); + + ws.addEventListener("open", () => { + open = true; + for (const frame of pending.splice(0)) ws.send(frame); + resolveOpened(); + }); + ws.addEventListener("message", (event) => { + const bytes = new Uint8Array(event.data as ArrayBuffer); + for (const listener of listeners) listener(bytes); + }); + ws.addEventListener("close", () => { + const error = new Error("websocket closed"); + for (const listener of closeListeners) listener(error); + }); + ws.addEventListener("error", () => { + const error = new Error("websocket error"); + rejectOpened(error); + for (const listener of closeListeners) listener(error); + }); + + return { + opened, + postMessage(message: Uint8Array) { + if (open) ws.send(message); + else pending.push(message); + }, + subscribe(cb: (message: Uint8Array) => void) { + listeners.add(cb); + return () => listeners.delete(cb); + }, + subscribeClose(cb: (error: Error) => void) { + closeListeners.add(cb); + return () => closeListeners.delete(cb); + }, + dispose() { + ws.close(); + }, + }; +} diff --git a/rust/crates/truapi-host-cli/src/attestation.rs b/rust/crates/truapi-host-cli/src/attestation.rs new file mode 100644 index 00000000..c914888b --- /dev/null +++ b/rust/crates/truapi-host-cli/src/attestation.rs @@ -0,0 +1,216 @@ +//! Lite-username attestation against the People-chain identity backend. +//! +//! Ports signing-bot `attestation.ts`: fetch the backend verifier, build the +//! client proofs (`truapi_server::host_logic::attestation`), POST them to +//! `/usernames`, then poll People-chain `Resources.Consumers` until the record +//! lands. Registers the signing host's root account so the paired host can +//! resolve its username via `get_user_id`. + +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::Message; +use tracing::{info, warn}; +use truapi_server::host_logic::attestation::build_lite_registration; +use truapi_server::host_logic::identity::{ + decode_people_identity, resources_consumers_storage_key, +}; +use truapi_server::host_logic::product_account::{ + derive_root_keypair_from_entropy, derive_sr25519_hard_path, product_public_key_to_address, +}; + +/// Inputs for one attestation run. +pub struct AttestConfig { + /// Identity backend base URL including `/api/v1`. + pub backend_base: String, + /// People-chain WebSocket URL for the `Resources.Consumers` poll. + pub people_ws: String, + /// BIP-39 entropy of the signing host's root account. + pub entropy: Vec, + /// Requested lite username base (6+ lowercase letters, no digits). + pub username_base: String, +} + +/// Register (or confirm) the signing host's lite username and wait until the +/// People-chain `Resources.Consumers` record exists. Returns the candidate +/// account's SS58 address. +pub async fn attest(config: &AttestConfig) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build()?; + + let verifier = fetch_verifier(&client, &config.backend_base).await?; + let registration = build_lite_registration(&config.entropy, verifier, &config.username_base) + .map_err(|reason| anyhow::anyhow!("failed to build registration params: {reason}"))?; + info!( + candidate = %registration.candidate_account_id, + "attesting lite username '{}'", + config.username_base + ); + + submit_registration( + &client, + &config.backend_base, + &config.username_base, + ®istration, + ) + .await?; + + let storage_key = format!( + "0x{}", + hex::encode(resources_consumers_storage_key( + ®istration.candidate_public_key + )) + ); + wait_for_consumer_record(&config.people_ws, &storage_key).await?; + info!("lite username registered and confirmed on-chain"); + Ok(registration.candidate_account_id) +} + +/// Probe the People chain for which derivation of `entropy` (bare root, +/// `//wallet`, `//wallet//sso`) has a `Resources.Consumers` record, printing +/// the account and decoded username. Used to confirm a pre-onboarded account. +pub async fn check_identity(people_ws: &str, entropy: &[u8]) -> Result<()> { + let root = derive_root_keypair_from_entropy(entropy) + .map_err(|err| anyhow::anyhow!("invalid entropy: {err}"))?; + let wallet = derive_sr25519_hard_path(entropy, &["wallet"]) + .map_err(|err| anyhow::anyhow!("//wallet derivation failed: {err}"))?; + let wallet_sso = derive_sr25519_hard_path(entropy, &["wallet", "sso"]) + .map_err(|err| anyhow::anyhow!("//wallet//sso derivation failed: {err}"))?; + + for (label, public) in [ + ("", root.public.to_bytes()), + ("//wallet", wallet.public.to_bytes()), + ("//wallet//sso", wallet_sso.public.to_bytes()), + ] { + let key = format!( + "0x{}", + hex::encode(resources_consumers_storage_key(&public)) + ); + let address = product_public_key_to_address(public); + match query_storage(people_ws, &key).await { + Ok(Some(value)) => { + let decoded = hex::decode(value.strip_prefix("0x").unwrap_or(&value)) + .ok() + .and_then(|bytes| decode_people_identity(&bytes).ok()); + let username = decoded + .and_then(|id| id.full_username.or(id.lite_username)) + .unwrap_or_else(|| "".to_string()); + println!("IDENTITY_FOUND path={label} account={address} username={username}"); + } + Ok(None) => println!("IDENTITY_NONE path={label} account={address}"), + Err(err) => println!("IDENTITY_ERROR path={label} account={address} error={err}"), + } + } + Ok(()) +} + +async fn fetch_verifier(client: &reqwest::Client, backend_base: &str) -> Result<[u8; 32]> { + let url = format!("{backend_base}/attester"); + let body: Value = client + .get(&url) + .send() + .await + .with_context(|| format!("GET {url}"))? + .error_for_status()? + .json() + .await + .context("decoding attester response")?; + let hex_value = body + .get("attester") + .and_then(Value::as_str) + .context("attester response missing 'attester' field")?; + let bytes = hex::decode(hex_value.strip_prefix("0x").unwrap_or(hex_value)) + .context("attester is not valid hex")?; + <[u8; 32]>::try_from(bytes) + .map_err(|bytes| anyhow::anyhow!("attester must be 32 bytes, got {}", bytes.len())) +} + +async fn submit_registration( + client: &reqwest::Client, + backend_base: &str, + username_base: &str, + reg: &truapi_server::host_logic::attestation::LiteRegistration, +) -> Result<()> { + let url = format!("{backend_base}/usernames"); + let body = json!({ + "username": username_base, + "candidateAccountId": reg.candidate_account_id, + "candidateSignature": hex0x(®.candidate_signature), + "ringVrfKey": hex0x(®.ring_vrf_key), + "proofOfOwnership": hex0x(®.proof_of_ownership), + "identifierKey": hex0x(®.identifier_key), + "consumerRegistrationSignature": hex0x(®.consumer_registration_signature), + }); + let response = client + .post(&url) + .json(&body) + .send() + .await + .with_context(|| format!("POST {url}"))?; + let status = response.status(); + if status.is_success() { + let text = response.text().await.unwrap_or_default(); + info!(%status, body = %text, "POST /usernames accepted"); + return Ok(()); + } + let text = response.text().await.unwrap_or_default(); + // Already-registered is a soft success; the on-chain poll confirms it. + if text.contains("already") || text.contains("AlreadyRegistered") || text.contains("duplicate") + { + warn!(%status, "username already registered; confirming on-chain"); + return Ok(()); + } + bail!("username registration failed ({status}): {text}"); +} + +fn hex0x(bytes: &[u8]) -> String { + format!("0x{}", hex::encode(bytes)) +} + +async fn wait_for_consumer_record(people_ws: &str, storage_key: &str) -> Result<()> { + // First-time lite registration is backend-async and can take minutes + // (ring onboarding). The record is permanent once written, so later runs + // resolve on the first poll. + const MAX_ATTEMPTS: usize = 90; + for attempt in 1..=MAX_ATTEMPTS { + match query_storage(people_ws, storage_key).await { + Ok(Some(_)) => return Ok(()), + Ok(None) => info!("Resources.Consumers poll {attempt}/{MAX_ATTEMPTS}: empty"), + Err(err) => warn!(%err, "Resources.Consumers poll attempt {attempt} failed"), + } + if attempt < MAX_ATTEMPTS { + tokio::time::sleep(Duration::from_secs(4)).await; + } + } + bail!("Resources.Consumers record did not appear after attestation") +} + +/// One `state_getStorage` request over a fresh WebSocket; returns the value +/// hex when present. +async fn query_storage(people_ws: &str, storage_key: &str) -> Result> { + let (mut ws, _) = connect_async(people_ws).await?; + let request = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "state_getStorage", + "params": [storage_key], + }); + ws.send(Message::Text(request.to_string())).await?; + while let Some(message) = ws.next().await { + if let Message::Text(text) = message? { + let value: Value = serde_json::from_str(&text)?; + if value.get("id").and_then(Value::as_u64) == Some(1) { + let _ = ws.close(None).await; + return Ok(value + .get("result") + .and_then(Value::as_str) + .map(str::to_string)); + } + } + } + Ok(None) +} diff --git a/rust/crates/truapi-host-cli/src/chain.rs b/rust/crates/truapi-host-cli/src/chain.rs new file mode 100644 index 00000000..8114e3f3 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/chain.rs @@ -0,0 +1,170 @@ +//! Native WebSocket `ChainProvider` / `JsonRpcConnection`. +//! +//! The headless hosts reach the dev statement-store [`crate::relay`] over +//! WebSocket JSON-RPC. Every `connect` opens a fresh socket; the runtime's +//! `HostRpcClient` sits on top and speaks statement-store RPC. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use async_trait::async_trait; +use futures::stream::BoxStream; +use futures_util::{SinkExt, StreamExt}; +use tokio::sync::{broadcast, mpsc}; +use tokio_stream::wrappers::BroadcastStream; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::Message; +use tracing::debug; +use truapi::v01; +use truapi_platform::{ChainProvider, JsonRpcConnection}; + +/// Broadcast backlog for inbound JSON-RPC frames per connection. +const INBOUND_CHANNEL_CAPACITY: usize = 1024; + +/// Public paseo-next-v2 endpoints that speak the new JSON-RPC (`chainHead_v1`) +/// API, so the pairing host can serve the playground's `Chain/*` examples +/// against real nodes (read-only) just like the browser host. +const PASEO_NEXT_V2_CHAIN_ENDPOINTS: &[(&str, &str)] = &[ + // Asset Hub Next (the chain the `Chain/*` examples target). + ( + "bf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f", + "wss://paseo-asset-hub-next-rpc.polkadot.io", + ), + // Individuality/People Next (used by the create-transaction example to + // build a payload from live metadata). + ( + "c5af1826b31493f08b7e2a823842f98575b806a784126f28da9608c68665afa5", + "wss://paseo-people-next-system-rpc.polkadot.io", + ), +]; + +/// Chain provider that maps a requested genesis hash to a WebSocket endpoint. +/// +/// The all-zero genesis (the headless SSO sentinel) and any unmapped genesis +/// fall back to the local statement-store relay; known public genesis hashes +/// route to real testnet nodes. +pub struct WsChainProvider { + fallback_url: String, + by_genesis: HashMap<[u8; 32], String>, +} + +impl WsChainProvider { + pub fn new(fallback_url: impl Into) -> Self { + // Live-chain routing is opt-in: when disabled, every genesis (including + // the real testnet ones the `Chain/*` examples request) falls back to + // the local relay, which does not speak chainHead, so those methods + // fail cleanly without disturbing the SSO/signer path. + let by_genesis = if std::env::var("E2E_LIVE_CHAIN").as_deref() == Ok("1") { + PASEO_NEXT_V2_CHAIN_ENDPOINTS + .iter() + .filter_map(|(genesis_hex, url)| { + let bytes = hex::decode(genesis_hex).ok()?; + Some((<[u8; 32]>::try_from(bytes).ok()?, url.to_string())) + }) + .collect() + } else { + HashMap::new() + }; + Self { + fallback_url: fallback_url.into(), + by_genesis, + } + } + + fn url_for(&self, genesis_hash: &[u8; 32]) -> &str { + self.by_genesis + .get(genesis_hash) + .map(String::as_str) + .unwrap_or(&self.fallback_url) + } +} + +#[async_trait] +impl ChainProvider for WsChainProvider { + async fn connect( + &self, + genesis_hash: [u8; 32], + ) -> Result, v01::GenericError> { + let url = self.url_for(&genesis_hash); + debug!(genesis = %hex::encode(genesis_hash), %url, "chain connect"); + let connection = WsJsonRpcConnection::connect(url) + .await + .map_err(|reason| v01::GenericError { reason })?; + Ok(Box::new(connection)) + } +} + +/// One WebSocket JSON-RPC connection: outbound requests are queued to a writer +/// task, inbound frames are broadcast to every `responses()` stream. +pub struct WsJsonRpcConnection { + outbound: mpsc::UnboundedSender, + inbound: broadcast::Sender, + closed: Arc, +} + +impl WsJsonRpcConnection { + async fn connect(url: &str) -> Result { + let (stream, _response) = connect_async(url) + .await + .map_err(|err| format!("statement-store websocket connect failed: {err}"))?; + let (mut write, mut read) = stream.split(); + let (outbound_tx, mut outbound_rx) = mpsc::unbounded_channel::(); + let (inbound_tx, _inbound_rx) = broadcast::channel(INBOUND_CHANNEL_CAPACITY); + let closed = Arc::new(AtomicBool::new(false)); + + tokio::spawn(async move { + while let Some(message) = outbound_rx.recv().await { + if write.send(message).await.is_err() { + break; + } + } + let _ = write.close().await; + }); + + let reader_inbound = inbound_tx.clone(); + let reader_closed = closed.clone(); + tokio::spawn(async move { + while let Some(message) = read.next().await { + match message { + Ok(Message::Text(text)) => { + let _ = reader_inbound.send(text.to_string()); + } + Ok(Message::Binary(bytes)) => { + if let Ok(text) = String::from_utf8(bytes.to_vec()) { + let _ = reader_inbound.send(text); + } + } + Ok(Message::Close(_)) | Err(_) => break, + Ok(_) => {} + } + } + reader_closed.store(true, Ordering::Release); + }); + + Ok(Self { + outbound: outbound_tx, + inbound: inbound_tx, + closed, + }) + } +} + +impl JsonRpcConnection for WsJsonRpcConnection { + fn send(&self, request: String) { + if self.closed.load(Ordering::Acquire) { + return; + } + let _ = self.outbound.send(Message::Text(request)); + } + + fn responses(&self) -> BoxStream<'static, String> { + BroadcastStream::new(self.inbound.subscribe()) + .filter_map(|item| async move { item.ok() }) + .boxed() + } + + fn close(&self) { + self.closed.store(true, Ordering::Release); + } +} diff --git a/rust/crates/truapi-host-cli/src/frame_server.rs b/rust/crates/truapi-host-cli/src/frame_server.rs new file mode 100644 index 00000000..eb346e1c --- /dev/null +++ b/rust/crates/truapi-host-cli/src/frame_server.rs @@ -0,0 +1,114 @@ +//! Product-frame WebSocket bridge for the pairing host. +//! +//! Each WebSocket connection is one product: inbound binary frames are pushed +//! into a [`ProductRuntime`] and its outgoing frames are written back as +//! binary messages. One binary WS message carries exactly one SCALE +//! `ProtocolMessage`, matching the browser transport's framing. + +use std::net::SocketAddr; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use futures_util::{SinkExt, StreamExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::mpsc; +use tokio_tungstenite::accept_async; +use tokio_tungstenite::tungstenite::Message; +use tracing::{debug, info}; +use truapi_server::{FrameSink, PairingHostRuntime, ProductContext}; + +/// Frame sink that writes each outgoing protocol frame as one binary message. +struct WsFrameSink { + outbound: mpsc::UnboundedSender, +} + +impl FrameSink for WsFrameSink { + fn emit_frame(&self, frame: Vec) { + let _ = self.outbound.send(Message::Binary(frame)); + } +} + +/// Serve product frames for `product_id` on `addr` until the process exits. +/// +/// The product dispatch future is `!Send` (matching the single-threaded wasm +/// runtime), so connections are driven with `spawn_local` under a `LocalSet`. +/// The runtime's own subscription work is `Send` and still runs on the +/// multi-thread pool via the tokio spawner. +pub async fn serve( + runtime: Arc, + product_id: String, + addr: SocketAddr, +) -> Result<()> { + let listener = TcpListener::bind(addr) + .await + .with_context(|| format!("frame server failed to bind {addr}"))?; + let bound = listener.local_addr()?; + info!(%bound, %product_id, "product frame server listening"); + println!("FRAMES_LISTENING ws://{bound}"); + + let local = tokio::task::LocalSet::new(); + local + .run_until(async move { + loop { + let (stream, peer) = listener.accept().await?; + let runtime = runtime.clone(); + let product_id = product_id.clone(); + tokio::task::spawn_local(async move { + if let Err(err) = serve_connection(runtime, product_id, stream).await { + debug!(%peer, %err, "frame connection ended"); + } + }); + } + }) + .await +} + +async fn serve_connection( + runtime: Arc, + product_id: String, + stream: TcpStream, +) -> Result<()> { + let ws = accept_async(stream).await?; + let (mut write, mut read) = ws.split(); + let (outbound_tx, mut outbound_rx) = mpsc::unbounded_channel::(); + + let writer = tokio::spawn(async move { + while let Some(message) = outbound_rx.recv().await { + if write.send(message).await.is_err() { + break; + } + } + }); + + let product = ProductContext::new(product_id) + .map_err(|err| anyhow::anyhow!("invalid product id: {err}"))?; + let sink = Arc::new(WsFrameSink { + outbound: outbound_tx.clone(), + }); + let product_runtime = runtime.product_runtime(product, sink); + + while let Some(message) = read.next().await { + match message { + Ok(Message::Binary(bytes)) => { + if let Err(err) = product_runtime.receive_frame(bytes.to_vec()).await { + debug!(%err, "product runtime rejected frame"); + } + } + Ok(Message::Text(text)) => { + if let Err(err) = product_runtime + .receive_frame(text.as_bytes().to_vec()) + .await + { + debug!(%err, "product runtime rejected text frame"); + } + } + Ok(Message::Close(_)) | Err(_) => break, + Ok(_) => {} + } + } + + product_runtime.dispose(); + drop(outbound_tx); + let _ = writer.await; + Ok(()) +} diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs new file mode 100644 index 00000000..3a910b86 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -0,0 +1,279 @@ +//! Headless TrUAPI hosts for local end-to-end testing. +//! +//! Three roles, one binary: +//! - `relay`: an in-memory statement-store the two hosts pair over. +//! - `pairing-host`: a seedless host that presents a pairing deeplink and +//! serves product frames over WebSocket (the surface a product/test driver +//! talks to). +//! - `signing-host`: a wallet-local host that answers a pairing deeplink and +//! auto-signs, replacing the external signing-bot in e2e. + +mod attestation; +mod chain; +mod frame_server; +mod platform; +mod relay; + +use std::io::BufRead; +use std::net::SocketAddr; +use std::sync::Arc; + +use anyhow::{Context, Result, bail}; +use clap::{Parser, Subcommand}; +use futures::future::BoxFuture; +use truapi_platform::{HostInfo, PlatformInfo}; +use truapi_server::subscription::Spawner; +use truapi_server::{PairingHostConfig, PairingHostRuntime, SigningHostConfig, SigningHostRuntime}; + +use crate::platform::{ApprovalPolicy, CliPlatform}; + +/// Default dev mnemonic used when a signing host is started without one. +const DEFAULT_MNEMONIC: &str = + "bottom drive obey lake curtain smoke basket hold race lonely fit walk"; +/// Default product served by the pairing host's frame endpoint. +const DEFAULT_PRODUCT_ID: &str = "truapi-playground.dot"; +/// Deeplink scheme advertised by the pairing host. +const DEEPLINK_SCHEME: &str = "polkadotapp"; +/// paseo-next-v2 identity backend base (includes /api/v1). +const IDENTITY_BACKEND_BASE: &str = "https://identity-backend-next.parity-testnet.parity.io/api/v1"; +/// paseo-next-v2 People-chain WebSocket for the attestation on-chain poll. +const PEOPLE_CHAIN_WS: &str = "wss://paseo-people-next-system-rpc.polkadot.io"; +/// paseo-next-v2 People/Individuality chain genesis (username lookups). +const PEOPLE_CHAIN_GENESIS: [u8; 32] = + hex_literal_genesis("c5af1826b31493f08b7e2a823842f98575b806a784126f28da9608c68665afa5"); + +/// Decode a 64-char hex genesis at compile time. +const fn hex_literal_genesis(hex: &str) -> [u8; 32] { + let bytes = hex.as_bytes(); + let mut out = [0u8; 32]; + let mut i = 0; + while i < 32 { + out[i] = hex_nibble(bytes[i * 2]) << 4 | hex_nibble(bytes[i * 2 + 1]); + i += 1; + } + out +} + +const fn hex_nibble(c: u8) -> u8 { + match c { + b'0'..=b'9' => c - b'0', + b'a'..=b'f' => c - b'a' + 10, + _ => panic!("invalid hex digit in genesis literal"), + } +} + +#[derive(Parser)] +#[command(name = "truapi-host", about = "Headless TrUAPI hosts for e2e testing")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Run the in-memory statement-store relay. + Relay { + /// Address to listen on. + #[arg(long, default_value = "127.0.0.1:9944")] + listen: SocketAddr, + }, + /// Run a seedless pairing host and serve product frames over WebSocket. + PairingHost { + /// Statement-store relay WebSocket URL. + #[arg(long, default_value = "ws://127.0.0.1:9944")] + relay: String, + /// Address to serve product frames on. + #[arg(long, default_value = "127.0.0.1:9955")] + frame_listen: SocketAddr, + /// Product id presented to product frame connections. + #[arg(long, default_value = DEFAULT_PRODUCT_ID)] + product: String, + /// Resolve usernames from the real paseo-next-v2 People chain (so + /// `get_user_id` works), instead of only the SSO relay. + #[arg(long)] + resolve_identity: bool, + }, + /// Answer a pairing deeplink as a wallet-local signing host and auto-sign. + SigningHost { + /// Statement-store relay WebSocket URL. + #[arg(long, default_value = "ws://127.0.0.1:9944")] + relay: String, + /// BIP-39 mnemonic for the wallet root. + #[arg(long, default_value = DEFAULT_MNEMONIC)] + mnemonic: String, + /// Pairing deeplink to answer. Read from stdin when omitted. + #[arg(long)] + deeplink: Option, + /// Reject every sensitive action instead of auto-approving. + #[arg(long)] + reject: bool, + /// Register this lite username base (6+ lowercase letters) on the + /// People chain via the identity backend before pairing, so + /// `get_user_id` resolves. Requires network access. + #[arg(long)] + username: Option, + }, + /// Probe the People chain for a mnemonic's registered identity/username. + IdentityCheck { + /// BIP-39 mnemonic to probe. + #[arg(long, default_value = DEFAULT_MNEMONIC)] + mnemonic: String, + /// People-chain WebSocket URL. + #[arg(long, default_value = PEOPLE_CHAIN_WS)] + people_ws: String, + }, +} + +#[tokio::main] +async fn main() -> Result<()> { + // Install a rustls crypto provider so `wss://` chain connections work; + // rustls 0.23 panics without a process-level default provider. + let _ = rustls::crypto::ring::default_provider().install_default(); + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .with_writer(std::io::stderr) + .init(); + + match Cli::parse().command { + Command::Relay { listen } => relay::Relay::serve(listen).await, + Command::PairingHost { + relay, + frame_listen, + product, + resolve_identity, + } => run_pairing_host(relay, frame_listen, product, resolve_identity).await, + Command::SigningHost { + relay, + mnemonic, + deeplink, + reject, + username, + } => run_signing_host(relay, mnemonic, deeplink, reject, username).await, + Command::IdentityCheck { + mnemonic, + people_ws, + } => { + let entropy = bip39::Mnemonic::parse(mnemonic.trim()) + .context("invalid BIP-39 mnemonic")? + .to_entropy(); + attestation::check_identity(&people_ws, &entropy).await + } + } +} + +/// Spawner that runs runtime futures on the tokio runtime, so their WebSocket +/// connects and timers have a reactor. +fn tokio_spawner() -> Spawner { + Arc::new(|fut: BoxFuture<'static, ()>| { + tokio::spawn(fut); + }) +} + +fn host_info(name: &str) -> HostInfo { + HostInfo { + name: name.to_string(), + icon: None, + version: Some(env!("CARGO_PKG_VERSION").to_string()), + } +} + +fn platform_info() -> PlatformInfo { + PlatformInfo { + kind: Some("cli".to_string()), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + } +} + +async fn run_pairing_host( + relay: String, + frame_listen: SocketAddr, + product: String, + resolve_identity: bool, +) -> Result<()> { + let platform = CliPlatform::new(relay, ApprovalPolicy::Always); + let mut config = PairingHostConfig::new( + host_info("Headless Pairing Host"), + platform_info(), + [0u8; 32], + DEEPLINK_SCHEME.to_string(), + ) + .context("invalid pairing host config")?; + if resolve_identity { + // SSO stays on the relay ([0;32]); resolve usernames from the real + // People chain. Requires live-chain routing (E2E_LIVE_CHAIN=1). + config = config.with_identity_chain_genesis_hash(PEOPLE_CHAIN_GENESIS); + } + let runtime = Arc::new(PairingHostRuntime::new(platform, config, tokio_spawner())); + frame_server::serve(runtime, product, frame_listen).await +} + +async fn run_signing_host( + relay: String, + mnemonic: String, + deeplink: Option, + reject: bool, + username: Option, +) -> Result<()> { + let entropy = bip39::Mnemonic::parse(mnemonic.trim()) + .context("invalid BIP-39 mnemonic")? + .to_entropy(); + + if let Some(username_base) = username { + let registered = attestation::attest(&attestation::AttestConfig { + backend_base: IDENTITY_BACKEND_BASE.to_string(), + people_ws: PEOPLE_CHAIN_WS.to_string(), + entropy: entropy.clone(), + username_base, + }) + .await + .context("lite username attestation failed")?; + println!("SIGNING_HOST_ATTESTED {registered}"); + } + + let deeplink = match deeplink { + Some(deeplink) => deeplink, + None => read_deeplink_from_stdin()?, + }; + + let approval = if reject { + ApprovalPolicy::Never + } else { + ApprovalPolicy::Always + }; + let platform = CliPlatform::new(relay, approval); + let config = SigningHostConfig::new( + host_info("Headless Signing Host"), + platform_info(), + [0u8; 32], + ) + .context("invalid signing host config")?; + let runtime = SigningHostRuntime::new(platform, config, tokio_spawner()); + runtime + .activate_local_session(entropy) + .await + .map_err(|err| anyhow::anyhow!("failed to activate local session: {}", err.reason))?; + println!("SIGNING_HOST_READY"); + + let exit = runtime + .respond_to_pairing(&deeplink) + .await + .map_err(|err| anyhow::anyhow!("pairing failed: {}", err.reason))?; + println!("SIGNING_HOST_EXIT {exit:?}"); + Ok(()) +} + +fn read_deeplink_from_stdin() -> Result { + let stdin = std::io::stdin(); + for line in stdin.lock().lines() { + let line = line.context("failed to read deeplink from stdin")?; + let line = line.trim().to_string(); + if !line.is_empty() { + return Ok(line); + } + } + bail!("no pairing deeplink received on stdin"); +} diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs new file mode 100644 index 00000000..b84f3d21 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -0,0 +1,247 @@ +//! `Platform` implementation for the headless hosts. +//! +//! In-memory product and core storage, a WebSocket chain provider pointed at +//! the dev relay, and an auto-approving [`UserConfirmation`]. Auth-state +//! transitions are published on a channel so the CLI can print the pairing +//! deeplink and observe connection status. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use blake2_rfc::blake2b::blake2b; +use futures::stream::{self, BoxStream}; +use truapi::v01; +use truapi_platform::{ + AuthState, ChainProvider, CoreStorage, CoreStorageKey, Features, JsonRpcConnection, Navigation, + Notifications, Permissions, PreimageHost, ProductStorage, ThemeHost, UserConfirmation, + UserConfirmationReview, +}; + +use crate::chain::WsChainProvider; + +/// How the host answers [`UserConfirmation`] prompts. +#[derive(Clone, Copy)] +pub enum ApprovalPolicy { + /// Approve every sensitive action (default for e2e). + Always, + /// Reject every sensitive action (for negative tests). + Never, +} + +impl ApprovalPolicy { + fn approves(self) -> bool { + matches!(self, ApprovalPolicy::Always) + } +} + +/// Headless-host platform shared by both roles. +pub struct CliPlatform { + chain: WsChainProvider, + product_storage: Mutex>>, + core_storage: Mutex, Vec>>, + preimages: Mutex, Vec>>, + approval: ApprovalPolicy, +} + +impl CliPlatform { + /// Build a platform whose chain provider connects to `relay_url`. + pub fn new(relay_url: impl Into, approval: ApprovalPolicy) -> Arc { + Arc::new(Self { + chain: WsChainProvider::new(relay_url), + product_storage: Mutex::new(HashMap::new()), + core_storage: Mutex::new(HashMap::new()), + preimages: Mutex::new(HashMap::new()), + approval, + }) + } + + fn core_key(key: &CoreStorageKey) -> Vec { + use parity_scale_codec::Encode; + key.encode() + } +} + +#[async_trait] +impl ProductStorage for CliPlatform { + async fn read(&self, key: String) -> Result>, v01::HostLocalStorageReadError> { + Ok(self + .product_storage + .lock() + .expect("product storage mutex poisoned") + .get(&key) + .cloned()) + } + + async fn write( + &self, + key: String, + value: Vec, + ) -> Result<(), v01::HostLocalStorageReadError> { + self.product_storage + .lock() + .expect("product storage mutex poisoned") + .insert(key, value); + Ok(()) + } + + async fn clear(&self, key: String) -> Result<(), v01::HostLocalStorageReadError> { + self.product_storage + .lock() + .expect("product storage mutex poisoned") + .remove(&key); + Ok(()) + } +} + +#[async_trait] +impl CoreStorage for CliPlatform { + async fn read_core_storage( + &self, + key: CoreStorageKey, + ) -> Result>, v01::GenericError> { + Ok(self + .core_storage + .lock() + .expect("core storage mutex poisoned") + .get(&Self::core_key(&key)) + .cloned()) + } + + async fn write_core_storage( + &self, + key: CoreStorageKey, + value: Vec, + ) -> Result<(), v01::GenericError> { + self.core_storage + .lock() + .expect("core storage mutex poisoned") + .insert(Self::core_key(&key), value); + Ok(()) + } + + async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), v01::GenericError> { + self.core_storage + .lock() + .expect("core storage mutex poisoned") + .remove(&Self::core_key(&key)); + Ok(()) + } +} + +#[async_trait] +impl ChainProvider for CliPlatform { + async fn connect( + &self, + genesis_hash: [u8; 32], + ) -> Result, v01::GenericError> { + self.chain.connect(genesis_hash).await + } +} + +#[async_trait] +impl Navigation for CliPlatform { + async fn navigate_to(&self, url: String) -> Result<(), v01::HostNavigateToError> { + tracing::info!(%url, "navigate_to"); + Ok(()) + } +} + +#[async_trait] +impl Notifications for CliPlatform { + async fn push_notification( + &self, + _notification: v01::HostPushNotificationRequest, + ) -> Result { + Ok(v01::HostPushNotificationResponse { id: 1 }) + } +} + +#[async_trait] +impl Permissions for CliPlatform { + async fn device_permission( + &self, + _request: v01::HostDevicePermissionRequest, + ) -> Result { + Ok(v01::HostDevicePermissionResponse { + granted: self.approval.approves(), + }) + } + + async fn remote_permission( + &self, + _request: v01::RemotePermissionRequest, + ) -> Result { + Ok(v01::RemotePermissionResponse { + granted: self.approval.approves(), + }) + } +} + +#[async_trait] +impl Features for CliPlatform { + async fn feature_supported( + &self, + _request: v01::HostFeatureSupportedRequest, + ) -> Result { + Ok(v01::HostFeatureSupportedResponse { supported: true }) + } +} + +impl truapi_platform::AuthPresenter for CliPlatform { + fn auth_state_changed(&self, state: AuthState) { + // Machine-readable lines for orchestrators to observe pairing. + match &state { + AuthState::Pairing { deeplink } => println!("PAIRING_DEEPLINK {deeplink}"), + AuthState::Connected(_) => println!("PAIRING_CONNECTED"), + AuthState::Disconnected => println!("PAIRING_DISCONNECTED"), + AuthState::LoginFailed { reason } => println!("PAIRING_FAILED {reason}"), + } + } +} + +#[async_trait] +impl UserConfirmation for CliPlatform { + async fn confirm_user_action( + &self, + review: UserConfirmationReview, + ) -> Result { + tracing::debug!( + ?review, + approved = self.approval.approves(), + "confirm_user_action" + ); + Ok(self.approval.approves()) + } +} + +impl ThemeHost for CliPlatform { + fn subscribe_theme(&self) -> BoxStream<'static, Result> { + Box::pin(stream::once(async { Ok(v01::ThemeVariant::Dark) })) + } +} + +#[async_trait] +impl PreimageHost for CliPlatform { + async fn submit_preimage(&self, value: Vec) -> Result, v01::PreimageSubmitError> { + let key = blake2b(32, &[], &value).as_bytes().to_vec(); + self.preimages + .lock() + .expect("preimage mutex poisoned") + .insert(key.clone(), value); + Ok(key) + } + + fn lookup_preimage( + &self, + key: Vec, + ) -> BoxStream<'static, Result>, v01::GenericError>> { + let value = self + .preimages + .lock() + .expect("preimage mutex poisoned") + .get(&key) + .cloned(); + Box::pin(stream::once(async move { Ok(value) })) + } +} diff --git a/rust/crates/truapi-host-cli/src/relay.rs b/rust/crates/truapi-host-cli/src/relay.rs new file mode 100644 index 00000000..f1580bfa --- /dev/null +++ b/rust/crates/truapi-host-cli/src/relay.rs @@ -0,0 +1,297 @@ +//! In-memory statement-store relay for local end-to-end testing. +//! +//! Speaks the statement-store JSON-RPC intersection (host-spec N.5) over +//! WebSocket: `statement_submit`, `statement_subscribeStatement`, +//! `statement_unsubscribeStatement`. Statements are retained for the process +//! lifetime and replayed to new subscribers, so pairing works regardless of +//! which side connects first. Topic matching is on the statement's +//! `Topic1..4` fields; this is a test double, not a real statement store. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use anyhow::{Context, Result}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{Mutex, mpsc}; +use tokio_tungstenite::accept_async; +use tokio_tungstenite::tungstenite::Message; +use tracing::{debug, info, warn}; +use truapi_server::host_logic::statement_store::decode_signed_statement; + +/// Statement-store topic filter (host-spec N.5 / substrate `TopicFilter`). +#[derive(Clone)] +enum Filter { + MatchAll(Vec<[u8; 32]>), + MatchAny(Vec<[u8; 32]>), +} + +impl Filter { + fn matches(&self, topics: &[[u8; 32]]) -> bool { + match self { + Filter::MatchAll(wanted) => wanted.iter().all(|topic| topics.contains(topic)), + Filter::MatchAny(wanted) => wanted.iter().any(|topic| topics.contains(topic)), + } + } +} + +struct Subscription { + id: String, + filter: Filter, + outbound: mpsc::UnboundedSender, +} + +#[derive(Default)] +struct RelayState { + statements: Vec<(Vec, Vec<[u8; 32]>)>, + subscriptions: Vec, +} + +/// Shared relay store: retained statements plus live subscriptions. +#[derive(Clone, Default)] +pub struct Relay { + state: Arc>, + next_id: Arc, +} + +impl Relay { + /// Serve the relay on `addr` until the process exits. Returns the bound + /// address (useful when `addr` uses port 0). + pub async fn serve(addr: SocketAddr) -> Result<()> { + let listener = TcpListener::bind(addr) + .await + .with_context(|| format!("relay failed to bind {addr}"))?; + let bound = listener.local_addr()?; + info!(%bound, "statement-store relay listening"); + // Machine-readable readiness line for orchestrators. + println!("RELAY_LISTENING ws://{bound}"); + let relay = Relay::default(); + loop { + let (stream, peer) = listener.accept().await?; + let relay = relay.clone(); + tokio::spawn(async move { + if let Err(err) = relay.handle_connection(stream).await { + debug!(%peer, %err, "relay connection ended"); + } + }); + } + } + + async fn handle_connection(&self, stream: TcpStream) -> Result<()> { + let ws = accept_async(stream).await?; + let (mut write, mut read) = ws.split(); + let (outbound_tx, mut outbound_rx) = mpsc::unbounded_channel::(); + + let writer = tokio::spawn(async move { + while let Some(message) = outbound_rx.recv().await { + if write.send(message).await.is_err() { + break; + } + } + }); + + let mut connection_subscription_ids = Vec::new(); + while let Some(message) = read.next().await { + let text = match message { + Ok(Message::Text(text)) => text.to_string(), + Ok(Message::Binary(bytes)) => match String::from_utf8(bytes.to_vec()) { + Ok(text) => text, + Err(_) => continue, + }, + Ok(Message::Close(_)) | Err(_) => break, + Ok(_) => continue, + }; + if let Some(reply) = self + .handle_request(&text, &outbound_tx, &mut connection_subscription_ids) + .await + && outbound_tx.send(Message::Text(reply)).is_err() + { + break; + } + } + + self.drop_subscriptions(&connection_subscription_ids).await; + drop(outbound_tx); + let _ = writer.await; + Ok(()) + } + + /// Handle one JSON-RPC request, returning the reply frame to send back. + async fn handle_request( + &self, + text: &str, + outbound: &mpsc::UnboundedSender, + connection_subscription_ids: &mut Vec, + ) -> Option { + let request: Value = serde_json::from_str(text).ok()?; + let id = request.get("id").cloned().unwrap_or(Value::Null); + let method = request.get("method")?.as_str()?; + let params = request.get("params"); + match method { + "statement_submit" => Some(self.handle_submit(id, params).await), + "statement_subscribeStatement" => { + let (reply, sub_id) = self.handle_subscribe(id, params, outbound).await; + if let Some(sub_id) = sub_id { + connection_subscription_ids.push(sub_id); + } + Some(reply) + } + "statement_unsubscribeStatement" => { + if let Some(sub_id) = params + .and_then(|params| params.get(0)) + .and_then(Value::as_str) + { + self.drop_subscriptions(std::slice::from_ref(&sub_id.to_string())) + .await; + connection_subscription_ids.retain(|id| id != sub_id); + } + Some(result_frame(id, json!(true))) + } + other => { + warn!(method = other, "relay received unsupported method"); + Some(error_frame(id, -32601, "method not supported")) + } + } + } + + async fn handle_submit(&self, id: Value, params: Option<&Value>) -> String { + let Some(statement) = params + .and_then(|params| params.get(0)) + .and_then(Value::as_str) + .and_then(decode_hex) + else { + return error_frame(id, -32602, "invalid statement_submit params"); + }; + let topics = statement_topics(&statement); + let mut state = self.state.lock().await; + state.statements.push((statement.clone(), topics.clone())); + for subscription in &state.subscriptions { + if subscription.filter.matches(&topics) { + let _ = subscription + .outbound + .send(Message::Text(new_statements_frame( + &subscription.id, + &[&statement], + ))); + } + } + result_frame(id, json!("0xsubmitted")) + } + + async fn handle_subscribe( + &self, + id: Value, + params: Option<&Value>, + outbound: &mpsc::UnboundedSender, + ) -> (String, Option) { + let Some(filter) = params + .and_then(|params| params.get(0)) + .and_then(parse_filter) + else { + return ( + error_frame(id, -32602, "invalid statement_subscribeStatement params"), + None, + ); + }; + let sub_id = format!("relay-sub-{}", self.next_id.fetch_add(1, Ordering::Relaxed)); + let mut state = self.state.lock().await; + let backlog: Vec> = state + .statements + .iter() + .filter(|(_, topics)| filter.matches(topics)) + .map(|(statement, _)| statement.clone()) + .collect(); + state.subscriptions.push(Subscription { + id: sub_id.clone(), + filter, + outbound: outbound.clone(), + }); + drop(state); + + if !backlog.is_empty() { + let refs: Vec<&[u8]> = backlog.iter().map(Vec::as_slice).collect(); + let _ = outbound.send(Message::Text(new_statements_frame(&sub_id, &refs))); + } + (result_frame(id, json!(sub_id)), Some(sub_id)) + } + + async fn drop_subscriptions(&self, ids: &[String]) { + if ids.is_empty() { + return; + } + let mut state = self.state.lock().await; + state + .subscriptions + .retain(|subscription| !ids.contains(&subscription.id)); + } +} + +fn parse_filter(value: &Value) -> Option { + if let Some(topics) = value.get("matchAll").and_then(Value::as_array) { + return Some(Filter::MatchAll(parse_topics(topics)?)); + } + if let Some(topics) = value.get("matchAny").and_then(Value::as_array) { + return Some(Filter::MatchAny(parse_topics(topics)?)); + } + if value.as_str() == Some("any") { + return Some(Filter::MatchAny(Vec::new())); + } + None +} + +fn parse_topics(values: &[Value]) -> Option> { + values + .iter() + .map(|value| { + let bytes = value.as_str().and_then(decode_hex)?; + <[u8; 32]>::try_from(bytes).ok() + }) + .collect() +} + +fn statement_topics(statement: &[u8]) -> Vec<[u8; 32]> { + let mut topics: Vec<[u8; 32]> = decode_signed_statement(statement) + .map(|signed| signed.topics) + .unwrap_or_default(); + topics.sort(); + topics.dedup(); + topics +} + +fn decode_hex(value: &str) -> Option> { + hex::decode(value.strip_prefix("0x").unwrap_or(value)).ok() +} + +fn result_frame(id: Value, result: Value) -> String { + json!({ "jsonrpc": "2.0", "id": id, "result": result }).to_string() +} + +fn error_frame(id: Value, code: i64, message: &str) -> String { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": code, "message": message }, + }) + .to_string() +} + +fn new_statements_frame(subscription_id: &str, statements: &[&[u8]]) -> String { + let statements: Vec = statements + .iter() + .map(|statement| format!("0x{}", hex::encode(statement))) + .collect(); + json!({ + "jsonrpc": "2.0", + "method": "statement_subscribeStatement", + "params": { + "subscription": subscription_id, + "result": { + "event": "newStatements", + "data": { "statements": statements, "remaining": 0 }, + }, + }, + }) + .to_string() +} diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index b133af52..7e7f3215 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -48,6 +48,13 @@ pub struct PairingHostConfig { pub host: HostRuntimeConfig, /// People-chain genesis hash used for statement-store SSO. pub people_chain_genesis_hash: [u8; 32], + /// Optional distinct genesis for People-chain identity (username) lookups. + /// + /// In production this equals `people_chain_genesis_hash` (SSO and identity + /// share the People chain). It can be set separately so a host can run SSO + /// over one transport (e.g. a local relay) while resolving usernames from + /// the real People chain. `None` falls back to `people_chain_genesis_hash`. + pub identity_chain_genesis_hash: Option<[u8; 32]>, /// Deeplink URI scheme used in pairing QR payloads, without `://`. /// /// Host-spec L.2-L.3 define the `polkadotapp://pair` route and construction @@ -147,10 +154,24 @@ impl PairingHostConfig { let config = Self { host: HostRuntimeConfig::new(host_info, platform_info)?, people_chain_genesis_hash, + identity_chain_genesis_hash: None, pairing_deeplink_scheme, }; Ok(config) } + + /// Resolve usernames from a People chain distinct from the SSO transport. + pub fn with_identity_chain_genesis_hash(mut self, genesis_hash: [u8; 32]) -> Self { + self.identity_chain_genesis_hash = Some(genesis_hash); + self + } + + /// Genesis used for People-chain identity lookups (falls back to the SSO + /// People-chain genesis when no distinct identity chain is configured). + pub fn identity_lookup_genesis_hash(&self) -> [u8; 32] { + self.identity_chain_genesis_hash + .unwrap_or(self.people_chain_genesis_hash) + } } impl SigningHostConfig { diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index 648fed22..017927a4 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -47,6 +47,10 @@ tracing-subscriber = { version = "0.3", default-features = false, features = ["r [target.'cfg(not(target_arch = "wasm32"))'.dependencies] subxt-rpcs = { version = "0.50.1", default-features = false, features = ["native"] } +# Bandersnatch ring-VRF for signing-host account aliases. The alias path uses +# only the thin VRF (no ring/SRS), and only the native signing host needs it, +# so it is a non-wasm dependency. Pinned to the rev the mobile app references. +verifiable = { git = "https://github.com/paritytech/verifiable", rev = "f65b39df04f2f9a453d78550438b189c96785285", default-features = false, features = ["std"] } [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = "0.3" diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 8478467c..c58ea3a4 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -26,8 +26,8 @@ use truapi_platform::{ use crate::core::TrUApiCore; use crate::frame::ProtocolMessage; use crate::runtime::{ - LocalActivation, PairingHostRole, ProductAuthority, ProductRuntimeHost, RuntimeServices, - SigningHostRole, + LocalActivation, PairingHostRole, ProductAuthority, ProductRuntimeHost, ResponderExit, + RuntimeServices, SigningHostRole, respond_to_pairing, }; use crate::subscription::Spawner; use crate::transport::Transport; @@ -184,9 +184,9 @@ impl PairingHostAdmin for PairingHostRuntime { /// Owns the shared services plus signing-host state. There is no pairing flow, /// so pairing cancellation is not present here. /// -/// Raw-bytes signing and product entropy are implemented; extrinsic-payload -/// signing, transaction construction, ring-VRF aliases, and resource allocation -/// return an `Unavailable` error pending chain-metadata and on-chain support. +/// Raw-bytes and extrinsic-payload signing, v4 transaction construction, and +/// product entropy are implemented; ring-VRF aliases and resource allocation +/// return an `Unavailable` error pending on-chain support. pub struct SigningHostRuntime { services: Arc, signing_host: Arc, @@ -247,6 +247,20 @@ impl SigningHostRuntime { reason: err.reason(), }) } + + /// Answer a pairing host's handshake deeplink and serve the resulting SSO + /// session until it ends (host-spec §B responder half). Requires an + /// active local session; sensitive requests consult the platform's + /// [`truapi_platform::UserConfirmation`] before signing. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.respond_to_pairing"))] + pub async fn respond_to_pairing( + &self, + deeplink: &str, + ) -> Result { + respond_to_pairing(self.services.clone(), self.signing_host.clone(), deeplink) + .await + .map_err(|reason| v01::GenericError { reason }) + } } /// Product-scoped administration handle for host UI. diff --git a/rust/crates/truapi-server/src/host_logic.rs b/rust/crates/truapi-server/src/host_logic.rs index 183688fa..3a198a22 100644 --- a/rust/crates/truapi-server/src/host_logic.rs +++ b/rust/crates/truapi-server/src/host_logic.rs @@ -4,6 +4,12 @@ //! storage, URL handler, notification center). Everything else lives here so //! iOS, Android, and web hosts share one canonical implementation. +/// Bandersnatch ring-VRF product-account aliases (native signing host only). +#[cfg(not(target_arch = "wasm32"))] +pub mod alias; +/// Lite-person username registration parameters (native signing host only). +#[cfg(not(target_arch = "wasm32"))] +pub mod attestation; pub mod dotns; pub mod entropy; pub mod features; @@ -14,3 +20,4 @@ pub mod session; pub mod session_store; pub mod sso; pub mod statement_store; +pub mod transaction; diff --git a/rust/crates/truapi-server/src/host_logic/alias.rs b/rust/crates/truapi-server/src/host_logic/alias.rs new file mode 100644 index 00000000..6d572350 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/alias.rs @@ -0,0 +1,80 @@ +//! Bandersnatch ring-VRF product-account aliases (signing host). +//! +//! Mirrors the mobile app's `ProductAccountHolder.deriveAlias`: the alias is a +//! thin bandersnatch VRF output over a per-product context, using a +//! bandersnatch secret derived from the wallet's BIP-39 entropy. No ring +//! commitment or SRS is involved (that machinery is only for membership +//! proofs, which this path does not use). +//! +//! Reference: polkadot-app-ios-v2 `Packages/Products/.../ProductAccountHolder.swift` +//! and `verifiable-swift` over `paritytech/verifiable`. + +use blake2_rfc::blake2b::blake2b; +use verifiable::GenerateVerifiable; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +/// A product-account contextual alias. +pub struct ProductAlias { + /// 32-byte context identifier (blake2b-256 of the derivation path). + pub context: [u8; 32], + /// 32-byte ring-VRF alias output. + pub alias: [u8; 32], +} + +/// Derive the contextual alias for a product account from the wallet entropy. +/// +/// - `context = blake2b_256("/product/{product_id}/{derivation_index}")` +/// - `bandersnatch_entropy = blake2b_256(root_entropy)` +/// - `alias = BandersnatchVrf::alias_in_context(new_secret(bandersnatch_entropy), context)` +pub fn derive_product_alias( + root_entropy: &[u8], + product_id: &str, + derivation_index: u32, +) -> Result { + let derivation_path = format!("/product/{product_id}/{derivation_index}"); + let context = blake2b256(derivation_path.as_bytes()); + let bandersnatch_entropy = blake2b256(root_entropy); + let secret = BandersnatchVrfVerifiable::new_secret(bandersnatch_entropy); + let alias = BandersnatchVrfVerifiable::alias_in_context(&secret, &context) + .map_err(|err| format!("ring-VRF alias derivation failed: {err:?}"))?; + Ok(ProductAlias { context, alias }) +} + +fn blake2b256(message: &[u8]) -> [u8; 32] { + blake2b(32, &[], message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The alias is deterministic in the entropy, product id, and index, and + /// the context is the blake2b-256 of the derivation path. + #[test] + fn alias_is_deterministic_with_expected_context() { + let entropy = [0xABu8; 16]; + let first = derive_product_alias(&entropy, "truapi-playground.dot", 0).unwrap(); + let again = derive_product_alias(&entropy, "truapi-playground.dot", 0).unwrap(); + + assert_eq!(first.context, again.context); + assert_eq!(first.alias, again.alias); + assert_eq!( + first.context, + blake2b256(b"/product/truapi-playground.dot/0") + ); + } + + #[test] + fn alias_varies_by_product_and_index() { + let entropy = [0xABu8; 16]; + let base = derive_product_alias(&entropy, "a.dot", 0).unwrap(); + let other_product = derive_product_alias(&entropy, "b.dot", 0).unwrap(); + let other_index = derive_product_alias(&entropy, "a.dot", 1).unwrap(); + + assert_ne!(base.alias, other_product.alias); + assert_ne!(base.alias, other_index.alias); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/attestation.rs b/rust/crates/truapi-server/src/host_logic/attestation.rs new file mode 100644 index 00000000..53832596 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/attestation.rs @@ -0,0 +1,166 @@ +//! Lite-person username registration parameters (signing host, native only). +//! +//! Builds the client-side proofs the People-chain identity backend needs to +//! attest a lite username for an account: an sr25519 proof-of-ownership, a +//! bandersnatch ring-VRF member key + plain-VRF proof, and an sr25519 +//! consumer-registration signature. The backend submits the on-chain +//! `register_lite_person` extrinsic; the host never signs a chain extrinsic. +//! +//! Byte layout mirrors signing-bot `src/core/attestation.ts` for backend +//! parity. The registered account is the account whose secret signs here; the +//! paired host resolves the username from `Resources.Consumers[that account]`. + +use blake2_rfc::blake2b::blake2b; +use parity_scale_codec::Encode; +use verifiable::GenerateVerifiable; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +use crate::host_logic::product_account::{ + SR25519_SIGNING_CONTEXT, derive_sr25519_hard_path, product_public_key_to_address, +}; +use crate::host_logic::sso::pairing::derive_p256_keypair_from_entropy; + +/// sr25519 proof-of-ownership message prefix (exact bytes; one space). +const REGISTER_PREFIX: &[u8] = b"pop:people-lite:register using"; +/// Domain label for the P-256 identifier key advertised to the backend. +const IDENTIFIER_KEY_LABEL: &[u8] = b"chat-encryption"; + +/// Client-computed parameters for `POST /usernames`. +pub struct LiteRegistration { + /// SS58 (prefix 42) of the candidate account. + pub candidate_account_id: String, + /// Raw 32-byte candidate public key (the future `Resources.Consumers` key). + pub candidate_public_key: [u8; 32], + /// sr25519 signature over `prefix ‖ candidate_pub ‖ ring_vrf_key`. + pub candidate_signature: [u8; 64], + /// Bandersnatch ring-VRF member key. + pub ring_vrf_key: [u8; 32], + /// Plain bandersnatch VRF proof over the same proof message. + pub proof_of_ownership: [u8; 64], + /// 65-byte uncompressed P-256 identifier key. + pub identifier_key: [u8; 65], + /// sr25519 signature over the SCALE consumer-registration tuple. + pub consumer_registration_signature: [u8; 64], +} + +/// Build the lite-person registration parameters for `username_base` +/// (6+ lowercase letters, no digit suffix) against the backend `verifier`. +pub fn build_lite_registration( + entropy: &[u8], + verifier_account_id: [u8; 32], + username_base: &str, +) -> Result { + // The candidate is the `//wallet//sso` statement account, matching the + // account the SSO responder presents as `identity_account_id`. + let candidate = derive_sr25519_hard_path(entropy, &["wallet", "sso"]) + .map_err(|err| format!("//wallet//sso derivation failed: {err}"))?; + let candidate_public_key = candidate.public.to_bytes(); + + let vrf_entropy = blake2b256(entropy); + let vrf_secret = BandersnatchVrfVerifiable::new_secret(vrf_entropy); + let ring_vrf_key = BandersnatchVrfVerifiable::member_from_secret(&vrf_secret); + + let mut proof_message = Vec::with_capacity(REGISTER_PREFIX.len() + 64); + proof_message.extend_from_slice(REGISTER_PREFIX); + proof_message.extend_from_slice(&candidate_public_key); + proof_message.extend_from_slice(&ring_vrf_key); + + let candidate_signature = candidate + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &proof_message, &candidate.public) + .to_bytes(); + let proof_of_ownership = BandersnatchVrfVerifiable::sign(&vrf_secret, &proof_message) + .map_err(|err| format!("ring-VRF proof-of-ownership failed: {err:?}"))?; + + let (_identifier_secret, identifier_key) = + derive_p256_keypair_from_entropy(entropy, IDENTIFIER_KEY_LABEL) + .map_err(|err| format!("identifier key derivation failed: {err}"))?; + + // SCALE Tuple(Bytes(32), Bytes(32), Bytes(65), str, Option(Bytes())=None). + let mut consumer_message = Vec::new(); + consumer_message.extend_from_slice(&candidate_public_key); + consumer_message.extend_from_slice(&verifier_account_id); + consumer_message.extend_from_slice(&identifier_key); + consumer_message.extend_from_slice(&username_base.encode()); + consumer_message.push(0u8); + let consumer_registration_signature = candidate + .secret + .sign_simple( + SR25519_SIGNING_CONTEXT, + &consumer_message, + &candidate.public, + ) + .to_bytes(); + + Ok(LiteRegistration { + candidate_account_id: product_public_key_to_address(candidate_public_key), + candidate_public_key, + candidate_signature, + ring_vrf_key, + proof_of_ownership, + identifier_key, + consumer_registration_signature, + }) +} + +fn blake2b256(message: &[u8]) -> [u8; 32] { + blake2b(32, &[], message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +#[cfg(test)] +mod tests { + use super::*; + use schnorrkel::{PublicKey, Signature}; + + const ENTROPY: [u8; 16] = [0xAB; 16]; + + #[test] + fn registration_params_have_expected_shapes_and_verify() { + let verifier = [0x11u8; 32]; + let reg = build_lite_registration(&ENTROPY, verifier, "headlesstester").unwrap(); + + assert_eq!(reg.identifier_key[0], 0x04, "P-256 uncompressed prefix"); + assert!( + reg.candidate_account_id + .chars() + .all(|c| c.is_alphanumeric()) + ); + + // candidateSignature verifies over prefix ‖ candidate_pub ‖ ring_vrf_key. + let mut proof_message = Vec::new(); + proof_message.extend_from_slice(REGISTER_PREFIX); + proof_message.extend_from_slice(®.candidate_public_key); + proof_message.extend_from_slice(®.ring_vrf_key); + let public = PublicKey::from_bytes(®.candidate_public_key).unwrap(); + let sig = Signature::from_bytes(®.candidate_signature).unwrap(); + assert!( + public + .verify_simple(SR25519_SIGNING_CONTEXT, &proof_message, &sig) + .is_ok(), + "candidate signature verifies" + ); + + // proofOfOwnership verifies as a plain VRF signature for the member key. + assert!( + BandersnatchVrfVerifiable::verify_signature( + ®.proof_of_ownership, + &proof_message, + ®.ring_vrf_key + ), + "ring-VRF proof-of-ownership validates against the member key" + ); + } + + #[test] + fn registration_is_deterministic_per_entropy_and_username() { + let verifier = [0x22u8; 32]; + let first = build_lite_registration(&ENTROPY, verifier, "aliceheadless").unwrap(); + let again = build_lite_registration(&ENTROPY, verifier, "aliceheadless").unwrap(); + assert_eq!(first.candidate_public_key, again.candidate_public_key); + assert_eq!(first.ring_vrf_key, again.ring_vrf_key); + assert_eq!(first.candidate_account_id, again.candidate_account_id); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/entropy.rs b/rust/crates/truapi-server/src/host_logic/entropy.rs index cbdd3fe2..4ca07938 100644 --- a/rust/crates/truapi-server/src/host_logic/entropy.rs +++ b/rust/crates/truapi-server/src/host_logic/entropy.rs @@ -24,8 +24,14 @@ pub fn derive_product_entropy( product_id: &str, key: &[u8], ) -> Result<[u8; 32], ProductEntropyError> { - let root_entropy_source = blake2b256_keyed(entropy_secret, DOMAIN_SEPARATOR); - derive_product_entropy_from_source(&root_entropy_source, product_id, key) + derive_product_entropy_from_source(&root_entropy_source(entropy_secret), product_id, key) +} + +/// Pre-hashed root entropy source (RFC-0007 layer 1). Signing hosts share this +/// value with paired hosts during the SSO handshake so both sides derive the +/// same product entropy. +pub fn root_entropy_source(entropy_secret: &[u8]) -> [u8; 32] { + blake2b256_keyed(entropy_secret, DOMAIN_SEPARATOR) } /// Derive product-scoped entropy when the session already stores the diff --git a/rust/crates/truapi-server/src/host_logic/product_account.rs b/rust/crates/truapi-server/src/host_logic/product_account.rs index 04298daa..154a2836 100644 --- a/rust/crates/truapi-server/src/host_logic/product_account.rs +++ b/rust/crates/truapi-server/src/host_logic/product_account.rs @@ -100,6 +100,27 @@ pub fn product_public_key_to_address(public_key: [u8; 32]) -> String { bs58::encode(payload).into_string() } +/// Derive an sr25519 keypair down a path of hard string junctions from the +/// BIP-39 entropy, e.g. `["wallet", "sso"]` for `//wallet//sso`. +/// +/// Matches Substrate hard derivation (schnorrkel `SchnorrRistrettoHDKD`), the +/// same path the mobile/bot signing hosts use for their statement identity +/// account. +pub fn derive_sr25519_hard_path( + entropy: &[u8], + junctions: &[&str], +) -> Result { + let mut keypair = derive_root_keypair_from_entropy(entropy)?; + for junction in junctions { + let chain_code = schnorrkel::derive::ChainCode(create_chain_code(junction)?); + let (mini_secret, _) = keypair + .secret + .hard_derive_mini_secret_key(Some(chain_code), b""); + keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + } + Ok(keypair) +} + /// Create a Substrate soft-derivation chain code for one junction. fn create_chain_code(code: &str) -> Result<[u8; 32], ProductAccountError> { let encoded = if !code.is_empty() && code.bytes().all(|byte| byte.is_ascii_digit()) { diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs index f801abae..e9f8eff5 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -30,10 +30,11 @@ use crate::host_logic::session::SsoSessionInfo; use crate::host_logic::sso::pairing::{ AES_GCM_NONCE_LEN, SsoStatementData, decrypt_session_statement_data, encrypt_session_statement_data, encrypt_session_statement_data_with_nonce, + peer_response_channel, }; use crate::host_logic::statement_store::{ - build_signed_session_request_statement, current_unix_secs, decode_verified_statement_data, - statement_expiry_elapsed, + build_signed_session_request_statement, build_signed_statement, current_unix_secs, + decode_verified_statement_data, statement_expiry_elapsed, }; pub mod v1; @@ -136,6 +137,31 @@ impl From for SigningPayloadRequest { } } +impl From for truapi::v01::HostSignPayloadRequest { + fn from(value: SigningPayloadRequest) -> Self { + Self { + account: value.product_account_id, + payload: truapi::v01::HostSignPayloadData { + block_hash: value.block_hash, + block_number: value.block_number, + era: value.era, + genesis_hash: value.genesis_hash, + method: value.method, + nonce: value.nonce, + spec_version: value.spec_version, + tip: value.tip, + transaction_version: value.transaction_version, + signed_extensions: value.signed_extensions, + version: value.version, + asset_id: value.asset_id, + metadata_hash: value.metadata_hash, + mode: value.mode, + with_signed_transaction: value.with_signed_transaction.0, + }, + } + } +} + /// Request sent when a product asks the paired signing host to sign raw bytes or a /// string message with a product-derived account. /// @@ -188,6 +214,24 @@ impl From for SigningRawPayload { } } +impl From for RawPayload { + fn from(value: SigningRawPayload) -> Self { + match value { + SigningRawPayload::Bytes(bytes) => Self::Bytes { bytes }, + SigningRawPayload::Payload(payload) => Self::Payload { payload }, + } + } +} + +impl From for truapi::v01::HostSignRawRequest { + fn from(value: SigningRawRequest) -> Self { + Self { + account: value.product_account_id, + payload: value.data.into(), + } + } +} + /// Response returned by the signing host for a product-account signing request. /// /// Decoded from [`v1::RemoteMessage::SignResponse`] while the runtime is waiting @@ -568,6 +612,81 @@ pub fn create_transaction_legacy_message( } } +/// Inbound request decoded from a peer-signed session statement. +/// +/// `request_id` identifies the statement for the transport-level ack; +/// `messages` are the application messages batched into it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IncomingSsoRequest { + pub request_id: String, + pub messages: Vec, +} + +/// Decode an inbound session statement into the peer's request batch. +/// +/// Returns `Ok(None)` for statements that carry no peer request: own echoes, +/// transport-level acks, and expired statements. Used by the signing-host +/// responder, which serves peer requests instead of matching pending ones. +pub fn decode_incoming_sso_request( + session: &SsoSessionInfo, + statement: &[u8], +) -> Result, String> { + let verified = + decode_verified_statement_data(statement, None).map_err(|err| err.to_string())?; + if verified.signer == session.ss_public_key { + return Ok(None); + } + if verified.signer != session.identity_account_id { + return Err("statement proof signer does not match expected peer".to_string()); + } + if verified + .expiry + .is_some_and(|expiry| statement_expiry_elapsed(expiry, current_unix_secs())) + { + return Ok(None); + } + match decrypt_session_statement_data(session, &verified.data)? { + SsoStatementData::Response { .. } => Ok(None), + SsoStatementData::Request { request_id, data } => { + let messages = data + .iter() + .map(|message| { + RemoteMessage::decode(&mut message.as_slice()) + .map_err(|err| format!("invalid SSO remote message: {err}")) + }) + .collect::, _>>()?; + Ok(Some(IncomingSsoRequest { + request_id, + messages, + })) + } + } +} + +/// Build the signed transport-level ack for a peer-initiated request +/// statement: topic `session_id_peer`, channel [`peer_response_channel`]. +pub fn build_signed_session_response_statement( + session: &SsoSessionInfo, + request_id: String, + response_code: u8, + expiry: u64, +) -> Result, String> { + let encrypted = encrypt_session_statement_data( + session, + &SsoStatementData::Response { + request_id, + response_code, + }, + )?; + build_signed_statement( + session, + peer_response_channel(session), + session.session_id_peer, + encrypted, + expiry, + ) +} + /// Build a signed outbound SSO request statement with a random nonce. pub fn build_outgoing_request_statement( session: &SsoSessionInfo, @@ -992,6 +1111,186 @@ mod tests { assert_eq!(decoded, None); } + fn host_and_responder_sessions() -> (SsoSessionInfo, SsoSessionInfo) { + use crate::host_logic::sso::pairing::{ + ResponderIdentity, create_pairing_bootstrap, derive_p256_keypair_from_entropy, + establish_responder_session_info, establish_sso_session_info, + }; + use truapi_platform::{HostInfo, PairingHostConfig, PlatformInfo}; + + let config = PairingHostConfig::new( + HostInfo { + name: "Test Host".to_string(), + icon: None, + version: None, + }, + PlatformInfo::default(), + [0; 32], + "polkadotapp".to_string(), + ) + .expect("test pairing config is valid"); + let bootstrap = create_pairing_bootstrap(&config).unwrap(); + let statement_keypair = MiniSecretKey::from_bytes(&[7; 32]) + .unwrap() + .expand_to_keypair(ExpansionMode::Ed25519); + let (encryption_secret_key, encryption_public_key) = + derive_p256_keypair_from_entropy(&[0xAB; 16], b"sso-encryption").unwrap(); + let responder = ResponderIdentity { + statement_secret: statement_keypair.secret.to_bytes(), + statement_public_key: statement_keypair.public.to_bytes(), + encryption_secret_key, + encryption_public_key, + }; + let responder_session = establish_responder_session_info( + &responder, + bootstrap.statement_store_public_key, + bootstrap.encryption_public_key, + ) + .unwrap(); + let host_session = establish_sso_session_info( + &bootstrap, + responder.statement_public_key, + responder.encryption_public_key, + ) + .unwrap(); + (host_session, responder_session) + } + + /// A host-built request statement decodes on the responder side into the + /// batched remote messages, and the responder's ack plus response + /// statements resolve the host's pending wait. + #[test] + fn host_request_round_trips_through_responder_statements() { + let (host_session, responder_session) = host_and_responder_sessions(); + let request = sign_raw_message( + "remote-1".to_string(), + HostSignRawRequest { + account: account(), + payload: RawPayload::Payload { + payload: "hello".to_string(), + }, + }, + ); + let host_statement = build_outgoing_request_statement( + &host_session, + "statement-1".to_string(), + vec![request.clone()], + fresh_expiry(), + ) + .unwrap(); + + let incoming = decode_incoming_sso_request(&responder_session, &host_statement) + .unwrap() + .expect("responder should surface the host request"); + assert_eq!( + incoming, + IncomingSsoRequest { + request_id: "statement-1".to_string(), + messages: vec![request], + } + ); + + let ack = build_signed_session_response_statement( + &responder_session, + incoming.request_id.clone(), + 0, + fresh_expiry(), + ) + .unwrap(); + assert_eq!( + decode_sso_session_statement(&host_session, &ack, "statement-1", "remote-1").unwrap(), + Some(SsoSessionStatement::RequestAccepted) + ); + + let response = RemoteMessage { + message_id: "resp-1".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::SignResponse(SigningResponse { + responding_to: "remote-1".to_string(), + payload: Ok(SigningPayloadResponseData { + signature: vec![9; 64], + signed_transaction: None, + }), + })), + }; + let response_statement = build_outgoing_request_statement( + &responder_session, + "resp-statement-1".to_string(), + vec![response], + fresh_expiry(), + ) + .unwrap(); + let decoded = decode_sso_session_statement( + &host_session, + &response_statement, + "statement-1", + "remote-1", + ) + .unwrap(); + assert_eq!( + decoded, + Some(SsoSessionStatement::RemoteResponse( + SsoRemoteResponse::Sign(SigningResponse { + responding_to: "remote-1".to_string(), + payload: Ok(SigningPayloadResponseData { + signature: vec![9; 64], + signed_transaction: None, + }), + }) + )) + ); + } + + #[test] + fn responder_ignores_own_echo_and_transport_acks() { + let (host_session, responder_session) = host_and_responder_sessions(); + let own_statement = build_outgoing_request_statement( + &responder_session, + "resp-statement-1".to_string(), + vec![RemoteMessage { + message_id: "resp-1".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + }], + fresh_expiry(), + ) + .unwrap(); + assert_eq!( + decode_incoming_sso_request(&responder_session, &own_statement).unwrap(), + None + ); + + let host_ack = build_signed_session_response_statement( + &host_session, + "resp-statement-1".to_string(), + 0, + fresh_expiry(), + ) + .unwrap(); + assert_eq!( + decode_incoming_sso_request(&responder_session, &host_ack).unwrap(), + None + ); + } + + #[test] + fn responder_ignores_expired_host_request() { + let (host_session, responder_session) = host_and_responder_sessions(); + let stale_statement = build_outgoing_request_statement( + &host_session, + "statement-1".to_string(), + vec![RemoteMessage { + message_id: "remote-1".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + }], + elapsed_expiry(), + ) + .unwrap(); + + assert_eq!( + decode_incoming_sso_request(&responder_session, &stale_statement).unwrap(), + None + ); + } + fn response_ack_statement(session: &SsoSessionInfo, expiry: u64) -> Vec { let encrypted = encrypt_session_statement_data_with_nonce( session, diff --git a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs index eff47058..d8fa2d40 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs @@ -112,6 +112,53 @@ pub enum SsoStatementData { }, } +/// Decode a pairing deeplink (or its bare handshake hex) into the advertised +/// handshake proposal. Inverse of [`build_pairing_deeplink`]. +pub fn decode_pairing_deeplink(deeplink: &str) -> Result { + let hex_payload = match deeplink.split_once("?handshake=") { + Some((_, hex_payload)) => hex_payload, + None => deeplink, + }; + let encoded = hex::decode(hex_payload.trim()) + .map_err(|err| format!("invalid pairing deeplink hex: {err}"))?; + let mut input = encoded.as_slice(); + let proposal = VersionedHandshakeProposal::decode(&mut input) + .map_err(|err| format!("invalid pairing handshake proposal: {err}"))?; + if !input.is_empty() { + return Err("invalid pairing handshake proposal: trailing bytes".to_string()); + } + Ok(proposal) +} + +/// Encrypt a v2 handshake response for the host that advertised +/// `host_encryption_public_key`. Inverse of [`decrypt_v2_handshake_response`]: +/// a fresh ephemeral P-256 key is used per response so each Pending/Success +/// statement carries an independent ciphertext. +pub fn encrypt_v2_handshake_response( + host_encryption_public_key: [u8; 65], + response: &v2::EncryptedResponse, +) -> Result { + let (ephemeral_secret, ephemeral_public) = + generate_p256_keypair().map_err(|err| err.to_string())?; + let shared_secret = shared_secret(ephemeral_secret, host_encryption_public_key)?; + let aes_key = aes_key_from_shared_secret(&shared_secret)?; + let mut nonce = [0u8; AES_GCM_NONCE_LEN]; + getrandom::getrandom(&mut nonce) + .map_err(|err| format!("failed to generate AES-GCM nonce: {err}"))?; + let cipher = Aes256Gcm::new_from_slice(&aes_key) + .map_err(|err| format!("failed to initialize AES-GCM: {err}"))?; + let mut encrypted_message = nonce.to_vec(); + encrypted_message.extend( + cipher + .encrypt(Nonce::from_slice(&nonce), response.encode().as_slice()) + .map_err(|err| format!("failed to encrypt SSO handshake response: {err}"))?, + ); + Ok(VersionedHandshakeResponse::V2 { + encrypted_message, + public_key: ephemeral_public, + }) +} + /// Decode wallet-posted pairing handshake data from SCALE bytes. pub fn decode_app_handshake_data(blob: &[u8]) -> Result { let mut input = blob; @@ -176,6 +223,96 @@ pub fn establish_sso_session_info( }) } +/// Signing-host key material answering one pairing proposal. +/// +/// The statement keypair signs every session statement (its public key is the +/// `identityAccountId` the pairing host binds the session to), and the P-256 +/// secret is the persistent `sso_enc` key both sides feed into the session +/// ECDH. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResponderIdentity { + pub statement_secret: [u8; 64], + pub statement_public_key: [u8; 32], + pub encryption_secret_key: [u8; 32], + pub encryption_public_key: [u8; 65], +} + +/// Derive the SSO session channels from the responder (signing host) +/// perspective after answering the handshake advertised by +/// `host_statement_account_id` / `host_encryption_public_key`. +/// +/// Mirror of [`establish_sso_session_info`]: the responder's `session_id_own` +/// equals the pairing host's `session_id_peer` and vice versa, so statements +/// built from either side land on the topics the other side subscribes to. +pub fn establish_responder_session_info( + identity: &ResponderIdentity, + host_statement_account_id: [u8; 32], + host_encryption_public_key: [u8; 65], +) -> Result { + let shared_secret = shared_secret(identity.encryption_secret_key, host_encryption_public_key)?; + let shared_secret_bytes: [u8; 32] = (*shared_secret.raw_secret_bytes()).into(); + let session_id_own = create_session_id( + shared_secret_bytes, + identity.statement_public_key, + host_statement_account_id, + ); + let session_id_peer = create_session_id( + shared_secret_bytes, + host_statement_account_id, + identity.statement_public_key, + ); + + Ok(SsoSessionInfo { + ss_secret: identity.statement_secret, + ss_public_key: identity.statement_public_key, + enc_secret: identity.encryption_secret_key, + peer_enc_pubkey: host_encryption_public_key, + identity_account_id: host_statement_account_id, + session_id_own, + session_id_peer, + request_channel: keyed_hash(session_id_own, REQUEST_CHANNEL_SUFFIX), + response_channel: keyed_hash(session_id_own, RESPONSE_CHANNEL_SUFFIX), + peer_request_channel: keyed_hash(session_id_peer, REQUEST_CHANNEL_SUFFIX), + }) +} + +/// Statement channel acknowledging requests the peer initiated: the peer's +/// own `response` channel seen from this side of the session. +pub fn peer_response_channel(session: &SsoSessionInfo) -> [u8; 32] { + keyed_hash(session.session_id_peer, RESPONSE_CHANNEL_SUFFIX) +} + +/// Derive a deterministic P-256 keypair from BIP-39 entropy and a domain +/// label. Host-spec C.4 leaves signing-host P-256 derivation +/// implementation-defined; this scheme only needs to be stable per entropy. +pub fn derive_p256_keypair_from_entropy( + entropy: &[u8], + label: &[u8], +) -> Result<([u8; 32], [u8; 65]), PairingBootstrapError> { + for attempt in 0..MAX_P256_SECRET_ATTEMPTS { + let mut message = Vec::with_capacity(label.len() + 1); + message.extend_from_slice(label); + message.push(attempt as u8); + let candidate: [u8; 32] = blake2b(32, entropy, &message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes"); + let Ok(secret) = SecretKey::from_slice(&candidate) else { + continue; + }; + let public = secret.public_key().to_encoded_point(false); + let public = public.as_bytes(); + if public.len() != 65 { + return Err(PairingBootstrapError::InvalidP256Secret); + } + let mut encryption_public_key = [0u8; 65]; + encryption_public_key.copy_from_slice(public); + return Ok((candidate, encryption_public_key)); + } + + Err(PairingBootstrapError::InvalidP256Secret) +} + /// Encrypt session-channel statement data with a random nonce. pub fn encrypt_session_statement_data( session: &SsoSessionInfo, @@ -663,6 +800,136 @@ mod tests { ); } + #[test] + fn decodes_pairing_deeplink_round_trip() { + let config = runtime_config(); + let deeplink = build_pairing_deeplink("polkadotapp", SS_PUBLIC, ENC_PUBLIC, &config); + + let decoded = decode_pairing_deeplink(&deeplink).unwrap(); + + let VersionedHandshakeProposal::V2(proposal) = decoded; + assert_eq!(proposal.device.statement_account_id, SS_PUBLIC); + assert_eq!(proposal.device.encryption_public_key, ENC_PUBLIC); + } + + #[test] + fn decodes_bare_handshake_hex() { + let config = runtime_config(); + let deeplink = build_pairing_deeplink("polkadotapp", SS_PUBLIC, ENC_PUBLIC, &config); + let hex_payload = deeplink.split("handshake=").nth(1).unwrap(); + + assert_eq!( + decode_pairing_deeplink(hex_payload).unwrap(), + decode_pairing_deeplink(&deeplink).unwrap() + ); + } + + #[test] + fn rejects_pairing_deeplink_with_trailing_bytes() { + let config = runtime_config(); + let deeplink = build_pairing_deeplink("polkadotapp", SS_PUBLIC, ENC_PUBLIC, &config); + + let err = decode_pairing_deeplink(&format!("{deeplink}00")).unwrap_err(); + + assert_eq!(err, "invalid pairing handshake proposal: trailing bytes"); + } + + #[test] + fn encrypted_handshake_response_round_trips_through_host_decrypt() { + let host_secret = SecretKey::from_slice(&[1; 32]).unwrap(); + let host_public: [u8; 65] = host_secret + .public_key() + .to_encoded_point(false) + .as_bytes() + .try_into() + .unwrap(); + let response = v2::EncryptedResponse::Success(Box::new(v2::Success { + identity_account_id: [8; 32], + root_account_id: [7; 32], + identity_chat_private_key: [6; 32], + sso_enc_pub_key: ENC_PUBLIC, + device_enc_pub_key: ENC_PUBLIC, + root_entropy_source: [5; 32], + })); + + let VersionedHandshakeResponse::V2 { + encrypted_message, + public_key, + } = encrypt_v2_handshake_response(host_public, &response).unwrap(); + + assert_eq!( + decrypt_v2_handshake_response( + host_secret.to_bytes().into(), + public_key, + &encrypted_message + ) + .unwrap(), + response + ); + } + + fn responder_identity() -> ResponderIdentity { + let (statement_secret, statement_public_key) = generate_statement_store_keypair().unwrap(); + let (encryption_secret_key, encryption_public_key) = + derive_p256_keypair_from_entropy(&[0xAB; 16], b"sso-encryption").unwrap(); + ResponderIdentity { + statement_secret, + statement_public_key, + encryption_secret_key, + encryption_public_key, + } + } + + /// The responder-perspective session must mirror the host-perspective + /// session: swapped session ids, aligned channels, and one shared AES key + /// so either side can decrypt the other's statement data. + #[test] + fn responder_session_mirrors_host_session() { + let config = runtime_config(); + let host_bootstrap = create_pairing_bootstrap(&config).unwrap(); + let responder = responder_identity(); + + let responder_session = establish_responder_session_info( + &responder, + host_bootstrap.statement_store_public_key, + host_bootstrap.encryption_public_key, + ) + .unwrap(); + let host_session = establish_sso_session_info( + &host_bootstrap, + responder.statement_public_key, + responder.encryption_public_key, + ) + .unwrap(); + + assert_eq!( + responder_session.session_id_own, + host_session.session_id_peer + ); + assert_eq!( + responder_session.session_id_peer, + host_session.session_id_own + ); + assert_eq!( + responder_session.request_channel, + host_session.peer_request_channel + ); + assert_eq!( + peer_response_channel(&responder_session), + host_session.response_channel + ); + + let data = SsoStatementData::Request { + request_id: "req-1".to_string(), + data: vec![vec![0xde, 0xad]], + }; + let encrypted = encrypt_session_statement_data(&responder_session, &data).unwrap(); + assert_eq!( + decrypt_session_statement_data(&host_session, &encrypted).unwrap(), + data + ); + } + #[test] fn statement_data_codec_round_trips_request_and_response() { let request = SsoStatementData::Request { diff --git a/rust/crates/truapi-server/src/host_logic/transaction.rs b/rust/crates/truapi-server/src/host_logic/transaction.rs new file mode 100644 index 00000000..55522a8a --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/transaction.rs @@ -0,0 +1,207 @@ +//! Extrinsic signing preimages and v4 signed-extrinsic assembly. +//! +//! Signing hosts receive pre-encoded payload fields (`HostSignPayloadData`) +//! or pre-encoded transaction extensions (`TxPayloadExtension`), so no chain +//! metadata is needed: the preimage is a byte concatenation and the signed +//! extrinsic is assembled mechanically. Matches the polkadot-app signing +//! convention: preimages longer than 256 bytes are BLAKE2b-256 hashed before +//! signing. + +use blake2_rfc::blake2b::blake2b; +use parity_scale_codec::{Compact, Encode}; +use truapi::v01::{HostSignPayloadData, TxPayloadExtension}; + +/// Preimages longer than this are hashed before signing (standard Substrate +/// signed-payload rule). +const MAX_SIGNED_PREIMAGE_LEN: usize = 256; + +/// Extrinsic version 4 with the signed bit set. +const EXTRINSIC_V4_SIGNED: u8 = 0x84; +/// `MultiAddress::Id` variant index. +const MULTI_ADDRESS_ID: u8 = 0x00; +/// `MultiSignature::Sr25519` variant index. +const MULTI_SIGNATURE_SR25519: u8 = 0x01; + +/// Signing preimage for an extrinsic payload assembled from pre-encoded +/// fields, in the polkadot-app field order. Empty optional fields are +/// skipped, mirroring the JS falsy-field rule. +pub fn extrinsic_payload_preimage(payload: &HostSignPayloadData) -> Vec { + let parts: [&[u8]; 8] = [ + &payload.method, + &payload.era, + &payload.nonce, + &payload.tip, + &payload.spec_version, + &payload.transaction_version, + &payload.genesis_hash, + &payload.block_hash, + ]; + let mut preimage = Vec::new(); + for part in parts { + preimage.extend_from_slice(part); + } + if let Some(asset_id) = &payload.asset_id { + preimage.extend_from_slice(asset_id); + } + if let Some(metadata_hash) = &payload.metadata_hash { + preimage.extend_from_slice(metadata_hash); + } + hash_large_preimage(preimage) +} + +/// Signing preimage for a transaction built from pre-encoded extensions: +/// call data, then every extension's `extra`, then every extension's +/// `additional_signed`. +pub fn transaction_signing_preimage( + call_data: &[u8], + extensions: &[TxPayloadExtension], +) -> Vec { + let mut preimage = call_data.to_vec(); + for extension in extensions { + preimage.extend_from_slice(&extension.extra); + } + for extension in extensions { + preimage.extend_from_slice(&extension.additional_signed); + } + hash_large_preimage(preimage) +} + +/// Assemble a v4 signed extrinsic from a signer public key, an sr25519 +/// signature over [`transaction_signing_preimage`], the pre-encoded +/// extension `extra` data, and the call data. +pub fn build_v4_signed_extrinsic( + signer_public_key: [u8; 32], + signature: [u8; 64], + extensions: &[TxPayloadExtension], + call_data: &[u8], +) -> Vec { + let mut body = Vec::with_capacity(2 + 32 + 1 + 64 + call_data.len()); + body.push(EXTRINSIC_V4_SIGNED); + body.push(MULTI_ADDRESS_ID); + body.extend_from_slice(&signer_public_key); + body.push(MULTI_SIGNATURE_SR25519); + body.extend_from_slice(&signature); + for extension in extensions { + body.extend_from_slice(&extension.extra); + } + body.extend_from_slice(call_data); + + let mut extrinsic = Compact(body.len() as u32).encode(); + extrinsic.extend_from_slice(&body); + extrinsic +} + +fn hash_large_preimage(preimage: Vec) -> Vec { + if preimage.len() > MAX_SIGNED_PREIMAGE_LEN { + blake2b(32, &[], &preimage).as_bytes().to_vec() + } else { + preimage + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn payload() -> HostSignPayloadData { + HostSignPayloadData { + block_hash: vec![0xB1, 0xB2], + block_number: vec![0xFF], + era: vec![0xE1], + genesis_hash: vec![0x61, 0x62], + method: vec![0x4D], + nonce: vec![0x4E], + spec_version: vec![0x51], + tip: vec![0x54], + transaction_version: vec![0x56], + signed_extensions: vec![], + version: 4, + asset_id: None, + metadata_hash: None, + mode: None, + with_signed_transaction: None, + } + } + + #[test] + fn payload_preimage_uses_polkadot_app_field_order() { + // method, era, nonce, tip, spec_version, transaction_version, + // genesis_hash, block_hash. block_number is not part of the preimage. + assert_eq!( + extrinsic_payload_preimage(&payload()), + vec![0x4D, 0xE1, 0x4E, 0x54, 0x51, 0x56, 0x61, 0x62, 0xB1, 0xB2] + ); + } + + #[test] + fn payload_preimage_appends_asset_id_and_metadata_hash() { + let mut payload = payload(); + payload.asset_id = Some(vec![0xAA]); + payload.metadata_hash = Some(vec![0xBB]); + + assert_eq!( + extrinsic_payload_preimage(&payload), + vec![ + 0x4D, 0xE1, 0x4E, 0x54, 0x51, 0x56, 0x61, 0x62, 0xB1, 0xB2, 0xAA, 0xBB + ] + ); + } + + #[test] + fn long_preimages_are_blake2b_hashed() { + let mut payload = payload(); + payload.method = vec![0x4D; 300]; + + let preimage = extrinsic_payload_preimage(&payload); + + assert_eq!(preimage.len(), 32); + let mut raw = vec![0x4D; 300]; + raw.extend_from_slice(&[0xE1, 0x4E, 0x54, 0x51, 0x56, 0x61, 0x62, 0xB1, 0xB2]); + assert_eq!(preimage, blake2b(32, &[], &raw).as_bytes().to_vec()); + } + + #[test] + fn transaction_preimage_orders_call_extra_then_implicit() { + let extensions = vec![ + TxPayloadExtension { + id: "CheckNonce".to_string(), + extra: vec![0x01], + additional_signed: vec![0x02], + }, + TxPayloadExtension { + id: "CheckSpecVersion".to_string(), + extra: vec![0x03], + additional_signed: vec![0x04], + }, + ]; + + assert_eq!( + transaction_signing_preimage(&[0xCA, 0x11], &extensions), + vec![0xCA, 0x11, 0x01, 0x03, 0x02, 0x04] + ); + } + + #[test] + fn builds_v4_signed_extrinsic_layout() { + let extensions = vec![TxPayloadExtension { + id: "CheckNonce".to_string(), + extra: vec![0xEE], + additional_signed: vec![0xDD], + }]; + + let extrinsic = + build_v4_signed_extrinsic([0xAB; 32], [0xCD; 64], &extensions, &[0xCA, 0x11]); + + let body_len = 1 + 1 + 32 + 1 + 64 + 1 + 2; + assert_eq!(extrinsic[..2], Compact(body_len as u32).encode()[..]); + let body = &extrinsic[2..]; + assert_eq!(body.len(), body_len); + assert_eq!(body[0], 0x84); + assert_eq!(body[1], 0x00); + assert_eq!(&body[2..34], &[0xAB; 32]); + assert_eq!(body[34], 0x01); + assert_eq!(&body[35..99], &[0xCD; 64]); + assert_eq!(body[99], 0xEE); + assert_eq!(&body[100..], &[0xCA, 0x11]); + } +} diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index 9d2d8489..3d890b94 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -32,6 +32,7 @@ pub use host_core::{ FrameSink, HostAdmin, PairingHostRuntime, ProductRuntime, ProductRuntimeError, SigningHostRuntime, }; +pub use runtime::ResponderExit; pub use truapi_platform::{ HostRuntimeConfig, PairingHostConfig, PermissionAuthorizationRequest, PermissionAuthorizationStatus, Platform, ProductContext, SigningHostConfig, diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 6c120a33..974eaad6 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -37,7 +37,10 @@ pub(crate) use authority::ProductAuthority; use pairing_host::PairingHost; pub(crate) use pairing_host::PairingHost as PairingHostRole; pub(crate) use services::RuntimeServices; -pub(crate) use signing_host::{LocalActivation, SigningHost as SigningHostRole}; +pub use signing_host::ResponderExit; +pub(crate) use signing_host::{ + LocalActivation, SigningHost as SigningHostRole, respond_to_pairing, +}; use authority::{ AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, diff --git a/rust/crates/truapi-server/src/runtime/identity.rs b/rust/crates/truapi-server/src/runtime/identity.rs index d5bda8e9..24f215c8 100644 --- a/rust/crates/truapi-server/src/runtime/identity.rs +++ b/rust/crates/truapi-server/src/runtime/identity.rs @@ -40,7 +40,7 @@ pub(super) async fn resolve_session_identity_with_chain( } let preferred_account = session.identity_account_id.unwrap_or(session.public_key); - if !lookup_and_apply( + if lookup_and_apply( chain, people_chain_genesis_hash, preferred_account, @@ -48,6 +48,7 @@ pub(super) async fn resolve_session_identity_with_chain( "identity", ) .await + == LookupOutcome::NoRecord && preferred_account != session.public_key { let public_key = session.public_key; @@ -64,42 +65,60 @@ pub(super) async fn resolve_session_identity_with_chain( session } +/// Maximum lookup attempts per account on transient failure. The first attempt +/// warms the People-chain connection (cached per genesis), so a retry after a +/// cold-start timeout usually resolves immediately. +const IDENTITY_LOOKUP_MAX_ATTEMPTS: usize = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LookupOutcome { + /// A username record was found and applied. + Applied, + /// The account has no consumer record (definitive; do not retry). + NoRecord, + /// The lookup failed transiently after exhausting retries. + Failed, +} + /// Look up `account`'s people-chain identity and apply any usernames to -/// `session`; returns whether a username record was found and applied. +/// `session`, retrying transient failures against the warmed connection. async fn lookup_and_apply( chain: &ChainRuntime, people_chain_genesis_hash: [u8; 32], account: [u8; 32], session: &mut SessionInfo, label: &str, -) -> bool { - match lookup_people_identity(chain, people_chain_genesis_hash, account).await { - Ok(Some(identity)) => { - debug!( - account = %hex::encode(account), - lite_username = identity.lite_username.as_deref().unwrap_or(""), - full_username = identity.full_username.as_deref().unwrap_or(""), - "People-chain {label} lookup found username" - ); - apply_people_identity(session, identity); - true - } - Ok(None) => { - debug!( - account = %hex::encode(account), - "People-chain {label} lookup found no consumer record" - ); - false - } - Err(reason) => { - warn!( - account = %hex::encode(account), - %reason, - "People-chain {label} lookup failed" - ); - false +) -> LookupOutcome { + for attempt in 1..=IDENTITY_LOOKUP_MAX_ATTEMPTS { + match lookup_people_identity(chain, people_chain_genesis_hash, account).await { + Ok(Some(identity)) => { + debug!( + account = %hex::encode(account), + lite_username = identity.lite_username.as_deref().unwrap_or(""), + full_username = identity.full_username.as_deref().unwrap_or(""), + "People-chain {label} lookup found username" + ); + apply_people_identity(session, identity); + return LookupOutcome::Applied; + } + Ok(None) => { + debug!( + account = %hex::encode(account), + "People-chain {label} lookup found no consumer record" + ); + return LookupOutcome::NoRecord; + } + Err(reason) => { + warn!( + account = %hex::encode(account), + attempt, + %reason, + "People-chain {label} lookup failed" + ); + } } } + LookupOutcome::Failed } fn non_empty(value: &Option) -> bool { @@ -172,7 +191,9 @@ async fn lookup_people_identity( async fn wait_for_identity_follow_hash( follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, ) -> Result, String> { - let timeout = futures_timer::Delay::new(Duration::from_secs(10)).fuse(); + // Best-effort lookup: tolerate a cold chainHead follow init against a real + // node (a fresh WS can take well over 10s to report a finalized block). + let timeout = futures_timer::Delay::new(Duration::from_secs(30)).fuse(); pin_mut!(timeout); loop { let next = follow.next().fuse(); @@ -236,7 +257,7 @@ async fn wait_for_identity_storage_value( operation_id: &str, key: &[u8], ) -> Result>, String> { - let timeout = futures_timer::Delay::new(Duration::from_secs(10)).fuse(); + let timeout = futures_timer::Delay::new(Duration::from_secs(30)).fuse(); pin_mut!(timeout); let mut value = None; loop { diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index b6734d73..91ad53bf 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -6,17 +6,20 @@ //! (the host owns its persistence, e.g. the OS keychain) and kept in memory //! for the session, zeroized on disconnect. //! -//! Implemented: local session lifecycle, raw-bytes signing, and RFC-0007 -//! product entropy. Deferred (return [`AuthorityError::Unavailable`]): -//! extrinsic-payload signing and transaction construction (need chain -//! metadata to assemble the signing payload), ring-VRF aliases, and -//! resource allocation (needs on-chain allowance submission). +//! Implemented: local session lifecycle, raw-bytes signing, extrinsic-payload +//! signing, v4 transaction construction (payload fields and extensions arrive +//! pre-encoded, so no chain metadata is needed), RFC-0007 product entropy, and +//! bandersnatch ring-VRF product-account aliases (native only). Deferred +//! (returns [`AuthorityError::Unavailable`]): on-chain resource allocation. mod local_activation; +mod sso_responder; use std::sync::{Arc, Mutex}; pub(crate) use local_activation::LocalActivation; +pub use sso_responder::ResponderExit; +pub(crate) use sso_responder::respond_to_pairing; use super::authority::{ AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, ProductAuthority, @@ -26,10 +29,12 @@ use super::authority::{ use super::connected_session_ui_info; use crate::host_logic::entropy::derive_product_entropy; use crate::host_logic::product_account::{ - ProductAccountError, SR25519_SIGNING_CONTEXT, derive_product_keypair, - derive_root_keypair_from_entropy, + ProductAccountError, SR25519_SIGNING_CONTEXT, derive_product_keypair, derive_sr25519_hard_path, }; use crate::host_logic::session::SessionState; +use crate::host_logic::transaction::{ + build_v4_signed_extrinsic, extrinsic_payload_preimage, transaction_signing_preimage, +}; use crate::runtime::auth_state::AuthStateMachine; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; @@ -71,24 +76,25 @@ impl SigningHost { .ok_or(AuthorityError::Disconnected) } - /// Derive the product-account keypair for `account` from the root entropy. + /// Derive the product-account keypair for `account` from the wallet root. /// - /// The root keypair is recomputed per call (PBKDF2, 2048 rounds, via - /// `substrate-bip39`) rather than cached: the signing host holds only the - /// raw, zeroizable entropy, never an expanded secret key. + /// Per host-spec C.5, product keys derive from the user's main wallet + /// account at `//wallet` (whose public key is `rootUserAccountId`), not the + /// bare BIP-39 root. The wallet keypair is recomputed per call; the signing + /// host holds only the raw, zeroizable entropy. fn product_keypair( &self, account: &v01::ProductAccountId, ) -> Result { let entropy = self.root_entropy()?; - let root = derive_root_keypair_from_entropy(&entropy).map_err(product_authority_error)?; + let wallet = wallet_root_keypair(&entropy)?; let product_id = normalize_product_identifier(&account.dot_ns_identifier).map_err(|err| { AuthorityError::Unavailable { reason: err.to_string(), } })?; - derive_product_keypair(&root, &product_id, account.derivation_index) + derive_product_keypair(&wallet, &product_id, account.derivation_index) .map_err(product_authority_error) } } @@ -134,13 +140,26 @@ impl ProductAuthority for SigningHost { async fn sign_payload( &self, _cx: &CallContext, - _session: &AuthoritySession, - _request: SignPayloadAuthorityRequest, + session: &AuthoritySession, + request: SignPayloadAuthorityRequest, ) -> Result { - Err(AuthorityError::Unavailable { - reason: "signing host: extrinsic-payload signing needs chain-metadata payload \ - assembly (not yet implemented)" - .to_string(), + let (account, payload) = match request { + SignPayloadAuthorityRequest::Product(request) => (request.account, request.payload), + SignPayloadAuthorityRequest::LegacyAccount { + product_account, + request, + } => (product_account, request.payload), + }; + require_current_session(&self.session_state, session)?; + let keypair = self.product_keypair(&account)?; + let message = extrinsic_payload_preimage(&payload); + let signature = keypair + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &message, &keypair.public) + .to_bytes(); + Ok(v01::HostSignPayloadResponse { + signature: signature.to_vec(), + signed_transaction: None, }) } @@ -172,26 +191,84 @@ impl ProductAuthority for SigningHost { async fn create_transaction( &self, _cx: &CallContext, - _session: &AuthoritySession, - _request: CreateTransactionAuthorityRequest, + session: &AuthoritySession, + request: CreateTransactionAuthorityRequest, ) -> Result { - Err(AuthorityError::Unavailable { - reason: "signing host: transaction construction needs chain metadata (not yet \ - implemented)" - .to_string(), + let (account, call_data, extensions, tx_ext_version) = match request { + CreateTransactionAuthorityRequest::Product(payload) => ( + payload.signer, + payload.call_data, + payload.extensions, + payload.tx_ext_version, + ), + CreateTransactionAuthorityRequest::LegacyAccount { + product_account, + request, + } => ( + product_account, + request.call_data, + request.extensions, + request.tx_ext_version, + ), + }; + if tx_ext_version != 0 { + return Err(AuthorityError::Unavailable { + reason: format!( + "signing host: extrinsic v5 construction (tx_ext_version \ + {tx_ext_version}) is not supported" + ), + }); + } + require_current_session(&self.session_state, session)?; + let keypair = self.product_keypair(&account)?; + let message = transaction_signing_preimage(&call_data, &extensions); + let signature = keypair + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &message, &keypair.public) + .to_bytes(); + Ok(v01::HostCreateTransactionResponse { + transaction: build_v4_signed_extrinsic( + keypair.public.to_bytes(), + signature, + &extensions, + &call_data, + ), }) } async fn account_alias( &self, _cx: &CallContext, - _session: &AuthoritySession, - _product_account_id: v01::ProductAccountId, + session: &AuthoritySession, + product_account_id: v01::ProductAccountId, _requesting_product_id: String, ) -> Result { - Err(AuthorityError::Unavailable { - reason: "signing host: ring-VRF alias derivation not yet implemented".to_string(), - }) + #[cfg(target_arch = "wasm32")] + { + let _ = (session, product_account_id); + Err(AuthorityError::Unavailable { + reason: "signing host: ring-VRF alias derivation is native-only".to_string(), + }) + } + #[cfg(not(target_arch = "wasm32"))] + { + require_current_session(&self.session_state, session)?; + let entropy = self.root_entropy()?; + let product_id = normalize_product_identifier(&product_account_id.dot_ns_identifier) + .map_err(|err| AuthorityError::Unavailable { + reason: err.to_string(), + })?; + let alias = crate::host_logic::alias::derive_product_alias( + &entropy, + &product_id, + product_account_id.derivation_index, + ) + .map_err(|reason| AuthorityError::Unknown { reason })?; + Ok(v01::HostAccountGetAliasResponse { + context: alias.context, + alias: alias.alias.to_vec(), + }) + } } async fn allocate_resources( @@ -228,6 +305,13 @@ fn product_authority_error(err: ProductAccountError) -> AuthorityError { } } +/// The user's main wallet keypair at `//wallet` (host-spec C.0), the root of +/// product-account derivation and the `rootUserAccountId` shared with paired +/// hosts. +pub(crate) fn wallet_root_keypair(entropy: &[u8]) -> Result { + derive_sr25519_hard_path(entropy, &["wallet"]).map_err(product_authority_error) +} + /// Wrap raw sign-message bytes in the `` envelope unless /// already wrapped, matching the polkadot-app raw-signing convention. /// @@ -272,9 +356,7 @@ mod tests { use super::super::authority::{AuthorityError, SignRawAuthorityRequest}; use super::super::{ProductAuthority, ProductRuntimeHost, RuntimeServices, SigningHostRole}; use super::{BYTES_WRAP_PREFIX, BYTES_WRAP_SUFFIX, LocalActivation, raw_payload_bytes}; - use crate::host_logic::product_account::{ - derive_product_keypair, derive_root_keypair_from_entropy, - }; + use crate::host_logic::product_account::{derive_product_keypair, derive_sr25519_hard_path}; use crate::test_support::{StubPlatform, test_spawner}; use truapi::api::{Account, Entropy, Signing}; use truapi::versioned::account::{HostAccountGetError, HostAccountGetRequest}; @@ -355,8 +437,8 @@ mod tests { futures::executor::block_on(runtime.sign_raw(&cx, request)).expect("sign_raw ok"); assert!(response.signed_transaction.is_none()); - let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); - let keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + let wallet = derive_sr25519_hard_path(&ENTROPY, &["wallet"]).unwrap(); + let keypair = derive_product_keypair(&wallet, "myapp.dot", 0).unwrap(); let signature = schnorrkel::Signature::from_bytes(&response.signature).expect("64-byte signature"); assert!( @@ -498,8 +580,8 @@ mod tests { }); let HostSignRawResponse::V1(response) = futures::executor::block_on(runtime.sign_raw(&cx, request)).expect("sign_raw ok"); - let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); - let keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + let wallet = derive_sr25519_hard_path(&ENTROPY, &["wallet"]).unwrap(); + let keypair = derive_product_keypair(&wallet, "myapp.dot", 0).unwrap(); let signature = schnorrkel::Signature::from_bytes(&response.signature).expect("64-byte signature"); assert!( @@ -585,7 +667,159 @@ mod tests { } #[test] - fn deferred_operations_return_unavailable() { + fn sign_payload_verifies_against_derived_product_key() { + use super::super::authority::SignPayloadAuthorityRequest; + use crate::host_logic::transaction::extrinsic_payload_preimage; + + let (_services, authority) = signing_runtime(); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation"); + let session = authority.current_session().expect("connected"); + let cx = CallContext::new(); + let payload = v01::HostSignPayloadData { + block_hash: vec![0xB1; 32], + block_number: vec![0x01], + era: vec![0x00], + genesis_hash: vec![0x61; 32], + method: vec![0x4D, 0x00], + nonce: vec![0x00], + spec_version: vec![0x51], + tip: vec![0x00], + transaction_version: vec![0x56], + signed_extensions: vec![], + version: 4, + asset_id: None, + metadata_hash: None, + mode: None, + with_signed_transaction: None, + }; + let request = v01::HostSignPayloadRequest { + account: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + payload: payload.clone(), + }; + + let response = futures::executor::block_on(authority.sign_payload( + &cx, + &session, + SignPayloadAuthorityRequest::Product(request), + )) + .expect("sign_payload ok"); + + assert!(response.signed_transaction.is_none()); + let wallet = derive_sr25519_hard_path(&ENTROPY, &["wallet"]).unwrap(); + let keypair = derive_product_keypair(&wallet, "myapp.dot", 0).unwrap(); + let signature = + schnorrkel::Signature::from_bytes(&response.signature).expect("64-byte signature"); + assert!( + keypair + .public + .verify_simple( + b"substrate", + &extrinsic_payload_preimage(&payload), + &signature + ) + .is_ok(), + "signature verifies over the payload preimage", + ); + } + + #[test] + fn create_transaction_builds_verifiable_v4_extrinsic() { + use super::super::authority::CreateTransactionAuthorityRequest; + use crate::host_logic::transaction::transaction_signing_preimage; + + let (_services, authority) = signing_runtime(); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation"); + let session = authority.current_session().expect("connected"); + let cx = CallContext::new(); + let extensions = vec![v01::TxPayloadExtension { + id: "CheckNonce".to_string(), + extra: vec![0x04], + additional_signed: vec![], + }]; + let payload = v01::ProductAccountTxPayload { + signer: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + genesis_hash: [0x61; 32], + call_data: vec![0x00, 0x00], + extensions: extensions.clone(), + tx_ext_version: 0, + }; + + let response = futures::executor::block_on(authority.create_transaction( + &cx, + &session, + CreateTransactionAuthorityRequest::Product(payload), + )) + .expect("create_transaction ok"); + + let wallet = derive_sr25519_hard_path(&ENTROPY, &["wallet"]).unwrap(); + let keypair = derive_product_keypair(&wallet, "myapp.dot", 0).unwrap(); + let transaction = response.transaction; + let mut body = transaction.as_slice(); + let body_len = + as parity_scale_codec::Decode>::decode(&mut body) + .expect("compact length prefix") + .0 as usize; + assert_eq!(body.len(), body_len); + assert_eq!(body[0], 0x84); + assert_eq!(body[1], 0x00); + assert_eq!(&body[2..34], &keypair.public.to_bytes()); + assert_eq!(body[34], 0x01); + let signature = schnorrkel::Signature::from_bytes(&body[35..99]).unwrap(); + assert_eq!(body[99], 0x04); + assert_eq!(&body[100..], &[0x00, 0x00]); + assert!( + keypair + .public + .verify_simple( + b"substrate", + &transaction_signing_preimage(&[0x00, 0x00], &extensions), + &signature + ) + .is_ok(), + "extrinsic signature verifies over call ++ extra ++ implicit", + ); + } + + #[test] + fn create_transaction_rejects_v5_extension_version() { + use super::super::authority::CreateTransactionAuthorityRequest; + + let (_services, authority) = signing_runtime(); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation"); + let session = authority.current_session().expect("connected"); + let cx = CallContext::new(); + let payload = v01::ProductAccountTxPayload { + signer: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + genesis_hash: [0x61; 32], + call_data: vec![], + extensions: vec![], + tx_ext_version: 1, + }; + + let err = futures::executor::block_on(authority.create_transaction( + &cx, + &session, + CreateTransactionAuthorityRequest::Product(payload), + )) + .expect_err("v5 rejected"); + + assert!(matches!(err, AuthorityError::Unavailable { .. })); + } + + #[test] + fn account_alias_returns_ring_vrf_alias() { let (_services, authority) = signing_runtime(); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation"); @@ -596,13 +830,27 @@ mod tests { &cx, &session, v01::ProductAccountId { - dot_ns_identifier: "other.dot".to_string(), + dot_ns_identifier: "truapi-playground.dot".to_string(), derivation_index: 0, }, - "myapp.dot".to_string(), + "truapi-playground.dot".to_string(), )) - .expect_err("alias deferred"); - assert!(matches!(alias, AuthorityError::Unavailable { .. })); + .expect("alias derives"); + + let expected = + crate::host_logic::alias::derive_product_alias(&ENTROPY, "truapi-playground.dot", 0) + .unwrap(); + assert_eq!(alias.context, expected.context); + assert_eq!(alias.alias, expected.alias.to_vec()); + } + + #[test] + fn deferred_operations_return_unavailable() { + let (_services, authority) = signing_runtime(); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation"); + let session = authority.current_session().expect("connected"); + let cx = CallContext::new(); let alloc = futures::executor::block_on(authority.allocate_resources( &cx, 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 new file mode 100644 index 00000000..d698aa82 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -0,0 +1,397 @@ +//! Signing-host responder half of the host-spec §B pairing protocol. +//! +//! Answers a pairing host's handshake proposal (QR/deeplink) with an +//! encrypted `Success` statement, then serves the encrypted SSO session: +//! acks every inbound request statement, dispatches the batched +//! [`v1::RemoteMessage`] requests onto the local signing authority, and posts +//! the response statements the pairing host is waiting for. Runs until the +//! peer sends `Disconnected`, the local session ends, or the transport fails. +//! +//! Sensitive operations consult [`truapi_platform::UserConfirmation`], the +//! same seam browser hosts use for their confirmation modals; a headless host +//! implements it with its approval policy. + +use std::collections::HashSet; +use std::sync::Arc; + +use parity_scale_codec::Encode; +use tracing::{debug, info, instrument, warn}; +use truapi::latest::HostAccountGetAliasResponse; +use truapi::{CallContext, v01}; +use truapi_platform::{ + CreateTransactionReview, SignPayloadReview, SignRawReview, UserConfirmationReview, +}; + +use super::SigningHost; +use crate::host_logic::entropy::root_entropy_source; +use crate::host_logic::product_account::derive_sr25519_hard_path; +use crate::host_logic::session::SsoSessionInfo; +use crate::host_logic::sso::messages::{ + self, CreateTransactionPayload, IncomingSsoRequest, RemoteMessage, RemoteMessageData, + ResourceAllocationResponse, RingVrfAliasResponse, SignRawLegacyResponse, + SigningPayloadResponseData, SigningRequest, SigningResponse, SsoAllocationOutcome, + build_outgoing_request_statement, build_signed_session_response_statement, + decode_incoming_sso_request, v1, +}; +use crate::host_logic::sso::pairing::{ + ResponderIdentity, VersionedHandshakeProposal, bootstrap_topic, decode_pairing_deeplink, + derive_p256_keypair_from_entropy, encrypt_v2_handshake_response, + establish_responder_session_info, v2, +}; +use crate::host_logic::statement_store::{build_signed_statement, parse_new_statements_result}; +use crate::runtime::authority::{ + CreateTransactionAuthorityRequest, ProductAuthority, SignPayloadAuthorityRequest, + SignRawAuthorityRequest, +}; +use crate::runtime::services::RuntimeServices; +use crate::runtime::sso_remote::fresh_statement_expiry; +use crate::runtime::statement_store_rpc; + +/// Domain label for the responder's persistent P-256 encryption key. +const SSO_ENCRYPTION_KEY_LABEL: &[u8] = b"sso-encryption"; +/// Domain label for the identity chat key shared in the handshake payload. +const CHAT_KEY_LABEL: &[u8] = b"chat-encryption"; + +/// Terminal outcome of one responder serve loop. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResponderExit { + /// The pairing host announced `Disconnected`. + PeerDisconnected, + /// The statement subscription ended without a disconnect message. + SubscriptionEnded, +} + +/// Answer `deeplink` and serve the resulting SSO session until it ends. +#[instrument(skip_all, fields(runtime.method = "sso_responder.respond_to_pairing"))] +pub(crate) async fn respond_to_pairing( + services: Arc, + signing_host: Arc, + deeplink: &str, +) -> Result { + let VersionedHandshakeProposal::V2(proposal) = decode_pairing_deeplink(deeplink)?; + let entropy = signing_host + .root_entropy() + .map_err(|err| format!("signing host has no active local session: {err}"))?; + // `//wallet` is the user's main account (host-spec C.0): it backs + // product-account derivation and is `rootUserAccountId`. The + // statement/identity account is `//wallet//sso`. Usernames may be + // registered on either (mobile registers `//wallet`, the bot + // `//wallet//sso`); the paired host's lookup tries identity then root. + let wallet = derive_sr25519_hard_path(&entropy, &["wallet"]) + .map_err(|err| format!("//wallet derivation failed: {err}"))?; + let statement = derive_sr25519_hard_path(&entropy, &["wallet", "sso"]) + .map_err(|err| format!("//wallet//sso derivation failed: {err}"))?; + let (encryption_secret_key, encryption_public_key) = + derive_p256_keypair_from_entropy(&entropy, SSO_ENCRYPTION_KEY_LABEL) + .map_err(|err| format!("responder P-256 derivation failed: {err}"))?; + let (identity_chat_private_key, _) = derive_p256_keypair_from_entropy(&entropy, CHAT_KEY_LABEL) + .map_err(|err| format!("responder chat-key derivation failed: {err}"))?; + let identity = ResponderIdentity { + statement_secret: statement.secret.to_bytes(), + statement_public_key: statement.public.to_bytes(), + encryption_secret_key, + encryption_public_key, + }; + let session = establish_responder_session_info( + &identity, + proposal.device.statement_account_id, + proposal.device.encryption_public_key, + )?; + + let success = v2::EncryptedResponse::Success(Box::new(v2::Success { + identity_account_id: identity.statement_public_key, + root_account_id: wallet.public.to_bytes(), + identity_chat_private_key, + sso_enc_pub_key: identity.encryption_public_key, + device_enc_pub_key: identity.encryption_public_key, + root_entropy_source: root_entropy_source(&entropy), + })); + let handshake = encrypt_v2_handshake_response(proposal.device.encryption_public_key, &success)?; + let topic = bootstrap_topic( + proposal.device.statement_account_id, + proposal.device.encryption_public_key, + ); + let statement = build_signed_statement( + &session, + topic, + topic, + handshake.encode(), + fresh_statement_expiry(), + )?; + services + .statement_store + .submit(statement, "sso-responder handshake") + .await?; + info!("answered pairing handshake, serving SSO session"); + + serve_session(services, signing_host, session).await +} + +/// Serve inbound session statements until the session ends. +#[instrument(skip_all, fields(runtime.method = "sso_responder.serve_session"))] +async fn serve_session( + services: Arc, + signing_host: Arc, + session: SsoSessionInfo, +) -> Result { + let rpc_client = services + .statement_store + .client("sso-responder session") + .await?; + let mut subscription = + statement_store_rpc::subscribe_match_all(&rpc_client, &[session.session_id_peer]) + .await + .map_err(|err| format!("sso-responder subscribe failed: {err}"))?; + let mut served_request_ids = HashSet::new(); + + while let Some(item) = subscription.next().await { + let value = item.map_err(|err| format!("sso-responder subscription failed: {err}"))?; + let page = parse_new_statements_result("sso-responder".to_string(), &value) + .map_err(|err| err.to_string())?; + for statement in page.statements { + let incoming = match decode_incoming_sso_request(&session, &statement) { + Ok(Some(incoming)) => incoming, + Ok(None) => continue, + Err(reason) => { + debug!(%reason, "ignoring undecodable session statement"); + continue; + } + }; + if !served_request_ids.insert(incoming.request_id.clone()) { + continue; + } + if let Some(exit) = serve_request(&services, &signing_host, &session, incoming).await? { + return Ok(exit); + } + } + } + Ok(ResponderExit::SubscriptionEnded) +} + +/// Ack one inbound request statement and answer its batched messages. +async fn serve_request( + services: &Arc, + signing_host: &Arc, + session: &SsoSessionInfo, + incoming: IncomingSsoRequest, +) -> Result, String> { + let ack = build_signed_session_response_statement( + session, + incoming.request_id.clone(), + 0, + fresh_statement_expiry(), + )?; + services + .statement_store + .submit(ack, "sso-responder ack") + .await?; + + for message in incoming.messages { + let RemoteMessageData::V1(request) = message.data; + if matches!(request, v1::RemoteMessage::Disconnected) { + info!("pairing host disconnected the SSO session"); + return Ok(Some(ResponderExit::PeerDisconnected)); + } + let Some(response) = + answer_remote_message(services, signing_host, message.message_id, request).await + else { + continue; + }; + let statement_request_id = format!("resp:{}", response.message_id); + let statement = build_outgoing_request_statement( + session, + statement_request_id, + vec![response], + fresh_statement_expiry(), + )?; + services + .statement_store + .submit(statement, "sso-responder response") + .await?; + } + Ok(None) +} + +/// Answer one application-level request message; `None` for message kinds +/// that take no response (responses echoed by the peer, unknown variants). +async fn answer_remote_message( + services: &Arc, + signing_host: &Arc, + message_id: String, + request: v1::RemoteMessage, +) -> Option { + let response_id = format!("{message_id}:response"); + let data = match request { + v1::RemoteMessage::SignRequest(request) => v1::RemoteMessage::SignResponse( + sign_response(services, signing_host, &message_id, *request).await, + ), + v1::RemoteMessage::RingVrfAliasRequest(request) => { + let payload = account_alias_response(signing_host, request).await; + v1::RemoteMessage::RingVrfAliasResponse(RingVrfAliasResponse { + responding_to: message_id, + payload, + }) + } + v1::RemoteMessage::ResourceAllocationRequest(request) => { + // No on-chain allowance support yet: every requested resource is + // reported `NotAvailable` so the pairing host resolves instead of + // timing out. + v1::RemoteMessage::ResourceAllocationResponse(ResourceAllocationResponse { + responding_to: message_id, + payload: Ok(request + .resources + .iter() + .map(|_| SsoAllocationOutcome::NotAvailable) + .collect()), + }) + } + v1::RemoteMessage::CreateTransactionRequest(request) => { + let CreateTransactionPayload::V1(payload) = request.payload; + let signed_transaction = create_transaction_response( + services, + signing_host, + CreateTransactionReview::Product(payload.clone()), + CreateTransactionAuthorityRequest::Product(payload), + ) + .await; + v1::RemoteMessage::CreateTransactionResponse(messages::CreateTransactionResponse { + responding_to: message_id, + signed_transaction, + }) + } + v1::RemoteMessage::CreateTransactionLegacyRequest(_) => { + v1::RemoteMessage::CreateTransactionResponse(messages::CreateTransactionResponse { + responding_to: message_id, + signed_transaction: Err( + "signing host: legacy-account transactions are not supported".to_string(), + ), + }) + } + v1::RemoteMessage::SignRawLegacyRequest(_) => { + v1::RemoteMessage::SignRawLegacyResponse(SignRawLegacyResponse { + responding_to: message_id, + signature: Err( + "signing host: legacy-account raw signing is not supported".to_string() + ), + }) + } + v1::RemoteMessage::Disconnected + | v1::RemoteMessage::SignResponse(_) + | v1::RemoteMessage::RingVrfAliasResponse(_) + | v1::RemoteMessage::ResourceAllocationResponse(_) + | v1::RemoteMessage::CreateTransactionResponse(_) + | v1::RemoteMessage::SignRawLegacyResponse(_) => return None, + }; + Some(RemoteMessage { + message_id: response_id, + data: RemoteMessageData::V1(data), + }) +} + +/// Confirm and serve a payload or raw signing request. +async fn sign_response( + services: &Arc, + signing_host: &Arc, + message_id: &str, + request: SigningRequest, +) -> SigningResponse { + let payload = serve_sign_request(services, signing_host, request).await; + if let Err(reason) = &payload { + warn!(%reason, "sign request failed"); + } + SigningResponse { + responding_to: message_id.to_string(), + payload, + } +} + +async fn serve_sign_request( + services: &Arc, + signing_host: &Arc, + request: SigningRequest, +) -> Result { + let session = signing_host + .current_session() + .ok_or_else(|| "signing host session is not active".to_string())?; + let cx = CallContext::new(); + let response = match request { + SigningRequest::Payload(request) => { + let request: v01::HostSignPayloadRequest = (*request).into(); + confirm( + services, + UserConfirmationReview::SignPayload(SignPayloadReview::Product(request.clone())), + ) + .await?; + signing_host + .sign_payload(&cx, &session, SignPayloadAuthorityRequest::Product(request)) + .await + } + SigningRequest::Raw(request) => { + let request: v01::HostSignRawRequest = request.into(); + confirm( + services, + UserConfirmationReview::SignRaw(SignRawReview::Product(request.clone())), + ) + .await?; + signing_host + .sign_raw(&cx, &session, SignRawAuthorityRequest::Product(request)) + .await + } + } + .map_err(|err| err.reason())?; + Ok(SigningPayloadResponseData { + signature: response.signature, + signed_transaction: response.signed_transaction, + }) +} + +/// Confirm and serve a transaction-creation request. +async fn create_transaction_response( + services: &Arc, + signing_host: &Arc, + review: CreateTransactionReview, + request: CreateTransactionAuthorityRequest, +) -> Result, String> { + let session = signing_host + .current_session() + .ok_or_else(|| "signing host session is not active".to_string())?; + confirm(services, UserConfirmationReview::CreateTransaction(review)).await?; + let cx = CallContext::new(); + signing_host + .create_transaction(&cx, &session, request) + .await + .map(|response| response.transaction) + .map_err(|err| err.reason()) +} + +async fn account_alias_response( + signing_host: &Arc, + request: messages::RingVrfAliasRequest, +) -> Result { + let session = signing_host + .current_session() + .ok_or_else(|| "signing host session is not active".to_string())?; + let cx = CallContext::new(); + signing_host + .account_alias( + &cx, + &session, + request.product_account_id, + request.product_id, + ) + .await + .map_err(|err| err.reason()) +} + +/// Run the platform confirmation seam; rejection and failure both refuse the +/// operation with an opaque reason (host-spec B.7). +async fn confirm( + services: &Arc, + review: UserConfirmationReview, +) -> Result<(), String> { + match services.platform.confirm_user_action(review).await { + Ok(true) => Ok(()), + Ok(false) => Err("Rejected".to_string()), + Err(err) => Err(format!("confirmation failed: {}", err.reason)), + } +} diff --git a/rust/crates/truapi-server/src/runtime/sso_pairing.rs b/rust/crates/truapi-server/src/runtime/sso_pairing.rs index 66b34721..4999688e 100644 --- a/rust/crates/truapi-server/src/runtime/sso_pairing.rs +++ b/rust/crates/truapi-server/src/runtime/sso_pairing.rs @@ -206,7 +206,7 @@ impl<'a> SsoPairingFlow<'a> { }; let resolve_session = resolve_session_identity_with_chain( &self.host.chain, - self.host.host_config.people_chain_genesis_hash, + self.host.host_config.identity_lookup_genesis_hash(), session, ) .fuse(); From 1d919f006af82d076305a16e261c2a9f4b90ffb9 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Sun, 5 Jul 2026 21:36:05 +0200 Subject: [PATCH 03/13] fix(chain): stop_transaction example broadcasts then cancels by id The stop_transaction playground example used a hardcoded placeholder operationId; it now broadcasts a transaction and stops it with the returned id, the correct usage. Also emit captured example output for passing methods in the headless diagnosis report and load the e2e signer mnemonic from a gitignored e2e/.env. Note: against the public paseo Asset Hub RPC, transaction_v1_stop rejects broadcast operation ids (-32602), so this method stays red there; it passes on hosts whose transaction API honors stop (e.g. smoldot in the browser host). --- explorer/diagnosis-reports/headless.md | 86 +++++++++++----------- rust/crates/truapi-host-cli/e2e/run-e2e.ts | 5 +- rust/crates/truapi/src/api/chain.rs | 9 ++- 3 files changed, 54 insertions(+), 46 deletions(-) diff --git a/explorer/diagnosis-reports/headless.md b/explorer/diagnosis-reports/headless.md index ca1791cd..826f1679 100644 --- a/explorer/diagnosis-reports/headless.md +++ b/explorer/diagnosis-reports/headless.md @@ -2,25 +2,25 @@ | Method | Status | Details | | --- | --- | --- | -| `Account/connection_status_subscribe` | ✅ | | -| `Account/get_account` | ✅ | | -| `Account/get_account_alias` | ✅ | | +| `Account/connection_status_subscribe` | ✅ | connection status: Connected | +| `Account/get_account` | ✅ | account retrieved: { "account": { "publicKey": "0xf41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef13" } } | +| `Account/get_account_alias` | ✅ | account alias: { "context": "0x05cc3451e5a525ff21f0a62ffe5e5c184fa4cd790bbaead4ef44ab8dc914ecee", "alias": "0x34cc10180a6fc6977f179b03ae39a261ae5cd44fab26d2086545e00b1b94368c" } | | `Account/create_account_proof` | ⏭️ | | -| `Account/get_legacy_accounts` | ✅ | | -| `Account/get_user_id` | ✅ | | -| `Account/request_login` | ✅ | | -| `Chain/follow_head_subscribe` | ✅ | | -| `Chain/get_head_header` | ✅ | | -| `Chain/get_head_body` | ✅ | | -| `Chain/get_head_storage` | ✅ | | -| `Chain/call_head` | ✅ | | -| `Chain/unpin_head` | ✅ | | -| `Chain/continue_head` | ✅ | | -| `Chain/stop_head_operation` | ✅ | | -| `Chain/get_spec_genesis_hash` | ✅ | | -| `Chain/get_spec_chain_name` | ✅ | | -| `Chain/get_spec_properties` | ✅ | | -| `Chain/broadcast_transaction` | ✅ | | +| `Account/get_legacy_accounts` | ✅ | legacy accounts: { "accounts": [ { "publicKey": "0xf41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef13", "name": "pgherveou.06" } ] } | +| `Account/get_user_id` | ✅ | user id: { "primaryUsername": "pgherveou.06" } | +| `Account/request_login` | ✅ | login completed: AlreadyConnected | +| `Chain/follow_head_subscribe` | ✅ | head follow event: { "tag": "Initialized", "value": { "finalizedBlockHashes": [ "0xa5ae138f1b4cd068bc704ee3cb6f9358b21e82799451d1b08658be523757fe2f", "0x2c2ec575d2c336b34f635965ef2636587938fda8d64578fbf88f720947a1203a", "0xd8abfa22d8ed08c4728916457d353f9b122c5f109bef8f8727d956b4ad1e37b5", "0x541e... | +| `Chain/get_head_header` | ✅ | block header: { "header": "0xf9a495e0317aa41a3e1d51e73ecb9a9d4a495ed4e0991f838207944dc521a0e7aaba5a006d711144eeb697c01f656c61e86a9747f958e10c9f9037fd1dc52df3c0104ba52b0f9b97f61207308e3a1b3f5ba6616de4073a48c3279956d1b8f64340d7e6701006434d4c531001000104066175726120a98edb08000000000452505352903d2a29... | +| `Chain/get_head_body` | ✅ | block body: { "operation": { "tag": "Started", "value": { "operationId": "0" } } } | +| `Chain/get_head_storage` | ✅ | storage value: { "operation": { "tag": "Started", "value": { "operationId": "0" } } } | +| `Chain/call_head` | ✅ | runtime call result: { "operation": { "tag": "Started", "value": { "operationId": "0" } } } | +| `Chain/unpin_head` | ✅ | blocks unpinned | +| `Chain/continue_head` | ✅ | operation continued | +| `Chain/stop_head_operation` | ✅ | operation stopped | +| `Chain/get_spec_genesis_hash` | ✅ | genesis hash: { "genesisHash": "0xbf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f" } | +| `Chain/get_spec_chain_name` | ✅ | chain name: { "chainName": "Paseo Asset Hub Next" } | +| `Chain/get_spec_properties` | ✅ | chain properties: { "properties": "{\"tokenDecimals\":10,\"tokenSymbol\":\"PAS\"}" } | +| `Chain/broadcast_transaction` | ✅ | transaction broadcast: { "operationId": "IOSWFVOvUOCr6EqA" } | | `Chain/stop_transaction` | ❌ | stopTransaction failed: { "error": { "tag": "HostFailure", "value": { "reason": "remote_chain_transaction_stop: User error: Invalid operation id (-32602)" } } } | | `Chat/create_room` | ⏭️ | | | `Chat/register_bot` | ⏭️ | | @@ -37,32 +37,32 @@ | `Coin Payment/deposit` | ⏭️ | | | `Coin Payment/refund` | ⏭️ | | | `Coin Payment/listen_for_payment` | ⏭️ | | -| `Entropy/derive` | ✅ | | -| `Local Storage/read` | ✅ | | -| `Local Storage/write` | ✅ | | -| `Local Storage/clear` | ✅ | | -| `Notifications/send_push_notification` | ✅ | | -| `Notifications/cancel_push_notification` | ✅ | | +| `Entropy/derive` | ✅ | entropy derived: { "entropy": "0x9ad6f1f6ac64687863a3456a7ddcb06a94c0b9a950930bb8eea3b51743f70baa" } | +| `Local Storage/read` | ✅ | storage value read: | +| `Local Storage/write` | ✅ | storage write succeeded | +| `Local Storage/clear` | ✅ | storage clear succeeded | +| `Notifications/send_push_notification` | ✅ | notification sent: { "id": 1 } | +| `Notifications/cancel_push_notification` | ✅ | notification cancelled | | `Payment/balance_subscribe` | ⏭️ | | | `Payment/top_up` | ⏭️ | | | `Payment/request` | ⏭️ | | | `Payment/status_subscribe` | ⏭️ | | -| `Permissions/request_device_permission` | ✅ | | -| `Permissions/request_remote_permission` | ✅ | | -| `Preimage/lookup_subscribe` | ✅ | | -| `Preimage/submit` | ✅ | | -| `Resource Allocation/request` | ✅ | | -| `Signing/create_transaction` | ✅ | | -| `Signing/create_transaction_with_legacy_account` | ✅ | | -| `Signing/sign_raw_with_legacy_account` | ✅ | | -| `Signing/sign_payload_with_legacy_account` | ✅ | | -| `Signing/sign_raw` | ✅ | | -| `Signing/sign_payload` | ✅ | | -| `Statement Store/subscribe` | ✅ | | -| `Statement Store/create_proof` | ✅ | | -| `Statement Store/submit` | ✅ | | -| `Statement Store/create_proof_authorized` | ✅ | | -| `System/handshake` | ✅ | | -| `System/feature_supported` | ✅ | | -| `System/navigate_to` | ✅ | | -| `Theme/subscribe` | ✅ | | +| `Permissions/request_device_permission` | ✅ | device permission result: { "granted": true } | +| `Permissions/request_remote_permission` | ✅ | remote permission result: { "granted": true } | +| `Preimage/lookup_subscribe` | ✅ | preimage lookup received: { "value": "0xdeadbeef" } | +| `Preimage/submit` | ✅ | preimage submitted: 0xf3e925002fed7cc0ded46842569eb5c90c910c091d8d04a1bdf96e0db719fd91 | +| `Resource Allocation/request` | ✅ | resource allocation result: { "outcomes": [ "NotAvailable", "NotAvailable" ] } | +| `Signing/create_transaction` | ✅ | transaction created: { "transaction": "0xd9018400f41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef1301a277fa7a08aaa6253478e7f5a974ccde4b4a89aa891d94150815d3a514a01e1a7bd5d16fbc3e8df09f39d112dc92dbf2854b10dc4bca2d5bff21aa966609008400000000000000000000000000000000000000" } | +| `Signing/create_transaction_with_legacy_account` | ✅ | selected legacy account: { "publicKey": "0xf41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef13", "name": "pgherveou.06" } transaction created: { "transaction": "0xd9018400f41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef1301cea6ac96e57e11d4f1f910704963723e1e18df71947f2bf... | +| `Signing/sign_raw_with_legacy_account` | ✅ | raw bytes signed: { "signature": "0x5e3a2c277cfab5b897be4e49b2e01d31297d40da7e4dead2a14b42a17f38c27cb836c8f30985cac947cf656c7d27aa808e2d827f132d57e101b61241d5c74c8b" } | +| `Signing/sign_payload_with_legacy_account` | ✅ | payload signed: { "signature": "0xd0e90abc571c107f492f3d6b8b0811799e3869d12e0b4a103c2a77b81cf03034079441f76884c823a14a6577b0f12e018608735e12c149cddc296fe851b19a87" } | +| `Signing/sign_raw` | ✅ | raw bytes signed: { "signature": "0x641d998ce2ce375ab55682a22d2e941bb881f99ed986aa44aa1cbc5d23734569667f098642436d72d44fe199be2c9d062ca992a7e8578cb2627b04111d081b87" } | +| `Signing/sign_payload` | ✅ | payload signed: { "signature": "0xf6a22196b0ad973c405ab42a0041b3ac83fed630c0257663707ebc593dcae179189aaa6e3db2bc3f4ba8e52d99bfc76f6ec54d8f66923d5fb5d236fa02b4a483" } | +| `Statement Store/subscribe` | ✅ | submitting statement: { "expiry": "7659500274590941184n", "topics": [ "0xa54edfe3483582af1d5eca2fe801f0dce1737aaa01805c28992b1feda9f424ea" ], "proof": { "tag": "Sr25519", "value": { "signature": "0x683790faacf0cdd69fd7ddbe467487a99b6b745ed9b2de14e4dc5a94b2c8875ec9bc1c4da29d8791ea59939a1b56c4b831e... | +| `Statement Store/create_proof` | ✅ | proof created: { "proof": { "tag": "Sr25519", "value": { "signature": "0x740f4a72c94a4fe3a10b8bb2c1569dadf2e7b9970eb79fc17767e068b219616c16e8852ce1afac38d544b6f0cf4fe3964f0460601963376aeb40e2b988a64c8d", "signer": "0x92efa72e75d92c115fb4d596387290e4409ff1b1a72a3ec39457ef351086d838" } } } | +| `Statement Store/submit` | ✅ | statement submitted | +| `Statement Store/create_proof_authorized` | ✅ | proof created: { "proof": { "tag": "Sr25519", "value": { "signature": "0x1ee8ba4f76b76029dd398f9f1c364aeb25fcb8c97a9ae0d2cec5a94527c07d35122c37b12f7c92bab87ff52bdc1acee73927f7b93859554c7cc8b98a122b6c83", "signer": "0x92efa72e75d92c115fb4d596387290e4409ff1b1a72a3ec39457ef351086d838" } } } | +| `System/handshake` | ✅ | handshake succeeded | +| `System/feature_supported` | ✅ | feature supported: true | +| `System/navigate_to` | ✅ | navigation succeeded | +| `Theme/subscribe` | ✅ | theme received: { "name": { "tag": "Default" }, "variant": "Dark" } | diff --git a/rust/crates/truapi-host-cli/e2e/run-e2e.ts b/rust/crates/truapi-host-cli/e2e/run-e2e.ts index 19fd5757..8ef406d5 100644 --- a/rust/crates/truapi-host-cli/e2e/run-e2e.ts +++ b/rust/crates/truapi-host-cli/e2e/run-e2e.ts @@ -246,7 +246,8 @@ function cleanDetail(output: string): string { // Emit a report in the same table shape as explorer/diagnosis-reports/web.md // so the headless run can be diffed against the browser host directly. -// Details are shown for failures (matching web.md); pass/skip cells are blank. +// Details carry the captured example output for passes and the error for +// failures; skipped rows are blank. function renderReport(rows: DiagnosisRow[]): string { const icon = (s: DiagnosisRow["status"]) => s === "pass" ? "✅" : s === "skipped" ? "⏭️" : "❌"; @@ -258,7 +259,7 @@ function renderReport(rows: DiagnosisRow[]): string { "| --- | --- | --- |", ...rows.map( (r) => - `| \`${r.id}\` | ${icon(r.status)} | ${r.status === "fail" ? cleanDetail(r.output) : ""} |`, + `| \`${r.id}\` | ${icon(r.status)} | ${r.status === "skipped" ? "" : cleanDetail(r.output)} |`, ), ].join("\n") + "\n" ); diff --git a/rust/crates/truapi/src/api/chain.rs b/rust/crates/truapi/src/api/chain.rs index 158dd12f..371e6f1d 100644 --- a/rust/crates/truapi/src/api/chain.rs +++ b/rust/crates/truapi/src/api/chain.rs @@ -362,9 +362,16 @@ pub trait Chain: Send + Sync { /// ```ts /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; /// + /// // Start a broadcast, then stop it using the returned operation id. + /// const broadcast = await truapi.chain.broadcastTransaction({ + /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, + /// transaction: "0x", + /// }); + /// assert(broadcast.isOk(), "broadcastTransaction failed:", broadcast); + /// /// const result = await truapi.chain.stopTransaction({ /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis, - /// operationId: "op-id", + /// operationId: broadcast.value.operationId, /// }); /// assert(result.isOk(), "stopTransaction failed:", result); /// console.log("transaction broadcast stopped"); From 18891940fce81f883e25a742fd7a1082df5761a7 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Mon, 6 Jul 2026 11:02:20 +0200 Subject: [PATCH 04/13] feat(truapi-host-cli): pair over the real People-chain statement store The headless hosts now connect to the statement store the same way an iOS/web client does: the real paseo-next-v2 People chain, which serves both the statement-store RPC and the chain RPC over one WebSocket. The in-memory relay (relay.rs, the `relay` subcommand, and the relay orchestration in the e2e runner) is removed; both hosts default `--statement-store` to the People node. Statement-store allowance is obtained on-chain. New native module `src/alloc/` ports `Resources.set_statement_store_account` (pallet 63 call 10): metadata-driven signed-extension encoding + inherited-implication proof message (pinned to the live-verified known answer), bandersnatch ring-VRF membership proof via the `verifiable` prover, the unsigned General (v5) extrinsic, `Members` ring fetch, SSS_SLOT slot scan, a minimal WS JSON-RPC client, and `author_submitAndWatchExtrinsic`. Before pairing, the signing host registers allowance for both its own `//wallet//sso` account and the pairing host's device key, proving its LitePeople ring membership. A new `alloc-check` subcommand diagnoses (or `--submit`s) allowance. statement_store_rpc::submit now inspects the SubmitResult: only `new`/ `known` count as accepted, so `NoAllowance`/`BadProof` rejections surface instead of being silently dropped. --- CLAUDE.md | 2 +- Cargo.lock | 7 + README.md | 2 +- explorer/diagnosis-reports/headless.md | 24 +- rust/crates/truapi-host-cli/Cargo.toml | 9 +- rust/crates/truapi-host-cli/README.md | 85 ++-- rust/crates/truapi-host-cli/e2e/run-e2e.ts | 47 +-- rust/crates/truapi-host-cli/src/alloc.rs | 177 +++++++++ .../truapi-host-cli/src/alloc/dynamic.rs | 132 +++++++ .../truapi-host-cli/src/alloc/extension.rs | 370 ++++++++++++++++++ .../truapi-host-cli/src/alloc/extrinsic.rs | 142 +++++++ .../crates/truapi-host-cli/src/alloc/proof.rs | 111 ++++++ rust/crates/truapi-host-cli/src/alloc/ring.rs | 164 ++++++++ rust/crates/truapi-host-cli/src/alloc/rpc.rs | 171 ++++++++ rust/crates/truapi-host-cli/src/alloc/slot.rs | 129 ++++++ rust/crates/truapi-host-cli/src/chain.rs | 20 +- rust/crates/truapi-host-cli/src/main.rs | 274 +++++++++++-- rust/crates/truapi-host-cli/src/platform.rs | 12 +- rust/crates/truapi-host-cli/src/relay.rs | 297 -------------- .../fixtures/paseo-next-v2-metadata.scale | Bin 0 -> 388952 bytes .../src/runtime/statement_store.rs | 4 +- .../src/runtime/statement_store_rpc.rs | 16 +- rust/crates/truapi-server/src/test_support.rs | 4 +- 23 files changed, 1770 insertions(+), 429 deletions(-) create mode 100644 rust/crates/truapi-host-cli/src/alloc.rs create mode 100644 rust/crates/truapi-host-cli/src/alloc/dynamic.rs create mode 100644 rust/crates/truapi-host-cli/src/alloc/extension.rs create mode 100644 rust/crates/truapi-host-cli/src/alloc/extrinsic.rs create mode 100644 rust/crates/truapi-host-cli/src/alloc/proof.rs create mode 100644 rust/crates/truapi-host-cli/src/alloc/ring.rs create mode 100644 rust/crates/truapi-host-cli/src/alloc/rpc.rs create mode 100644 rust/crates/truapi-host-cli/src/alloc/slot.rs delete mode 100644 rust/crates/truapi-host-cli/src/relay.rs create mode 100644 rust/crates/truapi-host-cli/tests/fixtures/paseo-next-v2-metadata.scale diff --git a/CLAUDE.md b/CLAUDE.md index 19beb772..1260ef33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ rust/crates/ truapi-macros/ #[wire(id = N)] proc-macro truapi-platform/ Host syscall traits (storage, navigation, consent, ...) truapi-server/ Rust runtime hosts implement; ships as WASM (browser/node) - truapi-host-cli/ Headless pairing-host + signing-host + dev statement-store relay CLIs; local e2e signing-bot replacement + truapi-host-cli/ Headless pairing-host + signing-host CLIs that pair over the real People-chain statement store; local e2e signing-bot replacement js/packages/ truapi/ @parity/truapi TS package; generated TS lives under ignored paths truapi-host-wasm/ @parity/truapi-host-wasm: WASM-backed host runtime. Subpath entries: diff --git a/Cargo.lock b/Cargo.lock index cd8a25d8..cfe3de7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1304,6 +1304,7 @@ dependencies = [ "cfg-if", "parity-scale-codec", "scale-info", + "serde", ] [[package]] @@ -3034,6 +3035,7 @@ dependencies = [ "derive_more 1.0.0", "parity-scale-codec", "scale-info-derive", + "serde", ] [[package]] @@ -3980,13 +3982,16 @@ dependencies = [ "bip39", "blake2-rfc", "clap", + "frame-metadata", "futures", "futures-util", "hex", "parity-scale-codec", "reqwest", "rustls", + "scale-info", "serde_json", + "sp-crypto-hashing", "tokio", "tokio-stream", "tokio-tungstenite", @@ -3995,6 +4000,7 @@ dependencies = [ "truapi", "truapi-platform", "truapi-server", + "verifiable", ] [[package]] @@ -4098,6 +4104,7 @@ checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" dependencies = [ "cfg-if", "digest 0.10.7", + "rand 0.8.6", "static_assertions", ] diff --git a/README.md b/README.md index 6f48cff9..edefc53c 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ rust/crates/ truapi-macros/ #[wire(id = N)] proc-macro truapi-platform/ Host syscall traits used by truapi-server (storage, navigation, consent, ...) truapi-server/ Rust runtime that hosts implement: dispatcher, frames, SCALE, WASM surface - truapi-host-cli/ Headless pairing-host + signing-host + dev relay CLIs for local e2e (signing-bot replacement) + truapi-host-cli/ Headless pairing-host + signing-host CLIs that pair over the real People-chain statement store for local e2e (signing-bot replacement) js/packages/ truapi/ @parity/truapi TypeScript client truapi-host-wasm/ @parity/truapi-host-wasm: WASM-backed host runtime; entries `.` diff --git a/explorer/diagnosis-reports/headless.md b/explorer/diagnosis-reports/headless.md index 826f1679..9001b7f8 100644 --- a/explorer/diagnosis-reports/headless.md +++ b/explorer/diagnosis-reports/headless.md @@ -9,8 +9,8 @@ | `Account/get_legacy_accounts` | ✅ | legacy accounts: { "accounts": [ { "publicKey": "0xf41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef13", "name": "pgherveou.06" } ] } | | `Account/get_user_id` | ✅ | user id: { "primaryUsername": "pgherveou.06" } | | `Account/request_login` | ✅ | login completed: AlreadyConnected | -| `Chain/follow_head_subscribe` | ✅ | head follow event: { "tag": "Initialized", "value": { "finalizedBlockHashes": [ "0xa5ae138f1b4cd068bc704ee3cb6f9358b21e82799451d1b08658be523757fe2f", "0x2c2ec575d2c336b34f635965ef2636587938fda8d64578fbf88f720947a1203a", "0xd8abfa22d8ed08c4728916457d353f9b122c5f109bef8f8727d956b4ad1e37b5", "0x541e... | -| `Chain/get_head_header` | ✅ | block header: { "header": "0xf9a495e0317aa41a3e1d51e73ecb9a9d4a495ed4e0991f838207944dc521a0e7aaba5a006d711144eeb697c01f656c61e86a9747f958e10c9f9037fd1dc52df3c0104ba52b0f9b97f61207308e3a1b3f5ba6616de4073a48c3279956d1b8f64340d7e6701006434d4c531001000104066175726120a98edb08000000000452505352903d2a29... | +| `Chain/follow_head_subscribe` | ✅ | head follow event: { "tag": "Initialized", "value": { "finalizedBlockHashes": [ "0x250744308f9f54705da08b8ed1360756a60c4c4d5ed0dd028f6d3ffaef34697c", "0x167e135c78af237da48362dce50efdd1a9362c0248250935890f060b9ac2572e", "0xb772c6963007376a073bd79bbb0ede819a97ab3605a26975df75be98116ffb6f", "0xc0cb... | +| `Chain/get_head_header` | ✅ | block header: { "header": "0x4a65540ed66cc5b8a4023520d76a617c1cf95ce8ddb9ccd86890e7c26a45dd0616d05a0000dc38d0b9c4aad4dd573ababe1457db2ee8455f4a4107cbaed510a67027050538cc0edf23a8f6548e4d80afe3f1f2daa9f3dd50b732f86a8cca75bf5fb512331006434d4c531001000104066175726120479adb0800000000045250535290f2cf90... | | `Chain/get_head_body` | ✅ | block body: { "operation": { "tag": "Started", "value": { "operationId": "0" } } } | | `Chain/get_head_storage` | ✅ | storage value: { "operation": { "tag": "Started", "value": { "operationId": "0" } } } | | `Chain/call_head` | ✅ | runtime call result: { "operation": { "tag": "Started", "value": { "operationId": "0" } } } | @@ -20,7 +20,7 @@ | `Chain/get_spec_genesis_hash` | ✅ | genesis hash: { "genesisHash": "0xbf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f" } | | `Chain/get_spec_chain_name` | ✅ | chain name: { "chainName": "Paseo Asset Hub Next" } | | `Chain/get_spec_properties` | ✅ | chain properties: { "properties": "{\"tokenDecimals\":10,\"tokenSymbol\":\"PAS\"}" } | -| `Chain/broadcast_transaction` | ✅ | transaction broadcast: { "operationId": "IOSWFVOvUOCr6EqA" } | +| `Chain/broadcast_transaction` | ✅ | transaction broadcast: { "operationId": "78Z1IqDRkuXpP8rO" } | | `Chain/stop_transaction` | ❌ | stopTransaction failed: { "error": { "tag": "HostFailure", "value": { "reason": "remote_chain_transaction_stop: User error: Invalid operation id (-32602)" } } } | | `Chat/create_room` | ⏭️ | | | `Chat/register_bot` | ⏭️ | | @@ -52,16 +52,16 @@ | `Preimage/lookup_subscribe` | ✅ | preimage lookup received: { "value": "0xdeadbeef" } | | `Preimage/submit` | ✅ | preimage submitted: 0xf3e925002fed7cc0ded46842569eb5c90c910c091d8d04a1bdf96e0db719fd91 | | `Resource Allocation/request` | ✅ | resource allocation result: { "outcomes": [ "NotAvailable", "NotAvailable" ] } | -| `Signing/create_transaction` | ✅ | transaction created: { "transaction": "0xd9018400f41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef1301a277fa7a08aaa6253478e7f5a974ccde4b4a89aa891d94150815d3a514a01e1a7bd5d16fbc3e8df09f39d112dc92dbf2854b10dc4bca2d5bff21aa966609008400000000000000000000000000000000000000" } | -| `Signing/create_transaction_with_legacy_account` | ✅ | selected legacy account: { "publicKey": "0xf41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef13", "name": "pgherveou.06" } transaction created: { "transaction": "0xd9018400f41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef1301cea6ac96e57e11d4f1f910704963723e1e18df71947f2bf... | -| `Signing/sign_raw_with_legacy_account` | ✅ | raw bytes signed: { "signature": "0x5e3a2c277cfab5b897be4e49b2e01d31297d40da7e4dead2a14b42a17f38c27cb836c8f30985cac947cf656c7d27aa808e2d827f132d57e101b61241d5c74c8b" } | -| `Signing/sign_payload_with_legacy_account` | ✅ | payload signed: { "signature": "0xd0e90abc571c107f492f3d6b8b0811799e3869d12e0b4a103c2a77b81cf03034079441f76884c823a14a6577b0f12e018608735e12c149cddc296fe851b19a87" } | -| `Signing/sign_raw` | ✅ | raw bytes signed: { "signature": "0x641d998ce2ce375ab55682a22d2e941bb881f99ed986aa44aa1cbc5d23734569667f098642436d72d44fe199be2c9d062ca992a7e8578cb2627b04111d081b87" } | -| `Signing/sign_payload` | ✅ | payload signed: { "signature": "0xf6a22196b0ad973c405ab42a0041b3ac83fed630c0257663707ebc593dcae179189aaa6e3db2bc3f4ba8e52d99bfc76f6ec54d8f66923d5fb5d236fa02b4a483" } | -| `Statement Store/subscribe` | ✅ | submitting statement: { "expiry": "7659500274590941184n", "topics": [ "0xa54edfe3483582af1d5eca2fe801f0dce1737aaa01805c28992b1feda9f424ea" ], "proof": { "tag": "Sr25519", "value": { "signature": "0x683790faacf0cdd69fd7ddbe467487a99b6b745ed9b2de14e4dc5a94b2c8875ec9bc1c4da29d8791ea59939a1b56c4b831e... | -| `Statement Store/create_proof` | ✅ | proof created: { "proof": { "tag": "Sr25519", "value": { "signature": "0x740f4a72c94a4fe3a10b8bb2c1569dadf2e7b9970eb79fc17767e068b219616c16e8852ce1afac38d544b6f0cf4fe3964f0460601963376aeb40e2b988a64c8d", "signer": "0x92efa72e75d92c115fb4d596387290e4409ff1b1a72a3ec39457ef351086d838" } } } | +| `Signing/create_transaction` | ✅ | transaction created: { "transaction": "0xd9018400f41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef1301c8264dd11330720ff8232378c87186794c5e577abdd74d74cd967367f623e30e1e14165e7cdec32a7f9b566faf06e026fa7d71cf6f510062094f284293daf08d00000000000000000000000000000000000000" } | +| `Signing/create_transaction_with_legacy_account` | ✅ | selected legacy account: { "publicKey": "0xf41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef13", "name": "pgherveou.06" } transaction created: { "transaction": "0xd9018400f41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef1301c2a7dfb92f096c1bfc1d680f234ae203e68352dd9e79dff... | +| `Signing/sign_raw_with_legacy_account` | ✅ | raw bytes signed: { "signature": "0xd291fcd096acd68f0fdbdada30ac91bc58bd7960e9328ccb96b251effc991c7518cabe8c9559a33426b2da0a6beec409980bdfb91502a2fa84ee889624236681" } | +| `Signing/sign_payload_with_legacy_account` | ✅ | payload signed: { "signature": "0x70636930d6fbdeff4587ea2500def78fefe75195ad0e5ec09e8e945fa82af22b24b986ed01a0de3c2b270d9bdf623e1abb53836b491163925b71ffc9205d628e" } | +| `Signing/sign_raw` | ✅ | raw bytes signed: { "signature": "0xfcc96443f18f6e8b1340fdfaa3baa5d33f8f983014a9ce602ede6e7a769067569f32514e95a481bbcc5a0a5c1c02a175458596bd436fea50c5c9d870e963ba8a" } | +| `Signing/sign_payload` | ✅ | payload signed: { "signature": "0x625e911b25ca6471b5aa4b917012ccbad10a54347142e78a31930901749480457a7fee72d0b9a8ab666b948de6db9b094a09a4f0508e9c666689c0a8a5e7d189" } | +| `Statement Store/subscribe` | ✅ | submitting statement: { "expiry": "7659654034420137984n", "topics": [ "0xd61590958f674d52bad9c60348459ce813540d1a5108266f803d3083530dafaf" ], "proof": { "tag": "Sr25519", "value": { "signature": "0x0a3feb7cebe294484fbe3beda1a7474cec09cbc1f08dfbe31f353b2497cfb9184adae85612ab6c0ae5e1e30c76142d36ec8... | +| `Statement Store/create_proof` | ✅ | proof created: { "proof": { "tag": "Sr25519", "value": { "signature": "0x2299d6c886a51b79f8de93e15269f5dc5601f0a379ce19243e8366db93e98b46f1a3caed8cf04265752d086a684afb50c7a1348af31e91f0cbb73246db485186", "signer": "0xd6af71ea43ef71caea4afb37d908333411a79aad6f2559864296606d47d47a52" } } } | | `Statement Store/submit` | ✅ | statement submitted | -| `Statement Store/create_proof_authorized` | ✅ | proof created: { "proof": { "tag": "Sr25519", "value": { "signature": "0x1ee8ba4f76b76029dd398f9f1c364aeb25fcb8c97a9ae0d2cec5a94527c07d35122c37b12f7c92bab87ff52bdc1acee73927f7b93859554c7cc8b98a122b6c83", "signer": "0x92efa72e75d92c115fb4d596387290e4409ff1b1a72a3ec39457ef351086d838" } } } | +| `Statement Store/create_proof_authorized` | ✅ | proof created: { "proof": { "tag": "Sr25519", "value": { "signature": "0xd861edf3ff18bdb04c0f6d76add5429014afa964521740147044ce7ac2d9d3369446a8c888b4efef32269dac45ad6d7bf07e34f278037ddb7ee2f3bd60304786", "signer": "0xd6af71ea43ef71caea4afb37d908333411a79aad6f2559864296606d47d47a52" } } } | | `System/handshake` | ✅ | handshake succeeded | | `System/feature_supported` | ✅ | feature supported: true | | `System/navigate_to` | ✅ | navigation succeeded | diff --git a/rust/crates/truapi-host-cli/Cargo.toml b/rust/crates/truapi-host-cli/Cargo.toml index c9548dac..e5f72947 100644 --- a/rust/crates/truapi-host-cli/Cargo.toml +++ b/rust/crates/truapi-host-cli/Cargo.toml @@ -2,7 +2,7 @@ name = "truapi-host-cli" version = "0.1.0" edition.workspace = true -description = "Headless TrUAPI hosts: a signing-host companion and a pairing host, plus a dev statement-store relay, for end-to-end testing without an external signer service" +description = "Headless TrUAPI hosts: a signing-host companion and a pairing host that pair over the real People-chain statement store, for end-to-end testing without an external signer service" license = "MIT" [[bin]] @@ -21,10 +21,13 @@ async-trait = "0.1" bip39 = "2" blake2-rfc = { version = "0.2", default-features = false } clap = { version = "4", features = ["derive"] } +frame-metadata = { version = "23", default-features = false, features = ["std", "current", "decode"] } futures = "0.3" futures-util = "0.3" hex = "0.4" parity-scale-codec = { version = "3", features = ["derive"] } +scale-info = { version = "2.11", default-features = false, features = ["decode"] } +sp-crypto-hashing = "0.1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } rustls = { version = "0.23", default-features = false, features = ["ring"] } serde_json = "1" @@ -38,3 +41,7 @@ tokio-tungstenite = { version = "0.24", features = ["connect", "rustls-tls-webpk # that connection mid-run. See src/chain.rs. tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +# Ring-VRF prover for on-chain statement-store allowance registration +# (`set_statement_store_account`). `std` already implies `prover`, but list it +# explicitly since we call the prover-gated `open`/`create`. Native only. +verifiable = { git = "https://github.com/paritytech/verifiable", rev = "f65b39df04f2f9a453d78550438b189c96785285", default-features = false, features = ["std", "prover"] } diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index add81aa9..da8786fc 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -2,38 +2,52 @@ Headless TrUAPI hosts for local end-to-end testing, built on `truapi-server`. They replace the external signing-bot service: two CLI processes take the two -host-spec §B roles and pair over a local statement-store, so the playground's -own tests can run against a real signer with no Novasama-operated dependency. +host-spec §B roles and pair over the **real People-chain statement store** (the +same node an iOS/web client uses), so the playground's own tests can run against +a real signer with no Novasama-operated dependency. -One binary, `truapi-host`, with three roles: +One binary, `truapi-host`, with these roles: | Command | Role | | --- | --- | -| `relay` | In-memory statement-store the two hosts pair over (dev test double). | | `pairing-host` | Seedless host: presents a pairing deeplink, serves product frames over WebSocket. | -| `signing-host` | Wallet-local host: answers a pairing deeplink, auto-signs (the signing-bot replacement). | - -The signing host reuses the `truapi-server` signing-host runtime and its new -SSO responder (`runtime/signing_host/sso_responder.rs`); the pairing host is the -existing `PairingHostRuntime`. Both talk to the relay over a native WebSocket -`JsonRpcConnection`, and the pairing host bridges product byte-frames to the -runtime over a second WebSocket (one binary message per SCALE `ProtocolMessage`, -matching the browser transport). +| `signing-host` | Wallet-local host: answers a pairing deeplink, registers statement allowance on-chain, auto-signs (the signing-bot replacement). | +| `identity-check` | Probe which derivation of a mnemonic carries a registered username. | +| `alloc-check` | Diagnose (or `--submit`) on-chain statement-store allowance: ring membership, chosen slot, and the `set_statement_store_account` extrinsic. | + +The signing host reuses the `truapi-server` signing-host runtime and its SSO +responder (`runtime/signing_host/sso_responder.rs`); the pairing host is the +existing `PairingHostRuntime`. Both connect to the People-chain statement store +over a native WebSocket `JsonRpcConnection`, and the pairing host bridges product +byte-frames to the runtime over a second WebSocket (one binary message per SCALE +`ProtocolMessage`, matching the browser transport). + +## Statement-store allowance + +The real statement store enforces per-account allowance. Before pairing, the +signing host grants it on-chain exactly as a real client does: it proves its +LitePeople ring membership with a bandersnatch ring-VRF and submits an unsigned +General (v5) `Resources.set_statement_store_account` extrinsic for each account +that submits statements — its own `//wallet//sso` account and the pairing host's +per-pairing device key. The port lives in `src/alloc/` (metadata-driven +signed-extension encoding, ring fetch, slot scan, ring-VRF proof, extrinsic +assembly, submit). The signing account must be an attested LitePeople member; +`alloc-check` verifies this and can submit a test registration. ## End-to-end test -`e2e/run-e2e.ts` boots the relay, a pairing host, and (once login begins) a -signing host, then drives the real `@parity/truapi` client against the pairing -host. Two modes: +`e2e/run-e2e.ts` boots a pairing host, and (once login begins) a signing host +that registers allowance on-chain, then drives the real `@parity/truapi` client +against the pairing host over the real statement store. Two modes: - default: a curated battery of signer-backed methods (login, get account, raw and payload signing, transaction construction, entropy) — a deterministic gate, 7/7. - `E2E_DIAGNOSIS=1`: runs the playground's own generated example sources through the playground's `runExample`, i.e. literally the playground diagnosis. Gated - on the signer-critical methods; chain-node methods and deferred features - (ring-VRF alias, identity, live-chain transaction assembly) are reported but - not gated, since the hermetic relay is a statement store, not a full node. + on the signer-critical methods; Asset Hub `Chain/*` methods and deferred + features are reported but not gated unless `E2E_LIVE_CHAIN=1` routes them to a + real node. ```bash # One-time JS setup (generated client + built package + playground deps): @@ -52,11 +66,16 @@ E2E_DIAGNOSIS=1 bash rust/crates/truapi-host-cli/e2e/run.sh # full diagnosis cargo build -p truapi-host-cli BIN=target/debug/truapi-host -$BIN relay --listen 127.0.0.1:9944 & -$BIN pairing-host --relay ws://127.0.0.1:9944 --frame-listen 127.0.0.1:9955 & +# Both hosts default to the real People-chain statement store +# (wss://paseo-people-next-system-rpc.polkadot.io); override with --statement-store. +$BIN pairing-host --frame-listen 127.0.0.1:9955 & # A product connects to ws://127.0.0.1:9955 and calls account.requestLogin; -# the pairing host prints `PAIRING_DEEPLINK `. Hand it to the signer: -$BIN signing-host --relay ws://127.0.0.1:9944 --deeplink '' +# the pairing host prints `PAIRING_DEEPLINK `. Hand it to the signer, +# which registers on-chain allowance then answers the handshake: +$BIN signing-host --deeplink '' + +# Inspect on-chain statement-store allowance for a mnemonic: +$BIN alloc-check --lookback 100 # ring membership + free slot (read-only) ``` Signing is auto-approved via the platform's `UserConfirmation`; pass @@ -93,15 +112,19 @@ environmental, not a host defect: - **Ring-VRF product-account aliases** are implemented natively via the `verifiable` crate (`get_account_alias`); on wasm they remain `Unavailable`. - **`get_user_id`** resolves the signing account's username from People-chain - `Resources.Consumers`, so the pairing host needs `--resolve-identity` (which - points identity lookups at the real People chain while SSO stays on the - relay). The signing host presents its `//wallet//sso` account as its - statement identity. `truapi-host signing-host --username ` registers a - fresh lite username via the identity backend (`src/attestation.rs`); first - registration is backend-async and can take minutes (ring onboarding), so the - e2e uses an account that is already registered. `truapi-host identity-check - --mnemonic ` probes which derivation carries a username. -- **On-chain resource allocation** returns `Unavailable` on the signing host. + `Resources.Consumers`. Since SSO and identity both run over the real People + chain, the username always resolves; the signing host presents its + `//wallet//sso` account as its statement identity. `truapi-host signing-host + --username ` registers a fresh lite username via the identity backend + (`src/attestation.rs`); first registration is backend-async and can take + minutes (ring onboarding), so the e2e uses an account that is already + registered. `truapi-host identity-check --mnemonic ` probes which + derivation carries a username. +- **Statement-store allowance** is registered on-chain before pairing (see + above). The signing account must be an attested LitePeople ring member; it may + sit in an old ring, so the signing host scans back from the current ring index + to find it (slow, but one-time per pairing). `set_statement_store_account` + resource-allocation over SSO is still reported `NotAvailable`. - Everything else the browser host exercises passes: signing (raw, payload, create-transaction, and their legacy variants), statement store, entropy, aliases, preimage, storage, permissions, notifications, theme, system, chain diff --git a/rust/crates/truapi-host-cli/e2e/run-e2e.ts b/rust/crates/truapi-host-cli/e2e/run-e2e.ts index 8ef406d5..aaeb4acf 100644 --- a/rust/crates/truapi-host-cli/e2e/run-e2e.ts +++ b/rust/crates/truapi-host-cli/e2e/run-e2e.ts @@ -1,9 +1,10 @@ -// End-to-end orchestrator: relay + pairing host + signing host + product driver. +// End-to-end orchestrator: pairing host + signing host + product driver. // -// Boots the dev statement-store relay, a headless pairing host (frame server), -// connects the real @parity/truapi client, starts login, hands the pairing -// deeplink to a headless signing host, and once paired runs the signing -// battery. Prints a pass/fail summary and exits non-zero on any failure. +// Boots a headless pairing host (frame server) pointed at the real People-chain +// statement store, connects the real @parity/truapi client, starts login, hands +// the pairing deeplink to a headless signing host (which registers statement +// allowance on-chain), and once paired runs the signing battery. Prints a +// pass/fail summary and exits non-zero on any failure. import { resolve } from "node:path"; import { readFileSync } from "node:fs"; import { beginLogin, connect, runBattery, type CaseResult } from "./driver.ts"; @@ -130,20 +131,12 @@ async function main() { const cleanup = () => processes.forEach((p) => p.kill()); try { - const relay = new HostProcess("relay", ["relay", "--listen", "127.0.0.1:0"]); - processes.push(relay); - const relayUrl = wsUrlFrom(await relay.waitFor("RELAY_LISTENING "), "RELAY_LISTENING "); - - // With live chain on, resolve usernames from the real People chain so - // get_user_id works (SSO still runs over the relay). - const liveChain = process.env.E2E_LIVE_CHAIN === "1"; + // The hosts pair over the real People-chain statement store (the CLI + // default), so no local relay is started. const pairing = new HostProcess("pairing", [ "pairing-host", - "--relay", - relayUrl, "--frame-listen", "127.0.0.1:0", - ...(liveChain ? ["--resolve-identity"] : []), ]); processes.push(pairing); const frameUrl = wsUrlFrom(await pairing.waitFor("FRAMES_LISTENING "), "FRAMES_LISTENING "); @@ -161,28 +154,26 @@ async function main() { "PAIRING_DEEPLINK ", ); - // External-signer mode: hand the relay URL + deeplink to a separate - // signing-host process (e.g. a second tmux pane) via a file, instead of - // spawning the signer here. Lets the two hosts run as visibly separate - // processes. + // External-signer mode: hand the deeplink to a separate signing-host + // process (e.g. a second tmux pane) via a file, instead of spawning the + // signer here. Lets the two hosts run as visibly separate processes. const handoffFile = process.env.E2E_HANDOFF_FILE; if (handoffFile) { - // Two lines: relay URL, then deeplink — trivially read by a shell script. - await Bun.write(handoffFile, `${relayUrl}\n${deeplink}\n`); - console.error(`wrote relay + deeplink handoff to ${handoffFile}; waiting for external signer`); + await Bun.write(handoffFile, `${deeplink}\n`); + console.error(`wrote deeplink handoff to ${handoffFile}; waiting for external signer`); } else { console.error(`launching signing host for deeplink ${deeplink.slice(0, 48)}...`); const mnemonic = process.env.E2E_SIGNER_MNEMONIC; const signing = new HostProcess("signing", [ "signing-host", - "--relay", - relayUrl, "--deeplink", deeplink, ...(mnemonic ? ["--mnemonic", mnemonic] : []), ]); processes.push(signing); - await signing.waitFor("SIGNING_HOST_READY"); + // The signing host registers on-chain statement allowance (ring scan + + // two extrinsics) before it is ready, which can take a couple of minutes. + await signing.waitFor("SIGNING_HOST_READY", 240_000); } const login = await loginPromise; @@ -205,9 +196,9 @@ async function main() { console.log(`${pass} passed, ${fail} failed, ${skip} skipped (of ${rows.length})`); cleanup(); // Gate on the signer path: chain-node methods and deferred features - // (ring-VRF alias, identity, live-chain transaction assembly) fail - // against the hermetic statement-store relay; those are environmental, - // not signing regressions. + // (ring-VRF alias, live-chain transaction assembly) may fail when + // Asset Hub routing is off; those are environmental, not signing + // regressions. const critical = rows.filter((r) => MUST_PASS.has(r.id) && r.status === "fail"); if (critical.length > 0) { console.log(`\nGATE FAILED: ${critical.map((r) => r.id).join(", ")}`); diff --git a/rust/crates/truapi-host-cli/src/alloc.rs b/rust/crates/truapi-host-cli/src/alloc.rs new file mode 100644 index 00000000..7a68170e --- /dev/null +++ b/rust/crates/truapi-host-cli/src/alloc.rs @@ -0,0 +1,177 @@ +//! On-chain statement-store allowance registration (`set_statement_store_account`). +//! +//! Mirrors how an iOS/web client obtains statement-store allowance from the real +//! People chain: build the `Resources.set_statement_store_account` call, prove +//! LitePeople ring membership with a bandersnatch ring-VRF, and submit the +//! resulting unsigned General (v5) extrinsic. Native only (needs the +//! `verifiable` prover and live chain reads). + +pub mod dynamic; +pub mod extension; +pub mod extrinsic; +pub mod proof; +pub mod ring; +pub mod rpc; +pub mod slot; + +use blake2_rfc::blake2b::blake2b; +use parity_scale_codec::Decode; +use serde_json::{Value, json}; + +use extension::{ChainState, Metadata}; +use ring::RingParams; +use rpc::RpcClient; +use slot::SlotSelection; + +/// Bandersnatch entropy for a bip39 entropy: `blake2b256(bip39_entropy)`. +pub fn bandersnatch_entropy(bip39_entropy: &[u8]) -> [u8; 32] { + blake2b(32, &[], bip39_entropy) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +/// Fetch and decode the runtime metadata (`state_getMetadata`). +pub async fn fetch_metadata(rpc: &RpcClient) -> Result { + let value = rpc + .call("state_getMetadata", json!([])) + .await + .map_err(|e| e.to_string())?; + let hex_str = value + .as_str() + .ok_or_else(|| "state_getMetadata returned non-string".to_string())?; + let bytes = hex::decode(hex_str.strip_prefix("0x").unwrap_or(hex_str)) + .map_err(|e| format!("metadata hex: {e}"))?; + // `state_getMetadata` may return either the raw `RuntimeMetadataPrefixed` + // (starts with the `meta` magic) or an OpaqueMetadata wrapper + // (`Vec` = compact(len) ‖ bytes). Strip the wrapper only when present. + const META_MAGIC: [u8; 4] = *b"meta"; + if bytes.get(..4) == Some(&META_MAGIC) { + Metadata::decode(&bytes) + } else { + let inner = + Vec::::decode(&mut &bytes[..]).map_err(|e| format!("opaque metadata: {e}"))?; + Metadata::decode(&inner) + } +} + +/// Fetch the chain state needed to fill the signed extensions. +pub async fn fetch_chain_state(rpc: &RpcClient) -> Result { + let genesis_hex = rpc + .call("chain_getBlockHash", json!([0])) + .await + .map_err(|e| e.to_string())?; + let genesis_str = genesis_hex + .as_str() + .ok_or_else(|| "chain_getBlockHash returned non-string".to_string())?; + let genesis = hex::decode(genesis_str.strip_prefix("0x").unwrap_or(genesis_str)) + .map_err(|e| format!("genesis hex: {e}"))?; + let genesis_hash: [u8; 32] = genesis + .try_into() + .map_err(|_| "genesis hash is not 32 bytes".to_string())?; + + let runtime = rpc + .call("state_getRuntimeVersion", json!([])) + .await + .map_err(|e| e.to_string())?; + let spec_version = json_u32(&runtime, "specVersion")?; + let transaction_version = json_u32(&runtime, "transactionVersion")?; + + Ok(ChainState { + spec_version, + transaction_version, + genesis_hash, + nonce: 0, + }) +} + +/// Read a u32 field from a JSON object. +fn json_u32(value: &Value, field: &str) -> Result { + value + .get(field) + .and_then(Value::as_u64) + .and_then(|v| u32::try_from(v).ok()) + .ok_or_else(|| format!("missing/invalid {field}")) +} + +/// Result of a statement-store allowance registration attempt. +pub enum RegistrationOutcome { + /// The extrinsic reached a block; the target now holds slot `seq`. + Registered { + /// Block hash the extrinsic landed in. + block_hash: String, + /// Claimed slot sequence. + seq: u32, + /// Ring index the proof was built against. + ring_index: u32, + }, + /// The target already held a slot this period; nothing submitted. + AlreadyAllocated { + /// Existing slot sequence. + seq: u32, + }, +} + +/// Find the newest ring (scanning up to `lookback` back from the current index) +/// that includes our member key. Reads the ring exponent once and stops at the +/// first match. +pub async fn find_including_ring( + rpc: &RpcClient, + metadata: &Metadata, + entropy: [u8; 32], + lookback: u32, +) -> Result, String> { + let member = proof::member_key(entropy); + let exponent = ring::read_ring_exponent(rpc, metadata).await?; + let current = ring::read_current_ring_index(rpc).await?; + let oldest = current.saturating_sub(lookback); + for ring_index in (oldest..=current).rev() { + let members = ring::read_ring_members_at(rpc, ring_index).await?; + if members.contains(&member) { + return Ok(Some(RingParams { + members, + exponent, + ring_index, + })); + } + } + Ok(None) +} + +/// Register statement-store allowance for `target`, proving membership in the +/// already-located `ring`, at UTC-day `period`. +pub async fn register_statement_account( + rpc: &RpcClient, + metadata: &Metadata, + chain_state: &ChainState, + entropy: [u8; 32], + target: &[u8; 32], + period: u32, + ring: &RingParams, +) -> Result { + let seq = match slot::scan_slot(rpc, metadata, entropy, period, target).await? { + SlotSelection::AlreadyAllocated(seq) => { + return Ok(RegistrationOutcome::AlreadyAllocated { seq }); + } + SlotSelection::Free(seq) => seq, + }; + + let context = slot::derive_slot_context(period, seq); + let call = extrinsic::build_set_statement_store_account_call(period, seq, target); + let message = extension::build_proof_message(metadata, &call, chain_state)?; + let domain = proof::domain_for_ring_exponent(ring.exponent)?; + let ring_proof = proof::ring_vrf_proof(domain, entropy, &ring.members, &context, &message)?; + let as_resources_extra = extrinsic::build_as_resources_extra(&ring_proof, ring.ring_index); + let extrinsic = + extrinsic::build_unsigned_extrinsic(metadata, chain_state, &call, &as_resources_extra)?; + + let block_hash = rpc + .submit_and_watch(&extrinsic) + .await + .map_err(|e| e.to_string())?; + Ok(RegistrationOutcome::Registered { + block_hash, + seq, + ring_index: ring.ring_index, + }) +} diff --git a/rust/crates/truapi-host-cli/src/alloc/dynamic.rs b/rust/crates/truapi-host-cli/src/alloc/dynamic.rs new file mode 100644 index 00000000..0fe11728 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/alloc/dynamic.rs @@ -0,0 +1,132 @@ +//! Minimal metadata-driven SCALE walker. +//! +//! Just enough to read one field out of a storage struct without a full dynamic +//! codec: `skip` advances a cursor past one value of a given type, and +//! `read_field_variant_name` walks a composite to a named field and returns its +//! enum variant name (used for `CollectionInfo.ring_size` -> `R2e9`/`R2e10`/`R2e14`). + +use parity_scale_codec::{Compact, Decode}; +use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive}; + +/// Advance `input` past exactly one SCALE-encoded value of `type_id`. +pub fn skip(registry: &PortableRegistry, type_id: u32, input: &mut &[u8]) -> Result<(), String> { + let ty = registry + .resolve(type_id) + .ok_or_else(|| format!("unknown type id {type_id}"))?; + match &ty.type_def { + TypeDef::Composite(c) => { + for field in &c.fields { + skip(registry, field.ty.id, input)?; + } + } + TypeDef::Tuple(t) => { + for field in &t.fields { + skip(registry, field.id, input)?; + } + } + TypeDef::Array(a) => { + for _ in 0..a.len { + skip(registry, a.type_param.id, input)?; + } + } + TypeDef::Sequence(s) => { + let len = read_compact(input)?; + for _ in 0..len { + skip(registry, s.type_param.id, input)?; + } + } + TypeDef::Variant(v) => { + let index = read_u8(input)?; + let variant = v + .variants + .iter() + .find(|var| var.index == index) + .ok_or_else(|| format!("unknown variant index {index}"))?; + for field in &variant.fields { + skip(registry, field.ty.id, input)?; + } + } + TypeDef::Compact(_) => { + read_compact(input)?; + } + TypeDef::BitSequence(_) => { + let bits = read_compact(input)?; + advance(input, bits.div_ceil(8))?; + } + TypeDef::Primitive(p) => { + let len = match p { + TypeDefPrimitive::Bool | TypeDefPrimitive::U8 | TypeDefPrimitive::I8 => 1, + TypeDefPrimitive::U16 | TypeDefPrimitive::I16 => 2, + TypeDefPrimitive::Char | TypeDefPrimitive::U32 | TypeDefPrimitive::I32 => 4, + TypeDefPrimitive::U64 | TypeDefPrimitive::I64 => 8, + TypeDefPrimitive::U128 | TypeDefPrimitive::I128 => 16, + TypeDefPrimitive::U256 | TypeDefPrimitive::I256 => 32, + // Length-prefixed UTF-8: compact byte length then the bytes. + TypeDefPrimitive::Str => read_compact(input)?, + }; + advance(input, len)?; + } + } + Ok(()) +} + +/// Walk composite `struct_type_id` to `field_name` and return the enum variant +/// name selected there (the field must be a fieldless/simple enum). +pub fn read_field_variant_name( + registry: &PortableRegistry, + struct_type_id: u32, + field_name: &str, + bytes: &[u8], +) -> Result { + let ty = registry + .resolve(struct_type_id) + .ok_or_else(|| format!("unknown type id {struct_type_id}"))?; + let TypeDef::Composite(composite) = &ty.type_def else { + return Err(format!("type {struct_type_id} is not a composite")); + }; + + let mut input = bytes; + for field in &composite.fields { + if field.name.as_deref() == Some(field_name) { + let field_ty = registry + .resolve(field.ty.id) + .ok_or_else(|| format!("unknown field type id {}", field.ty.id))?; + let TypeDef::Variant(variant) = &field_ty.type_def else { + return Err(format!("field `{field_name}` is not an enum")); + }; + let index = read_u8(&mut input)?; + return variant + .variants + .iter() + .find(|var| var.index == index) + .map(|var| var.name.clone()) + .ok_or_else(|| format!("unknown variant index {index} for `{field_name}`")); + } + skip(registry, field.ty.id, &mut input)?; + } + Err(format!("field `{field_name}` not found")) +} + +/// Decode a SCALE compact-encoded length, advancing `input`. +fn read_compact(input: &mut &[u8]) -> Result { + let Compact(value) = Compact::::decode(input).map_err(|err| format!("compact: {err}"))?; + usize::try_from(value).map_err(|_| "compact length overflow".to_string()) +} + +/// Read one byte, advancing `input`. +fn read_u8(input: &mut &[u8]) -> Result { + let (&first, rest) = input + .split_first() + .ok_or_else(|| "unexpected end".to_string())?; + *input = rest; + Ok(first) +} + +/// Advance `input` by `n` bytes. +fn advance(input: &mut &[u8], n: usize) -> Result<(), String> { + if input.len() < n { + return Err(format!("need {n} bytes, have {}", input.len())); + } + *input = &input[n..]; + Ok(()) +} diff --git a/rust/crates/truapi-host-cli/src/alloc/extension.rs b/rust/crates/truapi-host-cli/src/alloc/extension.rs new file mode 100644 index 00000000..c2e925b1 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/alloc/extension.rs @@ -0,0 +1,370 @@ +//! Signed-extension encoding for the unsigned General (v5) `AsResources` +//! extrinsic, driven by live chain metadata. +//! +//! The extension **order** and per-extension type ids come from the runtime +//! metadata (`state_getMetadata`, V14/V15); the per-extension `extra` / +//! `additional_signed` bytes come from a name-keyed encoder mirroring +//! signing-bot `src/core/create-transaction.ts` `encodeSignedExtensions`, with a +//! generic default for the personhood extensions (all `Option`/void). +//! +//! Two concatenations are derived from the same encoded list: +//! - the ring-VRF proof message (`build_proof_message`) over the extensions +//! strictly *after* `AsResources` (host-spec inherited implication), and +//! - the full extrinsic body's `Σ extra` (see `extrinsic.rs`), over *all* +//! extensions with `AsResources` carrying `Some(AsResourcesInfo)`. + +use std::collections::HashMap; + +use blake2_rfc::blake2b::blake2b; +use frame_metadata::RuntimeMetadata; +use frame_metadata::RuntimeMetadataPrefixed; +use parity_scale_codec::{Compact, Decode, Encode}; +use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive}; + +/// Signed-extension identifier that carries the `AsResources` authorization. +pub const AS_RESOURCES: &str = "AsResources"; + +/// Chain state needed to fill the standard signed extensions. +#[derive(Debug, Clone, Copy)] +pub struct ChainState { + /// Runtime `specVersion` (CheckSpecVersion implicit). + pub spec_version: u32, + /// Runtime `transactionVersion` (CheckTxVersion implicit). + pub transaction_version: u32, + /// Genesis block hash (CheckGenesis / CheckMortality implicit). + pub genesis_hash: [u8; 32], + /// Account nonce (CheckNonce extra); ignored by the unsigned path. + pub nonce: u32, +} + +/// A signed extension's identifier plus the type ids of its `extra` and +/// `additional_signed` fields, in metadata order. +struct ExtensionDef { + identifier: String, + extra_type: u32, + additional_signed_type: u32, +} + +/// A signed extension encoded to its `extra` and `additional_signed` bytes. +pub struct EncodedExtension { + /// SCALE-encoded `extra` (goes into the extrinsic body). + pub extra: Vec, + /// SCALE-encoded `additional_signed` (the implicit, part of the signed data). + pub additional_signed: Vec, +} + +/// Decoded metadata: the ordered signed-extension defs, the type registry, and +/// each storage entry's value type id (`(pallet, entry) -> type id`). +pub struct Metadata { + extensions: Vec, + registry: PortableRegistry, + storage_values: HashMap<(String, String), u32>, + constants: HashMap<(String, String), Vec>, +} + +/// Collect extensions, type registry, storage value types, and pallet constants +/// from a decoded V14/V15 metadata; `$set` is the version's `StorageEntryType`. +macro_rules! collect_metadata { + ($m:expr, $set:path) => {{ + let extensions = $m + .extrinsic + .signed_extensions + .iter() + .map(|e| ExtensionDef { + identifier: e.identifier.clone(), + extra_type: e.ty.id, + additional_signed_type: e.additional_signed.id, + }) + .collect(); + let mut storage_values = HashMap::new(); + let mut constants = HashMap::new(); + for pallet in &$m.pallets { + for constant in &pallet.constants { + constants.insert( + (pallet.name.clone(), constant.name.clone()), + constant.value.clone(), + ); + } + let Some(storage) = &pallet.storage else { + continue; + }; + for entry in &storage.entries { + use $set as EntryType; + let value_type = match &entry.ty { + EntryType::Plain(ty) => ty.id, + EntryType::Map { value, .. } => value.id, + }; + storage_values.insert((pallet.name.clone(), entry.name.clone()), value_type); + } + } + (extensions, $m.types, storage_values, constants) + }}; +} + +impl Metadata { + /// Decode `state_getMetadata` bytes (a `RuntimeMetadataPrefixed`, V14 or + /// V15) into the ordered signed-extension defs, type registry, and storage + /// value types. + pub fn decode(bytes: &[u8]) -> Result { + let prefixed = RuntimeMetadataPrefixed::decode(&mut &bytes[..]) + .map_err(|err| format!("metadata decode failed: {err}"))?; + let (extensions, registry, storage_values, constants) = match prefixed.1 { + RuntimeMetadata::V14(m) => collect_metadata!(m, frame_metadata::v14::StorageEntryType), + RuntimeMetadata::V15(m) => collect_metadata!(m, frame_metadata::v15::StorageEntryType), + other => return Err(format!("unsupported metadata version {}", other.version())), + }; + Ok(Self { + extensions, + registry, + storage_values, + constants, + }) + } + + /// The type registry, for dynamic decoding of storage values. + pub fn registry(&self) -> &PortableRegistry { + &self.registry + } + + /// The value type id of storage entry `pallet::entry`, if present. + pub fn storage_value_type(&self, pallet: &str, entry: &str) -> Option { + self.storage_values + .get(&(pallet.to_string(), entry.to_string())) + .copied() + } + + /// The SCALE-encoded value bytes of pallet constant `pallet::name`. + pub fn constant(&self, pallet: &str, name: &str) -> Option<&[u8]> { + self.constants + .get(&(pallet.to_string(), name.to_string())) + .map(Vec::as_slice) + } + + /// Encode every signed extension in metadata order. + pub fn encode_signed_extensions(&self, state: &ChainState) -> Vec { + self.extensions + .iter() + .map(|ext| { + let (extra, additional_signed) = self.encode_one(ext, state); + EncodedExtension { + extra, + additional_signed, + } + }) + .collect() + } + + /// The signed-extension identifiers, in metadata order. + #[cfg(test)] + pub fn extension_ids(&self) -> Vec<&str> { + self.extensions + .iter() + .map(|e| e.identifier.as_str()) + .collect() + } + + /// Encode a single extension's `(extra, additional_signed)`, mirroring the + /// signing-bot switch; unknown personhood extensions fall back to the + /// metadata type default (`Option` -> None, void -> empty). + fn encode_one(&self, ext: &ExtensionDef, state: &ChainState) -> (Vec, Vec) { + match ext.identifier.as_str() { + "CheckNonce" => (Compact(state.nonce).encode(), Vec::new()), + "CheckSpecVersion" => (Vec::new(), state.spec_version.to_le_bytes().to_vec()), + "CheckTxVersion" => (Vec::new(), state.transaction_version.to_le_bytes().to_vec()), + "CheckGenesis" => (Vec::new(), state.genesis_hash.to_vec()), + // extra = Era::Immortal (0x00); implicit = genesis hash. + "CheckMortality" => (vec![0x00], state.genesis_hash.to_vec()), + // extra = first variant `Disabled` (void) = 0x00. + "VerifyMultiSignature" => (vec![0x00], Vec::new()), + // extra = { tip: compact(0), asset_id: None } = 0x00 0x00. + "ChargeAssetTxPayment" => (vec![0x00, 0x00], Vec::new()), + // extra = bool false = 0x00. + "RestrictOrigins" => (vec![0x00], Vec::new()), + _ => ( + self.encode_default(ext.extra_type), + self.encode_default(ext.additional_signed_type), + ), + } + } + + /// Encode the "disabled" default value for a metadata type: `Option` -> None + /// (`0x00`), void/empty tuple -> empty, enums -> first variant, primitives + /// -> zero. Matches signing-bot `defaultValueForType`. + fn encode_default(&self, type_id: u32) -> Vec { + let Some(ty) = self.registry.resolve(type_id) else { + return Vec::new(); + }; + match &ty.type_def { + TypeDef::Composite(c) => c + .fields + .iter() + .flat_map(|f| self.encode_default(f.ty.id)) + .collect(), + TypeDef::Tuple(t) => t + .fields + .iter() + .flat_map(|f| self.encode_default(f.id)) + .collect(), + TypeDef::Variant(v) => { + // Option encodes None as 0x00. + if ty.path.segments.last().map(String::as_str) == Some("Option") { + return vec![0x00]; + } + match v.variants.iter().min_by_key(|var| var.index) { + None => Vec::new(), + Some(first) => { + let mut out = vec![first.index]; + for field in &first.fields { + out.extend(self.encode_default(field.ty.id)); + } + out + } + } + } + TypeDef::Array(a) => { + let elem = self.encode_default(a.type_param.id); + elem.repeat(a.len as usize) + } + // Sequences / strings / bit-sequences encode an empty run as compact(0). + TypeDef::Sequence(_) | TypeDef::BitSequence(_) => vec![0x00], + TypeDef::Compact(_) => vec![0x00], + TypeDef::Primitive(p) => match p { + TypeDefPrimitive::Bool | TypeDefPrimitive::U8 | TypeDefPrimitive::I8 => vec![0], + TypeDefPrimitive::Char | TypeDefPrimitive::U32 | TypeDefPrimitive::I32 => { + vec![0; 4] + } + TypeDefPrimitive::U16 | TypeDefPrimitive::I16 => vec![0; 2], + TypeDefPrimitive::U64 | TypeDefPrimitive::I64 => vec![0; 8], + TypeDefPrimitive::U128 | TypeDefPrimitive::I128 => vec![0; 16], + TypeDefPrimitive::U256 | TypeDefPrimitive::I256 => vec![0; 32], + // Length-prefixed string: empty = compact(0). + TypeDefPrimitive::Str => vec![0x00], + }, + } + } + + /// Index of `AsResources` in the extension list, if present. + pub fn as_resources_index(&self) -> Option { + self.extensions + .iter() + .position(|e| e.identifier == AS_RESOURCES) + } +} + +/// Build the ring-VRF proof message for an `AsResources`-authorized call: +/// `blake2b256(0x00 ‖ call ‖ Σ tail.extra ‖ Σ tail.additional_signed)`, where +/// the tail is the extensions ordered strictly after `AsResources`. The leading +/// `0x00` is the General-transaction extension-version byte. +pub fn build_proof_message( + metadata: &Metadata, + call_data: &[u8], + state: &ChainState, +) -> Result<[u8; 32], String> { + let all = metadata.encode_signed_extensions(state); + let tail_start = metadata + .as_resources_index() + .map(|i| i + 1) + .ok_or_else(|| format!("{AS_RESOURCES} extension not found in metadata"))?; + let tail = &all[tail_start..]; + + let mut payload = Vec::with_capacity(1 + call_data.len()); + payload.push(0x00); + payload.extend_from_slice(call_data); + for ext in tail { + payload.extend_from_slice(&ext.extra); + } + for ext in tail { + payload.extend_from_slice(&ext.additional_signed); + } + Ok(blake2b256(&payload)) +} + +/// BLAKE2b-256 of `message`. +pub fn blake2b256(message: &[u8]) -> [u8; 32] { + blake2b(32, &[], message) + .as_bytes() + .try_into() + .expect("BLAKE2b-256 returns 32 bytes") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Fixture metadata captured from paseo-next-v2 (raw `RuntimeMetadataPrefixed`). + const FIXTURE: &[u8] = include_bytes!("../../tests/fixtures/paseo-next-v2-metadata.scale"); + + /// The known-answer chain state frozen alongside the fixture. + fn fixture_state() -> ChainState { + ChainState { + spec_version: 1_000_000, + transaction_version: 1, + genesis_hash: [0xab; 32], + nonce: 0, + } + } + + /// `Resources.set_statement_store_account(period=7, seq=0, target=0)`. + fn fixture_call() -> Vec { + let mut call = vec![0x3f, 0x0a]; + call.extend_from_slice(&7u32.to_le_bytes()); + call.extend_from_slice(&0u32.to_le_bytes()); + call.extend_from_slice(&[0u8; 32]); + call + } + + #[test] + fn proof_message_matches_frozen_known_answer() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let msg = build_proof_message(&metadata, &fixture_call(), &fixture_state()).unwrap(); + assert_eq!( + hex::encode(msg), + "1d2e6d8d8f421b0857097c6076115507432d66fea47ebe0c3be282a369f6743c", + ); + } + + #[test] + fn as_resources_tail_is_indices_10_through_20() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let idx = metadata.as_resources_index().unwrap(); + // AsResources sits at index 9; the proof tail is everything after it. + assert_eq!(idx, 9); + let ids = metadata.extension_ids(); + assert_eq!( + ids[idx + 1..].to_vec(), + vec![ + "AuthorizeCall", + "RestrictOrigins", + "CheckNonZeroSender", + "CheckSpecVersion", + "CheckTxVersion", + "CheckGenesis", + "CheckMortality", + "CheckNonce", + "CheckWeight", + "ChargeAssetTxPayment", + "StorageWeightReclaim", + ], + ); + } + + #[test] + fn dropping_the_version_byte_changes_the_hash() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let state = fixture_state(); + let call = fixture_call(); + let all = metadata.encode_signed_extensions(&state); + let tail = &all[metadata.as_resources_index().unwrap() + 1..]; + let mut without = call.clone(); + for e in tail { + without.extend_from_slice(&e.extra); + } + for e in tail { + without.extend_from_slice(&e.additional_signed); + } + assert_ne!( + build_proof_message(&metadata, &call, &state).unwrap(), + blake2b256(&without), + ); + } +} diff --git a/rust/crates/truapi-host-cli/src/alloc/extrinsic.rs b/rust/crates/truapi-host-cli/src/alloc/extrinsic.rs new file mode 100644 index 00000000..8d0dd928 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/alloc/extrinsic.rs @@ -0,0 +1,142 @@ +//! `Resources.set_statement_store_account` call + unsigned General (v5) +//! extrinsic assembly. Mirrors signing-bot `allocation.ts` / `extrinsic-submit.ts`. + +use parity_scale_codec::{Compact, Encode}; + +use super::extension::{ChainState, Metadata}; + +/// Pallet + call index for `Resources.set_statement_store_account` (63 / 10). +pub const SET_STATEMENT_STORE_ACCOUNT_CALL: [u8; 2] = [0x3f, 0x0a]; +/// `AsResourcesInfo::RegisterStatementStoreAllowance` variant index. +const REGISTER_STATEMENT_STORE_ALLOWANCE: u8 = 0x02; +/// `MembershipCollection::LitePeople` variant index. +const MEMBERSHIP_COLLECTION_LITE_PEOPLE: u8 = 0x01; +/// General-transaction preamble byte: `0b01` (General) | version 5. +const GENERAL_V5_PREAMBLE: u8 = 0x45; +/// Current signed-extension version byte. +const EXTENSION_VERSION: u8 = 0x00; +/// `Option::Some` discriminant for the `AsResources` extension `extra`. +const OPTION_SOME: u8 = 0x01; + +/// Encode `Resources.set_statement_store_account(period, seq, target)`: +/// `3f 0a ‖ period_u32LE ‖ seq_u32LE ‖ target[32]`. +pub fn build_set_statement_store_account_call(period: u32, seq: u32, target: &[u8; 32]) -> Vec { + let mut call = Vec::with_capacity(2 + 4 + 4 + 32); + call.extend_from_slice(&SET_STATEMENT_STORE_ACCOUNT_CALL); + call.extend_from_slice(&period.to_le_bytes()); + call.extend_from_slice(&seq.to_le_bytes()); + call.extend_from_slice(target); + call +} + +/// Encode the `AsResources` extension `extra` for a statement-store allowance: +/// `Some(RegisterStatementStoreAllowance { proof, ring_index, LitePeople })`. +pub fn build_as_resources_extra(proof: &[u8], ring_index: u32) -> Vec { + let mut extra = Vec::with_capacity(2 + 2 + proof.len() + 4 + 1); + extra.push(OPTION_SOME); + extra.push(REGISTER_STATEMENT_STORE_ALLOWANCE); + extra.extend_from_slice(&Compact(proof.len() as u32).encode()); + extra.extend_from_slice(proof); + extra.extend_from_slice(&ring_index.to_le_bytes()); + extra.push(MEMBERSHIP_COLLECTION_LITE_PEOPLE); + extra +} + +/// Assemble the unsigned General (v5) extrinsic: +/// `compact(len) ‖ 0x45 ‖ 0x00 ‖ Σ(all extra, AsResources = Some(info)) ‖ call`. +pub fn build_unsigned_extrinsic( + metadata: &Metadata, + state: &ChainState, + call_data: &[u8], + as_resources_extra: &[u8], +) -> Result, String> { + let all = metadata.encode_signed_extensions(state); + let as_resources_index = metadata + .as_resources_index() + .ok_or_else(|| "AsResources extension not found in metadata".to_string())?; + + let mut body = vec![GENERAL_V5_PREAMBLE, EXTENSION_VERSION]; + for (i, ext) in all.iter().enumerate() { + if i == as_resources_index { + body.extend_from_slice(as_resources_extra); + } else { + body.extend_from_slice(&ext.extra); + } + } + body.extend_from_slice(call_data); + + let mut extrinsic = Compact(body.len() as u32).encode(); + extrinsic.extend_from_slice(&body); + Ok(extrinsic) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FIXTURE: &[u8] = include_bytes!("../../tests/fixtures/paseo-next-v2-metadata.scale"); + + fn fixture_state() -> ChainState { + ChainState { + spec_version: 1_000_000, + transaction_version: 1, + genesis_hash: [0xab; 32], + nonce: 0, + } + } + + #[test] + fn call_layout_is_pallet_call_period_seq_target() { + let call = build_set_statement_store_account_call(7, 0, &[0u8; 32]); + assert_eq!( + call, + [ + vec![0x3f, 0x0a], + 7u32.to_le_bytes().to_vec(), + 0u32.to_le_bytes().to_vec(), + vec![0u8; 32], + ] + .concat() + ); + } + + #[test] + fn as_resources_extra_wraps_proof_as_bytes() { + let proof = vec![0xEE; 785]; + let extra = build_as_resources_extra(&proof, 3); + // Some(0x01) ‖ variant(0x02) ‖ compact(785)=0x45,0x0c ‖ 785 bytes ‖ ringIndex LE ‖ LitePeople. + assert_eq!(&extra[..2], &[0x01, 0x02]); + assert_eq!(&extra[2..4], &Compact(785u32).encode()[..]); + assert_eq!(&extra[4..4 + 785], &proof[..]); + assert_eq!(&extra[4 + 785..4 + 785 + 4], &3u32.to_le_bytes()); + assert_eq!(extra[4 + 785 + 4], MEMBERSHIP_COLLECTION_LITE_PEOPLE); + } + + #[test] + fn extrinsic_has_general_v5_preamble_and_embeds_call() { + let metadata = Metadata::decode(FIXTURE).unwrap(); + let call = build_set_statement_store_account_call(7, 0, &[0u8; 32]); + let extra = build_as_resources_extra(&vec![0xEE; 785], 0); + let xt = build_unsigned_extrinsic(&metadata, &fixture_state(), &call, &extra).unwrap(); + + // Strip the compact length prefix and check the body head + tail. + let body = &xt[compact_prefix_len(&xt)..]; + assert_eq!(&body[..2], &[GENERAL_V5_PREAMBLE, EXTENSION_VERSION]); + assert_eq!(&body[body.len() - call.len()..], &call[..]); + // The Some(info) extra appears verbatim in the body. + assert!( + body.windows(extra.len()).any(|w| w == extra), + "AsResources Some(info) extra should appear in the body", + ); + } + + /// Length of the SCALE compact prefix at the head of `xt`. + fn compact_prefix_len(xt: &[u8]) -> usize { + match xt[0] & 0b11 { + 0b00 => 1, + 0b01 => 2, + 0b10 => 4, + _ => 5, + } + } +} diff --git a/rust/crates/truapi-host-cli/src/alloc/proof.rs b/rust/crates/truapi-host-cli/src/alloc/proof.rs new file mode 100644 index 00000000..b9dc567f --- /dev/null +++ b/rust/crates/truapi-host-cli/src/alloc/proof.rs @@ -0,0 +1,111 @@ +//! Bandersnatch ring-VRF one-shot membership proof (prover side). +//! +//! Wraps `verifiable`'s prover-gated `open` + `create` into the single-shot +//! proof a `RegisterStatementStoreAllowance` needs: prove that our member key is +//! in the LitePeople ring, bound to a slot `context` and the extrinsic proof +//! `message`. Mirrors signing-bot `ring-proof.ts` `oneShotProof`. + +use verifiable::GenerateVerifiable; +use verifiable::ring::RingDomainSize; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +/// A single-context ring-VRF signature is exactly 785 bytes. +pub const RING_VRF_PROOF_LEN: usize = 785; + +/// Map an on-chain `RingExponent` (9 / 10 / 14) to the FFT domain size +/// (power = exponent + 2). +pub fn domain_for_ring_exponent(exponent: u8) -> Result { + match exponent { + 9 => Ok(RingDomainSize::Domain11), + 10 => Ok(RingDomainSize::Domain12), + 14 => Ok(RingDomainSize::Domain16), + other => Err(format!("unsupported ring exponent {other}")), + } +} + +/// The ring member key for a bandersnatch entropy (`blake2b256(bip39_entropy)`). +pub fn member_key(entropy: [u8; 32]) -> [u8; 32] { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + BandersnatchVrfVerifiable::member_from_secret(&secret) +} + +/// Produce the 785-byte ring-VRF membership proof over `members` (already +/// sliced to the ring's included prefix), bound to `context` and `message`. +/// +/// `entropy` is the bandersnatch entropy; its member key must be present in +/// `members` or `open` fails with `NotInRing`. +pub fn ring_vrf_proof( + domain: RingDomainSize, + entropy: [u8; 32], + members: &[[u8; 32]], + context: &[u8], + message: &[u8], +) -> Result, String> { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + let member = BandersnatchVrfVerifiable::member_from_secret(&secret); + let commitment = BandersnatchVrfVerifiable::open(domain, &member, members.iter().copied()) + .map_err(|err| format!("ring-VRF open failed: {err:?}"))?; + let (proof, _alias) = BandersnatchVrfVerifiable::create(commitment, &secret, context, message) + .map_err(|err| format!("ring-VRF create failed: {err:?}"))?; + let bytes = proof.into_inner(); + if bytes.len() != RING_VRF_PROOF_LEN { + return Err(format!( + "ring-VRF proof is {} bytes, expected {RING_VRF_PROOF_LEN}", + bytes.len() + )); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exponent_maps_to_domain() { + assert_eq!( + domain_for_ring_exponent(9).unwrap(), + RingDomainSize::Domain11 + ); + assert_eq!( + domain_for_ring_exponent(10).unwrap(), + RingDomainSize::Domain12 + ); + assert_eq!( + domain_for_ring_exponent(14).unwrap(), + RingDomainSize::Domain16 + ); + assert!(domain_for_ring_exponent(11).is_err()); + } + + #[test] + fn proof_is_785_bytes_for_a_single_member_ring() { + let entropy = [0x11u8; 32]; + let member = member_key(entropy); + let members = vec![member]; + let proof = ring_vrf_proof( + RingDomainSize::Domain11, + entropy, + &members, + b"SSS_SLOT:test-context-padding..", + &[0x42; 32], + ) + .unwrap(); + assert_eq!(proof.len(), RING_VRF_PROOF_LEN); + } + + #[test] + fn open_fails_when_member_absent_from_ring() { + let entropy = [0x11u8; 32]; + let other = member_key([0x22u8; 32]); + let err = ring_vrf_proof( + RingDomainSize::Domain11, + entropy, + &[other], + b"SSS_SLOT:test-context-padding..", + &[0x42; 32], + ) + .unwrap_err(); + assert!(err.contains("open failed"), "unexpected error: {err}"); + } +} diff --git a/rust/crates/truapi-host-cli/src/alloc/ring.rs b/rust/crates/truapi-host-cli/src/alloc/ring.rs new file mode 100644 index 00000000..aac6403d --- /dev/null +++ b/rust/crates/truapi-host-cli/src/alloc/ring.rs @@ -0,0 +1,164 @@ +//! LitePeople ring parameters from the People chain (`Members` pallet). +//! +//! Reads the on-chain ring so the membership proof is built against the same +//! members the runtime verifies against: the baked-in `included` prefix of the +//! current ring. Mirrors signing-bot `ring-proof.ts`. + +use parity_scale_codec::{Compact, Decode}; +use sp_crypto_hashing::{blake2_128, twox_64, twox_128}; + +use super::dynamic::read_field_variant_name; +use super::extension::Metadata; +use super::rpc::RpcClient; + +/// LitePeople collection identifier: ASCII, exactly 32 bytes. +const LITE_PEOPLE_IDENTIFIER: &[u8; 32] = b"pop:polkadot.network/people-lite"; +/// Ring member public key length. +const MEMBER_LEN: usize = 32; + +/// On-chain LitePeople ring parameters for building a verifying proof. +pub struct RingParams { + /// Ring members, sliced to the baked-in `included` prefix. + pub members: Vec<[u8; 32]>, + /// Ring size exponent (9 / 10 / 14). + pub exponent: u8, + /// Ring index these members belong to. + pub ring_index: u32, +} + +/// `Members.CurrentRingIndex[id]` storage key. +fn current_ring_index_key() -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"CurrentRingIndex").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + ] + .concat() +} + +/// `Members.Collections[id]` storage key. +fn collections_key() -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"Collections").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + ] + .concat() +} + +/// `Members.RingKeysStatus[(id, ring_index)]` storage key. +fn ring_keys_status_key(ring_index: u32) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"RingKeysStatus").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + &blake2_128_concat(&ring_index.to_le_bytes()), + ] + .concat() +} + +/// `Members.RingKeys[(id, ring_index, page)]` storage key. +fn ring_keys_key(ring_index: u32, page: u32) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"RingKeys").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + &blake2_128_concat(&ring_index.to_le_bytes()), + &twox_64_concat(&page.to_le_bytes()), + ] + .concat() +} + +/// `Blake2_128Concat(x)` = `blake2_128(x) ‖ x`. +pub(super) fn blake2_128_concat(x: &[u8]) -> Vec { + [blake2_128(x).as_slice(), x].concat() +} + +/// `Twox64Concat(x)` = `twox_64(x) ‖ x`. +fn twox_64_concat(x: &[u8]) -> Vec { + [twox_64(x).as_slice(), x].concat() +} + +/// Map a `RingExponent` variant name to its exponent. +fn ring_exponent_from_name(name: &str) -> Result { + match name { + "R2e9" => Ok(9), + "R2e10" => Ok(10), + "R2e14" => Ok(14), + other => Err(format!("unsupported RingExponent variant `{other}`")), + } +} + +/// Read the current LitePeople ring index (absent => 0). +pub async fn read_current_ring_index(rpc: &RpcClient) -> Result { + match rpc + .get_storage(¤t_ring_index_key()) + .await + .map_err(|e| e.to_string())? + { + Some(bytes) => u32::decode(&mut &bytes[..]).map_err(|e| format!("ring index: {e}")), + None => Ok(0), + } +} + +/// Read the LitePeople ring size exponent from `Collections[LitePeople].ring_size`. +/// This is a chain constant, so read it once and reuse across ring indices. +pub async fn read_ring_exponent(rpc: &RpcClient, metadata: &Metadata) -> Result { + let collection = rpc + .get_storage(&collections_key()) + .await + .map_err(|e| e.to_string())? + .ok_or_else(|| "Members.Collections[LitePeople] missing".to_string())?; + let value_type = metadata + .storage_value_type("Members", "Collections") + .ok_or_else(|| "Members.Collections type not in metadata".to_string())?; + let variant = + read_field_variant_name(metadata.registry(), value_type, "ring_size", &collection)?; + ring_exponent_from_name(&variant) +} + +/// Read the members of `ring_index`, sliced to the baked-in `included` prefix. +pub async fn read_ring_members_at( + rpc: &RpcClient, + ring_index: u32, +) -> Result, String> { + // 1. Page through RingKeys collecting raw 32-byte members. + let mut members = Vec::new(); + for page in 0.. { + let Some(bytes) = rpc + .get_storage(&ring_keys_key(ring_index, page)) + .await + .map_err(|e| e.to_string())? + else { + break; + }; + let mut cursor = &bytes[..]; + let Compact(len) = + Compact::::decode(&mut cursor).map_err(|e| format!("ring keys len: {e}"))?; + if len == 0 { + break; + } + for i in 0..len as usize { + let start = i * MEMBER_LEN; + let member: [u8; 32] = cursor + .get(start..start + MEMBER_LEN) + .ok_or_else(|| "ring keys page truncated".to_string())? + .try_into() + .expect("slice is 32 bytes"); + members.push(member); + } + } + + // 2. Slice to the baked-in `included` prefix (absent status => all included). + if let Some(status) = rpc + .get_storage(&ring_keys_status_key(ring_index)) + .await + .map_err(|e| e.to_string())? + { + // RingStatus = { total: u32 LE, included: u32 LE, .. }. + let included = u32::decode(&mut &status[4..]).map_err(|e| format!("ring status: {e}"))?; + members.truncate(included as usize); + } + + Ok(members) +} diff --git a/rust/crates/truapi-host-cli/src/alloc/rpc.rs b/rust/crates/truapi-host-cli/src/alloc/rpc.rs new file mode 100644 index 00000000..7e32e675 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/alloc/rpc.rs @@ -0,0 +1,171 @@ +//! Minimal JSON-RPC-over-WebSocket client for the People chain. +//! +//! Sequential request/response (one in flight at a time) plus a +//! submit-and-watch helper for `author_submitAndWatchExtrinsic`. Enough to read +//! metadata / storage / runtime version and submit the allowance extrinsic; +//! statement-store traffic keeps using the runtime's own transport. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use anyhow::{Context, Result, anyhow, bail}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use tokio::net::TcpStream; +use tokio::sync::Mutex; +use tokio::time::timeout; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async}; + +type Ws = WebSocketStream>; + +/// Timeout for a single call's response. +const CALL_TIMEOUT: Duration = Duration::from_secs(30); +/// Timeout for an extrinsic to reach `inBlock`. +const SUBMIT_TIMEOUT: Duration = Duration::from_secs(120); + +/// A single WebSocket JSON-RPC connection. +pub struct RpcClient { + ws: Mutex, + next_id: AtomicU64, +} + +impl RpcClient { + /// Open a WebSocket JSON-RPC connection to `url`. + pub async fn connect(url: &str) -> Result { + let (ws, _) = connect_async(url) + .await + .with_context(|| format!("connect {url}"))?; + Ok(Self { + ws: Mutex::new(ws), + next_id: AtomicU64::new(1), + }) + } + + /// Call `method` with `params`, returning the `result` value. + pub async fn call(&self, method: &str, params: Value) -> Result { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let request = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); + let mut ws = self.ws.lock().await; + ws.send(Message::Text(request.to_string())) + .await + .with_context(|| format!("send {method}"))?; + loop { + let value = next_json(&mut ws, CALL_TIMEOUT, method).await?; + if value.get("id").and_then(Value::as_u64) == Some(id) { + if let Some(err) = value.get("error") { + bail!("rpc error for {method}: {err}"); + } + return Ok(value.get("result").cloned().unwrap_or(Value::Null)); + } + } + } + + /// `state_getStorage(key)` -> raw value bytes, or `None` if absent. + pub async fn get_storage(&self, key: &[u8]) -> Result>> { + let key_hex = format!("0x{}", hex::encode(key)); + match self.call("state_getStorage", json!([key_hex])).await? { + Value::String(hex_value) => Ok(Some(decode_hex(&hex_value)?)), + _ => Ok(None), + } + } + + /// Submit an extrinsic and wait for `inBlock`/`finalized`; returns the block + /// hash. Rejects on `invalid` / `dropped` / `usurped` / `finalityTimeout`. + pub async fn submit_and_watch(&self, extrinsic: &[u8]) -> Result { + let extrinsic_hex = format!("0x{}", hex::encode(extrinsic)); + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let request = json!({ + "jsonrpc": "2.0", + "id": id, + "method": "author_submitAndWatchExtrinsic", + "params": [extrinsic_hex], + }); + let mut ws = self.ws.lock().await; + ws.send(Message::Text(request.to_string())) + .await + .context("send author_submitAndWatchExtrinsic")?; + + // First the subscription id, then a stream of status notifications. + let mut subscription_id: Option = None; + loop { + let value = + next_json(&mut ws, SUBMIT_TIMEOUT, "author_submitAndWatchExtrinsic").await?; + if subscription_id.is_none() { + if value.get("id").and_then(Value::as_u64) == Some(id) { + if let Some(err) = value.get("error") { + bail!("submit rejected: {err}"); + } + subscription_id = value + .get("result") + .and_then(Value::as_str) + .map(str::to_string); + if subscription_id.is_none() { + bail!("submit response missing subscription id: {value}"); + } + } + continue; + } + let params = value.get("params"); + let matches = params + .and_then(|p| p.get("subscription")) + .and_then(Value::as_str) + == subscription_id.as_deref(); + if !matches { + continue; + } + if let Some(status) = params.and_then(|p| p.get("result")) { + match extrinsic_status(status) { + ExtrinsicStatus::InBlock(hash) => return Ok(hash), + ExtrinsicStatus::Rejected(reason) => bail!("extrinsic {reason}"), + ExtrinsicStatus::Pending => {} + } + } + } + } +} + +/// Terminal or pending state of a submitted extrinsic. +enum ExtrinsicStatus { + InBlock(String), + Rejected(String), + Pending, +} + +/// Classify an `author_extrinsicUpdate` status value. +fn extrinsic_status(status: &Value) -> ExtrinsicStatus { + // Terminal-success statuses carry a block hash: {"inBlock": "0x…"} / {"finalized": "0x…"}. + for key in ["inBlock", "finalized"] { + if let Some(hash) = status.get(key).and_then(Value::as_str) { + return ExtrinsicStatus::InBlock(hash.to_string()); + } + } + for key in ["invalid", "dropped", "usurped", "finalityTimeout"] { + if status.get(key).is_some() { + return ExtrinsicStatus::Rejected(key.to_string()); + } + } + ExtrinsicStatus::Pending +} + +/// Read the next JSON frame, skipping non-text frames, within `deadline`. +async fn next_json(ws: &mut Ws, deadline: Duration, context: &str) -> Result { + loop { + let message = timeout(deadline, ws.next()) + .await + .map_err(|_| anyhow!("timed out waiting for {context}"))? + .ok_or_else(|| anyhow!("websocket closed waiting for {context}"))??; + let text = match message { + Message::Text(text) => text.to_string(), + Message::Binary(bytes) => String::from_utf8_lossy(&bytes).into_owned(), + Message::Close(_) => bail!("websocket closed waiting for {context}"), + _ => continue, + }; + return serde_json::from_str(&text).with_context(|| format!("parse {context} response")); + } +} + +/// Decode a `0x`-prefixed hex string to bytes. +fn decode_hex(value: &str) -> Result> { + hex::decode(value.strip_prefix("0x").unwrap_or(value)).context("decode hex storage value") +} diff --git a/rust/crates/truapi-host-cli/src/alloc/slot.rs b/rust/crates/truapi-host-cli/src/alloc/slot.rs new file mode 100644 index 00000000..0bd42604 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/alloc/slot.rs @@ -0,0 +1,129 @@ +//! 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 +//! `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 sp_crypto_hashing::twox_128; +use verifiable::GenerateVerifiable; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + +use super::extension::Metadata; +use super::ring::blake2_128_concat; +use super::rpc::RpcClient; + +/// StatementStore allowance period: one UTC day, in seconds. +pub const STATEMENT_STORE_PERIOD_SECONDS: u64 = 86_400; + +/// The current allowance period for `now_seconds`. +pub fn current_period(now_seconds: u64) -> u32 { + (now_seconds / STATEMENT_STORE_PERIOD_SECONDS) 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 +} + +/// The slot alias for our `entropy` at `(period, seq)`. +pub fn slot_alias(entropy: [u8; 32], period: u32, seq: u32) -> Result<[u8; 32], String> { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + let context = derive_slot_context(period, seq); + BandersnatchVrfVerifiable::alias_in_context(&secret, &context) + .map_err(|err| format!("alias_in_context failed: {err:?}")) +} + +/// `Resources.StatementStoreAllowances[period][alias]` storage key. +/// key1 = Identity(u32be period); key2 = Blake2_128Concat(alias). +fn statement_store_allowance_key(period: u32, alias: &[u8; 32]) -> Vec { + [ + twox_128(b"Resources").as_slice(), + twox_128(b"StatementStoreAllowances").as_slice(), + &period.to_be_bytes(), + &blake2_128_concat(alias), + ] + .concat() +} + +/// Max StatementStore slots per period from `Resources.LiteStmtStoreSlotsPerPeriod`. +fn max_slots(metadata: &Metadata) -> Result { + let bytes = metadata + .constant("Resources", "LiteStmtStoreSlotsPerPeriod") + .ok_or_else(|| "Resources.LiteStmtStoreSlotsPerPeriod constant missing".to_string())?; + let mut buf = [0u8; 4]; + let n = bytes.len().min(4); + buf[..n].copy_from_slice(&bytes[..n]); + Ok(u32::from_le_bytes(buf)) +} + +/// The account id occupying a slot entry, if the storage value is present. +/// Entry = `account_id(32) ‖ seq(u32 LE) ‖ since(u64 LE)`. +fn entry_account_id(bytes: &[u8]) -> Option<[u8; 32]> { + bytes.get(..32).map(|s| s.try_into().expect("32 bytes")) +} + +/// Outcome of scanning for a slot to register `target` in. +pub enum SlotSelection { + /// A free `seq` we should claim. + Free(u32), + /// `target` already holds `seq` this period; no registration needed. + AlreadyAllocated(u32), +} + +/// Scan slots `0..max` for `period`, returning the first free seq (or detecting +/// that `target` already holds one). `entropy` is our bandersnatch entropy. +pub async fn scan_slot( + rpc: &RpcClient, + metadata: &Metadata, + entropy: [u8; 32], + period: u32, + target: &[u8; 32], +) -> Result { + let max = max_slots(metadata)?; + let mut first_free: Option = None; + for seq in 0..max { + let alias = slot_alias(entropy, period, seq)?; + let key = statement_store_allowance_key(period, &alias); + match rpc.get_storage(&key).await.map_err(|e| e.to_string())? { + None => { + if first_free.is_none() { + first_free = Some(seq); + } + } + Some(bytes) => { + if entry_account_id(&bytes) == Some(*target) { + return Ok(SlotSelection::AlreadyAllocated(seq)); + } + } + } + } + first_free + .map(SlotSelection::Free) + .ok_or_else(|| format!("no free StatementStore slot in period {period} (max {max})")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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)); + } + + #[test] + fn period_is_utc_day_index() { + assert_eq!(current_period(86_400 * 20_000 + 5), 20_000); + } +} diff --git a/rust/crates/truapi-host-cli/src/chain.rs b/rust/crates/truapi-host-cli/src/chain.rs index 8114e3f3..233b9cf2 100644 --- a/rust/crates/truapi-host-cli/src/chain.rs +++ b/rust/crates/truapi-host-cli/src/chain.rs @@ -1,8 +1,9 @@ //! Native WebSocket `ChainProvider` / `JsonRpcConnection`. //! -//! The headless hosts reach the dev statement-store [`crate::relay`] over -//! WebSocket JSON-RPC. Every `connect` opens a fresh socket; the runtime's -//! `HostRpcClient` sits on top and speaks statement-store RPC. +//! The headless hosts reach the real People-chain statement store over +//! WebSocket JSON-RPC (the same node an iOS/web client uses). Every `connect` +//! opens a fresh socket; the runtime's `HostRpcClient` sits on top and speaks +//! statement-store RPC. use std::collections::HashMap; use std::sync::Arc; @@ -42,8 +43,8 @@ const PASEO_NEXT_V2_CHAIN_ENDPOINTS: &[(&str, &str)] = &[ /// Chain provider that maps a requested genesis hash to a WebSocket endpoint. /// /// The all-zero genesis (the headless SSO sentinel) and any unmapped genesis -/// fall back to the local statement-store relay; known public genesis hashes -/// route to real testnet nodes. +/// fall back to the People-chain statement store; the Asset Hub genesis routes +/// to its own node (opt-in) for the `Chain/*` playground examples. pub struct WsChainProvider { fallback_url: String, by_genesis: HashMap<[u8; 32], String>, @@ -51,10 +52,11 @@ pub struct WsChainProvider { impl WsChainProvider { pub fn new(fallback_url: impl Into) -> Self { - // Live-chain routing is opt-in: when disabled, every genesis (including - // the real testnet ones the `Chain/*` examples request) falls back to - // the local relay, which does not speak chainHead, so those methods - // fail cleanly without disturbing the SSO/signer path. + // The fallback is the People-chain statement store, which serves the + // SSO/identity path directly. Asset Hub routing (for the `Chain/*` + // examples) is opt-in; when off, those genesis requests fall back to the + // People node, which does not serve Asset Hub chainHead, so they fail + // cleanly without disturbing the SSO/signer path. let by_genesis = if std::env::var("E2E_LIVE_CHAIN").as_deref() == Ok("1") { PASEO_NEXT_V2_CHAIN_ENDPOINTS .iter() diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 3a910b86..5bcacea3 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -1,18 +1,19 @@ //! Headless TrUAPI hosts for local end-to-end testing. //! -//! Three roles, one binary: -//! - `relay`: an in-memory statement-store the two hosts pair over. +//! Two roles, one binary, pairing over the real People-chain statement store: //! - `pairing-host`: a seedless host that presents a pairing deeplink and //! serves product frames over WebSocket (the surface a product/test driver //! talks to). //! - `signing-host`: a wallet-local host that answers a pairing deeplink and //! auto-signs, replacing the external signing-bot in e2e. +//! +//! Plus `alloc-check`, a diagnostic for on-chain statement-store allowance. +mod alloc; mod attestation; mod chain; mod frame_server; mod platform; -mod relay; use std::io::BufRead; use std::net::SocketAddr; @@ -71,33 +72,23 @@ struct Cli { #[derive(Subcommand)] enum Command { - /// Run the in-memory statement-store relay. - Relay { - /// Address to listen on. - #[arg(long, default_value = "127.0.0.1:9944")] - listen: SocketAddr, - }, /// Run a seedless pairing host and serve product frames over WebSocket. PairingHost { - /// Statement-store relay WebSocket URL. - #[arg(long, default_value = "ws://127.0.0.1:9944")] - relay: String, + /// Statement-store WebSocket URL (the real People chain by default). + #[arg(long = "statement-store", default_value = PEOPLE_CHAIN_WS)] + statement_store: String, /// Address to serve product frames on. #[arg(long, default_value = "127.0.0.1:9955")] frame_listen: SocketAddr, /// Product id presented to product frame connections. #[arg(long, default_value = DEFAULT_PRODUCT_ID)] product: String, - /// Resolve usernames from the real paseo-next-v2 People chain (so - /// `get_user_id` works), instead of only the SSO relay. - #[arg(long)] - resolve_identity: bool, }, /// Answer a pairing deeplink as a wallet-local signing host and auto-sign. SigningHost { - /// Statement-store relay WebSocket URL. - #[arg(long, default_value = "ws://127.0.0.1:9944")] - relay: String, + /// Statement-store WebSocket URL (the real People chain by default). + #[arg(long = "statement-store", default_value = PEOPLE_CHAIN_WS)] + statement_store: String, /// BIP-39 mnemonic for the wallet root. #[arg(long, default_value = DEFAULT_MNEMONIC)] mnemonic: String, @@ -122,6 +113,27 @@ enum Command { #[arg(long, default_value = PEOPLE_CHAIN_WS)] people_ws: String, }, + /// Check (and optionally submit) a statement-store allowance registration + /// against the real People chain: ring membership, the chosen slot, and + /// (with `--submit`) the `set_statement_store_account` extrinsic. + AllocCheck { + /// BIP-39 mnemonic proving LitePeople ring membership. + #[arg(long, default_value = DEFAULT_MNEMONIC)] + mnemonic: String, + /// People-chain WebSocket URL (statement store + chain RPC). + #[arg(long, default_value = PEOPLE_CHAIN_WS)] + people_ws: String, + /// Target account (hex, 32 bytes) to grant allowance to. Defaults to + /// all-zero (read-only slot scan only). + #[arg(long)] + target: Option, + /// How many rings back from the current index to scan for our member. + #[arg(long, default_value_t = 8)] + lookback: u32, + /// Submit the extrinsic instead of only checking membership + slot. + #[arg(long)] + submit: bool, + }, } #[tokio::main] @@ -139,20 +151,18 @@ async fn main() -> Result<()> { .init(); match Cli::parse().command { - Command::Relay { listen } => relay::Relay::serve(listen).await, Command::PairingHost { - relay, + statement_store, frame_listen, product, - resolve_identity, - } => run_pairing_host(relay, frame_listen, product, resolve_identity).await, + } => run_pairing_host(statement_store, frame_listen, product).await, Command::SigningHost { - relay, + statement_store, mnemonic, deeplink, reject, username, - } => run_signing_host(relay, mnemonic, deeplink, reject, username).await, + } => run_signing_host(statement_store, mnemonic, deeplink, reject, username).await, Command::IdentityCheck { mnemonic, people_ws, @@ -162,7 +172,114 @@ async fn main() -> Result<()> { .to_entropy(); attestation::check_identity(&people_ws, &entropy).await } + Command::AllocCheck { + mnemonic, + people_ws, + target, + lookback, + submit, + } => run_alloc_check(mnemonic, people_ws, target, lookback, submit).await, + } +} + +/// Check statement-store allowance for a mnemonic: ring membership, the chosen +/// slot, and (with `submit`) the `set_statement_store_account` extrinsic. +async fn run_alloc_check( + mnemonic: String, + people_ws: String, + target: Option, + lookback: u32, + submit: bool, +) -> Result<()> { + let entropy = bip39::Mnemonic::parse(mnemonic.trim()) + .context("invalid BIP-39 mnemonic")? + .to_entropy(); + let bandersnatch = alloc::bandersnatch_entropy(&entropy); + + let target = match target { + Some(hex_str) => { + let bytes = hex::decode(hex_str.strip_prefix("0x").unwrap_or(&hex_str)) + .context("invalid --target hex")?; + <[u8; 32]>::try_from(bytes.as_slice()) + .map_err(|_| anyhow::anyhow!("--target must be 32 bytes"))? + } + None => [0u8; 32], + }; + + let rpc = alloc::rpc::RpcClient::connect(&people_ws).await?; + let metadata = alloc::fetch_metadata(&rpc) + .await + .map_err(anyhow::Error::msg)?; + let chain_state = alloc::fetch_chain_state(&rpc) + .await + .map_err(anyhow::Error::msg)?; + println!( + "chain: specVersion={} txVersion={} genesis=0x{}", + chain_state.spec_version, + chain_state.transaction_version, + hex::encode(chain_state.genesis_hash), + ); + + let member = alloc::proof::member_key(bandersnatch); + println!("bandersnatch member=0x{}", hex::encode(member)); + let current_ring = alloc::ring::read_current_ring_index(&rpc) + .await + .map_err(anyhow::Error::msg)?; + println!("current ring index={current_ring}"); + let ring = alloc::find_including_ring(&rpc, &metadata, bandersnatch, lookback) + .await + .map_err(anyhow::Error::msg)?; + match &ring { + Some(r) => println!( + "member INCLUDED in ring_index={} exponent={} included_members={}", + r.ring_index, + r.exponent, + r.members.len(), + ), + None => println!("member NOT in the last {lookback} rings (onboarding pending)"), + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .context("system clock before UNIX epoch")? + .as_secs(); + let period = alloc::slot::current_period(now); + println!("period={period} target=0x{}", hex::encode(target)); + + match alloc::slot::scan_slot(&rpc, &metadata, bandersnatch, period, &target).await { + Ok(alloc::slot::SlotSelection::Free(seq)) => println!("slot scan: free seq={seq}"), + Ok(alloc::slot::SlotSelection::AlreadyAllocated(seq)) => { + println!("slot scan: target already allocated at seq={seq}") + } + Err(err) => println!("slot scan: {err}"), + } + + if submit { + let ring = ring.ok_or_else(|| anyhow::anyhow!("cannot submit: member not in any ring"))?; + match alloc::register_statement_account( + &rpc, + &metadata, + &chain_state, + bandersnatch, + &target, + period, + &ring, + ) + .await + { + Ok(alloc::RegistrationOutcome::Registered { + block_hash, + seq, + ring_index, + }) => println!("REGISTERED seq={seq} ring_index={ring_index} block={block_hash}"), + Ok(alloc::RegistrationOutcome::AlreadyAllocated { seq }) => { + println!("already allocated at seq={seq}") + } + Err(err) => bail!("registration failed: {err}"), + } } + + Ok(()) } /// Spawner that runs runtime futures on the tokio runtime, so their WebSocket @@ -189,30 +306,27 @@ fn platform_info() -> PlatformInfo { } async fn run_pairing_host( - relay: String, + statement_store: String, frame_listen: SocketAddr, product: String, - resolve_identity: bool, ) -> Result<()> { - let platform = CliPlatform::new(relay, ApprovalPolicy::Always); - let mut config = PairingHostConfig::new( + let platform = CliPlatform::new(statement_store, ApprovalPolicy::Always); + // SSO and identity both run over the real People chain, so usernames always + // resolve from `Resources.Consumers` (host-spec G). + let config = PairingHostConfig::new( host_info("Headless Pairing Host"), platform_info(), [0u8; 32], DEEPLINK_SCHEME.to_string(), ) - .context("invalid pairing host config")?; - if resolve_identity { - // SSO stays on the relay ([0;32]); resolve usernames from the real - // People chain. Requires live-chain routing (E2E_LIVE_CHAIN=1). - config = config.with_identity_chain_genesis_hash(PEOPLE_CHAIN_GENESIS); - } + .context("invalid pairing host config")? + .with_identity_chain_genesis_hash(PEOPLE_CHAIN_GENESIS); let runtime = Arc::new(PairingHostRuntime::new(platform, config, tokio_spawner())); frame_server::serve(runtime, product, frame_listen).await } async fn run_signing_host( - relay: String, + statement_store: String, mnemonic: String, deeplink: Option, reject: bool, @@ -244,7 +358,13 @@ async fn run_signing_host( } else { ApprovalPolicy::Always }; - let platform = CliPlatform::new(relay, approval); + // Grant statement-store allowance to the accounts that submit statements + // over the real store: our own `//wallet//sso` and the pairing host's + // device key. A real client does this on-chain; without it the store + // rejects the handshake with `NoAllowance`. + register_pairing_allowances(&statement_store, &entropy, &deeplink).await?; + + let platform = CliPlatform::new(statement_store, approval); let config = SigningHostConfig::new( host_info("Headless Signing Host"), platform_info(), @@ -266,6 +386,86 @@ async fn run_signing_host( Ok(()) } +/// Grant on-chain statement-store allowance to the two accounts that submit +/// statements during pairing: the signing host's own `//wallet//sso` account +/// and the pairing host's per-pairing device key (from the deeplink). Proves +/// the signing account's LitePeople ring membership once and reuses it. +async fn register_pairing_allowances( + statement_store_url: &str, + entropy: &[u8], + deeplink: &str, +) -> Result<()> { + use truapi_server::host_logic::product_account::derive_sr25519_hard_path; + use truapi_server::host_logic::sso::pairing::{ + VersionedHandshakeProposal, decode_pairing_deeplink, + }; + + let wallet_sso = derive_sr25519_hard_path(entropy, &["wallet", "sso"]) + .map_err(|e| anyhow::anyhow!("//wallet//sso derivation failed: {e}"))? + .public + .to_bytes(); + let VersionedHandshakeProposal::V2(proposal) = + decode_pairing_deeplink(deeplink).map_err(anyhow::Error::msg)?; + let device = proposal.device.statement_account_id; + + let bandersnatch = alloc::bandersnatch_entropy(entropy); + let rpc = alloc::rpc::RpcClient::connect(statement_store_url).await?; + let metadata = alloc::fetch_metadata(&rpc) + .await + .map_err(anyhow::Error::msg)?; + let chain_state = alloc::fetch_chain_state(&rpc) + .await + .map_err(anyhow::Error::msg)?; + + // The signing account may be in an old ring, so scan back to genesis. + let current = alloc::ring::read_current_ring_index(&rpc) + .await + .map_err(anyhow::Error::msg)?; + let ring = alloc::find_including_ring(&rpc, &metadata, bandersnatch, current) + .await + .map_err(anyhow::Error::msg)? + .ok_or_else(|| { + anyhow::anyhow!( + "signing account is not a LitePeople ring member; cannot grant allowance" + ) + })?; + println!( + "SIGNING_HOST_RING ring_index={} members={}", + ring.ring_index, + ring.members.len() + ); + + let period = alloc::slot::current_period( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .context("system clock before UNIX epoch")? + .as_secs(), + ); + + for (label, target) in [("wallet-sso", wallet_sso), ("device", device)] { + let outcome = alloc::register_statement_account( + &rpc, + &metadata, + &chain_state, + bandersnatch, + &target, + period, + &ring, + ) + .await + .map_err(|e| anyhow::anyhow!("allowance registration for {label} failed: {e}"))?; + match outcome { + alloc::RegistrationOutcome::Registered { + block_hash, seq, .. + } => println!("SIGNING_HOST_ALLOWANCE {label} seq={seq} block={block_hash}"), + alloc::RegistrationOutcome::AlreadyAllocated { seq } => { + println!("SIGNING_HOST_ALLOWANCE {label} already-allocated seq={seq}") + } + } + } + Ok(()) +} + fn read_deeplink_from_stdin() -> Result { let stdin = std::io::stdin(); for line in stdin.lock().lines() { diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs index b84f3d21..5dafa4ca 100644 --- a/rust/crates/truapi-host-cli/src/platform.rs +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -1,9 +1,9 @@ //! `Platform` implementation for the headless hosts. //! //! In-memory product and core storage, a WebSocket chain provider pointed at -//! the dev relay, and an auto-approving [`UserConfirmation`]. Auth-state -//! transitions are published on a channel so the CLI can print the pairing -//! deeplink and observe connection status. +//! the real People-chain statement store, and an auto-approving +//! [`UserConfirmation`]. Auth-state transitions are published on a channel so +//! the CLI can print the pairing deeplink and observe connection status. use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -45,10 +45,10 @@ pub struct CliPlatform { } impl CliPlatform { - /// Build a platform whose chain provider connects to `relay_url`. - pub fn new(relay_url: impl Into, approval: ApprovalPolicy) -> Arc { + /// Build a platform whose chain provider connects to `statement_store_url`. + pub fn new(statement_store_url: impl Into, approval: ApprovalPolicy) -> Arc { Arc::new(Self { - chain: WsChainProvider::new(relay_url), + chain: WsChainProvider::new(statement_store_url), product_storage: Mutex::new(HashMap::new()), core_storage: Mutex::new(HashMap::new()), preimages: Mutex::new(HashMap::new()), diff --git a/rust/crates/truapi-host-cli/src/relay.rs b/rust/crates/truapi-host-cli/src/relay.rs deleted file mode 100644 index f1580bfa..00000000 --- a/rust/crates/truapi-host-cli/src/relay.rs +++ /dev/null @@ -1,297 +0,0 @@ -//! In-memory statement-store relay for local end-to-end testing. -//! -//! Speaks the statement-store JSON-RPC intersection (host-spec N.5) over -//! WebSocket: `statement_submit`, `statement_subscribeStatement`, -//! `statement_unsubscribeStatement`. Statements are retained for the process -//! lifetime and replayed to new subscribers, so pairing works regardless of -//! which side connects first. Topic matching is on the statement's -//! `Topic1..4` fields; this is a test double, not a real statement store. - -use std::net::SocketAddr; -use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; - -use anyhow::{Context, Result}; -use futures_util::{SinkExt, StreamExt}; -use serde_json::{Value, json}; -use tokio::net::{TcpListener, TcpStream}; -use tokio::sync::{Mutex, mpsc}; -use tokio_tungstenite::accept_async; -use tokio_tungstenite::tungstenite::Message; -use tracing::{debug, info, warn}; -use truapi_server::host_logic::statement_store::decode_signed_statement; - -/// Statement-store topic filter (host-spec N.5 / substrate `TopicFilter`). -#[derive(Clone)] -enum Filter { - MatchAll(Vec<[u8; 32]>), - MatchAny(Vec<[u8; 32]>), -} - -impl Filter { - fn matches(&self, topics: &[[u8; 32]]) -> bool { - match self { - Filter::MatchAll(wanted) => wanted.iter().all(|topic| topics.contains(topic)), - Filter::MatchAny(wanted) => wanted.iter().any(|topic| topics.contains(topic)), - } - } -} - -struct Subscription { - id: String, - filter: Filter, - outbound: mpsc::UnboundedSender, -} - -#[derive(Default)] -struct RelayState { - statements: Vec<(Vec, Vec<[u8; 32]>)>, - subscriptions: Vec, -} - -/// Shared relay store: retained statements plus live subscriptions. -#[derive(Clone, Default)] -pub struct Relay { - state: Arc>, - next_id: Arc, -} - -impl Relay { - /// Serve the relay on `addr` until the process exits. Returns the bound - /// address (useful when `addr` uses port 0). - pub async fn serve(addr: SocketAddr) -> Result<()> { - let listener = TcpListener::bind(addr) - .await - .with_context(|| format!("relay failed to bind {addr}"))?; - let bound = listener.local_addr()?; - info!(%bound, "statement-store relay listening"); - // Machine-readable readiness line for orchestrators. - println!("RELAY_LISTENING ws://{bound}"); - let relay = Relay::default(); - loop { - let (stream, peer) = listener.accept().await?; - let relay = relay.clone(); - tokio::spawn(async move { - if let Err(err) = relay.handle_connection(stream).await { - debug!(%peer, %err, "relay connection ended"); - } - }); - } - } - - async fn handle_connection(&self, stream: TcpStream) -> Result<()> { - let ws = accept_async(stream).await?; - let (mut write, mut read) = ws.split(); - let (outbound_tx, mut outbound_rx) = mpsc::unbounded_channel::(); - - let writer = tokio::spawn(async move { - while let Some(message) = outbound_rx.recv().await { - if write.send(message).await.is_err() { - break; - } - } - }); - - let mut connection_subscription_ids = Vec::new(); - while let Some(message) = read.next().await { - let text = match message { - Ok(Message::Text(text)) => text.to_string(), - Ok(Message::Binary(bytes)) => match String::from_utf8(bytes.to_vec()) { - Ok(text) => text, - Err(_) => continue, - }, - Ok(Message::Close(_)) | Err(_) => break, - Ok(_) => continue, - }; - if let Some(reply) = self - .handle_request(&text, &outbound_tx, &mut connection_subscription_ids) - .await - && outbound_tx.send(Message::Text(reply)).is_err() - { - break; - } - } - - self.drop_subscriptions(&connection_subscription_ids).await; - drop(outbound_tx); - let _ = writer.await; - Ok(()) - } - - /// Handle one JSON-RPC request, returning the reply frame to send back. - async fn handle_request( - &self, - text: &str, - outbound: &mpsc::UnboundedSender, - connection_subscription_ids: &mut Vec, - ) -> Option { - let request: Value = serde_json::from_str(text).ok()?; - let id = request.get("id").cloned().unwrap_or(Value::Null); - let method = request.get("method")?.as_str()?; - let params = request.get("params"); - match method { - "statement_submit" => Some(self.handle_submit(id, params).await), - "statement_subscribeStatement" => { - let (reply, sub_id) = self.handle_subscribe(id, params, outbound).await; - if let Some(sub_id) = sub_id { - connection_subscription_ids.push(sub_id); - } - Some(reply) - } - "statement_unsubscribeStatement" => { - if let Some(sub_id) = params - .and_then(|params| params.get(0)) - .and_then(Value::as_str) - { - self.drop_subscriptions(std::slice::from_ref(&sub_id.to_string())) - .await; - connection_subscription_ids.retain(|id| id != sub_id); - } - Some(result_frame(id, json!(true))) - } - other => { - warn!(method = other, "relay received unsupported method"); - Some(error_frame(id, -32601, "method not supported")) - } - } - } - - async fn handle_submit(&self, id: Value, params: Option<&Value>) -> String { - let Some(statement) = params - .and_then(|params| params.get(0)) - .and_then(Value::as_str) - .and_then(decode_hex) - else { - return error_frame(id, -32602, "invalid statement_submit params"); - }; - let topics = statement_topics(&statement); - let mut state = self.state.lock().await; - state.statements.push((statement.clone(), topics.clone())); - for subscription in &state.subscriptions { - if subscription.filter.matches(&topics) { - let _ = subscription - .outbound - .send(Message::Text(new_statements_frame( - &subscription.id, - &[&statement], - ))); - } - } - result_frame(id, json!("0xsubmitted")) - } - - async fn handle_subscribe( - &self, - id: Value, - params: Option<&Value>, - outbound: &mpsc::UnboundedSender, - ) -> (String, Option) { - let Some(filter) = params - .and_then(|params| params.get(0)) - .and_then(parse_filter) - else { - return ( - error_frame(id, -32602, "invalid statement_subscribeStatement params"), - None, - ); - }; - let sub_id = format!("relay-sub-{}", self.next_id.fetch_add(1, Ordering::Relaxed)); - let mut state = self.state.lock().await; - let backlog: Vec> = state - .statements - .iter() - .filter(|(_, topics)| filter.matches(topics)) - .map(|(statement, _)| statement.clone()) - .collect(); - state.subscriptions.push(Subscription { - id: sub_id.clone(), - filter, - outbound: outbound.clone(), - }); - drop(state); - - if !backlog.is_empty() { - let refs: Vec<&[u8]> = backlog.iter().map(Vec::as_slice).collect(); - let _ = outbound.send(Message::Text(new_statements_frame(&sub_id, &refs))); - } - (result_frame(id, json!(sub_id)), Some(sub_id)) - } - - async fn drop_subscriptions(&self, ids: &[String]) { - if ids.is_empty() { - return; - } - let mut state = self.state.lock().await; - state - .subscriptions - .retain(|subscription| !ids.contains(&subscription.id)); - } -} - -fn parse_filter(value: &Value) -> Option { - if let Some(topics) = value.get("matchAll").and_then(Value::as_array) { - return Some(Filter::MatchAll(parse_topics(topics)?)); - } - if let Some(topics) = value.get("matchAny").and_then(Value::as_array) { - return Some(Filter::MatchAny(parse_topics(topics)?)); - } - if value.as_str() == Some("any") { - return Some(Filter::MatchAny(Vec::new())); - } - None -} - -fn parse_topics(values: &[Value]) -> Option> { - values - .iter() - .map(|value| { - let bytes = value.as_str().and_then(decode_hex)?; - <[u8; 32]>::try_from(bytes).ok() - }) - .collect() -} - -fn statement_topics(statement: &[u8]) -> Vec<[u8; 32]> { - let mut topics: Vec<[u8; 32]> = decode_signed_statement(statement) - .map(|signed| signed.topics) - .unwrap_or_default(); - topics.sort(); - topics.dedup(); - topics -} - -fn decode_hex(value: &str) -> Option> { - hex::decode(value.strip_prefix("0x").unwrap_or(value)).ok() -} - -fn result_frame(id: Value, result: Value) -> String { - json!({ "jsonrpc": "2.0", "id": id, "result": result }).to_string() -} - -fn error_frame(id: Value, code: i64, message: &str) -> String { - json!({ - "jsonrpc": "2.0", - "id": id, - "error": { "code": code, "message": message }, - }) - .to_string() -} - -fn new_statements_frame(subscription_id: &str, statements: &[&[u8]]) -> String { - let statements: Vec = statements - .iter() - .map(|statement| format!("0x{}", hex::encode(statement))) - .collect(); - json!({ - "jsonrpc": "2.0", - "method": "statement_subscribeStatement", - "params": { - "subscription": subscription_id, - "result": { - "event": "newStatements", - "data": { "statements": statements, "remaining": 0 }, - }, - }, - }) - .to_string() -} diff --git a/rust/crates/truapi-host-cli/tests/fixtures/paseo-next-v2-metadata.scale b/rust/crates/truapi-host-cli/tests/fixtures/paseo-next-v2-metadata.scale new file mode 100644 index 0000000000000000000000000000000000000000..28a7ac3f92c946227e1e78d17ea74df9ad432b76 GIT binary patch literal 388952 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Ⓢ#AKM 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

#&t+Qh>dI$UiUGJ;CJ;(J# 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 literal 0 HcmV?d00001 diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index 2d7b42d3..b8d439a5 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -405,7 +405,9 @@ mod tests { #[test] fn statement_store_submit_posts_signed_statement_and_waits_for_ack() { let platform = Arc::new(StubPlatform { - rpc_responses: vec![r#"{"jsonrpc":"2.0","id":"truapi:1","result":"0xok"}"#.to_string()], + rpc_responses: vec![ + r#"{"jsonrpc":"2.0","id":"truapi:1","result":{"status":"new"}}"#.to_string(), + ], ..Default::default() }); let host = ProductRuntimeHost::new( diff --git a/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs b/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs index bec77132..fbadb576 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs @@ -103,16 +103,24 @@ pub(super) async fn subscribe_match_all( subscribe(rpc_client, TopicFilterKind::MatchAll, topics).await } -/// Submit a SCALE-encoded statement and wait for the JSON-RPC ack. +/// Submit a SCALE-encoded statement and confirm the store accepted it. +/// +/// `statement_submit` returns an RPC error only for internal failures; a +/// rejected or invalid statement (e.g. `NoAllowance`, `BadProof`) comes back as +/// `Ok(SubmitResult)`. Treat only `new`/`known` as success, so allowance/proof +/// rejections surface instead of being silently dropped. pub(super) async fn submit(rpc_client: &RpcClient, statement: Vec) -> Result<(), String> { - rpc_client + let result = rpc_client .request::( SUBMIT_STATEMENT_METHOD, rpc_params![format!("0x{}", hex::encode(&statement))], ) .await - .map(|_| ()) - .map_err(rpc_error_message) + .map_err(rpc_error_message)?; + match result.get("status").and_then(Value::as_str) { + Some("new") | Some("known") => Ok(()), + _ => Err(format!("statement_submit not accepted: {result}")), + } } /// Statement-store topic filter encoded as JSON-RPC params. diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 0636eb8b..65b07ad8 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -410,10 +410,12 @@ pub(crate) fn subscribe_ack_frame(request_id: &str, subscription_id: &str) -> St } fn statement_submit_ack_frame(request_id: &str) -> String { + // Mirror the real `statement_submit` result shape (`SubmitResult`); `submit` + // treats only `new`/`known` as accepted. serde_json::json!({ "jsonrpc": "2.0", "id": request_id, - "result": "0xok", + "result": { "status": "new" }, }) .to_string() } From d01d9f5235bb7f2eb77dd0a0694dafdfc7b9d8c2 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Mon, 6 Jul 2026 12:40:22 +0200 Subject: [PATCH 05/13] feat(truapi-host-cli): drive hosts with product scripts + CLI confirmations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pairing host is now driven by a product script: `pairing-host --script foo.ts --product-id p.dot [--auto-accept]` starts the frame server and runs the script with a global `truapi` injected (the `@parity/truapi` client, scoped to the product) plus a `host` helper. The Rust CLI spawns the script via an embedded `js/runner.ts`, and the command exits with the script's status, so the host command is the test — no separate bun orchestrator. Both hosts take `--auto-accept`; without it every confirmation a web/iOS host would show as a modal is prompted y/n on the CLI (ApprovalPolicy::Prompt). `make headless` builds the CLI + client; `e2e/run.sh` is a thin launcher that runs a product script through a pairing host and pipes the emitted deeplink to a signing host. Product scripts live in `js/scripts/`: `battery.ts` (curated signer gate, 7/7) and `diagnosis.ts` (playground generated examples -> explorer/diagnosis-reports/headless.md, 43/1/20 with a live chain). The former `e2e/{run-e2e,driver,diagnosis,ws-provider}.ts` and `wait-and-sign.sh` are gone. Product ids must be a `.dot`/`localhost` identifier; the default is `headless-playground.dot`. --- Makefile | 6 +- explorer/diagnosis-reports/headless.md | 34 +-- rust/crates/truapi-host-cli/Cargo.toml | 2 +- rust/crates/truapi-host-cli/README.md | 195 +++++++------ rust/crates/truapi-host-cli/e2e/run-e2e.ts | 269 ------------------ rust/crates/truapi-host-cli/e2e/run.sh | 55 +++- .../truapi-host-cli/e2e/wait-and-sign.sh | 19 -- .../truapi-host-cli/{e2e => js}/diagnosis.ts | 0 rust/crates/truapi-host-cli/js/runner.ts | 93 ++++++ .../{e2e/driver.ts => js/scripts/battery.ts} | 112 ++++---- .../truapi-host-cli/js/scripts/diagnosis.ts | 80 ++++++ .../{e2e => js}/ws-provider.ts | 0 .../truapi-host-cli/src/frame_server.rs | 48 ++-- rust/crates/truapi-host-cli/src/main.rs | 131 ++++++--- rust/crates/truapi-host-cli/src/platform.rs | 88 ++++-- .../truapi-host-cli/src/script_runner.rs | 49 ++++ 16 files changed, 627 insertions(+), 554 deletions(-) delete mode 100644 rust/crates/truapi-host-cli/e2e/run-e2e.ts delete mode 100755 rust/crates/truapi-host-cli/e2e/wait-and-sign.sh rename rust/crates/truapi-host-cli/{e2e => js}/diagnosis.ts (100%) create mode 100644 rust/crates/truapi-host-cli/js/runner.ts rename rust/crates/truapi-host-cli/{e2e/driver.ts => js/scripts/battery.ts} (53%) create mode 100644 rust/crates/truapi-host-cli/js/scripts/diagnosis.ts rename rust/crates/truapi-host-cli/{e2e => js}/ws-provider.ts (100%) create mode 100644 rust/crates/truapi-host-cli/src/script_runner.rs diff --git a/Makefile b/Makefile index 285aa467..efbab2b6 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # Run `make help` for the list of targets. .DEFAULT_GOAL := help -.PHONY: help setup build codegen test check playground wasm wasm-crypto-test dev dev-bootstrap dev-link-check e2e-dotli matrix explorer +.PHONY: help setup build codegen test check playground wasm wasm-crypto-test dev dev-bootstrap dev-link-check e2e-dotli headless matrix explorer TRUAPI_PKG := js/packages/truapi PLAYGROUND := playground @@ -48,6 +48,10 @@ build: ## Build the Rust workspace and the TypeScript client. cd $(TRUAPI_PKG) && npm run build cd $(HOST_WASM_PKG) && npm run build +headless: ## Build everything the headless-host e2e needs; then run rust/crates/truapi-host-cli/e2e/run.sh. + cargo build -p truapi-host-cli + cd $(TRUAPI_PKG) && npm run build + codegen: ## Regenerate generated TS/Rust artifacts from the Rust crates. ./scripts/codegen.sh cd $(PLAYGROUND) && rm -rf node_modules/@parity && yarn install diff --git a/explorer/diagnosis-reports/headless.md b/explorer/diagnosis-reports/headless.md index 9001b7f8..d132a56e 100644 --- a/explorer/diagnosis-reports/headless.md +++ b/explorer/diagnosis-reports/headless.md @@ -3,14 +3,14 @@ | Method | Status | Details | | --- | --- | --- | | `Account/connection_status_subscribe` | ✅ | connection status: Connected | -| `Account/get_account` | ✅ | account retrieved: { "account": { "publicKey": "0xf41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef13" } } | -| `Account/get_account_alias` | ✅ | account alias: { "context": "0x05cc3451e5a525ff21f0a62ffe5e5c184fa4cd790bbaead4ef44ab8dc914ecee", "alias": "0x34cc10180a6fc6977f179b03ae39a261ae5cd44fab26d2086545e00b1b94368c" } | +| `Account/get_account` | ✅ | account retrieved: { "account": { "publicKey": "0xfca633ac856b1e3eaddd5fa1e82baece58fbea027e3f5ff3321e5ecdec8c0a38" } } | +| `Account/get_account_alias` | ✅ | account alias: { "context": "0x05cc3451e5a525ff21f0a62ffe5e5c184fa4cd790bbaead4ef44ab8dc914ecee", "alias": "0xb032abb7574e4f2fa7895896586f14b4189312afd59a6a006564ae9787594834" } | | `Account/create_account_proof` | ⏭️ | | -| `Account/get_legacy_accounts` | ✅ | legacy accounts: { "accounts": [ { "publicKey": "0xf41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef13", "name": "pgherveou.06" } ] } | -| `Account/get_user_id` | ✅ | user id: { "primaryUsername": "pgherveou.06" } | +| `Account/get_legacy_accounts` | ✅ | legacy accounts: { "accounts": [ { "publicKey": "0xfca633ac856b1e3eaddd5fa1e82baece58fbea027e3f5ff3321e5ecdec8c0a38", "name": "guestnrun.93" } ] } | +| `Account/get_user_id` | ✅ | user id: { "primaryUsername": "guestnrun.93" } | | `Account/request_login` | ✅ | login completed: AlreadyConnected | -| `Chain/follow_head_subscribe` | ✅ | head follow event: { "tag": "Initialized", "value": { "finalizedBlockHashes": [ "0x250744308f9f54705da08b8ed1360756a60c4c4d5ed0dd028f6d3ffaef34697c", "0x167e135c78af237da48362dce50efdd1a9362c0248250935890f060b9ac2572e", "0xb772c6963007376a073bd79bbb0ede819a97ab3605a26975df75be98116ffb6f", "0xc0cb... | -| `Chain/get_head_header` | ✅ | block header: { "header": "0x4a65540ed66cc5b8a4023520d76a617c1cf95ce8ddb9ccd86890e7c26a45dd0616d05a0000dc38d0b9c4aad4dd573ababe1457db2ee8455f4a4107cbaed510a67027050538cc0edf23a8f6548e4d80afe3f1f2daa9f3dd50b732f86a8cca75bf5fb512331006434d4c531001000104066175726120479adb0800000000045250535290f2cf90... | +| `Chain/follow_head_subscribe` | ✅ | head follow event: { "tag": "Initialized", "value": { "finalizedBlockHashes": [ "0x576535cc60a3538ced094ff3e0cd40e69441918f0baf501b18465e333d82d25e", "0x6ae85468b9062598632eef6ac0b6748b0ffbf2f1a764b50870e7627d111e27e2", "0x5b49c32ec0f0f7aa493c09e742850421b0b2e4069be256c8c27d1dfb687413cf", "0xaeb7... | +| `Chain/get_head_header` | ✅ | block header: { "header": "0x1fb8cfd495dcc15812e72a6d16c9c1803c54bd9bdb179198ee49202840bb73b332db5a009a4e087e9aff10872c94b0841cc799411c3e545102217aa65db6b86980a056879b56d1cdf162436c88f29877570c007b5064af3ff4414380f167cf4af705b9ac1006434d4c53100100010406617572612050a0db080000000004525053529076047e... | | `Chain/get_head_body` | ✅ | block body: { "operation": { "tag": "Started", "value": { "operationId": "0" } } } | | `Chain/get_head_storage` | ✅ | storage value: { "operation": { "tag": "Started", "value": { "operationId": "0" } } } | | `Chain/call_head` | ✅ | runtime call result: { "operation": { "tag": "Started", "value": { "operationId": "0" } } } | @@ -20,7 +20,7 @@ | `Chain/get_spec_genesis_hash` | ✅ | genesis hash: { "genesisHash": "0xbf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f" } | | `Chain/get_spec_chain_name` | ✅ | chain name: { "chainName": "Paseo Asset Hub Next" } | | `Chain/get_spec_properties` | ✅ | chain properties: { "properties": "{\"tokenDecimals\":10,\"tokenSymbol\":\"PAS\"}" } | -| `Chain/broadcast_transaction` | ✅ | transaction broadcast: { "operationId": "78Z1IqDRkuXpP8rO" } | +| `Chain/broadcast_transaction` | ✅ | transaction broadcast: { "operationId": "FCGwt6VNmp37UdU1" } | | `Chain/stop_transaction` | ❌ | stopTransaction failed: { "error": { "tag": "HostFailure", "value": { "reason": "remote_chain_transaction_stop: User error: Invalid operation id (-32602)" } } } | | `Chat/create_room` | ⏭️ | | | `Chat/register_bot` | ⏭️ | | @@ -37,7 +37,7 @@ | `Coin Payment/deposit` | ⏭️ | | | `Coin Payment/refund` | ⏭️ | | | `Coin Payment/listen_for_payment` | ⏭️ | | -| `Entropy/derive` | ✅ | entropy derived: { "entropy": "0x9ad6f1f6ac64687863a3456a7ddcb06a94c0b9a950930bb8eea3b51743f70baa" } | +| `Entropy/derive` | ✅ | entropy derived: { "entropy": "0x38bb56cd04aed300dfb618c03016c9f1ec8304617d32b54d30bbbcf27528ede1" } | | `Local Storage/read` | ✅ | storage value read: | | `Local Storage/write` | ✅ | storage write succeeded | | `Local Storage/clear` | ✅ | storage clear succeeded | @@ -52,16 +52,16 @@ | `Preimage/lookup_subscribe` | ✅ | preimage lookup received: { "value": "0xdeadbeef" } | | `Preimage/submit` | ✅ | preimage submitted: 0xf3e925002fed7cc0ded46842569eb5c90c910c091d8d04a1bdf96e0db719fd91 | | `Resource Allocation/request` | ✅ | resource allocation result: { "outcomes": [ "NotAvailable", "NotAvailable" ] } | -| `Signing/create_transaction` | ✅ | transaction created: { "transaction": "0xd9018400f41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef1301c8264dd11330720ff8232378c87186794c5e577abdd74d74cd967367f623e30e1e14165e7cdec32a7f9b566faf06e026fa7d71cf6f510062094f284293daf08d00000000000000000000000000000000000000" } | -| `Signing/create_transaction_with_legacy_account` | ✅ | selected legacy account: { "publicKey": "0xf41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef13", "name": "pgherveou.06" } transaction created: { "transaction": "0xd9018400f41df7581a7a4bdf59ab2497f3d86dd8ea14db35c5c1930790d5a3554d0bef1301c2a7dfb92f096c1bfc1d680f234ae203e68352dd9e79dff... | -| `Signing/sign_raw_with_legacy_account` | ✅ | raw bytes signed: { "signature": "0xd291fcd096acd68f0fdbdada30ac91bc58bd7960e9328ccb96b251effc991c7518cabe8c9559a33426b2da0a6beec409980bdfb91502a2fa84ee889624236681" } | -| `Signing/sign_payload_with_legacy_account` | ✅ | payload signed: { "signature": "0x70636930d6fbdeff4587ea2500def78fefe75195ad0e5ec09e8e945fa82af22b24b986ed01a0de3c2b270d9bdf623e1abb53836b491163925b71ffc9205d628e" } | -| `Signing/sign_raw` | ✅ | raw bytes signed: { "signature": "0xfcc96443f18f6e8b1340fdfaa3baa5d33f8f983014a9ce602ede6e7a769067569f32514e95a481bbcc5a0a5c1c02a175458596bd436fea50c5c9d870e963ba8a" } | -| `Signing/sign_payload` | ✅ | payload signed: { "signature": "0x625e911b25ca6471b5aa4b917012ccbad10a54347142e78a31930901749480457a7fee72d0b9a8ab666b948de6db9b094a09a4f0508e9c666689c0a8a5e7d189" } | -| `Statement Store/subscribe` | ✅ | submitting statement: { "expiry": "7659654034420137984n", "topics": [ "0xd61590958f674d52bad9c60348459ce813540d1a5108266f803d3083530dafaf" ], "proof": { "tag": "Sr25519", "value": { "signature": "0x0a3feb7cebe294484fbe3beda1a7474cec09cbc1f08dfbe31f353b2497cfb9184adae85612ab6c0ae5e1e30c76142d36ec8... | -| `Statement Store/create_proof` | ✅ | proof created: { "proof": { "tag": "Sr25519", "value": { "signature": "0x2299d6c886a51b79f8de93e15269f5dc5601f0a379ce19243e8366db93e98b46f1a3caed8cf04265752d086a684afb50c7a1348af31e91f0cbb73246db485186", "signer": "0xd6af71ea43ef71caea4afb37d908333411a79aad6f2559864296606d47d47a52" } } } | +| `Signing/create_transaction` | ✅ | transaction created: { "transaction": "0xd9018400fca633ac856b1e3eaddd5fa1e82baece58fbea027e3f5ff3321e5ecdec8c0a38010234909e12d9c3c4522b9527d1d8315082409a42f46c748e2523cb2a45d805709c266ec8367aed7f6976e1275404039f256427fb5b8962fbb11f8b21c601778500000000000000000000000000000000000000" } | +| `Signing/create_transaction_with_legacy_account` | ✅ | selected legacy account: { "publicKey": "0xfca633ac856b1e3eaddd5fa1e82baece58fbea027e3f5ff3321e5ecdec8c0a38", "name": "guestnrun.93" } transaction created: { "transaction": "0xd9018400fca633ac856b1e3eaddd5fa1e82baece58fbea027e3f5ff3321e5ecdec8c0a38010e8284365affca179e95690f753ef80d0b245930ce29edc... | +| `Signing/sign_raw_with_legacy_account` | ✅ | raw bytes signed: { "signature": "0x00ac3a33bda4ac91500717f1a71c5529462e04c2d07411b96b79c064b764c3644febadac7e373973798a03fb95d9e96eb5262017ddc991b7ff6badd77e25a48b" } | +| `Signing/sign_payload_with_legacy_account` | ✅ | payload signed: { "signature": "0xec1558e930e22555fd4e6635f8ee2d91be02303ec0fd2713e2c6568bc67c1c6e2e55784da9893ed467b739ce3f38cd45b5fe0995f189833dfa02b738ecdc278a" } | +| `Signing/sign_raw` | ✅ | raw bytes signed: { "signature": "0xc6f96f3e49630290fb2141d8bc157b9bc1b6734ec90cca99bbbabdaf74eb42704e7449c13830c0afe28f7e395f7bd4b3019002653bfa437fdc5c99cae98a5988" } | +| `Signing/sign_payload` | ✅ | payload signed: { "signature": "0x2a7000ea648925fe611012ab0eda862a313ecce9e478b9ea2087143722979e27efd5e59b8a619f0fccbe77c15280f2e3e710bf745607b72fd945b70855fb0b8f" } | +| `Statement Store/subscribe` | ✅ | submitting statement: { "expiry": "7659733435480539136n", "topics": [ "0xb00a99642d2f4b7d2928e8d71d6a1147112be506053c72653ad710bd708a1ac5" ], "proof": { "tag": "Sr25519", "value": { "signature": "0x18201b3dbae5222f50035bf25161f3e4f58c68b3a7904d352b26fa303fa4c54163980a13581bb5e7499c353aaa0d35fdc1b... | +| `Statement Store/create_proof` | ✅ | proof created: { "proof": { "tag": "Sr25519", "value": { "signature": "0xa864c5c0bd2fe22dcb871df0e52cd612a611d60f416894670153a39fd714aa786915a4f551780f193c215baaec4969ac1978c24ff7b848e639240c118da38587", "signer": "0xd6dc1d8edb8088d7b489c7cffe4062fbedc5e90b43e40a576b5212b4a6b6ca1d" } } } | | `Statement Store/submit` | ✅ | statement submitted | -| `Statement Store/create_proof_authorized` | ✅ | proof created: { "proof": { "tag": "Sr25519", "value": { "signature": "0xd861edf3ff18bdb04c0f6d76add5429014afa964521740147044ce7ac2d9d3369446a8c888b4efef32269dac45ad6d7bf07e34f278037ddb7ee2f3bd60304786", "signer": "0xd6af71ea43ef71caea4afb37d908333411a79aad6f2559864296606d47d47a52" } } } | +| `Statement Store/create_proof_authorized` | ✅ | proof created: { "proof": { "tag": "Sr25519", "value": { "signature": "0x4017df05ca60010b0d7656b925f5c6e397dfd8cf720a0ce92d1d38aa452cab169a970430c5fba68a1321f750b0eb138fdb256e0e4c06576dd2acee398165e387", "signer": "0xd6dc1d8edb8088d7b489c7cffe4062fbedc5e90b43e40a576b5212b4a6b6ca1d" } } } | | `System/handshake` | ✅ | handshake succeeded | | `System/feature_supported` | ✅ | feature supported: true | | `System/navigate_to` | ✅ | navigation succeeded | diff --git a/rust/crates/truapi-host-cli/Cargo.toml b/rust/crates/truapi-host-cli/Cargo.toml index e5f72947..ba7e7eff 100644 --- a/rust/crates/truapi-host-cli/Cargo.toml +++ b/rust/crates/truapi-host-cli/Cargo.toml @@ -31,7 +31,7 @@ sp-crypto-hashing = "0.1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } rustls = { version = "0.23", default-features = false, features = ["ring"] } serde_json = "1" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "net", "time", "signal"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "io-std", "net", "time", "signal", "process"] } tokio-stream = { version = "0.1", features = ["sync"] } tokio-tungstenite = { version = "0.24", features = ["connect", "rustls-tls-webpki-roots"] } # TLS is compiled in so the pairing host *can* reach real `wss://` chain nodes diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index da8786fc..27013120 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -3,24 +3,97 @@ Headless TrUAPI hosts for local end-to-end testing, built on `truapi-server`. They replace the external signing-bot service: two CLI processes take the two host-spec §B roles and pair over the **real People-chain statement store** (the -same node an iOS/web client uses), so the playground's own tests can run against -a real signer with no Novasama-operated dependency. +same node an iOS/web client uses), so tests run against a real signer with no +Novasama-operated dependency. -One binary, `truapi-host`, with these roles: +The pairing host is driven by a **product script** you write: a JS/TS file that +receives a global `truapi` (the `@parity/truapi` client, scoped to a product id) +and calls it like any product would. The CLI runs the script and exits with its +status, so `truapi-host pairing-host --script foo.ts` *is* the test — there is no +separate bun orchestrator. + +One binary, `truapi-host`: | Command | Role | | --- | --- | -| `pairing-host` | Seedless host: presents a pairing deeplink, serves product frames over WebSocket. | -| `signing-host` | Wallet-local host: answers a pairing deeplink, registers statement allowance on-chain, auto-signs (the signing-bot replacement). | +| `pairing-host` | Seedless host: serves product frames and runs your `--script` with `truapi` injected. | +| `signing-host` | Wallet-local host: answers a pairing deeplink, registers statement allowance on-chain, signs. | | `identity-check` | Probe which derivation of a mnemonic carries a registered username. | | `alloc-check` | Diagnose (or `--submit`) on-chain statement-store allowance: ring membership, chosen slot, and the `set_statement_store_account` extrinsic. | -The signing host reuses the `truapi-server` signing-host runtime and its SSO -responder (`runtime/signing_host/sso_responder.rs`); the pairing host is the -existing `PairingHostRuntime`. Both connect to the People-chain statement store -over a native WebSocket `JsonRpcConnection`, and the pairing host bridges product -byte-frames to the runtime over a second WebSocket (one binary message per SCALE -`ProtocolMessage`, matching the browser transport). +## Quick start + +```bash +make headless # build the CLI + JS client (once) +rust/crates/truapi-host-cli/e2e/run.sh # run js/scripts/battery.ts end-to-end +rust/crates/truapi-host-cli/e2e/run.sh path/to/my-script.ts # or a custom script +``` + +`run.sh` starts a pairing host running the product script, hands the emitted +pairing deeplink to a signing host, and exits with the script's status. It uses +the dev mnemonic by default (a registered LitePeople member); override with +`SIGNER_MNEMONIC=...`, the product with `PRODUCT_ID=...`, and the port with +`FRAME=...`. + +## Writing a product script + +A product script is an ES module. The runner injects two globals before it runs: + +- **`truapi`** — the `@parity/truapi` client connected to the pairing host and + scoped to the host's `--product-id`. Call `truapi.account.requestLogin(...)`, + `truapi.signing.signRaw(...)`, `truapi.localStorage.write(...)`, etc. +- **`host`** — helpers: `host.productId`, `host.productAccount(index?)`, + `host.log(...)` (stderr), `host.assert(cond, msg)`. + +Export a default function to receive the `host` context; throw (or reject) to +fail the run. Minimal example: + +```ts +export default async function (host) { + const login = await truapi.account.requestLogin({ reason: undefined }); + host.assert(login.isOk() && login.value === "Success", "login failed"); + + const res = await truapi.signing.signRaw({ + account: host.productAccount(), + payload: { tag: "Bytes", value: { bytes: "0xdeadbeef" } }, + }); + res.match( + (v) => host.log("signature", v.signature), + (e) => { throw new Error(JSON.stringify(e)); }, + ); +} +``` + +`--product-id` (a `.dot` name or `localhost` identifier; default +`headless-playground.dot`) scopes product-owned APIs like `truapi.localStorage.*` +and the accounts `host.productAccount()` returns. + +Two scripts ship under `js/scripts/`: + +- `battery.ts` — the curated signer gate (login + raw/payload signing, + create-transaction, entropy). This is `run.sh`'s default. +- `diagnosis.ts` — runs the playground's own generated example sources + (`runExample`) and writes a `web.md`-shape report to + `explorer/diagnosis-reports/headless.md`, gating on the signer-critical + methods. The generated examples are baked to the `truapi-playground.dot` + product, so run it with that product id: + + ```bash + PRODUCT_ID=truapi-playground.dot E2E_LIVE_CHAIN=1 \ + rust/crates/truapi-host-cli/e2e/run.sh rust/crates/truapi-host-cli/js/scripts/diagnosis.ts + ``` + + With a live chain, this is **43 passed, 1 failed, 20 skipped**; the lone + failure is `Chain/stop_transaction` (the example sends a deliberately-invalid + operation id the real RPC node rejects, which the browser host's smoldot + tolerates). It needs the playground's deps (`cd playground && bun install`). + +## Confirmations + +Both hosts take `--auto-accept`. Without it, every confirmation a web/iOS host +would show as a modal (sign requests, permission prompts) is printed on the CLI +and answered `y/n` on stdin. `run.sh` passes `--auto-accept` to both for +unattended runs. ## Statement-store allowance @@ -31,100 +104,46 @@ General (v5) `Resources.set_statement_store_account` extrinsic for each account that submits statements — its own `//wallet//sso` account and the pairing host's per-pairing device key. The port lives in `src/alloc/` (metadata-driven signed-extension encoding, ring fetch, slot scan, ring-VRF proof, extrinsic -assembly, submit). The signing account must be an attested LitePeople member; -`alloc-check` verifies this and can submit a test registration. - -## End-to-end test +assembly, submit). The signing account must be an attested LitePeople member, +and may sit in an old ring, so the signing host scans back from the current ring +index (slow, one-time per pairing). `alloc-check` verifies membership and can +submit a test registration. -`e2e/run-e2e.ts` boots a pairing host, and (once login begins) a signing host -that registers allowance on-chain, then drives the real `@parity/truapi` client -against the pairing host over the real statement store. Two modes: - -- default: a curated battery of signer-backed methods (login, get account, raw - and payload signing, transaction construction, entropy) — a deterministic - gate, 7/7. -- `E2E_DIAGNOSIS=1`: runs the playground's own generated example sources through - the playground's `runExample`, i.e. literally the playground diagnosis. Gated - on the signer-critical methods; Asset Hub `Chain/*` methods and deferred - features are reported but not gated unless `E2E_LIVE_CHAIN=1` routes them to a - real node. +## Manual use (two terminals) ```bash -# One-time JS setup (generated client + built package + playground deps): -./scripts/codegen.sh -( cd js/packages/truapi && bun install && bunx tsc -b ) -( cd playground && bun install ) - -# Run it: -bash rust/crates/truapi-host-cli/e2e/run.sh # curated battery -E2E_DIAGNOSIS=1 bash rust/crates/truapi-host-cli/e2e/run.sh # full diagnosis -``` - -## Manual use - -```bash -cargo build -p truapi-host-cli +make headless BIN=target/debug/truapi-host -# Both hosts default to the real People-chain statement store -# (wss://paseo-people-next-system-rpc.polkadot.io); override with --statement-store. -$BIN pairing-host --frame-listen 127.0.0.1:9955 & -# A product connects to ws://127.0.0.1:9955 and calls account.requestLogin; -# the pairing host prints `PAIRING_DEEPLINK `. Hand it to the signer, -# which registers on-chain allowance then answers the handshake: -$BIN signing-host --deeplink '' +# Terminal 1 — pairing host runs a product script and prints PAIRING_DEEPLINK: +$BIN pairing-host --product-id myapp.dot --script js/scripts/battery.ts --auto-accept + +# Terminal 2 — hand the deeplink to a signing host (registers allowance, signs): +$BIN signing-host --deeplink '' --auto-accept # Inspect on-chain statement-store allowance for a mnemonic: $BIN alloc-check --lookback 100 # ring membership + free slot (read-only) ``` -Signing is auto-approved via the platform's `UserConfirmation`; pass -`--reject` to the signing host to refuse every sensitive action (negative -tests). - -## Playground diagnosis coverage - -`E2E_DIAGNOSIS=1` writes `explorer/diagnosis-reports/headless.md` in the same -table shape as `web.md` (the dotli browser host), for a direct diff. Run with -`E2E_LIVE_CHAIN=1` to route `Chain/*` to real paseo-next-v2 nodes: - -```bash -E2E_LIVE_CHAIN=1 E2E_DIAGNOSIS=1 bun rust/crates/truapi-host-cli/e2e/run-e2e.ts -``` - -With live chain on, the diagnosis is **43 passed, 1 failed, 20 skipped**. The -headless stack matches the browser host on every method it passes and also -passes 3 the browser host fails (`Signing/sign_raw_with_legacy_account`, -`Signing/sign_payload_with_legacy_account`, -`Statement Store/create_proof_authorized`). The one remaining failure is -environmental, not a host defect: - -- `Chain/stop_transaction` — the example passes a hardcoded bogus `operationId`; - the real RPC node rejects it (`-32602`), whereas the browser host's smoldot - tolerates unknown ids. +Both hosts default `--statement-store` to the real People chain +(`wss://paseo-people-next-system-rpc.polkadot.io`); override with +`--statement-store`. ## Scope / gaps - **Chain methods** route to real `wss://` nodes when `E2E_LIVE_CHAIN=1` - (`src/chain.rs`, `PASEO_NEXT_V2_CHAIN_ENDPOINTS`); off by default so the - curated battery stays hermetic and network-free. A rustls crypto provider is - installed at startup for the TLS connections. + (`src/chain.rs`, `PASEO_NEXT_V2_CHAIN_ENDPOINTS`); off by default. A rustls + crypto provider is installed at startup for the TLS connections. - **Ring-VRF product-account aliases** are implemented natively via the `verifiable` crate (`get_account_alias`); on wasm they remain `Unavailable`. - **`get_user_id`** resolves the signing account's username from People-chain - `Resources.Consumers`. Since SSO and identity both run over the real People - chain, the username always resolves; the signing host presents its - `//wallet//sso` account as its statement identity. `truapi-host signing-host - --username ` registers a fresh lite username via the identity backend - (`src/attestation.rs`); first registration is backend-async and can take - minutes (ring onboarding), so the e2e uses an account that is already - registered. `truapi-host identity-check --mnemonic ` probes which - derivation carries a username. -- **Statement-store allowance** is registered on-chain before pairing (see - above). The signing account must be an attested LitePeople ring member; it may - sit in an old ring, so the signing host scans back from the current ring index - to find it (slow, but one-time per pairing). `set_statement_store_account` - resource-allocation over SSO is still reported `NotAvailable`. + `Resources.Consumers`. `truapi-host signing-host --username ` registers a + fresh lite username via the identity backend (`src/attestation.rs`); first + registration is backend-async and can take minutes (ring onboarding), so the + e2e uses an already-registered account. `truapi-host identity-check --mnemonic + ` probes which derivation carries a username. +- `set_statement_store_account` resource-allocation over SSO is still reported + `NotAvailable`. - Everything else the browser host exercises passes: signing (raw, payload, create-transaction, and their legacy variants), statement store, entropy, aliases, preimage, storage, permissions, notifications, theme, system, chain diff --git a/rust/crates/truapi-host-cli/e2e/run-e2e.ts b/rust/crates/truapi-host-cli/e2e/run-e2e.ts deleted file mode 100644 index aaeb4acf..00000000 --- a/rust/crates/truapi-host-cli/e2e/run-e2e.ts +++ /dev/null @@ -1,269 +0,0 @@ -// End-to-end orchestrator: pairing host + signing host + product driver. -// -// Boots a headless pairing host (frame server) pointed at the real People-chain -// statement store, connects the real @parity/truapi client, starts login, hands -// the pairing deeplink to a headless signing host (which registers statement -// allowance on-chain), and once paired runs the signing battery. Prints a -// pass/fail summary and exits non-zero on any failure. -import { resolve } from "node:path"; -import { readFileSync } from "node:fs"; -import { beginLogin, connect, runBattery, type CaseResult } from "./driver.ts"; -import { runDiagnosis, type DiagnosisRow } from "./diagnosis.ts"; - -// Load `e2e/.env` (gitignored) so a registered signer mnemonic can be kept -// out of the command line. Existing environment variables win. -function loadDotenv() { - try { - for (const line of readFileSync(resolve(import.meta.dir, ".env"), "utf8").split("\n")) { - const match = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); - if (!match || line.trimStart().startsWith("#")) continue; - const key = match[1]; - const value = match[2].replace(/^["']|["']$/g, ""); - if (process.env[key] === undefined) process.env[key] = value; - } - } catch { - /* no .env: fall back to the process environment */ - } -} -loadDotenv(); - -// When set, run the playground's own generated example sources (the literal -// playground diagnosis) instead of the curated battery. -const USE_DIAGNOSIS = process.env.E2E_DIAGNOSIS === "1"; - -// Signer-critical, chain-node-independent methods that must pass in diagnosis -// mode. Everything else in the full diagnosis either needs a live chain node -// (all Chain/*, live-chain transaction assembly) or is a deferred feature. -const MUST_PASS = new Set([ - "account.requestLogin", - "Account/request_login", - "Account/connection_status_subscribe", - "Account/get_account", - "Account/get_legacy_accounts", - "Signing/sign_raw", - "Signing/sign_payload", - "Signing/sign_raw_with_legacy_account", - "Signing/sign_payload_with_legacy_account", - "Resource Allocation/request", - "Statement Store/create_proof", - "Statement Store/create_proof_authorized", - "Statement Store/submit", - "Entropy/derive", -]); - -const REPO_ROOT = resolve(import.meta.dir, "../../../.."); -const BINARY = resolve(REPO_ROOT, "target/debug/truapi-host"); - -/** A spawned host process whose stdout lines can be awaited by prefix. */ -class HostProcess { - private readonly lines: string[] = []; - private readonly waiters: Array<{ prefix: string; resolve: (line: string) => void }> = []; - readonly proc: ReturnType; - - constructor(label: string, args: string[]) { - this.proc = Bun.spawn([BINARY, ...args], { - stdout: "pipe", - stderr: "pipe", - env: { ...process.env, RUST_LOG: process.env.RUST_LOG ?? "info" }, - }); - this.pump(label, this.proc.stdout, false); - this.pump(label, this.proc.stderr, true); - } - - private async pump(label: string, stream: ReadableStream, isErr: boolean) { - const decoder = new TextDecoder(); - let buffer = ""; - for await (const chunk of stream) { - buffer += decoder.decode(chunk, { stream: true }); - let index: number; - while ((index = buffer.indexOf("\n")) >= 0) { - const line = buffer.slice(0, index); - buffer = buffer.slice(index + 1); - if (isErr) { - if (process.env.E2E_VERBOSE) console.error(`[${label}:err] ${line}`); - continue; - } - console.error(`[${label}] ${line}`); - this.lines.push(line); - for (let i = this.waiters.length - 1; i >= 0; i--) { - if (line.startsWith(this.waiters[i].prefix)) { - this.waiters.splice(i, 1)[0].resolve(line); - } - } - } - } - } - - waitFor(prefix: string, timeoutMs = 30_000): Promise { - const existing = this.lines.find((line) => line.startsWith(prefix)); - if (existing) return Promise.resolve(existing); - return new Promise((resolvePromise, rejectPromise) => { - const timer = setTimeout( - () => rejectPromise(new Error(`timed out waiting for "${prefix}"`)), - timeoutMs, - ); - this.waiters.push({ - prefix, - resolve: (line) => { - clearTimeout(timer); - resolvePromise(line); - }, - }); - }); - } - - kill() { - this.proc.kill(); - } -} - -function wsUrlFrom(line: string, prefix: string): string { - return line.slice(prefix.length).trim(); -} - -async function main() { - if (!(await Bun.file(BINARY).exists())) { - console.error(`missing binary ${BINARY}; run: cargo build -p truapi-host-cli`); - process.exit(2); - } - - const processes: HostProcess[] = []; - const cleanup = () => processes.forEach((p) => p.kill()); - - try { - // The hosts pair over the real People-chain statement store (the CLI - // default), so no local relay is started. - const pairing = new HostProcess("pairing", [ - "pairing-host", - "--frame-listen", - "127.0.0.1:0", - ]); - processes.push(pairing); - const frameUrl = wsUrlFrom(await pairing.waitFor("FRAMES_LISTENING "), "FRAMES_LISTENING "); - - console.error(`connecting product client to ${frameUrl}`); - const { client, opened, dispose } = connect(frameUrl); - await opened; - - // Start login without awaiting: the pairing host emits the deeplink, which - // we hand to the signing host; login resolves once pairing completes. - const loginPromise = beginLogin(client); - - const deeplink = wsUrlFrom( - await pairing.waitFor("PAIRING_DEEPLINK "), - "PAIRING_DEEPLINK ", - ); - - // External-signer mode: hand the deeplink to a separate signing-host - // process (e.g. a second tmux pane) via a file, instead of spawning the - // signer here. Lets the two hosts run as visibly separate processes. - const handoffFile = process.env.E2E_HANDOFF_FILE; - if (handoffFile) { - await Bun.write(handoffFile, `${deeplink}\n`); - console.error(`wrote deeplink handoff to ${handoffFile}; waiting for external signer`); - } else { - console.error(`launching signing host for deeplink ${deeplink.slice(0, 48)}...`); - const mnemonic = process.env.E2E_SIGNER_MNEMONIC; - const signing = new HostProcess("signing", [ - "signing-host", - "--deeplink", - deeplink, - ...(mnemonic ? ["--mnemonic", mnemonic] : []), - ]); - processes.push(signing); - // The signing host registers on-chain statement allowance (ring scan + - // two extrinsics) before it is ready, which can take a couple of minutes. - await signing.waitFor("SIGNING_HOST_READY", 240_000); - } - - const login = await loginPromise; - const loginOk = login.isOk() && login.value === "Success"; - console.error(`login result: ${login.isOk() ? login.value : JSON.stringify(login.error)}`); - if (!loginOk) throw new Error("pairing/login did not succeed"); - - if (USE_DIAGNOSIS) { - const rows = await runDiagnosis(client); - dispose(); - const report = renderReport(rows); - const path = resolve(REPO_ROOT, "explorer/diagnosis-reports/headless.md"); - await Bun.write(path, report); - // Print the full web.md-shape table so the pane carries the same detail. - console.log("\n" + report); - const pass = rows.filter((r) => r.status === "pass").length; - const fail = rows.filter((r) => r.status === "fail").length; - const skip = rows.filter((r) => r.status === "skipped").length; - console.log(`\nwrote ${path}`); - console.log(`${pass} passed, ${fail} failed, ${skip} skipped (of ${rows.length})`); - cleanup(); - // Gate on the signer path: chain-node methods and deferred features - // (ring-VRF alias, live-chain transaction assembly) may fail when - // Asset Hub routing is off; those are environmental, not signing - // regressions. - const critical = rows.filter((r) => MUST_PASS.has(r.id) && r.status === "fail"); - if (critical.length > 0) { - console.log(`\nGATE FAILED: ${critical.map((r) => r.id).join(", ")}`); - } else { - console.log(`\nGATE PASSED: all signer-critical methods pass`); - } - process.exit(critical.length === 0 ? 0 : 1); - } - - const results: CaseResult[] = [ - { name: "account.requestLogin", ok: true, detail: String(login.value) }, - ...(await runBattery(client)), - ]; - dispose(); - printSummary(results); - cleanup(); - const failures = results.filter((r) => !r.ok); - console.log( - failures.length > 0 - ? `\nGATE FAILED: ${failures.map((r) => r.name).join(", ")}` - : `\nGATE PASSED: ${results.length} signer-critical cases`, - ); - process.exit(failures.length === 0 ? 0 : 1); - } catch (error) { - console.error(`e2e failed: ${String(error)}`); - cleanup(); - process.exit(1); - } -} - -/** Collapse whitespace runs so multi-line JSON fits one table cell. */ -function cleanDetail(output: string): string { - const collapsed = output.replace(/\s+/g, " ").trim(); - return collapsed.length > 300 ? collapsed.slice(0, 297) + "..." : collapsed; -} - -// Emit a report in the same table shape as explorer/diagnosis-reports/web.md -// so the headless run can be diffed against the browser host directly. -// Details carry the captured example output for passes and the error for -// failures; skipped rows are blank. -function renderReport(rows: DiagnosisRow[]): string { - const icon = (s: DiagnosisRow["status"]) => - s === "pass" ? "✅" : s === "skipped" ? "⏭️" : "❌"; - return ( - [ - "## Truapi Headless Pairing Host Diagnosis", - "", - "| Method | Status | Details |", - "| --- | --- | --- |", - ...rows.map( - (r) => - `| \`${r.id}\` | ${icon(r.status)} | ${r.status === "skipped" ? "" : cleanDetail(r.output)} |`, - ), - ].join("\n") + "\n" - ); -} - -function printSummary(results: CaseResult[]) { - const pass = results.filter((r) => r.ok).length; - console.log("\n=== Headless host e2e results ==="); - for (const r of results) { - console.log(`${r.ok ? "PASS" : "FAIL"} ${r.name.padEnd(28)} ${r.detail}`); - } - console.log(`--------------------------------`); - console.log(`${pass}/${results.length} passed`); -} - -main(); diff --git a/rust/crates/truapi-host-cli/e2e/run.sh b/rust/crates/truapi-host-cli/e2e/run.sh index 78db0a29..afe3b77f 100755 --- a/rust/crates/truapi-host-cli/e2e/run.sh +++ b/rust/crates/truapi-host-cli/e2e/run.sh @@ -1,17 +1,52 @@ #!/usr/bin/env bash -# Build the headless hosts and run the end-to-end pairing + signing test. +# Headless end-to-end run: a pairing host drives a product script against a +# signing host, pairing over the real People-chain statement store. # -# run.sh curated signer battery (deterministic gate) -# E2E_DIAGNOSIS=1 run.sh full playground diagnosis (gated on signer methods) +# make headless # build once +# e2e/run.sh # runs js/scripts/battery.ts (default) +# e2e/run.sh path/to/script.ts # runs a custom product script # -# Prerequisites for the JS driver (one-time): -# ./scripts/codegen.sh (or generate js/packages/truapi/src/generated) -# (cd js/packages/truapi && bun install && bunx tsc -b) -# (cd playground && bun install) +# Env: +# PRODUCT_ID product id the pairing host serves (default headless-playground) +# SIGNER_MNEMONIC wallet mnemonic for the signing host (default: dev mnemonic) +# FRAME frame-server address (default 127.0.0.1:9955) set -euo pipefail ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)" -cd "$ROOT" +BIN="$ROOT/target/debug/truapi-host" +SCRIPT="${1:-$ROOT/rust/crates/truapi-host-cli/js/scripts/battery.ts}" +PRODUCT_ID="${PRODUCT_ID:-headless-playground.dot}" +FRAME="${FRAME:-127.0.0.1:9955}" -cargo build -p truapi-host-cli -exec bun rust/crates/truapi-host-cli/e2e/run-e2e.ts +[ -x "$BIN" ] || { echo "missing $BIN — run: make headless" >&2; exit 2; } + +LOG="$(mktemp)" +SIGNER_PID="" +cleanup() { + [ -n "$SIGNER_PID" ] && kill "$SIGNER_PID" 2>/dev/null || true + rm -f "$LOG" +} +trap cleanup EXIT + +# The pairing host runs the product script; the script's +# `truapi.account.requestLogin` makes the host emit a pairing deeplink, which we +# hand to a signing host. The pairing host exits with the script's status. +"$BIN" pairing-host --product-id "$PRODUCT_ID" --script "$SCRIPT" \ + --frame-listen "$FRAME" --auto-accept 2>&1 | tee "$LOG" & +PAIR_PID=$! + +deeplink="" +for _ in $(seq 1 600); do + deeplink="$(grep -m1 -oE 'PAIRING_DEEPLINK .+' "$LOG" | cut -d' ' -f2- || true)" + [ -n "$deeplink" ] && break + kill -0 "$PAIR_PID" 2>/dev/null || break + sleep 0.5 +done +[ -n "$deeplink" ] || { echo "pairing host did not emit a deeplink" >&2; exit 1; } + +signer_args=(signing-host --deeplink "$deeplink" --auto-accept) +[ -n "${SIGNER_MNEMONIC:-}" ] && signer_args+=(--mnemonic "$SIGNER_MNEMONIC") +"$BIN" "${signer_args[@]}" & +SIGNER_PID=$! + +wait "$PAIR_PID" diff --git a/rust/crates/truapi-host-cli/e2e/wait-and-sign.sh b/rust/crates/truapi-host-cli/e2e/wait-and-sign.sh deleted file mode 100755 index 5944b650..00000000 --- a/rust/crates/truapi-host-cli/e2e/wait-and-sign.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -# Signing-host side of the two-pane demo: wait for the pairing host (run by -# the orchestrator in E2E_HANDOFF_FILE mode) to emit the relay URL + deeplink, -# then answer the handshake and serve the SSO session. -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)" -cd "$ROOT" - -HANDOFF="${1:-/tmp/truapi-e2e-handoff.txt}" -BIN="target/debug/truapi-host" - -echo "SIGNING PANE: waiting for pairing host to present a deeplink ($HANDOFF)..." -while [ ! -s "$HANDOFF" ]; do sleep 0.2; done - -{ read -r RELAY; read -r DEEPLINK; } < "$HANDOFF" -echo "SIGNING PANE: got deeplink, answering pairing on $RELAY" -echo -exec "$BIN" signing-host --relay "$RELAY" --deeplink "$DEEPLINK" diff --git a/rust/crates/truapi-host-cli/e2e/diagnosis.ts b/rust/crates/truapi-host-cli/js/diagnosis.ts similarity index 100% rename from rust/crates/truapi-host-cli/e2e/diagnosis.ts rename to rust/crates/truapi-host-cli/js/diagnosis.ts diff --git a/rust/crates/truapi-host-cli/js/runner.ts b/rust/crates/truapi-host-cli/js/runner.ts new file mode 100644 index 00000000..0ffeafc0 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/runner.ts @@ -0,0 +1,93 @@ +// Host-script runner: the Rust CLI spawns this to drive a headless host from a +// user-provided JavaScript/TypeScript file. +// +// The pairing host serves the product frame protocol on a WebSocket; this +// runner connects the real `@parity/truapi` client to it, injects it as the +// global `truapi` (scoped to the host's product id), and evaluates the user +// script. The script is the product: it calls `truapi.account.requestLogin()`, +// `truapi.signing.*`, `truapi.localStorage.*`, etc. A thrown error or rejected +// promise exits non-zero, so `truapi-host pairing-host --script …` is the test. +// +// Env (set by the Rust CLI): +// TRUAPI_FRAME_URL ws:// URL of the pairing host's frame server +// TRUAPI_PRODUCT_ID product id the host serves (scopes storage etc.) +// TRUAPI_SCRIPT absolute path to the user script +import { pathToFileURL } from "node:url"; +import { + createClient, + createTransport, + type ProductAccountId, + type TrUApiClient, +} from "../../../../js/packages/truapi/src/index.ts"; +import { wsProvider } from "./ws-provider.ts"; + +/** Helpers injected alongside `truapi` for scripts to use. */ +export interface HostContext { + /** The product id this host serves. */ + productId: string; + /** A product account id for `derivationIndex` (default 0) under this product. */ + productAccount(index?: number): ProductAccountId; + /** Log to stderr (keeps stdout clean for machine-readable host output). */ + log(...args: unknown[]): void; + /** Throw `message` unless `condition` holds. */ + assert(condition: unknown, message: string): asserts condition; +} + +declare global { + // eslint-disable-next-line no-var + var truapi: TrUApiClient; + // eslint-disable-next-line no-var + var host: HostContext; +} + +const OPEN_TIMEOUT_MS = 15_000; + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`${name} must be set`); + return value; +} + +async function main() { + const frameUrl = requireEnv("TRUAPI_FRAME_URL"); + const productId = requireEnv("TRUAPI_PRODUCT_ID"); + const scriptPath = requireEnv("TRUAPI_SCRIPT"); + + const provider = wsProvider(frameUrl); + const client = createClient(createTransport(provider)); + + const context: HostContext = { + productId, + productAccount: (index = 0) => ({ dotNsIdentifier: productId, derivationIndex: index }), + log: (...args) => console.error("[script]", ...args), + assert: (condition, message) => { + if (!condition) throw new Error(`assertion failed: ${message}`); + }, + }; + globalThis.truapi = client; + globalThis.host = context; + + const timer = setTimeout(() => { + console.error(`[runner] timed out connecting to ${frameUrl}`); + process.exit(2); + }, OPEN_TIMEOUT_MS); + await provider.opened; + clearTimeout(timer); + + try { + const module = await import(pathToFileURL(scriptPath).href); + if (typeof module.default === "function") { + await module.default(context); + } + } finally { + provider.dispose(); + } +} + +main().then( + () => process.exit(0), + (error) => { + console.error(`[script error] ${error instanceof Error ? error.stack : String(error)}`); + process.exit(1); + }, +); diff --git a/rust/crates/truapi-host-cli/e2e/driver.ts b/rust/crates/truapi-host-cli/js/scripts/battery.ts similarity index 53% rename from rust/crates/truapi-host-cli/e2e/driver.ts rename to rust/crates/truapi-host-cli/js/scripts/battery.ts index 10b8f1c4..87d2ff6f 100644 --- a/rust/crates/truapi-host-cli/e2e/driver.ts +++ b/rust/crates/truapi-host-cli/js/scripts/battery.ts @@ -1,66 +1,46 @@ -// Product-side test battery run against a headless pairing host. +// Curated signer battery, as a product script for the pairing host. // -// Constructs the real @parity/truapi client over a WebSocket to the pairing -// host's frame endpoint and drives the SSO-class methods the playground -// diagnosis exercises against the signer: login, account lookup, raw signing, -// payload signing, transaction construction, and product entropy. Each method -// is one pass/fail case, mirroring how the playground diagnosis reports. -import { - createClient, - createTransport, - type TrUApiClient, -} from "../../../../js/packages/truapi/src/index.ts"; -import { wsProvider } from "./ws-provider.ts"; +// Run via: truapi-host pairing-host --product-id

--script js/scripts/battery.ts +// The runner injects `truapi` (the @parity/truapi client, scoped to the product) +// and `host` (helpers). Logs in with the paired signing host, exercises the +// signer-backed methods the playground diagnosis covers, and throws on any +// failure so the host command exits non-zero. +import type { HostContext } from "../runner.ts"; -export interface CaseResult { +const GENESIS_HASH = `0x${"11".repeat(32)}` as const; + +interface Case { name: string; ok: boolean; detail: string; } -const PRODUCT_ID = "truapi-playground.dot"; -const GENESIS_HASH = `0x${"11".repeat(32)}` as const; - -function productAccount(index = 0) { - return { dotNsIdentifier: PRODUCT_ID, derivationIndex: index }; -} - -/** Connect a client to the pairing host and return it plus the open signal. */ -export function connect(frameUrl: string): { - client: TrUApiClient; - opened: Promise; - dispose: () => void; -} { - const provider = wsProvider(frameUrl); - const transport = createTransport(provider); - const client = createClient(transport); - return { client, opened: provider.opened, dispose: () => provider.dispose() }; -} +export default async function run(host: HostContext) { + const account = host.productAccount(); + const results: Case[] = []; -/** Begin login; resolves when the host reports the pairing outcome. */ -export function beginLogin(client: TrUApiClient) { - return client.account.requestLogin({ reason: undefined }); -} - -/** Run the signing battery against an already-paired client. */ -export async function runBattery(client: TrUApiClient): Promise { - const results: CaseResult[] = []; - - const record = async ( - name: string, - run: () => Promise<{ ok: boolean; detail: string }>, - ) => { + const record = async (name: string, fn: () => Promise<{ ok: boolean; detail: string }>) => { try { - results.push({ name, ...(await run()) }); + results.push({ name, ...(await fn()) }); } catch (error) { results.push({ name, ok: false, detail: `threw: ${String(error)}` }); } }; + const login = await truapi.account.requestLogin({ reason: undefined }); + const loginOk = login.isOk() && login.value === "Success"; + results.push({ + name: "account.requestLogin", + ok: loginOk, + detail: login.isOk() ? String(login.value) : JSON.stringify(login.error), + }); + if (!loginOk) { + report(results); + throw new Error("login did not succeed"); + } + await record("account.getAccount", async () => { - const result = await client.account.getAccount({ - productAccountId: productAccount(), - }); + const result = await truapi.account.getAccount({ productAccountId: account }); return result.match( (value) => ({ ok: value.account.publicKey.startsWith("0x") && value.account.publicKey.length > 4, @@ -71,8 +51,8 @@ export async function runBattery(client: TrUApiClient): Promise { }); await record("signing.signRaw(bytes)", async () => { - const result = await client.signing.signRaw({ - account: productAccount(), + const result = await truapi.signing.signRaw({ + account, payload: { tag: "Bytes", value: { bytes: "0xdeadbeef" } }, }); return result.match( @@ -85,9 +65,9 @@ export async function runBattery(client: TrUApiClient): Promise { }); await record("signing.signRaw(message)", async () => { - const result = await client.signing.signRaw({ - account: productAccount(), - payload: { tag: "Payload", value: { payload: "hello from e2e" } }, + const result = await truapi.signing.signRaw({ + account, + payload: { tag: "Payload", value: { payload: "hello from the headless battery" } }, }); return result.match( (value) => ({ ok: value.signature.startsWith("0x"), detail: value.signature.slice(0, 18) }), @@ -96,8 +76,8 @@ export async function runBattery(client: TrUApiClient): Promise { }); await record("signing.signPayload", async () => { - const result = await client.signing.signPayload({ - account: productAccount(), + const result = await truapi.signing.signPayload({ + account, payload: { blockHash: GENESIS_HASH, blockNumber: "0x01", @@ -123,8 +103,8 @@ export async function runBattery(client: TrUApiClient): Promise { }); await record("signing.createTransaction", async () => { - const result = await client.signing.createTransaction({ - signer: productAccount(), + const result = await truapi.signing.createTransaction({ + signer: account, genesisHash: GENESIS_HASH, callData: "0x0000", extensions: [{ id: "CheckNonce", extra: "0x04", additionalSigned: "0x" }], @@ -140,12 +120,26 @@ export async function runBattery(client: TrUApiClient): Promise { }); await record("entropy.derive", async () => { - const result = await client.entropy.derive({ context: "0x6d792d6b6579" }); + const result = await truapi.entropy.derive({ context: "0x6d792d6b6579" }); return result.match( (value) => ({ ok: value.entropy.startsWith("0x"), detail: value.entropy.slice(0, 18) }), (error) => ({ ok: false, detail: JSON.stringify(error) }), ); }); - return results; + report(results); + const failures = results.filter((r) => !r.ok); + if (failures.length > 0) { + throw new Error(`GATE FAILED: ${failures.map((r) => r.name).join(", ")}`); + } + host.log(`GATE PASSED: ${results.length} signer-critical cases`); +} + +function report(results: Case[]) { + console.log("\n=== Headless host signer battery ==="); + for (const r of results) { + console.log(`${r.ok ? "PASS" : "FAIL"} ${r.name.padEnd(28)} ${r.detail}`); + } + const pass = results.filter((r) => r.ok).length; + console.log(`--------------------------------\n${pass}/${results.length} passed`); } diff --git a/rust/crates/truapi-host-cli/js/scripts/diagnosis.ts b/rust/crates/truapi-host-cli/js/scripts/diagnosis.ts new file mode 100644 index 00000000..4c470fa9 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/scripts/diagnosis.ts @@ -0,0 +1,80 @@ +// Full playground diagnosis, as a product script for the pairing host. +// +// Run via: truapi-host pairing-host --product-id truapi-playground.dot --script js/scripts/diagnosis.ts +// The generated example sources hardcode the `truapi-playground.dot` product, so +// the pairing host must serve that product id (else signing methods fail with +// PermissionDenied). Logs in, runs the examples against the paired signing host, +// writes a web.md-shape report to explorer/diagnosis-reports/headless.md, and +// gates on the signer-critical methods (chain-node methods and deferred features +// are reported, not gated, unless a live chain node is routed in). +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import type { HostContext } from "../runner.ts"; +import { runDiagnosis, type DiagnosisRow } from "../diagnosis.ts"; + +// Signer-critical, chain-node-independent methods that must pass. +const MUST_PASS = new Set([ + "Account/request_login", + "Account/connection_status_subscribe", + "Account/get_account", + "Account/get_legacy_accounts", + "Signing/sign_raw", + "Signing/sign_payload", + "Signing/sign_raw_with_legacy_account", + "Signing/sign_payload_with_legacy_account", + "Resource Allocation/request", + "Statement Store/create_proof", + "Statement Store/create_proof_authorized", + "Statement Store/submit", + "Entropy/derive", +]); + +const REPORT_PATH = fileURLToPath( + new URL("../../../../../explorer/diagnosis-reports/headless.md", import.meta.url), +); + +export default async function run(host: HostContext) { + const login = await truapi.account.requestLogin({ reason: undefined }); + if (!login.isOk() || login.value !== "Success") { + throw new Error(`login failed: ${login.isOk() ? login.value : JSON.stringify(login.error)}`); + } + + const rows = await runDiagnosis(truapi); + const report = renderReport(rows); + writeFileSync(REPORT_PATH, report); + + console.log("\n" + report); + const pass = rows.filter((r) => r.status === "pass").length; + const fail = rows.filter((r) => r.status === "fail").length; + const skip = rows.filter((r) => r.status === "skipped").length; + console.log(`\nwrote ${REPORT_PATH}`); + console.log(`${pass} passed, ${fail} failed, ${skip} skipped (of ${rows.length})`); + + const critical = rows.filter((r) => MUST_PASS.has(r.id) && r.status === "fail"); + if (critical.length > 0) { + throw new Error(`GATE FAILED: ${critical.map((r) => r.id).join(", ")}`); + } + host.log("GATE PASSED: all signer-critical methods pass"); +} + +// web.md-shape table so the headless run can be diffed against the browser host. +function renderReport(rows: DiagnosisRow[]): string { + const icon = (s: DiagnosisRow["status"]) => + s === "pass" ? "✅" : s === "skipped" ? "⏭️" : "❌"; + return ( + [ + "## Truapi Headless Pairing Host Diagnosis", + "", + "| Method | Status | Details |", + "| --- | --- | --- |", + ...rows.map( + (r) => `| \`${r.id}\` | ${icon(r.status)} | ${r.status === "skipped" ? "" : cleanDetail(r.output)} |`, + ), + ].join("\n") + "\n" + ); +} + +function cleanDetail(output: string): string { + const collapsed = output.replace(/\s+/g, " ").trim(); + return collapsed.length > 300 ? collapsed.slice(0, 297) + "..." : collapsed; +} diff --git a/rust/crates/truapi-host-cli/e2e/ws-provider.ts b/rust/crates/truapi-host-cli/js/ws-provider.ts similarity index 100% rename from rust/crates/truapi-host-cli/e2e/ws-provider.ts rename to rust/crates/truapi-host-cli/js/ws-provider.ts diff --git a/rust/crates/truapi-host-cli/src/frame_server.rs b/rust/crates/truapi-host-cli/src/frame_server.rs index eb346e1c..15a130ac 100644 --- a/rust/crates/truapi-host-cli/src/frame_server.rs +++ b/rust/crates/truapi-host-cli/src/frame_server.rs @@ -28,39 +28,37 @@ impl FrameSink for WsFrameSink { } } -/// Serve product frames for `product_id` on `addr` until the process exits. +/// Bind the product-frame listener on `addr`. +pub async fn bind(addr: SocketAddr) -> Result { + TcpListener::bind(addr) + .await + .with_context(|| format!("frame server failed to bind {addr}")) +} + +/// Accept product-frame connections on `listener` for `product_id` until +/// cancelled. /// /// The product dispatch future is `!Send` (matching the single-threaded wasm -/// runtime), so connections are driven with `spawn_local` under a `LocalSet`. -/// The runtime's own subscription work is `Send` and still runs on the -/// multi-thread pool via the tokio spawner. -pub async fn serve( +/// runtime), so connections are driven with `spawn_local`; callers must run +/// this inside a `tokio::task::LocalSet`. The runtime's own subscription work +/// is `Send` and still runs on the multi-thread pool via the tokio spawner. +pub async fn accept_loop( runtime: Arc, product_id: String, - addr: SocketAddr, + listener: TcpListener, ) -> Result<()> { - let listener = TcpListener::bind(addr) - .await - .with_context(|| format!("frame server failed to bind {addr}"))?; let bound = listener.local_addr()?; info!(%bound, %product_id, "product frame server listening"); - println!("FRAMES_LISTENING ws://{bound}"); - - let local = tokio::task::LocalSet::new(); - local - .run_until(async move { - loop { - let (stream, peer) = listener.accept().await?; - let runtime = runtime.clone(); - let product_id = product_id.clone(); - tokio::task::spawn_local(async move { - if let Err(err) = serve_connection(runtime, product_id, stream).await { - debug!(%peer, %err, "frame connection ended"); - } - }); + loop { + let (stream, peer) = listener.accept().await?; + let runtime = runtime.clone(); + let product_id = product_id.clone(); + tokio::task::spawn_local(async move { + if let Err(err) = serve_connection(runtime, product_id, stream).await { + debug!(%peer, %err, "frame connection ended"); } - }) - .await + }); + } } async fn serve_connection( diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 5bcacea3..f1c9050e 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -14,9 +14,11 @@ mod attestation; mod chain; mod frame_server; mod platform; +mod script_runner; use std::io::BufRead; use std::net::SocketAddr; +use std::path::PathBuf; use std::sync::Arc; use anyhow::{Context, Result, bail}; @@ -31,8 +33,9 @@ use crate::platform::{ApprovalPolicy, CliPlatform}; /// Default dev mnemonic used when a signing host is started without one. const DEFAULT_MNEMONIC: &str = "bottom drive obey lake curtain smoke basket hold race lonely fit walk"; -/// Default product served by the pairing host's frame endpoint. -const DEFAULT_PRODUCT_ID: &str = "truapi-playground.dot"; +/// Default product served by the pairing host's frame endpoint. Product ids +/// must be a `.dot` name or a `localhost` identifier (host-spec product id). +const DEFAULT_PRODUCT_ID: &str = "headless-playground.dot"; /// Deeplink scheme advertised by the pairing host. const DEEPLINK_SCHEME: &str = "polkadotapp"; /// paseo-next-v2 identity backend base (includes /api/v1). @@ -72,32 +75,46 @@ struct Cli { #[derive(Subcommand)] enum Command { - /// Run a seedless pairing host and serve product frames over WebSocket. + /// Run a seedless pairing host and drive a product script against it. + /// + /// Starts the product-frame server, then runs `--script` with a global + /// `truapi` injected (the `@parity/truapi` client, scoped to `--product-id`). + /// The host command exits with the script's exit status. PairingHost { - /// Statement-store WebSocket URL (the real People chain by default). - #[arg(long = "statement-store", default_value = PEOPLE_CHAIN_WS)] - statement_store: String, + /// Product script to run (JS/TS). Receives the global `truapi`. + #[arg(long)] + script: PathBuf, + /// Product id the host serves; scopes storage and product accounts. + #[arg(long = "product-id", default_value = DEFAULT_PRODUCT_ID)] + product_id: String, /// Address to serve product frames on. #[arg(long, default_value = "127.0.0.1:9955")] frame_listen: SocketAddr, - /// Product id presented to product frame connections. - #[arg(long, default_value = DEFAULT_PRODUCT_ID)] - product: String, - }, - /// Answer a pairing deeplink as a wallet-local signing host and auto-sign. - SigningHost { /// Statement-store WebSocket URL (the real People chain by default). #[arg(long = "statement-store", default_value = PEOPLE_CHAIN_WS)] statement_store: String, - /// BIP-39 mnemonic for the wallet root. - #[arg(long, default_value = DEFAULT_MNEMONIC)] - mnemonic: String, + /// Approve every confirmation without prompting on the CLI. + #[arg(long)] + auto_accept: bool, + }, + /// Answer a pairing deeplink as a wallet-local signing host and sign. + /// + /// Registers statement allowance on-chain, answers the deeplink, and serves + /// the SSO session. Confirmations are prompted on the CLI unless + /// `--auto-accept` is set. + SigningHost { /// Pairing deeplink to answer. Read from stdin when omitted. #[arg(long)] deeplink: Option, - /// Reject every sensitive action instead of auto-approving. + /// BIP-39 mnemonic for the wallet root. + #[arg(long, default_value = DEFAULT_MNEMONIC)] + mnemonic: String, + /// Statement-store WebSocket URL (the real People chain by default). + #[arg(long = "statement-store", default_value = PEOPLE_CHAIN_WS)] + statement_store: String, + /// Approve every confirmation without prompting on the CLI. #[arg(long)] - reject: bool, + auto_accept: bool, /// Register this lite username base (6+ lowercase letters) on the /// People chain via the identity backend before pairing, so /// `get_user_id` resolves. Requires network access. @@ -152,17 +169,28 @@ async fn main() -> Result<()> { match Cli::parse().command { Command::PairingHost { - statement_store, + script, + product_id, frame_listen, - product, - } => run_pairing_host(statement_store, frame_listen, product).await, - Command::SigningHost { statement_store, - mnemonic, + auto_accept, + } => { + run_pairing_host( + script, + product_id, + frame_listen, + statement_store, + auto_accept, + ) + .await + } + Command::SigningHost { deeplink, - reject, + mnemonic, + statement_store, + auto_accept, username, - } => run_signing_host(statement_store, mnemonic, deeplink, reject, username).await, + } => run_signing_host(deeplink, mnemonic, statement_store, auto_accept, username).await, Command::IdentityCheck { mnemonic, people_ws, @@ -282,6 +310,16 @@ async fn run_alloc_check( Ok(()) } +/// Map the `--auto-accept` flag to an approval policy: auto-accept, or prompt +/// each confirmation on the CLI. +fn approval_policy(auto_accept: bool) -> ApprovalPolicy { + if auto_accept { + ApprovalPolicy::AutoAccept + } else { + ApprovalPolicy::Prompt + } +} + /// Spawner that runs runtime futures on the tokio runtime, so their WebSocket /// connects and timers have a reactor. fn tokio_spawner() -> Spawner { @@ -306,11 +344,13 @@ fn platform_info() -> PlatformInfo { } async fn run_pairing_host( - statement_store: String, + script: PathBuf, + product_id: String, frame_listen: SocketAddr, - product: String, + statement_store: String, + auto_accept: bool, ) -> Result<()> { - let platform = CliPlatform::new(statement_store, ApprovalPolicy::Always); + let platform = CliPlatform::new(statement_store, approval_policy(auto_accept)); // SSO and identity both run over the real People chain, so usernames always // resolve from `Resources.Consumers` (host-spec G). let config = PairingHostConfig::new( @@ -322,14 +362,36 @@ async fn run_pairing_host( .context("invalid pairing host config")? .with_identity_chain_genesis_hash(PEOPLE_CHAIN_GENESIS); let runtime = Arc::new(PairingHostRuntime::new(platform, config, tokio_spawner())); - frame_server::serve(runtime, product, frame_listen).await + + // Bind the frame server, then drive the product script against it; the + // command exits with the script's status. The frame accept loop is `!Send`, + // so it runs on a LocalSet alongside the (Send) script subprocess. + let listener = frame_server::bind(frame_listen).await?; + let frame_url = format!("ws://{}", listener.local_addr()?); + println!("FRAMES_LISTENING {frame_url}"); + + let local = tokio::task::LocalSet::new(); + let status = local + .run_until(async move { + let server = tokio::task::spawn_local(frame_server::accept_loop( + runtime, + product_id.clone(), + listener, + )); + let status = script_runner::run(&frame_url, &product_id, &script).await; + server.abort(); + status + }) + .await?; + + std::process::exit(status.code().unwrap_or(1)); } async fn run_signing_host( - statement_store: String, - mnemonic: String, deeplink: Option, - reject: bool, + mnemonic: String, + statement_store: String, + auto_accept: bool, username: Option, ) -> Result<()> { let entropy = bip39::Mnemonic::parse(mnemonic.trim()) @@ -353,18 +415,13 @@ async fn run_signing_host( None => read_deeplink_from_stdin()?, }; - let approval = if reject { - ApprovalPolicy::Never - } else { - ApprovalPolicy::Always - }; // Grant statement-store allowance to the accounts that submit statements // over the real store: our own `//wallet//sso` and the pairing host's // device key. A real client does this on-chain; without it the store // rejects the handshake with `NoAllowance`. register_pairing_allowances(&statement_store, &entropy, &deeplink).await?; - let platform = CliPlatform::new(statement_store, approval); + let platform = CliPlatform::new(statement_store, approval_policy(auto_accept)); let config = SigningHostConfig::new( host_info("Headless Signing Host"), platform_info(), diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs index 5dafa4ca..0a6f289a 100644 --- a/rust/crates/truapi-host-cli/src/platform.rs +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -1,9 +1,10 @@ //! `Platform` implementation for the headless hosts. //! //! In-memory product and core storage, a WebSocket chain provider pointed at -//! the real People-chain statement store, and an auto-approving -//! [`UserConfirmation`]. Auth-state transitions are published on a channel so -//! the CLI can print the pairing deeplink and observe connection status. +//! the real People-chain statement store, and a [`UserConfirmation`] that +//! either auto-accepts or prompts on the CLI (the web/iOS "sign?" modal). +//! Auth-state transitions are published on a channel so the CLI can print the +//! pairing deeplink and observe connection status. use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -11,6 +12,8 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use blake2_rfc::blake2b::blake2b; use futures::stream::{self, BoxStream}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::Mutex as AsyncMutex; use truapi::v01; use truapi_platform::{ AuthState, ChainProvider, CoreStorage, CoreStorageKey, Features, JsonRpcConnection, Navigation, @@ -20,19 +23,13 @@ use truapi_platform::{ use crate::chain::WsChainProvider; -/// How the host answers [`UserConfirmation`] prompts. +/// How the host answers confirmation prompts (the web/iOS "sign?" modals). #[derive(Clone, Copy)] pub enum ApprovalPolicy { - /// Approve every sensitive action (default for e2e). - Always, - /// Reject every sensitive action (for negative tests). - Never, -} - -impl ApprovalPolicy { - fn approves(self) -> bool { - matches!(self, ApprovalPolicy::Always) - } + /// Approve every sensitive action without prompting (`--auto-accept`). + AutoAccept, + /// Prompt on the CLI (y/n) for every sensitive action. + Prompt, } /// Headless-host platform shared by both roles. @@ -42,6 +39,9 @@ pub struct CliPlatform { core_storage: Mutex, Vec>>, preimages: Mutex, Vec>>, approval: ApprovalPolicy, + /// Serializes interactive CLI prompts so concurrent confirmations don't + /// interleave on stdin. + prompt_lock: AsyncMutex<()>, } impl CliPlatform { @@ -53,6 +53,7 @@ impl CliPlatform { core_storage: Mutex::new(HashMap::new()), preimages: Mutex::new(HashMap::new()), approval, + prompt_lock: AsyncMutex::new(()), }) } @@ -60,6 +61,40 @@ impl CliPlatform { use parity_scale_codec::Encode; key.encode() } + + /// Resolve a confirmation: auto-accept, or prompt y/n on the CLI. + async fn decide(&self, action: &str, detail: String) -> bool { + match self.approval { + ApprovalPolicy::AutoAccept => { + eprintln!("[auto-accept] {action}: {detail}"); + true + } + ApprovalPolicy::Prompt => { + let _guard = self.prompt_lock.lock().await; + prompt_yes_no(action, &detail).await + } + } + } +} + +/// Print a confirmation and read a y/n answer from the CLI (default: no). +async fn prompt_yes_no(action: &str, detail: &str) -> bool { + let mut stdout = tokio::io::stdout(); + let _ = stdout + .write_all( + format!( + "\n\u{2500}\u{2500} confirm: {action} \u{2500}\u{2500}\n{detail}\nApprove? [y/N] " + ) + .as_bytes(), + ) + .await; + let _ = stdout.flush().await; + let mut line = String::new(); + let mut reader = BufReader::new(tokio::io::stdin()); + if reader.read_line(&mut line).await.unwrap_or(0) == 0 { + return false; + } + matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes") } #[async_trait] @@ -161,20 +196,22 @@ impl Notifications for CliPlatform { impl Permissions for CliPlatform { async fn device_permission( &self, - _request: v01::HostDevicePermissionRequest, + request: v01::HostDevicePermissionRequest, ) -> Result { - Ok(v01::HostDevicePermissionResponse { - granted: self.approval.approves(), - }) + let granted = self + .decide("device permission", format!("{request:?}")) + .await; + Ok(v01::HostDevicePermissionResponse { granted }) } async fn remote_permission( &self, - _request: v01::RemotePermissionRequest, + request: v01::RemotePermissionRequest, ) -> Result { - Ok(v01::RemotePermissionResponse { - granted: self.approval.approves(), - }) + let granted = self + .decide("remote permission", format!("{request:?}")) + .await; + Ok(v01::RemotePermissionResponse { granted }) } } @@ -206,12 +243,7 @@ impl UserConfirmation for CliPlatform { &self, review: UserConfirmationReview, ) -> Result { - tracing::debug!( - ?review, - approved = self.approval.approves(), - "confirm_user_action" - ); - Ok(self.approval.approves()) + Ok(self.decide("sign request", format!("{review:?}")).await) } } diff --git a/rust/crates/truapi-host-cli/src/script_runner.rs b/rust/crates/truapi-host-cli/src/script_runner.rs new file mode 100644 index 00000000..58d96061 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/script_runner.rs @@ -0,0 +1,49 @@ +//! Runs a user host-script under `bun`, driving a host through the injected +//! `truapi` global. +//! +//! The Rust CLI owns the flow: it starts the host, then spawns `js/runner.ts` +//! (which connects the `@parity/truapi` client to the host and evaluates the +//! user script). The child's exit status becomes the host command's status, so +//! `truapi-host pairing-host --script foo.ts` *is* the test — there is no +//! separate bun orchestrator. + +use std::path::{Path, PathBuf}; +use std::process::ExitStatus; + +use anyhow::{Context, Result}; +use tokio::process::Command; + +/// Locate `js/runner.ts`, shipped alongside the crate. +/// +/// Overridable with `TRUAPI_HOST_RUNNER` for packaged/relocated builds. +fn runner_path() -> PathBuf { + if let Ok(path) = std::env::var("TRUAPI_HOST_RUNNER") { + return PathBuf::from(path); + } + Path::new(env!("CARGO_MANIFEST_DIR")).join("js/runner.ts") +} + +/// Run `script` against the host serving frames at `frame_url`, as product +/// `product_id`. Inherits stdio so the script's output and any CLI confirmation +/// prompts share the terminal. Returns the child's exit status. +pub async fn run(frame_url: &str, product_id: &str, script: &Path) -> Result { + let runner = runner_path(); + if !runner.exists() { + anyhow::bail!( + "host-script runner not found at {}; set TRUAPI_HOST_RUNNER", + runner.display() + ); + } + let script = script + .canonicalize() + .with_context(|| format!("script not found: {}", script.display()))?; + + Command::new("bun") + .arg(&runner) + .env("TRUAPI_FRAME_URL", frame_url) + .env("TRUAPI_PRODUCT_ID", product_id) + .env("TRUAPI_SCRIPT", &script) + .status() + .await + .context("failed to spawn `bun` for the host script (is bun installed?)") +} From 6f8d6424d944759964d9d620e8a65beec95b9aa3 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Mon, 6 Jul 2026 13:11:34 +0200 Subject: [PATCH 06/13] refactor(truapi-host-cli): slim the script API + finalize allowance Product scripts are now top-level code (no `export default` wrapper); the runner injects `truapi` and a minimal `host` (`productId` + `productAccount(i)` only, so accounts stay in sync with `--product-id`). Scripts use `console.log`/`throw` for everything else. `js/scripts/{battery,diagnosis}.ts` converted accordingly. The signing host reads its wallet mnemonic from `--mnemonic`, else the `HOST_CLI_SIGNER_MNEMONIC` env var (or a gitignored `e2e/.env`), else the dev mnemonic. Allowance registration now waits for the `set_statement_store_account` extrinsic to finalize, not just reach a block: the statement store does not honor a freshly -set allowance at `inBlock`, so the handshake submit raced it and failed with `NoAllowance`. Verified end-to-end against paseo-next-v2 (7/7 signer battery). --- rust/crates/truapi-host-cli/Cargo.toml | 2 +- rust/crates/truapi-host-cli/README.md | 50 ++-- rust/crates/truapi-host-cli/e2e/run.sh | 17 +- rust/crates/truapi-host-cli/js/runner.ts | 15 +- .../truapi-host-cli/js/scripts/battery.ts | 218 +++++++++--------- .../truapi-host-cli/js/scripts/diagnosis.ts | 46 ++-- rust/crates/truapi-host-cli/src/alloc/rpc.rs | 12 +- rust/crates/truapi-host-cli/src/main.rs | 6 +- 8 files changed, 186 insertions(+), 180 deletions(-) diff --git a/rust/crates/truapi-host-cli/Cargo.toml b/rust/crates/truapi-host-cli/Cargo.toml index ba7e7eff..886aca75 100644 --- a/rust/crates/truapi-host-cli/Cargo.toml +++ b/rust/crates/truapi-host-cli/Cargo.toml @@ -20,7 +20,7 @@ anyhow = "1" async-trait = "0.1" bip39 = "2" blake2-rfc = { version = "0.2", default-features = false } -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive", "env"] } frame-metadata = { version = "23", default-features = false, features = ["std", "current", "decode"] } futures = "0.3" futures-util = "0.3" diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index 27013120..bb101a37 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -30,38 +30,39 @@ rust/crates/truapi-host-cli/e2e/run.sh path/to/my-script.ts # or a custom scri ``` `run.sh` starts a pairing host running the product script, hands the emitted -pairing deeplink to a signing host, and exits with the script's status. It uses -the dev mnemonic by default (a registered LitePeople member); override with -`SIGNER_MNEMONIC=...`, the product with `PRODUCT_ID=...`, and the port with -`FRAME=...`. +pairing deeplink to a signing host, and exits with the script's status. The +signing host uses `HOST_CLI_SIGNER_MNEMONIC` (from the environment or a gitignored +`e2e/.env`) if set, otherwise the dev mnemonic — either must be a registered +LitePeople ring member. Override the product with `PRODUCT_ID=...` and the port +with `FRAME=...`. ## Writing a product script -A product script is an ES module. The runner injects two globals before it runs: +A product script is top-level code (an ES module). The runner injects two +globals before running it: - **`truapi`** — the `@parity/truapi` client connected to the pairing host and scoped to the host's `--product-id`. Call `truapi.account.requestLogin(...)`, `truapi.signing.signRaw(...)`, `truapi.localStorage.write(...)`, etc. -- **`host`** — helpers: `host.productId`, `host.productAccount(index?)`, - `host.log(...)` (stderr), `host.assert(cond, msg)`. +- **`host`** — just `host.productId` and `host.productAccount(index?)`. That is + all it does: it keeps product accounts in sync with the host's `--product-id` + (hardcoding a mismatched id fails signing with `PermissionDenied`). Use + `console.log` and `throw` for everything else. -Export a default function to receive the `host` context; throw (or reject) to -fail the run. Minimal example: +Write it top-level and `throw` (or reject) to fail the run: ```ts -export default async function (host) { - const login = await truapi.account.requestLogin({ reason: undefined }); - host.assert(login.isOk() && login.value === "Success", "login failed"); - - const res = await truapi.signing.signRaw({ - account: host.productAccount(), - payload: { tag: "Bytes", value: { bytes: "0xdeadbeef" } }, - }); - res.match( - (v) => host.log("signature", v.signature), - (e) => { throw new Error(JSON.stringify(e)); }, - ); -} +const login = await truapi.account.requestLogin({ reason: undefined }); +if (!login.isOk() || login.value !== "Success") throw new Error("login failed"); + +const res = await truapi.signing.signRaw({ + account: host.productAccount(), + payload: { tag: "Bytes", value: { bytes: "0xdeadbeef" } }, +}); +res.match( + (v) => console.log("signature", v.signature), + (e) => { throw new Error(JSON.stringify(e)); }, +); ``` `--product-id` (a `.dot` name or `localhost` identifier; default @@ -118,8 +119,11 @@ BIN=target/debug/truapi-host # Terminal 1 — pairing host runs a product script and prints PAIRING_DEEPLINK: $BIN pairing-host --product-id myapp.dot --script js/scripts/battery.ts --auto-accept -# Terminal 2 — hand the deeplink to a signing host (registers allowance, signs): +# Terminal 2 — hand the deeplink to a signing host (registers allowance, signs). +# The wallet mnemonic comes from --mnemonic, else $HOST_CLI_SIGNER_MNEMONIC, +# else the dev mnemonic; it must be a registered LitePeople ring member. $BIN signing-host --deeplink '' --auto-accept +HOST_CLI_SIGNER_MNEMONIC="spin battle …" $BIN signing-host --deeplink '' --auto-accept # Inspect on-chain statement-store allowance for a mnemonic: $BIN alloc-check --lookback 100 # ring membership + free slot (read-only) diff --git a/rust/crates/truapi-host-cli/e2e/run.sh b/rust/crates/truapi-host-cli/e2e/run.sh index afe3b77f..953b5c79 100755 --- a/rust/crates/truapi-host-cli/e2e/run.sh +++ b/rust/crates/truapi-host-cli/e2e/run.sh @@ -7,9 +7,9 @@ # e2e/run.sh path/to/script.ts # runs a custom product script # # Env: -# PRODUCT_ID product id the pairing host serves (default headless-playground) -# SIGNER_MNEMONIC wallet mnemonic for the signing host (default: dev mnemonic) -# FRAME frame-server address (default 127.0.0.1:9955) +# PRODUCT_ID product id the pairing host serves (default headless-playground.dot) +# HOST_CLI_SIGNER_MNEMONIC wallet mnemonic for the signing host (default: dev mnemonic) +# FRAME frame-server address (default 127.0.0.1:9955) set -euo pipefail ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)" @@ -18,6 +18,11 @@ SCRIPT="${1:-$ROOT/rust/crates/truapi-host-cli/js/scripts/battery.ts}" PRODUCT_ID="${PRODUCT_ID:-headless-playground.dot}" FRAME="${FRAME:-127.0.0.1:9955}" +# Load HOST_CLI_SIGNER_MNEMONIC (and any other vars) from a gitignored e2e/.env +# if present, so the signing host uses a registered account. +ENV_FILE="$(dirname "$0")/.env" +[ -f "$ENV_FILE" ] && { set -a; . "$ENV_FILE"; set +a; } + [ -x "$BIN" ] || { echo "missing $BIN — run: make headless" >&2; exit 2; } LOG="$(mktemp)" @@ -44,9 +49,9 @@ for _ in $(seq 1 600); do done [ -n "$deeplink" ] || { echo "pairing host did not emit a deeplink" >&2; exit 1; } -signer_args=(signing-host --deeplink "$deeplink" --auto-accept) -[ -n "${SIGNER_MNEMONIC:-}" ] && signer_args+=(--mnemonic "$SIGNER_MNEMONIC") -"$BIN" "${signer_args[@]}" & +# The signing host reads HOST_CLI_SIGNER_MNEMONIC from the env (else the dev +# mnemonic). It must be a registered LitePeople ring member for allowance. +"$BIN" signing-host --deeplink "$deeplink" --auto-accept & SIGNER_PID=$! wait "$PAIR_PID" diff --git a/rust/crates/truapi-host-cli/js/runner.ts b/rust/crates/truapi-host-cli/js/runner.ts index 0ffeafc0..4bcce35a 100644 --- a/rust/crates/truapi-host-cli/js/runner.ts +++ b/rust/crates/truapi-host-cli/js/runner.ts @@ -21,16 +21,15 @@ import { } from "../../../../js/packages/truapi/src/index.ts"; import { wsProvider } from "./ws-provider.ts"; -/** Helpers injected alongside `truapi` for scripts to use. */ +/// The host context injected alongside `truapi`. It only exposes what a script +/// can't get from `truapi` alone: the product id the host serves, so product +/// accounts stay in sync with `--product-id` (hardcoding a mismatched id fails +/// signing with `PermissionDenied`). Use `console.log` / `throw` for the rest. export interface HostContext { - /** The product id this host serves. */ + /** The product id this host serves (its `--product-id`). */ productId: string; /** A product account id for `derivationIndex` (default 0) under this product. */ productAccount(index?: number): ProductAccountId; - /** Log to stderr (keeps stdout clean for machine-readable host output). */ - log(...args: unknown[]): void; - /** Throw `message` unless `condition` holds. */ - assert(condition: unknown, message: string): asserts condition; } declare global { @@ -59,10 +58,6 @@ async function main() { const context: HostContext = { productId, productAccount: (index = 0) => ({ dotNsIdentifier: productId, derivationIndex: index }), - log: (...args) => console.error("[script]", ...args), - assert: (condition, message) => { - if (!condition) throw new Error(`assertion failed: ${message}`); - }, }; globalThis.truapi = client; globalThis.host = context; diff --git a/rust/crates/truapi-host-cli/js/scripts/battery.ts b/rust/crates/truapi-host-cli/js/scripts/battery.ts index 87d2ff6f..97d00903 100644 --- a/rust/crates/truapi-host-cli/js/scripts/battery.ts +++ b/rust/crates/truapi-host-cli/js/scripts/battery.ts @@ -1,13 +1,17 @@ +/// // Curated signer battery, as a product script for the pairing host. // // Run via: truapi-host pairing-host --product-id

--script js/scripts/battery.ts // The runner injects `truapi` (the @parity/truapi client, scoped to the product) -// and `host` (helpers). Logs in with the paired signing host, exercises the -// signer-backed methods the playground diagnosis covers, and throws on any -// failure so the host command exits non-zero. -import type { HostContext } from "../runner.ts"; +// and `host` (`productId`, `productAccount(i)`). This is top-level product code: +// it logs in with the paired signing host, exercises the signer-backed methods +// the playground diagnosis covers, and throws on any failure so the host command +// exits non-zero. + +export {}; // module marker so top-level `await` is allowed const GENESIS_HASH = `0x${"11".repeat(32)}` as const; +const account = host.productAccount(); interface Case { name: string; @@ -15,127 +19,123 @@ interface Case { detail: string; } -export default async function run(host: HostContext) { - const account = host.productAccount(); - const results: Case[] = []; - - const record = async (name: string, fn: () => Promise<{ ok: boolean; detail: string }>) => { - try { - results.push({ name, ...(await fn()) }); - } catch (error) { - results.push({ name, ok: false, detail: `threw: ${String(error)}` }); - } - }; +const results: Case[] = []; - const login = await truapi.account.requestLogin({ reason: undefined }); - const loginOk = login.isOk() && login.value === "Success"; - results.push({ - name: "account.requestLogin", - ok: loginOk, - detail: login.isOk() ? String(login.value) : JSON.stringify(login.error), - }); - if (!loginOk) { - report(results); - throw new Error("login did not succeed"); +async function record(name: string, fn: () => Promise<{ ok: boolean; detail: string }>) { + try { + results.push({ name, ...(await fn()) }); + } catch (error) { + results.push({ name, ok: false, detail: `threw: ${String(error)}` }); } +} - await record("account.getAccount", async () => { - const result = await truapi.account.getAccount({ productAccountId: account }); - return result.match( - (value) => ({ - ok: value.account.publicKey.startsWith("0x") && value.account.publicKey.length > 4, - detail: value.account.publicKey.slice(0, 18), - }), - (error) => ({ ok: false, detail: JSON.stringify(error) }), - ); - }); +const login = await truapi.account.requestLogin({ reason: undefined }); +results.push({ + name: "account.requestLogin", + ok: login.isOk() && login.value === "Success", + detail: login.isOk() ? String(login.value) : JSON.stringify(login.error), +}); +if (!(login.isOk() && login.value === "Success")) { + report(); + throw new Error("login did not succeed"); +} - await record("signing.signRaw(bytes)", async () => { - const result = await truapi.signing.signRaw({ - account, - payload: { tag: "Bytes", value: { bytes: "0xdeadbeef" } }, - }); - return result.match( - (value) => ({ - ok: value.signature.length === 130 || value.signature.length === 132, - detail: value.signature.slice(0, 18), - }), - (error) => ({ ok: false, detail: JSON.stringify(error) }), - ); - }); +await record("account.getAccount", async () => { + const result = await truapi.account.getAccount({ productAccountId: account }); + return result.match( + (value) => ({ + ok: value.account.publicKey.startsWith("0x") && value.account.publicKey.length > 4, + detail: value.account.publicKey.slice(0, 18), + }), + (error) => ({ ok: false, detail: JSON.stringify(error) }), + ); +}); - await record("signing.signRaw(message)", async () => { - const result = await truapi.signing.signRaw({ - account, - payload: { tag: "Payload", value: { payload: "hello from the headless battery" } }, - }); - return result.match( - (value) => ({ ok: value.signature.startsWith("0x"), detail: value.signature.slice(0, 18) }), - (error) => ({ ok: false, detail: JSON.stringify(error) }), - ); +await record("signing.signRaw(bytes)", async () => { + const result = await truapi.signing.signRaw({ + account, + payload: { tag: "Bytes", value: { bytes: "0xdeadbeef" } }, }); + return result.match( + (value) => ({ + ok: value.signature.length === 130 || value.signature.length === 132, + detail: value.signature.slice(0, 18), + }), + (error) => ({ ok: false, detail: JSON.stringify(error) }), + ); +}); - await record("signing.signPayload", async () => { - const result = await truapi.signing.signPayload({ - account, - payload: { - blockHash: GENESIS_HASH, - blockNumber: "0x01", - era: "0x00", - genesisHash: GENESIS_HASH, - method: "0x0400", - nonce: "0x00", - specVersion: "0x01000000", - tip: "0x00", - transactionVersion: "0x01000000", - signedExtensions: [], - version: 4, - assetId: undefined, - metadataHash: undefined, - mode: undefined, - withSignedTransaction: undefined, - }, - }); - return result.match( - (value) => ({ ok: value.signature.startsWith("0x"), detail: value.signature.slice(0, 18) }), - (error) => ({ ok: false, detail: JSON.stringify(error) }), - ); +await record("signing.signRaw(message)", async () => { + const result = await truapi.signing.signRaw({ + account, + payload: { tag: "Payload", value: { payload: "hello from the headless battery" } }, }); + return result.match( + (value) => ({ ok: value.signature.startsWith("0x"), detail: value.signature.slice(0, 18) }), + (error) => ({ ok: false, detail: JSON.stringify(error) }), + ); +}); - await record("signing.createTransaction", async () => { - const result = await truapi.signing.createTransaction({ - signer: account, +await record("signing.signPayload", async () => { + const result = await truapi.signing.signPayload({ + account, + payload: { + blockHash: GENESIS_HASH, + blockNumber: "0x01", + era: "0x00", genesisHash: GENESIS_HASH, - callData: "0x0000", - extensions: [{ id: "CheckNonce", extra: "0x04", additionalSigned: "0x" }], - txExtVersion: 0, - }); - return result.match( - (value) => ({ - ok: value.transaction.startsWith("0x") && value.transaction.length > 4, - detail: `${value.transaction.length} chars`, - }), - (error) => ({ ok: false, detail: JSON.stringify(error) }), - ); + method: "0x0400", + nonce: "0x00", + specVersion: "0x01000000", + tip: "0x00", + transactionVersion: "0x01000000", + signedExtensions: [], + version: 4, + assetId: undefined, + metadataHash: undefined, + mode: undefined, + withSignedTransaction: undefined, + }, }); + return result.match( + (value) => ({ ok: value.signature.startsWith("0x"), detail: value.signature.slice(0, 18) }), + (error) => ({ ok: false, detail: JSON.stringify(error) }), + ); +}); - await record("entropy.derive", async () => { - const result = await truapi.entropy.derive({ context: "0x6d792d6b6579" }); - return result.match( - (value) => ({ ok: value.entropy.startsWith("0x"), detail: value.entropy.slice(0, 18) }), - (error) => ({ ok: false, detail: JSON.stringify(error) }), - ); +await record("signing.createTransaction", async () => { + const result = await truapi.signing.createTransaction({ + signer: account, + genesisHash: GENESIS_HASH, + callData: "0x0000", + extensions: [{ id: "CheckNonce", extra: "0x04", additionalSigned: "0x" }], + txExtVersion: 0, }); + return result.match( + (value) => ({ + ok: value.transaction.startsWith("0x") && value.transaction.length > 4, + detail: `${value.transaction.length} chars`, + }), + (error) => ({ ok: false, detail: JSON.stringify(error) }), + ); +}); - report(results); - const failures = results.filter((r) => !r.ok); - if (failures.length > 0) { - throw new Error(`GATE FAILED: ${failures.map((r) => r.name).join(", ")}`); - } - host.log(`GATE PASSED: ${results.length} signer-critical cases`); +await record("entropy.derive", async () => { + const result = await truapi.entropy.derive({ context: "0x6d792d6b6579" }); + return result.match( + (value) => ({ ok: value.entropy.startsWith("0x"), detail: value.entropy.slice(0, 18) }), + (error) => ({ ok: false, detail: JSON.stringify(error) }), + ); +}); + +report(); +const failures = results.filter((r) => !r.ok); +if (failures.length > 0) { + throw new Error(`GATE FAILED: ${failures.map((r) => r.name).join(", ")}`); } +console.log(`GATE PASSED: ${results.length} signer-critical cases`); -function report(results: Case[]) { +function report() { console.log("\n=== Headless host signer battery ==="); for (const r of results) { console.log(`${r.ok ? "PASS" : "FAIL"} ${r.name.padEnd(28)} ${r.detail}`); diff --git a/rust/crates/truapi-host-cli/js/scripts/diagnosis.ts b/rust/crates/truapi-host-cli/js/scripts/diagnosis.ts index 4c470fa9..20ee45b1 100644 --- a/rust/crates/truapi-host-cli/js/scripts/diagnosis.ts +++ b/rust/crates/truapi-host-cli/js/scripts/diagnosis.ts @@ -1,15 +1,16 @@ +/// // Full playground diagnosis, as a product script for the pairing host. // // Run via: truapi-host pairing-host --product-id truapi-playground.dot --script js/scripts/diagnosis.ts // The generated example sources hardcode the `truapi-playground.dot` product, so // the pairing host must serve that product id (else signing methods fail with -// PermissionDenied). Logs in, runs the examples against the paired signing host, -// writes a web.md-shape report to explorer/diagnosis-reports/headless.md, and -// gates on the signer-critical methods (chain-node methods and deferred features -// are reported, not gated, unless a live chain node is routed in). +// PermissionDenied). Top-level product code: logs in, runs the examples against +// the paired signing host, writes a web.md-shape report to +// explorer/diagnosis-reports/headless.md, and gates on the signer-critical +// methods (chain-node methods and deferred features are reported, not gated, +// unless a live chain node is routed in). import { writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import type { HostContext } from "../runner.ts"; import { runDiagnosis, type DiagnosisRow } from "../diagnosis.ts"; // Signer-critical, chain-node-independent methods that must pass. @@ -33,29 +34,26 @@ const REPORT_PATH = fileURLToPath( new URL("../../../../../explorer/diagnosis-reports/headless.md", import.meta.url), ); -export default async function run(host: HostContext) { - const login = await truapi.account.requestLogin({ reason: undefined }); - if (!login.isOk() || login.value !== "Success") { - throw new Error(`login failed: ${login.isOk() ? login.value : JSON.stringify(login.error)}`); - } +const login = await truapi.account.requestLogin({ reason: undefined }); +if (!login.isOk() || login.value !== "Success") { + throw new Error(`login failed: ${login.isOk() ? login.value : JSON.stringify(login.error)}`); +} - const rows = await runDiagnosis(truapi); - const report = renderReport(rows); - writeFileSync(REPORT_PATH, report); +const rows = await runDiagnosis(truapi); +writeFileSync(REPORT_PATH, renderReport(rows)); - console.log("\n" + report); - const pass = rows.filter((r) => r.status === "pass").length; - const fail = rows.filter((r) => r.status === "fail").length; - const skip = rows.filter((r) => r.status === "skipped").length; - console.log(`\nwrote ${REPORT_PATH}`); - console.log(`${pass} passed, ${fail} failed, ${skip} skipped (of ${rows.length})`); +console.log("\n" + renderReport(rows)); +const pass = rows.filter((r) => r.status === "pass").length; +const fail = rows.filter((r) => r.status === "fail").length; +const skip = rows.filter((r) => r.status === "skipped").length; +console.log(`\nwrote ${REPORT_PATH}`); +console.log(`${pass} passed, ${fail} failed, ${skip} skipped (of ${rows.length})`); - const critical = rows.filter((r) => MUST_PASS.has(r.id) && r.status === "fail"); - if (critical.length > 0) { - throw new Error(`GATE FAILED: ${critical.map((r) => r.id).join(", ")}`); - } - host.log("GATE PASSED: all signer-critical methods pass"); +const critical = rows.filter((r) => MUST_PASS.has(r.id) && r.status === "fail"); +if (critical.length > 0) { + throw new Error(`GATE FAILED: ${critical.map((r) => r.id).join(", ")}`); } +console.log("GATE PASSED: all signer-critical methods pass"); // web.md-shape table so the headless run can be diffed against the browser host. function renderReport(rows: DiagnosisRow[]): string { diff --git a/rust/crates/truapi-host-cli/src/alloc/rpc.rs b/rust/crates/truapi-host-cli/src/alloc/rpc.rs index 7e32e675..2d481fce 100644 --- a/rust/crates/truapi-host-cli/src/alloc/rpc.rs +++ b/rust/crates/truapi-host-cli/src/alloc/rpc.rs @@ -133,12 +133,14 @@ enum ExtrinsicStatus { } /// Classify an `author_extrinsicUpdate` status value. +/// +/// Only `finalized` is terminal success: a freshly-set statement-store allowance +/// is not honored by the store until its `set_statement_store_account` extrinsic +/// is finalized, so returning at `inBlock` would race the handshake submit +/// (which then fails with `NoAllowance`). fn extrinsic_status(status: &Value) -> ExtrinsicStatus { - // Terminal-success statuses carry a block hash: {"inBlock": "0x…"} / {"finalized": "0x…"}. - for key in ["inBlock", "finalized"] { - if let Some(hash) = status.get(key).and_then(Value::as_str) { - return ExtrinsicStatus::InBlock(hash.to_string()); - } + if let Some(hash) = status.get("finalized").and_then(Value::as_str) { + return ExtrinsicStatus::InBlock(hash.to_string()); } for key in ["invalid", "dropped", "usurped", "finalityTimeout"] { if status.get(key).is_some() { diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index f1c9050e..3fbf329a 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -106,8 +106,10 @@ enum Command { /// Pairing deeplink to answer. Read from stdin when omitted. #[arg(long)] deeplink: Option, - /// BIP-39 mnemonic for the wallet root. - #[arg(long, default_value = DEFAULT_MNEMONIC)] + /// BIP-39 mnemonic for the wallet root. Defaults to the + /// `HOST_CLI_SIGNER_MNEMONIC` env var if set, otherwise the dev mnemonic. + /// Must be a registered LitePeople ring member for allowance to succeed. + #[arg(long, env = "HOST_CLI_SIGNER_MNEMONIC", default_value = DEFAULT_MNEMONIC)] mnemonic: String, /// Statement-store WebSocket URL (the real People chain by default). #[arg(long = "statement-store", default_value = PEOPLE_CHAIN_WS)] From 6441925a0dc91c52c48fdfc711a03e2d07a2b320 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Sun, 12 Jul 2026 09:41:27 +0200 Subject: [PATCH 07/13] fix: allocate headless signer resources Decode ring revisions correctly for Bulletin allowance claims. Add CLI-managed signer accounts, network presets, and script-capable host modes. Regenerate headless signing and pairing diagnosis reports with zero failures. --- Cargo.lock | 3 + .../diagnosis-reports/headless-pairing.md | 68 ++ .../diagnosis-reports/headless-signing.md | 68 ++ rust/crates/truapi-host-cli/Cargo.toml | 3 +- rust/crates/truapi-host-cli/README.md | 75 ++- rust/crates/truapi-host-cli/e2e/run.sh | 11 +- .../truapi-host-cli/js/scripts/diagnosis.ts | 16 +- .../js/scripts/preimage-smoke.ts | 31 + .../js/scripts/signing-smoke.ts | 29 + rust/crates/truapi-host-cli/src/accounts.rs | 417 ++++++++++++ rust/crates/truapi-host-cli/src/alloc/rpc.rs | 9 +- .../crates/truapi-host-cli/src/attestation.rs | 26 + rust/crates/truapi-host-cli/src/chain.rs | 33 +- .../truapi-host-cli/src/frame_server.rs | 24 +- rust/crates/truapi-host-cli/src/main.rs | 626 ++++++++++++------ rust/crates/truapi-host-cli/src/network.rs | 94 +++ rust/crates/truapi-host-cli/src/platform.rs | 165 ++++- .../crates/truapi-server/src/chain_runtime.rs | 13 + rust/crates/truapi-server/src/host_core.rs | 18 +- rust/crates/truapi-server/src/runtime.rs | 241 ++++++- .../truapi-server/src/runtime/bulletin_rpc.rs | 42 +- .../truapi-server/src/runtime/signing_host.rs | 166 ++++- .../runtime/signing_host/local_activation.rs | 19 +- .../src/runtime/signing_host/sso_responder.rs | 129 +++- .../src/runtime/statement_allowance.rs | 194 ++++++ .../runtime/statement_allowance/dynamic.rs | 36 +- .../runtime/statement_allowance/extension.rs | 48 ++ .../runtime/statement_allowance/extrinsic.rs | 62 ++ .../src/runtime/statement_allowance/ring.rs | 35 +- .../src/runtime/statement_allowance/slot.rs | 116 ++++ .../src/runtime/statement_store.rs | 2 +- 31 files changed, 2459 insertions(+), 360 deletions(-) create mode 100644 explorer/diagnosis-reports/headless-pairing.md create mode 100644 explorer/diagnosis-reports/headless-signing.md create mode 100644 rust/crates/truapi-host-cli/js/scripts/preimage-smoke.ts create mode 100644 rust/crates/truapi-host-cli/js/scripts/signing-smoke.ts create mode 100644 rust/crates/truapi-host-cli/src/accounts.rs create mode 100644 rust/crates/truapi-host-cli/src/network.rs diff --git a/Cargo.lock b/Cargo.lock index d331a88c..500204e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -514,6 +514,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ "bitcoin_hashes", + "rand 0.8.6", + "rand_core 0.6.4", "serde", "unicode-normalization", ] @@ -4389,6 +4391,7 @@ dependencies = [ "reqwest", "rustls", "scale-info", + "serde", "serde_json", "sp-crypto-hashing", "tokio", diff --git a/explorer/diagnosis-reports/headless-pairing.md b/explorer/diagnosis-reports/headless-pairing.md new file mode 100644 index 00000000..fb77bf8f --- /dev/null +++ b/explorer/diagnosis-reports/headless-pairing.md @@ -0,0 +1,68 @@ +## Truapi Headless Pairing Host Diagnosis + +| Method | Status | Details | +| --- | --- | --- | +| `Account/connection_status_subscribe` | ✅ | | +| `Account/get_account` | ✅ | | +| `Account/get_account_alias` | ✅ | | +| `Account/create_account_proof` | ⏭️ | | +| `Account/get_legacy_accounts` | ✅ | | +| `Account/get_user_id` | ✅ | | +| `Account/request_login` | ✅ | | +| `Chain/follow_head_subscribe` | ✅ | | +| `Chain/get_head_header` | ✅ | | +| `Chain/get_head_body` | ✅ | | +| `Chain/get_head_storage` | ✅ | | +| `Chain/call_head` | ✅ | | +| `Chain/unpin_head` | ✅ | | +| `Chain/continue_head` | ✅ | | +| `Chain/stop_head_operation` | ✅ | | +| `Chain/get_spec_genesis_hash` | ✅ | | +| `Chain/get_spec_chain_name` | ✅ | | +| `Chain/get_spec_properties` | ✅ | | +| `Chain/broadcast_transaction` | ✅ | | +| `Chain/stop_transaction` | ✅ | | +| `Chat/create_room` | ⏭️ | | +| `Chat/register_bot` | ⏭️ | | +| `Chat/list_subscribe` | ⏭️ | | +| `Chat/post_message` | ⏭️ | | +| `Chat/action_subscribe` | ⏭️ | | +| `Chat/custom_message_render_subscribe` | ⏭️ | | +| `Coin Payment/create_purse` | ⏭️ | | +| `Coin Payment/query_purse` | ⏭️ | | +| `Coin Payment/rebalance_purse` | ⏭️ | | +| `Coin Payment/delete_purse` | ⏭️ | | +| `Coin Payment/create_receivable` | ⏭️ | | +| `Coin Payment/create_cheque` | ⏭️ | | +| `Coin Payment/deposit` | ⏭️ | | +| `Coin Payment/refund` | ⏭️ | | +| `Coin Payment/listen_for_payment` | ⏭️ | | +| `Entropy/derive` | ✅ | | +| `Local Storage/read` | ✅ | | +| `Local Storage/write` | ✅ | | +| `Local Storage/clear` | ✅ | | +| `Notifications/send_push_notification` | ✅ | | +| `Notifications/cancel_push_notification` | ✅ | | +| `Payment/balance_subscribe` | ⏭️ | | +| `Payment/top_up` | ⏭️ | | +| `Payment/request` | ⏭️ | | +| `Payment/status_subscribe` | ⏭️ | | +| `Permissions/request_device_permission` | ✅ | | +| `Permissions/request_remote_permission` | ✅ | | +| `Preimage/lookup_subscribe` | ✅ | | +| `Preimage/submit` | ✅ | | +| `Resource Allocation/request` | ✅ | | +| `Signing/create_transaction` | ✅ | | +| `Signing/create_transaction_with_legacy_account` | ✅ | | +| `Signing/sign_raw_with_legacy_account` | ✅ | | +| `Signing/sign_payload_with_legacy_account` | ✅ | | +| `Signing/sign_raw` | ✅ | | +| `Signing/sign_payload` | ✅ | | +| `Statement Store/subscribe` | ✅ | | +| `Statement Store/create_proof` | ✅ | | +| `Statement Store/submit` | ✅ | | +| `Statement Store/create_proof_authorized` | ✅ | | +| `System/handshake` | ✅ | | +| `System/feature_supported` | ✅ | | +| `System/navigate_to` | ✅ | | +| `Theme/subscribe` | ✅ | | diff --git a/explorer/diagnosis-reports/headless-signing.md b/explorer/diagnosis-reports/headless-signing.md new file mode 100644 index 00000000..36667911 --- /dev/null +++ b/explorer/diagnosis-reports/headless-signing.md @@ -0,0 +1,68 @@ +## Truapi Headless Signing Host Diagnosis + +| Method | Status | Details | +| --- | --- | --- | +| `Account/connection_status_subscribe` | ✅ | | +| `Account/get_account` | ✅ | | +| `Account/get_account_alias` | ✅ | | +| `Account/create_account_proof` | ⏭️ | | +| `Account/get_legacy_accounts` | ✅ | | +| `Account/get_user_id` | ✅ | | +| `Account/request_login` | ✅ | | +| `Chain/follow_head_subscribe` | ✅ | | +| `Chain/get_head_header` | ✅ | | +| `Chain/get_head_body` | ✅ | | +| `Chain/get_head_storage` | ✅ | | +| `Chain/call_head` | ✅ | | +| `Chain/unpin_head` | ✅ | | +| `Chain/continue_head` | ✅ | | +| `Chain/stop_head_operation` | ✅ | | +| `Chain/get_spec_genesis_hash` | ✅ | | +| `Chain/get_spec_chain_name` | ✅ | | +| `Chain/get_spec_properties` | ✅ | | +| `Chain/broadcast_transaction` | ✅ | | +| `Chain/stop_transaction` | ✅ | | +| `Chat/create_room` | ⏭️ | | +| `Chat/register_bot` | ⏭️ | | +| `Chat/list_subscribe` | ⏭️ | | +| `Chat/post_message` | ⏭️ | | +| `Chat/action_subscribe` | ⏭️ | | +| `Chat/custom_message_render_subscribe` | ⏭️ | | +| `Coin Payment/create_purse` | ⏭️ | | +| `Coin Payment/query_purse` | ⏭️ | | +| `Coin Payment/rebalance_purse` | ⏭️ | | +| `Coin Payment/delete_purse` | ⏭️ | | +| `Coin Payment/create_receivable` | ⏭️ | | +| `Coin Payment/create_cheque` | ⏭️ | | +| `Coin Payment/deposit` | ⏭️ | | +| `Coin Payment/refund` | ⏭️ | | +| `Coin Payment/listen_for_payment` | ⏭️ | | +| `Entropy/derive` | ✅ | | +| `Local Storage/read` | ✅ | | +| `Local Storage/write` | ✅ | | +| `Local Storage/clear` | ✅ | | +| `Notifications/send_push_notification` | ✅ | | +| `Notifications/cancel_push_notification` | ✅ | | +| `Payment/balance_subscribe` | ⏭️ | | +| `Payment/top_up` | ⏭️ | | +| `Payment/request` | ⏭️ | | +| `Payment/status_subscribe` | ⏭️ | | +| `Permissions/request_device_permission` | ✅ | | +| `Permissions/request_remote_permission` | ✅ | | +| `Preimage/lookup_subscribe` | ✅ | | +| `Preimage/submit` | ✅ | | +| `Resource Allocation/request` | ✅ | | +| `Signing/create_transaction` | ✅ | | +| `Signing/create_transaction_with_legacy_account` | ✅ | | +| `Signing/sign_raw_with_legacy_account` | ✅ | | +| `Signing/sign_payload_with_legacy_account` | ✅ | | +| `Signing/sign_raw` | ✅ | | +| `Signing/sign_payload` | ✅ | | +| `Statement Store/subscribe` | ✅ | | +| `Statement Store/create_proof` | ✅ | | +| `Statement Store/submit` | ✅ | | +| `Statement Store/create_proof_authorized` | ✅ | | +| `System/handshake` | ✅ | | +| `System/feature_supported` | ✅ | | +| `System/navigate_to` | ✅ | | +| `Theme/subscribe` | ✅ | | diff --git a/rust/crates/truapi-host-cli/Cargo.toml b/rust/crates/truapi-host-cli/Cargo.toml index 886aca75..9f66bf5b 100644 --- a/rust/crates/truapi-host-cli/Cargo.toml +++ b/rust/crates/truapi-host-cli/Cargo.toml @@ -18,7 +18,7 @@ truapi-platform = { path = "../truapi-platform" } truapi-server = { path = "../truapi-server" } anyhow = "1" async-trait = "0.1" -bip39 = "2" +bip39 = { version = "2", features = ["rand"] } blake2-rfc = { version = "0.2", default-features = false } clap = { version = "4", features = ["derive", "env"] } frame-metadata = { version = "23", default-features = false, features = ["std", "current", "decode"] } @@ -30,6 +30,7 @@ scale-info = { version = "2.11", default-features = false, features = ["decode"] sp-crypto-hashing = "0.1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } rustls = { version = "0.23", default-features = false, features = ["ring"] } +serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "io-std", "net", "time", "signal", "process"] } tokio-stream = { version = "0.1", features = ["sync"] } diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index bb101a37..675cc8d4 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -6,18 +6,18 @@ host-spec §B roles and pair over the **real People-chain statement store** (the same node an iOS/web client uses), so tests run against a real signer with no Novasama-operated dependency. -The pairing host is driven by a **product script** you write: a JS/TS file that +Either host can be driven by a **product script** you write: a JS/TS file that receives a global `truapi` (the `@parity/truapi` client, scoped to a product id) -and calls it like any product would. The CLI runs the script and exits with its -status, so `truapi-host pairing-host --script foo.ts` *is* the test — there is no -separate bun orchestrator. +and calls it like any product would. With `--script`, the CLI runs the script +and exits with its status. Without `--script`, the host stays in an interactive +shell until you quit. One binary, `truapi-host`: | Command | Role | | --- | --- | -| `pairing-host` | Seedless host: serves product frames and runs your `--script` with `truapi` injected. | -| `signing-host` | Wallet-local host: answers a pairing deeplink, registers statement allowance on-chain, signs. | +| `pairing-host` | Seedless host: serves product frames, emits pairing deeplinks, and can run product scripts. | +| `signing-host` | Wallet-local host: owns signer identity, can run product scripts, accepts pairing deeplinks, registers statement allowance on-chain, signs. | | `identity-check` | Probe which derivation of a mnemonic carries a registered username. | | `alloc-check` | Diagnose (or `--submit`) on-chain statement-store allowance: ring membership, chosen slot, and the `set_statement_store_account` extrinsic. | @@ -31,10 +31,12 @@ rust/crates/truapi-host-cli/e2e/run.sh path/to/my-script.ts # or a custom scri `run.sh` starts a pairing host running the product script, hands the emitted pairing deeplink to a signing host, and exits with the script's status. The -signing host uses `HOST_CLI_SIGNER_MNEMONIC` (from the environment or a gitignored -`e2e/.env`) if set, otherwise the dev mnemonic — either must be a registered -LitePeople ring member. Override the product with `PRODUCT_ID=...` and the port -with `FRAME=...`. +signing host uses `--mnemonic` / `HOST_CLI_SIGNER_MNEMONIC` if set. Otherwise it +auto-selects or creates a stored account under `--base-path` (default +`$XDG_STATE_HOME/truapi-host` or `~/.local/state/truapi-host`), attests it +through the identity backend, waits for ring readiness, and rotates when the +current account exhausts Statement Store slots. Override the product with +`PRODUCT_ID=...` and the pairing frame port with `FRAME=...`. ## Writing a product script @@ -84,10 +86,15 @@ Two scripts ship under `js/scripts/`: rust/crates/truapi-host-cli/e2e/run.sh rust/crates/truapi-host-cli/js/scripts/diagnosis.ts ``` - With a live chain, this is **43 passed, 1 failed, 20 skipped**; the lone - failure is `Chain/stop_transaction` (the example sends a deliberately-invalid - operation id the real RPC node rejects, which the browser host's smoldot - tolerates). It needs the playground's deps (`cd playground && bun install`). + With live routing enabled, `Chain/stop_transaction` uses host-owned operation + ids and treats already-finished provider operations as stopped. `Preimage/*` + also uses the real Bulletin Next chain and asks the signing host to claim + People-chain long-term storage before returning the product-scoped Bulletin + allowance key. It needs the playground's deps + (`cd playground && bun install`). Repeated live runs can exhaust the + signer's per-period Statement Store or Bulletin allocation slots; the + signing host now rotates auto-managed signer accounts when Statement Store + slots are exhausted. ## Confirmations @@ -107,8 +114,10 @@ per-pairing device key. The port lives in `src/alloc/` (metadata-driven signed-extension encoding, ring fetch, slot scan, ring-VRF proof, extrinsic assembly, submit). The signing account must be an attested LitePeople member, and may sit in an old ring, so the signing host scans back from the current ring -index (slow, one-time per pairing). `alloc-check` verifies membership and can -submit a test registration. +index (slow, one-time per pairing). Auto-managed accounts are stored in +`accounts.json` under `--base-path`; mnemonics are plaintext local test secrets +and the file is written with `0600` permissions on Unix. `alloc-check` verifies +membership and can submit a test registration. ## Manual use (two terminals) @@ -120,35 +129,35 @@ BIN=target/debug/truapi-host $BIN pairing-host --product-id myapp.dot --script js/scripts/battery.ts --auto-accept # Terminal 2 — hand the deeplink to a signing host (registers allowance, signs). -# The wallet mnemonic comes from --mnemonic, else $HOST_CLI_SIGNER_MNEMONIC, -# else the dev mnemonic; it must be a registered LitePeople ring member. +# The wallet mnemonic comes from --mnemonic / $HOST_CLI_SIGNER_MNEMONIC when set; +# otherwise the CLI auto-selects or creates an attested account. $BIN signing-host --deeplink '' --auto-accept HOST_CLI_SIGNER_MNEMONIC="spin battle …" $BIN signing-host --deeplink '' --auto-accept # Inspect on-chain statement-store allowance for a mnemonic: -$BIN alloc-check --lookback 100 # ring membership + free slot (read-only) +$BIN alloc-check --mnemonic "spin battle …" --lookback 100 ``` -Both hosts default `--statement-store` to the real People chain -(`wss://paseo-people-next-system-rpc.polkadot.io`); override with -`--statement-store`. +Both hosts take `--network` (default `paseo-next-v2`). The network preset owns +the identity backend URL, People RPC, Bulletin RPC, and genesis hashes; there is +no public `--statement-store` flag. ## Scope / gaps -- **Chain methods** route to real `wss://` nodes when `E2E_LIVE_CHAIN=1` - (`src/chain.rs`, `PASEO_NEXT_V2_CHAIN_ENDPOINTS`); off by default. A rustls - crypto provider is installed at startup for the TLS connections. +- **Chain methods** route to real `wss://` nodes from the selected `--network` + when `E2E_LIVE_CHAIN=1`; off by default. A rustls crypto provider is + installed at startup for the TLS connections. - **Ring-VRF product-account aliases** are implemented natively via the `verifiable` crate (`get_account_alias`); on wasm they remain `Unavailable`. - **`get_user_id`** resolves the signing account's username from People-chain - `Resources.Consumers`. `truapi-host signing-host --username ` registers a - fresh lite username via the identity backend (`src/attestation.rs`); first - registration is backend-async and can take minutes (ring onboarding), so the - e2e uses an already-registered account. `truapi-host identity-check --mnemonic - ` probes which derivation carries a username. -- `set_statement_store_account` resource-allocation over SSO is still reported - `NotAvailable`. + `Resources.Consumers`. Auto-managed signing accounts register fresh lite + usernames via the identity backend (`src/attestation.rs`); first registration + is backend-async and can take minutes (ring onboarding). `truapi-host + identity-check --mnemonic ` probes which derivation carries a username. +- `set_statement_store_account` and Bulletin long-term-storage resource + allocation are implemented over SSO on native headless hosts. - Everything else the browser host exercises passes: signing (raw, payload, create-transaction, and their legacy variants), statement store, entropy, aliases, preimage, storage, permissions, notifications, theme, system, chain - (with `E2E_LIVE_CHAIN=1`), and user id. + (with `E2E_LIVE_CHAIN=1`), and user id, subject to live chain availability + and allowance-slot capacity. diff --git a/rust/crates/truapi-host-cli/e2e/run.sh b/rust/crates/truapi-host-cli/e2e/run.sh index a0408dd9..6fa1d4b8 100755 --- a/rust/crates/truapi-host-cli/e2e/run.sh +++ b/rust/crates/truapi-host-cli/e2e/run.sh @@ -8,7 +8,8 @@ # # Env: # PRODUCT_ID product id the pairing host serves (default headless-playground.dot) -# HOST_CLI_SIGNER_MNEMONIC wallet mnemonic for the signing host (default: dev mnemonic) +# HOST_CLI_SIGNER_MNEMONIC optional wallet mnemonic; when unset, signing-host auto-manages one +# TRUAPI_HOST_BASE_PATH optional root for generated accounts and host state # FRAME frame-server address (default 127.0.0.1:9955) set -euo pipefail @@ -18,8 +19,8 @@ SCRIPT="${1:-$ROOT/rust/crates/truapi-host-cli/js/scripts/battery.ts}" PRODUCT_ID="${PRODUCT_ID:-headless-playground.dot}" FRAME="${FRAME:-127.0.0.1:9955}" -# Load HOST_CLI_SIGNER_MNEMONIC (and any other vars) from a gitignored e2e/.env -# if present, so the signing host uses a registered account. +# Load HOST_CLI_SIGNER_MNEMONIC / TRUAPI_HOST_BASE_PATH (and any other vars) +# from a gitignored e2e/.env if present. ENV_FILE="$(dirname "$0")/.env" [ -f "$ENV_FILE" ] && { set -a; . "$ENV_FILE"; set +a; } @@ -59,8 +60,8 @@ for _ in $(seq 1 600); do done [ -n "$deeplink" ] || { echo "pairing host did not emit a deeplink" >&2; exit 1; } -# The signing host reads HOST_CLI_SIGNER_MNEMONIC from the env (else the dev -# mnemonic). It must be a registered LitePeople ring member for allowance. +# The signing host reads HOST_CLI_SIGNER_MNEMONIC from the env when set. +# Otherwise it auto-selects or creates an attested account under its base path. "$BIN" signing-host --deeplink "$deeplink" --auto-accept & SIGNER_PID=$! diff --git a/rust/crates/truapi-host-cli/js/scripts/diagnosis.ts b/rust/crates/truapi-host-cli/js/scripts/diagnosis.ts index 20ee45b1..a31fa390 100644 --- a/rust/crates/truapi-host-cli/js/scripts/diagnosis.ts +++ b/rust/crates/truapi-host-cli/js/scripts/diagnosis.ts @@ -9,7 +9,8 @@ // explorer/diagnosis-reports/headless.md, and gates on the signer-critical // methods (chain-node methods and deferred features are reported, not gated, // unless a live chain node is routed in). -import { writeFileSync } from "node:fs"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { runDiagnosis, type DiagnosisRow } from "../diagnosis.ts"; @@ -30,16 +31,19 @@ const MUST_PASS = new Set([ "Entropy/derive", ]); -const REPORT_PATH = fileURLToPath( +const DEFAULT_REPORT_PATH = fileURLToPath( new URL("../../../../../explorer/diagnosis-reports/headless.md", import.meta.url), ); +const REPORT_PATH = process.env.TRUAPI_DIAGNOSIS_REPORT_PATH || DEFAULT_REPORT_PATH; +const REPORT_TITLE = process.env.TRUAPI_DIAGNOSIS_TITLE || "Truapi Headless Pairing Host Diagnosis"; const login = await truapi.account.requestLogin({ reason: undefined }); -if (!login.isOk() || login.value !== "Success") { +if (!login.isOk() || !["Success", "AlreadyConnected"].includes(String(login.value))) { throw new Error(`login failed: ${login.isOk() ? login.value : JSON.stringify(login.error)}`); } const rows = await runDiagnosis(truapi); +mkdirSync(dirname(REPORT_PATH), { recursive: true }); writeFileSync(REPORT_PATH, renderReport(rows)); console.log("\n" + renderReport(rows)); @@ -58,15 +62,15 @@ console.log("GATE PASSED: all signer-critical methods pass"); // web.md-shape table so the headless run can be diffed against the browser host. function renderReport(rows: DiagnosisRow[]): string { const icon = (s: DiagnosisRow["status"]) => - s === "pass" ? "✅" : s === "skipped" ? "⏭️" : "❌"; + s === "pass" ? "✅" : s === "skipped" ? "⏭️" : "❌"; return ( [ - "## Truapi Headless Pairing Host Diagnosis", + `## ${REPORT_TITLE}`, "", "| Method | Status | Details |", "| --- | --- | --- |", ...rows.map( - (r) => `| \`${r.id}\` | ${icon(r.status)} | ${r.status === "skipped" ? "" : cleanDetail(r.output)} |`, + (r) => `| \`${r.id}\` | ${icon(r.status)} | ${r.status === "fail" ? cleanDetail(r.output) : ""} |`, ), ].join("\n") + "\n" ); diff --git a/rust/crates/truapi-host-cli/js/scripts/preimage-smoke.ts b/rust/crates/truapi-host-cli/js/scripts/preimage-smoke.ts new file mode 100644 index 00000000..7ad5c39a --- /dev/null +++ b/rust/crates/truapi-host-cli/js/scripts/preimage-smoke.ts @@ -0,0 +1,31 @@ +/// +// Focused live-chain preimage smoke for debugging Bulletin submission. + +const login = await truapi.account.requestLogin({ reason: undefined }); +if (!login.isOk() || !["Success", "AlreadyConnected"].includes(String(login.value))) { + throw new Error(`requestLogin failed: ${login.isOk() ? login.value : JSON.stringify(login.error)}`); +} + +const bytes = crypto.getRandomValues(new Uint8Array(4)); +const value = `0x${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}` as `0x${string}`; +console.log(`PREIMAGE_VALUE ${value}`); + +const submitted = await truapi.preimage.submit(value); +console.log(`PREIMAGE_SUBMIT ${JSON.stringify(submitted)}`); +if (!submitted.isOk()) { + throw new Error(`preimage submit failed: ${JSON.stringify(submitted.error)}`); +} + +const item = await new Promise((resolve, reject) => { + let sub: { unsubscribe: () => void } | undefined; + sub = truapi.preimage.lookupSubscribe({ request: { key: submitted.value } }).subscribe({ + next(value: unknown) { + sub?.unsubscribe(); + resolve(value); + }, + error(error: unknown) { + reject(error); + }, + }); +}); +console.log(`PREIMAGE_LOOKUP ${JSON.stringify(item)}`); diff --git a/rust/crates/truapi-host-cli/js/scripts/signing-smoke.ts b/rust/crates/truapi-host-cli/js/scripts/signing-smoke.ts new file mode 100644 index 00000000..a8a89b04 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/scripts/signing-smoke.ts @@ -0,0 +1,29 @@ +/// +export {}; + +const login = await truapi.account.requestLogin({ reason: undefined }); +if (!login.isOk() || login.value !== "AlreadyConnected") { + throw new Error(`requestLogin failed: ${login.isOk() ? login.value : JSON.stringify(login.error)}`); +} + +const account = host.productAccount(); +const accountResult = await truapi.account.getAccount({ productAccountId: account }); +accountResult.match( + (value) => console.log(`ACCOUNT ${value.account.publicKey.slice(0, 18)}`), + (error) => { + throw new Error(`getAccount failed: ${JSON.stringify(error)}`); + }, +); + +const signatureResult = await truapi.signing.signRaw({ + account, + payload: { tag: "Bytes", value: { bytes: "0xdeadbeef" } }, +}); +signatureResult.match( + (value) => console.log(`SIGNATURE ${value.signature.slice(0, 18)}`), + (error) => { + throw new Error(`signRaw failed: ${JSON.stringify(error)}`); + }, +); + +console.log("SIGNING_SMOKE_OK"); diff --git a/rust/crates/truapi-host-cli/src/accounts.rs b/rust/crates/truapi-host-cli/src/accounts.rs new file mode 100644 index 00000000..5907749b --- /dev/null +++ b/rust/crates/truapi-host-cli/src/accounts.rs @@ -0,0 +1,417 @@ +use std::collections::BTreeSet; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, bail}; +use bip39::Mnemonic; +use serde::{Deserialize, Serialize}; +use tracing::{info, warn}; +use truapi_server::host_logic::product_account::{ + derive_sr25519_hard_path, product_public_key_to_address, +}; + +use crate::alloc; +use crate::attestation; +use crate::network::NetworkConfig; + +const ACCOUNT_STORE_FILE: &str = "accounts.json"; +const DEFAULT_USERNAME_PREFIX: &str = "headless"; + +#[derive(Debug, Clone)] +pub struct ResolvedSigner { + pub entropy: Vec, + pub account_name: Option, + pub lite_username: Option, + pub auto_managed: bool, +} + +#[derive(Debug, Clone)] +pub struct ResolveSignerConfig<'a> { + pub base_path: &'a Path, + pub network: NetworkConfig, + pub mnemonic: Option, + pub account: Option, + pub lite_username_prefix: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountRecord { + pub name: String, + pub network: String, + pub mnemonic: String, + pub lite_username: String, + pub public_key_hex: String, + pub address: String, + pub created_at_unix: u64, + #[serde(default)] + pub attested: bool, + #[serde(default)] + exhausted_statement_periods: BTreeSet, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct AccountStoreData { + version: u32, + accounts: Vec, +} + +pub struct AccountStore { + path: PathBuf, + data: AccountStoreData, +} + +impl AccountStore { + pub fn load(base_path: &Path) -> Result { + let path = base_path.join(ACCOUNT_STORE_FILE); + let data = match fs::read_to_string(&path) { + Ok(text) => { + serde_json::from_str(&text).with_context(|| format!("decode {}", path.display()))? + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => AccountStoreData { + version: 1, + accounts: Vec::new(), + }, + Err(err) => return Err(err).with_context(|| format!("read {}", path.display())), + }; + Ok(Self { path, data }) + } + + pub fn save(&self) -> Result<()> { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let text = serde_json::to_string_pretty(&self.data)?; + write_secret_file(&self.path, text.as_bytes()) + .with_context(|| format!("write {}", self.path.display())) + } + + pub fn get(&self, network_id: &str, name: &str) -> Option<&AccountRecord> { + self.data + .accounts + .iter() + .find(|record| record.network == network_id && record.name == name) + } + + fn upsert(&mut self, record: AccountRecord) { + if let Some(existing) = self + .data + .accounts + .iter_mut() + .find(|existing| existing.network == record.network && existing.name == record.name) + { + *existing = record; + return; + } + self.data.accounts.push(record); + } + + fn update_attested(&mut self, network_id: &str, name: &str) { + if let Some(record) = self + .data + .accounts + .iter_mut() + .find(|record| record.network == network_id && record.name == name) + { + record.attested = true; + } + } + + fn auto_candidate(&self, network_id: &str, period: u32) -> Option { + self.data + .accounts + .iter() + .find(|record| { + record.network == network_id + && record.attested + && !record.exhausted_statement_periods.contains(&period) + }) + .cloned() + } + + fn next_auto_name(&self, network_id: &str) -> String { + let mut index = 1usize; + loop { + let name = format!("auto-{index}"); + if !self + .data + .accounts + .iter() + .any(|record| record.network == network_id && record.name == name) + { + return name; + } + index += 1; + } + } + + pub fn mark_exhausted(&mut self, network_id: &str, name: &str, period: u32) -> Result<()> { + let Some(record) = self + .data + .accounts + .iter_mut() + .find(|record| record.network == network_id && record.name == name) + else { + return Ok(()); + }; + record.exhausted_statement_periods.insert(period); + self.save() + } +} + +pub async fn resolve_signer(config: ResolveSignerConfig<'_>) -> Result { + if let Some(mnemonic) = config.mnemonic { + let entropy = mnemonic_entropy(&mnemonic)?; + return Ok(ResolvedSigner { + entropy, + account_name: None, + lite_username: None, + auto_managed: false, + }); + } + + let mut store = AccountStore::load(config.base_path)?; + if let Some(name) = config.account { + let record = store + .get(config.network.id, &name) + .cloned() + .with_context(|| format!("account {name:?} not found for {}", config.network.id))?; + ensure_record_ready(&mut store, config.network, &record).await?; + return resolved_from_record(record, false); + } + + let period = current_statement_period()?; + if let Some(record) = store.auto_candidate(config.network.id, period) { + ensure_record_ready(&mut store, config.network, &record).await?; + return resolved_from_record(record, true); + } + + let record = create_auto_account( + &mut store, + config.network, + config + .lite_username_prefix + .as_deref() + .unwrap_or(DEFAULT_USERNAME_PREFIX), + ) + .await?; + resolved_from_record(record, true) +} + +pub fn mark_account_exhausted( + base_path: &Path, + network_id: &str, + name: &str, + period: u32, +) -> Result<()> { + AccountStore::load(base_path)?.mark_exhausted(network_id, name, period) +} + +pub fn current_statement_period() -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock before UNIX epoch")? + .as_secs(); + Ok(alloc::slot::current_period(now)) +} + +async fn create_auto_account( + store: &mut AccountStore, + network: NetworkConfig, + username_prefix: &str, +) -> Result { + validate_username_prefix(username_prefix)?; + let name = store.next_auto_name(network.id); + let mnemonic = Mnemonic::generate(12) + .context("generate BIP-39 mnemonic")? + .to_string(); + let identity = identity_from_mnemonic(&mnemonic)?; + + for attempt in 0..8 { + let lite_username = generated_username(username_prefix, attempt); + if !attestation::lite_username_available(network.identity_backend_base, &lite_username) + .await + .with_context(|| format!("check lite username {lite_username:?} availability"))? + { + continue; + } + + let mut record = AccountRecord { + name: name.clone(), + network: network.id.to_string(), + mnemonic: mnemonic.clone(), + lite_username, + public_key_hex: format!("0x{}", hex::encode(identity.public_key)), + address: identity.address.clone(), + created_at_unix: now_unix(), + attested: false, + exhausted_statement_periods: BTreeSet::new(), + }; + store.upsert(record.clone()); + store.save()?; + + info!( + account = %record.name, + network = %record.network, + lite_username = %record.lite_username, + address = %record.address, + "created auto signer account" + ); + + attest_record(network, &record).await?; + wait_for_ring_membership(network.people_ws, &identity.entropy).await?; + record.attested = true; + store.upsert(record.clone()); + store.save()?; + return Ok(record); + } + + bail!("could not find an available lite username for prefix {username_prefix:?}"); +} + +async fn ensure_record_ready( + store: &mut AccountStore, + network: NetworkConfig, + record: &AccountRecord, +) -> Result<()> { + let identity = identity_from_mnemonic(&record.mnemonic)?; + if !record.attested { + attest_record(network, record).await?; + store.update_attested(network.id, &record.name); + store.save()?; + } + wait_for_ring_membership(network.people_ws, &identity.entropy).await +} + +async fn attest_record(network: NetworkConfig, record: &AccountRecord) -> Result<()> { + let entropy = mnemonic_entropy(&record.mnemonic)?; + let registered = attestation::attest(&attestation::AttestConfig { + backend_base: network.identity_backend_base.to_string(), + people_ws: network.people_ws.to_string(), + entropy, + username_base: record.lite_username.clone(), + }) + .await + .with_context(|| format!("attest account {}", record.name))?; + info!( + account = %record.name, + lite_username = %record.lite_username, + registered, + "signer account attested" + ); + Ok(()) +} + +async fn wait_for_ring_membership(people_ws: &str, entropy: &[u8]) -> Result<()> { + const MAX_ATTEMPTS: usize = 90; + const SLEEP: Duration = Duration::from_secs(4); + + let bandersnatch = alloc::bandersnatch_entropy(entropy); + for attempt in 1..=MAX_ATTEMPTS { + let rpc = alloc::rpc::RpcClient::connect(people_ws).await?; + let metadata = alloc::fetch_metadata(&rpc) + .await + .map_err(anyhow::Error::msg)?; + let current = alloc::ring::read_current_ring_index(&rpc) + .await + .map_err(anyhow::Error::msg)?; + if alloc::find_including_ring(&rpc, &metadata, bandersnatch, current) + .await + .map_err(anyhow::Error::msg)? + .is_some() + { + return Ok(()); + } + if attempt < MAX_ATTEMPTS { + warn!( + attempt, + max_attempts = MAX_ATTEMPTS, + "signer account not in a LitePeople ring yet" + ); + tokio::time::sleep(SLEEP).await; + } + } + bail!("signer account did not appear in a LitePeople ring"); +} + +fn resolved_from_record(record: AccountRecord, auto_managed: bool) -> Result { + let entropy = mnemonic_entropy(&record.mnemonic)?; + Ok(ResolvedSigner { + entropy, + account_name: Some(record.name), + lite_username: Some(record.lite_username), + auto_managed, + }) +} + +struct SignerIdentity { + entropy: Vec, + public_key: [u8; 32], + address: String, +} + +fn identity_from_mnemonic(mnemonic: &str) -> Result { + let entropy = mnemonic_entropy(mnemonic)?; + let candidate = derive_sr25519_hard_path(&entropy, &["wallet", "sso"]) + .map_err(|err| anyhow::anyhow!("//wallet//sso derivation failed: {err}"))?; + let public_key = candidate.public.to_bytes(); + Ok(SignerIdentity { + entropy, + public_key, + address: product_public_key_to_address(public_key), + }) +} + +fn mnemonic_entropy(mnemonic: &str) -> Result> { + Ok(Mnemonic::parse(mnemonic.trim()) + .context("invalid BIP-39 mnemonic")? + .to_entropy()) +} + +fn validate_username_prefix(prefix: &str) -> Result<()> { + if prefix.is_empty() || !prefix.bytes().all(|byte| byte.is_ascii_lowercase()) { + bail!("--lite-username-prefix must contain lowercase ASCII letters only"); + } + Ok(()) +} + +fn generated_username(prefix: &str, attempt: usize) -> String { + let mut username = prefix.to_string(); + let mut seed = now_unix() + ^ u64::from(std::process::id()) + ^ ((attempt as u64) << 32) + ^ (prefix.len() as u64); + while username.len() < prefix.len().max(6) + 6 { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + username.push((b'a' + (seed % 26) as u8) as char); + } + username +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn write_secret_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o600) + .open(path)?; + file.write_all(bytes)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + file.sync_all() + } + #[cfg(not(unix))] + { + fs::write(path, bytes) + } +} diff --git a/rust/crates/truapi-host-cli/src/alloc/rpc.rs b/rust/crates/truapi-host-cli/src/alloc/rpc.rs index 2d481fce..bd38db36 100644 --- a/rust/crates/truapi-host-cli/src/alloc/rpc.rs +++ b/rust/crates/truapi-host-cli/src/alloc/rpc.rs @@ -6,7 +6,7 @@ //! statement-store traffic keeps using the runtime's own transport. use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::Duration; +use std::time::{Duration, Instant}; use anyhow::{Context, Result, anyhow, bail}; use futures_util::{SinkExt, StreamExt}; @@ -87,10 +87,13 @@ impl RpcClient { .context("send author_submitAndWatchExtrinsic")?; // First the subscription id, then a stream of status notifications. + let started = Instant::now(); let mut subscription_id: Option = None; loop { - let value = - next_json(&mut ws, SUBMIT_TIMEOUT, "author_submitAndWatchExtrinsic").await?; + let remaining = SUBMIT_TIMEOUT + .checked_sub(started.elapsed()) + .ok_or_else(|| anyhow!("timed out waiting for author_submitAndWatchExtrinsic"))?; + let value = next_json(&mut ws, remaining, "author_submitAndWatchExtrinsic").await?; if subscription_id.is_none() { if value.get("id").and_then(Value::as_u64) == Some(id) { if let Some(err) = value.get("error") { diff --git a/rust/crates/truapi-host-cli/src/attestation.rs b/rust/crates/truapi-host-cli/src/attestation.rs index c914888b..a1eb6179 100644 --- a/rust/crates/truapi-host-cli/src/attestation.rs +++ b/rust/crates/truapi-host-cli/src/attestation.rs @@ -34,6 +34,32 @@ pub struct AttestConfig { pub username_base: String, } +/// Check whether a lite username base is available through the identity +/// backend. The username must be the base form without the digit suffix. +pub async fn lite_username_available(backend_base: &str, username_base: &str) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build()?; + let url = format!("{backend_base}/usernames/available"); + let body = json!({ "usernames": [username_base] }); + let response = client + .post(&url) + .json(&body) + .send() + .await + .with_context(|| format!("POST {url}"))? + .error_for_status() + .with_context(|| format!("username availability check failed for {username_base}"))?; + let body: Value = response + .json() + .await + .context("decoding availability response")?; + Ok(body + .get(username_base) + .and_then(Value::as_str) + .is_some_and(|status| status == "AVAILABLE")) +} + /// Register (or confirm) the signing host's lite username and wait until the /// People-chain `Resources.Consumers` record exists. Returns the candidate /// account's SS58 address. diff --git a/rust/crates/truapi-host-cli/src/chain.rs b/rust/crates/truapi-host-cli/src/chain.rs index 233b9cf2..7424da37 100644 --- a/rust/crates/truapi-host-cli/src/chain.rs +++ b/rust/crates/truapi-host-cli/src/chain.rs @@ -20,50 +20,33 @@ use tracing::debug; use truapi::v01; use truapi_platform::{ChainProvider, JsonRpcConnection}; +use crate::network::ChainEndpoint; + /// Broadcast backlog for inbound JSON-RPC frames per connection. const INBOUND_CHANNEL_CAPACITY: usize = 1024; -/// Public paseo-next-v2 endpoints that speak the new JSON-RPC (`chainHead_v1`) -/// API, so the pairing host can serve the playground's `Chain/*` examples -/// against real nodes (read-only) just like the browser host. -const PASEO_NEXT_V2_CHAIN_ENDPOINTS: &[(&str, &str)] = &[ - // Asset Hub Next (the chain the `Chain/*` examples target). - ( - "bf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f", - "wss://paseo-asset-hub-next-rpc.polkadot.io", - ), - // Individuality/People Next (used by the create-transaction example to - // build a payload from live metadata). - ( - "c5af1826b31493f08b7e2a823842f98575b806a784126f28da9608c68665afa5", - "wss://paseo-people-next-system-rpc.polkadot.io", - ), -]; - /// Chain provider that maps a requested genesis hash to a WebSocket endpoint. /// /// The all-zero genesis (the headless SSO sentinel) and any unmapped genesis -/// fall back to the People-chain statement store; the Asset Hub genesis routes -/// to its own node (opt-in) for the `Chain/*` playground examples. +/// fall back to the People-chain statement store; the Asset Hub and Bulletin +/// genesis hashes route to their own nodes (opt-in) for live playground +/// examples. pub struct WsChainProvider { fallback_url: String, by_genesis: HashMap<[u8; 32], String>, } impl WsChainProvider { - pub fn new(fallback_url: impl Into) -> Self { + pub fn new(fallback_url: impl Into, live_chain_endpoints: &[ChainEndpoint]) -> Self { // The fallback is the People-chain statement store, which serves the // SSO/identity path directly. Asset Hub routing (for the `Chain/*` // examples) is opt-in; when off, those genesis requests fall back to the // People node, which does not serve Asset Hub chainHead, so they fail // cleanly without disturbing the SSO/signer path. let by_genesis = if std::env::var("E2E_LIVE_CHAIN").as_deref() == Ok("1") { - PASEO_NEXT_V2_CHAIN_ENDPOINTS + live_chain_endpoints .iter() - .filter_map(|(genesis_hex, url)| { - let bytes = hex::decode(genesis_hex).ok()?; - Some((<[u8; 32]>::try_from(bytes).ok()?, url.to_string())) - }) + .map(|endpoint| (endpoint.genesis, endpoint.ws.to_string())) .collect() } else { HashMap::new() diff --git a/rust/crates/truapi-host-cli/src/frame_server.rs b/rust/crates/truapi-host-cli/src/frame_server.rs index 15a130ac..131f7b9b 100644 --- a/rust/crates/truapi-host-cli/src/frame_server.rs +++ b/rust/crates/truapi-host-cli/src/frame_server.rs @@ -15,7 +15,25 @@ use tokio::sync::mpsc; use tokio_tungstenite::accept_async; use tokio_tungstenite::tungstenite::Message; use tracing::{debug, info}; -use truapi_server::{FrameSink, PairingHostRuntime, ProductContext}; +use truapi_server::{ + FrameSink, PairingHostRuntime, ProductContext, ProductRuntime, SigningHostRuntime, +}; + +pub trait ProductRuntimeFactory: Send + Sync + 'static { + fn product_runtime(&self, product: ProductContext, sink: Arc) -> ProductRuntime; +} + +impl ProductRuntimeFactory for PairingHostRuntime { + fn product_runtime(&self, product: ProductContext, sink: Arc) -> ProductRuntime { + PairingHostRuntime::product_runtime(self, product, sink) + } +} + +impl ProductRuntimeFactory for SigningHostRuntime { + fn product_runtime(&self, product: ProductContext, sink: Arc) -> ProductRuntime { + SigningHostRuntime::product_runtime(self, product, sink) + } +} /// Frame sink that writes each outgoing protocol frame as one binary message. struct WsFrameSink { @@ -43,7 +61,7 @@ pub async fn bind(addr: SocketAddr) -> Result { /// this inside a `tokio::task::LocalSet`. The runtime's own subscription work /// is `Send` and still runs on the multi-thread pool via the tokio spawner. pub async fn accept_loop( - runtime: Arc, + runtime: Arc, product_id: String, listener: TcpListener, ) -> Result<()> { @@ -62,7 +80,7 @@ pub async fn accept_loop( } async fn serve_connection( - runtime: Arc, + runtime: Arc, product_id: String, stream: TcpStream, ) -> Result<()> { diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 05293fe2..693a73a2 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -9,65 +9,42 @@ //! //! Plus `alloc-check`, a diagnostic for on-chain statement-store allowance. +mod accounts; mod alloc; mod attestation; mod chain; mod frame_server; +mod network; mod platform; mod script_runner; -use std::io::BufRead; +use std::future::Future; use std::net::SocketAddr; use std::path::PathBuf; +use std::process::ExitStatus; use std::sync::Arc; use anyhow::{Context, Result, bail}; -use clap::{Parser, Subcommand}; +use clap::{Args, Parser, Subcommand}; use futures::future::BoxFuture; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use truapi_platform::{HostInfo, PlatformInfo}; use truapi_server::subscription::Spawner; use truapi_server::{PairingHostConfig, PairingHostRuntime, SigningHostConfig, SigningHostRuntime}; +use crate::accounts::{ResolveSignerConfig, ResolvedSigner}; +use crate::network::{Network, NetworkConfig}; use crate::platform::{ApprovalPolicy, CliPlatform}; -/// Default dev mnemonic used when a signing host is started without one. -const DEFAULT_MNEMONIC: &str = - "bottom drive obey lake curtain smoke basket hold race lonely fit walk"; /// Default product served by the pairing host's frame endpoint. Product ids /// must be a `.dot` name or a `localhost` identifier (host-spec product id). const DEFAULT_PRODUCT_ID: &str = "headless-playground.dot"; +/// Default product-frame address for the pairing host. +const DEFAULT_PAIRING_FRAME_LISTEN: &str = "127.0.0.1:9955"; +/// Default product-frame address for the signing host. +const DEFAULT_SIGNING_FRAME_LISTEN: &str = "127.0.0.1:9956"; /// Deeplink scheme advertised by the pairing host. const DEEPLINK_SCHEME: &str = "polkadotapp"; -/// paseo-next-v2 identity backend base (includes /api/v1). -const IDENTITY_BACKEND_BASE: &str = "https://identity-backend-next.parity-testnet.parity.io/api/v1"; -/// paseo-next-v2 People-chain WebSocket for the attestation on-chain poll. -const PEOPLE_CHAIN_WS: &str = "wss://paseo-people-next-system-rpc.polkadot.io"; -/// paseo-next-v2 People/Individuality chain genesis (username lookups). -const PEOPLE_CHAIN_GENESIS: [u8; 32] = - hex_literal_genesis("c5af1826b31493f08b7e2a823842f98575b806a784126f28da9608c68665afa5"); -/// Headless CLI sentinel for Bulletin submissions. The current CLI scenarios -/// do not allocate live Bulletin allowance keys. -const BULLETIN_CHAIN_GENESIS: [u8; 32] = [0u8; 32]; - -/// Decode a 64-char hex genesis at compile time. -const fn hex_literal_genesis(hex: &str) -> [u8; 32] { - let bytes = hex.as_bytes(); - let mut out = [0u8; 32]; - let mut i = 0; - while i < 32 { - out[i] = hex_nibble(bytes[i * 2]) << 4 | hex_nibble(bytes[i * 2 + 1]); - i += 1; - } - out -} - -const fn hex_nibble(c: u8) -> u8 { - match c { - b'0'..=b'9' => c - b'0', - b'a'..=b'f' => c - b'a' + 10, - _ => panic!("invalid hex digit in genesis literal"), - } -} #[derive(Parser)] #[command(name = "truapi-host", about = "Headless TrUAPI hosts for e2e testing")] @@ -78,73 +55,36 @@ struct Cli { #[derive(Subcommand)] enum Command { - /// Run a seedless pairing host and drive a product script against it. + /// Run a seedless pairing host for product scripts or interactive pairing. /// - /// Starts the product-frame server, then runs `--script` with a global - /// `truapi` injected (the `@parity/truapi` client, scoped to `--product-id`). - /// The host command exits with the script's exit status. - PairingHost { - /// Product script to run (JS/TS). Receives the global `truapi`. - #[arg(long)] - script: PathBuf, - /// Product id the host serves; scopes storage and product accounts. - #[arg(long = "product-id", default_value = DEFAULT_PRODUCT_ID)] - product_id: String, - /// Address to serve product frames on. - #[arg(long, default_value = "127.0.0.1:9955")] - frame_listen: SocketAddr, - /// Statement-store WebSocket URL (the real People chain by default). - #[arg(long = "statement-store", default_value = PEOPLE_CHAIN_WS)] - statement_store: String, - /// Approve every confirmation without prompting on the CLI. - #[arg(long)] - auto_accept: bool, - }, - /// Answer a pairing deeplink as a wallet-local signing host and sign. + /// With `--script`, exits with the script's status. Without it, stays in an + /// interactive shell where scripts can be run repeatedly. + PairingHost(PairingHostArgs), + /// Run a wallet-local signing host for scripts or pairing deeplinks. /// - /// Registers statement allowance on-chain, answers the deeplink, and serves - /// the SSO session. Confirmations are prompted on the CLI unless - /// `--auto-accept` is set. - SigningHost { - /// Pairing deeplink to answer. Read from stdin when omitted. - #[arg(long)] - deeplink: Option, - /// BIP-39 mnemonic for the wallet root. Defaults to the - /// `HOST_CLI_SIGNER_MNEMONIC` env var if set, otherwise the dev mnemonic. - /// Must be a registered LitePeople ring member for allowance to succeed. - #[arg(long, env = "HOST_CLI_SIGNER_MNEMONIC", default_value = DEFAULT_MNEMONIC)] - mnemonic: String, - /// Statement-store WebSocket URL (the real People chain by default). - #[arg(long = "statement-store", default_value = PEOPLE_CHAIN_WS)] - statement_store: String, - /// Approve every confirmation without prompting on the CLI. - #[arg(long)] - auto_accept: bool, - /// Register this lite username base (6+ lowercase letters) on the - /// People chain via the identity backend before pairing, so - /// `get_user_id` resolves. Requires network access. - #[arg(long)] - username: Option, - }, + /// Owns signer identity, auto-manages accounts when no mnemonic/account is + /// specified, and can accept pairing deeplinks. With `--script`, exits with + /// the script's status; otherwise stays interactive. + SigningHost(SigningHostArgs), /// Probe the People chain for a mnemonic's registered identity/username. IdentityCheck { /// BIP-39 mnemonic to probe. - #[arg(long, default_value = DEFAULT_MNEMONIC)] + #[arg(long, env = "HOST_CLI_SIGNER_MNEMONIC")] mnemonic: String, - /// People-chain WebSocket URL. - #[arg(long, default_value = PEOPLE_CHAIN_WS)] - people_ws: String, + /// Network preset to probe. + #[arg(long, value_enum, default_value = "paseo-next-v2")] + network: Network, }, /// Check (and optionally submit) a statement-store allowance registration /// against the real People chain: ring membership, the chosen slot, and /// (with `--submit`) the `set_statement_store_account` extrinsic. AllocCheck { /// BIP-39 mnemonic proving LitePeople ring membership. - #[arg(long, default_value = DEFAULT_MNEMONIC)] + #[arg(long, env = "HOST_CLI_SIGNER_MNEMONIC")] mnemonic: String, - /// People-chain WebSocket URL (statement store + chain RPC). - #[arg(long, default_value = PEOPLE_CHAIN_WS)] - people_ws: String, + /// Network preset to use for People-chain RPC. + #[arg(long, value_enum, default_value = "paseo-next-v2")] + network: Network, /// Target account (hex, 32 bytes) to grant allowance to. Defaults to /// all-zero (read-only slot scan only). #[arg(long)] @@ -158,6 +98,66 @@ enum Command { }, } +#[derive(Args)] +struct PairingHostArgs { + /// Product script to run (JS/TS). If omitted, start an interactive shell. + #[arg(long)] + script: Option, + /// Product id the host serves; scopes storage and product accounts. + #[arg(long = "product-id", default_value = DEFAULT_PRODUCT_ID)] + product_id: String, + /// Address to serve product frames on. + #[arg(long, default_value = DEFAULT_PAIRING_FRAME_LISTEN)] + frame_listen: SocketAddr, + /// Root directory for CLI-managed host state. + #[arg(long = "base-path", env = "TRUAPI_HOST_BASE_PATH")] + base_path: Option, + /// Network preset that supplies all RPC/backend/genesis config. + #[arg(long, value_enum, default_value = "paseo-next-v2")] + network: Network, + /// Approve every confirmation without prompting on the CLI. + #[arg(long)] + auto_accept: bool, +} + +#[derive(Args)] +struct SigningHostArgs { + /// Product script to run (JS/TS). If omitted, start an interactive shell. + #[arg(long)] + script: Option, + /// Product id used by scripts and product-scoped operations. + #[arg(long = "product-id", default_value = DEFAULT_PRODUCT_ID)] + product_id: String, + /// Pairing deeplink to answer. If omitted, no pairing is accepted + /// automatically; interactive mode lets you paste one later. + #[arg(long)] + deeplink: Option, + /// BIP-39 mnemonic for the wallet root. If omitted, the + /// `HOST_CLI_SIGNER_MNEMONIC` env var is used when set. Any mnemonic + /// bypasses account auto-management. + #[arg(long, env = "HOST_CLI_SIGNER_MNEMONIC")] + mnemonic: Option, + /// Named stored account to use. Omit this and `--mnemonic` to auto-select + /// or create a usable account. + #[arg(long)] + account: Option, + /// Prefix for newly-created lite usernames in auto-account mode. + #[arg(long = "lite-username-prefix")] + lite_username_prefix: Option, + /// Root directory for CLI-managed account and host state. + #[arg(long = "base-path", env = "TRUAPI_HOST_BASE_PATH")] + base_path: Option, + /// Network preset that supplies all RPC/backend/genesis config. + #[arg(long, value_enum, default_value = "paseo-next-v2")] + network: Network, + /// Address to serve product frames on when running scripts. + #[arg(long, default_value = DEFAULT_SIGNING_FRAME_LISTEN)] + frame_listen: SocketAddr, + /// Approve every confirmation without prompting on the CLI. + #[arg(long)] + auto_accept: bool, +} + #[tokio::main] async fn main() -> Result<()> { // Install a rustls crypto provider so `wss://` chain connections work; @@ -173,45 +173,21 @@ async fn main() -> Result<()> { .init(); match Cli::parse().command { - Command::PairingHost { - script, - product_id, - frame_listen, - statement_store, - auto_accept, - } => { - run_pairing_host( - script, - product_id, - frame_listen, - statement_store, - auto_accept, - ) - .await - } - Command::SigningHost { - deeplink, - mnemonic, - statement_store, - auto_accept, - username, - } => run_signing_host(deeplink, mnemonic, statement_store, auto_accept, username).await, - Command::IdentityCheck { - mnemonic, - people_ws, - } => { + Command::PairingHost(args) => run_pairing_host(args).await, + Command::SigningHost(args) => run_signing_host(args).await, + Command::IdentityCheck { mnemonic, network } => { let entropy = bip39::Mnemonic::parse(mnemonic.trim()) .context("invalid BIP-39 mnemonic")? .to_entropy(); - attestation::check_identity(&people_ws, &entropy).await + attestation::check_identity(network.config().people_ws, &entropy).await } Command::AllocCheck { mnemonic, - people_ws, + network, target, lookback, submit, - } => run_alloc_check(mnemonic, people_ws, target, lookback, submit).await, + } => run_alloc_check(mnemonic, network.config(), target, lookback, submit).await, } } @@ -219,7 +195,7 @@ async fn main() -> Result<()> { /// slot, and (with `submit`) the `set_statement_store_account` extrinsic. async fn run_alloc_check( mnemonic: String, - people_ws: String, + network: NetworkConfig, target: Option, lookback: u32, submit: bool, @@ -239,7 +215,7 @@ async fn run_alloc_check( None => [0u8; 32], }; - let rpc = alloc::rpc::RpcClient::connect(&people_ws).await?; + let rpc = alloc::rpc::RpcClient::connect(network.people_ws).await?; let metadata = alloc::fetch_metadata(&rpc) .await .map_err(anyhow::Error::msg)?; @@ -350,101 +326,279 @@ fn platform_info() -> PlatformInfo { } } -async fn run_pairing_host( - script: PathBuf, - product_id: String, - frame_listen: SocketAddr, - statement_store: String, - auto_accept: bool, -) -> Result<()> { - let platform = CliPlatform::new(statement_store, approval_policy(auto_accept)); +async fn run_pairing_host(args: PairingHostArgs) -> Result<()> { + let network = args.network.config(); + let base_path = args.base_path.unwrap_or_else(default_base_path); + let product_id = args.product_id; + let platform = CliPlatform::new( + network.people_ws, + network.live_chain_endpoints, + Some(role_state_path(&base_path, network, "pairing-host")), + approval_policy(args.auto_accept), + ); // SSO and identity both run over the real People chain, so usernames always // resolve from `Resources.Consumers` (host-spec G). let config = PairingHostConfig::new( host_info("Headless Pairing Host"), platform_info(), - [0u8; 32], - BULLETIN_CHAIN_GENESIS, + network.people_genesis, + network.bulletin_genesis, DEEPLINK_SCHEME.to_string(), ) .context("invalid pairing host config")? - .with_identity_chain_genesis_hash(PEOPLE_CHAIN_GENESIS); + .with_identity_chain_genesis_hash(network.people_genesis); let runtime = Arc::new(PairingHostRuntime::new(platform, config, tokio_spawner())); - // Bind the frame server, then drive the product script against it; the - // command exits with the script's status. The frame accept loop is `!Send`, - // so it runs on a LocalSet alongside the (Send) script subprocess. - let listener = frame_server::bind(frame_listen).await?; + let listener = frame_server::bind(args.frame_listen).await?; let frame_url = format!("ws://{}", listener.local_addr()?); println!("FRAMES_LISTENING {frame_url}"); + let runtime: Arc = runtime; - let local = tokio::task::LocalSet::new(); - let status = local - .run_until(async move { - let server = tokio::task::spawn_local(frame_server::accept_loop( - runtime, - product_id.clone(), - listener, - )); - let status = script_runner::run(&frame_url, &product_id, &script).await; - server.abort(); - status + if let Some(script) = args.script { + let script_product_id = product_id.clone(); + let script_frame_url = frame_url.clone(); + let status = with_frame_server(runtime, product_id, listener, async move { + script_runner::run(&script_frame_url, &script_product_id, &script).await }) .await?; + std::process::exit(status.code().unwrap_or(1)); + } - std::process::exit(status.code().unwrap_or(1)); + with_frame_server(runtime, product_id.clone(), listener, async move { + pairing_interactive_loop(frame_url, product_id).await + }) + .await } -async fn run_signing_host( - deeplink: Option, - mnemonic: String, - statement_store: String, - auto_accept: bool, - username: Option, -) -> Result<()> { - let entropy = bip39::Mnemonic::parse(mnemonic.trim()) - .context("invalid BIP-39 mnemonic")? - .to_entropy(); - - if let Some(username_base) = username { - let registered = attestation::attest(&attestation::AttestConfig { - backend_base: IDENTITY_BACKEND_BASE.to_string(), - people_ws: PEOPLE_CHAIN_WS.to_string(), - entropy: entropy.clone(), - username_base, +async fn run_signing_host(args: SigningHostArgs) -> Result<()> { + validate_signing_args(&args)?; + let network = args.network.config(); + let base_path = args.base_path.clone().unwrap_or_else(default_base_path); + let mut session = start_signing_host(&args, base_path, network).await?; + let listener = frame_server::bind(args.frame_listen).await?; + let frame_url = format!("ws://{}", listener.local_addr()?); + println!("FRAMES_LISTENING {frame_url}"); + let runtime_for_frames: Arc = session.runtime.clone(); + + if let Some(script) = args.script { + let product_id = args.product_id.clone(); + let script_product_id = product_id.clone(); + let script_frame_url = frame_url.clone(); + let initial_deeplink = args.deeplink.clone(); + let status = with_frame_server(runtime_for_frames, product_id, listener, async move { + let mut responder = None; + if let Some(deeplink) = initial_deeplink { + prepare_pairing_response(&mut session, &deeplink).await?; + let runtime = session.runtime.clone(); + responder = Some(tokio::spawn(async move { + match runtime.respond_to_pairing(&deeplink).await { + Ok(exit) => println!("SIGNING_HOST_EXIT {exit:?}"), + Err(err) => eprintln!("SIGNING_HOST_ERROR {}", err.reason), + } + })); + } + ensure_signer(&mut session).await?; + let status = script_runner::run(&script_frame_url, &script_product_id, &script).await?; + if let Some(responder) = responder { + responder.abort(); + } + Ok::(status) }) - .await - .context("lite username attestation failed")?; - println!("SIGNING_HOST_ATTESTED {registered}"); + .await?; + std::process::exit(status.code().unwrap_or(1)); } - let deeplink = match deeplink { - Some(deeplink) => deeplink, - None => read_deeplink_from_stdin()?, - }; + let product_id = args.product_id.clone(); + let initial_deeplink = args.deeplink.clone(); + with_frame_server( + runtime_for_frames, + product_id.clone(), + listener, + async move { + if let Some(deeplink) = initial_deeplink { + respond_to_deeplink(&mut session, deeplink).await?; + } + signing_interactive_loop(&mut session, frame_url, product_id).await + }, + ) + .await +} - // Grant statement-store allowance to the accounts that submit statements - // over the real store: our own `//wallet//sso` and the pairing host's - // device key. A real client does this on-chain; without it the store - // rejects the handshake with `NoAllowance`. - register_pairing_allowances(&statement_store, &entropy, &deeplink).await?; +struct SigningHostSession { + runtime: Arc, + signer: Option, + base_path: PathBuf, + network: NetworkConfig, + mnemonic: Option, + account: Option, + lite_username_prefix: Option, +} - let platform = CliPlatform::new(statement_store, approval_policy(auto_accept)); +async fn start_signing_host( + args: &SigningHostArgs, + base_path: PathBuf, + network: NetworkConfig, +) -> Result { + let platform = CliPlatform::new( + network.people_ws, + network.live_chain_endpoints, + Some(role_state_path(&base_path, network, "signing-host")), + approval_policy(args.auto_accept), + ); let config = SigningHostConfig::new( host_info("Headless Signing Host"), platform_info(), - [0u8; 32], - BULLETIN_CHAIN_GENESIS, + network.people_genesis, + network.bulletin_genesis, ) .context("invalid signing host config")?; - let runtime = SigningHostRuntime::new(platform, config, tokio_spawner()); - runtime - .activate_local_session(entropy) + let runtime = Arc::new(SigningHostRuntime::new(platform, config, tokio_spawner())); + + Ok(SigningHostSession { + runtime, + signer: None, + base_path, + network, + mnemonic: normalized(args.mnemonic.clone()), + account: normalized(args.account.clone()), + lite_username_prefix: normalized(args.lite_username_prefix.clone()), + }) +} + +fn validate_signing_args(args: &SigningHostArgs) -> Result<()> { + let mnemonic = normalized(args.mnemonic.clone()); + let account = normalized(args.account.clone()); + let prefix = normalized(args.lite_username_prefix.clone()); + if mnemonic.is_some() && account.is_some() { + bail!("--account cannot be used when --mnemonic or HOST_CLI_SIGNER_MNEMONIC is set"); + } + if mnemonic.is_some() && prefix.is_some() { + bail!( + "--lite-username-prefix cannot be used when --mnemonic or HOST_CLI_SIGNER_MNEMONIC is set" + ); + } + if account.is_some() && prefix.is_some() { + bail!("--lite-username-prefix only applies when --account is omitted"); + } + Ok(()) +} + +fn normalized(value: Option) -> Option { + value.and_then(|value| { + let trimmed = value.trim().to_string(); + (!trimmed.is_empty()).then_some(trimmed) + }) +} + +async fn with_frame_server( + runtime: Arc, + product_id: String, + listener: tokio::net::TcpListener, + body: Fut, +) -> Result +where + Fut: Future>, +{ + let local = tokio::task::LocalSet::new(); + local + .run_until(async move { + let server = + tokio::task::spawn_local(frame_server::accept_loop(runtime, product_id, listener)); + let result = body.await; + server.abort(); + result + }) + .await +} + +async fn ensure_signer(session: &mut SigningHostSession) -> Result<()> { + if session.signer.is_some() { + return Ok(()); + } + session.signer = Some( + accounts::resolve_signer(ResolveSignerConfig { + base_path: &session.base_path, + network: session.network, + mnemonic: session.mnemonic.clone(), + account: session.account.clone(), + lite_username_prefix: session.lite_username_prefix.clone(), + }) + .await?, + ); + activate_current_signer(session).await +} + +async fn activate_current_signer(session: &mut SigningHostSession) -> Result<()> { + let signer = session + .signer + .as_ref() + .context("signer has not been resolved")?; + session + .runtime + .activate_local_session_with_identity(signer.entropy.clone(), signer.lite_username.clone()) .await .map_err(|err| anyhow::anyhow!("failed to activate local session: {}", err.reason))?; println!("SIGNING_HOST_READY"); + Ok(()) +} - let exit = runtime +async fn prepare_pairing_response(session: &mut SigningHostSession, deeplink: &str) -> Result<()> { + let mut attempts = 0usize; + loop { + ensure_signer(session).await?; + let (entropy, auto_managed, account_name) = { + let signer = session + .signer + .as_ref() + .context("signer has not been resolved")?; + ( + signer.entropy.clone(), + signer.auto_managed, + signer.account_name.clone(), + ) + }; + match register_pairing_allowances(session.network.people_ws, &entropy, deeplink).await { + Ok(()) => return Ok(()), + Err(err) if auto_managed && is_statement_slot_exhaustion(&err) => { + attempts += 1; + if attempts > 8 { + return Err(err); + } + if let Some(name) = &account_name { + let period = accounts::current_statement_period()?; + accounts::mark_account_exhausted( + &session.base_path, + session.network.id, + name, + period, + )?; + println!("SIGNING_HOST_ACCOUNT_EXHAUSTED {name} period={period}"); + } + session.signer = Some( + accounts::resolve_signer(ResolveSignerConfig { + base_path: &session.base_path, + network: session.network, + mnemonic: None, + account: None, + lite_username_prefix: session.lite_username_prefix.clone(), + }) + .await?, + ); + activate_current_signer(session).await?; + } + Err(err) => return Err(err), + } + } +} + +fn is_statement_slot_exhaustion(err: &anyhow::Error) -> bool { + err.to_string().contains("no free StatementStore slot") +} + +async fn respond_to_deeplink(session: &mut SigningHostSession, deeplink: String) -> Result<()> { + prepare_pairing_response(session, &deeplink).await?; + let exit = session + .runtime .respond_to_pairing(&deeplink) .await .map_err(|err| anyhow::anyhow!("pairing failed: {}", err.reason))?; @@ -509,6 +663,7 @@ async fn register_pairing_allowances( ); for (label, target) in [("wallet-sso", wallet_sso), ("device", device)] { + println!("SIGNING_HOST_ALLOWANCE {label} checking"); let outcome = alloc::register_statement_account( &rpc, &metadata, @@ -532,14 +687,101 @@ async fn register_pairing_allowances( Ok(()) } -fn read_deeplink_from_stdin() -> Result { - let stdin = std::io::stdin(); - for line in stdin.lock().lines() { - let line = line.context("failed to read deeplink from stdin")?; - let line = line.trim().to_string(); - if !line.is_empty() { - return Ok(line); +async fn pairing_interactive_loop(frame_url: String, product_id: String) -> Result<()> { + println!("PAIRING_HOST_INTERACTIVE commands: script , quit"); + let mut lines = BufReader::new(tokio::io::stdin()).lines(); + loop { + print_prompt("pairing-host> ").await?; + let Some(line) = lines.next_line().await? else { + return Ok(()); + }; + let line = line.trim(); + if line.is_empty() { + continue; + } + if is_quit(line) { + return Ok(()); } + let Some(script) = script_command(line) else { + println!("unknown command; use: script , quit"); + continue; + }; + let status = script_runner::run(&frame_url, &product_id, &script).await?; + println!("SCRIPT_EXIT {}", status.code().unwrap_or(1)); } - bail!("no pairing deeplink received on stdin"); +} + +async fn signing_interactive_loop( + session: &mut SigningHostSession, + frame_url: String, + product_id: String, +) -> Result<()> { + println!("SIGNING_HOST_INTERACTIVE commands: deeplink , script , quit"); + let mut lines = BufReader::new(tokio::io::stdin()).lines(); + loop { + print_prompt("signing-host> ").await?; + let Some(line) = lines.next_line().await? else { + return Ok(()); + }; + let line = line.trim(); + if line.is_empty() { + continue; + } + if is_quit(line) { + return Ok(()); + } + if let Some(deeplink) = deeplink_command(line) { + respond_to_deeplink(session, deeplink).await?; + continue; + } + if let Some(script) = script_command(line) { + ensure_signer(session).await?; + let status = script_runner::run(&frame_url, &product_id, &script).await?; + println!("SCRIPT_EXIT {}", status.code().unwrap_or(1)); + continue; + } + println!("unknown command; use: deeplink , script , quit"); + } +} + +async fn print_prompt(prompt: &str) -> Result<()> { + let mut stdout = tokio::io::stdout(); + stdout.write_all(prompt.as_bytes()).await?; + stdout.flush().await?; + Ok(()) +} + +fn is_quit(line: &str) -> bool { + matches!(line, "quit" | "exit" | "q") +} + +fn script_command(line: &str) -> Option { + line.strip_prefix("script ") + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(PathBuf::from) +} + +fn deeplink_command(line: &str) -> Option { + if line.starts_with("polkadotapp://pair?") { + return Some(line.to_string()); + } + line.strip_prefix("deeplink ") + .map(str::trim) + .filter(|deeplink| !deeplink.is_empty()) + .map(str::to_string) +} + +fn default_base_path() -> PathBuf { + if let Some(path) = std::env::var_os("XDG_STATE_HOME") { + return PathBuf::from(path).join("truapi-host"); + } + if let Some(home) = std::env::var_os("HOME") { + return PathBuf::from(home).join(".local/state/truapi-host"); + } + PathBuf::from(".truapi-host") +} + +fn role_state_path(base_path: &std::path::Path, network: NetworkConfig, role: &str) -> PathBuf { + base_path.join(network.id).join(role) } diff --git a/rust/crates/truapi-host-cli/src/network.rs b/rust/crates/truapi-host-cli/src/network.rs new file mode 100644 index 00000000..a6e90e10 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/network.rs @@ -0,0 +1,94 @@ +use clap::ValueEnum; + +/// Supported live network presets for the headless hosts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum Network { + #[value(name = "paseo-next-v2")] + PaseoNextV2, +} + +impl Network { + pub fn config(self) -> NetworkConfig { + match self { + Self::PaseoNextV2 => NetworkConfig { + id: "paseo-next-v2", + identity_backend_base: "https://identity-backend-next.parity-testnet.parity.io/api/v1", + people_ws: "wss://paseo-people-next-system-rpc.polkadot.io", + bulletin_ws: "wss://paseo-bulletin-next-rpc.polkadot.io", + people_genesis: hex_literal_genesis( + "c5af1826b31493f08b7e2a823842f98575b806a784126f28da9608c68665afa5", + ), + bulletin_genesis: hex_literal_genesis( + "8cfe6717dc4becfda2e13c488a1e2061ff2dfee96e7d031157f72d36716c0a22", + ), + live_chain_endpoints: PASEO_NEXT_V2_CHAIN_ENDPOINTS, + }, + } + } +} + +const PASEO_NEXT_V2_CHAIN_ENDPOINTS: &[ChainEndpoint] = &[ + ChainEndpoint { + genesis: hex_literal_genesis( + "bf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f", + ), + ws: "wss://paseo-asset-hub-next-rpc.polkadot.io", + }, + ChainEndpoint { + genesis: hex_literal_genesis( + "c5af1826b31493f08b7e2a823842f98575b806a784126f28da9608c68665afa5", + ), + ws: "wss://paseo-people-next-system-rpc.polkadot.io", + }, + ChainEndpoint { + genesis: hex_literal_genesis( + "8cfe6717dc4becfda2e13c488a1e2061ff2dfee96e7d031157f72d36716c0a22", + ), + ws: "wss://paseo-bulletin-next-rpc.polkadot.io", + }, +]; + +impl Default for Network { + fn default() -> Self { + Self::PaseoNextV2 + } +} + +/// Resolved RPC/backend/genesis values for one network preset. +#[derive(Debug, Clone, Copy)] +pub struct NetworkConfig { + pub id: &'static str, + pub identity_backend_base: &'static str, + pub people_ws: &'static str, + #[allow(dead_code)] + pub bulletin_ws: &'static str, + pub people_genesis: [u8; 32], + pub bulletin_genesis: [u8; 32], + pub live_chain_endpoints: &'static [ChainEndpoint], +} + +#[derive(Debug, Clone, Copy)] +pub struct ChainEndpoint { + pub genesis: [u8; 32], + pub ws: &'static str, +} + +/// Decode a 64-char hex genesis at compile time. +const fn hex_literal_genesis(hex: &str) -> [u8; 32] { + let bytes = hex.as_bytes(); + let mut out = [0u8; 32]; + let mut i = 0; + while i < 32 { + out[i] = hex_nibble(bytes[i * 2]) << 4 | hex_nibble(bytes[i * 2 + 1]); + i += 1; + } + out +} + +const fn hex_nibble(c: u8) -> u8 { + match c { + b'0'..=b'9' => c - b'0', + b'a'..=b'f' => c - b'a' + 10, + _ => panic!("invalid hex digit in genesis literal"), + } +} diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs index 19723988..d1a87f0a 100644 --- a/rust/crates/truapi-host-cli/src/platform.rs +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -7,10 +7,13 @@ //! pairing deeplink and observe connection status. use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use async_trait::async_trait; use futures::stream::{self, BoxStream}; +use serde::{Deserialize, Serialize}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::sync::Mutex as AsyncMutex; use truapi::v01; @@ -36,6 +39,8 @@ pub struct CliPlatform { chain: WsChainProvider, product_storage: Mutex>>, core_storage: Mutex, Vec>>, + product_storage_path: Option, + core_storage_path: Option, preimages: Mutex, Vec>>, approval: ApprovalPolicy, /// Serializes interactive CLI prompts so concurrent confirmations don't @@ -44,12 +49,39 @@ pub struct CliPlatform { } impl CliPlatform { - /// Build a platform whose chain provider connects to `statement_store_url`. - pub fn new(statement_store_url: impl Into, approval: ApprovalPolicy) -> Arc { + /// Build a platform whose chain provider connects to the network's People + /// chain and whose optional state directory backs product/core storage. + pub fn new( + statement_store_url: impl Into, + live_chain_endpoints: &[crate::network::ChainEndpoint], + storage_dir: Option, + approval: ApprovalPolicy, + ) -> Arc { + let (product_storage_path, core_storage_path) = storage_dir + .as_ref() + .map(|dir| { + let _ = fs::create_dir_all(dir); + ( + Some(dir.join("product-storage.json")), + Some(dir.join("core-storage.json")), + ) + }) + .unwrap_or((None, None)); + let product_storage = product_storage_path + .as_deref() + .map(load_string_map) + .unwrap_or_default(); + let core_storage = core_storage_path + .as_deref() + .map(load_hex_key_map) + .unwrap_or_default(); + Arc::new(Self { - chain: WsChainProvider::new(statement_store_url), - product_storage: Mutex::new(HashMap::new()), - core_storage: Mutex::new(HashMap::new()), + chain: WsChainProvider::new(statement_store_url, live_chain_endpoints), + product_storage: Mutex::new(product_storage), + core_storage: Mutex::new(core_storage), + product_storage_path, + core_storage_path, preimages: Mutex::new(HashMap::new()), approval, prompt_lock: AsyncMutex::new(()), @@ -61,6 +93,28 @@ impl CliPlatform { key.encode() } + fn persist_product_storage(&self) -> Result<(), String> { + let Some(path) = &self.product_storage_path else { + return Ok(()); + }; + let storage = self + .product_storage + .lock() + .expect("product storage mutex poisoned"); + save_string_map(path, &storage) + } + + fn persist_core_storage(&self) -> Result<(), String> { + let Some(path) = &self.core_storage_path else { + return Ok(()); + }; + let storage = self + .core_storage + .lock() + .expect("core storage mutex poisoned"); + save_hex_key_map(path, &storage) + } + /// Resolve a confirmation: auto-accept, or prompt y/n on the CLI. async fn decide(&self, action: &str, detail: String) -> bool { match self.approval { @@ -112,19 +166,25 @@ impl ProductStorage for CliPlatform { key: String, value: Vec, ) -> Result<(), v01::HostLocalStorageReadError> { - self.product_storage - .lock() - .expect("product storage mutex poisoned") - .insert(key, value); - Ok(()) + { + self.product_storage + .lock() + .expect("product storage mutex poisoned") + .insert(key, value); + } + self.persist_product_storage() + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) } async fn clear(&self, key: String) -> Result<(), v01::HostLocalStorageReadError> { - self.product_storage - .lock() - .expect("product storage mutex poisoned") - .remove(&key); - Ok(()) + { + self.product_storage + .lock() + .expect("product storage mutex poisoned") + .remove(&key); + } + self.persist_product_storage() + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) } } @@ -147,19 +207,25 @@ impl CoreStorage for CliPlatform { key: CoreStorageKey, value: Vec, ) -> Result<(), v01::GenericError> { - self.core_storage - .lock() - .expect("core storage mutex poisoned") - .insert(Self::core_key(&key), value); - Ok(()) + { + self.core_storage + .lock() + .expect("core storage mutex poisoned") + .insert(Self::core_key(&key), value); + } + self.persist_core_storage() + .map_err(|reason| v01::GenericError { reason }) } async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), v01::GenericError> { - self.core_storage - .lock() - .expect("core storage mutex poisoned") - .remove(&Self::core_key(&key)); - Ok(()) + { + self.core_storage + .lock() + .expect("core storage mutex poisoned") + .remove(&Self::core_key(&key)); + } + self.persist_core_storage() + .map_err(|reason| v01::GenericError { reason }) } } @@ -266,3 +332,52 @@ impl PreimageHost for CliPlatform { Box::pin(stream::once(async move { Ok(value) })) } } + +#[derive(Serialize, Deserialize)] +struct JsonMap { + values: HashMap, +} + +fn load_string_map(path: &Path) -> HashMap> { + let Ok(text) = fs::read_to_string(path) else { + return HashMap::new(); + }; + serde_json::from_str::(&text) + .ok() + .map(|json| { + json.values + .into_iter() + .filter_map(|(key, value)| hex::decode(value).ok().map(|bytes| (key, bytes))) + .collect() + }) + .unwrap_or_default() +} + +fn save_string_map(path: &Path, values: &HashMap>) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| format!("create storage dir: {err}"))?; + } + let json = JsonMap { + values: values + .iter() + .map(|(key, value)| (key.clone(), hex::encode(value))) + .collect(), + }; + let text = serde_json::to_string_pretty(&json).map_err(|err| err.to_string())?; + fs::write(path, text).map_err(|err| format!("write {}: {err}", path.display())) +} + +fn load_hex_key_map(path: &Path) -> HashMap, Vec> { + load_string_map(path) + .into_iter() + .filter_map(|(key, value)| hex::decode(key).ok().map(|decoded| (decoded, value))) + .collect() +} + +fn save_hex_key_map(path: &Path, values: &HashMap, Vec>) -> Result<(), String> { + let keyed: HashMap> = values + .iter() + .map(|(key, value)| (hex::encode(key), value.clone())) + .collect(); + save_string_map(path, &keyed) +} diff --git a/rust/crates/truapi-server/src/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs index 8069be14..9d858648 100644 --- a/rust/crates/truapi-server/src/chain_runtime.rs +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -526,6 +526,19 @@ impl ChainRuntime { Ok(self.subxt_connection(genesis_hash).await?.client) } + /// Raw JSON-RPC client for the chain identified by `genesis_hash`. + pub(crate) async fn rpc_client( + &self, + method: &'static str, + genesis_hash: &[u8], + ) -> Result { + Ok(self + .connection_for(method, genesis_hash) + .await? + .rpc_client + .clone()) + } + async fn subxt_connection( &self, genesis_hash: &[u8], diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 7467adda..576aee5b 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -207,7 +207,7 @@ impl SigningHostRuntime { config.bulletin_chain_genesis_hash, spawner, ); - let signing_host = SigningHostRole::new(platform); + let signing_host = SigningHostRole::new(platform, services.clone()); Self { services, signing_host, @@ -253,6 +253,22 @@ impl SigningHostRuntime { }) } + /// Activate a wallet-local session from host-held secret material and + /// attach known identity metadata. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.activate_local_session"))] + pub async fn activate_local_session_with_identity( + &self, + secret: Vec, + lite_username: Option, + ) -> Result<(), v01::GenericError> { + self.signing_host + .activate_local_session_with_identity(secret, lite_username) + .await + .map_err(|err| v01::GenericError { + reason: err.reason(), + }) + } + /// Answer a pairing host's handshake deeplink and serve the resulting SSO /// session until it ends (host-spec §B responder half). Requires an /// active local session; sensitive requests consult the platform's diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index efd849d3..02614d4f 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -24,7 +24,8 @@ mod statement_store_rpc; use core::future::Future; use core::time::Duration; -use std::sync::Arc; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; use crate::chain_runtime::RuntimeFailure; use crate::host_logic::bulletin::preimage_key; @@ -218,6 +219,8 @@ pub struct ProductRuntimeHost { /// Stable per-product-runtime id used to scope long-lived chain follow /// operation ids within one shared host runtime. core_instance: u64, + /// Host-owned transaction operation ids mapped to provider operation ids. + transaction_operations: Mutex>, } impl ProductRuntimeHost { @@ -233,6 +236,7 @@ impl ProductRuntimeHost { authority, product, core_instance, + transaction_operations: Mutex::new(HashMap::new()), } } @@ -321,6 +325,7 @@ impl ProductRuntimeHost { authority: pairing_host.clone(), product, core_instance, + transaction_operations: Mutex::new(HashMap::new()), }; (host, pairing_host) } @@ -377,6 +382,16 @@ impl ProductRuntimeHost { fn follow_id(&self, id: &str) -> String { format!("c{}:{id}", self.core_instance) } + + fn transaction_id(&self) -> String { + format!("c{}:tx:{}", self.core_instance, nanoid::nanoid!(10)) + } +} + +#[derive(Debug, Clone)] +struct TransactionOperation { + genesis_hash: Vec, + provider_operation_id: String, } impl ProductRuntimeHost { @@ -1566,12 +1581,34 @@ impl Chain for ProductRuntimeHost { }, )) .await?; - self.services + let genesis_hash = inner.genesis_hash.clone(); + let response = self + .services .chain .remote_chain_transaction_broadcast(inner) .await - .map(RemoteChainTransactionBroadcastResponse::V1) - .map_err(runtime_failure_to_call_error) + .map_err(runtime_failure_to_call_error)?; + let Some(provider_operation_id) = response.operation_id else { + return Ok(RemoteChainTransactionBroadcastResponse::V1( + v01::RemoteChainTransactionBroadcastResponse { operation_id: None }, + )); + }; + let operation_id = self.transaction_id(); + self.transaction_operations + .lock() + .expect("transaction operations mutex poisoned") + .insert( + operation_id.clone(), + TransactionOperation { + genesis_hash, + provider_operation_id, + }, + ); + Ok(RemoteChainTransactionBroadcastResponse::V1( + v01::RemoteChainTransactionBroadcastResponse { + operation_id: Some(operation_id), + }, + )) } #[instrument(skip_all, fields(runtime.method = "chain.stop_transaction"))] @@ -1582,18 +1619,58 @@ impl Chain for ProductRuntimeHost { ) -> Result> { let RemoteChainTransactionStopRequest::V1(inner) = request; - // We intentionally forward the provider operation id here. Transaction - // operation ids are node-assigned and short-lived, so cross-product - // collision or guessing is not worth local id indirection yet. - self.services + let operation = { + let operations = self + .transaction_operations + .lock() + .expect("transaction operations mutex poisoned"); + let Some(operation) = operations.get(&inner.operation_id).cloned() else { + return Err(CallError::HostFailure { + reason: format!("unknown transaction operation id: {}", inner.operation_id), + }); + }; + if operation.genesis_hash != inner.genesis_hash { + return Err(CallError::HostFailure { + reason: "transaction operation id belongs to a different chain".to_string(), + }); + } + operation + }; + let stop_request = v01::RemoteChainTransactionStopRequest { + genesis_hash: operation.genesis_hash, + operation_id: operation.provider_operation_id, + }; + let result = self + .services .chain - .remote_chain_transaction_stop(inner) + .remote_chain_transaction_stop(stop_request) .await + .or_else(|failure| { + if transaction_operation_already_finished(&failure) { + Ok(()) + } else { + Err(failure) + } + }) .map(|()| RemoteChainTransactionStopResponse::V1) - .map_err(runtime_failure_to_call_error) + .map_err(runtime_failure_to_call_error); + if result.is_ok() { + self.transaction_operations + .lock() + .expect("transaction operations mutex poisoned") + .remove(&inner.operation_id); + } + result } } +fn transaction_operation_already_finished(failure: &RuntimeFailure) -> bool { + failure + .reason() + .to_ascii_lowercase() + .contains("invalid operation id") +} + // --------------------------------------------------------------------------- // Deferred product surfaces. // @@ -2909,6 +2986,150 @@ mod tests { assert!(platform.sent_rpc.lock().unwrap().is_empty()); } + #[test] + fn chain_stop_transaction_uses_host_operation_id_and_accepts_finished_remote_op() { + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + r#"{"jsonrpc":"2.0","id":"truapi:1","result":"remote-op"}"#.to_string(), + r#"{"jsonrpc":"2.0","id":"truapi:2","error":{"code":-32602,"message":"Invalid operation id"}}"#.to_string(), + ], + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::new(); + let genesis_hash = vec![7u8; 32]; + let broadcast = futures::executor::block_on(Chain::broadcast_transaction( + &host, + &cx, + RemoteChainTransactionBroadcastRequest::V1( + v01::RemoteChainTransactionBroadcastRequest { + genesis_hash: genesis_hash.clone(), + transaction: vec![1, 2, 3], + }, + ), + )) + .unwrap(); + let RemoteChainTransactionBroadcastResponse::V1(broadcast) = broadcast; + let operation_id = broadcast.operation_id.expect("host operation id"); + assert!(operation_id.starts_with("c")); + assert!(operation_id.contains(":tx:")); + assert_ne!(operation_id, "remote-op"); + + futures::executor::block_on(Chain::stop_transaction( + &host, + &cx, + RemoteChainTransactionStopRequest::V1(v01::RemoteChainTransactionStopRequest { + genesis_hash, + operation_id: operation_id.clone(), + }), + )) + .expect("finished provider operation should count as stopped"); + + let sent = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); + let stop_request: serde_json::Value = serde_json::from_str( + sent.iter() + .find(|request| request.contains("transaction_v1_stop")) + .expect("remote stop request sent"), + ) + .unwrap(); + assert_eq!(stop_request["params"][0], "remote-op"); + assert_ne!(stop_request["params"][0], operation_id); + } + + #[test] + fn chain_stop_transaction_rejects_unknown_host_operation_id() { + let platform = Arc::new(StubPlatform::default()); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::new(); + let err = futures::executor::block_on(Chain::stop_transaction( + &host, + &cx, + RemoteChainTransactionStopRequest::V1(v01::RemoteChainTransactionStopRequest { + genesis_hash: vec![7u8; 32], + operation_id: "missing-op".to_string(), + }), + )) + .unwrap_err(); + assert!( + matches!(err, CallError::HostFailure { ref reason } if reason.contains("unknown transaction operation id")), + "unexpected error: {err:?}", + ); + assert!(platform.sent_rpc.lock().unwrap().is_empty()); + } + + #[test] + fn chain_stop_transaction_keeps_operation_retryable_after_remote_failure() { + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + r#"{"jsonrpc":"2.0","id":"truapi:1","result":"remote-op"}"#.to_string(), + r#"{"jsonrpc":"2.0","id":"truapi:2","error":{"code":-32000,"message":"temporary stop failure"}}"#.to_string(), + r#"{"jsonrpc":"2.0","id":"truapi:3","result":null}"#.to_string(), + ], + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::new(); + let genesis_hash = vec![7u8; 32]; + let broadcast = futures::executor::block_on(Chain::broadcast_transaction( + &host, + &cx, + RemoteChainTransactionBroadcastRequest::V1( + v01::RemoteChainTransactionBroadcastRequest { + genesis_hash: genesis_hash.clone(), + transaction: vec![1, 2, 3], + }, + ), + )) + .unwrap(); + let RemoteChainTransactionBroadcastResponse::V1(broadcast) = broadcast; + let operation_id = broadcast.operation_id.expect("host operation id"); + + let first_stop = futures::executor::block_on(Chain::stop_transaction( + &host, + &cx, + RemoteChainTransactionStopRequest::V1(v01::RemoteChainTransactionStopRequest { + genesis_hash: genesis_hash.clone(), + operation_id: operation_id.clone(), + }), + )) + .unwrap_err(); + assert!( + matches!(first_stop, CallError::HostFailure { ref reason } if reason.contains("temporary stop failure")), + "unexpected error: {first_stop:?}", + ); + + futures::executor::block_on(Chain::stop_transaction( + &host, + &cx, + RemoteChainTransactionStopRequest::V1(v01::RemoteChainTransactionStopRequest { + genesis_hash, + operation_id, + }), + )) + .expect("operation should remain retryable"); + + let sent = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); + let stop_operation_ids = sent + .iter() + .filter(|request| request.contains("transaction_v1_stop")) + .map(|request| serde_json::from_str::(request).unwrap()) + .map(|request| request["params"][0].clone()) + .collect::>(); + assert_eq!(stop_operation_ids, vec!["remote-op", "remote-op"]); + } + #[test] fn preimage_lookup_cache_hit_emits_once_and_stays_open() { use crate::host_logic::bulletin::preimage_key; diff --git a/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs index c19de06b..c1c8db78 100644 --- a/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs +++ b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs @@ -22,6 +22,7 @@ use subxt::tx::{ SubmittableTransaction, TransactionInBlock, TransactionInvalid, TransactionStatus, TransactionUnknown, ValidationResult, }; +use subxt_rpcs::RpcClient; use tracing::{instrument, warn}; use truapi::CallContext; use truapi_platform::BulletinAllowanceKey; @@ -156,6 +157,15 @@ impl BulletinRpc { } } + /// Open a raw RPC client over the configured Bulletin chain. + pub(crate) async fn client(&self, label: &'static str) -> Result { + self.chain + .rpc_client(label, &self.genesis_hash) + .await + .map(RpcClient::new) + .map_err(|failure| failure.reason()) + } + /// Submit `value` as a Bulletin preimage signed by `allowance`, returning /// the preimage key once the transaction is included and its dispatch /// succeeded. @@ -275,18 +285,32 @@ impl BulletinRpc { .map_err(|error| BulletinSubmitError::ChainUnavailable { reason: format!("block {} unavailable: {error}", block.number()), })?; - let signed = build_signed_store_transaction(&at_block, signer, value) - .await - .map_err(map_store_transaction_build_error)?; + let signed = match build_signed_store_transaction(&at_block, signer, value).await { + Ok(signed) => signed, + Err(error) => { + warn!( + block = block.number(), + error = %error, + "Bulletin store transaction assembly failed" + ); + return Err(map_store_transaction_build_error(error)); + } + }; self.enter_phase("dry-run"); - let validity = - signed - .validate() - .await - .map_err(|error| BulletinSubmitError::ChainUnavailable { + let validity = match signed.validate().await { + Ok(validity) => validity, + Err(error) => { + warn!( + block = block.number(), + error = %error, + "Bulletin transaction dry-run failed" + ); + return Err(BulletinSubmitError::ChainUnavailable { reason: format!("transaction dry-run unavailable: {error}"), - })?; + }); + } + }; match Self::classify_dry_run_validity(validity)? { DryRunStatus::Valid => return Ok(signed), DryRunStatus::AllowanceRejected diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index e980285d..115c75fa 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -9,8 +9,9 @@ //! Implemented: local session lifecycle, raw-bytes signing, extrinsic-payload //! signing, v4 transaction construction (payload fields and extensions arrive //! pre-encoded, so no chain metadata is needed), RFC-0007 product entropy, and -//! bandersnatch ring-VRF product-account aliases (native only). Deferred -//! (returns [`AuthorityError::Unavailable`]): on-chain resource allocation. +//! bandersnatch ring-VRF product-account aliases (native only), and +//! product-scoped Bulletin allowance keys. Deferred (returns +//! [`AuthorityError::Unavailable`]): on-chain resource allocation. mod local_activation; mod sso_responder; @@ -33,8 +34,10 @@ use crate::host_logic::product_account::{ ProductAccountError, SR25519_SIGNING_CONTEXT, derive_product_keypair, derive_sr25519_hard_path, }; use crate::host_logic::session::SessionState; +use crate::host_logic::sso::messages::OnExistingAllowancePolicy; use crate::host_logic::transaction::extrinsic_payload_preimage; use crate::runtime::auth_state::AuthStateMachine; +use crate::runtime::services::RuntimeServices; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; use truapi::{CallContext, CallError, v01}; @@ -46,6 +49,7 @@ const BYTES_WRAP_SUFFIX: &[u8] = b""; /// Wallet-local account authority for a signing host. pub(crate) struct SigningHost { + services: Arc, session_state: Arc, auth_state: AuthStateMachine, /// Root BIP-39 entropy held only while a session is active. @@ -53,8 +57,9 @@ pub(crate) struct SigningHost { } impl SigningHost { - pub(crate) fn new(platform: Arc) -> Arc { + pub(crate) fn new(platform: Arc, services: Arc) -> Arc { Arc::new(Self { + services, session_state: SessionState::new(), auth_state: AuthStateMachine::new(platform), root_entropy: Mutex::new(None), @@ -96,6 +101,52 @@ impl SigningHost { derive_product_keypair(&wallet, &product_id, account.derivation_index) .map_err(product_authority_error) } + + #[cfg(not(target_arch = "wasm32"))] + async fn local_statement_store_allowance_key( + &self, + product_id: &str, + ) -> Result { + let secret = + sso_responder::allocate_statement_store_allowance(&self.services, self, product_id) + .await + .map_err(allocation_error)?; + StatementStoreAllowanceKey::from_secret_bytes(secret) + } + + #[cfg(target_arch = "wasm32")] + async fn local_statement_store_allowance_key( + &self, + _product_id: &str, + ) -> Result { + Err(AuthorityError::Unavailable { + reason: "signing host: statement-store allowance allocation is native-only".to_string(), + }) + } + + #[cfg(not(target_arch = "wasm32"))] + async fn local_bulletin_allowance_key( + &self, + product_id: &str, + policy: OnExistingAllowancePolicy, + ) -> Result { + let secret = + sso_responder::allocate_bulletin_allowance(&self.services, self, product_id, policy) + .await + .map_err(allocation_error)?; + BulletinAllowanceKey::from_secret_bytes(secret).map_err(AuthorityError::from) + } + + #[cfg(target_arch = "wasm32")] + async fn local_bulletin_allowance_key( + &self, + _product_id: &str, + _policy: OnExistingAllowancePolicy, + ) -> Result { + Err(AuthorityError::Unavailable { + reason: "signing host: Bulletin allowance allocation is native-only".to_string(), + }) + } } #[async_trait::async_trait] @@ -168,15 +219,16 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, request: SignRawAuthorityRequest, ) -> Result { - let SignRawAuthorityRequest::Product(request) = request else { - return Err(AuthorityError::Unavailable { - reason: "signing host: legacy-account raw signing is not yet implemented" - .to_string(), - }); + let (account, payload) = match request { + SignRawAuthorityRequest::Product(request) => (request.account, request.payload), + SignRawAuthorityRequest::LegacyAccount { + product_account, + request, + } => (product_account, request.payload), }; require_current_session(&self.session_state, session)?; - let keypair = self.product_keypair(&request.account)?; - let message = raw_payload_bytes(request.payload)?; + let keypair = self.product_keypair(&account)?; + let message = raw_payload_bytes(payload)?; let signature = keypair .secret .sign_simple(SR25519_SIGNING_CONTEXT, &message, &keypair.public) @@ -269,50 +321,65 @@ impl ProductAuthority for SigningHost { async fn allocate_resources( &self, _cx: &CallContext, - _session: &AuthoritySession, - _product_id: String, - _request: v01::HostRequestResourceAllocationRequest, + session: &AuthoritySession, + product_id: String, + request: v01::HostRequestResourceAllocationRequest, ) -> Result { - Err(AuthorityError::Unavailable { - reason: "signing host: on-chain resource allocation not yet implemented".to_string(), - }) + require_current_session(&self.session_state, session)?; + let mut outcomes = Vec::with_capacity(request.resources.len()); + for resource in request.resources { + let outcome = match resource { + v01::AllocatableResource::StatementStoreAllowance => { + self.local_statement_store_allowance_key(&product_id) + .await?; + v01::AllocationOutcome::Allocated + } + v01::AllocatableResource::BulletinAllowance => { + self.local_bulletin_allowance_key( + &product_id, + OnExistingAllowancePolicy::Ignore, + ) + .await?; + v01::AllocationOutcome::Allocated + } + v01::AllocatableResource::SmartContractAllowance(_) + | v01::AllocatableResource::AutoSigning => v01::AllocationOutcome::NotAvailable, + }; + outcomes.push(outcome); + } + Ok(v01::HostRequestResourceAllocationResponse { outcomes }) } async fn statement_store_allowance_key( &self, _cx: &CallContext, session: &AuthoritySession, - _product_id: String, + product_id: String, ) -> Result { require_current_session(&self.session_state, session)?; - Err(AuthorityError::Unavailable { - reason: "signing host: statement-store allowance allocation not yet implemented" - .to_string(), - }) + self.local_statement_store_allowance_key(&product_id).await } async fn bulletin_allowance_key( &self, _cx: &CallContext, session: &AuthoritySession, - _product_id: String, + product_id: String, ) -> Result { require_current_session(&self.session_state, session)?; - Err(AuthorityError::Unavailable { - reason: "signing host: bulletin allowance allocation not yet implemented".to_string(), - }) + self.local_bulletin_allowance_key(&product_id, OnExistingAllowancePolicy::Ignore) + .await } async fn refresh_bulletin_allowance_key( &self, _cx: &CallContext, session: &AuthoritySession, - _product_id: String, + product_id: String, ) -> Result { require_current_session(&self.session_state, session)?; - Err(AuthorityError::Unavailable { - reason: "signing host: bulletin allowance allocation not yet implemented".to_string(), - }) + self.local_bulletin_allowance_key(&product_id, OnExistingAllowancePolicy::Increase) + .await } async fn sign_statement_store_product_payload( @@ -352,6 +419,10 @@ fn product_authority_error(err: ProductAccountError) -> AuthorityError { } } +fn allocation_error(reason: String) -> AuthorityError { + AuthorityError::Unavailable { reason } +} + /// The user's main wallet keypair at `//wallet` (host-spec C.0), the root of /// product-account derivation and the `rootUserAccountId` shared with paired /// hosts. @@ -359,6 +430,17 @@ pub(crate) fn wallet_root_keypair(entropy: &[u8]) -> Result Result { + let allowance = derive_sr25519_hard_path(entropy, &["allowance", "bulletin", product_id]) + .map_err(product_authority_error)?; + BulletinAllowanceKey::from_secret_bytes(allowance.secret.to_bytes().to_vec()) + .map_err(AuthorityError::from) +} + /// Assemble and sign a transaction locally from caller-supplied, pre-encoded /// parts. Only Extrinsic V4 (`tx_ext_version == 0`) is supported; the caller's /// extension bytes carry the whole chain binding, so no metadata is consulted. @@ -426,7 +508,10 @@ mod tests { AuthorityError, CreateTransactionAuthorityRequest, SignRawAuthorityRequest, }; use super::super::{ProductAuthority, ProductRuntimeHost, RuntimeServices, SigningHostRole}; - use super::{BYTES_WRAP_PREFIX, BYTES_WRAP_SUFFIX, LocalActivation, raw_payload_bytes}; + use super::{ + BYTES_WRAP_PREFIX, BYTES_WRAP_SUFFIX, LocalActivation, derive_bulletin_allowance_key, + raw_payload_bytes, + }; use crate::host_logic::extrinsic::tests::split_v4; use crate::host_logic::product_account::{derive_product_keypair, derive_sr25519_hard_path}; use crate::test_support::{StubPlatform, test_spawner}; @@ -463,7 +548,7 @@ mod tests { config.bulletin_chain_genesis_hash, test_spawner(), ); - let signing_host = SigningHostRole::new(platform); + let signing_host = SigningHostRole::new(platform, services.clone()); (services, signing_host) } @@ -1080,7 +1165,7 @@ mod tests { } #[test] - fn deferred_operations_return_unavailable() { + fn empty_resource_allocation_returns_empty_response() { let (_services, authority) = signing_runtime(); futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) .expect("activation"); @@ -1093,7 +1178,20 @@ mod tests { "myapp.dot".to_string(), v01::HostRequestResourceAllocationRequest { resources: vec![] }, )) - .expect_err("allocation deferred"); - assert!(matches!(alloc, AuthorityError::Unavailable { .. })); + .expect("empty allocation"); + assert!(alloc.outcomes.is_empty()); + } + + #[test] + fn bulletin_allowance_key_uses_product_scoped_ios_path() { + let key = derive_bulletin_allowance_key(&ENTROPY, "truapi-playground.dot") + .expect("bulletin allowance key"); + let expected = derive_sr25519_hard_path( + &ENTROPY, + &["allowance", "bulletin", "truapi-playground.dot"], + ) + .unwrap(); + + assert_eq!(key.as_secret_bytes(), &expected.secret.to_bytes()); } } diff --git a/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs b/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs index 110a6353..cf7899a0 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs @@ -15,11 +15,28 @@ pub(crate) trait LocalActivation: Send + Sync { /// Activate a local session from raw BIP-39 entropy, deriving the root /// public key and marking the session connected. async fn activate_local_session(&self, secret: Vec) -> Result<(), AuthorityError>; + + /// Activate a local session and attach known identity metadata from the + /// host's signer/account store. + async fn activate_local_session_with_identity( + &self, + secret: Vec, + lite_username: Option, + ) -> Result<(), AuthorityError>; } #[async_trait::async_trait] impl LocalActivation for SigningHost { async fn activate_local_session(&self, secret: Vec) -> Result<(), AuthorityError> { + self.activate_local_session_with_identity(secret, None) + .await + } + + async fn activate_local_session_with_identity( + &self, + secret: Vec, + lite_username: Option, + ) -> Result<(), AuthorityError> { let secret = Zeroizing::new(secret); let wallet = wallet_root_keypair(&secret)?; let public_key = wallet.public.to_bytes(); @@ -32,7 +49,7 @@ impl LocalActivation for SigningHost { sso: None, root_entropy_source: None, identity_account_id: None, - lite_username: None, + lite_username, full_username: None, }; self.session_state.set_session(session.clone()); 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 45af8846..00c7e437 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 @@ -27,8 +27,8 @@ use crate::host_logic::entropy::root_entropy_source; use crate::host_logic::product_account::{ProductAccountError, derive_sr25519_hard_path}; use crate::host_logic::session::SsoSessionInfo; use crate::host_logic::sso::messages::{ - self, CreateTransactionPayload, IncomingSsoRequest, RemoteMessage, RemoteMessageData, - ResourceAllocationResponse, RingVrfAliasResponse, SignRawLegacyResponse, + self, CreateTransactionPayload, IncomingSsoRequest, OnExistingAllowancePolicy, RemoteMessage, + RemoteMessageData, ResourceAllocationResponse, RingVrfAliasResponse, SignRawLegacyResponse, SigningPayloadResponseData, SigningRequest, SigningResponse, SsoAllocatableResource, SsoAllocatedResource, SsoAllocationOutcome, StatementStoreProductSignResponse, build_outgoing_request_statement, build_signed_session_response_statement, @@ -235,6 +235,9 @@ async fn answer_remote_message( } v1::RemoteMessage::ResourceAllocationRequest(request) => { let payload = resource_allocation_response(services, signing_host, request).await; + if let Err(reason) = &payload { + warn!(%reason, "resource allocation request failed"); + } v1::RemoteMessage::ResourceAllocationResponse(ResourceAllocationResponse { responding_to: message_id, payload, @@ -312,8 +315,19 @@ async fn resource_allocation_response( slot_account_key, }) } - SsoAllocatableResource::BulletinAllowance - | SsoAllocatableResource::SmartContractAllowance(_) + SsoAllocatableResource::BulletinAllowance => { + let slot_account_key = allocate_bulletin_allowance( + services, + signing_host, + &request.calling_product_id, + request.on_existing, + ) + .await?; + SsoAllocationOutcome::Allocated(SsoAllocatedResource::BulletinAllowance { + slot_account_key, + }) + } + SsoAllocatableResource::SmartContractAllowance(_) | SsoAllocatableResource::AutoSigning => SsoAllocationOutcome::NotAvailable, }; outcomes.push(outcome); @@ -322,9 +336,9 @@ async fn resource_allocation_response( } #[cfg(not(target_arch = "wasm32"))] -async fn allocate_statement_store_allowance( +pub(super) async fn allocate_statement_store_allowance( services: &Arc, - signing_host: &Arc, + signing_host: &SigningHost, product_id: &str, ) -> Result, String> { use crate::runtime::statement_allowance::{ @@ -388,15 +402,114 @@ async fn allocate_statement_store_allowance( Ok(allowance.secret.to_bytes().to_vec()) } +#[cfg(not(target_arch = "wasm32"))] +pub(super) async fn allocate_bulletin_allowance( + services: &Arc, + signing_host: &SigningHost, + product_id: &str, + policy: OnExistingAllowancePolicy, +) -> Result, String> { + use crate::runtime::statement_allowance::{ + self, claim_long_term_storage, fetch_bulletin_allowance, fetch_chain_state, fetch_metadata, + find_including_ring, wait_bulletin_authorization, + }; + + const AUTHORIZATION_WAIT: std::time::Duration = std::time::Duration::from_secs(60); + + let entropy = signing_host.root_entropy().map_err(|err| err.reason())?; + let allowance = derive_sr25519_hard_path(&entropy, &["allowance", "bulletin", product_id]) + .map_err(product_account_error)?; + let target = allowance.public.to_bytes(); + + let bulletin_rpc = statement_allowance::rpc::RpcClient::new( + services.bulletin.client("bulletin allowance").await?, + ); + let current_allowance = fetch_bulletin_allowance(&bulletin_rpc, &target).await?; + if matches!(policy, OnExistingAllowancePolicy::Ignore) + && current_allowance.is_some_and(|allowance| allowance.available()) + { + return Ok(allowance.secret.to_bytes().to_vec()); + } + + let people_rpc = statement_allowance::rpc::RpcClient::new( + services + .statement_store + .client("bulletin allowance claim") + .await?, + ); + let metadata = fetch_metadata(&people_rpc).await?; + let chain_state = fetch_chain_state(&people_rpc).await?; + let bandersnatch = statement_allowance::bandersnatch_entropy(&entropy); + let current = statement_allowance::ring::read_current_ring_index(&people_rpc).await?; + let ring = find_including_ring(&people_rpc, &metadata, bandersnatch, current) + .await? + .ok_or_else(|| { + "signing account is not a LitePeople ring member; cannot grant Bulletin allowance" + .to_string() + })?; + let period_duration = statement_allowance::slot::long_term_storage_period_duration(&metadata)?; + let period = statement_allowance::slot::current_long_term_storage_period( + current_unix_secs()?, + period_duration, + )?; + let outcome = claim_long_term_storage( + &people_rpc, + &metadata, + &chain_state, + bandersnatch, + &target, + period, + &ring, + ) + .await?; + let statement_allowance::LongTermStorageOutcome::Claimed { + block_hash, + counter, + ring_index, + } = outcome; + info!( + %product_id, + %block_hash, + counter, + ring_index, + "claimed Bulletin long-term storage allowance" + ); + + let authorization = wait_bulletin_authorization( + &bulletin_rpc, + &target, + current_allowance, + AUTHORIZATION_WAIT, + ) + .await?; + info!( + %product_id, + remained_size = authorization.remained_size, + remained_transactions = authorization.remained_transactions, + "Bulletin authorization visible" + ); + Ok(allowance.secret.to_bytes().to_vec()) +} + #[cfg(target_arch = "wasm32")] -async fn allocate_statement_store_allowance( +pub(super) async fn allocate_statement_store_allowance( _services: &Arc, - _signing_host: &Arc, + _signing_host: &SigningHost, _product_id: &str, ) -> Result, String> { Err("signing host: statement-store allowance allocation is native-only".to_string()) } +#[cfg(target_arch = "wasm32")] +pub(super) async fn allocate_bulletin_allowance( + _services: &Arc, + _signing_host: &SigningHost, + _product_id: &str, + _policy: OnExistingAllowancePolicy, +) -> Result, String> { + Err("signing host: Bulletin allowance allocation is native-only".to_string()) +} + #[cfg(not(target_arch = "wasm32"))] fn current_unix_secs() -> Result { std::time::SystemTime::now() diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index d8904925..87dd5b22 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -14,9 +14,14 @@ pub mod ring; pub mod rpc; pub mod slot; +use std::time::{Duration, Instant}; + use blake2_rfc::blake2b::blake2b; +use futures::FutureExt; use parity_scale_codec::Decode; use serde_json::{Value, json}; +use sp_crypto_hashing::twox_128; +use tracing::{info, warn}; use extension::{ChainState, Metadata}; use ring::RingParams; @@ -112,6 +117,37 @@ pub enum RegistrationOutcome { }, } +/// Result of a long-term storage claim attempt. +pub enum LongTermStorageOutcome { + /// The extrinsic reached a block; the target should receive Bulletin + /// authorization once XCM/chain propagation completes. + Claimed { + /// Block hash the extrinsic landed in. + block_hash: String, + /// Claimed counter within the long-term storage period. + counter: u8, + /// Ring index the proof was built against. + ring_index: u32, + }, +} + +/// Bulletin authorization state for one account. +#[derive(Debug, Clone, Copy)] +pub struct BulletinAllowanceInfo { + pub remained_size: u64, + pub remained_transactions: u32, + pub expires_in: u32, + pub fetched_at: u32, +} + +impl BulletinAllowanceInfo { + pub fn available(self) -> bool { + self.remained_size > 0 + && self.remained_transactions > 0 + && self.fetched_at < self.expires_in + } +} + /// Find the newest ring (scanning up to `lookback` back from the current index) /// that includes our member key. Reads the ring exponent once and stops at the /// first match. @@ -192,6 +228,164 @@ pub async fn register_statement_account( } } +/// 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, +) -> Result { + let revision = ring::read_ring_revision(rpc, metadata, ring.ring_index).await?; + let mut skipped_duplicate_counters = Vec::new(); + loop { + let counter = slot::scan_long_term_storage_counter_excluding( + rpc, + metadata, + entropy, + period, + &skipped_duplicate_counters, + ) + .await?; + + let context = slot::derive_long_term_storage_context(period, counter); + let call = extrinsic::build_claim_long_term_storage_call(period, counter, target); + let message = extension::build_proof_message(metadata, &call, chain_state)?; + let domain = proof::domain_for_ring_exponent(ring.exponent)?; + let ring_proof = proof::ring_vrf_proof(domain, entropy, &ring.members, &context, &message)?; + let as_resources_extra = + extrinsic::build_long_term_storage_extra(&ring_proof, ring.ring_index, revision); + let extrinsic = + extrinsic::build_unsigned_extrinsic(metadata, chain_state, &call, &as_resources_extra)?; + info!( + period, + counter, + ring_index = ring.ring_index, + revision, + "submitting Bulletin long-term-storage claim" + ); + + match rpc.submit_and_watch(&extrinsic).await { + Ok(block_hash) => { + return Ok(LongTermStorageOutcome::Claimed { + block_hash, + counter, + ring_index: ring.ring_index, + }); + } + Err(err) if duplicate_submit_error(&err) => { + skipped_duplicate_counters.push(counter); + } + Err(err) => { + warn!( + period, + counter, + ring_index = ring.ring_index, + revision, + %err, + "Bulletin long-term-storage claim failed" + ); + return Err(err.to_string()); + } + } + } +} + +/// Fetch Bulletin `TransactionStorage.Authorizations[Account(target)]`. +pub async fn fetch_bulletin_allowance( + rpc: &RpcClient, + target: &[u8; 32], +) -> Result, String> { + let Some(bytes) = rpc + .get_storage(&bulletin_authorization_key(target)) + .await + .map_err(|e| e.to_string())? + else { + return Ok(None); + }; + let fetched_at = fetch_block_number(rpc).await?; + decode_bulletin_allowance(&bytes, fetched_at).map(Some) +} + +/// Wait until Bulletin authorization appears with more remaining transactions +/// than `current`. +pub async fn wait_bulletin_authorization( + rpc: &RpcClient, + target: &[u8; 32], + current: Option, + timeout: Duration, +) -> Result { + let started = Instant::now(); + let current_transactions = current.map(|info| info.remained_transactions).unwrap_or(0); + loop { + if let Some(info) = fetch_bulletin_allowance(rpc, target).await? { + if info.remained_transactions > current_transactions && info.available() { + return Ok(info); + } + } + let Some(remaining) = timeout.checked_sub(started.elapsed()) else { + return Err("timed out waiting for Bulletin authorization".to_string()); + }; + let delay = futures_timer::Delay::new(remaining.min(Duration::from_secs(2))).fuse(); + futures::pin_mut!(delay); + delay.await; + } +} + +/// `TransactionStorage.Authorizations[AuthorizationScope::Account(target)]`. +fn bulletin_authorization_key(target: &[u8; 32]) -> Vec { + let mut scope = Vec::with_capacity(1 + 32); + scope.push(0x00); + scope.extend_from_slice(target); + [ + twox_128(b"TransactionStorage").as_slice(), + twox_128(b"Authorizations").as_slice(), + &ring::blake2_128_concat(&scope), + ] + .concat() +} + +fn decode_bulletin_allowance( + bytes: &[u8], + fetched_at: u32, +) -> Result { + let mut input = bytes; + let transactions = + u32::decode(&mut input).map_err(|err| format!("authorization transactions: {err}"))?; + let transactions_allowance = u32::decode(&mut input) + .map_err(|err| format!("authorization transactions_allowance: {err}"))?; + let bytes_used = + u64::decode(&mut input).map_err(|err| format!("authorization bytes: {err}"))?; + let _bytes_permanent = + u64::decode(&mut input).map_err(|err| format!("authorization bytes_permanent: {err}"))?; + let bytes_allowance = + u64::decode(&mut input).map_err(|err| format!("authorization bytes_allowance: {err}"))?; + let expires_in = + u32::decode(&mut input).map_err(|err| format!("authorization expiration: {err}"))?; + Ok(BulletinAllowanceInfo { + remained_size: bytes_allowance.saturating_sub(bytes_used), + remained_transactions: transactions_allowance.saturating_sub(transactions), + expires_in, + fetched_at, + }) +} + +async fn fetch_block_number(rpc: &RpcClient) -> Result { + let header = rpc + .call("chain_getHeader", json!([])) + .await + .map_err(|err| err.to_string())?; + let number = header + .get("number") + .and_then(Value::as_str) + .ok_or_else(|| "chain_getHeader returned no number".to_string())?; + u32::from_str_radix(number.trim_start_matches("0x"), 16) + .map_err(|err| format!("chain_getHeader number: {err}")) +} + fn duplicate_submit_error(message: &str) -> bool { let message = message.to_ascii_lowercase(); message.contains("priority is too low") diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/dynamic.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/dynamic.rs index 0fe11728..42e1df77 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/dynamic.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/dynamic.rs @@ -1,9 +1,10 @@ //! Minimal metadata-driven SCALE walker. //! //! Just enough to read one field out of a storage struct without a full dynamic -//! codec: `skip` advances a cursor past one value of a given type, and +//! codec: `skip` advances a cursor past one value of a given type, //! `read_field_variant_name` walks a composite to a named field and returns its -//! enum variant name (used for `CollectionInfo.ring_size` -> `R2e9`/`R2e10`/`R2e14`). +//! enum variant name (used for `CollectionInfo.ring_size` -> `R2e9`/`R2e10`/`R2e14`), +//! and `read_field_u32` reads simple numeric fields such as `RingRoot.revision`. use parity_scale_codec::{Compact, Decode}; use scale_info::{PortableRegistry, TypeDef, TypeDefPrimitive}; @@ -107,6 +108,37 @@ pub fn read_field_variant_name( Err(format!("field `{field_name}` not found")) } +/// Walk composite `struct_type_id` to `field_name` and decode the field as a +/// SCALE `u32`. +pub fn read_field_u32( + registry: &PortableRegistry, + struct_type_id: u32, + field_name: &str, + bytes: &[u8], +) -> Result { + let ty = registry + .resolve(struct_type_id) + .ok_or_else(|| format!("unknown type id {struct_type_id}"))?; + let TypeDef::Composite(composite) = &ty.type_def else { + return Err(format!("type {struct_type_id} is not a composite")); + }; + + let mut input = bytes; + for field in &composite.fields { + if field.name.as_deref() == Some(field_name) { + let field_ty = registry + .resolve(field.ty.id) + .ok_or_else(|| format!("unknown field type id {}", field.ty.id))?; + if !matches!(field_ty.type_def, TypeDef::Primitive(TypeDefPrimitive::U32)) { + return Err(format!("field `{field_name}` is not a u32")); + } + return u32::decode(&mut input).map_err(|err| format!("field `{field_name}`: {err}")); + } + skip(registry, field.ty.id, &mut input)?; + } + Err(format!("field `{field_name}` not found")) +} + /// Decode a SCALE compact-encoded length, advancing `input`. fn read_compact(input: &mut &[u8]) -> Result { let Compact(value) = Compact::::decode(input).map_err(|err| format!("compact: {err}"))?; 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 ab462f34..f23125b1 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extension.rs @@ -101,6 +101,53 @@ macro_rules! collect_metadata { }}; } +macro_rules! collect_metadata_v16 { + ($m:expr) => {{ + let extension_indexes = $m + .extrinsic + .transaction_extensions_by_version + .get(&5) + .map(|indexes| { + indexes + .iter() + .map(|Compact(index)| *index as usize) + .collect::>() + }) + .unwrap_or_else(|| (0..$m.extrinsic.transaction_extensions.len()).collect()); + let extensions = extension_indexes + .into_iter() + .filter_map(|index| $m.extrinsic.transaction_extensions.get(index)) + .map(|e| ExtensionDef { + identifier: e.identifier.clone(), + extra_type: e.ty.id, + additional_signed_type: e.implicit.id, + }) + .collect(); + let mut storage_values = HashMap::new(); + let mut constants = HashMap::new(); + for pallet in &$m.pallets { + for constant in &pallet.constants { + constants.insert( + (pallet.name.clone(), constant.name.clone()), + constant.value.clone(), + ); + } + let Some(storage) = &pallet.storage else { + continue; + }; + for entry in &storage.entries { + use frame_metadata::v16::StorageEntryType as EntryType; + let value_type = match &entry.ty { + EntryType::Plain(ty) => ty.id, + EntryType::Map { value, .. } => value.id, + }; + storage_values.insert((pallet.name.clone(), entry.name.clone()), value_type); + } + } + (extensions, $m.types, storage_values, constants) + }}; +} + impl Metadata { /// Decode `state_getMetadata` bytes (a `RuntimeMetadataPrefixed`, V14 or /// V15) into the ordered signed-extension defs, type registry, and storage @@ -111,6 +158,7 @@ impl Metadata { let (extensions, registry, storage_values, constants) = match prefixed.1 { RuntimeMetadata::V14(m) => collect_metadata!(m, frame_metadata::v14::StorageEntryType), RuntimeMetadata::V15(m) => collect_metadata!(m, frame_metadata::v15::StorageEntryType), + RuntimeMetadata::V16(m) => collect_metadata_v16!(m), other => return Err(format!("unsupported metadata version {}", other.version())), }; Ok(Self { diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs b/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs index add28cfc..a427e88a 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/extrinsic.rs @@ -7,8 +7,12 @@ use super::extension::{ChainState, Metadata}; /// Pallet + call index for `Resources.set_statement_store_account` (63 / 10). pub const SET_STATEMENT_STORE_ACCOUNT_CALL: [u8; 2] = [0x3f, 0x0a]; +/// Pallet + call index for `Resources.claim_long_term_storage` (63 / 12). +pub const CLAIM_LONG_TERM_STORAGE_CALL: [u8; 2] = [0x3f, 0x0c]; /// `AsResourcesInfo::RegisterStatementStoreAllowance` variant index. const REGISTER_STATEMENT_STORE_ALLOWANCE: u8 = 0x02; +/// `AsResourcesInfo::ClaimLongTermStorage` variant index. +const CLAIM_LONG_TERM_STORAGE: u8 = 0x03; /// `MembershipCollection::LitePeople` variant index. const MEMBERSHIP_COLLECTION_LITE_PEOPLE: u8 = 0x01; /// General-transaction preamble byte: `0b01` (General) | version 5. @@ -29,6 +33,21 @@ pub fn build_set_statement_store_account_call(period: u32, seq: u32, target: &[u call } +/// Encode `Resources.claim_long_term_storage(period, counter, account_id)`: +/// `3f 0c ‖ period_u32LE ‖ counter_u8 ‖ account_id[32]`. +pub fn build_claim_long_term_storage_call( + period: u32, + counter: u8, + account_id: &[u8; 32], +) -> Vec { + let mut call = Vec::with_capacity(2 + 4 + 1 + 32); + call.extend_from_slice(&CLAIM_LONG_TERM_STORAGE_CALL); + call.extend_from_slice(&period.to_le_bytes()); + call.push(counter); + call.extend_from_slice(account_id); + call +} + /// Encode the `AsResources` extension `extra` for a statement-store allowance: /// `Some(RegisterStatementStoreAllowance { proof, ring_index, LitePeople })`. pub fn build_as_resources_extra(proof: &[u8], ring_index: u32) -> Vec { @@ -42,6 +61,20 @@ pub fn build_as_resources_extra(proof: &[u8], ring_index: u32) -> Vec { extra } +/// Encode the `AsResources` extension `extra` for a long-term storage claim: +/// `Some(ClaimLongTermStorage { proof, ring_index, revision, LitePeople })`. +pub fn build_long_term_storage_extra(proof: &[u8], ring_index: u32, revision: u32) -> Vec { + let mut extra = Vec::with_capacity(2 + 2 + proof.len() + 4 + 4 + 1); + extra.push(OPTION_SOME); + extra.push(CLAIM_LONG_TERM_STORAGE); + extra.extend_from_slice(&Compact(proof.len() as u32).encode()); + extra.extend_from_slice(proof); + extra.extend_from_slice(&ring_index.to_le_bytes()); + extra.extend_from_slice(&revision.to_le_bytes()); + extra.push(MEMBERSHIP_COLLECTION_LITE_PEOPLE); + extra +} + /// Assemble the unsigned General (v5) extrinsic: /// `compact(len) ‖ 0x45 ‖ 0x00 ‖ Σ(all extra, AsResources = Some(info)) ‖ call`. pub fn build_unsigned_extrinsic( @@ -100,6 +133,21 @@ mod tests { ); } + #[test] + fn long_term_storage_call_layout_is_pallet_call_period_counter_account() { + let call = build_claim_long_term_storage_call(7, 3, &[0u8; 32]); + assert_eq!( + call, + [ + vec![0x3f, 0x0c], + 7u32.to_le_bytes().to_vec(), + vec![3], + vec![0u8; 32], + ] + .concat() + ); + } + #[test] fn as_resources_extra_wraps_proof_as_bytes() { let proof = vec![0xEE; 785]; @@ -112,6 +160,20 @@ mod tests { assert_eq!(extra[4 + 785 + 4], MEMBERSHIP_COLLECTION_LITE_PEOPLE); } + #[test] + fn long_term_storage_extra_wraps_revision() { + let proof = vec![0xEE; 785]; + let extra = build_long_term_storage_extra(&proof, 3, 9); + // Some(0x01) ‖ variant(0x03) ‖ compact(785)=0x45,0x0c ‖ proof + // ‖ ringIndex LE ‖ revision LE ‖ LitePeople. + assert_eq!(&extra[..2], &[0x01, 0x03]); + assert_eq!(&extra[2..4], &Compact(785u32).encode()[..]); + assert_eq!(&extra[4..4 + 785], &proof[..]); + assert_eq!(&extra[4 + 785..4 + 785 + 4], &3u32.to_le_bytes()); + assert_eq!(&extra[4 + 785 + 4..4 + 785 + 8], &9u32.to_le_bytes()); + assert_eq!(extra[4 + 785 + 8], MEMBERSHIP_COLLECTION_LITE_PEOPLE); + } + #[test] fn extrinsic_has_general_v5_preamble_and_embeds_call() { let metadata = Metadata::decode(FIXTURE).unwrap(); 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 aac6403d..a461dcac 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/ring.rs @@ -7,7 +7,7 @@ use parity_scale_codec::{Compact, Decode}; use sp_crypto_hashing::{blake2_128, twox_64, twox_128}; -use super::dynamic::read_field_variant_name; +use super::dynamic::{read_field_u32, read_field_variant_name}; use super::extension::Metadata; use super::rpc::RpcClient; @@ -57,6 +57,17 @@ fn ring_keys_status_key(ring_index: u32) -> Vec { .concat() } +/// `Members.Root[(id, ring_index)]` storage key. +fn ring_root_key(ring_index: u32) -> Vec { + [ + twox_128(b"Members").as_slice(), + twox_128(b"Root").as_slice(), + LITE_PEOPLE_IDENTIFIER.as_slice(), + &blake2_128_concat(&ring_index.to_le_bytes()), + ] + .concat() +} + /// `Members.RingKeys[(id, ring_index, page)]` storage key. fn ring_keys_key(ring_index: u32, page: u32) -> Vec { [ @@ -162,3 +173,25 @@ pub async fn read_ring_members_at( Ok(members) } + +/// Read `Members.Root[LitePeople][ring_index].revision` (absent => 0). +pub async fn read_ring_revision( + rpc: &RpcClient, + metadata: &Metadata, + ring_index: u32, +) -> Result { + match rpc + .get_storage(&ring_root_key(ring_index)) + .await + .map_err(|e| e.to_string())? + { + Some(bytes) => { + let value_type = metadata + .storage_value_type("Members", "Root") + .ok_or_else(|| "Members.Root type not in metadata".to_string())?; + read_field_u32(metadata.registry(), value_type, "revision", &bytes) + .map_err(|e| format!("ring revision: {e}")) + } + None => Ok(0), + } +} 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 fd1fb59f..a9a99b81 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance/slot.rs @@ -16,12 +16,25 @@ use super::rpc::RpcClient; /// 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"; /// The current allowance period for `now_seconds`. pub fn current_period(now_seconds: u64) -> u32 { (now_seconds / STATEMENT_STORE_PERIOD_SECONDS) as u32 } +/// The current long-term-storage period for `now_seconds`. +pub fn current_long_term_storage_period( + now_seconds: u64, + period_duration: u32, +) -> Result { + if period_duration == 0 { + return Err("Resources.LongTermStoragePeriodDuration is zero".to_string()); + } + 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] { @@ -32,6 +45,17 @@ pub fn derive_slot_context(period: u32, seq: u32) -> [u8; 32] { ctx } +/// 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 +} + /// The slot alias for our `entropy` at `(period, seq)`. pub fn slot_alias(entropy: [u8; 32], period: u32, seq: u32) -> Result<[u8; 32], String> { let secret = BandersnatchVrfVerifiable::new_secret(entropy); @@ -40,6 +64,18 @@ pub fn slot_alias(entropy: [u8; 32], period: u32, seq: u32) -> Result<[u8; 32], .map_err(|err| format!("alias_in_context failed: {err:?}")) } +/// The long-term-storage slot alias for our `entropy` at `(period, counter)`. +pub fn long_term_storage_alias( + entropy: [u8; 32], + period: u32, + counter: u8, +) -> Result<[u8; 32], String> { + let secret = BandersnatchVrfVerifiable::new_secret(entropy); + let context = derive_long_term_storage_context(period, counter); + BandersnatchVrfVerifiable::alias_in_context(&secret, &context) + .map_err(|err| format!("alias_in_context failed: {err:?}")) +} + /// `Resources.StatementStoreAllowances[period][alias]` storage key. /// key1 = Identity(u32be period); key2 = Blake2_128Concat(alias). fn statement_store_allowance_key(period: u32, alias: &[u8; 32]) -> Vec { @@ -52,6 +88,18 @@ fn statement_store_allowance_key(period: u32, alias: &[u8; 32]) -> Vec { .concat() } +/// `Resources.SpentLongTermStorageAliases[period][alias]` storage key. +/// key1 = Identity(u32be period); key2 = Blake2_128Concat(alias). +fn spent_long_term_storage_alias_key(period: u32, alias: &[u8; 32]) -> Vec { + [ + twox_128(b"Resources").as_slice(), + twox_128(b"SpentLongTermStorageAliases").as_slice(), + &period.to_be_bytes(), + &blake2_128_concat(alias), + ] + .concat() +} + /// Max StatementStore slots per period from `Resources.LiteStmtStoreSlotsPerPeriod`. fn max_slots(metadata: &Metadata) -> Result { let bytes = metadata @@ -63,6 +111,27 @@ fn max_slots(metadata: &Metadata) -> Result { Ok(u32::from_le_bytes(buf)) } +/// Max long-term-storage claims per period from +/// `Resources.LongTermStorageClaimsPerPeriod`. +fn long_term_storage_claims_per_period(metadata: &Metadata) -> Result { + metadata + .constant("Resources", "LongTermStorageClaimsPerPeriod") + .and_then(|bytes| bytes.first().copied()) + .ok_or_else(|| "Resources.LongTermStorageClaimsPerPeriod constant missing".to_string()) +} + +/// Long-term-storage period duration in seconds from +/// `Resources.LongTermStoragePeriodDuration`. +pub fn long_term_storage_period_duration(metadata: &Metadata) -> Result { + let bytes = metadata + .constant("Resources", "LongTermStoragePeriodDuration") + .ok_or_else(|| "Resources.LongTermStoragePeriodDuration constant missing".to_string())?; + let mut buf = [0u8; 4]; + let n = bytes.len().min(4); + buf[..n].copy_from_slice(&bytes[..n]); + Ok(u32::from_le_bytes(buf)) +} + /// The account id occupying a slot entry, if the storage value is present. /// Entry = `account_id(32) ‖ seq(u32 LE) ‖ since(u64 LE)`. fn entry_account_id(bytes: &[u8]) -> Option<[u8; 32]> { @@ -111,6 +180,36 @@ pub async fn scan_slot_excluding( .ok_or_else(|| format!("no free StatementStore slot in period {period} (max {max})")) } +/// Scan long-term-storage aliases `0..max` for `period`, returning the first +/// free counter not listed in `excluded`. `entropy` is our bandersnatch entropy. +pub async fn scan_long_term_storage_counter_excluding( + rpc: &RpcClient, + metadata: &Metadata, + entropy: [u8; 32], + period: u32, + excluded: &[u8], +) -> Result { + let max = long_term_storage_claims_per_period(metadata)?; + for counter in 0..max { + if excluded.contains(&counter) { + continue; + } + let alias = long_term_storage_alias(entropy, period, counter)?; + let key = spent_long_term_storage_alias_key(period, &alias); + if rpc + .get_storage(&key) + .await + .map_err(|e| e.to_string())? + .is_none() + { + return Ok(counter); + } + } + Err(format!( + "no free long-term-storage slot in period {period} (max {max})" + )) +} + #[cfg(test)] mod tests { use super::*; @@ -124,8 +223,25 @@ mod tests { assert!(ctx[17..].iter().all(|&b| b == 0x20)); } + #[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)); + } + #[test] fn period_is_utc_day_index() { assert_eq!(current_period(86_400 * 20_000 + 5), 20_000); } + + #[test] + fn long_term_storage_period_uses_chain_duration() { + assert_eq!( + current_long_term_storage_period(1_209_600 * 20 + 5, 1_209_600).unwrap(), + 20, + ); + } } diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index d3c3c940..34297aaf 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -420,7 +420,7 @@ mod tests { fn signing_host_runtime(product_id: &str) -> (ProductRuntimeHost, Arc) { let platform: Arc = Arc::new(StubPlatform::default()); let services = RuntimeServices::new(platform.clone(), [0; 32], [0xbb; 32], test_spawner()); - let signing_host = SigningHostRole::new(platform); + let signing_host = SigningHostRole::new(platform, services.clone()); futures::executor::block_on(signing_host.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); let host = ProductRuntimeHost::from_services( From 20667cf04968c2cbea9ea6416a940deab23016a3 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Sun, 12 Jul 2026 09:45:10 +0200 Subject: [PATCH 08/13] chore: ignore local generated artifacts --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 9dc237bb..f0359297 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,10 @@ lerna-debug.log* node_modules target +# Local Bun side effects (repo lockfiles are npm/yarn) +bun.lock +bun.lockb + # Gradle (Android workspace at repo root) /.gradle/ /build/ @@ -56,3 +60,4 @@ js/packages/truapi/dist/generated/ js/packages/truapi-host/src/generated/ js/packages/truapi-host/dist/generated/ js/packages/truapi-host/dist/wasm/ +js/packages/truapi-host-wasm/ From bf3c7a7bd4264c2c0234894c9d8d5d48fc9d0776 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Sun, 12 Jul 2026 09:51:34 +0200 Subject: [PATCH 09/13] chore: add headless cli install target --- Makefile | 5 ++++- rust/crates/truapi-host-cli/README.md | 13 ++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 317eb9b0..6a9835ab 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # Run `make help` for the list of targets. .DEFAULT_GOAL := help -.PHONY: help setup build codegen test check clean playground wasm wasm-crypto-test dev dev-bootstrap dev-link-check e2e-dotli headless matrix explorer +.PHONY: help setup build codegen test check clean playground wasm wasm-crypto-test dev dev-bootstrap dev-link-check e2e-dotli headless install matrix explorer TRUAPI_PKG := js/packages/truapi PLAYGROUND := playground @@ -54,6 +54,9 @@ headless: ## Build everything the headless-host e2e needs; then run rust/crates/ cargo build -p truapi-host-cli cd $(TRUAPI_PKG) && npm run build +install: headless ## Install the truapi-host CLI into Cargo's bin dir; use as `make headless install`. + cargo install --path rust/crates/truapi-host-cli --bin truapi-host --locked --force + codegen: ## Regenerate generated TS/Rust artifacts from the Rust crates. ./scripts/codegen.sh cd $(PLAYGROUND) && rm -rf node_modules/@parity && yarn install diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index 675cc8d4..be1f8782 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -24,7 +24,7 @@ One binary, `truapi-host`: ## Quick start ```bash -make headless # build the CLI + JS client (once) +make headless install # build deps + install truapi-host (once) rust/crates/truapi-host-cli/e2e/run.sh # run js/scripts/battery.ts end-to-end rust/crates/truapi-host-cli/e2e/run.sh path/to/my-script.ts # or a custom script ``` @@ -122,20 +122,19 @@ membership and can submit a test registration. ## Manual use (two terminals) ```bash -make headless -BIN=target/debug/truapi-host +make headless install # Terminal 1 — pairing host runs a product script and prints PAIRING_DEEPLINK: -$BIN pairing-host --product-id myapp.dot --script js/scripts/battery.ts --auto-accept +truapi-host pairing-host --product-id myapp.dot --script js/scripts/battery.ts --auto-accept # Terminal 2 — hand the deeplink to a signing host (registers allowance, signs). # The wallet mnemonic comes from --mnemonic / $HOST_CLI_SIGNER_MNEMONIC when set; # otherwise the CLI auto-selects or creates an attested account. -$BIN signing-host --deeplink '' --auto-accept -HOST_CLI_SIGNER_MNEMONIC="spin battle …" $BIN signing-host --deeplink '' --auto-accept +truapi-host signing-host --deeplink '' --auto-accept +HOST_CLI_SIGNER_MNEMONIC="spin battle …" truapi-host signing-host --deeplink '' --auto-accept # Inspect on-chain statement-store allowance for a mnemonic: -$BIN alloc-check --mnemonic "spin battle …" --lookback 100 +truapi-host alloc-check --mnemonic "spin battle …" --lookback 100 ``` Both hosts take `--network` (default `paseo-next-v2`). The network preset owns From 4e0df24acc1c5fef414a3a56e0e255d79ff80222 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Sun, 12 Jul 2026 10:17:58 +0200 Subject: [PATCH 10/13] fix: tighten headless allowance flows --- Cargo.lock | 2 + Makefile | 2 +- rust/crates/truapi-host-cli/Cargo.toml | 1 + rust/crates/truapi-host-cli/e2e/run.sh | 7 +- rust/crates/truapi-host-cli/src/alloc/rpc.rs | 162 ++++++------------ rust/crates/truapi-host-cli/src/main.rs | 4 + .../truapi-server/src/runtime/bulletin_rpc.rs | 5 +- .../src/runtime/signing_host/sso_responder.rs | 6 +- .../src/runtime/statement_allowance.rs | 69 +++++++- 9 files changed, 135 insertions(+), 123 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 500204e2..a38a473f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3969,6 +3969,7 @@ dependencies = [ "serde_json", "subxt-lightclient", "thiserror 2.0.18", + "tokio-util", "tracing", "url", "wasm-bindgen-futures", @@ -4394,6 +4395,7 @@ dependencies = [ "serde", "serde_json", "sp-crypto-hashing", + "subxt-rpcs", "tokio", "tokio-stream", "tokio-tungstenite", diff --git a/Makefile b/Makefile index 6a9835ab..88bd4139 100644 --- a/Makefile +++ b/Makefile @@ -145,7 +145,7 @@ e2e-dotli: ## Fully automated dotli + playground diagnosis e2e. Requires SIGNER_ if [ -n "$$SIGNER_BOT_SVC_TOKEN_ENV" ]; then SIGNER_BOT_SVC_TOKEN="$$SIGNER_BOT_SVC_TOKEN_ENV"; export SIGNER_BOT_SVC_TOKEN; fi; \ if [ "$$SIGNER_BOT_BASE_URL_ORIGIN" != "file" ] && [ -n "$$SIGNER_BOT_BASE_URL_ENV" ]; then SIGNER_BOT_BASE_URL="$$SIGNER_BOT_BASE_URL_ENV"; export SIGNER_BOT_BASE_URL; fi; \ if [ "$$SIGNER_BOT_NETWORK_ORIGIN" != "file" ] && [ -n "$$SIGNER_BOT_NETWORK_ENV" ]; then SIGNER_BOT_NETWORK="$$SIGNER_BOT_NETWORK_ENV"; export SIGNER_BOT_NETWORK; fi; \ - if [ "$$E2E_DOTLI_SMOKE" != "1" ]; then test -n "$$SIGNER_BOT_SVC_TOKEN" || (echo "Missing SIGNER_BOT_SVC_TOKEN. e2e-dotli requires signer-bot; without it a human phone scan is required."; exit 1); fi; \ + if [ "$$E2E_DOTLI_SMOKE" != "1" ]; then test -n "$$SIGNER_BOT_SVC_TOKEN" || { echo "Missing SIGNER_BOT_SVC_TOKEN. e2e-dotli requires signer-bot; without it a human phone scan is required."; exit 1; }; fi; \ $(MAKE) dev-bootstrap; \ cd $(PLAYGROUND) && bun tests/e2e/dotli-diagnosis.ts diff --git a/rust/crates/truapi-host-cli/Cargo.toml b/rust/crates/truapi-host-cli/Cargo.toml index 9f66bf5b..511b45bb 100644 --- a/rust/crates/truapi-host-cli/Cargo.toml +++ b/rust/crates/truapi-host-cli/Cargo.toml @@ -32,6 +32,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus rustls = { version = "0.23", default-features = false, features = ["ring"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +subxt-rpcs = { version = "0.50.1", default-features = false, features = ["jsonrpsee", "native"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "io-std", "net", "time", "signal", "process"] } tokio-stream = { version = "0.1", features = ["sync"] } tokio-tungstenite = { version = "0.24", features = ["connect", "rustls-tls-webpki-roots"] } diff --git a/rust/crates/truapi-host-cli/e2e/run.sh b/rust/crates/truapi-host-cli/e2e/run.sh index 6fa1d4b8..daa82fe6 100755 --- a/rust/crates/truapi-host-cli/e2e/run.sh +++ b/rust/crates/truapi-host-cli/e2e/run.sh @@ -15,15 +15,16 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)" BIN="$ROOT/target/debug/truapi-host" -SCRIPT="${1:-$ROOT/rust/crates/truapi-host-cli/js/scripts/battery.ts}" -PRODUCT_ID="${PRODUCT_ID:-headless-playground.dot}" -FRAME="${FRAME:-127.0.0.1:9955}" # Load HOST_CLI_SIGNER_MNEMONIC / TRUAPI_HOST_BASE_PATH (and any other vars) # from a gitignored e2e/.env if present. ENV_FILE="$(dirname "$0")/.env" [ -f "$ENV_FILE" ] && { set -a; . "$ENV_FILE"; set +a; } +SCRIPT="${1:-$ROOT/rust/crates/truapi-host-cli/js/scripts/battery.ts}" +PRODUCT_ID="${PRODUCT_ID:-headless-playground.dot}" +FRAME="${FRAME:-127.0.0.1:9955}" + [ -x "$BIN" ] || { echo "missing $BIN — run: make headless" >&2; exit 2; } LOG="$(mktemp)" diff --git a/rust/crates/truapi-host-cli/src/alloc/rpc.rs b/rust/crates/truapi-host-cli/src/alloc/rpc.rs index bd38db36..e6660c4c 100644 --- a/rust/crates/truapi-host-cli/src/alloc/rpc.rs +++ b/rust/crates/truapi-host-cli/src/alloc/rpc.rs @@ -1,149 +1,96 @@ -//! Minimal JSON-RPC-over-WebSocket client for the People chain. +//! JSON-RPC helpers for statement-store allowance registration. //! -//! Sequential request/response (one in flight at a time) plus a -//! submit-and-watch helper for `author_submitAndWatchExtrinsic`. Enough to read -//! metadata / storage / runtime version and submit the allowance extrinsic; -//! statement-store traffic keeps using the runtime's own transport. +//! Keep the CLI diagnostic path on the same `subxt-rpcs` transport surface as +//! the runtime code instead of hand-rolling request ids, subscriptions, and +//! websocket framing here. -use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; use anyhow::{Context, Result, anyhow, bail}; -use futures_util::{SinkExt, StreamExt}; -use serde_json::{Value, json}; -use tokio::net::TcpStream; -use tokio::sync::Mutex; +use serde_json::Value; +use subxt_rpcs::client::{RpcClient as SubxtRpcClient, RpcParams, rpc_params}; use tokio::time::timeout; -use tokio_tungstenite::tungstenite::Message; -use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async}; -type Ws = WebSocketStream>; - -/// Timeout for a single call's response. -const CALL_TIMEOUT: Duration = Duration::from_secs(30); -/// Timeout for an extrinsic to reach `inBlock`. +/// Timeout for an allowance registration extrinsic to finalize. const SUBMIT_TIMEOUT: Duration = Duration::from_secs(120); -/// A single WebSocket JSON-RPC connection. +/// A People-chain JSON-RPC connection. pub struct RpcClient { - ws: Mutex, - next_id: AtomicU64, + inner: SubxtRpcClient, } impl RpcClient { - /// Open a WebSocket JSON-RPC connection to `url`. + /// Open a JSON-RPC connection to `url`. pub async fn connect(url: &str) -> Result { - let (ws, _) = connect_async(url) + let inner = SubxtRpcClient::from_insecure_url(url) .await .with_context(|| format!("connect {url}"))?; - Ok(Self { - ws: Mutex::new(ws), - next_id: AtomicU64::new(1), - }) + Ok(Self { inner }) } - /// Call `method` with `params`, returning the `result` value. + /// Call `method` with `params`, returning the result value. pub async fn call(&self, method: &str, params: Value) -> Result { - let id = self.next_id.fetch_add(1, Ordering::Relaxed); - let request = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); - let mut ws = self.ws.lock().await; - ws.send(Message::Text(request.to_string())) + self.inner + .request(method, value_to_params(params)?) .await - .with_context(|| format!("send {method}"))?; - loop { - let value = next_json(&mut ws, CALL_TIMEOUT, method).await?; - if value.get("id").and_then(Value::as_u64) == Some(id) { - if let Some(err) = value.get("error") { - bail!("rpc error for {method}: {err}"); - } - return Ok(value.get("result").cloned().unwrap_or(Value::Null)); - } - } + .with_context(|| format!("rpc {method}")) } /// `state_getStorage(key)` -> raw value bytes, or `None` if absent. pub async fn get_storage(&self, key: &[u8]) -> Result>> { let key_hex = format!("0x{}", hex::encode(key)); - match self.call("state_getStorage", json!([key_hex])).await? { + match self + .inner + .request::("state_getStorage", rpc_params![key_hex]) + .await + .context("rpc state_getStorage")? + { Value::String(hex_value) => Ok(Some(decode_hex(&hex_value)?)), _ => Ok(None), } } - /// Submit an extrinsic and wait for `inBlock`/`finalized`; returns the block - /// hash. Rejects on `invalid` / `dropped` / `usurped` / `finalityTimeout`. + /// Submit an extrinsic and wait for `finalized`; returns the block hash. pub async fn submit_and_watch(&self, extrinsic: &[u8]) -> Result { let extrinsic_hex = format!("0x{}", hex::encode(extrinsic)); - let id = self.next_id.fetch_add(1, Ordering::Relaxed); - let request = json!({ - "jsonrpc": "2.0", - "id": id, - "method": "author_submitAndWatchExtrinsic", - "params": [extrinsic_hex], - }); - let mut ws = self.ws.lock().await; - ws.send(Message::Text(request.to_string())) + let mut subscription = self + .inner + .subscribe::( + "author_submitAndWatchExtrinsic", + rpc_params![extrinsic_hex], + "author_unwatchExtrinsic", + ) .await - .context("send author_submitAndWatchExtrinsic")?; - - // First the subscription id, then a stream of status notifications. + .context("rpc author_submitAndWatchExtrinsic")?; let started = Instant::now(); - let mut subscription_id: Option = None; + loop { let remaining = SUBMIT_TIMEOUT .checked_sub(started.elapsed()) .ok_or_else(|| anyhow!("timed out waiting for author_submitAndWatchExtrinsic"))?; - let value = next_json(&mut ws, remaining, "author_submitAndWatchExtrinsic").await?; - if subscription_id.is_none() { - if value.get("id").and_then(Value::as_u64) == Some(id) { - if let Some(err) = value.get("error") { - bail!("submit rejected: {err}"); - } - subscription_id = value - .get("result") - .and_then(Value::as_str) - .map(str::to_string); - if subscription_id.is_none() { - bail!("submit response missing subscription id: {value}"); - } - } - continue; - } - let params = value.get("params"); - let matches = params - .and_then(|p| p.get("subscription")) - .and_then(Value::as_str) - == subscription_id.as_deref(); - if !matches { - continue; - } - if let Some(status) = params.and_then(|p| p.get("result")) { - match extrinsic_status(status) { - ExtrinsicStatus::InBlock(hash) => return Ok(hash), - ExtrinsicStatus::Rejected(reason) => bail!("extrinsic {reason}"), - ExtrinsicStatus::Pending => {} - } + let status = timeout(remaining, subscription.next()) + .await + .map_err(|_| anyhow!("timed out waiting for author_submitAndWatchExtrinsic"))? + .ok_or_else(|| anyhow!("author_submitAndWatchExtrinsic subscription ended"))? + .context("author_submitAndWatchExtrinsic update")?; + match extrinsic_status(&status) { + ExtrinsicStatus::Finalized(hash) => return Ok(hash), + ExtrinsicStatus::Rejected(reason) => bail!("extrinsic {reason}"), + ExtrinsicStatus::Pending => {} } } } } -/// Terminal or pending state of a submitted extrinsic. enum ExtrinsicStatus { - InBlock(String), + Finalized(String), Rejected(String), Pending, } -/// Classify an `author_extrinsicUpdate` status value. -/// -/// Only `finalized` is terminal success: a freshly-set statement-store allowance -/// is not honored by the store until its `set_statement_store_account` extrinsic -/// is finalized, so returning at `inBlock` would race the handshake submit -/// (which then fails with `NoAllowance`). fn extrinsic_status(status: &Value) -> ExtrinsicStatus { if let Some(hash) = status.get("finalized").and_then(Value::as_str) { - return ExtrinsicStatus::InBlock(hash.to_string()); + return ExtrinsicStatus::Finalized(hash.to_string()); } for key in ["invalid", "dropped", "usurped", "finalityTimeout"] { if status.get(key).is_some() { @@ -153,24 +100,17 @@ fn extrinsic_status(status: &Value) -> ExtrinsicStatus { ExtrinsicStatus::Pending } -/// Read the next JSON frame, skipping non-text frames, within `deadline`. -async fn next_json(ws: &mut Ws, deadline: Duration, context: &str) -> Result { - loop { - let message = timeout(deadline, ws.next()) - .await - .map_err(|_| anyhow!("timed out waiting for {context}"))? - .ok_or_else(|| anyhow!("websocket closed waiting for {context}"))??; - let text = match message { - Message::Text(text) => text.to_string(), - Message::Binary(bytes) => String::from_utf8_lossy(&bytes).into_owned(), - Message::Close(_) => bail!("websocket closed waiting for {context}"), - _ => continue, - }; - return serde_json::from_str(&text).with_context(|| format!("parse {context} response")); +fn value_to_params(value: Value) -> Result { + let Value::Array(values) = value else { + bail!("RPC params must be a JSON array"); + }; + let mut params = RpcParams::new(); + for value in values { + params.push(value).context("serialize RPC params")?; } + Ok(params) } -/// Decode a `0x`-prefixed hex string to bytes. fn decode_hex(value: &str) -> Result> { hex::decode(value.strip_prefix("0x").unwrap_or(value)).context("decode hex storage value") } diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 693a73a2..bcc8b465 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -205,6 +205,10 @@ async fn run_alloc_check( .to_entropy(); let bandersnatch = alloc::bandersnatch_entropy(&entropy); + if submit && target.is_none() { + bail!("--target is required with --submit; the all-zero default is read-only"); + } + let target = match target { Some(hex_str) => { let bytes = hex::decode(hex_str.strip_prefix("0x").unwrap_or(&hex_str)) diff --git a/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs index c1c8db78..3fb67331 100644 --- a/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs +++ b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs @@ -27,7 +27,7 @@ use tracing::{instrument, warn}; use truapi::CallContext; use truapi_platform::BulletinAllowanceKey; -use crate::chain_runtime::ChainRuntime; +use crate::chain_runtime::{ChainRuntime, RuntimeFailure}; use crate::host_logic::bulletin::{ STORE_PALLET_NAME, allowance_signer, build_signed_store_transaction, preimage_key, }; @@ -158,12 +158,11 @@ impl BulletinRpc { } /// Open a raw RPC client over the configured Bulletin chain. - pub(crate) async fn client(&self, label: &'static str) -> Result { + pub(crate) async fn client(&self, label: &'static str) -> Result { self.chain .rpc_client(label, &self.genesis_hash) .await .map(RpcClient::new) - .map_err(|failure| failure.reason()) } /// Submit `value` as a Bulletin preimage signed by `allowance`, returning 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 00c7e437..3cf7fd70 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 @@ -422,7 +422,11 @@ pub(super) async fn allocate_bulletin_allowance( let target = allowance.public.to_bytes(); let bulletin_rpc = statement_allowance::rpc::RpcClient::new( - services.bulletin.client("bulletin allowance").await?, + services + .bulletin + .client("bulletin allowance") + .await + .map_err(|err| err.reason())?, ); let current_allowance = fetch_bulletin_allowance(&bulletin_rpc, &target).await?; if matches!(policy, OnExistingAllowancePolicy::Ignore) diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index 87dd5b22..854b2846 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -310,8 +310,7 @@ pub async fn fetch_bulletin_allowance( decode_bulletin_allowance(&bytes, fetched_at).map(Some) } -/// Wait until Bulletin authorization appears with more remaining transactions -/// than `current`. +/// Wait until Bulletin authorization is available and fresher than `current`. pub async fn wait_bulletin_authorization( rpc: &RpcClient, target: &[u8; 32], @@ -319,10 +318,10 @@ pub async fn wait_bulletin_authorization( timeout: Duration, ) -> Result { let started = Instant::now(); - let current_transactions = current.map(|info| info.remained_transactions).unwrap_or(0); + let baseline = current.filter(|info| info.available()); loop { if let Some(info) = fetch_bulletin_allowance(rpc, target).await? { - if info.remained_transactions > current_transactions && info.available() { + if authorization_refreshed(info, baseline) { return Ok(info); } } @@ -335,6 +334,23 @@ pub async fn wait_bulletin_authorization( } } +fn authorization_refreshed( + info: BulletinAllowanceInfo, + baseline: Option, +) -> bool { + if !info.available() { + return false; + } + match baseline { + None => true, + Some(current) => { + info.remained_transactions > current.remained_transactions + || info.remained_size > current.remained_size + || info.expires_in > current.expires_in + } + } +} + /// `TransactionStorage.Authorizations[AuthorizationScope::Account(target)]`. fn bulletin_authorization_key(target: &[u8; 32]) -> Vec { let mut scope = Vec::with_capacity(1 + 32); @@ -392,3 +408,48 @@ fn duplicate_submit_error(message: &str) -> bool { || message.contains("already imported") || message.contains("temporarily banned") } + +#[cfg(test)] +mod tests { + use super::*; + + fn allowance( + remained_size: u64, + remained_transactions: u32, + expires_in: u32, + ) -> BulletinAllowanceInfo { + BulletinAllowanceInfo { + remained_size, + remained_transactions, + expires_in, + fetched_at: 10, + } + } + + #[test] + fn bulletin_refresh_accepts_available_state_when_baseline_was_unusable() { + let exhausted_by_size = allowance(0, 4, 100); + let refreshed_same_transactions = allowance(4096, 4, 100); + + assert!(!exhausted_by_size.available()); + assert!(authorization_refreshed( + refreshed_same_transactions, + Some(exhausted_by_size).filter(|info| info.available()), + )); + } + + #[test] + fn bulletin_refresh_accepts_size_only_increase() { + let baseline = allowance(128, 4, 100); + let refreshed = allowance(4096, 4, 100); + + assert!(authorization_refreshed(refreshed, Some(baseline))); + } + + #[test] + fn bulletin_refresh_rejects_unchanged_available_state() { + let baseline = allowance(128, 4, 100); + + assert!(!authorization_refreshed(baseline, Some(baseline))); + } +} From be2b29c8f8d50dd3af090543687ef038715f85b7 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Sun, 12 Jul 2026 10:25:58 +0200 Subject: [PATCH 11/13] fix: use release wasm for dotli e2e --- Makefile | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 88bd4139..5a7acccc 100644 --- a/Makefile +++ b/Makefile @@ -31,6 +31,8 @@ export VITE_NETWORKS # preview by default. Override with `DOTLI_PREVIEW=preview` to test production # preview behavior. DOTLI_PREVIEW ?= preview:debug +TRUAPI_WASM_PROFILE ?= dev +E2E_DOTLI_WASM_PROFILE ?= release help: ## Show this help. @awk 'BEGIN { FS = ":.*##"; printf "Usage: make \n\nTargets:\n" } \ @@ -112,7 +114,7 @@ dev-bootstrap: ## Prepare ignored generated/build artifacts needed by dotli prev if [ ! -f "$(HOST_CALLBACKS_GENERATED)" ] || [ ! -f "$(HOST_WASM_ADAPTER_GENERATED)" ] || [ ! -f "$(HOST_WASM_WORKER_CALLBACKS_GENERATED)" ]; then ./scripts/codegen.sh; fi cd $(TRUAPI_PKG) && npm run build cd $(HOST_WASM_PKG) && npm run build - TRUAPI_WASM_PROFILE=dev $(MAKE) wasm + TRUAPI_WASM_PROFILE=$(TRUAPI_WASM_PROFILE) $(MAKE) wasm cd $(PLAYGROUND) && yarn install --frozen-lockfile cd $(DOTLI) && bun install --frozen-lockfile $(MAKE) dev-link-check @@ -146,7 +148,7 @@ e2e-dotli: ## Fully automated dotli + playground diagnosis e2e. Requires SIGNER_ if [ "$$SIGNER_BOT_BASE_URL_ORIGIN" != "file" ] && [ -n "$$SIGNER_BOT_BASE_URL_ENV" ]; then SIGNER_BOT_BASE_URL="$$SIGNER_BOT_BASE_URL_ENV"; export SIGNER_BOT_BASE_URL; fi; \ if [ "$$SIGNER_BOT_NETWORK_ORIGIN" != "file" ] && [ -n "$$SIGNER_BOT_NETWORK_ENV" ]; then SIGNER_BOT_NETWORK="$$SIGNER_BOT_NETWORK_ENV"; export SIGNER_BOT_NETWORK; fi; \ if [ "$$E2E_DOTLI_SMOKE" != "1" ]; then test -n "$$SIGNER_BOT_SVC_TOKEN" || { echo "Missing SIGNER_BOT_SVC_TOKEN. e2e-dotli requires signer-bot; without it a human phone scan is required."; exit 1; }; fi; \ - $(MAKE) dev-bootstrap; \ + TRUAPI_WASM_PROFILE=$(E2E_DOTLI_WASM_PROFILE) $(MAKE) dev-bootstrap; \ cd $(PLAYGROUND) && bun tests/e2e/dotli-diagnosis.ts matrix: ## Regenerate the host compatibility matrix from explorer/diagnosis-reports. From 496ec182e0e725587b284826715d51fc51391b42 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Sun, 12 Jul 2026 10:30:55 +0200 Subject: [PATCH 12/13] fix: use subxt rpc for attestation poll --- .../crates/truapi-host-cli/src/attestation.rs | 35 ++++++------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/rust/crates/truapi-host-cli/src/attestation.rs b/rust/crates/truapi-host-cli/src/attestation.rs index a1eb6179..c8dbdd8d 100644 --- a/rust/crates/truapi-host-cli/src/attestation.rs +++ b/rust/crates/truapi-host-cli/src/attestation.rs @@ -9,10 +9,8 @@ use std::time::Duration; use anyhow::{Context, Result, bail}; -use futures_util::{SinkExt, StreamExt}; use serde_json::{Value, json}; -use tokio_tungstenite::connect_async; -use tokio_tungstenite::tungstenite::Message; +use subxt_rpcs::client::{RpcClient, rpc_params}; use tracing::{info, warn}; use truapi_server::host_logic::attestation::build_lite_registration; use truapi_server::host_logic::identity::{ @@ -215,28 +213,15 @@ async fn wait_for_consumer_record(people_ws: &str, storage_key: &str) -> Result< bail!("Resources.Consumers record did not appear after attestation") } -/// One `state_getStorage` request over a fresh WebSocket; returns the value +/// One `state_getStorage` request over a fresh RPC connection; returns the value /// hex when present. async fn query_storage(people_ws: &str, storage_key: &str) -> Result> { - let (mut ws, _) = connect_async(people_ws).await?; - let request = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "state_getStorage", - "params": [storage_key], - }); - ws.send(Message::Text(request.to_string())).await?; - while let Some(message) = ws.next().await { - if let Message::Text(text) = message? { - let value: Value = serde_json::from_str(&text)?; - if value.get("id").and_then(Value::as_u64) == Some(1) { - let _ = ws.close(None).await; - return Ok(value - .get("result") - .and_then(Value::as_str) - .map(str::to_string)); - } - } - } - Ok(None) + let rpc = RpcClient::from_insecure_url(people_ws) + .await + .with_context(|| format!("connect {people_ws}"))?; + let value = rpc + .request::("state_getStorage", rpc_params![storage_key]) + .await + .context("rpc state_getStorage")?; + Ok(value.as_str().map(str::to_string)) } From 44dd085426a6fb40549912c2d1f312452809a09a Mon Sep 17 00:00:00 2001 From: pgherveou Date: Sun, 12 Jul 2026 10:59:46 +0200 Subject: [PATCH 13/13] fix: address headless host CI checks --- deny.toml | 7 +++++ rust/crates/truapi-host-cli/src/network.rs | 9 ++---- .../crates/truapi-server/src/chain_runtime.rs | 1 + .../truapi-server/src/runtime/bulletin_rpc.rs | 6 +++- .../truapi-server/src/runtime/signing_host.rs | 6 ++++ .../src/runtime/signing_host/sso_responder.rs | 5 +++- .../src/runtime/statement_allowance.rs | 30 ++++++++++++------- rust/crates/truapi/src/api/chain.rs | 1 + 8 files changed, 46 insertions(+), 19 deletions(-) diff --git a/deny.toml b/deny.toml index 9305a03f..500c13c3 100644 --- a/deny.toml +++ b/deny.toml @@ -9,6 +9,8 @@ allow = [ "BSD-2-Clause", "BSD-3-Clause", "CC0-1.0", + "CDLA-Permissive-2.0", + "ISC", "Unicode-3.0", "Unlicense", "Zlib", @@ -18,6 +20,10 @@ confidence-threshold = 0.8 # uniffi is MPL-2.0: file-level weak copyleft, consumed unmodified as a # dependency, which MPL-2.0 permits without affecting the MIT outbound licence. # Scoped per crate so MPL-2.0 stays disallowed everywhere else. +# +# verifiable is GPL-3.0-or-later with the Classpath exception, consumed as an +# unmodified library dependency for ring-VRF proof construction. Keep the +# exception scoped to that crate. exceptions = [ { name = "uniffi", allow = ["MPL-2.0"] }, { name = "uniffi_bindgen", allow = ["MPL-2.0"] }, @@ -27,4 +33,5 @@ exceptions = [ { name = "uniffi_meta", allow = ["MPL-2.0"] }, { name = "uniffi_pipeline", allow = ["MPL-2.0"] }, { name = "uniffi_udl", allow = ["MPL-2.0"] }, + { name = "verifiable", allow = ["GPL-3.0-or-later WITH Classpath-exception-2.0"] }, ] diff --git a/rust/crates/truapi-host-cli/src/network.rs b/rust/crates/truapi-host-cli/src/network.rs index a6e90e10..c8519b6b 100644 --- a/rust/crates/truapi-host-cli/src/network.rs +++ b/rust/crates/truapi-host-cli/src/network.rs @@ -1,9 +1,10 @@ use clap::ValueEnum; /// Supported live network presets for the headless hosts. -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)] pub enum Network { #[value(name = "paseo-next-v2")] + #[default] PaseoNextV2, } @@ -48,12 +49,6 @@ const PASEO_NEXT_V2_CHAIN_ENDPOINTS: &[ChainEndpoint] = &[ }, ]; -impl Default for Network { - fn default() -> Self { - Self::PaseoNextV2 - } -} - /// Resolved RPC/backend/genesis values for one network preset. #[derive(Debug, Clone, Copy)] pub struct NetworkConfig { diff --git a/rust/crates/truapi-server/src/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs index 9d858648..c4e2f494 100644 --- a/rust/crates/truapi-server/src/chain_runtime.rs +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -527,6 +527,7 @@ impl ChainRuntime { } /// Raw JSON-RPC client for the chain identified by `genesis_hash`. + #[cfg(not(target_arch = "wasm32"))] pub(crate) async fn rpc_client( &self, method: &'static str, diff --git a/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs index 3fb67331..fed6d0e6 100644 --- a/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs +++ b/rust/crates/truapi-server/src/runtime/bulletin_rpc.rs @@ -22,12 +22,15 @@ use subxt::tx::{ SubmittableTransaction, TransactionInBlock, TransactionInvalid, TransactionStatus, TransactionUnknown, ValidationResult, }; +#[cfg(not(target_arch = "wasm32"))] use subxt_rpcs::RpcClient; use tracing::{instrument, warn}; use truapi::CallContext; use truapi_platform::BulletinAllowanceKey; -use crate::chain_runtime::{ChainRuntime, RuntimeFailure}; +use crate::chain_runtime::ChainRuntime; +#[cfg(not(target_arch = "wasm32"))] +use crate::chain_runtime::RuntimeFailure; use crate::host_logic::bulletin::{ STORE_PALLET_NAME, allowance_signer, build_signed_store_transaction, preimage_key, }; @@ -158,6 +161,7 @@ impl BulletinRpc { } /// Open a raw RPC client over the configured Bulletin chain. + #[cfg(not(target_arch = "wasm32"))] pub(crate) async fn client(&self, label: &'static str) -> Result { self.chain .rpc_client(label, &self.genesis_hash) diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 115c75fa..e0924467 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -49,6 +49,7 @@ const BYTES_WRAP_SUFFIX: &[u8] = b""; /// Wallet-local account authority for a signing host. pub(crate) struct SigningHost { + #[cfg(not(target_arch = "wasm32"))] services: Arc, session_state: Arc, auth_state: AuthStateMachine, @@ -58,7 +59,11 @@ pub(crate) struct SigningHost { impl SigningHost { pub(crate) fn new(platform: Arc, services: Arc) -> Arc { + #[cfg(target_arch = "wasm32")] + let _ = services; + Arc::new(Self { + #[cfg(not(target_arch = "wasm32"))] services, session_state: SessionState::new(), auth_state: AuthStateMachine::new(platform), @@ -419,6 +424,7 @@ fn product_authority_error(err: ProductAccountError) -> AuthorityError { } } +#[cfg(not(target_arch = "wasm32"))] fn allocation_error(reason: String) -> AuthorityError { AuthorityError::Unavailable { reason } } 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 3cf7fd70..8a70b6db 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 @@ -24,7 +24,9 @@ use truapi_platform::{ use super::SigningHost; use crate::host_logic::entropy::root_entropy_source; -use crate::host_logic::product_account::{ProductAccountError, derive_sr25519_hard_path}; +#[cfg(not(target_arch = "wasm32"))] +use crate::host_logic::product_account::ProductAccountError; +use crate::host_logic::product_account::derive_sr25519_hard_path; use crate::host_logic::session::SsoSessionInfo; use crate::host_logic::sso::messages::{ self, CreateTransactionPayload, IncomingSsoRequest, OnExistingAllowancePolicy, RemoteMessage, @@ -522,6 +524,7 @@ fn current_unix_secs() -> Result { .map_err(|_| "system clock before UNIX epoch".to_string()) } +#[cfg(not(target_arch = "wasm32"))] fn product_account_error(err: ProductAccountError) -> String { err.to_string() } diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index 854b2846..cb9a1e95 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -320,20 +320,30 @@ pub async fn wait_bulletin_authorization( let started = Instant::now(); let baseline = current.filter(|info| info.available()); loop { - if let Some(info) = fetch_bulletin_allowance(rpc, target).await? { - if authorization_refreshed(info, baseline) { - return Ok(info); - } - } - let Some(remaining) = timeout.checked_sub(started.elapsed()) else { - return Err("timed out waiting for Bulletin authorization".to_string()); + let Some(info) = fetch_bulletin_allowance(rpc, target).await? else { + wait_before_next_bulletin_authorization_poll(started, timeout).await?; + continue; }; - let delay = futures_timer::Delay::new(remaining.min(Duration::from_secs(2))).fuse(); - futures::pin_mut!(delay); - delay.await; + if authorization_refreshed(info, baseline) { + return Ok(info); + } + wait_before_next_bulletin_authorization_poll(started, timeout).await?; } } +async fn wait_before_next_bulletin_authorization_poll( + started: Instant, + timeout: Duration, +) -> Result<(), String> { + let Some(remaining) = timeout.checked_sub(started.elapsed()) else { + return Err("timed out waiting for Bulletin authorization".to_string()); + }; + let delay = futures_timer::Delay::new(remaining.min(Duration::from_secs(2))).fuse(); + futures::pin_mut!(delay); + delay.await; + Ok(()) +} + fn authorization_refreshed( info: BulletinAllowanceInfo, baseline: Option, diff --git a/rust/crates/truapi/src/api/chain.rs b/rust/crates/truapi/src/api/chain.rs index 371e6f1d..d2d73fa2 100644 --- a/rust/crates/truapi/src/api/chain.rs +++ b/rust/crates/truapi/src/api/chain.rs @@ -368,6 +368,7 @@ pub trait Chain: Send + Sync { /// transaction: "0x", /// }); /// assert(broadcast.isOk(), "broadcastTransaction failed:", broadcast); + /// assert(broadcast.value.operationId, "broadcastTransaction returned no operation id"); /// /// const result = await truapi.chain.stopTransaction({ /// genesisHash: PASEO_NEXT_V2_ASSET_HUB.genesis,